security: complete the 2.4.0 pre-release audit (AUDIT-06 to AUDIT-22) - #751
Merged
Conversation
Audit findings AUDIT-06 and AUDIT-07. AUDIT-06 login CSRF: `state` was server-generated and stored globally, so the callback could only prove SOME flow issued it, never that THIS browser did. An attacker harvests their own valid code+state and delivers it to a victim, logging the victim into the ATTACKER's account; anything the victim then enters lands in an account the attacker controls (RFC 9700 4.7). A host-only HttpOnly cookie now binds the flow to the browser that started it. SameSite is None when Secure, matching the MFA cookie: Apple returns its callback as a cross-site form_post and a Lax cookie is not sent on one. The cookie is read back url-unescaped because gin escapes on write and nothing reverses it on read - the state contains "://" and spaces. AUDIT-07 codes were bound to a redirect_uri but not to an identity, so two confidential clients sharing a redirect origin could redeem each other's codes (RFC 6749 4.1.3). The token endpoint now compares the code's client against the AUTHENTICATED client, not the raw body field - clients may authenticate via Basic or a client assertion and send no body client_id. Both required adding a field to a positional "@@"-delimited blob that was hand-built and hand-parsed at a dozen sites across http_handlers and service. internal/codestate now owns both formats. Decoding is length- guarded per field, so codes minted by an older build stay redeemable across a deploy.
Audit findings AUDIT-09, AUDIT-10, AUDIT-15, plus a user-reported bug. AUDIT-10: access and refresh tokens were stored verbatim in the session store, so anyone who could read it - a Redis dump, a replica, a backup, an SSRF into the cache - walked away with live, directly replayable tokens. Stored as SHA-256 digests now. Reads are dual-mode (crypto.VerifySessionValue): a value written before the upgrade is still the raw token and is compared directly, so no live session or refresh token drops on deploy. The legacy branch can go once no pre-upgrade session can still be within its TTL. Three comparison sites had to move with it, one of which - revoke.go - was a plain != against a live refresh token rather than a constant-time compare. Two tests were scraping the store to recover a bearer token; they now use the one the API returned, which is the point of the change. AUDIT-09: at_hash/c_hash were hard-coded to SHA-256 whatever the signing alg. OIDC Core 3.1.3.6 and 3.3.2.11 require the digest implied by `alg`, so an instance signing RS384/RS512 emitted a value no conformant RP can reproduce - and an RP that cannot reproduce it skips the token-substitution check the claim exists to provide. AUDIT-15: a wildcard allow-list reflected the caller's Origin alongside Access-Control-Allow-Credentials, which is "any site may read credentialed responses from this API" wearing a disguise. Wildcard now returns a literal * with no credentials; credentialed CORS requires an explicit allow-list. Bug report (not from the audit): a user who clicked their verification link was told forever that their email was not verified, most visibly by passkey login. The email_verified_at write sat AFTER the MFA gate, and all three gate branches return early - and MFA is on by default, so a fresh signup's verification click lands on the setup screen and the write never happened. Recorded as soon as the token is proven now: clicking the link is the proof of mailbox control, and MFA interrupting session issuance must not discard it. AUDIT-13 (SameSite/domain-scoped cookies) was reviewed and declined - the default serves the subdomain-auth-server topology the product targets, same position Auth0 takes. Reasoning is now recorded at cookie.BuildSessionCookies with guard tests in internal/cookie and cmd, so it is not silently "fixed" later.
…racle
Audit findings AUDIT-08, AUDIT-12, and the low-severity batch.
AUDIT-08: a delegated (agent-acting-for-user) FGA check against a model with
no `type agent` authorized as the delegating user ALONE. That silently drops
the agent half of perms(agent) n perms(user) - the Confused Deputy the
intersection exists to prevent - at the moment the control is least able to
defend itself. A check that cannot be evaluated is not a check that passes.
Denies now; --fga-allow-unconstrained-agents restores the old behaviour for
operators mid-migration, logged on every use and still metered so the
exposure shows on a dashboard rather than during an incident.
AUDIT-12: signup answered a taken address with a distinct error and a free
one with "check your inbox" - an account-existence oracle usable to build
targeted phishing lists. Timing was already equalised; the response shape was
not. Both paths now return one shared constant. Only closable when email
verification is on: with it off a real signup answers with tokens, so a
collision differs by shape whatever the message says.
Lows:
- AUDIT-17 password change revokes sessions synchronously and checks the
error, matching reset_password. Fire-and-forget left a window where an
attacker's pre-existing token still worked after the response went out.
- AUDIT-18 CI assertion that no method is both `public` and carrying a
delegated scope. Such a method skips the scope check on gRPC/REST/MCP while
GraphQL still enforces it. Only Meta qualifies today and it is read-only;
this catches the next write method that gets a public fast-path.
- AUDIT-19 EncryptB64/DecryptB64 deleted, callers moved to Encode/Decode. A
name saying "Encrypt" over a reversible keyless transform invites routing a
real secret through it.
- AUDIT-20 NewHMACKey no longer builds a JWK. An HMAC key is symmetric, so
its "public" JWK is {"kty":"oct","k":"<the signing secret>"} sitting in the
public-key slot; only a filter in the JWKS handler kept it unserved.
- AUDIT-21 bcrypt cost 12 for new hashes. Write-side only - the cost lives
inside each hash string, so existing cost-10 hashes keep verifying and
nobody is locked out.
AUDIT-16 and AUDIT-22 needed no change: the --url startup warning already
cites GHSA-m82j-rq33-qjx2, and govulncheck already runs on PRs and weekly.
AUDIT-13 declined by decision, recorded at cookie.BuildSessionCookies.
…ations The passkey bug (a verification click landing on the MFA gate never wrote email_verified_at) survived because each flow was tested in isolation and the invariant ACROSS them was not. These pin it: whenever a principal has proven control of their address - clicked a mailed link, redeemed a mailed OTP, or the operator disabled verification entirely - the account ends with email_verified_at set. Anything else strands the user in a state they cannot escape, since every recovery route terminates at the same mailbox. Covers email signup under both verification and MFA settings (4 cells, including the one that produced the bug), magic-link signup under both MFA settings, login before/after verification, resend across pending / not-pending / already-verified, phone verification staying independent of email, and the downstream passkey consequence rather than just the column. Also moves the email-attestation contract cases into the per-provider spec files. mock-oauth holds ONE profile per provider globally, so two spec FILES driving the same provider race under parallel workers - one provider per file is what keeps the suite order-independent.
Three problems, all of which produce red builds that have nothing to do with the code under test. Prerequisites were `test-cleanup test-docker-up test-cleanup`. The trailing duplicate read as "tear down afterwards" but never ran: make deduplicates prerequisites, so it silently collapsed into the leading one. Teardown now lives in the recipe, where it can also run on failure. The recipe was two lines - `go test ...` then `$(MAKE) test-cleanup`. A failing test aborted the recipe before cleanup, leaking seven containers and leaving 5434/27017/9042/8529/8000/8091 bound, so the NEXT run could not start them and failed for a second, unrelated reason. Now captures the test status, always tears down, and exits with the TESTS' status - the same shape the e2e-playground target already uses. test-docker-up ended in `sleep 5`, which is a guess, not a readiness check. ScyllaDB routinely needs 30-60s before it accepts CQL, so on a cold or loaded machine the storage tests ran against a still-booting database. Replaced with scripts/wait-for-test-dbs.sh: a bounded per-port wait that fails the run outright if a container never comes up, rather than letting the suite discover it as a mystery connection error. Also adds the principal-class matrix (SSO/SAML, M2M, A2A). The identity invariant differs per class - SSO/SAML key on (org, issuer, subject) and never link by email, machine tokens carry no user identity at all, and a delegated token keeps the USER as `sub` with the agent in `act`. Treating these as one class is what let the social path drift into resolving accounts by email.
test-all-db was fixed in the previous commit; test-postgres, test-mongodb, test-scylladb, test-arangodb, test-dynamodb and test-couchbase carried the identical two defects and were left behind. Each guessed at readiness with a bare `sleep` - 3s for postgres/mongo/dynamo, 5s for arango, 15s for scylla, which routinely needs 30-60s before it accepts CQL. On a cold or loaded machine the suite ran against a still-booting database and failed for reasons unrelated to the code. Each also had `docker rm -vf` on its own recipe line, so a failing test aborted the recipe before teardown, leaving the port bound and breaking the NEXT run for a second, unrelated reason. All six now wait on a bounded per-port poll and always tear down, exiting with the TESTS' status. wait-for-test-dbs.sh takes an optional service list so a single-backend target does not block on six containers it never started; couchbase keeps its own provisioning script and gains only the teardown fix. Verified end to end with `make test-postgres`: readiness reported, suite green, container removed, exit 0.
Adding PasswordHashCost above EncryptPassword stranded that function's doc comment on the new const, which staticcheck (ST1022) caught.
…ntly using lax cookie.ParseSameSite falls back to Lax for anything it does not recognise. Safe, but silent: an operator who asks for `strict` and mistypes it gets Lax - a real downgrade from what they requested, with nothing anywhere to say so. A mistyped `none` withholds the session cookie from cross-site apps instead, which presents as "login randomly doesn't stick" with the cause three layers away. Validated at startup now, alongside ValidateEncryptionKey, so a typo in a startup flag stops the process rather than quietly selecting a policy the operator did not choose. Verified live: an invalid value exits 1 with the reason, `strict` boots through to "Starting HTTP server". Also documents and guards the one place that deliberately IGNORES this setting. BuildOAuthStateCookie hardcodes Lax/None because the provider's callback arrives as a cross-site redirect (a cross-site form_post for Apple), and Strict would withhold the state cookie on exactly those - every social login on a strict-configured deployment would fail with "invalid oauth state". Threading the operator's setting through for consistency is the obvious refactor and a silent outage; TestOAuthStateCookieIsNeverStrict now fails if anyone tries.
Reported from local testing of this PR: sign up, click the button in the verification email, land on MFA setup, enrol a passkey - and passkey login then refuses forever with "email is not verified", for a user who verified. GET /verify_email is what the emailed button literally points to, and it is a SEPARATE implementation from service.VerifyEmail. The earlier commit moved the email_verified_at write above the MFA gate in the service; the REST handler - the path every real user takes - still had the write after the gate, whose withheld branch redirects to MFA setup and returns. Why it hid: only WebauthnLoginVerify checks the column. Password login checks it too but SELF-HEALS - it diverts an unverified user into an email OTP, and verify_otp sets the flag. So TOTP and email-OTP users appeared fine while silently completing a second, redundant verification round-trip. A passkey user never passes through password login, so nothing repaired the flag and the hard check refused. Passkey was not the broken path; it was the only honest one. Remediation needs no backfill: DeleteVerificationRequest also sat after the gate, so affected users' verification requests were never consumed. Clicking the link again works, and resend_verify_email mints a fresh one if it expired. The regression test drives the real REST path and asserts the gate actually withheld (307 -> mfa_gate=offer) before checking the stored state, so it cannot pass by accidentally taking the non-withheld branch.
This was referenced Aug 7, 2026
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.
Pre-release security audit remediation, part 2 of 2. Stacked on #748 (merged).
Closes the remaining findings: AUDIT-06 through AUDIT-22.
With this, all 22 findings are resolved.
Findings closed
statenot bound to the initiating browser (login CSRF)client_id(RFC 6749 §4.1.3)at_hash/c_hashhard-coded to SHA-256 regardless ofalgAllow-CredentialspublicgRPC methods bypass delegated-scope enforcementAUDIT-13 (session-cookie SameSite/domain) was reviewed and declined — see below.
AUDIT-16 and AUDIT-22 needed no change: the
--urlstartup warning already citesGHSA-m82j-rq33-qjx2, and
govulncheckalready runs on PRs and weekly.Highlights
AUDIT-06.
statewas server-generated and stored globally, so the callback couldonly prove some flow issued it, never that this browser did. An attacker harvests
their own valid
code+stateand delivers it to a victim, logging the victim into theattacker's account; anything the victim then enters lands somewhere the attacker
controls. A host-only HttpOnly cookie now binds the flow to the browser that started it.
SameSiteisNonewhen Secure, matching the MFA cookie — Apple returns its callback asa cross-site
form_post, and a Lax cookie is not sent on one. The cookie is read backurl-unescaped because gin escapes on write and nothing reverses it on read.
AUDIT-07. Codes were bound to a
redirect_uribut not to an identity, so twoconfidential clients sharing a redirect origin could redeem each other's codes. The token
endpoint now compares against the authenticated client, not the raw body field —
clients may authenticate via Basic or a client assertion and send no body
client_idatall.
Both findings needed a new field in a positional
@@-delimited blob that was hand-builtand hand-parsed at ~12 sites across
http_handlersandservice.internal/codestatenow owns both formats; decoding is length-guarded per field, so codes minted by an older
build stay redeemable across a deploy.
AUDIT-08. A delegated (agent-acting-for-user) FGA check against a model with no
type agentauthorized as the delegating user alone — silently dropping the agent halfof
perms(agent) ∩ perms(user), which is the Confused Deputy the intersection exists toprevent. A check that cannot be evaluated is not a check that passes. It denies now;
--fga-allow-unconstrained-agentsrestores the old behaviour for operators mid-migration,logged on every use and still metered.
AUDIT-10. Tokens were stored verbatim, so anyone who could read the store — a Redis
dump, a replica, a backup, an SSRF into the cache — walked away with live, directly
replayable tokens. Stored as SHA-256 digests now, with dual-read on the way out so no
live session or refresh token drops on deploy. Three comparison sites moved with it, one
of which (
revoke.go) was a plain!=against a live refresh token rather than aconstant-time compare.
AUDIT-12. Timing was already equalised; the response shape was not — a distinct error
for a taken address versus "check your inbox" for a free one is an account-existence
oracle. Both paths now return one shared constant. Only closable when verification is on:
with it off a real signup answers with tokens, so a collision differs by shape whatever
the message says.
Also fixed: a user-reported bug (not from the audit)
A user who clicked their verification link was told permanently that their email was
not verified, most visibly by passkey login. The
email_verified_atwrite sat after theMFA gate, and all three gate branches return early — and MFA is on by default, so a fresh
signup's verification click lands on the setup screen and the write never happened. The
user had done everything right; the proof of mailbox control was discarded because MFA
interrupted session issuance.
Recorded as soon as the token is proven now. Also affected magic-link and invite flows
through the same function.
AUDIT-13: reviewed and declined
The audit wanted
SameSite=Lax+ host-only session cookies. Both were kept:.example.com) twin is what letsapp.example.comsee a sessionestablished at
auth.example.com— dropping it breaks subdomain SSO outright;SameSite=Noneis what lets an app on a different site complete a credentialed/sessioncall at all.Auth0 lands in the same place: it recommends
SameSite=Nonefor cross-originauthentication, ships fallback cookies for browsers that cannot do it, and answers
third-party-cookie blocking with Custom Domains — auth server on the customer's own
subdomain, which is exactly this topology.
The reasoning is now recorded at
cookie.BuildSessionCookieswith guard tests ininternal/cookieandcmd, so it fails loudly rather than being silently "fixed" later.Review follow-up: the
strictSameSite pathRaised in review, and both halves were real.
--app-cookie-same-sitewas never validated.cookie.ParseSameSitefallsback to Lax for anything it does not recognise — safe, but silent. An operator
who asks for
strictand mistypes it gets Lax: a genuine downgrade fromwhat they requested, with nothing anywhere to say so. A mistyped
nonegoes theother way and withholds the session cookie from cross-site apps, presenting as
"login randomly doesn't stick" with the cause three layers away. Now validated
at startup next to
ValidateEncryptionKey. Verified live — an invalid valueexits 1 with the reason,
strictboots through to "Starting HTTP server".One place deliberately ignores the setting, and nothing said so.
BuildOAuthStateCookie(new in AUDIT-06) hardcodes Lax/None. That is acorrectness requirement, not a preference: the provider's callback arrives as a
cross-site redirect — a cross-site
form_postfor Apple — and Strict withholdsthe cookie on exactly those, so every social login on a strict-configured
deployment would fail with "invalid oauth state". Threading the operator's
setting through "for consistency" is the obvious refactor and a silent outage.
Documented, and
TestOAuthStateCookieIsNeverStrictnow fails if anyone tries.Build tooling:
make test-all-dband every per-DB targetNot in the audit, but these produce red builds that have nothing to do with the
code under test, and two of them bit this branch directly.
docker rm -vfsat on its own recipeline, so a failing test aborted the recipe before cleanup, leaving ports bound
and breaking the next run for a second, unrelated reason. One real bug
became two confusing ones.
sleep 3/sleep 5/sleep 15instead of a check.ScyllaDB routinely needs 30-60s before accepting CQL, so on a cold or loaded
machine the storage tests ran against a still-booting database.
test-all-dbhad a prerequisite that never ran. It listedtest-cleanup test-docker-up test-cleanup; the trailing duplicate read as"tear down afterwards" but make deduplicates prerequisites, so it silently
collapsed into the leading one.
All seven targets now wait on a bounded per-port poll (
scripts/wait-for-test-dbs.sh,which fails the run outright if a container never comes up) and always tear down,
exiting with the tests' status.
Testing
New:
internal/codestate(format + legacy-blob decoding),at_hashper-alg digests, CORSheader matrix, cross-client code redemption, FGA fail-closed + opt-out + nil-config,
signup enumeration, and a signup × email-verification matrix (15 cells) that pins the
invariant the passkey bug slipped through — each flow was tested in isolation, the
invariant across them was not.
Fail-before/pass-after was confirmed by reverting each fix individually. Notably, the
nOAuth vulnerability was reproduced live against a stale image during e2e (callback
returned 302 + a session for an unattested address; 400
email_not_verifiedafter).Also restores one-provider-per-spec-file in the social e2e:
mock-oauthholds one profileper provider globally, so two spec files driving the same provider race under parallel
workers.
Principal-class matrix. The identity invariant is different per principal class, and
treating them as one is what let the social path drift into resolving accounts by email
while the SSO path (correctly) never did:
(org, issuer, subject); email never selects an accountsubstays the user; the agent rides inactEach row now has tests. SSO/SAML: email never links whether attested or not, the subject
survives an upstream email change, and the same subject under a different org is a
different principal. M2M: no
email/email_verified/rolesclaims ride along — thosewould let user-scoped gates evaluate against a zero value, and "is this verified?" checks
tend to fail open on zero. A2A:
sub=agent would silently drop every user-scoped gate,and a missing
actloses the agent half of the FGA intersection.Verified
make lint(0 issues),go build,go vet, 42 Go packages, 86/86 Playwright at defaultparallelism, and
make test-all-dbacross all seven backends — each with its real exitcode checked, not a pipeline's.
Breaking changes
--enable-email-verificationwith no SMTP now fails at boot. Every recovery routeterminates at the same mailbox, so without a mail path a user is created unverified and
can never recover.
type agentis now denied. Addtype agent,or set
--fga-allow-unconstrained-agentswhile migrating.Allow-Credentialsand returns a literal*ratherthan the caller's Origin. Credentialed CORS requires an explicit allow-list.
string, so existing cost-10 hashes keep verifying and nobody is locked out.
crypto.EncryptB64/DecryptB64removed (callers moved toEncodeB64/DecodeB64);NewHMACKeyno longer returns a JWK.