Files
worldmonitor/todos/082-complete-p1-err-message-leaks-internals-to-api-response.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 082
code-review
security
mcp
information-disclosure

err.message leaks internal service details in MCP tool error responses

Problem Statement

PR #2418 changed the MCP catch block from a hardcoded string to err.message, creating an information disclosure regression. Error messages from internal services can contain Redis key names, Upstash endpoint hostnames, internal service URLs, IP addresses, and stack fragments. The original code deliberately masked this with a static string.

Findings

  • api/mcp.ts:520-521 — changed from:
    } catch {
      return rpcError(id, -32603, 'Internal error: data fetch failed');
    
    to:
    } catch (err) {
      const msg = err instanceof Error ? err.message : String(err);
      return rpcError(id, -32603, `Internal error: ${msg}`);
    
  • Security sentinel: "The err.message from a network call, Redis read, or JSON parse failure can contain internal URLs, Redis key names, Upstash endpoint hostnames, internal service names, or stack fragments."
  • Example leak: Internal error: fetch failed — connect ECONNREFUSED 10.0.0.5:443 maps internal network topology
  • The original masking was intentional; this change is an unintentional regression introduced while adding the catch (err) for type narrowing
  • TypeScript reviewer flagged as regression: "This is a regression: the original code deliberately masked the error."

Proposed Solutions

} catch (err) {
  // Log full error internally for debugging, mask from API callers
  console.error('[mcp] tool execution error:', err);
  return rpcError(id, -32603, 'Internal error: data fetch failed');
}

If Sentry is wired in: Sentry.captureException(err) before returning.

Pros: Retains debuggability, masks internals from API surface. Cons: Requires Sentry integration or console.error. Effort: Small (1 line change) Risk: Low


Option 2: Sanitize error message before returning

Strip known patterns (URLs, IPs, file paths) from err.message before including in response.

Pros: Gives some signal without full masking. Cons: Regex sanitization is hard to get right and easy to bypass. Sanitization creates a false sense of security. Effort: Medium Risk: Medium (sanitization gaps can still leak)


Option 3: Revert to hardcoded string, keep err for logging only

} catch (err: unknown) {
  console.error('[mcp] executeTool error:', err);
  return rpcError(id, -32603, 'Internal error: data fetch failed');
}

Pros: Exact revert to the intentional behavior. Effort: Small Risk: Low

Option 3. The catch block only needs err for logging, not for the response string. Revert response to hardcoded string, keep catch (err: unknown) for console/Sentry.

Technical Details

Affected files:

  • api/mcp.ts:516-520 — change 2 lines

Acceptance Criteria

  • Tool error responses do not expose err.message to callers
  • Error is logged (console.error or Sentry)
  • Response string matches or is equivalent to original "Internal error: data fetch failed"
  • TypeScript type error from catch (err) is handled without leaking message

Work Log

2026-03-28 — Code Review Discovery

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

Actions:

  • Security sentinel (H-3) and TypeScript reviewer both flagged independently
  • Root cause: developer changed catch to catch (err) for type narrowing and accidentally introduced the leak by adding err.message to the response
  • Original hardcoded string was intentional masking, not a lazy placeholder