fix(migrate): refuse a bundle that claims another app's audience (H15) - #62
Conversation
…edirect (H13)
renderOIDCLoginError rebuilt the authorize URL by hand with fmt.Sprintf and
carried client_id, redirect_uri, state, nonce and scope — but NOT code_challenge
or code_challenge_method. One mistyped password therefore disabled PKCE for the
rest of the login:
failed POST -> error redirect without PKCE
-> showOIDCLoginPage reads an empty challenge, stamps empty fields
-> the successful retry stores OIDCAuthCode.CodeChallenge = ""
-> the token endpoint's `if ac.CodeChallenge != ""` guard is false
-> the code redeems with NO code_verifier
That undoes M6 for the remainder of the login, so an intercepted code (referrer
leak, malicious app on the redirect host) is redeemable by anyone.
Approach: introduce oidcAuthzRequest, a typed allowlist that is the single
definition of "the authorize request", plus parseOIDCAuthzRequest and values().
renderOIDCLoginError and the Kerberos ssoLink are both built from it now, so a
parameter added there is carried at every hop rather than having to be remembered
at each hand-concatenated site. This is the fourth instance of "app context
dropped at a redirect hop that rebuilds a URL from scratch" found in this
codebase; the type exists to make it the last.
Deliberately NOT a copy of r.Form. renderOIDCLoginError runs on a CREDENTIAL
POST — the body carries username and password. Copying and mutating the form
would put live credentials in a Location header, browser history, and every proxy
log on the path. r.URL.Query() is equally wrong: the form posts to the bare
authorize path, so the query is empty and a query-copy carries nothing silently.
Two invariants preserved and now asserted:
- the error always returns to SimpleAuth's OWN authorize endpoint. The
empty-credentials branch reaches this function BEFORE redirect_uri has been
checked against the app's allowlist, so bouncing to it would be an open
redirect (the OIDC sibling of F29).
- username / password / _csrf are never carried; showOIDCLoginPage mints a fresh
CSRF token and cookie per render (F30).
Also fixed, same defect class: handleLogout dropped client_id, dead-ending the
documented logout round-trip on a 400 for any app with its own redirect_uris.
Tests (internal/handler/oidc_pkce_test.go):
- TestOIDCPKCESurvivesFailedLogin drives the whole chain and asserts the
post-retry code is REJECTED without a verifier and accepted with the correct
one — the assertion that actually pins the vulnerability.
- TestOIDCLoginErrorPreservesAuthorizeRequest pins every parameter in the
allowlist plus the no-credential-leak invariant.
- TestOIDCLoginErrorWithNoCredentials pins the open-redirect invariant.
- TestLogoutPreservesClientID pins the logout round-trip end to end.
Three of the four fail with the fix reverted. Full suite green.
Not fixed here: handleSSOLogin's SPNEGO Negotiate-retry URL drops every
parameter including the client_id this change adds to ssoLink. That is a design
decision about the challenge-retry shape rather than a parameter carry — filed
separately.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…4, M38)
H14 — the forward bucket (provider:externalID -> guid) is authoritative and
bucketIdxMappingsByGUID is only a derived reverse index, but Bolt's
SetIdentityMapping overwrote the forward entry and called addMappingToIndex
without ever retracting the claim from the PREVIOUS owner. After a re-point both
GUIDs claimed the same identity.
Repro: alice signs in via LDAP and is JIT-provisioned as U1 (owning ldap:alice
AND local:alice). An admin later creates a local account for the same person via
POST /api/admin/users, which calls SetIdentityMapping("local","alice",U2).
Forward map says U2; U1's index still claims local:alice.
- resolvePreferredUsername reads the reverse index, so U1's access and ID
tokens carry preferred_username "alice" — the STANDARD claim — while the real
owner of that name is U2. An RP that authorizes on preferred_username grants
U1 alice's access.
- handleDeleteLocalUser and DeleteApp iterate GetMappingsForUser and delete
forward keys, so deleting U1 removes U2's live login identity.
Fix: read the incumbent before the Put and, when it differs, remove the mapping
from the old owner's index in the SAME transaction. prevOwner == userGUID is
skipped so re-setting a mapping stays idempotent — a naive remove-then-add would
strip the entry addMappingToIndex had just written.
Postgres needed NO change; verified correct. ON CONFLICT (provider, external_id)
DO UPDATE makes the single row the whole truth and it has no derived index to
drift. This restores backend parity rather than adding a Bolt-specific behaviour.
Existing data: repairMappingIndex runs at OpenBolt and prunes reverse-index
entries the forward bucket no longer backs. Prune-only, never rebuild —
reconstructing from forward keys would have to re-split the ambiguous composite
key (M38) and would corrupt the exactly-recorded applocal:<appID> providers the
index already holds correctly.
Because this deletes identity data at startup on data we have never seen, each
pruned claim is logged individually (guid, provider, external id) rather than
merely counted, so a wrong prune is reconstructible from the log, and
SA_SKIP_MAPPING_REPAIR=1 reports without writing. A non-zero prune count on a
deployment nobody believed was corrupt is a stop-and-investigate signal.
Defense in depth: DeleteApp now verifies the forward entry still belongs to the
GUID being cascaded before deleting it, so if the index ever drifts again the H8
cascade cannot destroy somebody else's live mapping. Postgres cascades by
WHERE user_guid IN (...), which is inherently owner-scoped — this makes Bolt
identical.
M38 — the forward key is provider + ":" + externalID and BOTH halves may contain
':' (app-local users are keyed under provider "applocal:<appID>", and
handleSetMapping accepts an arbitrary provider). ListAllMappings and
MigrateToPostgres both split on the first ':', reading applocal:billing:bob as
provider "applocal" / external id "billing:bob". The migration consequence is
silent and severe: those corrupted halves land in sa_identity_mappings, after
which ResolveMapping("applocal:"+appID, username) — the app-local login lookup —
can never match again. Row-count verification still passes, because the mapping
is 1:1 either way; only the column boundary moves.
Fix: decompose via the reverse index, which records both halves verbatim
(mappingSplits / splitMappingKey), falling back to the first ':' only for a key
the index does not cover — which only happens in already-corrupt data, where
falling back beats dropping the row.
Tests (internal/store/mapping_index_test.go): re-point retracts from the previous
owner and leaves that user's own mappings alone; re-setting the same mapping is
idempotent; applocal:<appID> round-trips through ListAllMappings; the repair
prunes an injected stale claim while leaving legitimate ones and the real owner
untouched; SA_SKIP_MAPPING_REPAIR=1 reports without writing; DeleteApp cascades
the app's own mappings (H8 holds) but not a victim's live one. The re-point test
fails with the writer reverted. Full suite green.
Behaviour change worth noting in review: correcting ListAllMappings makes
resolveUserRef's "ambiguous user" branch newly reachable on Bolt for a name that
exists as both local:<n> and applocal:<app>:<n> with different GUIDs. That turns
a previously-succeeding app-admin grant into an error — convergence toward
Postgres behaviour, but a real Bolt-only change.
Not covered by tests: migrateKV needs a live Postgres and this repo has no
Postgres harness. Verify by hand before relying on it (procedure in
SECURITY-AUDIT.md).
Not fixed here: MergeUsers is a fourth consumer of the reverse index and
blind-Puts forward keys without an ownership check. Correct once the index is
clean, which the repair ensures, but it should get the same guard.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
e8ea58d to
513c7ac
Compare
CodeQL note — the flagged alerts are pre-existing patterns, re-attributedCodeQL reports "new alerts in code changed by this pull request". These are alerts that already exist on Baseline on Per rule:
Happy to dismiss them individually, or — probably more useful — treat the 52 🤖 Generated with Claude Code |
513c7ac to
4cf6db5
Compare
| if state != "" { | ||
| redirectTarget += "&state=" + url.QueryEscape(state) | ||
| } | ||
| // Re-validate at the SINK. redirectURI is allowlist-checked by every caller |
4cf6db5 to
6e25cf9
Compare
…er (SA-7)
handleResetPassword gated proof of possession on
user.PasswordHash != "" && !user.ForcePasswordChange
which conflates two orthogonal questions — "is there a local credential to
prove?" and "is this account allowed to have one at all?". A directory-backed
user has an EMPTY PasswordHash because their credential lives in AD, so the whole
verification block was skipped and the handler unconditionally WROTE a fresh
bcrypt hash. The endpoint was a CREATE path, not merely a ROTATE path.
Every link of the chain is present:
- LDAP and Kerberos JIT provisioning create the user with SAMAccountName set,
PasswordHash empty, and BOTH an ldap and a local identity mapping.
- authenticateUser resolves the local mapping FIRST ("local users always take
priority"), so a planted hash is consulted before AD is contacted at all.
- validateAccessToken performs no audience check, so a token minted for ANY
registered app reaches this handler.
- Nothing mirrors AD account state (grep -rn userAccountControl: zero hits), so
the planted hash outlives disablement, password rotation and termination.
- A failed local check falls through to the LDAP step, so the victim's AD
password keeps working and nothing looks wrong.
- The only enforcement was CLIENT-SIDE: the account page hides the form when
auth_source === 'ldap'.
Net effect: one stolen short-lived, audience-scoped bearer token converts into a
permanent primary credential for that user at every app, defeating AD
offboarding.
Fix: gate on the empty hash BEFORE any bcrypt work and refuse with 403 —
directory-backed gets "password is managed by the directory", and an account with
no local password at all gets "ask an administrator". Both are 403 rather than
"supply current_password", because a directory user has no local password to
prove; demanding one would be an unsatisfiable 400 loop instead of an honest
answer. The second refusal locks out nobody: authenticateUser requires a
non-empty hash in every local branch, so such a user can never log in and can
never hold a token of their own.
isDirectoryBacked combines three signals because none is complete alone:
OwnerAppID != "" short-circuits to app-local (their password genuinely lives
here); SAMAccountName != "" is a reliable positive (written only by
syncUserFromLDAP and the JIT paths — no admin or app API exposes it) but is empty
for import-users accounts until first login; and any mapping whose provider is
neither "local" nor "applocal:<app_id>". It fails CLOSED on a store error.
Deliberately NOT userinfo's plain `!= "local"` test, which would wrongly classify
an app-local customer as a directory user and refuse them their own password
change.
The empty-hash gate can safely precede the force-change branch because
ForcePasswordChange has exactly two writers (handleSetPassword, handleBootstrap)
and both assign a real bcrypt hash immediately before setting the flag.
TestResetPasswordForceChangeStillWorks asserts that invariant directly rather
than trusting the reading.
Master-admin path unchanged: PUT /api/admin/users/{guid}/password may still set a
local password on a directory user — the master key is the top of this trust
model and break-glass is legitimate — but the audit record and log line now carry
directory_backed, which is how an operator distinguishes deliberate break-glass
from an account takeover after the fact.
NOT cleaned up: already-planted hashes. Nothing records the provenance of a
password hash, so a migration cannot tell a maliciously planted credential from a
legitimate admin-set one. Operators upgrading should audit for directory users
carrying a local hash and clear the ones they cannot account for — noted in
SECURITY-AUDIT.md.
Tests (internal/handler/reset_password_test.go): the directory-user test drives
the FULL chain — plant, assert no hash was written, then assert the credential
does not authenticate — rather than only asserting the status code. Plus the
no-local-password case, the unchanged local-user rotation (400 without / 403
wrong / 200 correct, and the new password works), the force-change flow, and the
isDirectoryBacked classification table including the app-local carve-out. The two
refusal tests return 200 {"status":"password updated"} with the fix reverted.
Full suite green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Apply carried the bundle's audience onto the target app unchecked:
if b.App.Audience != "" { app.Audience = b.App.Audience }
The audience is a security PRINCIPAL, not a label. appAudience() feeds
claims.Audience on every issuance path, and SimpleAuth deliberately does not pin
aud centrally — F51 records that issuer/audience are enforced at the SDK/RP
layer. So the string in aud, verified offline against the shared JWKS, is the
only thing between a token and a victim resource server.
And the migration-token holder is NOT a master admin: guardMigrationCall
authenticates a single-use bearer token scoped to ONE target app, and nothing
restricted the bundle to naming that app's own identity.
Attack: the standalone team POSTs a bundle with app.audience = "billing-api",
catalog.role_permissions = {"admin":["billing:write"]}, and a local user with a
self-chosen password hash and roles ["admin"]. They log in at their own migrated
app and receive aud ["billing-api"], roles ["admin"], signed by the central's
key. The real billing service verifies signature + audience offline and admits
them as a billing admin.
The no-effort variant: Package sets aud = app.AppID when the source home app has
no explicit audience, and ensureDefaultApp creates it with Audience = appID —
"simpleauth" on a stock standalone. Migrating a stock standalone therefore
stamped the target with the CENTRAL's own default-app audience, whose tokens
carry global directory roles. The integration tests encoded this as expected
behaviour; they now set a deliberate source audience via newMigTargetFrom, which
is what a real migration must do.
Fix: resolveCarriedAudience decides the target's audience and refuses a claim on
anyone else's — empty carries nothing (keeps the master-admin-set value); the
target's own audience or app_id is always allowed (so re-running a migration is
idempotent); the central's default app id is refused; anything matching another
registered app's audience or app_id is refused. Trimmed, so whitespace cannot
smuggle a near-collision past the check.
The default-app id is checked EXPLICITLY rather than via ListApps() because
ensureDefaultApp is called only from main.go — on an embedded deployment
(pkg/server) the row can be absent while resolveApp still synthesizes it, so a
ListApps-only check would miss the highest-value target.
Enforced in BOTH Classify (as a Blocked entry, so the existing preflight UI
renders it and Report.OK() is false) and Apply (before its first write, so a
refused bundle leaves nothing behind). Apply re-checks rather than trusting the
dry run because it is reachable on its own and the central's app set can change
between preflight and commit. Report.AudienceToApply surfaces the value in the
dry run and the commit audit entry records it. Fails CLOSED if ListApps errors.
Tests (internal/migrate/audience_test.go): foreign audience refused with nothing
written; app_id collision refused; the central's default-app audience refused
WITH NO SUCH APP ROW PRESENT (the embedded-deployment case); a distinct audience
still carried; re-running the same migration idempotent; whitespace trimmed;
empty carried audience preserves the target's; Classify blocks naming the
conflicting app and reports AudienceToApply on the happy path.
Integration tests: added newMigTargetFrom, which gives the SOURCE's home app a
deliberate audience before packaging, and moved all six migration subtests onto
it. These are //go:build integration and do NOT run under `go test ./...` — they
compile (go vet -tags integration) but were not executed here; run
`make -C test/integration test` before relying on them.
Known, not fixed here: RedirectURIs and CORSOrigins are carried the same
unchecked way (pre-existing). Lesser, because it only affects the app the token
already scopes the holder to rather than a third party's audience — but worth
review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6e25cf9 to
cecdc2d
Compare
Fourth of six. Stacked on #61.
The bug
Applycarried the bundle's audience onto the target app unchecked:The audience is a security principal, not a label:
appAudience()feedsclaims.Audienceon every issuance path, and SimpleAuth deliberately does not pinaudcentrally — F51 records that issuer/audience are enforced at the SDK/RP layer. The string inaud, verified offline against the shared JWKS, is the only thing between a token and a victim resource server.And the migration-token holder is not a master admin:
guardMigrationCallauthenticates a single-use token scoped to ONE target app. Nothing restricted the bundle to naming that app's own identity.Attack: the standalone team POSTs a bundle with
app.audience = "billing-api",catalog.role_permissions = {"admin":["billing:write"]}, and a local user with a self-chosen password hash androles:["admin"]. They log in at their own migrated app and receiveaud: ["billing-api"], roles:["admin"]signed by the central's key. The real billing service verifies signature + audience offline and admits them as a billing admin.The no-effort variant:
Packagesetsaud = app.AppIDwhen the source home app has no explicit audience, andensureDefaultAppcreates it withAudience: appID—"simpleauth"on a stock standalone. Migrating a stock standalone therefore stamped the target with the central's own default-app audience. The pre-fix integration tests encoded this as expected behavior.Approach
resolveCarriedAudiencerefuses a claim on anyone else's audience: empty carries nothing; the target's current audience is allowed (idempotent re-run); the central's default app id is refused explicitly; anything matching another registered app's audience or app_id is refused. Trimmed, so whitespace cannot smuggle a near-collision.The default-app id is checked explicitly rather than through
ListApps()becauseensureDefaultAppis called only frommain.go— on an embedded (pkg/server) deployment the row can be absent whileresolveAppstill synthesizes it, so aListApps-only check would miss the highest-value target.Enforced in both
Classify(as aBlockedentry, so the preflight UI renders it) andApply(before its first write). Fails closed ifListAppserrors.A bypass an adversarial review caught in the first cut
The self-allow was originally
aud == target.Audience || aud == target.AppID. The app_id half was a hole: nothing enforces audience uniqueness at app creation, so a central can holdApp{AppID:"reports", Audience:"analytics"}while the operator registers the target asApp{AppID:"analytics", Audience:"analytics-migrated"}. Self-allowing the app_id there hands the bundle"analytics"— a third-party RP's live audience. Now only the target's current audience is self-allowed;TestApplyRejectsAudienceMatchingTargetAppIDpins it.Caveats
newMigTargetFromhelper (which gives the source a deliberate audience, as a real migration must). They compile (go vet -tags integration) but this repo's harness needs Docker. Please runmake -C test/integration test.RedirectURIsandCORSOriginsare carried the same unchecked way (pre-existing). Lesser, because it only affects the app the token already scopes the holder to — but worth review.🤖 Generated with Claude Code