Files
worldmonitor/todos/081-complete-p1-api-key-exposed-in-url-query-param.md
Elie Habib 14a31c4283 feat(mcp): OAuth 2.0 Authorization Server for claude.ai connector (#2418)
* feat(mcp): add OAuth 2.0 Authorization Server for claude.ai connector

Implements spec-compliant MCP authentication so claude.ai's remote connector
(which requires OAuth Client ID + Secret, no custom headers) can authenticate.

- public/.well-known/oauth-authorization-server: RFC 8414 discovery document
- api/oauth/token.js: client_credentials grant, issues UUID Bearer token in Redis TTL 3600s
- api/_oauth-token.js: resolveApiKeyFromBearer() looks up token in Redis
- api/mcp.ts: 3-tier auth (Bearer OAuth first, then ?key=, then X-WorldMonitor-Key);
  switch to getPublicCorsHeaders; surface error messages in catch
- vercel.json: rewrite /oauth/token, exclude oauth from SPA, CORS headers
- tests: update SPA no-cache pattern

Supersedes PR #2417. Usage: URL=worldmonitor.app/mcp, Client ID=worldmonitor, Client Secret=<API key>

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* docs: fix markdown lint in OAuth plan (blank lines around lists)

* fix(oauth): address all P1+P2 code review findings for MCP OAuth endpoint

- Add per-IP rate limiting (10 req/min) to /oauth/token via Upstash slidingWindow
- Return HTTP 401 + WWW-Authenticate header when Bearer token is invalid/expired
- Add Cache-Control: no-store + Pragma: no-cache to token response (RFC 6749 §5.1)
- Simplify _oauth-token.js to delegate to readJsonFromUpstash (removes duplicated Redis boilerplate)
- Remove dead code from token.js: parseBasicAuth, JSON body path, clientId/issuedAt fields
- Add Content-Type: application/json header for /.well-known/oauth-authorization-server
- Remove response_types_supported (only applies to authorization endpoint, not client_credentials)

Closes: todos 075, 076, 077, 078, 079

🤖 Generated with claude-sonnet-4-6 via Claude Code (https://claude.ai/claude-code) + Compound Engineering v2.40.0

Co-Authored-By: claude-sonnet-4-6 (200K context) <noreply@anthropic.com>

* chore(review): fresh review findings — todos 081-086, mark 075/077/078/079 complete

* fix(mcp): remove ?key= URL param auth + mask internal errors

- Remove ?key= query param auth path — API keys in URLs appear in
  Vercel/CF access logs, browser history, Referer headers. OAuth
  client_credentials (same PR) already covers clients that cannot
  set custom headers. Only two auth paths remain: Bearer OAuth and
  X-WorldMonitor-Key header.

- Revert err.message disclosure: catch block was accidentally exposing
  internal service URLs/IPs via err.message. Restore original hardcoded
  string, add console.error for server-side visibility.

Resolves: todos 081, 082

* fix(oauth): resolve all P2/P3 review findings (todos 076, 080, 083-086)

- 076: no-credentials path in mcp.ts now returns HTTP 401 + WWW-Authenticate instead of rpcError (200)
- 080: store key fingerprint (sha256 first 16 hex chars) in Redis, not plaintext key
- 083: replace Array.includes() with timingSafeIncludes() (constant-time HMAC comparison) in token.js and mcp.ts
- 084: resolveApiKeyFromBearer uses direct fetch that throws on Redis errors (500 not 401 on infra failure)
- 085: token.js imports getClientIp, getPublicCorsHeaders, jsonResponse from shared helpers; removes local duplicates
- 086: mcp.ts auth chain restructured to check Bearer header first, passes token string to resolveApiKeyFromBearer (eliminates double header read + unconditional await)

* test(mcp): update auth test to expect HTTP 401 for missing credentials

Align with todo 076 fix: no-credentials path now returns 401 + WWW-Authenticate
instead of JSON-RPC 200 response. Also asserts WWW-Authenticate header presence.

* chore: mark todos 076, 080, 083-086 complete

* fix(mcp): harden OAuth error paths and fix rate limit cross-user collision

- Wrap resolveApiKeyFromBearer() in try/catch in mcp.ts; Redis/network
  errors now return 503 + Retry-After: 5 instead of crashing the handler
- Wrap storeToken() fetch in try/catch in oauth/token.js; network errors
  return false so the existing if (!stored) path returns 500 cleanly
- Re-key token endpoint rate limit by sha256(clientSecret).slice(0,8)
  instead of IP; prevents cross-user 429s when callers share Anthropic's
  shared outbound IPs (Claude remote MCP connector)

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 14:53:32 +04:00

3.7 KiB

status, priority, issue_id, tags, dependencies
status priority issue_id tags dependencies
complete p1 081
code-review
security
oauth
mcp

Remove ?key= URL query param auth path — API key exposed in access logs

Problem Statement

PR #2418 added ?key= as a third auth fallback in api/mcp.ts for "clients that cannot set headers." This is a security regression: API keys in URL query parameters are logged verbatim by Vercel access logs, Cloudflare access logs, browser history, proxy logs, and Referer headers. Unlike HTTP headers, URL params cannot be stripped at the transport layer. The OAuth client_credentials flow this same PR introduces already solves the "no headers" use case, making ?key= have no remaining justified use case.

Findings

  • api/mcp.ts:442const urlKey = new URL(req.url).searchParams.get('key') ?? ''; — new path added in this PR
  • Security sentinel: "This is a new attack surface introduced specifically by this PR. The original mcp.ts used header-only auth."
  • Architecture strategist: "The correct solution for clients that cannot set headers is the OAuth flow this PR already provides."
  • Pre-PR auth used validateApiKey(req, { forceKey: true }) which was header-only
  • Any MCP request to /mcp?key=wm_live_xxxxx permanently records the API key in Vercel + CF logs
  • Tool request URLs show up in browser devtools and Referer headers on redirect

Proposed Solutions

Delete the urlKey line and its usage. Direct clients that cannot set custom headers to use the OAuth flow (POST /oauth/tokenAuthorization: Bearer).

// Remove this:
const urlKey = new URL(req.url).searchParams.get('key') ?? '';
const headerKey = req.headers.get('X-WorldMonitor-Key') ?? '';
const candidateKey = urlKey || headerKey;

// Replace with:
const candidateKey = req.headers.get('X-WorldMonitor-Key') ?? '';

Pros: Eliminates credential-in-URL leakage. OAuth already handles the "no custom headers" use case. Cons: Any existing client using ?key= URL param breaks. (No known clients per PR description.) Effort: Small (2 lines) Risk: Low — no documented clients depend on ?key= per PR description.


Option 2: Keep ?key= but gate behind env flag

Add WORLDMONITOR_ALLOW_KEY_QUERY_PARAM=true env var; only enable the ?key= path if explicitly opted in.

Pros: Backward compat if any undocumented client uses it. Cons: Still allows the security risk to exist in production; adds env config complexity. Effort: Small Risk: Low


Option 3: Log a deprecation warning but keep the path

Return the response but add a header like Warning: 299 - "API key in URL is deprecated; use Authorization: Bearer".

Pros: Non-breaking, signals deprecation. Cons: Does not fix the log-exposure problem. Effort: Small Risk: Low (but doesn't fix the actual issue)

Option 1: Remove immediately. OAuth covers the stated use case. No documented clients depend on ?key=.

Technical Details

Affected files:

  • api/mcp.ts:440-451 — remove urlKey and change const candidateKey = urlKey || headerKey to const candidateKey = req.headers.get('X-WorldMonitor-Key') ?? ''

Acceptance Criteria

  • ?key= URL parameter is not present in api/mcp.ts auth chain
  • X-WorldMonitor-Key header path still works
  • OAuth Bearer path still works
  • No test regressions

Work Log

2026-03-28 — Code Review Discovery

By: Claude Code (compound-engineering:ce-review)

Actions:

  • Security sentinel and architecture strategist independently flagged as P1 regression
  • Pattern confirmed: OAuth client_credentials (added in same PR) covers the stated use case
  • No known clients depend on ?key= URL param per PR description