fix(auth): close the backslash open-redirect bypass; finish the P1 matrix (SEC-05, SC-12, MT-05) - #32
Merged
Merged
Conversation
…nue (SEC-05, SC-12)
`sanitize_continue` is the open-redirect guard: a `continue` target is
returned as-is only when it looks like a relative path — starts with `/`,
contains no `//`. That `//` test ran against the raw string, and it is not
what a browser checks.
WHATWG URL parsing treats `\` exactly like `/` while resolving a *special*
(http/https) URL — "relative slash state": if c is U+002F (/) or U+005C (\),
go to special authority ignore slashes state. So a browser reads
`Location: /\evil.example/x` as `//evil.example/x`, i.e. scheme-relative,
and navigates off-site. `/\evil.example` and `/\/evil.example` both walked
straight past the guard and were emitted verbatim.
Crystal's own parser disagrees with the browser here —
`URI.parse("/\\evil.example").host` is nil — so a spec asserting through
`URI.parse` would have called it harmless. The new specs resolve `Location`
the way a browser does instead.
Normalising backslashes before the check pushes those inputs down the
absolute-URL branch, where the authority-host comparison rejects them.
Reachable on `POST /auth/signin`, `GET /auth/logout`, and — because
`set_continue` sanitises on write and the callback replays the stored value
without re-checking — parked on the session and fired after a *successful*
SSO login.
Coverage (SEC-05, SC-12): a table-driven sweep of 7 hostile targets across
signin, logout, login and the provider hand-off, including the `//`,
`/\`, `/\/`, absolute, host-suffix (`localhost.evil.example`) and userinfo
(`localhost@evil.example`) forms; the safe cases that must keep working
(relative path, `//` inside a query string, same-host absolute reduced to a
path); and the full three-hop SSO round trip in
`oauth_provider_flows_spec.cr`.
Red-checked: without the normalisation, the signin sweep, the logout sweep
and the SSO round-trip fail; with it, all 308 specs pass.
Also pins, without changing, the one place an unsanitised value still
reaches `Location`: `login_failure` reflects `Referer` verbatim. That is
Ruby parity (`dropin-audit.md:22`), the exposure needs the victim to
already be on the attacker's page, and restricting it to same-host would be
a product decision about embedded login — the session cookie is
deliberately `SameSite=None` so a cross-site Referer can be a legitimate
embedder the retry should return to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`/auth/authority` is the most-called endpoint in the estate — every SPA polls it (1,125 hits in the dev sample) — and Ruby's `AuthoritiesController#current` re-issued the nginx-validated `verified` cookie whenever the caller's credential checked out. That is how a token-only client with no session cookie keeps its asset access alive; without it those clients bounce through `/auth/login` once the cookie lapses. The response *shape* was covered, this behaviour was not. Four cases: re-issued for a Bearer token, re-issued for an X-API-Key, and NOT issued to an unauthenticated caller (the control — without it the first two would pass even if the cookie were issued unconditionally). The fourth pins the ordering. `configure_asset_access` runs *after* `signed_in?` deliberately: resolving a dead session calls `remove_session` -> `clear_asset_access`, which writes the cleared cookie. Reversed, a client holding a logged-out session cookie *and* a good token gets handed the expired cookie while being told `token_valid: true`, and bounces to /auth/login on its next asset fetch. The comment said so; nothing enforced it. Moving the call above `signed_in?` now fails this spec and only this spec. The assertion checks the live cookie *shape* (16 hex . 64 hex HMAC, future expiry) rather than mere presence — `clear_asset_access` writes the same name and path, so a presence check would pass on a cleared cookie. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
The defect
sanitize_continueis the open-redirect guard for everycontinuetarget. It returns a value as-is when it looks like a relative path — starts with/, contains no//. That//test ran against the raw string, and it is not what a browser checks.WHATWG URL parsing treats
\exactly like/while resolving a special (http/https) URL — relative slash state: if c is U+002F (/) or U+005C (\), go to special authority ignore slashes state. So a browser readsas
//evil.example/x— scheme-relative — and navigates off-site. Both/\evil.exampleand/\/evil.examplewalked past the guard and were emitted verbatim.Crystal's parser disagrees with the browser here —
URI.parse("/\\evil.example").hostisnil, i.e. it reports the hostile value as harmless. A spec asserting throughURI.parsewould have confirmed the bug as safe. The new specs resolveLocationthe way a browser does instead.The fix normalises backslashes before the check, pushing those inputs down the absolute-URL branch where the authority-host comparison rejects them.
Where it was reachable
POST /auth/signin—sanitize_continue(continue) || "/"GET /auth/logout— same guardset_continuesanitises on write andprovider_callbacks.cr:195replays the stored value without re-checking, so a hostile continue could be parked on the session and fired after a successful loginCoverage
SEC-05 / SC-12 — a table-driven sweep of 7 hostile targets across signin, logout, login and the provider hand-off:
//evil.example/x/\evil.example/x/\/evil.example/xhttps://evil.example/x,http://evil.example/xhttps://localhost.evil.example/xhttps://localhost@evil.example/x…plus the safe cases that must keep working (relative path preserved byte-for-byte,
//allowed inside a query string, same-host absolute reduced to its path), and the full three-hop SSO round trip inoauth_provider_flows_spec.cr.The authorize grant redirect is already constrained to a registered
redirect_uri—authorize_validation_spec.cr(AU-03/AU-04) andauthorize_flow_spec.cr(deny path) — so it is referenced, not duplicated.MT-05 — the
verifiedasset-access cookie on/auth/authority, the most-called endpoint in the estate. Re-issued for a Bearer token and for an X-API-Key, not issued to an unauthenticated caller, and — the subtle one — a stale session's teardown must not clobber the fresh cookie.configure_asset_accessruns aftersigned_in?deliberately, because resolving a dead session callsremove_session→clear_asset_access. Reversed, a client holding a logged-out session cookie and a good token gets the expired cookie while being toldtoken_valid: true, then bounces to/auth/loginon its next asset fetch. The comment said so; nothing enforced it. Moving the call now fails that spec and only that spec.One thing pinned rather than fixed — wants your call
login_failurereflects theRefererheader verbatim intoLocation(sessions.cr:195). I did not change it:dropin-audit.md:22records the legacylogin_failure -> redirect_to referer;SameSite=Nonefor embedded login, so a cross-siteRefererhere can be a legitimate embedder that the retry is meant to return to. Restricting it to same-host is a product decision about embedded login, not pure hardening.It is now pinned by a spec that states all of the above, so the behaviour is a reviewed choice rather than an accident. Say the word and I'll restrict it.
Verification
configure_asset_accessabovesigned_in?fails the stale-session spec alone. Nothing else moves in either case.crystal tool format --check src specclean.Matrix state after this PR
Every P1 row is now closed except three, all blocked on something outside the repo: ID-13 (needs dev SSO credentials), CFG-03 and CFG-05 (need the parked helm/compose changes).
Two P1 rows were closed outside auth.cr and are not in this diff: SC-06 is now automated in
tasks/PPT-2536/config-parity/check.sh(compose passes — auth and nginx share.env.secret_key; helm WARNs, because there is no nginx chart in k8s-helm at all, so the pairing is unverifiable from committed config — same root cause as CFG-02).And a correction worth flagging: I re-read every P1 row marked ❌/◐ against the specs, and 17 of them were already covered —
ID-03(SAML signature enforcement) was marked "❌ deferred" while carrying five assertions including defect 1's own regression test. Those are now markedVERIFIED-COVEREDwith the covering example named.🤖 Generated with Claude Code