maintenance: validate MCP OAuth exchanges - #4279
Conversation
|
Author remediation update: The reviewed protocol gaps are now covered: PKCE S256 is transaction-bound and verified; public and confidential clients use distinct authentication rules; the repository-known credential is restricted to explicit development mode; authorization artifacts and access tokens have enforced expiry and bounded cleanup; unsupported refresh tokens are no longer issued; request bodies are bounded; redirect, scope, and response type checks are enforced; consent uses a one-time session-bound nonce; and public endpoints use a configured HTTPS origin with URI encoding. Focused Rust validation passed (59 tests, fmt, and clippy). The current GitHub head also has successful MCP dev/release, backend, E2E, license, and label checks. The author-side blocker is resolved; maintainer review is still required. |
6be2c68 to
ac02a5d
Compare
|
Follow-up remediation: dynamic client registration is now rate-limited to 16 successful registrations per minute, idle clients expire after one hour, expired entries are pruned before the 1,024-client capacity check, and only a successful token exchange renews a client. The old head accepted the 61st anonymous registration; the new contract returns HTTP 429 with Retry-After and proves expired-capacity recovery. Full Rust validation passed: 61 tests, rustfmt, and Clippy with warnings denied. |
39eb042 to
a6429a2
Compare
|
Follow-up for the reopened registration review is available on head The reviewed head failed three new outcome contracts:
The registration limiter is now per resolved source with a bounded 4,096-source table. It uses the socket peer by default. Deployments behind a proxy can explicitly configure exact trusted CIDRs; only those peers may provide Successful token issue and rotation now keep the registered client valid through the complete refresh-token lifetime. At client capacity, only the oldest never-activated registration is reclaimed; a client with a live refresh credential is protected. All 67 Rust tests, rustfmt, Clippy for all targets/features with warnings denied, and Git whitespace checks pass locally. The three old-head failures and trusted-proxy spoofing/bounded-state cases are included in that result. GitHub CI is running on the new head. |
|
Final current-head CI update for
The PR is based on |
|
The problems this targets are real, and I verified each on master:
The new PKCE implementation looks correct to me: S256 only, URL_SAFE_NO_PAD over Sha256::digest, subtle::ct_eq for the comparison, verifier length and charset per RFC 7636, and consuming the code on verifier failure so it can't become a brute-force oracle. Single-use codes and refresh tokens, plus hard cardinality caps on every remotely-growable map, are all good. Two issues though. Refresh token is removed before issuance can fail exchange_refresh_token deletes the record first and issues afterwards: let record = refresh_tokens.remove(&request.refresh_token).ok_or(OAuthError::InvalidGrant)?; issue_tokens can legitimately fail — TemporarilyUnavailable when access_tokens.len() >= MAX_ACCESS_TOKENS or refresh_tokens.len() >= MAX_REFRESH_TOKENS (both 4096), and InvalidClient if the client record has expired and been swept. In every one of those cases the old refresh token is already gone, so the client is permanently logged out and has to re-run the full authorization flow. Near the caps this stops being a corner case and becomes systematic — exactly when a server is busiest. exchange_authorization_code has the same shape. It matters less there because codes are single-use by design and the client can re-authorize, but it's the same class of bug. Suggestion: issue first, then remove; or restore the record on the error path. state has no length bound state is copied verbatim into AuthorizationTransaction in create_authorization_transaction with no validation. MAX_AUTH_TRANSACTIONS caps the count at 4096, and MAX_OAUTH_BODY_BYTES bounds POST bodies — but /authorize is a GET, so neither constrains the size of an individual state. 4096 transactions each holding an arbitrarily large state is a memory amplification vector reachable before any authentication. Suggestion: cap state (RFC 6749 doesn't mandate a size, but a few hundred bytes is generous) and reject anything longer with invalid_request. Hardening, not blocking The approval endpoint has no failure throttling. validate_approval_secret uses ct_eq, so timing is covered, but nothing rate-limits guesses. In practice this is bounded by main.rs::approval_secret_for_mode, which requires MCP_OAUTH_APPROVAL_SECRET to be set and at least 32 characters in production mode — enumeration at that length isn't feasible. Worth noting though that 32 characters is a length floor, not an entropy floor, so a per-source failure counter plus a warning log would still be cheap insurance for the operator who sets 32 identical characters. Also: refresh tokens are rotated but a replayed old token only returns InvalidGrant — it doesn't revoke the token family. OAuth 2.1 / RFC 6819 suggest treating reuse as a compromise signal and invalidating the descendants. Optional, but it's nearly free given you already have client_id on the record. On the size of this PR This is a ~1,600-line rewrite of an authorization server bundling roughly ten independent changes — PKCE, client authentication methods, default credential removal, consent-nonce binding, redirect URI enforcement, body size limits, TTLs across five object types, registration rate limiting, trusted-proxy resolution, and client lifetime management. Each is defensible; reviewing them as one unit is hard, and if any single piece needs to be reverted the whole thing goes with it. I'd suggest splitting into a sequence — the default-credential removal and PKCE enforcement alone would be an easy, high-value first PR. To be clear, that's a process concern and it doesn't substitute for the two issues above, which I think need fixing regardless of how the work is packaged. Minor sha2, subtle and base64 are new dependencies; they'll need to go through the usual ASF dependency license review for LICENSE/NOTICE. |
What changed
none) and confidential (client_secret_post) dynamic clientsexpires_inmatch server-side validationHostRegistration lifetime and capacity
An unused registration expires after one hour. After a successful authorization-code or refresh-token exchange, the registered client remains valid through the complete 24-hour lifetime of the issued refresh token; rotation extends both together.
Registration admission is scoped by resolved source, so one caller cannot occupy every caller's window. The source table is capped at 4,096 entries. Direct deployments use the accepted TCP peer. Reverse-proxy deployments may set
MCP_OAUTH_TRUSTED_PROXY_CIDRSto exact proxy networks; only those peers may supplyX-Forwarded-For, the rightmost untrusted hop is selected, malformed or overlong chains fall back to the socket peer, and/0trust is rejected.The client store remains capped at 1,024 entries. Expired entries are pruned first. If anonymous clients fill the remaining capacity, the oldest client that has never received a refresh token is reclaimed. A client with a live refresh credential is not evicted. This keeps open MCP registration interoperable without allowing unapproved registrations to permanently reserve the store.
Operator and compatibility impact
Production requires:
There is no built-in production client credential. OAuth-capable clients dynamically register and use PKCE. Existing tokens from the previous in-memory implementation do not survive a process restart and are intentionally invalid after this protocol correction.
Regression proof
The reviewed head failed three new outcome contracts:
All three now pass. Additional contracts prove the rate-limit source table remains bounded, trusted proxy chains resolve the rightmost untrusted address, untrusted peers cannot spoof forwarding headers, and invalid or address-family-wide proxy CIDRs are rejected.
Validation
cargo +1.88.0 test --manifest-path mcp-servers/mcp-bash-server/Cargo.toml— 67 passedcargo +1.88.0 clippy --all-targets --all-features --manifest-path mcp-servers/mcp-bash-server/Cargo.toml -- -D warningscargo +1.88.0 fmt --manifest-path mcp-servers/mcp-bash-server/Cargo.toml -- --checkgit diff --checkAI assistance: used for draft implementation and test iteration.
Human validation: reproduced the global-window, client/refresh lifetime, and unactivated-capacity failures; then ran the complete Rust suite, Clippy, rustfmt, peer/proxy admission, protected eviction, PKCE, expiry, refresh rotation, replay, redirect encoding, oversized-body, and public-base-URL contracts.
Risk notes: OAuth state remains process-local; a multi-instance deployment must provide sticky routing or replace the store with shared bounded state. A reverse proxy must be listed narrowly and must overwrite or safely append
X-Forwarded-For.