Skip to content

fix(security): harden API proxy, CORS, SSRF, ReDoS, and sanitization#2226

Open
plato23 wants to merge 2 commits intokoala73:mainfrom
plato23:security/hardening-2026-03
Open

fix(security): harden API proxy, CORS, SSRF, ReDoS, and sanitization#2226
plato23 wants to merge 2 commits intokoala73:mainfrom
plato23:security/hardening-2026-03

Conversation

@plato23
Copy link
Copy Markdown

@plato23 plato23 commented Mar 24, 2026

Summary

Type of change

  • Bug fix
  • New feature
  • New data source / feed
  • New map layer
  • Refactor / code cleanup
  • Documentation
  • CI / Build / Infrastructure

Affected areas

  • Map / Globe
  • News panels / RSS feeds
  • AI Insights / World Brief
  • Market Radar / Crypto
  • Desktop app (Tauri)
  • API endpoints (/api/*)
  • Config / Settings
  • Other:

Checklist

  • Tested on worldmonitor.app variant
  • Tested on tech.worldmonitor.app variant (if applicable)
  • New RSS feed domains added to api/rss-proxy.js allowlist (if adding feeds)
  • No API keys or secrets committed
  • TypeScript compiles without errors (npm run typecheck)

Screenshots

plato23 and others added 2 commits March 24, 2026 23:04
Security improvements across multiple vectors:

- **MCP proxy**: Block sensitive header overrides (Host, Cookie,
  Transfer-Encoding, proxy headers) via BLOCKED_HEADER_NAMES allowlist;
  add rate limiting; sanitize error responses to prevent info leakage

- **RSS proxy**: Remove internal error details (url, message) from
  client-facing error responses — log server-side only

- **Relay helper**: Remove internal error details from relay error
  responses; enforce HTTPS-only relay connections (ws:// now upgrades
  to https:// instead of http://)

- **News classifier**: Pre-build all keyword regexes at module load
  time to eliminate runtime RegExp construction from string arguments
  (ReDoS surface elimination per CWE-1333)

- **Feed digest**: Restrict extractTag() to pre-cached KNOWN_TAGS only,
  returning empty string for unknown tags — eliminates dynamic RegExp
  construction entirely

- **vercel.json**: Remove wildcard CORS headers (Access-Control-Allow-Origin: *)
  from static /api/* header block that was undermining the dynamic
  origin-checking CORS logic in edge functions

- **Dockerfile.relay**: Run relay container as non-root user (appuser)
  matching the main Dockerfile's security posture

All 2296 existing tests updated and passing.

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

Second batch of security fixes from comprehensive audit:

- **gateway.ts**: CORS fallback on exception now returns hardcoded
  production origin instead of wildcard `*` — prevents silent bypass
  of the entire origin allowlist on any CORS computation error

- **LiveWebcamsPanel.ts**: Add origin validation to postMessage handler
  matching LiveNewsPanel pattern — only accept messages from YouTube,
  Windy, and the local sidecar (fixes CWE-345)

- **widget-sanitizer.ts**: escapeSrcdoc now escapes `<` and `>` in
  addition to `&` and `"` — prevents srcdoc attribute breakout in
  pro widget iframe rendering

- **DeductionPanel.ts**: Restrict DOMPurify config on AI-generated
  markdown output — blocks `<a>`, `<img>`, and other tags that could
  enable phishing or data exfiltration from compromised AI backend

- **mcp-proxy.js**: Expand SSRF blocklist to cover `0.0.0.0`,
  unspecified IPv6 `::`, IPv4-mapped IPv6 `::ffff:`, and
  bracket-wrapped IPv6 hostnames

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

vercel bot commented Mar 24, 2026

@plato23 is attempting to deploy a commit to the Elie Team on Vercel.

A member of the Team first needs to authorize it.

@github-actions github-actions bot added the trust:caution Brin: contributor trust score caution label Mar 24, 2026
@koala73
Copy link
Copy Markdown
Owner

koala73 commented Mar 25, 2026

@greptileai

@greptile-apps
Copy link
Copy Markdown

greptile-apps bot commented Mar 25, 2026

Greptile Summary

This PR applies a broad sweep of defensive security hardening across the API proxy layer, CORS configuration, SSRF protection, ReDoS prevention, and client-side sanitization. The changes are well-motivated and largely correct, but four issues warrant attention before merge.

Key changes:

  • api/mcp-proxy.js — expands the SSRF blocklist with new IPv4/IPv6 patterns (0.0.0.0, ::, ::ffff:, fc/fd ULA, bracket-wrapped IPv6), blocks client-supplied sensitive headers, adds rate limiting, and suppresses internal error details from responses
  • api/_relay.js / server/_shared/relay.ts — forces HTTPS for WS_RELAY_URL regardless of input scheme, removes details from error responses
  • server/gateway.ts — replaces the * wildcard CORS fallback with a hardcoded production origin
  • vercel.json — removes CDN-level wildcard ACAO: * that was overriding per-origin handler CORS, adds X-Content-Type-Options: nosniff
  • server/worldmonitor/news/v1/_classifier.ts / list-feed-digest.ts — pre-builds keyword and tag regexes at module load time to eliminate runtime RegExp construction
  • src/components/DeductionPanel.ts — tightens DOMPurify to an explicit allowlist
  • src/components/LiveWebcamsPanel.ts — validates MessageEvent.origin before processing embed messages
  • src/utils/widget-sanitizer.ts — adds </> escaping to escapeSrcdoc
  • Dockerfile.relay — drops to a non-root user inside the container

Issues found:

  • The /^\[/ bracket pattern added to BLOCKED_HOST_PATTERNS is dead code — URL.hostname never contains brackets for valid http/https URLs, so it never fires against URL-derived hostnames
  • The fc00::/8 ULA IPv6 prefix is not blocked; only fd00::/8 is covered by /^fd[0-9a-f]{2}:/i, leaving half the RFC 4193 private range open to SSRF
  • The HTTPS-upgrade regex in both api/_relay.js and server/_shared/relay.ts is case-sensitive; an uppercase WSS:// or HTTP:// in WS_RELAY_URL silently produces a broken URL
  • The hardcoded https://worldmonitor.app CORS fallback in server/gateway.ts would break CORS for all non-production variant origins (tech.worldmonitor.app, finance.worldmonitor.app, etc.) on the unlikely event that getCorsHeaders throws

Confidence Score: 2/5

  • Not safe to merge as-is — two SSRF gaps in mcp-proxy and a CORS regression in gateway.ts need to be fixed first.
  • The overall intent is solid and most of the changes are correct hardening. However, the fc00::/8 ULA gap is a real SSRF bypass that the PR claims to fix but doesn't fully address, the dead bracket pattern gives false confidence, the case-sensitive relay URL upgrade can silently produce broken URLs, and the hardcoded CORS fallback in gateway.ts would break variant origins. These are all in security-critical paths.
  • api/mcp-proxy.js (SSRF blocklist gaps), api/_relay.js and server/_shared/relay.ts (case-sensitive URL upgrade), server/gateway.ts (hardcoded CORS fallback)

Important Files Changed

Filename Overview
api/mcp-proxy.js Rate limiting added; SSRF blocklist expanded with new IPv6/loopback patterns; sensitive headers blocked from client override. Two gaps remain: the /^\[/ bracket pattern is dead code against URL.hostname, and the fc00::/8 ULA prefix is not blocked.
api/_relay.js Forces HTTPS for the relay URL and removes details from error responses. The regex logic is case-sensitive; an uppercase WSS:// or HTTP:// env var would silently produce a broken URL.
server/_shared/relay.ts Mirrors the _relay.js HTTPS-upgrade logic. Same case-sensitivity gap for uppercase URL schemes in WS_RELAY_URL.
server/gateway.ts Replaces wildcard CORS fallback with a hardcoded https://worldmonitor.app. This breaks CORS for variant origins (tech, finance, commodity) if getCorsHeaders ever throws, since they would receive the wrong ACAO header.
server/worldmonitor/news/v1/_classifier.ts Pre-builds all keyword regexes at module load time instead of lazily, eliminating runtime regex construction. The fallback text.includes(kw) for unknown keywords correctly skips word-boundary matching but is safe since unknown keywords should never appear with the hardcoded maps.
src/components/LiveWebcamsPanel.ts Adds origin validation to handleEmbedMessage, restricting postMessage acceptance to YouTube, Windy, and the desktop sidecar origin. Correct and complete fix.
vercel.json Removes CDN-level wildcard Access-Control-Allow-Origin: * that was overriding per-origin handler CORS headers, and adds X-Content-Type-Options: nosniff for API routes. Correct change — CORS is now managed exclusively by the handlers.

Sequence Diagram

sequenceDiagram
    participant Browser
    participant Vercel CDN
    participant MCPProxy as api/mcp-proxy.js
    participant RateLimit as _rate-limit.js
    participant SSRF as validateServerUrl()
    participant MCP as External MCP Server

    Browser->>Vercel CDN: GET/POST /api/mcp-proxy
    Note over Vercel CDN: Adds X-Content-Type-Options: nosniff (vercel.json)
    Vercel CDN->>MCPProxy: Forward request
    MCPProxy->>MCPProxy: isDisallowedOrigin() → 403 if blocked
    MCPProxy->>MCPProxy: OPTIONS preflight → 204
    MCPProxy->>RateLimit: checkRateLimit(req)
    alt Rate limit exceeded
        RateLimit-->>Browser: 429 Too Many Requests
    end
    MCPProxy->>SSRF: validateServerUrl(rawServer)
    Note over SSRF: Checks protocol, BLOCKED_HOST_PATTERNS<br/>(loopback, RFC1918, link-local, ULA-fd, ::ffff:)
    alt URL blocked
        SSRF-->>MCPProxy: null
        MCPProxy-->>Browser: 400 Invalid serverUrl
    end
    MCPProxy->>MCP: initialize + tools/list or tools/call
    Note over MCPProxy,MCP: buildHeaders() strips BLOCKED_HEADER_NAMES<br/>(host, cookie, x-forwarded-*, cf-connecting-ip…)
    alt SSE transport (/sse path)
        MCPProxy->>MCP: GET SSE stream
        MCP-->>MCPProxy: endpoint event URL
        MCPProxy->>SSRF: Re-validate endpoint URL (SSRF check)
        MCPProxy->>MCP: POST JSON-RPC to endpoint
    end
    MCP-->>MCPProxy: JSON-RPC response
    MCPProxy-->>Browser: 200 { tools } or { result }
    Note over MCPProxy: Errors → generic "MCP request failed"<br/>(internal details logged server-side only)
Loading

Reviews (1): Last reviewed commit: "fix(security): harden CORS fallback, pos..." | Re-trigger Greptile

/^::1$/,
/^::$/, // unspecified IPv6
/^::ffff:/i, // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
/^\[/, // bracket-wrapped IPv6 in hostname
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 /^\[/ pattern is dead code and never matches

The WHATWG URL parser (used in Cloudflare Workers / Vercel Edge) always strips brackets from IPv6 hostnames. new URL('http://[::1]/').hostname returns '::1', not '[::1]'. Because BLOCKED_HOST_PATTERNS is tested against url.hostname, the /^\[/ pattern can never match and provides false confidence of protection.

The existing patterns (/^::1$/, /^::$/, /^::ffff:/i, /^fd.../i, /^fe80:/i) do cover the critical IPv6 cases, but any full IPv6 literal passed to validateServerUrl that slips through (see the separate fc comment) would never hit this guard regardless.

Suggested change
/^\[/, // bracket-wrapped IPv6 in hostname
/^\[/, // bracket-wrapped IPv6 in hostname (unreachable via URL.hostname — URL parser strips brackets; kept as defence-in-depth for raw-string callers)

Consider removing the check or adding a comment that it guards only hypothetical non-URL-parsed callers, so future reviewers don't rely on it for URL-derived hostnames.

/^::$/, // unspecified IPv6
/^::ffff:/i, // IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1)
/^\[/, // bracket-wrapped IPv6 in hostname
/^fd[0-9a-f]{2}:/i,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 fc00::/8 ULA prefix not blocked — SSRF gap

The RFC 4193 Unique Local Address (ULA) range is fc00::/7, which covers both fc00::/8 (the fc prefix) and fd00::/8 (the fd prefix). The current pattern only blocks addresses beginning with fd:

/^fd[0-9a-f]{2}:/i

An attacker supplying a serverUrl like http://[fc00::1]/internal would have url.hostname = 'fc00::1', which does not match any pattern in BLOCKED_HOST_PATTERNS and would therefore pass validateServerUrl.

fc addresses are rare in practice, but the full ULA range should be blocked for correctness:

Suggested change
/^fd[0-9a-f]{2}:/i,
/^f[cd][0-9a-f]{2}:/i,

This covers both fc and fd prefixes across the entire fc00::/7 range.

Comment on lines +12 to +16
const httpUrl = relayUrl.replace(/^wss:\/\//, 'https://');
// If the env var was already https:// or got converted above, we're done.
// Otherwise force https:// for any remaining non-secure scheme.
const secured = httpUrl.startsWith('https://') ? httpUrl : 'https://' + httpUrl.replace(/^[a-z]+:\/\//, '');
return secured.replace(/\/$/, '');
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Case-sensitive regex silently produces a broken URL

Both the wss:// replacement and the fallback replace(/^[a-z]+:\/\//, '') use case-sensitive patterns. If an operator sets WS_RELAY_URL=WSS://relay.example.com (uppercase — valid in RFC 3986), neither regex matches:

  1. /^wss:\/\// → no match → httpUrl = 'WSS://relay.example.com'
  2. httpUrl.startsWith('https://')false
  3. /^[a-z]+:\/\// → no match → strips nothing → 'https://WSS://relay.example.com'

This silently constructs a malformed URL that will cause all relay requests to fail with no clear error. The same issue exists in the mirrored copy at server/_shared/relay.ts (line 5).

Suggested change
const httpUrl = relayUrl.replace(/^wss:\/\//, 'https://');
// If the env var was already https:// or got converted above, we're done.
// Otherwise force https:// for any remaining non-secure scheme.
const secured = httpUrl.startsWith('https://') ? httpUrl : 'https://' + httpUrl.replace(/^[a-z]+:\/\//, '');
return secured.replace(/\/$/, '');
const httpUrl = relayUrl.replace(/^wss:\/\//i, 'https://');
// If the env var was already https:// or got converted above, we're done.
// Otherwise force https:// for any remaining non-secure scheme.
const secured = /^https:\/\//i.test(httpUrl) ? httpUrl : 'https://' + httpUrl.replace(/^[a-zA-Z]+:\/\//, '');

The same fix should be applied to the identical logic in server/_shared/relay.ts.

Comment on lines 222 to +225
} catch {
corsHeaders = { 'Access-Control-Allow-Origin': '*' };
// Never fall back to wildcard CORS — that would bypass the origin allowlist.
// Use the hardcoded production origin as a safe default.
corsHeaders = { 'Access-Control-Allow-Origin': 'https://worldmonitor.app', 'Vary': 'Origin' };
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Hardcoded fallback origin breaks CORS for non-production variants

When getCorsHeaders throws (however unlikely), the fallback is now https://worldmonitor.app. Requests from tech.worldmonitor.app, finance.worldmonitor.app, commodity.worldmonitor.app, etc. would receive Access-Control-Allow-Origin: https://worldmonitor.app, causing browsers to block those cross-origin responses.

The intent to avoid the previous wildcard fallback is correct. A safer approach is to echo back the request's Origin only when it already passed the isDisallowedOrigin check performed a few lines above — by the time we reach this try/catch, any disallowed origin has already been rejected:

Suggested change
} catch {
corsHeaders = { 'Access-Control-Allow-Origin': '*' };
// Never fall back to wildcard CORS — that would bypass the origin allowlist.
// Use the hardcoded production origin as a safe default.
corsHeaders = { 'Access-Control-Allow-Origin': 'https://worldmonitor.app', 'Vary': 'Origin' };
// Never fall back to wildcard CORS — that would bypass the origin allowlist.
// The disallowed-origin check above already rejected cross-origin requests,
// so it is safe to echo the request origin here.
const fallbackOrigin = req.headers.get('origin') || 'https://worldmonitor.app';
corsHeaders = { 'Access-Control-Allow-Origin': fallbackOrigin, 'Vary': 'Origin' };

@koala73 koala73 added High Value Meaningful contribution to the project Ready to Merge PR is mergeable, passes checks, and adds value labels Mar 26, 2026
@SebastienMelki
Copy link
Copy Markdown
Collaborator

@plato23 — thanks for the security hardening sweep! The Greptile review identified issues that need fixing before merge:

Must fix:

  1. SSRF gap: fc00::/8 ULA IPv6 prefix not blocked — only fd00::/8 covered. Regex needs /^f[cd][0-9a-f]{2}:/i
  2. CORS regression: Hardcoded https://worldmonitor.app fallback would send wrong Access-Control-Allow-Origin to variant subdomains. Use request Origin with an allowlist instead.
  3. Case-sensitive relay URL: HTTPS-upgrade regex won't match uppercase WSS:// or HTTP://. Add the i flag.

Minor: The /^[/ bracket pattern in BLOCKED_HOST_PATTERNS is dead code — URL.hostname never contains brackets.

Please also rebase on main and fill in the PR checklist.

@koala73 — not safe to merge as-is due to the SSRF and CORS gaps.

@SebastienMelki
Copy link
Copy Markdown
Collaborator

Note: merge conflicts with main. Fork PR — can't rebase from maintainer side. @plato23 — please rebase on current main when addressing the SSRF/CORS issues noted above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

High Value Meaningful contribution to the project Ready to Merge PR is mergeable, passes checks, and adds value trust:caution Brin: contributor trust score caution

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants