Skip to content

v0.5.2

Choose a tag to compare

@igorbenav igorbenav released this 07 Oct 02:55
· 52 commits to main since this release
5e0c5a5

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_actions and 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@latest

The 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_actions was not enforced. A view registered as {"view"} could still be created, edited and deleted through /crud. The built-in AdminUser view is registered without delete, yet DELETE /admin/AdminUser/crud/{id} deleted admin users.
  • Every column was returned. GET /admin/AdminUser/crud/{id} returned hashed_password. Any column you excluded with select_schema, and every session id in AdminSession, could be read the same way.
  • Nothing was audited. Writes through /crud skipped 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-vals is now plain JSON built with Jinja's tojson. htmx parses it as data and never evaluates it.
  • The server now accepts only asc/desc for sort_order and only real column names for sort_by.
  • The row-expand button reads the id from the row's data-row-id attribute.
  • 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 copied hashed_password into 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.
  • PasswordTransformer requires a hash_function. Without one it used to store the password as plaintext. It now raises ValueError at 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" or track_sessions_in_db=True, every session operation shared one AsyncSession. 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 whatever session_timeout_minutes said. 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 metadata while the column is audit_metadata. Because metadata is reserved on SQLAlchemy models, the value was dropped and every audit row stored {}. The field is now audit_metadata; metadata= is still accepted as input.
  • AdminUser.created_at and updated_at were 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 a PasswordTransformer (including every admin-user edit), or updating a model whose update schema has updated_at, raised AttributeError. They now use datetime.timezone.utc.
  • Page size is bounded. rows-per-page-select accepts 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}/crud routes 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(...) without hash_function raises ValueError.
  • 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 /crud if anything calls the admin's generic REST routes.
  • Search for PasswordTransformer( and confirm each call passes hash_function.

Tests

The suite grows from 361 to 428 tests:

  • Generated routes: the /crud routes are gone for every method, including on a view-only model.
  • List page: no js: hx-vals, crafted sort_order and sort_by values 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, /backoffice and 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: AdminUser timestamps and the PasswordTransformer requirement.

Each regression test fails against v0.5.1 and passes on v0.5.2.


What's Changed

  • Remove the generic /crud REST routes that bypassed allowed_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 AdminUser timestamps
  • 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