Repository navigation
v0.5.2
Release v0.5.2 - Access Control, XSS and Session Hardening
Release Notes by the CRUDAdmin team
This is a security patch release. A review of the admin interface found three access-control and injection defects, which are all fixed here:
- An undocumented REST API on every model view ignored
allowed_actionsand returned password hashes. - A crafted list-page link could run script in an admin's browser.
- The IP allowlist and the HTTPS redirect did nothing on any mount path other than
/admin.
The release also fixes a set of related hardening and correctness issues. Two of them stand out:
- On the database session backend, concurrent requests logged admins out at random.
- The audit log silently dropped its metadata.
Every affected version is 0.5.1 and earlier. If you run CRUDAdmin with more than one admin user, mount it anywhere other than /admin, or rely on allowed_ips, upgrade.
pip install -U crudadmin # or: uv add crudadmin@latestThe vulnerabilities
1. A REST API that bypassed allowed_actions (high)
Every model view mounted a generic REST API under /{Model}/crud, generated by FastCRUD's EndpointCreator:
POST /admin/{Model}/crud
GET /admin/{Model}/crud
GET /admin/{Model}/crud/{id}
PATCH /admin/{Model}/crud/{id}
DELETE /admin/{Model}/crud/{id}
The admin UI never used these routes, they were left out of the OpenAPI schema, and they were never documented. They were created with no restriction on methods and no read schema. That had three consequences:
allowed_actionswas not enforced. A view registered as{"view"}could still be created, edited and deleted through/crud. The built-inAdminUserview is registered withoutdelete, yetDELETE /admin/AdminUser/crud/{id}deleted admin users.- Every column was returned.
GET /admin/AdminUser/crud/{id}returnedhashed_password. Any column you excluded withselect_schema, and every session id inAdminSession, could be read the same way. - Nothing was audited. Writes through
/crudskipped the event log entirely.
The routes required a logged-in admin session. So the attacker was any admin user (or anyone holding an admin's session), not an anonymous visitor. On 0.5.0 and earlier there was one more case: if the admin app was mounted at a path that differed from its configured mount_path, these routes were reachable without a session (see the 0.5.1 notes).
Fix: the /crud router is removed. All admin reads and writes go through the view routes, which honour allowed_actions, use select_schema, and are audited.
2. Cross-site scripting in the list page (medium to high)
The Previous and Next pagination buttons built their request parameters with htmx's js: prefix, which evaluates the attribute as JavaScript:
hx-vals='js:{"page": "2", "sort_by": "{{ sort_column }}", "sort_order": "{{ sort_order }}"}'sort_order came straight from the query string. Jinja escaped the quotes to ", but the browser decodes entities before htmx evaluates the expression, so the value broke out of its string. An attacker could send a logged-in admin a link such as
/admin/<Model>/?sort_order=x"+fetch('/admin/AdminUser/crud/1',{method:'DELETE'})+"
and the script ran in the admin's session when they clicked Next or Previous. The session cookie is HttpOnly, so it couldn't be read, but the script could act as the admin. SameSite doesn't help here, because the requests are same-origin.
A related, narrower issue was in the row-expand button: it wrote the row's primary key into an inline onclick handler. On a model with a string primary key and a relationship, a key containing ' became stored script that ran when an admin expanded that row.
Fix:
- Every
hx-valsis now plain JSON built with Jinja'stojson. htmx parses it as data and never evaluates it. - The server now accepts only
asc/descforsort_orderand only real column names forsort_by. - The row-expand button reads the id from the row's
data-row-idattribute. - No request or record value is written into JavaScript any more.
3. IP allowlist and HTTPS redirect skipped on custom mount paths (high)
IPRestrictionMiddleware and HTTPSRedirectMiddleware only acted on paths that start with the literal string /admin:
if not request.url.path.startswith("/admin"):
return await call_next(request)Both are installed on the admin app itself, so on any other mount path every request skipped them. That covers mount_path="/backoffice", "/internal" and the root "/". With allowed_ips=[...] configured, any address could load the login page and log in. With enforce_https=True, plain HTTP was served. Mount paths that happen to start with /admin (such as /admin-panel) were not affected.
The HTTPS redirect also mishandled ports. With https_port=8443, a request to http://host:8000/admin was redirected to https://host:8000/admin instead of https://host:8443/admin.
Fix:
- Both middlewares check every request they see, whatever the mount path.
- The redirect now uses the configured HTTPS port, and drops the port when it is 443.
Classification: OWASP A01:2021 (Broken Access Control) for 1 and 3, A03:2021 (Injection) for 2.
Hardening
- Session ids no longer leak.
- Logs and the event log record a short SHA-256 handle instead of the raw session id or CSRF token. The handle still lets you correlate entries for one session.
- The session list in the admin no longer shows
session_id. - The admin user list no longer shows
hashed_password. It now shows id, username and superuser status.
- Audit snapshots are redacted. Values under credential-like keys (
password,secret,token,session_id,api_key), at any depth, are stored as[redacted]. Before, updating an admin user copiedhashed_passwordinto an audit row every admin could read. A password change is still recorded as a change, without the values. - Admin passwords must be 8–128 characters. The old rule was an ungrouped regex alternation that accepted any password containing a single letter or digit, including
"a". The documentation also described character-class rules that were never enforced; it now states the real rule. PasswordTransformerrequires ahash_function. Without one it used to store the password as plaintext. It now raisesValueErrorat construction.- Login errors are generic. A failure while creating the session used to show the exception text on the login page.
Bug fixes
- Random logouts on the database session backend. With
session_backend="database","hybrid"ortrack_sessions_in_db=True, every session operation shared oneAsyncSession. Concurrent page loads collided on it, and in testing 19 of 20 simultaneous requests were redirected to the login page. Each operation now uses its own session. The auth middleware also no longer holds a database connection while the request is handled. Holding it was exhausting the connection pool under load. - Session cookie lifetime. The session cookie always got
Max-Age=1800, so admins were logged out 30 minutes after login, however active they were and whateversession_timeout_minutessaid. The cookies are now browser-session cookies, and the server-side timeout, which slides with activity, decides when a session ends. - Audit metadata was never stored. The schema field was named
metadatawhile the column isaudit_metadata. Becausemetadatais reserved on SQLAlchemy models, the value was dropped and every audit row stored{}. The field is nowaudit_metadata;metadata=is still accepted as input. AdminUser.created_atandupdated_atwere evaluated once when the model was built, so every admin row carried the process start time. They are now set when each row is written. Existing rows keep their old values.- Updates crashed on Python 3.10. Two update paths used
datetime.UTC, which only exists on Python 3.11+. On 3.10, editing a record through aPasswordTransformer(including every admin-user edit), or updating a model whose update schema hasupdated_at, raisedAttributeError. They now usedatetime.timezone.utc. - Page size is bounded.
rows-per-page-selectaccepts only the offered sizes (10, 20, 50, 100). Before, any value was accepted, including one large enough to load the whole table.
Behavior changes
/{Model}/crudroutes are gone. If you called them directly, use the admin's own routes, or expose your own FastCRUD endpoints with the access rules you intend.- Errors inside admin pages now return a 500. The auth middleware used to catch exceptions raised by the page itself and redirect to the login page with "Authentication error", which hid real bugs behind a fake logout. Authentication failures still redirect to login.
- Session cookies end when the browser closes. Idle sessions still expire after
session_timeout_minutes. - The admin user list shows id, username and superuser status only.
PasswordTransformer(...)withouthash_functionraisesValueError.- New admin passwords must be 8–128 characters. Existing admin passwords are not re-validated and keep working.
Known limitations
CSRF tokens are issued but not yet checked, and the login form is not rate-limited. Earlier versions of the README listed both as features; it no longer does. Session cookies are SameSite=Strict, which blocks cross-site form posts in current browsers. Until enforcement lands in 0.6, put the admin behind allowed_ips / allowed_networks and rate-limit /admin/login at your reverse proxy.
Upgrading
pip install -U crudadmin. No schema changes and no configuration changes. Existing sessions and admin users keep working.
Check two things in your own code:
- Search for
/crudif anything calls the admin's generic REST routes. - Search for
PasswordTransformer(and confirm each call passeshash_function.
Tests
The suite grows from 361 to 428 tests:
- Generated routes: the
/crudroutes are gone for every method, including on a view-only model. - List page: no
js:hx-vals, craftedsort_orderandsort_byvalues never reach the page, string primary keys stay out of JavaScript, and page size is limited. - Network middleware: the allowlist and HTTPS redirect hold on
/admin,/backofficeand root mounts, including the port handling. - Database backend: 20 concurrent requests on both the database and hybrid backends.
- Login: cookie lifetime, generic error text, and no session id or CSRF token in any log record.
- Admin lists: no session ids or password hashes on the session and admin-user lists.
- Passwords: the password length rule.
- Audit log: metadata is stored and secrets are redacted.
- Models and transformer:
AdminUsertimestamps and thePasswordTransformerrequirement.
Each regression test fails against v0.5.1 and passes on v0.5.2.
What's Changed
- Remove the generic
/crudREST routes that bypassedallowed_actions, returned every column and skipped auditing - Fix XSS in the list page: JSON
hx-vals, server-side sort validation, and no record ids in inline handlers - Apply the IP allowlist and HTTPS redirect on every mount path, and redirect to the configured HTTPS port
- Give each database session operation its own session, and release the middleware's connection before the request is handled
- Keep session ids out of logs, the event log and the admin lists, and hide password hashes from the admin user list
- Redact credentials in audit snapshots, and store audit metadata
- Enforce 8–128 character admin passwords and require a hash function in
PasswordTransformer - Drop the fixed 30-minute cookie lifetime, make login errors generic, bound page size, and fix
AdminUsertimestamps - Correct the README and docs: security feature list, quick start, password rules, and the advisory link in
SECURITY.md
Full Changelog: v0.5.1...v0.5.2