fix(auth): accept any port on loopback redirect URIs (RFC 8252 §7.3) #3
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "oauth-loopback-port"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
is_allowed_redirect_urirequired exact string equality against the allowlist. Claude Code CLI binds a random free loopback port per session, so the allowlistedhttp://localhost:8787/callbackcould never match and DCR answered400 unregistered redirect_urifor every native loopback client.validate_redirect_urialready carried the loopback carve-out for the scheme; the port half of RFC 8252 §7.3 was never written.Change
src/oauth_redirect.rs— exact string equality is still tried first. On miss, and only when both the request and the allowlist entry are cleartexthttpon a loopback host, the port is ignored and scheme, host, path and query must match exactly.httpsand private-scheme entries keep exact matching including port.localhostdoes not match127.0.0.1; RFC 8252 relaxes the port, not the host./callbackdoes not match/oauth/callback— path comparison is equality, never a prefix.No deployment change: the existing allowlist already carries a loopback entry, which starts matching once the rule is right.
Tests
Four unit tests in
src/oauth_redirect.rsand one end-to-end test intests/http.rsthat registers and authorizeshttp://localhost:3118/callback— the port the failing session actually drew — and asserts DCR still refuseshttp://localhost:3118/oauth/callback,http://127.0.0.1:3118/callbackandhttps://claude.ai:8443/api/mcp/auth_callback.Each was watched go red against a broken matcher before being trusted. Five mutations, five failures:
loopback_port_relaxation_keeps_host_path_and_query_exact,loopback_paths_stay_distinctloopback_port_relaxation_keeps_host_path_and_query_exactnon_loopback_entries_keep_exact_port_matching,allowlist_accepts_private_use_schemesloopback_entry_matches_any_port,loopback_paths_stay_distinctThe integration test reproduces the reported symptom exactly under the last mutation:
DCR must accept http://localhost:3118/callback / left: 400.Gates
All five green locally on rustc 1.96.0:
cargo fmt --all --check,cargo clippy --all-targets --all-features --locked -- -D warnings,cargo test --all-features --locked(204 + 37 + 20 + 10 + 13 = 284 passed, 0 failed),cargo audit(5 pre-existing allowed unmaintained warnings in typst's tree, no advisories),cargo deny check bans licenses sources.Cross-engine review
Codex, read-only, two specific questions. Both confirmed against the code:
redirect_urifor exact equality against the URI bound into the authenticated code (pending.client_redirect_uri != redirect_uri,src/oauth_proxy.rs:426), so a code issued for port 3118 cannot be redeemed on port 9999. Port relaxation does not open a code-interception path.Closes #2
Update after the second commit (
54de8c4)A sibling worker raised two hardenings beyond the issue. One was already in the code, one already in the tests; neither had proof.
loopback_port_relaxation_keeps_host_path_and_query_exact.loopback_relaxation_does_not_cross_schemesnow assertshttps://localhost:3118/callbackdoes not match anhttploopback entry, and the mirror case.Full mutation table, one guard removed at a time
loopback_port_relaxation_keeps_host_path_and_query_exact,loopback_paths_stay_distinct,loopback_callback_registers_and_authorizes_on_any_portloopback_port_relaxation_keeps_host_path_and_query_exactparse_loopback_httploopback_relaxation_does_not_cross_schemesis_loopback_hostguard inparse_loopback_httploopback_entry_matches_any_port,loopback_paths_stay_distinct,loopback_callback_registers_and_authorizes_on_any_portThe
is_loopback_hostcall insideparse_loopback_httpsurvives mutation, and it is not a missing test.validate_redirect_urialready rejects cleartext non-loopback on the request side, and host equality would refuse a non-loopback entry against a loopback request, so removing it opens nothing reachable. It stays as second-line defence for a caller that handsis_allowed_redirect_uria list which never went throughparse_allowlist, and the code now says so rather than leaving a green mutation unexplained.Gates rerun on
54de8c4: fmt clean, clippy clean, 285 tests passed / 0 failed.Blocked on shared runner disk, not on this branch
CI / cargois red onee48634and on54de8c4, and neither run executed a gate. Both die inSet up jobat 3 seconds:That is
actions/cachebeing unpacked onto a full volume. Verified directly onforgejo-runner-5d7df544d-xlqbjinforgejo-runner:/datais 19.6G with 14.2M free, 100% used,/data/cacheholding 18.4G across 66 entries. Node disk is fine, so it is the PVC alone. The error names no disk, which is how it reads as a flake.CI / dockerstays green because it is theactions/cacheandcargo-deny-actioncheckouts that need the space, so a green docker job is not evidence the runner is healthy.ba85f15— the first commit on this branch, before the volume filled — ran the real gates and came backCI / cargosuccess,CI / dockersuccess. The two later commits change one test and two doc comments, nothing in a build path.Not merging and not tagging while
cargois red. Shared-infrastructure remediation belongs to whoever owns that runner; nothing here touches it.Gates, run locally on
ee48634(rustc 1.96.0)Mutation table, every guard removed one at a time
loopback_port_relaxation_keeps_host_path_and_query_exact,loopback_paths_stay_distinct,loopback_callback_registers_and_authorizes_on_any_portloopback_port_relaxation_keeps_host_path_and_query_exactloopback_relaxation_does_not_cross_schemes, atsrc/oauth_redirect.rs:301—https://localhost:3118/callbackmatches anhttpentryloopback_relaxation_does_not_cross_schemes, atsrc/oauth_redirect.rs:313— anhttps://localhost:8787/callbackentry relaxes into cleartexthttp://localhost:8787/callbackis_loopback_hostinparse_loopback_httploopback_entry_matches_any_port,loopback_paths_stay_distinct,loopback_callback_registers_and_authorizes_on_any_portThe two scheme rows were measured by temporarily splitting the shared
parse_loopback_httphelper into a request-side and an entry-side copy, mutating each alone, and recording which assertion panicked. They are two distinct assertions, so the entry-side downgrade — the dangerous direction, since it would put an authorization code on the wire in cleartext — cannot ride on the requested-side one. The split was reverted; the shipped code has one helper.is_loopback_hostinsideparse_loopback_httpsurvives mutation becausevalidate_redirect_urialready rejects cleartext non-loopback on the request side, and host equality would refuse a non-loopback entry matched against a loopback request. Removing it opens nothing reachable throughis_allowed_redirect_uri. It stays as second-line defence for a caller that passes a list which never went throughparse_allowlist, and the code says so rather than leaving a green mutation unexplained.Corrected: the deployed allowlist carries two loopback paths, and a test was claiming otherwise
I said earlier that this repo carries one loopback entry. That was true of the repo and wrong about the system. Read off the live container env:
Seven entries, and the last two share scheme, host and port, differing only by path. So
loopback_paths_stay_distinctis guarding a real production shape, not a synthetic one, and a path relaxation would silently merge two live entries. It now uses that exact pair and its comment says the shape is deployed.That also caught a stale test.
deployed_allowlist_parseswas documented as "the exact set the deployment ships" and carried nine entries — missinghttp://localhost:8787/oauth/callback, and listing threeclaude:///cowork://entries the deployment does not. Nothing failed, because it only asserted that its own string parsed. Split intodeployed_allowlist_parses(the live seven, dated) andallowlist_accepts_the_shapes_we_support(private-use schemes, no claim about production), and recorded inAGENTS.md.Third change: host canonicalisation is pinned.
url2.5.4 parses127.1,0177.0.0.1and2130706433all to127.0.0.1, so host spelling cannot slip past the loopback carve-out and cannot evade an allowlist entry either. The consequence worth knowing is that the host term enforces nothing against an obfuscated downgrade — the entry-side scheme guard is the whole control.Mutation table on
5eb38c5loopback_port_relaxation_keeps_host_path_and_query_exact,loopback_paths_stay_distinct, e2estarts_with(prefix match)loopback_port_relaxation_keeps_host_path_and_query_exact,loopback_paths_stay_distinctobfuscated_loopback_spellings_canonicalise, plus e2eloopback_port_relaxation_keeps_host_path_and_query_exactloopback_relaxation_does_not_cross_schemesatoauth_redirect.rs:301loopback_relaxation_does_not_cross_schemesatoauth_redirect.rs:313(the https→http downgrade)loopback_relaxation_does_not_cross_schemes,obfuscated_loopback_spellings_canonicaliseis_loopback_hostinparse_loopback_httpdeployed_allowlist_parses,loopback_paths_stay_distinct,loopback_entry_matches_any_port,obfuscated_loopback_spellings_canonicalise, e2eA green mutation means one of two opposite things and the table cannot tell them apart, so:
is_loopback_hostis unreachable rather than untested.validate_redirect_urialready refuses a cleartext non-loopback request, and host equality would refuse a non-loopback entry matched against a loopback request, so while the scheme guard stands that term can never be the deciding one. It stays as a second lock with a comment saying why no test dies with it, so nobody reads "no test dies" as "dead code" and removes the lock that made it unreachable.Every other guard is killed by at least one test, including the prefix-match degradation specifically.
Gates on
5eb38c5, local (rustc 1.96.0)fmt clean · clippy clean ·
cargo test --all-features --locked287 passed, 0 failed (207 lib, 37 http, 20 mcp, 10 sandbox, 13 templates) ·cargo audit5 pre-existing allowed unmaintained warnings, no advisories ·cargo deny check bans licenses sourcesok.CI
cargowill stay red until the runner volume is reclaimed; that failure is the disk, not this branch. Still unmerged, still untagged.