Skip to content

security: complete the 2.4.0 pre-release audit (AUDIT-06 to AUDIT-22) - #751

Merged
lakhansamani merged 9 commits into
mainfrom
security/2.4.0-audit-part-2
Aug 7, 2026
Merged

security: complete the 2.4.0 pre-release audit (AUDIT-06 to AUDIT-22)#751
lakhansamani merged 9 commits into
mainfrom
security/2.4.0-audit-part-2

Conversation

@lakhansamani

@lakhansamani lakhansamani commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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

# Sev Finding
AUDIT-06 Medium OAuth state not bound to the initiating browser (login CSRF)
AUDIT-07 Medium Authorization code not bound to client_id (RFC 6749 §4.1.3)
AUDIT-08 Medium Agent FGA attenuation silently failed open
AUDIT-09 Medium at_hash/c_hash hard-coded to SHA-256 regardless of alg
AUDIT-10 Medium Access/refresh tokens stored in plaintext in the session store
AUDIT-12 Medium Signup response enabled account enumeration
AUDIT-15 Medium CORS reflected an arbitrary Origin with Allow-Credentials
AUDIT-17 Low Password change revoked sessions fire-and-forget
AUDIT-18 Low public gRPC methods bypass delegated-scope enforcement
AUDIT-19 Low "Encrypt"-named helpers were plain base64
AUDIT-20 Low HMAC symmetric key marshaled into a JWK
AUDIT-21 Low bcrypt cost 10

AUDIT-13 (session-cookie SameSite/domain) was reviewed and declined — see below.
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.

Highlights

AUDIT-06. 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 somewhere the attacker
controls. 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.

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. 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_id at
all.

Both findings needed a new field in a positional @@-delimited blob that was hand-built
and hand-parsed at ~12 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-08. A delegated (agent-acting-for-user) FGA check against a model with no
type agent authorized as the delegating user alone — silently dropping the agent half
of perms(agent) ∩ perms(user), which is the Confused Deputy the intersection exists to
prevent. A check that cannot be evaluated is not a check that passes. It denies now;
--fga-allow-unconstrained-agents restores 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 a
constant-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_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. 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:

  • the domain-scoped (.example.com) twin is what lets app.example.com see a session
    established at auth.example.com — dropping it breaks subdomain SSO outright;
  • SameSite=None is what lets an app on a different site complete a credentialed
    /session call at all.

Auth0 lands in the same place: it recommends SameSite=None for cross-origin
authentication, 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.BuildSessionCookies with guard tests in
internal/cookie and cmd, so it fails loudly rather than being silently "fixed" later.

Review follow-up: the strict SameSite path

Raised in review, and both halves were real.

--app-cookie-same-site was never validated. 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 genuine downgrade from
what they requested, with nothing anywhere to say so. A mistyped none goes the
other 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 value
exits 1 with the reason, strict boots 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 a
correctness requirement, not a preference: the provider's callback arrives as a
cross-site redirect — a cross-site form_post for Apple — and Strict withholds
the 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 TestOAuthStateCookieIsNeverStrict now fails if anyone tries.

Build tooling: make test-all-db and every per-DB target

Not 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.

  • Teardown did not survive failure. docker rm -vf sat on its own recipe
    line, 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.
  • Readiness was a guess. sleep 3/sleep 5/sleep 15 instead 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-db had a prerequisite that never ran. It listed
    test-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_hash per-alg digests, CORS
header 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_verified after).

Also restores one-provider-per-spec-file in the social e2e: mock-oauth holds one profile
per 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:

Class Identity is…
Database the email, proven by clicking a mailed link
Social / OAuth / OIDC the email — so the provider must attest it
SSO / SAML (org, issuer, subject); email never selects an account
M2M the service account — no user, no email
A2A sub stays the user; the agent rides in act

Each 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/roles claims ride along — those
would 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 act loses the agent half of the FGA intersection.

Verified

make lint (0 issues), go build, go vet, 42 Go packages, 86/86 Playwright at default
parallelism
, and make test-all-db across all seven backends — each with its real exit
code checked, not a pipeline's.

Breaking changes

  • --enable-email-verification with no SMTP now fails at boot. Every recovery route
    terminates at the same mailbox, so without a mail path a user is created unverified and
    can never recover.
  • Delegated FGA against a model with no type agent is now denied. Add type agent,
    or set --fga-allow-unconstrained-agents while migrating.
  • Wildcard CORS no longer sends Allow-Credentials and returns a literal * rather
    than the caller's Origin. Credentialed CORS requires an explicit allow-list.
  • 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.
  • crypto.EncryptB64/DecryptB64 removed (callers moved to EncodeB64/DecodeB64);
    NewHMACKey no longer returns a JWK.

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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant