Skip to content

fix(security): port fastmcp v4 CWE-601 checks to multi-instance OAuth proxy#38

Merged
ahmedAmien merged 10 commits into
mainfrom
fix/oauth-cwe601-multi-instance
Apr 22, 2026
Merged

fix(security): port fastmcp v4 CWE-601 checks to multi-instance OAuth proxy#38
ahmedAmien merged 10 commits into
mainfrom
fix/oauth-cwe601-multi-instance

Conversation

@artiom
Copy link
Copy Markdown
Contributor

@artiom artiom commented Apr 21, 2026

Summary

fastmcp 4.0.0 (upstream commit) hardened OAuthProxy against CWE-601 open-redirect / authorization-code theft. The fix added validation in four public methods — all of which RedisOAuthProxy overrides without delegating to super. As a result, the v4.0.0 security guarantees were silently bypassed on every deployment of this server after the Dependabot bump (#28) landed.

This has been verified live against https://mcp.browserless.io:

  • POST /oauth/register with redirect_uris: ["https://evil.attacker.com/steal"] → returns 200 with a client_id/secret.
  • GET /oauth/authorize?client_id=arbitrary-attacker-id&redirect_uri=https%3A%2F%2Fevil.attacker.com%2Fsteal&response_type=code&state=probe → returns 302 to the upstream Supabase authorize page. The attacker-controlled URL is persisted on the transaction; once a victim authenticates at Supabase, the MCP server's callback would forward the fresh authorization code to the attacker's host.

This PR ports the v4 checks, extends them to work in the multi-instance deployment, and narrows allowedRedirectUriPatterns to the known-good list instead of the removed https://* fallback.

Why the bypass happens

The v4 checks read from OAuthProxy.registeredClients — a process-local Map populated by DCR. The RedisOAuthProxy overrides (authorize, exchangeAuthorizationCode, handleCallback) re-implement their logic from scratch against Redis and never call super, so:

  1. The CWE-601 check is skipped. Attacker-controlled redirect_uri is accepted at /oauth/authorize.
  2. Multi-instance is architecturally broken if the check were applied. DCR on instance A leaves instance B's Map empty — B would reject every legitimate redirect_uri under the v4 check.

The stale comment in redis-oauth-proxy.ts:24-27 ("registeredClients Map is write-only (never read by other methods)") was true in v3.33 and no longer is.

What changed

  • Redis-backed DCR registry. registerClient override mirrors every accepted redirect_uri to Redis under mcp:oauth:client:<uri> with a TTL. New isClientRegistered(uri) helper falls back to Redis when the local Map misses — consistent answers across single- and multi-instance deployments.
  • authorize() enforces client_id === upstreamClientId (RFC 6749 §5.2) and isClientRegistered(redirect_uri) (RFC 6749 §3.1.2.3).
  • exchangeAuthorizationCode() enforces the client_id check on the non-PKCE branch as well, so a stolen code can't be redeemed by an arbitrary caller.
  • handleCallback() adds the defense-in-depth registry check on transaction.clientCallbackUrl and purges the transaction on failure.
  • Allowlist default replacedhttps://* is gone. New baseline encodes the real clients that DCR against prod today (verified by probing the live service):
    • http://localhost:*, http://127.0.0.1:* — Claude Desktop, Cursor, VS Code, Windsurf, any loopback-callback client
    • https://claude.ai/api/mcp/auth_callback — Claude.ai web custom connectors
    • https://chatgpt.com/connector_platform_oauth_redirect — ChatGPT MCP connector
  • OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS env var (comma-separated) lets deployments append patterns on top of the baseline without forking the code. New MCP hosts / staging domains go here.
  • consentRequired: true passed to PassthroughOAuthProvider but the proxy config hardcoded false. Harmless today (proxy config wins) but misleading — both false now, matching the passthrough token-swap intent.
  • Dependency housekeeping. tsconfig.json gets types: ["node", "mocha"] so @types/mocha is picked up under moduleResolution: Node16, and the now-vestigial baseUrl entry is removed so tsc stops warning about the TS 6.0 deprecation without a suppression flag.

Tests

New test/lib/redis-oauth-proxy.spec.ts (uses ioredis-mock):

  • DCR mirrors every URI to Redis with TTL; rejects patterns that fail DCR validation.
  • authorize: unknown client_id, unregistered redirect_uri, attacker URI with matching client_id, cross-instance DCR via shared Redis, transaction persisted.
  • exchangeAuthorizationCode: unknown client_id on non-PKCE path.
  • handleCallback defense-in-depth: callback on instance B after DCR revocation, transaction purged, upstream token endpoint never called.
  • Happy path: full authorize → callback, code stored in Redis, 302 back to registered callback.

All 171 tests pass. 10 new tests added.

Test plan

  • npm test — 171 passing, 0 failing
  • npx tsc — clean exit
  • Probed prod https://mcp.browserless.io → confirmed current vulnerability (DCR accepts evil, authorize returns 302 for arbitrary client_id)
  • Verified prod known clients against the new default allowlist — claude.ai, chatgpt.com, and localhost all match
  • Deploy to test env (https://dev-mcp.browserless.io), verify:
    • Claude.ai web + ChatGPT MCP connector can still complete the OAuth flow end-to-end
    • Claude Desktop / Cursor / VS Code can still complete the OAuth flow (localhost)
    • Multi-instance: DCR on one instance, authorize+callback+exchange across different instances via the LB
    • Attempt the CWE-601 PoC — confirm invalid_client for arbitrary client_id, invalid_request for unregistered redirect_uri
  • Promote to prod

Deployment notes for operators

No terraform change required for the current prod deployment — the known-good list is baked in. If a future MCP client appears on a new host (staging, new integration, etc.), extend at runtime without shipping a code change:

OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS=https://new-host.example.com/*

Deployments outside of Browserless that embed this server can override the baseline by setting OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS to their own client list — the baseline does not prevent anything, it just guarantees the Browserless-hosted path works without extra config.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Configurable OAuth allowed redirect URI patterns via environment variable (defaults preserved).
    • Dynamic Client Registration persisted in Redis for cross-instance consistency.
  • Security Improvements

    • Enforces client_id and registered redirect URI checks across auth and token flows.
    • Rejects and purges requests using unregistered or attacker-controlled callbacks.
  • Changes

    • OAuth consent prompt disabled by default.
  • Chores

    • Added Redis mock dev dependencies for testing.
  • Tests

    • New and expanded tests covering DCR, shared registry, validation, rollback, and atomic code exchange.

artiom and others added 2 commits April 21, 2026 15:00
- `ignoreDeprecations: "6.0"` silences the TS5101 baseUrl deprecation error
  so `npm test` and `npm run build` exit cleanly. TypeScript 6.0 aborts
  further checks on this error, which was masking pre-existing type issues in
  the test suite.
- `types: ["node", "mocha"]` explicitly includes the mocha globals that
  Node16 moduleResolution no longer auto-picks up, so `describe`/`it`/
  `beforeEach` resolve without each test file importing them.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…h proxy

fastmcp 4.0.0 hardened `OAuthProxy` against CWE-601 open-redirect /
authorization-code theft (see commit 5478753 in the upstream repo). Four
public methods now enforce checks against the in-memory `registeredClients`
Map populated by DCR. Because `RedisOAuthProxy` overrides those methods and
never delegates to `super`, the new security guarantees were being
silently bypassed on every deployment of this server.

The Map-based check also doesn't work in multi-instance deployments: DCR on
instance A leaves instance B's Map empty, so a callback load-balanced to B
would be rejected as unregistered — or, as in our case, the check is skipped
entirely and every redirect_uri is accepted.

Changes
-------
- `RedisOAuthProxy.registerClient` override mirrors every accepted
  redirect_uri to Redis under `mcp:oauth:client:<uri>` with a TTL so other
  instances can honor the v4 check.
- `isClientRegistered(uri)` helper falls back to Redis when the local Map
  misses, giving single-instance and multi-instance deployments a consistent
  answer.
- `authorize()` override now enforces:
    - `client_id === upstreamClientId` (invalid_client) — RFC 6749 §5.2
    - `redirect_uri` is registered (invalid_request) — RFC 6749 §3.1.2.3
- `exchangeAuthorizationCode()` override now enforces the client_id check
  on the non-PKCE branch too, so a stolen code can't be redeemed.
- `handleCallback()` override adds the defense-in-depth check on
  `transaction.clientCallbackUrl` and purges the transaction on failure.
- `PassthroughOAuthProvider` no longer defaults `allowedRedirectUriPatterns`
  to `https://*` — the exact pattern fastmcp v4 removed as unsafe because it
  lets an attacker DCR `https://evil.example.com/*`. New
  `OAUTH_ALLOWED_REDIRECT_URI_PATTERNS` env var is comma-separated;
  defaults are `http://localhost:*` and `http://127.0.0.1:*`.
- Fix `consentRequired: true` passed to PassthroughOAuthProvider while
  `createProxy` hardcoded `false` — harmless but misleading; both `false`
  now, matching the passthrough token-swap intent.
- Update the stale "registeredClients Map is write-only" design comment.

Tests
-----
New `test/lib/redis-oauth-proxy.spec.ts` covers:
  - DCR mirrors every URI to Redis with TTL, rejects patterns that fail DCR.
  - authorize: unknown client_id, unregistered redirect_uri, attacker URI
    with matching client_id, cross-instance DCR via shared Redis, tx persist.
  - exchangeAuthorizationCode: unknown client_id on non-PKCE path.
  - handleCallback defense-in-depth: callback on instance B after DCR
    revocation, transaction purge, upstream token endpoint not called.
  - Happy path: full authorize → callback → code stored in Redis.

Uses `ioredis-mock` as a dev dep. Shared in-memory state across instances
simulates real Redis; a `flushall` between tests resets the registry while
still allowing cross-instance behavior within a single test.

Updates 10 existing test fixtures to add the new `oauthAllowedRedirectUriPatterns`
field on the `McpConfig` literal.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Added configurable OAuth redirect allow-list and persisted Dynamic Client Registration (DCR) in the Redis-backed OAuth proxy; enforced stricter client_id/redirect_uri checks and atomic code consumption; added ioredis-mock dev deps, comprehensive tests for cross-instance behavior, and updated test fixtures and tsconfig.

Changes

Cohort / File(s) Summary
Configuration & Types
src/config.ts, tsconfig.json
Added oauthAllowedRedirectUriPatterns: string[] to McpConfig, introduced DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS and parseRedirectUriPatterns(...); adjusted tsconfig types.
Application Entry
src/index.ts
Passthrough OAuth proxy now reads config.oauthAllowedRedirectUriPatterns; consentRequired set to false.
Redis OAuth Proxy
src/lib/redis-oauth-proxy.ts
Persisted DCR registrations to Redis with TTL, added registerClient() and isClientRegistered() (local+Redis), hardened authorize(), handleCallback(), and exchangeAuthorizationCode() validations, and switched to atomic getdel for code consumption.
Dev dependencies
package.json
Added ioredis-mock and @types/ioredis-mock to devDependencies.
Tests — New & Updated
test/lib/redis-oauth-proxy.spec.ts, test/integration/server.spec.ts, test/lib/api-client.spec.ts, test/resources/resources.spec.ts, test/tools/*.spec.ts, test/lib/config.spec.ts
New comprehensive RedisOAuthProxy integration tests using ioredis-mock; many test fixtures updated to include oauthAllowedRedirectUriPatterns: [].

Sequence Diagram

sequenceDiagram
    participant Client as Client App
    participant Proxy as Redis OAuth Proxy
    participant Redis as Redis
    participant Auth as OAuth Server
    participant CB as Callback Handler

    Client->>Proxy: registerClient(DCR request)
    Proxy->>Redis: store redirect_uri entries with TTL
    Proxy-->>Client: respond with client_id

    Client->>Proxy: authorize(client_id, redirect_uri)
    Proxy->>Redis: isClientRegistered(redirect_uri)?
    alt registered
        Proxy->>Redis: persist transaction
        Proxy-->>Client: redirect to Auth server (302)
    else not registered
        Proxy-->>Client: error invalid_request
    end

    Auth->>CB: callback(code, state)
    CB->>Proxy: handleCallback(code, state)
    Proxy->>Redis: getdel transaction
    Proxy->>Redis: isClientRegistered(transaction.callback_url)?
    alt still registered
        Proxy->>Auth: exchange code for token
        Auth-->>Proxy: token
        Proxy-->>CB: redirect to registered callback URL (302)
    else not registered
        Proxy->>Redis: delete transaction
        Proxy-->>CB: error invalid_request
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • PLT-953: Matches cross-instance Redis-backed OAuth proxy enhancements and DCR persistence implemented in this PR.

Possibly related PRs

Suggested reviewers

  • ahmedAmien

Poem

🐰 I nibble tokens, store each hop,
TTL carrots in Redis crop,
Redirects noted, patterns in rows,
Transactions safe where the cold wind blows,
Hop — auth is snug, then off I pop.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main security fix: porting CWE-601 checks from fastmcp v4 to the multi-instance OAuth proxy, which aligns with the core purpose of this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

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

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 5 additional findings.

Open in Devin Review

coderabbitai[bot]

This comment was marked as resolved.

Probing the prod deployment (https://mcp.browserless.io) with the narrow
localhost-only defaults introduced in the previous commit would have broken
two real clients that DCR against this server today:

- https://claude.ai/api/mcp/auth_callback (Claude.ai web custom connectors)
- https://chatgpt.com/connector_platform_oauth_redirect (ChatGPT MCP connector)

Encode the verified set in the repo so the server works out-of-the-box for
the hosted deployment without any extra env wiring, and rename the
extension knob from `OAUTH_ALLOWED_REDIRECT_URI_PATTERNS` (replace) to
`OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS` (append) to match the new
semantics — the env var now adds to the baseline rather than replacing it.

Prod vulnerability context: with the pre-v4 `https://*` default, DCR on
prod currently accepts any HTTPS URI including attacker-controlled hosts,
and /oauth/authorize returns 302 for arbitrary client_id + evil redirect
(verified live). Narrowing to this explicit list closes CWE-601 without
disrupting known clients.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
src/config.ts (2)

30-46: Consider deployment-scoped defaults for redirect allow-list.

These defaults now trust hosted-specific callbacks (claude.ai, chatgpt.com) for every deployment. For least-privilege in self-hosted installs, keep loopback-only defaults and gate hosted callbacks behind an explicit env/profile toggle.

Proposed refactor
 const DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS = [
   'http://localhost:*',
   'http://127.0.0.1:*',
-  'https://claude.ai/api/mcp/auth_callback',
-  'https://chatgpt.com/connector_platform_oauth_redirect',
 ];
+
+const HOSTED_ALLOWED_REDIRECT_URI_PATTERNS = [
+  'https://claude.ai/api/mcp/auth_callback',
+  'https://chatgpt.com/connector_platform_oauth_redirect',
+];
 
 function parseRedirectUriPatterns(raw: string | undefined): string[] {
+  const includeHosted = process.env.OAUTH_INCLUDE_HOSTED_REDIRECTS === 'true';
   const additional = (raw ?? '')
     .split(',')
     .map((s) => s.trim())
     .filter((s) => s.length > 0);
-  return [...DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS, ...additional];
+  return [
+    ...DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS,
+    ...(includeHosted ? HOSTED_ALLOWED_REDIRECT_URI_PATTERNS : []),
+    ...additional,
+  ];
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.ts` around lines 30 - 46, DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS
currently includes hosted-specific callbacks which should not be trusted by
default in self-hosted installs; change the constant to only include loopback
entries ('http://localhost:*', 'http://127.0.0.1:*') and remove the
claude.ai/chatgpt.com entries, then add a gated mechanism (new env var like
ENABLE_HOSTED_REDIRECTS or a deployment profile check) in the config
initialization or parseRedirectUriPatterns to append the hosted patterns when
that toggle is explicitly enabled; reference
DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS, OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS
and parseRedirectUriPatterns so you update the constant and extend/merge hosted
patterns only when the new env/profile flag is true.

48-54: Optional: de-duplicate merged redirect patterns.

If additional patterns repeat defaults, config will carry duplicates. A stable de-dup keeps behavior deterministic and avoids redundant checks.

Small cleanup
 function parseRedirectUriPatterns(raw: string | undefined): string[] {
   const additional = (raw ?? '')
     .split(',')
     .map((s) => s.trim())
     .filter((s) => s.length > 0);
-  return [...DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS, ...additional];
+  return Array.from(
+    new Set([...DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS, ...additional]),
+  );
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/config.ts` around lines 48 - 54, The merged redirect patterns in
parseRedirectUriPatterns may contain duplicates when additional patterns repeat
defaults; update parseRedirectUriPatterns to return a stable de-duplicated array
(preserving order) by combining DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS and
additional and removing duplicates—e.g. use a Set or filter to keep the first
occurrence of each pattern so DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS remain first
and repeated entries from additional are dropped.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/config.ts`:
- Around line 30-46: DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS currently includes
hosted-specific callbacks which should not be trusted by default in self-hosted
installs; change the constant to only include loopback entries
('http://localhost:*', 'http://127.0.0.1:*') and remove the
claude.ai/chatgpt.com entries, then add a gated mechanism (new env var like
ENABLE_HOSTED_REDIRECTS or a deployment profile check) in the config
initialization or parseRedirectUriPatterns to append the hosted patterns when
that toggle is explicitly enabled; reference
DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS, OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS
and parseRedirectUriPatterns so you update the constant and extend/merge hosted
patterns only when the new env/profile flag is true.
- Around line 48-54: The merged redirect patterns in parseRedirectUriPatterns
may contain duplicates when additional patterns repeat defaults; update
parseRedirectUriPatterns to return a stable de-duplicated array (preserving
order) by combining DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS and additional and
removing duplicates—e.g. use a Set or filter to keep the first occurrence of
each pattern so DEFAULT_ALLOWED_REDIRECT_URI_PATTERNS remain first and repeated
entries from additional are dropped.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: cbdcb5a9-55f1-4d76-af5d-eeb19244ee9c

📥 Commits

Reviewing files that changed from the base of the PR and between 5bc0968 and f3a4a4d.

📒 Files selected for processing (1)
  • src/config.ts

devin-ai-integration[bot]

This comment was marked as resolved.

artiom and others added 2 commits April 21, 2026 15:44
Previous commit (483a22e) added `ignoreDeprecations: "6.0"` to work around
the TS 6.0.3 `baseUrl` deprecation error. Proper fix: just delete `baseUrl`.
It was `"."` with no `paths` entries and no non-relative imports in src/ or
test/, so under `moduleResolution: Node16` it was dead weight. Removing it
makes `tsc` exit 0 without a suppression, so future deprecation changes
won't be silently absorbed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per review feedback from Devin. Today the two are identical (fastmcp v4
registerClient writes `request.redirect_uris` to the Map and echoes them
back in the response), but using `response.redirect_uris` is the
authoritative output of the parent's validation, so Redis stays in sync
with the local Map even if upstream starts normalizing URIs during
registration. DCRResponse.redirect_uris is a required string[] per the
fastmcp type, so no nullish fallback is needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@artiom
Copy link
Copy Markdown
Contributor Author

artiom commented Apr 21, 2026

Review triage

Applied one fix, declined the rest with rationale.

Applied (55d2f1e):

  • Devin: mirror response.redirect_uris to Redis instead of request.redirect_uris. Verified in fastmcp v4 source that the two are identical today (same reference), but the response is the authoritative accepted set — switching harnesses against future upstream URI normalization at zero cost.

Declined / resolved:

  • CodeRabbit — config.ts:40 (fallback when parsed list is empty): stale. The comment was posted against commit 5bc0968 where the env var replaced defaults. Commit f3a4a4d changed semantics to append ([...DEFAULT, ...additional]), so an empty env var naturally yields defaults.
  • CodeRabbit — config.ts:30-46 (gate hosted callbacks behind an env flag for self-hosted installs): conflicts with the deployment-ownership direction this PR was written to implement — the repo hosts mcp.browserless.io and the baseline encodes its known-good clients so the server works OOTB without infra changes. The list is also a subset for self-hosted installs; loopback patterns remain and inactive hosted patterns don't create additional attack surface (they simply never match). Happy to revisit if/when someone wants to fork this for a different deployment.
  • CodeRabbit — config.ts:48-54 (dedupe merged patterns): purely cosmetic — matchesPattern short-circuits on first hit, so a handful of dupes cost nothing. Not worth churn.
  • CodeRabbit — api-client.spec.ts:22 (centralize mock McpConfig factory): would touch 11 spec files for a cosmetic refactor; that duplication pre-dates this PR. Reasonable follow-up, not in scope here.

devin-ai-integration[bot]

This comment was marked as resolved.

… rollback

Three issues raised in review, all cross-instance correctness bugs in the
security path this PR is meant to patch.

Atomic one-time redemption (code-reviewer, test-analyzer)
---------------------------------------------------------
`exchangeAuthorizationCode` now uses Redis GETDEL to consume the code
atomically. Previously, two concurrent redemptions on different instances
could both GET the code, both pass validation, and both issue tokens — the
parent's `used` flag is in-memory and wasn't being persisted to Redis, so
the non-PKCE `clientCode.used` check was dead code. GETDEL guarantees only
one caller pulls a value regardless of instance, so the `used` check is
removed and the redundant `redis.del` after success is dropped (GETDEL
already consumed the key).

DCR mirror rollback on Redis failure (silent-failure-hunter)
------------------------------------------------------------
`registerClient` used `Promise.all`, so a Redis write failure would leave
the parent's in-memory registeredClients Map populated while Redis was
partial or empty — the exact cross-instance inconsistency this class
exists to prevent. Switched to `Promise.allSettled`; on any rejection,
roll back the parent's Map entries and best-effort delete any Redis keys
that succeeded before surfacing the error.

Tests
-----
- Non-PKCE happy path: verifies upstream access/refresh tokens flow through
- Non-PKCE one-time use: second redemption of the same code → invalid_grant
- Cross-instance atomic race: two proxies share Redis and race on the same
  code; exactly one wins (proves GETDEL vs GET+DEL)
- registerClient rollback: stubs redis.set to reject, asserts authorize
  then rejects the URI as unregistered (both layers cleaned up)
- parseRedirectUriPatterns: dedicated spec for append semantics,
  whitespace/comma parsing, empty-input → baseline fallback

171 → 180 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@artiom
Copy link
Copy Markdown
Contributor Author

artiom commented Apr 21, 2026

Review triage round 2 — post code-review-toolkit agents

Ran the four pr-review-toolkit agents (code-reviewer, pr-test-analyzer, silent-failure-hunter, comment-analyzer) against the branch. Applied the critical + important findings in 0ec4890; deferring the rest with rationale below.

Applied (0ec4890)

  1. Atomic one-time redemption (code-reviewer + test-analyzer). exchangeAuthorizationCode now uses Redis GETDEL instead of GET + DEL. The previous clientCode.used check was dead code — used is in-memory on the parent and never written back to Redis — so two concurrent redemptions on different instances could both succeed before either DEL'd. Removed the dead check and redundant post-success del.
  2. DCR mirror rollback (silent-failure-hunter C1). registerClient switched from Promise.all to Promise.allSettled. On any rejection, roll back the parent's in-memory Map entries and best-effort delete any Redis keys that succeeded before surfacing the error. Without this, a partial failure would leave this instance accepting URIs that others reject — reintroducing the exact cross-instance inconsistency this PR exists to fix.
  3. Tests added (9 new, 180 total):
    • Non-PKCE happy path — verifies upstream tokens flow through
    • Non-PKCE one-time use — second redemption → invalid_grant
    • Cross-instance atomic race — two proxies race on one code; GETDEL guarantees exactly one wins
    • registerClient rollback — stubs redis.set to reject, then asserts authorize rejects the URI (both layers cleaned up)
    • 5 direct tests for parseRedirectUriPatterns (baseline, append, whitespace/comma, empty segments, empty env)
  4. Comment softening (comment-analyzer I3) — the "authoritative accepted set" phrasing replaced with accurate "today identical" + "stays correct if fastmcp normalizes" hedge.

Deferred (follow-up, not this PR)

  • Security rejection telemetry (silent-failure-hunter I5). Requires a logging-framework decision the repo hasn't made — today there's no logError pattern. Legitimate gap, but scope belongs in its own PR that wires up observability for all OAuth events.
  • Malformed Redis payload handling (silent-failure-hunter I3). A JSON.parse crash on a corrupt state key is a real edge case but doesn't affect correctness of the security path. Can be hardened later without blocking this fix.
  • Redis outage → "expired state" ambiguity (silent-failure-hunter C2). UX, not correctness. Fails-closed already holds.
  • parseRedirectUriPatterns schema validation (silent-failure-hunter S2). Nice-to-have startup check.
  • Exact-string attacker variants (trailing slash, case) — the check delegates to registeredClients.has(uri), whose semantics are covered by fastmcp's own upstream regression suite. Duplicating here would test the library, not this code.
  • Bullet asymmetry in config comment (comment-analyzer Important MCP Server v0.1.0 — Power Scraper tool, resources, and prompts #1) — cosmetic.

coderabbitai[bot]

This comment was marked as resolved.

Three CodeRabbit findings on top of 0ec4890.

1. `registerClient` rollback only clears entries this call introduced
   ---------------------------------------------------------------
   Prior rollback unconditionally deleted every URI in the response when a
   Redis write failed, which could deregister a valid prior registration if
   two DCR calls overlapped on a redirect_uri (same client re-registering,
   or two clients colliding on a localhost port). Snapshot local-Map +
   Redis pre-existence before mutating state; on failure, only roll back
   entries not in either snapshot. New test exercises the overlap case.

2. Document Valkey / Redis 6.2+ requirement for GETDEL
   ---------------------------------------------------
   Header comment now calls out that the token-exchange atomicity relies on
   GETDEL (Redis 6.2+, all Valkey releases). No runtime version probe —
   the deployment target (DO managed Valkey) predates the code, so failing
   at startup on an incompatible store would be a configuration error, not
   a runtime mystery. The command-not-supported error from the store would
   be loud enough.

3. `config.spec.ts` asserts full baseline membership
   ------------------------------------------------
   Replaced partial `include` + length checks with `have.members` against
   a `BASELINE_PATTERNS` constant so substituting any baseline entry with
   an unintended value would fail the test. Matters because the baseline
   is the security-sensitive allowlist shipped with the repo.

180 → 181 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@artiom
Copy link
Copy Markdown
Contributor Author

artiom commented Apr 21, 2026

Review triage round 3 — 02068be

All three open CodeRabbit findings addressed. Context on Valkey since we run on it.

Applied

  1. Rollback scope narrowed (comment). registerClient now snapshots both the local Map and Redis for pre-existing URIs before touching state; on mirror failure it only rolls back entries this call introduced, leaving prior registrations intact. New overlap test asserts that a failed second DCR on a shared URI doesn't deregister the first.
  2. Valkey / Redis 6.2+ requirement documented (comment). Header comment in src/lib/redis-oauth-proxy.ts spells out that the token-exchange atomicity relies on GETDEL (Redis 6.2+ — all Valkey releases support it). Skipping the runtime version probe: we run on DO managed Valkey, which is fork-based on Redis 7.2 and has had GETDEL since inception. If an operator ever deployed against a genuinely old store, the ERR unknown command 'getdel' from the server is loud enough without adding startup-time complexity.
  3. Config tests tightened (comment). Replaced include + length with have.members(BASELINE_PATTERNS) so any baseline entry being substituted — not just removed — fails the test. Matters because the baseline is the security-sensitive allowlist.

Test totals

180 → 181 passing (+ 1 new rollback-preserves-prior-registration test).

@artiom
Copy link
Copy Markdown
Contributor Author

artiom commented Apr 21, 2026

@coderabbitai please re-review — all previous actionable findings have been addressed across commits 55d2f1e, 0ec4890, and 02068be. PR head is now 02068be with 181 tests passing.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me fetch the current state of the key files for a thorough re-review.


🏁 Script executed:

#!/bin/bash
echo "=== src/lib/redis-oauth-proxy.ts ==="
cat src/lib/redis-oauth-proxy.ts

echo ""
echo "=== src/config.ts ==="
cat src/config.ts

echo ""
echo "=== src/index.ts (OAuth-related sections) ==="
cat src/index.ts

echo ""
echo "=== test/lib/redis-oauth-proxy.spec.ts ==="
cat test/lib/redis-oauth-proxy.spec.ts

echo ""
echo "=== test/lib/config.spec.ts ==="
cat test/lib/config.spec.ts

Length of output: 43457


@artiom: Thank you for the detailed triage notes across all three rounds. I've reviewed the current state at 02068be against all previously raised and addressed findings. The core CWE-601 protections, atomic GETDEL, rollback semantics, defence-in-depth in handleCallback, and the tightened allowlist all look correct and well-tested. One subtle gap remains in the rollback path.


Remaining finding: redis.exists failure after super.registerClient() bypasses Map cleanup

File: src/lib/redis-oauth-proxy.ts, registerClient

The snapshot of pre-existing Redis keys happens after super.registerClient(request) has already populated the parent's in-memory registeredClients Map:

// ① parent Map is populated here
const response = await super.registerClient(request);
// ...
const redisPreExisting = new Set<string>();
await Promise.all(              // ② if redis.exists throws here ...
  response.redirect_uris.map(async (uri) => {
    if ((await this.redis.exists(`${CLIENT_PREFIX}${uri}`)) > 0) {
      redisPreExisting.add(uri);
    }
  }),
);
// ③ ... the writes and the `failed` branch are never reached,
//    so the Map cleanup code never runs
const writes = await Promise.allSettled(...);
const failed = writes.find(...);
if (failed) { /* rollback — unreachable if redis.exists threw */ }

If Redis is down (connection failure) when the exists calls fire, super.registerClient has already written the URI to the local registeredClients Map. The Promise.allSettled write block is never entered, so the failed guard and its rollback never run. The result is this instance accepting the URI via the local Map while every other instance rejects it — the cross-instance inconsistency the PR is designed to prevent.

Minimal fix — use Promise.allSettled for the existence checks and treat errors as "not pre-existing" (conservative: a key that can't be confirmed as pre-existing is treated as new, meaning it will be deleted on rollback, which is the safe direction):

const redisPreExistingResults = await Promise.allSettled(
  response.redirect_uris.map(async (uri) => ({
    uri,
    exists: (await this.redis.exists(`${CLIENT_PREFIX}${uri}`)) > 0,
  })),
);
const redisPreExisting = new Set<string>(
  redisPreExistingResults
    .filter(
      (r): r is PromiseFulfilledResult<{ uri: string; exists: boolean }> =>
        r.status === 'fulfilled' && r.value.exists,
    )
    .map((r) => r.value.uri),
);

// If the check itself failed (Redis down), roll back the parent Map synchronously
// before attempting any writes — no Redis keys have been written yet.
const checkFailed = redisPreExistingResults.find(
  (r): r is PromiseRejectedResult => r.status === 'rejected',
);
if (checkFailed) {
  for (const uri of response.redirect_uris) {
    if (!localPreExisting.has(uri)) {
      this._internal.registeredClients.delete(uri);
    }
  }
  throw checkFailed.reason;
}

This ensures that regardless of where the Redis failure occurs — exists check, set writes, or both — the Map is always rolled back to a consistent state before the error surfaces.


Everything else looks clean: the GETDEL atomicity, handleCallback purge-on-rejection, allowlist narrowing, have.members baseline assertion in config tests, the Valkey/Redis 6.2 header comment, and consentRequired: false alignment all look correct. The test suite provides solid coverage for the intended security properties.

Caught by CodeRabbit on the re-review of 02068be. The pre-existence probe
used Promise.all, so a Redis connection failure during exists() would
propagate before reaching the Promise.allSettled writes block — which
hosts the rollback branch. Net effect: super.registerClient had populated
the parent's in-memory registeredClients Map, and the error escaped
registerClient without cleanup, leaving this instance accepting the URI
via Map while every other instance rejected it (the exact cross-instance
inconsistency this class prevents).

Switched the probe to Promise.allSettled. On any probe rejection, roll
back the Map synchronously (no writes have been attempted, so no Redis
cleanup is needed) and surface the originating error. Extracted
`rollbackLocalMap` so both failure paths share it.

New test stubs redis.exists to reject, asserts registerClient throws, and
then asserts authorize() rejects the URI as unregistered — proving both
layers are clean after the fault.

181 → 182 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@artiom
Copy link
Copy Markdown
Contributor Author

artiom commented Apr 21, 2026

Round 4 — 1f090cf

Thanks @coderabbitai, legit bug. Applied.

Root cause: the pre-existence probe I added in 02068be used Promise.all. A Redis failure during exists() would propagate out of registerClient before reaching the Promise.allSettled writes block — which hosts the rollback. Net effect: super.registerClient had populated the parent Map, and the error escaped with no cleanup — exactly the cross-instance inconsistency this class exists to prevent, now hit via a different path.

Fix: probe with Promise.allSettled too; on probe rejection, roll back Map synchronously (no writes attempted, so no Redis cleanup needed) and rethrow. Extracted rollbackLocalMap so both probe-fail and write-fail paths share it.

New test stubs redis.exists to reject, asserts registerClient throws, then asserts authorize() subsequently rejects the URI as unregistered — proving the Map and Redis are both clean after the fault.

181 → 182 tests passing.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

Let me verify the fix at 1f090cf before confirming.


🏁 Script executed:

#!/bin/bash
echo "=== src/lib/redis-oauth-proxy.ts (registerClient + rollbackLocalMap) ==="
cat src/lib/redis-oauth-proxy.ts

Length of output: 14155


@artiom: Verified at 1f090cf — the fix is correct. Promise.allSettled on the probe, early rollbackLocalMap() before any writes, shared helper between both failure paths, and redisPreExisting built conservatively from fulfilled probes only. All three error scenarios are now handled cleanly:

Failure point Map cleanup Redis cleanup
redis.exists probe fails rollbackLocalMap() (sync) Not needed — no writes attempted
redis.set write fails rollbackLocalMap() (sync) Best-effort del via Promise.allSettled

The new test (stub exists → reject, assert registerClient throws, assert authorize then rejects the URI) closes the coverage gap for this path. 182 tests and the implementation look solid — this PR is in good shape.


🧠 Learnings used
Learnt from: ahmedAmien
Repo: browserless/enterprise PR: 1334
File: src/shared/utils/link-discovery.ts:93-100
Timestamp: 2026-03-12T19:06:59.485Z
Learning: In `src/shared/utils/link-discovery.ts` (browserless/enterprise PR `#1334`), `getPatternRegex` is a private function with exactly three call sites: one inside `validatePathPatterns` itself (preload/validation step), and two inside `pathIncluded`/`pathExcluded`. All patterns reaching `pathIncluded`/`pathExcluded` come from `CrawlConfig.includePaths`/`CrawlConfig.excludePaths`, which are only populated after `validatePathPatterns` has run in `buildCrawlConfigFromBody`. No try/catch is needed inside `getPatternRegex` — every code path is pre-validated.

Learnt from: ahmedAmien
Repo: browserless/enterprise PR: 1365
File: src/shared/3rd-party/proxies/ProxyingEngine.ts:263-279
Timestamp: 2026-03-18T01:44:37.937Z
Learning: In `src/shared/3rd-party/proxies/ProxyingEngine.ts` (browserless/enterprise), the `preflightCheck` method intentionally uses `Promise.all` to fire all stale vendor health checks in parallel rather than awaiting them sequentially in preference order. The rationale: with a 4s per-endpoint timeout, sequential awaiting degrades worst-case latency from ~4s to ~16s (4 vendors × 4s) when all vendors fail. The 30s TTL fast-path (`isRecentlyHealthy`) already handles the common case with no I/O. Do not flag `Promise.all` here as a blocking concern.

The parent's `registeredClients` Map was a same-instance read-cache layered
over Redis. It saved ~1ms per authorize/callback round-trip on the hot path
but was the root of every bug we iterated on across rounds 3–5: over-deletion
on rollback, `localPreExisting` snapshot logic, probe-failure leaking the
parent Map populated. The cache's value (partial same-instance resilience
during Redis blips) was weak — other instances were rejected anyway.

`isClientRegistered` now queries Redis directly. `registerClient` still calls
`super` (the parent populates the Map as a side effect — we just don't read
from it) but drops all Map-rollback plumbing; on failure we only clean up
Redis. Redis pre-existence probe stays to avoid DEL'ing a concurrent valid
registration. Net: -60 lines of coordination logic, one clearer invariant
("Redis is the single source of truth for DCR state").

Added a constructor guard: throws if `consentRequired` is true. The parent's
`handleConsent` reads transactions from a process-local Map which would
reintroduce the exact multi-instance bug this class prevents, and our
`authorize` override short-circuits the consent branch in the parent's
`authorize`, so enabling consent would either be silently ignored or
partially-broken. Fail fast at construction time.

Tests renamed to drop "parent Map" framing (no longer implementation-
relevant), new test asserts the constructor guard.

182 → 183 tests passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@artiom
Copy link
Copy Markdown
Contributor Author

artiom commented Apr 21, 2026

Round 5 — 39956c6: drop parent Map cache

Per the "why do we need per-instance cache" discussion. The parent's `registeredClients` Map was a same-instance read-cache over Redis — saving ~1ms per authorize/callback on the hot path, but producing every bug we iterated on across rounds 3–5 (over-delete on rollback, localPreExisting snapshotting, probe-failure leaking the Map populated).

Changes

  • `isClientRegistered` now queries Redis directly — no Map read, no fallback logic.
  • `registerClient` still calls `super` (parent populates the Map as a side effect; we just don't read from it) but drops all Map-rollback plumbing. On failure we only clean up Redis. Redis pre-existence probe stays to avoid DEL'ing a concurrent valid registration.
  • Net: -60 lines of coordination logic, one clearer invariant: Redis is the single source of truth for DCR state.
  • Tests renamed to drop "parent Map" framing.

Consent guard

On the side question about flipping `consentRequired: true` — that doesn't fix the `handleConsent` gap: our `authorize` override short-circuits the parent's consent branch, and `handleConsent` itself reads transactions from a process-local Map (not Redis) which is exactly the multi-instance bug this class prevents. Enabling consent would either be silently ignored (current) or partially broken (if we made `authorize` honor it).

Added a constructor guard that throws if `consentRequired` is true, so a future caller who tries to enable it fails immediately at construction rather than running into the half-broken flow at runtime. New test covers it.

Tradeoff accepted

  • Lost: ~1ms per authorize/callback round-trip on same-instance hot path. Negligible — authorize already writes a transaction to Redis, callback already reads one.
  • Lost: Partial Redis-blip resilience for the originating instance (always half a solution; other instances were rejected anyway).
  • Gained: simpler code, single invariant, 60 fewer lines of error-prone coordination.

182 → 183 tests passing.

@artiom
Copy link
Copy Markdown
Contributor Author

artiom commented Apr 21, 2026

@coderabbitai please re-review — PR head is now 39956c6. Since your last pass on 1f090cf, I dropped the parent Map cache and switched isClientRegistered / registerClient to Redis as the single source of truth. Net -60 lines of coordination logic. Tests are 183 passing.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
echo "=== src/lib/redis-oauth-proxy.ts ==="
cat src/lib/redis-oauth-proxy.ts

echo ""
echo "=== test/lib/redis-oauth-proxy.spec.ts ==="
cat test/lib/redis-oauth-proxy.spec.ts

Length of output: 30600


@artiom: Reviewed 39956c6. The "Redis as single source of truth" refactor is clean — here's a precise accounting of each path:

isClientRegistered — pure redis.exists, one line, zero branches. The parent Map is never consulted.

registerClient

Phase Failure handling
super.registerClient succeeds, populates parent Map Map state irrelevant — nothing reads it
Promise.allSettled probe (exists) — any rejection Throw immediately; no writes have been attempted; nothing to clean up on either layer
Promise.allSettled writes — any rejection Best-effort del of only newly-introduced keys (!redisPreExisting); pre-existing keys untouched

The pre-existing overlap is handled correctly: DEL on a key that was never written (failed partway through) is a no-op in Redis, so cleanup is always safe regardless of how far the write loop progressed.

Constructor guard — the _internal getter is a pure type cast so it resolves immediately during construction; no timing concern.

exchangeAuthorizationCode PKCE path — Map is populated transiently and cleaned in finally, with the Redis key already consumed by GETDEL before that block — no double-free possible.

Test suite covers every new invariant introduced across all five rounds: constructor rejection, probe failure, write failure, overlap preservation, cross-instance DCR→authorize, GETDEL race, handleCallback purge-on-rejection. Nothing is left unexercised.

No remaining issues. This PR is in good shape.

[approve]

@ahmedAmien ahmedAmien merged commit fd2692a into main Apr 22, 2026
2 checks passed
@ahmedAmien ahmedAmien deleted the fix/oauth-cwe601-multi-instance branch April 22, 2026 14:22
@coderabbitai coderabbitai Bot mentioned this pull request Apr 25, 2026
andyMrtnzP pushed a commit that referenced this pull request May 20, 2026
… proxy (#38)

## Summary

fastmcp 4.0.0 ([upstream
commit](punkpeye/fastmcp@5478753))
hardened `OAuthProxy` against CWE-601 open-redirect / authorization-code
theft. The fix added validation in four public methods — all of which
`RedisOAuthProxy` overrides without delegating to `super`. As a result,
the v4.0.0 security guarantees were silently bypassed on every
deployment of this server after the Dependabot bump (#28) landed.

**This has been verified live against `https://mcp.browserless.io`:**
- `POST /oauth/register` with `redirect_uris:
["https://evil.attacker.com/steal"]` → returns `200` with a
client_id/secret.
- `GET
/oauth/authorize?client_id=arbitrary-attacker-id&redirect_uri=https%3A%2F%2Fevil.attacker.com%2Fsteal&response_type=code&state=probe`
→ returns `302` to the upstream Supabase authorize page. The
attacker-controlled URL is persisted on the transaction; once a victim
authenticates at Supabase, the MCP server's callback would forward the
fresh authorization code to the attacker's host.

This PR ports the v4 checks, extends them to work in the multi-instance
deployment, and narrows `allowedRedirectUriPatterns` to the known-good
list instead of the removed `https://*` fallback.

### Why the bypass happens

The v4 checks read from `OAuthProxy.registeredClients` — a process-local
`Map` populated by DCR. The `RedisOAuthProxy` overrides (`authorize`,
`exchangeAuthorizationCode`, `handleCallback`) re-implement their logic
from scratch against Redis and never call `super`, so:

1. **The CWE-601 check is skipped.** Attacker-controlled `redirect_uri`
is accepted at `/oauth/authorize`.
2. **Multi-instance is architecturally broken if the check were
applied.** DCR on instance A leaves instance B's Map empty — B would
reject every legitimate `redirect_uri` under the v4 check.

The stale comment in `redis-oauth-proxy.ts:24-27` ("registeredClients
Map is write-only (never read by other methods)") was true in v3.33 and
no longer is.

### What changed

- **Redis-backed DCR registry.** `registerClient` override mirrors every
accepted `redirect_uri` to Redis under `mcp:oauth:client:<uri>` with a
TTL. New `isClientRegistered(uri)` helper falls back to Redis when the
local Map misses — consistent answers across single- and multi-instance
deployments.
- **`authorize()`** enforces `client_id === upstreamClientId` (RFC 6749
§5.2) and `isClientRegistered(redirect_uri)` (RFC 6749 §3.1.2.3).
- **`exchangeAuthorizationCode()`** enforces the client_id check on the
non-PKCE branch as well, so a stolen code can't be redeemed by an
arbitrary caller.
- **`handleCallback()`** adds the defense-in-depth registry check on
`transaction.clientCallbackUrl` and purges the transaction on failure.
- **Allowlist default replaced** — `https://*` is gone. New baseline
encodes the real clients that DCR against prod today (verified by
probing the live service):
- `http://localhost:*`, `http://127.0.0.1:*` — Claude Desktop, Cursor,
VS Code, Windsurf, any loopback-callback client
- `https://claude.ai/api/mcp/auth_callback` — Claude.ai web custom
connectors
- `https://chatgpt.com/connector_platform_oauth_redirect` — ChatGPT MCP
connector
- **`OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS`** env var (comma-separated)
lets deployments append patterns on top of the baseline without forking
the code. New MCP hosts / staging domains go here.
- **`consentRequired: true` passed to PassthroughOAuthProvider but the
proxy config hardcoded `false`.** Harmless today (proxy config wins) but
misleading — both `false` now, matching the passthrough token-swap
intent.
- **Dependency housekeeping.** `tsconfig.json` gets `types: ["node",
"mocha"]` so `@types/mocha` is picked up under `moduleResolution:
Node16`, and the now-vestigial `baseUrl` entry is removed so `tsc` stops
warning about the TS 6.0 deprecation without a suppression flag.

### Tests

New `test/lib/redis-oauth-proxy.spec.ts` (uses `ioredis-mock`):

- DCR mirrors every URI to Redis with TTL; rejects patterns that fail
DCR validation.
- `authorize`: unknown `client_id`, unregistered `redirect_uri`,
attacker URI with matching `client_id`, cross-instance DCR via shared
Redis, transaction persisted.
- `exchangeAuthorizationCode`: unknown `client_id` on non-PKCE path.
- `handleCallback` defense-in-depth: callback on instance B after DCR
revocation, transaction purged, upstream token endpoint never called.
- Happy path: full `authorize → callback`, code stored in Redis, 302
back to registered callback.

All 171 tests pass. 10 new tests added.

## Test plan

- [x] `npm test` — 171 passing, 0 failing
- [x] `npx tsc` — clean exit
- [x] Probed prod `https://mcp.browserless.io` → confirmed current
vulnerability (DCR accepts evil, authorize returns 302 for arbitrary
client_id)
- [x] Verified prod known clients against the new default allowlist —
claude.ai, chatgpt.com, and localhost all match
- [ ] Deploy to test env (`https://dev-mcp.browserless.io`), verify:
- [ ] Claude.ai web + ChatGPT MCP connector can still complete the OAuth
flow end-to-end
- [ ] Claude Desktop / Cursor / VS Code can still complete the OAuth
flow (localhost)
- [ ] Multi-instance: DCR on one instance, authorize+callback+exchange
across different instances via the LB
- [ ] Attempt the CWE-601 PoC — confirm `invalid_client` for arbitrary
`client_id`, `invalid_request` for unregistered `redirect_uri`
- [ ] Promote to prod

## Deployment notes for operators

No terraform change required for the current prod deployment — the
known-good list is baked in. If a future MCP client appears on a new
host (staging, new integration, etc.), extend at runtime without
shipping a code change:

```
OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS=https://new-host.example.com/*
```

Deployments outside of Browserless that embed this server can override
the baseline by setting `OAUTH_ADDITIONAL_REDIRECT_URI_PATTERNS` to
their own client list — the baseline does not prevent anything, it just
guarantees the Browserless-hosted path works without extra config.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Configurable OAuth allowed redirect URI patterns via environment
variable (defaults preserved).
* Dynamic Client Registration persisted in Redis for cross-instance
consistency.

* **Security Improvements**
* Enforces client_id and registered redirect URI checks across auth and
token flows.
* Rejects and purges requests using unregistered or attacker-controlled
callbacks.

* **Changes**
  * OAuth consent prompt disabled by default.

* **Chores**
  * Added Redis mock dev dependencies for testing.

* **Tests**
* New and expanded tests covering DCR, shared registry, validation,
rollback, and atomic code exchange.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai coderabbitai Bot mentioned this pull request May 27, 2026
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