fix(auth): refuse to create a local password on a directory-backed user (SA-7) - #61
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>
15e17b7 to
9c5c1e6
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 |
9c5c1e6 to
c7a8d40
Compare
| if state != "" { | ||
| redirectTarget += "&state=" + url.QueryEscape(state) | ||
| } | ||
| // Re-validate at the SINK. redirectURI is allowlist-checked by every caller |
c7a8d40 to
296aa17
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>
296aa17 to
5f9cbe4
Compare
Third of six. Stacked on #60. The most serious finding in the batch.
The bug
handleResetPasswordgated proof of possession on:That 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
PasswordHashbecause 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 a ROTATE path.Every link verified present:
SAMAccountNameset,PasswordHashempty, and both anldapand alocalmapping.authenticateUserresolves thelocalmapping first — "local users always take priority" — so a planted hash is consulted before AD is contacted at all.validateAccessTokenperforms no audience check, so a token minted for any app reaches this handler.grep -rn userAccountControl→ zero hits), so the planted hash outlives disablement, password rotation and termination.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.
Approach
Gate on the empty hash before any bcrypt work and refuse with 403 — directory-backed gets "password is managed by the directory", 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.isDirectoryBackedcombines three signals because none is complete alone:OwnerAppID != ""short-circuits to app-local;SAMAccountName != ""is a reliable positive but is empty forimport-usersaccounts until first login; and any mapping whose provider is neitherlocalnorapplocal:<app_id>. It fails closed on a store error.The empty-hash gate can precede the force-change branch because
ForcePasswordChange's writers all assign a real hash immediately before setting the flag —TestResetPasswordForceChangeStillWorksasserts that invariant directly rather than trusting the reading.Master-admin path unchanged.
PUT /api/admin/users/{guid}/passwordmay still set a local password on a directory user — break-glass is legitimate — but the audit record now carriesdirectory_backed, which is how an operator later distinguishes it from a takeover.Nothing records the provenance of a password hash, so a migration cannot distinguish a maliciously planted credential from a legitimate admin-set one. Operators should audit for directory users carrying a local hash and clear the ones they cannot account for.
Tests
The directory-user test drives the full chain — plant, assert no hash was written, then assert the credential does not authenticate — rather than only checking a status code. Plus the unchanged local rotation (400 without / 403 wrong / 200 correct, and the new password works), the force-change flow, and the classification table. The two refusal tests return
200 {"status":"password updated"}with the fix reverted.🤖 Generated with Claude Code