* 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>
3.7 KiB
status, priority, issue_id, tags, dependencies
| status | priority | issue_id | tags | dependencies | ||||
|---|---|---|---|---|---|---|---|---|
| complete | p1 | 082 |
|
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:to:} catch { return rpcError(id, -32603, 'Internal error: data fetch failed');} catch (err) { const msg = err instanceof Error ? err.message : String(err); return rpcError(id, -32603, `Internal error: ${msg}`);- Security sentinel: "The
err.messagefrom 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:443maps 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
Option 1: Log to Sentry, return fixed string (recommended)
} 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
Recommended Action
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.messageto 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
catchtocatch (err)for type narrowing and accidentally introduced the leak by addingerr.messageto the response - Original hardcoded string was intentional masking, not a lazy placeholder