Skip to content

fix(proxy): always reject a proxy password that mismatches auth_token - #73

Merged
andybons merged 5 commits into
mainfrom
feat/reject-mismatched-proxy-auth
Sep 28, 2026
Merged

andybons merged 5 commits into
mainfrom
feat/reject-mismatched-proxy-auth

Conversation

@andybons

@andybons andybons commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Summary

A gatekeeper companion configured with actor_token_from (RFC 8693 token exchange) used to delegate its entire proxy-auth check to the STS.

delegateAuth's hasBasicProxyAuth only required that some non-empty password was presented, never that it equaled the instance's own auth_token.

Confirmed live on boxes production: box A sent a request through box B's companion using A's own username and token.

B's companion answered 200 and served it as A, its log showing user_id=A, because the STS validates a token against whoever it actually belongs to, not against which instance received it.

An earlier version of this PR added an opt-in proxy.reject_mismatched_auth config flag, off by default. The owner rejected that direction: accepting a mismatched password is a bug, not a legitimate default behavior that should require opting out of. This PR now makes the correct behavior unconditional and removes the flag.

Behavior change

Whenever auth_token or auth_token_env resolves to a non-empty token, a request presenting a Proxy-Authorization password that does not constant-time-equal it now always gets 407, before delegateAuth or any credential resolver ever runs.

A config that sets auth_token together with actor_token_from and expects a different, STS-validated password to still pass will now see that password rejected.

A deployment that needs distinct per-caller passwords through one shared instance must leave auth_token and auth_token_env both unset; with neither set, actor_token_from keeps requiring, and forwarding to the STS, any non-empty password exactly as before.

A request with no Proxy-Authorization at all is unaffected, so callers that never send proxy credentials keep working.

