Security: fix the admin authentication bypass in AdminAuthMiddleware - #78
Merged
Merged
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Security: fix the admin authentication bypass in
AdminAuthMiddlewareAdminAuthMiddlewareexempted the login page and static assets from the session check, but it matched both by path shape rather than by path identity — a suffix test for/loginand a substring test for/static/. Because the admin's routes take path parameters, the value of a parameter could push a request into either exemption, and the admin's read endpoints had no authentication dependency of their own to fall back on. This branch anchors both exemptions to the configured admin prefix, and — while verifying that the middleware was no longer a single point of failure — found that four route registrations passed the auth dependency factory uncalled, so the dependencies that were supposed to be the backstop enforced nothing. Both defects are fixed, with a regression suite that fails against v0.5.0, the security policy published on the docs site, and a bump to v0.5.1. No public API changes.Reported privately by Ubaid Ur Rehman, found using shadowaudit.
The bypass
The two exemptions in
dispatchwere:Both are satisfiable by choosing a path parameter, so an unauthenticated request could be steered past the session check:
main/admin/Article/update/login/update/{id}withid="login"/admin/Product/update/login/update/{id}withid="login"idnot coercible to an integer PK)/admin/Author/related/static/books/related/{id}/{relationship_name}Each row was reproduced against a real app before the fix and is now covered by a test.
On the actual impact. Rendering real record data required a model with a string primary key holding a row keyed literally
"login"; on integer-PK models the same URL produced an unauthenticated 500 instead. List views, the dashboard, health, and the event log were never reachable this way — their URLs neither end in/loginnor contain a/static/segment — so the original report's "all READ endpoints are exposed" overstates it. It remains a real broken-access-control defect: unauthenticated requests reached endpoint code and issued database queries. The release notes state the narrower scope explicitly rather than repeating the report's framing.Exact path matching
Both exemptions are now anchored to the admin's configured URL prefix, so no path parameter can reach them:
url_prefixis hoisted out of the fourget_url_prefix()calls that built the redirect targets further downdispatch, which is why the diff on that file is larger than the two changed predicates.Why: the exemptions describe two specific resources — one page and one mount point — so they should be expressed as identity and prefix, not as a suffix and a substring that any parameterized route can satisfy.
rstrip("/")keeps/admin/login/working; it previously fell through to the session check and redirected to the login page carrying anerror=parameter.Defense in depth on the read routes
get_model_admin_page,get_model_create_page,get_model_update_page,get_related_data_endpoint, andget_relationship_options_endpointcarried noDepends(get_current_user). The write handlers (form_create,form_update,bulk-delete) always have, which is why write operations were never exposed by this bug.setup_routesnow builds anauth_dependencieslist once and attaches it to all six previously-unprotected GET routes. It resolves to[]whenadmin_siteisNone, so aModelViewconstructed standalone still sets up.Why: the middleware was the only thing standing in front of every read path in the admin, so a single flawed predicate exposed all of them at once. Under normal use nothing changes — the middleware still redirects anonymous visitors before the dependency runs — but the dependency is what keeps a future middleware mistake from being a bypass again. Measured: with the old middleware restored and only these dependencies in place, all six bypass tests still pass.
Four auth dependencies that enforced nothing
AdminAuthentication.get_current_useris a factory: it returns the dependency, it is not the dependency. Four registration sites passed it uncalled.Depends(factory)hands FastAPI a zero-parameter sync callable that returns a function and never raises — the route reads as protected and enforces nothing. The affected registrations:admin_site.py:204,212/(dashboard),/dashboard-contentcrud_admin.py:849/management/health,/management/health/content, and the event-log routes registered in that blockcrud_admin.py:1163include_routerfor every model viewThe last one matters most: that router-level dependency was written to protect all model-view routes, and the missing
()is why the read endpoints had nothing behind the middleware when the bypass reached them. Twocast(...)wrappers around those references are why mypy never flagged the type mismatch; both casts are removed rather than corrected, since the call now returns the right type on its own.How it surfaced. Mounting
admin.appat a path that differs from the configuredmount_pathmakes the middleware's outer prefix check miss, and it returnscall_next— it fails open. That configuration therefore isolates the dependencies. Before this change,/backoffice/and/backoffice/management/healthreturned 200 to an anonymous request; they now return 401, as do the model-view routes.One session validation per request instead of two
get_current_user_innercalledsession_manager.validate_sessiontwice with identical arguments, discarded the first result after a truthiness check, and used the second. Both calls default toupdate_activity=True, so every authenticated request also wrote the activity timestamp twice.That was invisible while the dependencies were inert. Making them enforce would have put the duplicate on every admin request, so the redundant call is removed as part of the same change.
get_current_usernow also memoizes the dependency it builds, so the router-level and route-level declarations share one callable and FastAPI's per-request dependency cache resolves it once.Measured on an authenticated page load: 3
validate_sessioncalls before this branch's dependency work, 2 after (one in the middleware, one in the dependency). Without the memoization it would have been 5.Regression tests
tests/auth/test_middleware_path_bypass.py(8 tests) builds a real admin app underTestClient— a string-PK model holding a row keyed"login"whose body is a sentinel string, plus a model with relationships — and asserts that each crafted URL is redirected or rejected and that the sentinel never appears in a response body. Two further tests assert the genuine login page stays open to anonymous visitors, including with a trailing slash.The fixture is module-scoped because
CRUDAdminregisters its admin models on a shared declarative base, so only one instance can be constructed per process.The app is mounted twice — at
/adminand at/backoffice— so one fixture covers both the middleware path and the middleware-skipped path.test_route_dependencies_hold_when_the_middleware_is_skippeddrives the second mount and asserts 401.test_protected_routes_declare_a_real_auth_dependencyinspectsroute.dependantand asserts each protected route declares an awaitable dependency, which is what catches theDepends(factory)mistake structurally rather than by symptom.Verified as regressions rather than assumed: 7 of the 8 original tests fail with the middleware change stashed, and 3 of the 5 new tests fail with the dependency fixes stashed.
The security policy is now published
docs/community/mirrors the root governance files through pymdownx snippet includes, butSECURITY.mdwas missing from that set — the private-reporting process lived only on GitHub, not on the docs site. This addsdocs/community/SECURITY.md(a one-line--8<-- "SECURITY.md", matching the three existing mirrors), a Security Policy section indocs/community/overview.md, and the nav entry inzensical.toml.SECURITY.mditself gains an Acknowledgments section — a table of reporters of fixed issues, opened by this report. TheModelView.setup_routesdocstring, which feeds the published API reference, now states that routes declare an authentication dependency alongside the middleware.Version bump
pyproject.tomlanduv.lockmove to 0.5.1. Nothing else hardcodes the version:crudadmin.__version__reads it from package metadata, andtests/test_version.pycompares against installed metadata rather than a literal.Test Plan
Automated
uv run pytest— 361 passed (13 new), SQLite; Postgres/MySQL container paths run in CIuv run ruff check crudadmin tests/ruff format --check— cleanuv run mypy crudadmin— clean (43 source files)uv run zensical build— no issues; the renderedsite/community/SECURITY/index.htmlcarries the Acknowledgments section and the nav entryThe bypass is closed
/admin/Article/update/login(string PK, row keyed"login") — 303 to the login page; the sentinel body is absent from the response/admin/Author/update/login(integer PK) — 303, no unauthenticated 500/admin/Author/related/1/loginand/admin/Author/related/static/books— 303/admin/Author/relationship-options/login— 303Normal use is unaffected
GET /admin/loginstill returns 200 with the login form to an anonymous visitorGET /admin/login/no longer redirects to an error-carrying login URLThe dependencies actually enforce
mount_path(middleware prefix check misses),/backoffice/,/backoffice/Article/,/backoffice/Article/update/login, and/backoffice/management/healthall return 401 — they returned 200 beforeroute.dependantvalidate_sessioncalls per authenticated request: 2 (was 3; would have been 5 without memoizing the dependency)Regression value confirmed
Dependencies
None added, removed, or re-pinned. The only
uv.lockchange is the editable self-reference moving0.5.0→0.5.1.Breaking Changes
None to the public API. Three behavior changes worth knowing about, all documented in the release notes:
/admin/login/(trailing slash) previously redirected to the login page with anerror=query parameter; it now resolves to the login page normally.admin.appoff itsmount_pathwas serving those pages unauthenticated and will now get 401s — that was the bug, not the fix.admin.appis mounted at a path that differs from the configuredmount_path, static files are no longer served unauthenticated. That configuration was already broken — the middleware's outer prefix check skipped authentication entirely for it — so the fix is to setmount_pathto match the actual mount point.