Skip to content

fix(auth): refuse to create a local password on a directory-backed user (SA-7) - #61

Merged
alghanim merged 3 commits into
bodaay:masterfrom
alghanim:fix/reset-password-shadow-credential
Aug 9, 2026
Merged

fix(auth): refuse to create a local password on a directory-backed user (SA-7)#61
alghanim merged 3 commits into
bodaay:masterfrom
alghanim:fix/reset-password-shadow-credential

Conversation

@alghanim

@alghanim alghanim commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator

Third of six. Stacked on #60. The most serious finding in the batch.

The bug

handleResetPassword gated proof of possession on:

user.PasswordHash != "" && !user.ForcePasswordChange

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 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 a ROTATE path.

Every link verified present:

  • LDAP and Kerberos JIT provisioning create the user with SAMAccountName set, PasswordHash empty, and both an ldap and a local 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 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.

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.

isDirectoryBacked combines three signals because none is complete alone: OwnerAppID != "" short-circuits to app-local; SAMAccountName != "" is a reliable positive 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.

The empty-hash gate can precede the force-change branch because ForcePasswordChange's writers all assign a real 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 — break-glass is legitimate — but the audit record now carries directory_backed, which is how an operator later distinguishes it from a takeover.

⚠️ Already-planted hashes are NOT cleaned up

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

Your Name and others added 2 commits August 8, 2026 21:24
…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>
Comment thread internal/handler/admin.go Fixed
Comment thread internal/handler/admin.go Fixed
Comment thread internal/handler/admin.go Fixed
Comment thread internal/handler/auth.go Fixed
Comment thread internal/handler/auth.go Fixed
@alghanim
alghanim force-pushed the fix/reset-password-shadow-credential branch from 15e17b7 to 9c5c1e6 Compare August 8, 2026 18:39
@alghanim

alghanim commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

CodeQL note — the flagged alerts are pre-existing patterns, re-attributed

CodeQL reports "new alerts in code changed by this pull request". These are alerts that already exist on master; this PR edits nearby lines, so they get re-attributed. Same phenomenon as the go/reflected-xss alerts on #58.

Baseline on master today:

gh api "repos/bodaay/SimpleAuth/code-scanning/alerts?ref=refs/heads/master&state=open&per_page=100" \
  --jq '[.[]|select(.rule.id|test("log-injection|clear-text-logging|unvalidated-url-redirection"))]|length'
# => 61      (go/log-injection: 52, go/unvalidated-url-redirection: 7, go/clear-text-logging: 2)

Per rule:

  • go/unvalidated-url-redirectionoidc.go:325. This is issueOIDCCodeRedirect's final http.Redirect, pre-existing code this branch does not modify. It is one of the 7 already open on master; the line number moved because the H13 commit added a type above it. The destination is validated upstream by appAllowsRedirect before the code is minted.

  • go/log-injection. 52 already open on master — it is the dominant pattern in this codebase's logging. Where this branch genuinely added one, I removed it: the SA-7 refusal path now logs user.GUID (server-generated, unspoofable) instead of the resolved username, which is better practice for a security event anyway.

  • go/clear-text-loggingadmin.go:272, on ForcePasswordChange. A boolean flag, not a secret. The log line is pre-existing; this branch appends directory_backed=%v to it, which is the detection hook that lets an operator distinguish a deliberate break-glass password-set on a directory user from an account takeover. Dropping it to silence the alert would remove the point of that hunk.

Happy to dismiss them individually, or — probably more useful — treat the 52 go/log-injection alerts as one separate cleanup, since they are a codebase-wide pattern rather than anything these PRs introduce.

🤖 Generated with Claude Code

Comment thread internal/handler/auth.go Fixed
@alghanim
alghanim force-pushed the fix/reset-password-shadow-credential branch from 9c5c1e6 to c7a8d40 Compare August 8, 2026 19:13
Comment thread internal/handler/admin.go Fixed
Comment thread internal/handler/auth.go Fixed
Comment thread internal/handler/oidc.go
if state != "" {
redirectTarget += "&state=" + url.QueryEscape(state)
}
// Re-validate at the SINK. redirectURI is allowlist-checked by every caller
Comment thread internal/handler/admin.go Fixed
Comment thread internal/handler/admin.go Fixed
Comment thread internal/handler/auth.go Fixed
Comment thread internal/handler/auth.go Fixed
@alghanim
alghanim force-pushed the fix/reset-password-shadow-credential branch from c7a8d40 to 296aa17 Compare August 8, 2026 19:20
…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>
@alghanim
alghanim force-pushed the fix/reset-password-shadow-credential branch from 296aa17 to 5f9cbe4 Compare August 8, 2026 19:28
@alghanim
alghanim merged commit 06477c2 into bodaay:master Aug 9, 2026
6 checks passed
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.

2 participants