contextResolver (moat's daemon mode) is unaffected: auth_token is never a single shared secret there, since each caller's own token is looked up individually, so this check does not apply.

Other consumers of the proxy password

  • proxy/postgres.go's authenticate already applies this exact rule unconditionally: a constant-time compare against authToken whenever it's set, with no delegateAuth-equivalent bypass ever existing on the Postgres data-plane listener. Already consistent, no change needed.
  • gatekeeper_tokenexchange.go's extractProxyAuthCredentials reads the Basic password only inside a CredentialResolver, which ServeHTTP invokes after this new check has already run. It now only ever sees a password that already passed, whenever auth_token is set.
  • The remaining Proxy-Authorization references (proxy/mcp.go, proxy/relay.go, the outbound header stripping in proxy.go) only delete the header before forwarding upstream; none of them make an authentication decision.

Tests

  • TestProxy_DelegateAuthSkipsStaticCheck (proxy package) is renamed TestProxy_DelegateAuthRejectsMismatchedStaticToken and now asserts 407 instead of 200 — a genuine, intentional behavior change.
  • TestHTTPSTokenExchangeActorTokenWithAuthToken (top-level package, full end-to-end TLS+STS integration test) is renamed TestHTTPSTokenExchangeActorTokenWithAuthTokenMismatchRejected and now asserts rejection instead of a successful exchange, also confirming the backend never receives the request.
  • TestProxy_MismatchedProxyPasswordRejected keeps the table: mismatched → 407, matching → pass, absent → unchanged, delegateAuth with no auth_token → the non-empty password still passes through.

Docs

  • docs/content/reference/02-config-file.md folds the rule into proxy.auth_token/auth_token_env and states the contextResolver exception plainly.
  • docs/content/guides/06-token-exchange.md states the auth_token interaction with actor_token_from plainly, replacing the removed config-flag cross-reference.
  • CHANGELOG.md's v0.25.0 entry moves from Added to Changed, describing this as a behavior change with the config-migration note.

Test plan

  • Red (this revision): with the new ServeHTTP check neutered, TestProxy_DelegateAuthRejectsMismatchedStaticToken, TestProxy_MismatchedProxyPasswordRejected/mismatched_password_from_another_box_is_rejected, and TestHTTPSTokenExchangeActorTokenWithAuthTokenMismatchRejected all failed for the named reason (status = 200, want 407).
  • Green: same tests pass after restoring the unconditional check, alongside the full existing suite.
  • go build ./..., go vet ./..., gofmt -l . (clean), go test -race -timeout 600s ./..., go run ./cmd/skill-lint all pass.

A companion configured with actor_token_from delegates its entire
Proxy-Authorization check to the STS: delegateAuth's hasBasicProxyAuth
only required some non-empty password, never that it equaled the
instance's own auth_token. Confirmed live: box A sent a request through
box B's companion using A's own username and token; B answered 200 and
served it as A, because the STS validates a token against whoever it
belongs to, not against which instance received it.

That behavior is correct for a shared instance serving many callers
with distinct per-user passwords, but wrong for an instance meant to
serve exactly one caller holding its own static token.

Add proxy.reject_mismatched_auth (default false). When true, a
presented password that does not equal the resolved
auth_token/auth_token_env is rejected with 407, constant-time
compared, before delegateAuth or any credential resolver runs. A
request with no Proxy-Authorization at all is unaffected, so callers
that never send proxy credentials keep working. The option requires
auth_token or auth_token_env to be set and has no effect under a
contextResolver, where tokens are never a single shared value. Every
existing config keeps its current behavior unchanged.
Copilot AI lite review requested due to automatic review settings September 27, 2026 22:34
@claude

claude Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @andybons's task in 6m 52s —— View job


Claude is reviewing this PR

  • Gather context (PR description, changed files)
  • Read core diff (config.go, gatekeeper.go, proxy/proxy.go, tests, docs, changelog) directly
  • Run /code-review skill against the diff (one finder pass done, one still running)
  • Consolidate findings and post inline comments where warranted
  • Post final summary

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The implementation and tests are complete; the remaining documentation nit is non-blocking.

Review effort: Lite
Findings: None

What changed in this PR

Adds opt-in proxy password validation against the configured static token to prevent cross-instance credential use.

Changes:

  • Adds constant-time mismatch rejection and configuration validation.
  • Wires the option into proxy authentication.
  • Adds regression tests and documentation updates.
File Description
proxy/​proxy.go Implements mismatch rejection.
proxy/​proxy_test.go Tests authentication scenarios.
gatekeeper.go Wires configuration into the proxy.
docs/​content/​reference/​02-config-file.md Documents the option.
docs/​content/​guides/​06-token-exchange.md Explains token-exchange usage.
config.go Adds configuration and validation.
config_test.go Tests YAML parsing and defaults.
config_authtoken_test.go Tests token validation requirements.
CHANGELOG.md Records the feature.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

The owner rejected the opt-in design: reject_mismatched_auth
implied the old behavior (accept any non-empty password under
delegateAuth) was a legitimate default rather than a bug. Removed
the config option entirely.

Behavior change: whenever auth_token or auth_token_env resolves to
a non-empty token, a request presenting a Proxy-Authorization
password that does not constant-time-equal it now always gets 407,
before delegateAuth or any credential resolver runs. This flips
TestProxy_DelegateAuthSkipsStaticCheck (renamed
TestProxy_DelegateAuthRejectsMismatchedStaticToken) and
TestHTTPSTokenExchangeActorTokenWithAuthToken (renamed
TestHTTPSTokenExchangeActorTokenWithAuthTokenMismatchRejected),
which both pinned the old "delegateAuth skips the static check"
behavior as correct. A config that sets auth_token together with a
different, STS-validated actor password now gets 407; a deployment
that needs that must leave auth_token and auth_token_env unset.

Absent credentials are unaffected, and delegateAuth with no
auth_token configured keeps requiring, and forwarding to the token
exchange, any non-empty password, matching or not, exactly as
before.

contextResolver (daemon) mode is unaffected and unchanged: authToken
is never a single shared secret there, so this check does not apply
— each caller's own token is looked up individually.

Checked every other reader of the proxy password:
- proxy/postgres.go's authenticate already applies this exact rule
  unconditionally (constant-time compare against authToken whenever
  it's set, no delegateAuth-equivalent bypass ever existed there) —
  already consistent, no change needed.
- gatekeeper_tokenexchange.go's extractProxyAuthCredentials reads the
  Basic password only inside a CredentialResolver, which ServeHTTP
  invokes after this check has already run — so it now only ever
  sees a password that already passed, when auth_token is set.
- The remaining Proxy-Authorization references (proxy/mcp.go,
  proxy/relay.go, proxy.go's outbound header stripping) only delete
  the header before forwarding upstream; they make no auth decision.

Docs: docs/content/reference/02-config-file.md folds the rule into
proxy.auth_token and states the contextResolver exception;
docs/content/guides/06-token-exchange.md states the auth_token
interaction plainly. CHANGELOG.md's v0.25.0 entry moves to Changed.
@andybons andybons changed the title feat(proxy): reject a proxy password that mismatches auth_token fix(proxy): always reject a proxy password that mismatches auth_token Sep 27, 2026
@claude

claude Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @andybons's task in 5m 59s —— View job


Claude finished the code review

  • Gather context (PR body, diff, files)
  • Run /code-review skill against the diff
  • Post findings as inline comments
  • Post summary

Summary

I reviewed origin/main...HEAD (commits 10b4002..8cd3ccf). This PR went through a design pivot mid-review: the first commit added an opt-in proxy.reject_mismatched_auth config flag; the second commit (8cd3ccf) removed that flag entirely and made the mismatch rejection unconditional whenever auth_token/auth_token_env resolves to a non-empty value. Note: the PR title/description at the top of this PR still describe the opt-in flag from the first commit — that text is now stale relative to the final code. Docs (docs/content/reference/02-config-file.md, docs/content/guides/06-token-exchange.md) and CHANGELOG.md were correctly updated to describe the unconditional behavior, so only the PR body itself is out of date.

I traced the new upfront check in proxy/proxy.go's ServeHTTP against every downstream consumer of Proxy-Authorization (checkAuth, hasBasicProxyAuth, postgres.go's independent check, gatekeeper_tokenexchange.go's resolver-time read) and confirmed the ordering and exemptions (absent credentials, contextResolver daemon mode) hold as documented. I did not find a live correctness bug in the new authorization gate itself — the tests (TestProxy_MismatchedProxyPasswordRejected, TestProxy_DelegateAuthRejectsMismatchedStaticToken, TestHTTPSTokenExchangeActorTokenWithAuthTokenMismatchRejected) cover the mismatch, match, absent-credentials, and no-auth_token cases correctly.

Two lower-severity, non-blocking findings, posted inline where the diff allows:

  1. gatekeeper.go:480-481 (not modified by this PR, so I couldn't leave an inline comment on it — GitHub only accepts inline comments on diff lines): the comment above SetDelegateAuth(true) still says actor_token_from causes gatekeeper to "skip the static authToken check," which is exactly the behavior commit 8cd3ccf removed. The analogous comment on SetDelegateAuth in proxy/proxy.go was updated in this same diff; this call-site comment was missed and would mislead a future reader relying on it while auth_token is also configured. Fix this →

  2. proxy/proxy.go:2072 (inline comment posted): the new upfront mismatch check re-implements checkAuth's extractProxyToken + subtle.ConstantTimeCompare logic inline instead of reusing it, so the comparison now lives in two places that nothing ties together — a later change to one could silently diverge from the other. Also, this means the else if p.authToken != "" && !p.checkAuth(r) branch at line 2110 is now unreachable for the "present but mismatched" case (it only still fires for "no credentials presented" while auth_token is set).

No security, race, or error-handling issues found beyond the above. Security-sensitive properties (constant-time comparison, credentials never logged, exemption of contextResolver/daemon mode, no effect on credential-less requests) all check out.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Add an assertion that the STS is never invoked and update the remaining conflicting documentation.

Review effort: Lite
Findings: 1 Medium severity

Open (1)

Comment thread gatekeeper_test.go
Comment thread proxy/proxy.go Outdated
Second Copilot review round on #73.

gatekeeper_test.go: TestHTTPSTokenExchangeActorTokenWithAuthTokenMismatchRejected
only asserted the backend never saw the request, which does not prove
the resolver (and its STS round trip) never ran. Add an stsCalls
counter, following the existing pattern in
TestHTTPSTokenExchangeOutrankedByStatic, and assert it stays zero.
Checked before the err==nil Fatal so a regression that lets the
request through still reports the resolver having run, instead of
being masked by the Fatal's early return. Red-verified by disabling
the ServeHTTP check: the new assertion failed with "STS was called 1
times, want 0" before failing on the missing error, then passed again
once restored.

proxy/proxy.go: the upfront mismatch check duplicated checkAuth's
extractProxyToken + subtle.ConstantTimeCompare inline. checkAuth now
returns (ok, present bool) so both call sites share one comparison:
the upfront check rejects when present && !ok, and the later
`else if p.authToken != ""` branch — previously split into a
`!checkAuth(r)` reject case and a separate success case purely to
mirror checkAuth's single bool — collapses into one branch that only
checks `present`, since the upfront check has already handled every
present-and-mismatched case by the time execution reaches it.
@andybons
andybons requested a lite review from Copilot September 27, 2026 23:00
@claude

claude Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @andybons's task in 13m 24s —— View job


Claude finished the review

  • Gather context (PR description, changed files, prior comments)
  • Run /code-review skill against the diff
  • Consolidate findings and post inline comments where warranted
  • Post final summary

Summary

The core fix is correct: I traced ServeHTTP's new pre-check (proxy/proxy.go:2053-2063) against the three downstream branches (contextResolver, delegateAuth, authToken-only) and confirmed a present-but-mismatched password is now rejected with 407 before delegateAuth or any credential resolver runs, in every mode except daemon (contextResolver) mode, which is correctly exempted since auth_token isn't a shared secret there. checkAuth's new (ok, present) split is used consistently at both call sites, and the new/renamed tests (TestProxy_DelegateAuthRejectsMismatchedStaticToken, TestHTTPSTokenExchangeActorTokenWithAuthTokenMismatchRejected, TestProxy_MismatchedProxyPasswordRejected) genuinely exercise the fixed path — the latter also asserts stsCalls == 0, proving the STS is never invoked for a mismatched password. No inverted conditions, no fall-through-unauthenticated path found.

I posted two inline comments on proxy/proxy.go for maintainability/low-severity issues (not blocking):

  • Redundant checkAuth call + implicit coupling (comment): the pre-check and the later else if p.authToken != "" branch both call checkAuth, and the delegateAuth branch now silently depends on the pre-check having already run, with no local signal of that dependency.
  • New error-message oracle (comment): "Proxy authentication mismatch" vs "Proxy authentication required" now lets a prober distinguish "auth_token is configured" from "auth is open" — a minor new signal that didn't exist when both cases returned identical text.

Stale docs outside the diff (couldn't inline-comment — not touched by this PR, so GitHub rejects comments on those lines)

This PR fixed the equivalent doc/comment in docs/content/guides/06-token-exchange.md and docs/content/reference/02-config-file.md, but missed two other places that still describe the exact pre-fix (buggy) behavior:

  • docs/content/reference/03-credential-sources.md:443 — "When actor_token_from is configured, gatekeeper sets delegate auth mode — the static auth_token check is skipped and each caller's identity is validated by the STS instead." This is the reference entry for actor_token_from itself and directly contradicts the new behavior.
  • docs/token-exchange-endpoint.md:211 — "gatekeeper's static auth_token check is replaced with a requirement that clients provide Basic proxy auth with a non-empty password. The password is not compared against a static value..." Same stale claim.
  • gatekeeper.go:480-481 — the comment above the actor_token_from/SetDelegateAuth wiring loop still reads "each caller has a unique proxy auth password validated by the STS — skip the static authToken check," while the equivalent comment on SetDelegateAuth itself in proxy/proxy.go was correctly updated in this PR.

An operator or future engineer reading any of these three would reasonably configure auth_token + actor_token_from expecting per-caller STS-validated passwords to keep working — reproducing the box-A/box-B incident this PR fixes, just via stale docs instead of the code bug. Worth a quick follow-up pass to align these three with the new behavior.

Noted, not requesting action (scope question for you)

  • No config-load-time validation for auth_token/auth_token_env combined with a credential's actor_token_from. The new semantics make that combination effectively non-functional (only a password literally equal to auth_token will ever pass), and the PR's own docs describe it as broken — but nothing at config-parse time warns or errors on it, so a misconfigured deployment would only discover this in production via a wave of 407s. Given AGENTS.md's guidance against silently expanding scope, I'm flagging this rather than requesting it — happy to open a follow-up issue if you'd like it tracked separately.
  • hasBasicProxyAuth (requires non-empty password) and extractProxyToken/checkAuth (treats an empty Basic password as "present" with token "") disagree on what counts as "present." Both currently converge on 407 today, so there's no live gap, just a latent inconsistency if a future change ever treats "present" as one shared concept across both.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Direct /relay/ requests can bypass the mismatch guard and still reach delegated authentication.

Review effort: Lite
Findings: 1 High severity · 2 Low severity

Open (3)
Resolved since last review (1)

Comment thread proxy/proxy.go Outdated
Comment thread CHANGELOG.md Outdated
Comment thread docs/content/guides/06-token-exchange.md
Comment thread proxy/proxy.go Outdated
Comment thread proxy/proxy.go Outdated
Third Copilot review round on #73, five threads.

SECURITY: handleRelay is reached directly via the NO_PROXY bypass in
ServeHTTP, before the CONNECT/plain-HTTP auth chain the previous
commit fixed. A direct /relay/{name} request carrying another
instance's still-valid Proxy-Authorization reached
getCredentialsForRequest, and the STS behind a token-exchange
credential, exactly like the CONNECT path already fixed. handleRelay
now calls the same mismatch check before doing any relay work.
Red-verified with a new table test in proxy/relay_test.go
(TestRelay_MismatchedAuthTokenRejectedBeforeResolver): before the
fix, a mismatched password returned 200 and the resolver was called
once; after, it returns 407 and the resolver is never called, while
a matching password and absent credentials both still reach the
resolver unchanged.

Maintainability: removed the standalone pre-check block ahead of the
auth chain. checkAuth now returns (ok, present bool), and a new
rejectMismatchedAuth(w, r) helper wraps it into the single "write 407
and return true" comparison every enforcing site needs. The plain
authToken branch, the delegateAuth branch, and handleRelay each call
it directly — one helper, one comparison, called once per request,
with no ordering dependency on a block ahead of the chain.

Both call sites now write the same body text, "Proxy authentication
required", for an absent password and a mismatched one, so a prober
cannot distinguish whether auth_token is configured from the 407
alone. The WARN log still names only the mismatch, never the
presented value.

CHANGELOG.md's v0.25.0 entry: corrected the closing sentence, which
read as if unauthenticated callers are now allowed somewhere — the
change is that no *new* rejection reason is added for absent
credentials, not that absence is newly tolerated; delegateAuth and a
configured auth_token both already rejected absent credentials
before this PR. Also notes the relay path is covered by the same
rule.

Docs: docs/token-exchange-endpoint.md and
docs/content/reference/03-credential-sources.md both still claimed
actor_token_from replaces or skips the static auth_token check
outright; corrected to state the auth_token-set/auth_token-unset
split. docs/content/reference/02-config-file.md now also names the
relay path explicitly. Grepped the repo for the same claim elsewhere;
docs/content/guides/06-token-exchange.md and
skills/configuring-gatekeeper/references/credential-sources.md
already stated it correctly from the prior round, and CHANGELOG.md's
historical v0.5.0 entry describes what shipped at the time and is
left alone.
@claude

claude Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor
📦 Previous Review (superseded by newer review)

Claude finished @andybons's task in 2m 31s —— View job


Claude finished the code review

  • Gather context (latest diff, prior review threads, new commits since last review)
  • Run /code-review skill against the current diff
  • Check whether prior findings were addressed by ea2022d
  • Post inline comments where warranted
  • Post final summary

Summary

This push (2a5253e, ea2022d) closes out the findings from the earlier review rounds on this PR. I re-verified each:

Fixed:

  • Relay-path bypass (Copilot, high severity) — handleRelay is reached directly via the /relay/ NO_PROXY bypass in ServeHTTP, before the CONNECT/plain-HTTP auth chain the prior commit fixed. ea2022d adds a p.rejectMismatchedAuth(w, r) call at the top of handleRelay (proxy/relay.go:60-63), backed by a red-verified test (TestRelay_MismatchedAuthTokenRejectedBeforeResolver) asserting the resolver is never called for a mismatched password. Confirmed by reading the code: this closes the gap.
  • Duplicated comparison logic — the standalone pre-check block ahead of the auth chain is gone; checkAuth now returns (ok, present bool), and a single rejectMismatchedAuth helper (proxy/proxy.go:2216-2233) is called from the plain-authToken branch, the delegateAuth branch, and handleRelay. One comparison, three call sites, no ordering dependency on a block sitting ahead of the chain.
  • 407-body enumeration signal — both the mismatch and absent-credentials cases now write the identical "Proxy authentication required" body, so a prober can no longer distinguish "auth_token is configured" from "auth is delegate-only" via the 407 text alone.
  • Doc inconsistencies — docs/token-exchange-endpoint.md and docs/content/reference/03-credential-sources.md no longer claim actor_token_from unconditionally replaces/skips the static auth_token check; both now state the auth_token-set vs. auth_token-unset split. docs/content/reference/02-config-file.md now names the relay path explicitly. CHANGELOG.md's closing sentence no longer reads as if unauthenticated callers are newly allowed anywhere.
  • STS call-count assertion — TestHTTPSTokenExchangeActorTokenWithAuthTokenMismatchRejected now asserts stsCalls == 0 in addition to the transport-error and empty-backend-auth checks.

Still open (not addressed by any of the three follow-up commits):

  • gatekeeper.go:480-481 — this comment is unchanged since the first review round flagged it:
    // When actor_token_from is configured, each caller has a unique proxy
    // auth password validated by the STS — skip the static authToken check.
    This is exactly the behavior 8cd3ccf removed — delegateAuth no longer skips the static check; rejectMismatchedAuth enforces it first. The analogous comment on SetDelegateAuth in proxy/proxy.go:721-726 was updated in this PR to say the opposite. I couldn't attach an inline comment here since gatekeeper.go has no changed lines in this diff (GitHub only accepts inline comments on diff lines) — flagging it here instead. Fix this →

Minor, non-blocking: in the p.authToken != "" branch (proxy/proxy.go:2094-2101), p.checkAuth(r) is called twice per request — once inside rejectMismatchedAuth, once more just to read present. Purely a redundant header-parse/decode/compare, not a correctness issue; not worth blocking on.

I did not find any new security, race, or logic issues in 2a5253e/ea2022d beyond the one doc comment above. The contextResolver/daemon-mode exemption, the /mcp/ and /_aws/ direct-request paths (both gated on contextResolver != nil, so already exempt for the same reason daemon mode is), and the constant-time comparison all still hold correctly under the refactor.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

No blocking issues remain; only a minor changelog clarification was noted.

Review effort: Lite
Findings: None

Resolved since last review (3)

Claude review on #73, two items.

gatekeeper.go: the comment above the actor_token_from loop still said
gatekeeper "skips" the static authToken check, which stopped being
true once rejectMismatchedAuth started enforcing it under
delegateAuth. Reworded to one line matching SetDelegateAuth's updated
doc in proxy/proxy.go.

proxy/proxy.go: the plain authToken branch called p.checkAuth(r)
twice — once inside rejectMismatchedAuth, once again right after to
check presence. rejectMismatchedAuth now returns (rejected, present
bool), computing checkAuth exactly once; the authToken branch uses
the returned present instead of calling checkAuth again, and the
delegateAuth and relay call sites ignore present since they already
have their own presence check (hasBasicProxyAuth) or don't need one.
@claude

claude Bot commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

Claude finished @andybons's task in 8m 38s —— View job


Claude finished the review

  • Gather context (PR body, diff, prior review comments)
  • Run /code-review skill against the current diff (origin/main...HEAD)
  • Consolidate findings and post inline comments where warranted
  • Post final summary

Summary

This is a review of the current state (origin/main..HEAD = 0350ef4), after the five commits that walked the design from an opt-in flag through to the unconditional fix and then addressed every prior round of review feedback (relay-path gap, duplicate checkAuth calls, stale comments/docs, the STS-call-count assertion, the distinguishable error message).

Core fix verified correct. I dispatched the /code-review skill (two independent sub-passes: a removed-behavior/cross-file tracer, and a reuse/simplification/efficiency/altitude pass) and separately traced the code myself. All three converge: the mismatch guard (rejectMismatchedAuth, proxy/proxy.go:2224) is now applied consistently across all three places that read Proxy-Authorization before authToken/delegateAuth would otherwise apply — the plain-authToken branch, the delegateAuth branch, and handleRelay (the direct NO_PROXY-bypass path, fixed in ea2022d after initially being missed). The Postgres data-plane (postgres.go's authenticate) and the contextResolver-gated direct-MCP/AWS handlers were confirmed structurally exempt, not just incidentally unaffected. Constant-time comparison and no-credential-logging are preserved. No correctness regressions found, and the previously-flagged stale comments/docs (gatekeeper.go:480, docs/token-exchange-endpoint.md, docs/content/reference/03-credential-sources.md) are now all consistent with the unconditional behavior.

One residual finding, posted inline on proxy/relay.go:64: rejectMismatchedAuth branches only on p.authToken, never on p.contextResolver. Everywhere else, contextResolver != nil and the authToken/delegateAuth chain are treated as mutually exclusive (daemon mode never consults authToken), but that exclusivity is only convention — nothing in the type enforces it, and handleRelay's dispatch doesn't check contextResolver either. Today it's harmless because the only real consumer (moat) never sets both SetAuthToken and SetContextResolver on the same Proxy, but since this is a general-purpose importable library, a future caller combining them (with relays also registered) would see /relay/ enforce a static-token check the main proxy path deliberately skips for that same request. Low severity, not blocking.

Minor, non-blocking observations from the automated passes (not inline-commented, since they're stylistic/efficiency rather than correctness, and this PR has already been through several rounds of nit-fixing):

  • rejectMismatchedAuth calls checkAuth (which base64-decodes Proxy-Authorization) before checking p.authToken == ""; in the documented common shape (actor_token_from set, auth_token left unset), that decode-and-compare runs and is discarded on every request. Reordering the empty check first would skip it.
  • In the delegateAuth branch, the same header is now decoded up to three times per request (checkAuth, hasBasicProxyAuth, extractProxyUsername) instead of two, pre-PR.
  • rejectMismatchedAuth's (rejected, present bool) return is only fully used by one of its three call sites; the other two discard present. A minor API wart, already the most-reduced form after two rounds of prior cleanup per the commit history.

I wasn't able to run go build/go vet/go test myself (sandboxed shell requires approval this job doesn't have), but the PR description states the full suite (go build ./..., go vet ./..., gofmt -l ., go test -race -timeout 600s ./...) passes, and the red/green methodology is documented in the commit messages per AGENTS.md's testing requirements.

Comment thread proxy/relay.go
// bypassing the CONNECT/plain-HTTP auth chain entirely, so it enforces
// authToken itself before any resolver or STS call. Absent credentials
// are unaffected: relay traffic requires no Proxy-Authorization at all.
if rejected, _ := p.rejectMismatchedAuth(w, r); rejected {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor, non-blocking: rejectMismatchedAuth only branches on p.authToken — it never checks p.contextResolver. The main ServeHTTP chain (proxy/proxy.go:2058) treats contextResolver != nil and the authToken/delegateAuth chain as mutually exclusive (daemon mode looks up each caller's own token via contextResolver and never consults authToken at all), but handleRelay's dispatch (proxy/proxy.go:2031) is gated only on len(p.relays) > 0, with no contextResolver == nil guard.

Today this is safe in practice because the only real consumer (moat) never calls both SetAuthToken and SetContextResolver on the same Proxy, so p.authToken == "" whenever contextResolver is set, and rejectMismatchedAuth short-circuits harmlessly. But since AGENTS.md notes this is a general-purpose library "meant to be imported," nothing in the type enforces that exclusivity — a future consumer that configures both (e.g. a static authToken as a fallback alongside daemon-mode contextResolver, while also registering relays) would find /relay/ requests rejected against the static authToken even though the same per-caller password would be accepted by the main proxy path via contextResolver. Worth either asserting the exclusivity where both are set, or having rejectMismatchedAuth/handleRelay skip the authToken compare when contextResolver != nil, consistent with how ServeHTTP treats the two modes everywhere else.

@andybons
andybons merged commit a0adfcd into main Sep 28, 2026
2 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