Repository navigation
Conversation
…ns can't be smuggled in
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.
Fix three security and correctness bugs found in review
Three defects, each reproduced against real Postgres before being touched, each fixed in its own commit, plus one hardening commit. The first is the subject of security advisory GHSA-h3fc-96x2-49rx (High, CWE-284 / CWE-915, reported by @pavelknespl): unauthenticated signup could mass-assign
email_verifiedand the OAuth identifier columns, which defeats crudauth's account-claim protection and enables OAuth account pre-hijacking. The other two are a 500 on a valid user lookup and/adminpages being marked publicly cacheable. No new dependencies. Three response-header strings and one OpenAPI response schema change; everything else is behaviour-preserving.1. Signup could pre-claim someone else's account
UserCreatedeclaredgoogle_id,github_id,oauth_provider,email_verified,oauth_created_atandoauth_updated_atas body fields, soPOST /api/v1/users/— which has no auth dependency — let a client mark its own address verified. That matters because crudauth links a verified provider login to an existing account without claiming it: a verified address is supposed to have proved itself, so there's no password reset, no MFA wipe, notoken_versionbump and no session termination. Registering under someone's address withemail_verified: trueturns that protection off.Reproduced end to end before the fix, then again as the control:
The attacker keeps password access to the account the victim now signs into with Google; the control run shows the claim working whenever the flag isn't set.
The fix removes those six fields from the public
UserCreate, which now carriesname,username,emailandpassword.UserCreateInternalkeeps all of them, so every server-side path is untouched: crudauth's OAuth provisioning builds theUserrow itself throughnew_user_fields, the admin panel'sform_create_rulesnever listed these fields, andscripts/create_first_superuser.pypasses only the four public ones.The advisory's Stage 1 payload against the patched code:
Why 422 and not silently dropping the fields
UserCreatealready hadextra="forbid", so not declaring the fields is enough to reject them, and that's the same treatment #302 gave the update path. Silently ignoring them would look to an integrator like the field was accepted — the failure would surface later as "why is this user not verified?" rather than at the call that was never going to work.What else can reach those columns
Checked, because the schema is only one door:
POST /api/v1/users/is the only route that writes a user row from client input; the auth router mountslogin,logout,logout-all,refresh-csrf,check-authand the two OAuth routes, with no crudauth/registerand no email-verification endpoints;UserAdminUpdateis referenced only by the sqladmin edit form, which is superuser-gated; andis_superuserwas never onUserCreate.2. Hardening: the row is built from an allowlist
Removing the fields made the row safe by omission rather than by construction.
UserService.createstill diduser.model_dump()→UserCreateInternal(**dict), so anything that reaches it carrying those keys would still write them — a re-added field, or a subclass. AUserCreatesubclass declaringemail_verified = Trueproved it: the row came back verified.createnow names what it reads —name,username,email, and the hash it computes — so a password signup always leaves the verification flag and the OAuth identifiers at their column defaults, whatever the payload carries. This is the advisory's "defense in depth" recommendation.It stops short of its "better still": routing self-registration through crudauth's own registration entry point. That changes the endpoint's contract and error shapes, and belongs in its own PR.
3. Looking up a valid user answered 500
UserProfileReadis theresponse_modelofGET /api/v1/users/{username}and hard-coded stricter rules than the column allows:usernameasmax_length=20withpattern=r"^[a-z0-9]+$"against aString(32)column andUSERNAME_PATTERN = ^[a-z0-9_]+$, plusnameas a literalmax_length=30. Response validation failed, the failure wasn't anHTTPException, and the global handler turned it into a 500. Sowith_underscore— or any username over 20 characters — signed up fine and was then unreadable. Both cases returned 500 before the fix and 200 after.nameandusernameare now plainstrwithexampleskept for the docs. A read schema describes what's stored: re-validating on the way out can only turn valid data into a 500, and it leaves a second copy of the rules to drift from the model. The visible effect is that this one response model losesmaxLength,minLengthandpatternin the OpenAPI document; no body or status code changes.The other read schemas were checked and left alone.
UserReaduses the shared constants and matches its columns, andTierRead/APIKeyRead/KeyUsageReadagree with theirs. One is worth its own change later:RateLimitReadinherits apathvalidator requiring^\/[a-zA-Z0-9_\-\/]+$, so a rate-limit row whose path is a route template like/api/v1/users/{username}— reachable through the admin panel, which writes through the model rather than the schema — would 500 on read.4.
/adminpages were marked publicly cacheableClientCacheMiddlewaregaveprivate, no-cache, no-store, must-revalidateto anything under/api/andpublic, max-age=<CLIENT_CACHE_MAX_AGE>to everything else — the SQLAdmin panel,/health,/docs,/openapi.jsonand the OAuth redirects. Behind a CDN or any shared proxy, one admin's page could be stored and served to the next visitor, and health checks could go stale.The rule is now, in order:
Cache-Controlthe response already set is left alone, so a route can opt into caching deliberately./api/keeps its exact current header, so nothing API-facing changes.public, max-age=<CLIENT_CACHE_MAX_AGE>for an explicit prefix allowlist, which is("/admin/statics/",)— SQLAdmin serves its own CSS and JS from/staticsunder the panel's mount. The prefixes are a constructor argument, so an application that mounts its own assets can add them.private, no-store. That covers the admin pages,/health, the docs and the OAuth redirects.CLIENT_CACHE_MAX_AGEtherefore governs the static allowlist only. Anyone who was relying on it to let a CDN cache an HTML page now sets the header on that route, which point 1 preserves.fastroai-templatecarries the same middleware, verbatim, so it has the same defect and wants the same change.Behaviour changes a client can notice
email_verified,google_id,github_id,oauth_provider,oauth_created_atandoauth_updated_atinstead of accepting them.GET /api/v1/users/{username}answers 200 where it used to answer 500, and its OpenAPI response schema no longer constrainsnameandusername.private, no-storeinstead ofpublic, max-age=….What was deliberately left out
Existing rows. The patch can't clean deployments that already ran the vulnerable code. This app has no email pipeline, so
email_verifiedcan only legitimately be true through an OAuth link or an admin edit, which makes the audit precise; the query and the remediation belong in the advisory, since people who copied the template can't fix it by upgrading:UserReadand the other read schemas, per above — only the broken one changed.RateLimitRead's path validator, flagged above, not fixed here.crudauth's own registration entry point as the signup path.
Test Plan
Automated
uv run pytest— 422 passed (407 onmain, 15 new)uv run ruff check src testsanduv run ruff format --check src tests— cleanuv run mypy src— no issuesSignup (8 new)
email_verified=Falsewith no OAuth identifiersgoogle_idandemail_verifiednow set — this pins crudauth's claim itselfAllowlist hardening (1 new)
UserCreatesubclass declaringemail_verified=Trueand agoogle_idstill produces an unverified row with no provider — failed before the changeProfile lookup (2 new)
with_underscoreand a 25-character username sign up and then look up with 200 — both 500'd beforeClient cache (4 new, 1 rewritten)
/admin/user/list,/healthand an unlisted/static/path areprivate, no-store— all failed before/admin/statics/css/main.cssispublic, max-age=…Cache-Controlkeeps it — failed beforetest_static_paths_get_public_cacherewritten astest_admin_statics_get_public_cache, since the old path is no longer public/api/keepsprivate, no-cache, no-store, must-revalidate(existing test, unchanged)Dependencies
None new.
After merging
The advisory has no affected or patched versions set, and the latest release is v0.19.0, so these commits want a tagged patch release to point at: affected
<= 0.19.0, patched at the new tag. The advisory text should carry the diff and the SQL above, not just a version, because downstream users apply patches rather than upgrade a dependency.