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 addedsession.cycle_id()on every privilege elevation. tower-sessions callscreate()(notsave()) at response commit for a recycled session.create()inCleaningMemoryStorehad 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 toRuniqueSessionStorewhenSESSION_USER_ID_KEYis present, identical to the existing logic insave(). -
Orphaned DB session entries after
cycle_id()(cleanup regression): tower-sessions callsdelete(old_id)aftercreate(new_id)duringcycle_id().CleaningMemoryStore::delete()only removed the entry from memory, leaving the old session ID as an orphan in the DB. Fixed:delete()now also callsdb.delete()when the DB fallback is configured. The operation is idempotent —logout()already removes the entry viaRuniqueSessionStore::delete()before callingsession.delete(). -
exclusive_loginonly invalidated memory sessions, not DB sessions:CleaningMemoryStore::save()evicted in-memory sessions for the same user whenexclusive_login = true, but never calledRuniqueSessionStore::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. UsingPkdirectly (serde_json::from_value::<Pk>) instead ofas_i64— correct under thebig-pkfeature flag.
Security — runique (forms, admin, templates)
-
CSRF token computed but never enforced on public forms (critical): the Prisme pipeline computes
csrf_validfor 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 sinceset_expected_valuewas removed (masked tokens differ per request). Only the admin panel re-checked the flag manually, so any public POST handler built on the documentedform.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 setsforce_invalidwhen the request is mutating andprisme.csrf_validis false, sois_valid()fails closed — reusing the existing honeypot mechanism without reintroducingset_expected_value. -
SQL injection on MySQL/MariaDB via raw-SQL value interpolation (high): admin list filters, search (
search_cond!), bulkgroup_set, and the m2m option query built conditions withExpr::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 viaExpr::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_fnof the built-inusers,groupes, anddroitsresources interpolated the filter column name (?filter_<col>=) directly intoCAST({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 viacust_with_values. -
Stored XSS via the
| markdownfilter (high): the template preprocessor rewrites every{{ x | markdown }}to{{ x | markdown | safe }}, and the filter emittedpulldown-cmarkoutput without sanitization. Raw inline HTML (<script>,onerror=) andjavascript:link/image URLs passed through unescaped, making any user-authored Markdown a stored-XSS vector. Fixed: the filter now runs its output through a newsanitize_markdown()(ammonia) — http/https/mailto schemes only, nostyleattribute, raw HTML stripped,rel="noopener noreferrer"on links. The sharedALLOWED_TAGS/ALLOWED_ATTRSwhitelist 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_redirecttreated/\evil.comas 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-Forin standalone-TLS mode (medium): the built-in TLS server (axum_server::bind_rustls, used for ACME / standalone HTTPS) served the router viainto_make_service()without connect-info. With noConnectInfo<SocketAddr>,trusted_proxiessawconn_ip = None, defaulted to loopback (a trusted CIDR), and therefore honored the client-controlledX-Forwarded-Forheader — letting any client forge its IP (rate-limit bypass, forged audit logs). Fixed on three layers: (1) the TLS serve path now usesinto_make_service_with_connect_info::<SocketAddr>(), exposing the real peer IP; (2)extract_client_ipreturns loopback without ever readingX-Forwarded-Forwhen 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 intrusted_proxies.rs. -
is_authenticateddeserialized the user id asi32(low): under thebig-pkfeature (i64), a user id exceedingi32::MAXfailed to deserialize, sois_authenticatedreturnedfalseinconsistently withget_user_id(which usesPk). Fixed: it now readsPk.