fix(auth): accept any port on loopback redirect URIs (RFC 8252 §7.3) #3

Merged
jlxq0 merged 7 commits from oauth-loopback-port into main 2026-08-25 03:51:38 +00:00
Owner

is_allowed_redirect_uri required exact string equality against the allowlist. Claude Code CLI binds a random free loopback port per session, so the allowlisted http://localhost:8787/callback could never match and DCR answered 400 unregistered redirect_uri for every native loopback client. validate_redirect_uri already 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 cleartext http on a loopback host, the port is ignored and scheme, host, path and query must match exactly.

  • https and private-scheme entries keep exact matching including port.
  • localhost does not match 127.0.0.1; RFC 8252 relaxes the port, not the host.
  • /callback does not match /oauth/callback — path comparison is equality, never a prefix.
  • Query is compared too, so a relaxed entry cannot pick up an attacker-supplied query string.

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.rs and one end-to-end test in tests/http.rs that registers and authorizes http://localhost:3118/callback — the port the failing session actually drew — and asserts DCR still refuses http://localhost:3118/oauth/callback, http://127.0.0.1:3118/callback and https://claude.ai:8443/api/mcp/auth_callback.

Each was watched go red against a broken matcher before being trusted. Five mutations, five failures:

Mutation Tests that failed
drop path comparison loopback_port_relaxation_keeps_host_path_and_query_exact, loopback_paths_stay_distinct
drop host comparison same two
drop query comparison loopback_port_relaxation_keeps_host_path_and_query_exact
relax port for all schemes non_loopback_entries_keep_exact_port_matching, allowlist_accepts_private_use_schemes
revert to exact string equality loopback_entry_matches_any_port, loopback_paths_stay_distinct

The 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:

  1. The relaxed branch cannot accept a non-loopback URI, nor a loopback URI with a different host or path — non-loopback acceptance is exact string equality only.
  2. The token endpoint compares the presented redirect_uri for 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

`is_allowed_redirect_uri` required exact string equality against the allowlist. Claude Code CLI binds a random free loopback port per session, so the allowlisted `http://localhost:8787/callback` could never match and DCR answered `400 unregistered redirect_uri` for every native loopback client. `validate_redirect_uri` already 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 cleartext `http` on a loopback host, the port is ignored and scheme, host, path and query must match exactly. - `https` and private-scheme entries keep exact matching including port. - `localhost` does not match `127.0.0.1`; RFC 8252 relaxes the port, not the host. - `/callback` does not match `/oauth/callback` — path comparison is equality, never a prefix. - Query is compared too, so a relaxed entry cannot pick up an attacker-supplied query string. 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.rs` and one end-to-end test in `tests/http.rs` that registers and authorizes `http://localhost:3118/callback` — the port the failing session actually drew — and asserts DCR still refuses `http://localhost:3118/oauth/callback`, `http://127.0.0.1:3118/callback` and `https://claude.ai:8443/api/mcp/auth_callback`. Each was watched go red against a broken matcher before being trusted. Five mutations, five failures: | Mutation | Tests that failed | |---|---| | drop path comparison | `loopback_port_relaxation_keeps_host_path_and_query_exact`, `loopback_paths_stay_distinct` | | drop host comparison | same two | | drop query comparison | `loopback_port_relaxation_keeps_host_path_and_query_exact` | | relax port for all schemes | `non_loopback_entries_keep_exact_port_matching`, `allowlist_accepts_private_use_schemes` | | revert to exact string equality | `loopback_entry_matches_any_port`, `loopback_paths_stay_distinct` | The 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: 1. The relaxed branch cannot accept a non-loopback URI, nor a loopback URI with a different host or path — non-loopback acceptance is exact string equality only. 2. The token endpoint compares the presented `redirect_uri` for 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
fix(auth): accept any port on loopback redirect URIs (RFC 8252 §7.3)
All checks were successful
CI / cargo (pull_request) Successful in 4m30s
CI / docker (pull_request) Successful in 2m5s
ba85f15a8a
`is_allowed_redirect_uri` required exact string equality against the
allowlist. Claude Code CLI binds a random free loopback port per session,
so an allowlisted `http://localhost:8787/callback` could never match and
DCR answered `400 unregistered redirect_uri` for every native loopback
client. `validate_redirect_uri` already carried the loopback carve-out for
the scheme; the port half of RFC 8252 §7.3 was never written.

