Releases and security

2.1.12 - 2026-05-30

Fix — runique (sessions)

  • DB session fallback broken after cycle_id() (critical regression introduced in 2.1.9): the session fixation fix added session.cycle_id() on every privilege elevation. tower-sessions calls create() (not save()) at response commit for a recycled session. create() in CleaningMemoryStore had no DB persistence, so the authenticated session data was never written to the DB store after a login — breaking the warm-restart fallback entirely. Fixed: create() now persists to RuniqueSessionStore when SESSION_USER_ID_KEY is present, identical to the existing logic in save().

  • Orphaned DB session entries after cycle_id() (cleanup regression): tower-sessions calls delete(old_id) after create(new_id) during cycle_id(). CleaningMemoryStore::delete() only removed the entry from memory, leaving the old session ID as an orphan in the DB. Fixed: delete() now also calls db.delete() when the DB fallback is configured. The operation is idempotent — logout() already removes the entry via RuniqueSessionStore::delete() before calling session.delete().

  • exclusive_login only invalidated memory sessions, not DB sessions: CleaningMemoryStore::save() evicted in-memory sessions for the same user when exclusive_login = true, but never called RuniqueSessionStore::invalidate_other_sessions(). After a server restart, the evicted sessions were restored from DB, making the exclusive-login guarantee ineffective. Fixed: the DB invalidation is now collected inside the lock and executed after release, symmetric with the memory cleanup. Using Pk directly (serde_json::from_value::<Pk>) instead of as_i64 — correct under the big-pk feature flag.

Security — runique (forms, admin, templates)

  • CSRF token computed but never enforced on public forms (critical): the Prisme pipeline computes csrf_valid for every mutating request but returns it as a flag without rejecting. The form layer (Request::form() / is_valid()) never consumed it, and the CSRF hidden-field validator is a no-op since set_expected_value was removed (masked tokens differ per request). Only the admin panel re-checked the flag manually, so any public POST handler built on the documented form.is_valid() pattern accepted cross-site forged submissions — verified end-to-end against a live registration endpoint (an account was created with no session cookie and no token). Fixed: Request::form() now sets force_invalid when the request is mutating and prisme.csrf_valid is false, so is_valid() fails closed — reusing the existing honeypot mechanism without reintroducing set_expected_value.

  • SQL injection on MySQL/MariaDB via raw-SQL value interpolation (high): admin list filters, search (search_cond!), bulk group_set, and the m2m option query built conditions with Expr::cust(format!("... = '{}'", val)) escaped only by doubling single quotes (' → ''). This is sufficient on PostgreSQL/SQLite (standard_conforming_strings) but bypassable on MySQL/MariaDB, where a backslash escapes the following quote (\' followed by '' breaks out of the string literal). An authenticated staff user with read access could execute arbitrary SQL. Fixed: all attacker-controlled values are now bound parameters via Expr::cust_with_values(..., [val]), delegating escaping to sea-query's backend-aware layer. Column identifiers stay inline but remain whitelisted (FILTER_COLS / SORT_COLS) or schema-fixed.

  • SQL injection in built-in admin resources (high): the hand-written list_fn of the built-in users, groupes, and droits resources interpolated the filter column name (?filter_<col>=) directly into CAST({col} AS TEXT) with no whitelist — an identifier injection exploitable on every backend, not just MySQL — in addition to the unsafe value escaping. Fixed: column names are validated against an [A-Za-z0-9_] charset before use, and values are bound via cust_with_values.

  • Stored XSS via the | markdown filter (high): the template preprocessor rewrites every {{ x | markdown }} to {{ x | markdown | safe }}, and the filter emitted pulldown-cmark output without sanitization. Raw inline HTML (<script>, onerror=) and javascript: link/image URLs passed through unescaped, making any user-authored Markdown a stored-XSS vector. Fixed: the filter now runs its output through a new sanitize_markdown() (ammonia) — http/https/mailto schemes only, no style attribute, raw HTML stripped, rel="noopener noreferrer" on links. The shared ALLOWED_TAGS / ALLOWED_ATTRS whitelist was widened (h1–h6, tables, del/s/sub/sup, hr, img, code[class]) to cover Markdown output without enabling any script-bearing element.

  • Open-redirect filter bypass via backslash (medium): is_safe_redirect treated /\evil.com as a safe relative path (starts_with('/') but not "//"). Browsers normalize \ to /, turning it into the protocol-relative //evil.com. Fixed: backslashes are normalized to forward slashes before the same-origin determination.

  • IP spoofing via X-Forwarded-For in standalone-TLS mode (medium): the built-in TLS server (axum_server::bind_rustls, used for ACME / standalone HTTPS) served the router via into_make_service() without connect-info. With no ConnectInfo<SocketAddr>, trusted_proxies saw conn_ip = None, defaulted to loopback (a trusted CIDR), and therefore honored the client-controlled X-Forwarded-For header — letting any client forge its IP (rate-limit bypass, forged audit logs). Fixed on three layers: (1) the TLS serve path now uses into_make_service_with_connect_info::<SocketAddr>(), exposing the real peer IP; (2) extract_client_ip returns loopback without ever reading X-Forwarded-For when the peer IP is unknown, so a missing connect-info can no longer enable spoofing; (3) IPv4-mapped IPv6 peers and XFF entries (::ffff:a.b.c.d, seen on dual-stack sockets) are canonicalized to IPv4 before the trusted-CIDR check, so a private reverse proxy is correctly recognized. Covered by unit tests in trusted_proxies.rs.

  • is_authenticated deserialized the user id as i32 (low): under the big-pk feature (i64), a user id exceeding i32::MAX failed to deserialize, so is_authenticated returned false inconsistently with get_user_id (which uses Pk). Fixed: it now reads Pk.