fix(auth): bind redirect_uri to client, reject empty host, require allowed-hosts off-loopback - #21
Conversation
Guard was only checking the raw remainder was non-empty, so `https://?evil` slipped through with no host. Extract the host segment like the http branch does and reject it when empty/whitespace. Co-Authored-By: Claude <noreply@anthropic.com>
/register was stateless and /authorize accepted any redirect_uri that passed the open-redirect rule, so a victim lured to an /authorize link carrying an attacker's redirect_uri + PKCE challenge handed over their auth code as soon as the Basic login succeeded. - persist client_id -> redirect_uri in a new `clients` table (no secrets: a public client_id and its own published callback) - /authorize now requires client_id and denies unless the redirect_uri is an exact match for that client — 400 invalid_request, never a redirect - /register rejects a body with no usable redirect_uris (RFC 7591 §2) - bin/mcp-token runs DCR before /authorize - single-use-code + PKCE guards unchanged; redeem still exact-matches the URI the code was issued for Co-Authored-By: Claude <noreply@anthropic.com>
- Off-loopback the default allowlist is no guard: a remote attacker passes it with `Host: 127.0.0.1`. Return ConfigError::AllowedHostsRequired instead of warning and serving; loopback binds keep the safe default - Set-but-empty MCP_SSH_ALLOWED_HOSTS / `allowed_hosts = []` counts as unset - Tests: v4/v6/unspecified non-loopback rejected, loopback (v4+v6) still defaults, explicit env/file hosts load; docs env tables note the requirement Co-Authored-By: Claude <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (9)
WalkthroughThe change adds durable OAuth client registration and exact client/redirect-URI enforcement, updates the token helper to use dynamic registration, and makes explicit host allowlists mandatory for non-loopback server binds. Tests and deployment documentation reflect the new behavior. ChangesOAuth and host security
Estimated code review effort: 4 (Complex) | ~45 minutes Sequence Diagram(s)sequenceDiagram
participant McpToken
participant RegisterEndpoint
participant Store
participant SQLite
participant AuthorizeEndpoint
McpToken->>RegisterEndpoint: POST redirect_uris
RegisterEndpoint->>Store: register_client(redirect_uris)
Store->>SQLite: persist client bindings
SQLite-->>Store: client_id
Store-->>RegisterEndpoint: registration response
McpToken->>AuthorizeEndpoint: request client_id and redirect_uri
AuthorizeEndpoint->>Store: client_allows_redirect(client_id, redirect_uri)
Store->>SQLite: check exact binding
SQLite-->>Store: binding result
AuthorizeEndpoint-->>McpToken: authorization code or invalid_request
Possibly related PRs
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
- /register is unauthenticated by design (it bootstraps auth), so an attacker can register their own client_id for their own callback and lure a victim to a self-consistent /authorize link. Client binding does not close that path; the Basic prompt is the last defense. - Correct docs/architecture.md and the /authorize comment, which both claimed a valid Basic login could no longer become an auth code for an attacker's callback. No behavior change. Co-Authored-By: Claude <noreply@anthropic.com>
Local verification — green
Beyond the unit suite, the three claims were exercised against a live server (loopback bind, throwaway SQLite + job dir), since there's no
No code changes were needed; nothing pushed. Blocking on
The two residual risks already documented in the PR description (unauthenticated DCR is not a complete anti-phishing control; the |
|
✅ Action performedReview finished.
|
|
@coderabbitai full review |
Re-verified at
|
| Check | Result |
|---|---|
bin/mcp-token full DCR → authorize → token |
exit 0, 43-char bearer |
That bearer on POST /mcp (initialize) |
200; garbage bearer 401 |
[D1] registered client_id + its own redirect_uri |
302 + code |
[D1] registered client_id + attacker's redirect_uri |
400, no redirect |
[D1] unregistered client_id / client_id omitted |
400, no redirect |
| [D1] client registered before a restart, authorized after | 302 + code — binding is durable |
/register with https://?evil, https://, http://evil.com/cb |
400 each |
[D2] off-loopback bind, hosts unset / " , " |
exit 1 both, with the AllowedHostsRequired message |
| [D2] off-loopback + explicit hosts / loopback + unset | both serve |
The red check is not a finding
CodeRabbit is failure with description "Review rate limited" — it never read the diff. The @coderabbitai review at 04:49 replied "Review finished" but posted zero inline comments and zero reviews, because that command is incremental and treated the rate-limited commits as already-reviewed (its own note says so). Re-triggered with full review above to force a real pass.
Still blocking: REVIEW_REQUIRED — needs a human approving review.
Residual risks from the PR description are unchanged and unaddressed here: unauthenticated DCR is not a complete anti-phishing control, and the clients table is unbounded.
✅ Action performedFull review finished. You're currently rate limited under our Fair Usage Limits Policy. Your recent PR review activity is in the 95th percentile or higher among CodeRabbit users, so adaptive limits apply. Your next review will be available in 14 minutes. |
|
Tip For best results, initiate chat on the files or code changes.
The red CodeRabbit status is understood as a rate-limit outcome, not a code finding. Since the forced full review was also rate-limited, I can’t claim an independent diff review or approval from this run. The remaining documented residual risks are appropriately out of scope for this PR. |
Verified at
|
- redeem() re-checks redirect_uri only: CodeEntry stores no client_id and the token request never sends one, so the *client* binding from /authorize does not carry through to redemption. The comment claimed it did (RFC 6749 §4.1.3). - Safe for a public PKCE client — a stolen code is useless without the verifier — but state it as the half-step it is. No behavior change. Co-Authored-By: Claude <noreply@anthropic.com>
Verified at
|
|
@coderabbitai review |
✅ Action performedReview finished.
|
Slice 04 of the audit fixes — auth hardening, defense-in-depth (
docs/plans/2026/07/20/101-mcp-ssh-audit-fixes/04-auth-hardening.md).https://?...used to passis_allowed_redirect.redirect_uribound to the registered client — DCR persistsclient_id → redirect_uri(clientstable, no secrets);/authorizeissues a code only for an exact registered match, elseinvalid_request. Enforces RFC 6749 §4.1.2.1, and kills the case where an/authorizelink pairs a real client'sclient_idwith an attacker'sredirect_uri.["localhost","127.0.0.1"]is no DNS-rebinding guard off-loopback (a remote attacker just sendsHost: 127.0.0.1).Config::loadnow returnsConfigError::AllowedHostsRequired; loopback binds keep the safe default. Set-but-empty counts as unset. Behavior change: a non-loopback deploy withoutMCP_SSH_ALLOWED_HOSTSnow refuses to start — matches the CLAUDE.md NEVER rule; docs env tables updated.bin/checkgreen (167 tests).Residual risk — D1 is not a complete anti-phishing control.
/registeris unauthenticated by design (it bootstraps auth), so an attacker can register their ownclient_idfor their own callback and lure a victim to a self-consistent/authorizelink; the Basic prompt remains the last line of defense. An earlier revision of this description and ofdocs/architecture.mdclaimed the path was closed — corrected in b276e50. Closing it properly needs a separate decision (consent screen showing the redirect target, authenticated DCR, or a redirect-URI allowlist).Residual risk — the
clientstable is unbounded./registeris unauthenticated and now writes a durable row, with no expiry and no reaper sweep (the hourly pass sweepsaccess_tokens/refresh_tokensonly). Rows are tiny and rate limiting lives in the reverse proxy per CLAUDE.md, so this is not a merge blocker — but it can't be fixed by simply adding a sweep either: nothing ties a token back to theclient_idthat obtained it, so age-based expiry would revoke live clients. Needs its own decision (acreated_unixcolumn plus a policy, or binding tokens to their client).Upgrade note. The
clientstable starts empty, so aclient_idregistered before this deploy has no binding and gets400 invalid_requestat/authorize. Low impact:REFRESH_TTLis 365 days and each refresh resets the window, so active clients never hit/authorize; a client that does re-authorize re-runs DCR first. No migration added — flagging the behavior, not papering over it.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Bug Fixes
Documentation