Relax the port for cleartext loopback entries only. Scheme, host, path and
query still match exactly, so `/callback` does not match `/oauth/callback`,
`localhost` is not `127.0.0.1`, and `https` and private-scheme entries keep
exact matching including their port.

No deployment change: the existing allowlist already carries a loopback
entry, which starts matching once the rule is right.

Closes #2

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMqndcRHThqewRGsEa8YNj
test(auth): guard the requested URI's scheme, not only the entry's
Some checks failed
CI / cargo (pull_request) Failing after 2s
CI / docker (pull_request) Has been skipped
54de8c40ae
Without a scheme check on the requested side, `https://localhost:3118/callback`
matches an `http` loopback entry — TLS on loopback is not the case RFC 8252
§7.3 carves out, and the mismatch means the caller is not the client that
registered. The check was already there; nothing proved it could fail.

Also record why the `is_loopback_host` guard in `parse_loopback_http` survives
mutation: `validate_redirect_uri` and host equality both already close that
path, so it is second-line defence for a caller that bypasses
`parse_allowlist`, not a reachable control.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMqndcRHThqewRGsEa8YNj
Author
Owner

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.

  • Query is compared as well as scheme, host and path. Already present, covered by loopback_port_relaxation_keeps_host_path_and_query_exact.
  • The requested URIs scheme is guarded, not only the entrys. Already present, but no test could fail without it. loopback_relaxation_does_not_cross_schemes now asserts https://localhost:3118/callback does not match an http loopback entry, and the mirror case.

Full mutation table, one guard removed at a time

Guard removed Tests that go red
path equality loopback_port_relaxation_keeps_host_path_and_query_exact, loopback_paths_stay_distinct, loopback_callback_registers_and_authorizes_on_any_port
host equality same three
query equality loopback_port_relaxation_keeps_host_path_and_query_exact
scheme guard in parse_loopback_http loopback_relaxation_does_not_cross_schemes
is_loopback_host guard in parse_loopback_http none — see below
whole relaxed branch (pre-fix behaviour) loopback_entry_matches_any_port, loopback_paths_stay_distinct, loopback_callback_registers_and_authorizes_on_any_port

The is_loopback_host call inside parse_loopback_http survives mutation, and it is not a missing test. validate_redirect_uri already 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 hands is_allowed_redirect_uri a list which never went through parse_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.

### 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. - **Query is compared** as well as scheme, host and path. Already present, covered by `loopback_port_relaxation_keeps_host_path_and_query_exact`. - **The *requested* URIs scheme is guarded, not only the entrys.** Already present, but no test could fail without it. `loopback_relaxation_does_not_cross_schemes` now asserts `https://localhost:3118/callback` does not match an `http` loopback entry, and the mirror case. ### Full mutation table, one guard removed at a time | Guard removed | Tests that go red | |---|---| | path equality | `loopback_port_relaxation_keeps_host_path_and_query_exact`, `loopback_paths_stay_distinct`, `loopback_callback_registers_and_authorizes_on_any_port` | | host equality | same three | | query equality | `loopback_port_relaxation_keeps_host_path_and_query_exact` | | scheme guard in `parse_loopback_http` | `loopback_relaxation_does_not_cross_schemes` | | `is_loopback_host` guard in `parse_loopback_http` | **none — see below** | | whole relaxed branch (pre-fix behaviour) | `loopback_entry_matches_any_port`, `loopback_paths_stay_distinct`, `loopback_callback_registers_and_authorizes_on_any_port` | The `is_loopback_host` call inside `parse_loopback_http` survives mutation, and it is not a missing test. `validate_redirect_uri` already 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 hands `is_allowed_redirect_uri` a list which never went through `parse_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.
docs(auth): record why each side of the scheme guard is asserted separately
Some checks failed
CI / cargo (pull_request) Failing after 2s
CI / docker (pull_request) Has been skipped
ee48634c72
Dropping the check on the allowlist entry lets an `https://localhost:8443/callback`
entry port-relax into cleartext `http://localhost:3118/callback` — an
https-to-http downgrade putting the authorization code on the wire. Verified by
mutating each side alone: the requested-side removal fires the assertion at
:301, the entry-side removal fires the one at :313.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMqndcRHThqewRGsEa8YNj
Author
Owner

