Releases and security

2.1.15 - 2026-06-13

Feature — runique (routing, templates)

  • Named URLs for built-in routes (forgot_password, reset_password, admin): framework-registered routes (password reset and admin panel) were mounted directly via Axum Router::route() without being registered in the URL name registry. They could not be referenced via {% link %} in templates. Fixed: build.rs now calls register_name_url after mounting password-reset routes ("forgot_password" → configured forgot_route, "reset_password" → configured reset_route with {token}/{encrypted_email} placeholders) and after mounting the admin panel ("admin" → the configured prefix). The registration picks up any custom routes set by the developer via PasswordResetConfig::forgot_route() / .reset_route() / AdminConfig::prefix().

Security — runique (auth)

  • User enumeration via timing attack on login (medium): authenticate_user and DefaultAdminAuth::authenticate returned None immediately via ? when the username was not found in the database, skipping the password hash verification entirely. An attacker measuring response times could distinguish "user does not exist" (fast — no hash work) from "wrong password" (slow — full Argon2 verification), allowing silent username enumeration. Fixed: both functions now always call verify() before short-circuiting. When the user is not found, the password is verified against a pre-computed dummy Argon2 hash (DUMMY_HASH, initialised once at first use via LazyLock) — burning the same CPU time regardless of whether the account exists. The ? on the user lookup is deferred to after verify() returns, so the result is always discarded once timing-sensitive work is done.

Security — runique (admin)

  • Missing authorization on the admin reset-password action (medium): in admin_post_id (admin/admin_main/mod.rs), the edit and delete actions were gated by a permission check (can_update / can_delete, with _own + ownership fallback), but the reset-password action had no authorization gate at all — and, unlike admin_get_id, this handler does not check can_access either. Any user authenticated to the admin panel (is_staff or is_superuser) with zero permissions on the resource could POST {prefix}/{resource}/{id}/reset-password to trigger a password-reset for any record, including superuser accounts. CSRF and authentication were enforced, but not authorization. Aggravating factor: when no mailer is configured, the reset link (token + encrypted email) is returned to the calling admin in a flash message — a low-privilege staff user could obtain a working reset link for a higher-privileged account. Fixed: reset-password now requires can_update (global, or can_update_own on an owned record), aligned with edit.

Security — runique (admin generator — SQL hardening)

  • Manual SQL escaping in foreign-key label resolution: the admin generator (admin/daemon/generator.rs) built the FK label lookup queries (list_fn and get_fn) by string-concatenating an IN (...) clause with Expr::cust(format!("CAST(id AS TEXT) IN ({})", ids_csv)), where ids_csv was assembled via format!("'{}'", s.replace('\'', "''")). Escaping only single quotes is insufficient on MySQL/MariaDB, where the backslash is an escape character by default — a value containing a backslash could break out of the literal. In practice the values are DB-stored FK ids (integers), so the exploitability was negligible, but it was manual escaping where the rest of the generator already uses whitelisted identifiers and bound values. Fixed: both queries now use bound values via the typed sea-query API — Expr::cust("CAST(id AS TEXT)").is_in(fk_ids.clone()) in list_fn and .eq(fk_key.clone()) in get_fn. No data is interpolated into the SQL string anymore; the placeholder is backend-correct on PostgreSQL/MySQL/SQLite.