Blocked on shared runner disk, not on this branch

CI / cargo is red on ee48634 and on 54de8c4, and neither run executed a gate. Both die in Set up job at 3 seconds:

git worktree add error: Preparing worktree (detached HEAD 0057852)
error: unable to write file dist/save/index.js
fatal: Could not reset index file to revision 'HEAD'.: exit status 128

That is actions/cache being unpacked onto a full volume. Verified directly on forgejo-runner-5d7df544d-xlqbj in forgejo-runner: /data is 19.6G with 14.2M free, 100% used, /data/cache holding 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 / docker stays green because it is the actions/cache and cargo-deny-action checkouts 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 back CI / cargo success, CI / docker success. The two later commits change one test and two doc comments, nothing in a build path.

Not merging and not tagging while cargo is red. Shared-infrastructure remediation belongs to whoever owns that runner; nothing here touches it.

Gates, run locally on ee48634 (rustc 1.96.0)

cargo fmt --all --check                                              clean
cargo clippy --all-targets --all-features --locked -- -D warnings    clean
cargo test --all-features --locked      205 + 0 + 37 + 20 + 10 + 13 = 285 passed, 0 failed
cargo audit          5 pre-existing allowed unmaintained warnings (bincode, paste,
                     rustybuzz, ttf-parser, yaml-rust — all in typst's tree), no advisories
cargo deny check bans licenses sources                   bans ok, licenses ok, sources ok

Mutation table, every guard removed one at a time

Guard removed Tests that go red
path equality loopback_port_relaxation_keeps_host_path_and_query_exact, loopback_paths_stay_distinct, loopback_callback_registers_and_authorizes_on_any_port
host equality same three
query equality loopback_port_relaxation_keeps_host_path_and_query_exact
scheme check, requested URI side only loopback_relaxation_does_not_cross_schemes, at src/oauth_redirect.rs:301https://localhost:3118/callback matches an http entry
scheme check, allowlist entry side only loopback_relaxation_does_not_cross_schemes, at src/oauth_redirect.rs:313 — an https://localhost:8787/callback entry relaxes into cleartext http://localhost:8787/callback
is_loopback_host in parse_loopback_http none, and it is not a missing test — see below
whole relaxed branch (pre-fix) loopback_entry_matches_any_port, loopback_paths_stay_distinct, loopback_callback_registers_and_authorizes_on_any_port

The two scheme rows were measured by temporarily splitting the shared parse_loopback_http helper 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_host inside parse_loopback_http survives mutation because validate_redirect_uri already 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 through is_allowed_redirect_uri. It stays as second-line defence for a caller that passes a list which never went through parse_allowlist, and the code says so rather than leaving a green mutation unexplained.

### Blocked on shared runner disk, not on this branch `CI / cargo` is red on `ee48634` and on `54de8c4`, and neither run executed a gate. Both die in `Set up job` at 3 seconds: ``` git worktree add error: Preparing worktree (detached HEAD 0057852) error: unable to write file dist/save/index.js fatal: Could not reset index file to revision 'HEAD'.: exit status 128 ``` That is `actions/cache` being unpacked onto a full volume. Verified directly on `forgejo-runner-5d7df544d-xlqbj` in `forgejo-runner`: `/data` is 19.6G with **14.2M free, 100% used**, `/data/cache` holding 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 / docker` stays green because it is the `actions/cache` and `cargo-deny-action` checkouts 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 back **`CI / cargo` success, `CI / docker` success**. The two later commits change one test and two doc comments, nothing in a build path. Not merging and not tagging while `cargo` is red. Shared-infrastructure remediation belongs to whoever owns that runner; nothing here touches it. ### Gates, run locally on `ee48634` (rustc 1.96.0) ``` cargo fmt --all --check clean cargo clippy --all-targets --all-features --locked -- -D warnings clean cargo test --all-features --locked 205 + 0 + 37 + 20 + 10 + 13 = 285 passed, 0 failed cargo audit 5 pre-existing allowed unmaintained warnings (bincode, paste, rustybuzz, ttf-parser, yaml-rust — all in typst's tree), no advisories cargo deny check bans licenses sources bans ok, licenses ok, sources ok ``` ### Mutation table, every guard removed one at a time | Guard removed | Tests that go red | |---|---| | path equality | `loopback_port_relaxation_keeps_host_path_and_query_exact`, `loopback_paths_stay_distinct`, `loopback_callback_registers_and_authorizes_on_any_port` | | host equality | same three | | query equality | `loopback_port_relaxation_keeps_host_path_and_query_exact` | | scheme check, **requested URI** side only | `loopback_relaxation_does_not_cross_schemes`, at `src/oauth_redirect.rs:301` — `https://localhost:3118/callback` matches an `http` entry | | scheme check, **allowlist entry** side only | `loopback_relaxation_does_not_cross_schemes`, at `src/oauth_redirect.rs:313` — an `https://localhost:8787/callback` entry relaxes into cleartext `http://localhost:8787/callback` | | `is_loopback_host` in `parse_loopback_http` | **none, and it is not a missing test** — see below | | whole relaxed branch (pre-fix) | `loopback_entry_matches_any_port`, `loopback_paths_stay_distinct`, `loopback_callback_registers_and_authorizes_on_any_port` | The two scheme rows were measured by temporarily splitting the shared `parse_loopback_http` helper 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_host` inside `parse_loopback_http` survives mutation because `validate_redirect_uri` already 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 through `is_allowed_redirect_uri`. It stays as second-line defence for a caller that passes a list which never went through `parse_allowlist`, and the code says so rather than leaving a green mutation unexplained.
test(auth): pin the matcher to the allowlist the deployment actually runs
Some checks failed
CI / cargo (pull_request) Failing after 2s
CI / docker (pull_request) Has been skipped
5eb38c5c9b
`loopback_paths_stay_distinct` used a synthetic localhost/127.0.0.1 pair. The
running deployment carries `http://localhost:8787/callback` and
`http://localhost:8787/oauth/callback` — same scheme, host and port, differing
only by path — so the test now uses that pair and asserts neither absorbs the
other. Degrading path equality to `starts_with` turns it red.

`deployed_allowlist_parses` claimed to be "the exact set the deployment ships"
and was not: nine entries against the live seven, missing the second loopback
path and carrying three private-use entries the deployment does not list. Read
off the live container env 2026-08-25 and split into the deployed set and the
shapes we support, so only one of them makes a claim about production.

Also pin host canonicalisation: `url` 2.5.4 parses `127.1`, `0177.0.0.1` and
`2130706433` all to `127.0.0.1`, so host spelling cannot slip past the loopback
carve-out and cannot evade an entry either — which means the entry-side scheme
guard, not the host term, is the whole control against an https-to-http
downgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMqndcRHThqewRGsEa8YNj
Author
Owner

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:

$ kubectl -n typst-mcp get deploy -o jsonpath='{...env[*]}' | grep -i redirect
TYPST_MCP_OAUTH_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback,
  https://claude.com/api/mcp/auth_callback,
  https://www.cursor.com/agents/mcp/oauth/callback,
  cursor://anysphere.cursor-mcp/oauth/callback,
  grokbot://mcp/oauth/callback,
  http://localhost:8787/callback,
  http://localhost:8787/oauth/callback

Seven entries, and the last two share scheme, host and port, differing only by path. So loopback_paths_stay_distinct is 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_parses was documented as "the exact set the deployment ships" and carried nine entries — missing http://localhost:8787/oauth/callback, and listing three claude:// / cowork:// entries the deployment does not. Nothing failed, because it only asserted that its own string parsed. Split into deployed_allowlist_parses (the live seven, dated) and allowlist_accepts_the_shapes_we_support (private-use schemes, no claim about production), and recorded in AGENTS.md.

Third change: host canonicalisation is pinned. url 2.5.4 parses 127.1, 0177.0.0.1 and 2130706433 all to 127.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 5eb38c5

Guard removed or degraded Tests that go red
path equality loopback_port_relaxation_keeps_host_path_and_query_exact, loopback_paths_stay_distinct, e2e
path equality → starts_with (prefix match) loopback_port_relaxation_keeps_host_path_and_query_exact, loopback_paths_stay_distinct
host equality those two, plus obfuscated_loopback_spellings_canonicalise, plus e2e
query equality loopback_port_relaxation_keeps_host_path_and_query_exact
scheme guard, requested side only loopback_relaxation_does_not_cross_schemes at oauth_redirect.rs:301
scheme guard, entry side only loopback_relaxation_does_not_cross_schemes at oauth_redirect.rs:313 (the https→http downgrade)
scheme guard, shared helper loopback_relaxation_does_not_cross_schemes, obfuscated_loopback_spellings_canonicalise
is_loopback_host in parse_loopback_http none — unreachable, not untested
whole relaxed branch (pre-fix) deployed_allowlist_parses, loopback_paths_stay_distinct, loopback_entry_matches_any_port, obfuscated_loopback_spellings_canonicalise, e2e

A green mutation means one of two opposite things and the table cannot tell them apart, so: is_loopback_host is unreachable rather than untested. validate_redirect_uri already 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 --locked 287 passed, 0 failed (207 lib, 37 http, 20 mcp, 10 sandbox, 13 templates) · cargo audit 5 pre-existing allowed unmaintained warnings, no advisories · cargo deny check bans licenses sources ok.

CI cargo will stay red until the runner volume is reclaimed; that failure is the disk, not this branch. Still unmerged, still untagged.

### 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: ``` $ kubectl -n typst-mcp get deploy -o jsonpath='{...env[*]}' | grep -i redirect TYPST_MCP_OAUTH_REDIRECT_URIS=https://claude.ai/api/mcp/auth_callback, https://claude.com/api/mcp/auth_callback, https://www.cursor.com/agents/mcp/oauth/callback, cursor://anysphere.cursor-mcp/oauth/callback, grokbot://mcp/oauth/callback, http://localhost:8787/callback, http://localhost:8787/oauth/callback ``` Seven entries, and the last two share scheme, host **and port**, differing only by path. So `loopback_paths_stay_distinct` is 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_parses` was documented as "the exact set the deployment ships" and carried nine entries — missing `http://localhost:8787/oauth/callback`, and listing three `claude://` / `cowork://` entries the deployment does not. Nothing failed, because it only asserted that its own string parsed. Split into `deployed_allowlist_parses` (the live seven, dated) and `allowlist_accepts_the_shapes_we_support` (private-use schemes, no claim about production), and recorded in `AGENTS.md`. Third change: host canonicalisation is pinned. `url` 2.5.4 parses `127.1`, `0177.0.0.1` and `2130706433` all to `127.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 `5eb38c5` | Guard removed or degraded | Tests that go red | |---|---| | path equality | `loopback_port_relaxation_keeps_host_path_and_query_exact`, `loopback_paths_stay_distinct`, e2e | | path equality → `starts_with` (prefix match) | `loopback_port_relaxation_keeps_host_path_and_query_exact`, `loopback_paths_stay_distinct` | | host equality | those two, plus `obfuscated_loopback_spellings_canonicalise`, plus e2e | | query equality | `loopback_port_relaxation_keeps_host_path_and_query_exact` | | scheme guard, requested side only | `loopback_relaxation_does_not_cross_schemes` at `oauth_redirect.rs:301` | | scheme guard, entry side only | `loopback_relaxation_does_not_cross_schemes` at `oauth_redirect.rs:313` (the https→http downgrade) | | scheme guard, shared helper | `loopback_relaxation_does_not_cross_schemes`, `obfuscated_loopback_spellings_canonicalise` | | `is_loopback_host` in `parse_loopback_http` | **none — unreachable, not untested** | | whole relaxed branch (pre-fix) | `deployed_allowlist_parses`, `loopback_paths_stay_distinct`, `loopback_entry_matches_any_port`, `obfuscated_loopback_spellings_canonicalise`, e2e | A green mutation means one of two opposite things and the table cannot tell them apart, so: `is_loopback_host` is unreachable rather than untested. `validate_redirect_uri` already 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 --locked` **287 passed, 0 failed** (207 lib, 37 http, 20 mcp, 10 sandbox, 13 templates) · `cargo audit` 5 pre-existing allowed unmaintained warnings, no advisories · `cargo deny check bans licenses sources` ok. CI `cargo` will stay red until the runner volume is reclaimed; that failure is the disk, not this branch. Still unmerged, still untagged.
test(auth): stop the allowlist snapshot claiming to check the deployment
Some checks failed
CI / cargo (pull_request) Failing after 2s
CI / docker (pull_request) Has been skipped
2ecd43af10
Renaming `deployed_allowlist_parses` to a dated production set fixed the
numbers and left the structure: `raw` is a literal and the assertions count
what that literal parsed to, so the deployment is not an input and no change
to it can turn the test red. It was accurate by hand, not by coverage.

`production_allowlist_snapshot_behaves` says that in its own doc comment,
carries the kubectl invocation that can actually detect drift, and keeps the
behavioural assertions it was always really making. AGENTS.md records the
class rather than the instance: the same fixture in caldav-mcp, carddav-mcp
and jmap-mcp matched their deployments on 2026-08-25, so three were right by
luck and no test could have said which.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMqndcRHThqewRGsEa8YNj
docs(agents): point the rotting-fixture pitfall at its cross-repo issue
Some checks failed
CI / cargo (pull_request) Failing after 2s
CI / docker (pull_request) Has been skipped
d8e5935140
caldav-mcp#7 is where the shared decision lives, across all five servers that
copy this fixture. Number verified against the API rather than taken from the
message that carried it — #6 in that repo is the two-sided-guard entry, filed
in the same minute.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMqndcRHThqewRGsEa8YNj
ci: re-trigger cargo after the runner volume expansion
All checks were successful
CI / cargo (pull_request) Successful in 1m42s
CI / docker (pull_request) Successful in 1m2s
523926e35a
Empty on purpose. The runner PVC went 20Gi -> 60Gi at 03:02Z and no cargo job
has run anywhere since, so "the disk has room" and "CI works" are still two
claims with evidence for one. This push is the measurement: whether the job
reaches a gate rather than dying in `Set up job` at three seconds.

Not a release. Merge and tag stay held.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KMqndcRHThqewRGsEa8YNj
jlxq0 merged commit ea80b40723 into main 2026-08-25 03:51:38 +00:00
jlxq0 deleted branch oauth-loopback-port 2026-08-25 03:51:38 +00:00
jlxq0 referenced this pull request from a commit 2026-08-25 03:56:24 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
jlxq0/typst-mcp!3
No description provided.