Fix — runique (CLI makemigrations — refactor for solid, reliable use)

  • Column [default: …] values were parsed but never emitted: ParsedColumn only carried a has_default_now flag (for CURRENT_TIMESTAMP); the literal value of [default: 0] / [default: true] / [default: "x"] was consumed by the parser and discarded, so no .default(...) ever reached the generated SQL — only auto timestamps got a default. Worst case: a bool [default: true] rendered as NOT NULL without a default, an ADD COLUMN that fails on a populated table. Fixed: ParsedColumn gains a default_value: Option<String>; parser_builder and parser_extend now capture the literal, render_column_def emits .default(<value>) (covering CREATE, snapshot and ALTER). The snapshot round-trip is preserved — parser_seaorm only treats .default(Expr::current_timestamp()) as a timestamp default and reads literal defaults back into default_value.

  • bool was not-null by default (inconsistent with other scalars): bool/boolean were missing from the v2 semantic-type list, so a boolean field without required was generated NOT NULL (v1 behaviour) while int, text, etc. were nullable. Combined with the dropped default above, a bool [default: true] produced an unrunnable ADD COLUMN NOT NULL. Fixed: bool/boolean added to the v2 type set — nullable unless required, consistent with the other scalar types.

  • CASCADE foreign keys on brand-new tables flagged as destructive: the destructive guard reported ADD FOREIGN KEY … ON DELETE CASCADE (existing rows may be deleted) for every CASCADE FK, including tables created in the same batch — which have no rows to lose. A first-time / from-scratch generation of any schema with CASCADE FKs wrongly required --force. Fixed: the CASCADE check now skips new tables (is_new_table), since only an existing populated table carries the risk.

  • Pure pipeline test suite added: migration/utils/tests_pipeline.rs — 11 dependency-free tests (no DB/Docker) covering default parsing & emission, bool nullability, the snapshot round-trip stability invariant ("generate twice = no changes"), the destructive guard (CASCADE new vs existing, DROP COLUMN), enum value additions, and Postgres-only CREATE TYPE/DROP TYPE gating.

  • Rollback deleted pre-existing snapshots instead of restoring them: on a mid-batch write failure, the rollback ran fs::remove_file on every file already written, including snapshots/{table}.rs. For an ALTER on an existing table the snapshot already existed and was overwritten during the run, so deleting it lost the previous content — the next makemigrations would see no snapshot and regenerate a full CREATE TABLE for an already-migrated table. Fixed: the content of every existing target is backed up in memory before writing (like the existing lib.rs backup); on rollback each file is restored if it had a backup, otherwise removed.

  • Destructive guard didn't cover extend!{} blocks: check_destructive only ran on the main model changes; the extend pass computed its own diffs and generated ALTER files directly, with no destructive check and ignoring --force. A DROP COLUMN / type change / nullable→required / dropped or CASCADE FK introduced via an extend!{} block was emitted silently. Fixed: extend changes are now included in a single destructive guard that honors the same --force flag as the main pass.

  • Non-atomic generation across passes (refactor to plan → validate → commit): the command wrote in three independent passes (main models, then extend!{}, then AdminTableMigration positioning), each committing on its own. A failure in a later pass left the earlier ones committed. Reworked into a single flow: both the main and extend changes are planned in memory first (Plan { files, dirs, lib_modules }), validated by one destructive guard, then committed atomically by a single commit_plan — directory creation, target backups, file writes, lib.rs registration and admin-migration positioning all run under one rollback. As a side effect, a project containing only extend!{} blocks (no own models) now generates its migrations correctly — previously an early return on empty model changes skipped the extend pass entirely.

Feature — runique / derive_form (DSL model! / extend!)

  • Enums in extend!{}: extend!{} now accepts an optional enums: { … } block (between table: and fields:), identical to model!. A choice [enum(EnumName)] column generates the real Rust enum type (DeriveActiveEnum), maps the entity field to it (instead of String), parses it in admin_from_form / admin_partial_update via FromStr, and populates the admin ChoiceField with the variants. makemigrations emits the column (Postgres: CREATE TYPE … AS ENUM; other engines: native VARCHAR/ENUM). The shared generate_enums was factored into generate_enum_defs(&[EnumDef]), reused by both macros. Degrades cleanly: choice [enum(X)] without a declared enums: block keeps the old String behaviour.

  • Column rename via [renamed_from: "old"]: renaming a field previously produced a DROP COLUMN + ADD COLUMN — silent data loss. The new explicit hint makes makemigrations emit ALTER TABLE … RENAME COLUMN old TO new (portable across PostgreSQL, MySQL/MariaDB and SQLite), with no data loss. It is a migration-only directive (no effect on the generated entity/form) and is tolerated by both DSL field parsers (FormFieldDecl for v2/extend!, FieldDef for v1). Guard against a stale hint: if the old column still exists in the snapshot, no rename is emitted (falls back to add). ParsedColumn.renamed_from is transient and never written to snapshots.

Fix — runique (CLI makemigrations — cross-engine correctness)

  • Enum value rename produced broken SQL on Postgres: a positional change of an enum value generated a data UPDATE before ALTER TYPE … ADD VALUE, plus a misleading "value removed" warning — unrunnable on a native PG enum (a value cannot be written before it exists, and a freshly added value cannot be used in the same transaction). A rename is now modelled as one operation: on Postgres it emits ALTER TYPE … RENAME VALUE 'old' TO 'new' (atomic, updates existing rows); on VARCHAR-backed engines it emits a plain data UPDATE. The renamed pair is excluded from the add/drop lists. Enum value add/drop DDL is now also gated to Postgres only (previously ALTER TYPE was emitted on MySQL/SQLite too — invalid SQL).

  • Foreign keys broke migrate on SQLite: all FK constraints were grouped into a separate create_relations migration applied via ALTER TABLE … ADD CONSTRAINT. PostgreSQL and MySQL/MariaDB accept this, but SQLite cannot add a FK to an existing table — migrate panicked. Fixed: for SQLite (DbKind::Other), FKs are now declared inline in the CREATE TABLE (build_create_table_cols gains an inline_fks flag) and the create_relations migration is skipped. PostgreSQL/MariaDB are unchanged; the snapshot keeps FKs as separate statements (round-trip stable). Validated end-to-end (fresh + full reset) on all three engines via a multi-engine smoke script.

  • Pipeline test suite expanded (11 → 63): migration/utils/tests_pipeline.rs now covers enums in extend!, enum value rename per engine, column rename (hint, portability, stale hint, extend!), primary-key types (i32/i64/uuid), per-engine timestamps, FK actions, i32-backed enums, the full destructive matrix, ALTER add/drop/modify column + FK + index, semantic type mapping, Changes::is_empty, the no-op diff, ignored (readonly) columns, topological FK ordering, and inline-FK-on-SQLite. All dependency-free (no DB/Docker).

Fix — runique (forms, admin)

  • add_js() scripts blocked by CSP strict-dynamic in admin forms: render_js() built its own isolated Tera context containing only js_files, with no access to the request-scoped csp_nonce. Under strict-dynamic, any <script> tag without the matching nonce is blocked by the browser. Fixed: FormRenderer gains a csp_nonce: Option<String> field and a set_nonce() method; Forms exposes set_csp_nonce(); the admin CRUD handlers (handle_crud.rs) inject the nonce from the request context into the form renderer before serialisation via a new inject_csp_nonce() helper, called at every context.insert(FORM_FIELDS, …) site (create GET/POST, edit GET/POST).

  • TextField double-encoded & when storing plain-text input: sanitize_strict() used ammonia::Builder::new().tags(HashSet::new()).clean(input) to strip all HTML tags. This is correct, but ammonia always HTML-encodes & → &amp; in its output even when no tags remain — a side effect of its HTML-entity serialiser. The raw output was then stored in the database. When Tera later rendered it with autoescaping active, the & in &amp; was re-encoded to &amp;amp;, so the browser displayed the literal text &amp; instead of &. Fixed: after ammonia strips the tags, the result is decoded back to plain text via html_escape::decode_html_entities() — removing the spurious entity encoding before storage. Dangerous protocol stripping (javascript:, vbscript:, data:, file:) runs on the decoded string, preserving security guarantees. New dependency: html-escape = "0.2".