Files
worldmonitor/vercel.json
Elie Habib be679e8663 fix(oauth): bypass form-action CSP blocking Connectors UI form submission (#2438)
* fix(oauth): bypass form-action CSP via JS fetch + fix vercel.json CSP

Root cause: the Connectors WebView treats the page origin as something
other than https://api.worldmonitor.app (likely a null/app origin from
the native shell), so form-action 'self' blocked the consent form POST.

Changes:
- Consent form now uses JavaScript fetch() as primary submission path.
  form-action CSP only restricts HTML form submissions; fetch() is
  governed by connect-src, which already allows https:. The HTML form
  action remains as fallback with the absolute URL.
- Server detects X-Requested-With: fetch header and returns JSON
  { location } on success (so JS can navigate the WebView) or
  { error, nonce } on invalid key (so JS can update the form without
  a page reload). Native form POST path still returns 302.
- vercel.json: add sha256 hash of new inline script to script-src,
  and add explicit https://api.worldmonitor.app to form-action as
  belt-and-suspenders for browsers that resolve 'self' unexpectedly.

* feat(oauth): redesign consent/error pages with WorldMonitor UI + fix CSP script hash

- Redesign htmlError() and consentPage() to match WorldMonitor dashboard
  aesthetic (dark #0a0a0a bg, ui-monospace font, #2d8a6e teal, sharp corners)
- Add globe SVG logo, MCP capabilities list, and /pro link to consent page
- Fix vercel.json script-src hash: was sha256-1wYO... (computed from raw escape
  sequence) now sha256-GNMh... (computed from evaluated Unicode ellipsis)
- Script logic and DOM IDs unchanged; hash is now byte-perfect with served HTML

* fix(oauth): remove X-Requested-With header + allow null origin for WebView

Fixes two real P1 bugs caught in review:

1. X-Requested-With header made the fetch a non-simple CORS request, which
   triggered a preflight. The OPTIONS handler returned no
   Access-Control-Allow-Headers and vercel.json /oauth/* only allowed
   Content-Type/Authorization — so the preflight failed, and the form
   submission never reached the server.

   Fix: replace custom header detection with _js=1 POST body field.
   Content-Type: application/x-www-form-urlencoded is a simple CORS type
   with no custom headers, so no preflight is triggered.

2. WebView with opaque origin sends Origin: null (the literal string).
   The old check blocked any origin != https://api.worldmonitor.app,
   which returned 403 for every WebView submission.

   Fix: allow 'null' explicitly. The CSRF nonce (server-stored, atomic
   GETDEL, 10-min TTL) provides the actual security — origin is a
   defense-in-depth layer, not the primary guard.

Also adds 4 structural tests in edge-functions.test.mjs that will catch
regressions on both issues.
2026-03-28 21:19:51 +04:00

188 lines
8.3 KiB
JSON

{
"ignoreCommand": "bash scripts/vercel-ignore.sh",
"crons": [],
"redirects": [
{ "source": "/docs", "destination": "/docs/documentation", "permanent": false }
],
"rewrites": [
{ "source": "/docs/:match*", "destination": "https://worldmonitor.mintlify.dev/docs/:match*" },
{ "source": "/pro", "destination": "/pro/index.html" },
{ "source": "/mcp", "destination": "/api/mcp" },
{ "source": "/oauth/token", "destination": "/api/oauth/token" },
{ "source": "/oauth/register", "destination": "/api/oauth/register" },
{ "source": "/oauth/authorize", "destination": "/api/oauth/authorize" },
{ "source": "/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)", "destination": "/index.html" }
],
"headers": [
{
"source": "/api/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization, X-WorldMonitor-Key, X-Widget-Key, X-Pro-Key" }
]
},
{
"source": "/mcp",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization, X-WorldMonitor-Key" }
]
},
{
"source": "/oauth/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Access-Control-Allow-Methods", "value": "GET, POST, OPTIONS" },
{ "key": "Access-Control-Allow-Headers", "value": "Content-Type, Authorization" }
]
},
{
"source": "/.well-known/oauth-protected-resource",
"headers": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Cache-Control", "value": "public, max-age=3600" }
]
},
{
"source": "/.well-known/oauth-authorization-server",
"headers": [
{ "key": "Content-Type", "value": "application/json" },
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Cache-Control", "value": "public, max-age=3600" }
]
},
{
"source": "/.well-known/(.*)",
"headers": [
{ "key": "Access-Control-Allow-Origin", "value": "*" },
{ "key": "Cache-Control", "value": "public, max-age=3600" }
]
},
{
"source": "/docs/:path*",
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" }
]
},
{
"source": "/((?!docs).*)",
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=(self), accelerometer=(), autoplay=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), bluetooth=(), display-capture=(), encrypted-media=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), gyroscope=(), hid=(), idle-detection=(), magnetometer=(), midi=(), payment=(), picture-in-picture=(self \"https://www.youtube.com\" \"https://www.youtube-nocookie.com\"), screen-wake-lock=(), serial=(), usb=(), xr-spatial-tracking=()" },
{ "key": "Content-Security-Policy", "value": "default-src 'self'; connect-src 'self' https: wss: blob: data:; img-src 'self' data: blob: https:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self' 'sha256-LnMFPWZxTgVOr2VYwIh9mhQ3l/l3+a3SfNOLERnuHfY=' 'sha256-4Z2xtr1B9QQugoojE/nbpOViG+8l2B7CZVlKgC78AeQ=' 'sha256-903UI9my1I7mqHoiVeZSc56yd50YoRJTB2269QqL76w=' 'sha256-EytE6o1N8rwzpVFMrF+WvBZr2y5UhFLw79o1/4VqS0s=' 'wasm-unsafe-eval' https://www.youtube.com https://static.cloudflareinsights.com https://vercel.live https://challenges.cloudflare.com https://*.clerk.accounts.dev https://abacus.worldmonitor.app; worker-src 'self' blob:; font-src 'self' data: https:; media-src 'self' data: blob: https:; frame-src 'self' https://worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://www.youtube.com https://www.youtube-nocookie.com https://webcams.windy.com https://challenges.cloudflare.com https://*.clerk.accounts.dev https://vercel.live https://*.vercel.app; frame-ancestors 'self' https://www.worldmonitor.app https://tech.worldmonitor.app https://finance.worldmonitor.app https://commodity.worldmonitor.app https://happy.worldmonitor.app https://worldmonitor.app https://vercel.live https://*.vercel.app; base-uri 'self'; object-src 'none'; form-action 'self' https://api.worldmonitor.app" }
]
},
{
"source": "/",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
},
{
"source": "/index.html",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
},
{
"source": "/((?!api|mcp|oauth|assets|blog|docs|favico|map-styles|data|textures|pro|sw\\.js|workbox-[a-f0-9]+\\.js|manifest\\.webmanifest|offline\\.html|robots\\.txt|sitemap\\.xml|llms\\.txt|llms-full\\.txt|\\.well-known|wm-widget-sandbox\\.html).*)",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
},
{
"source": "/assets/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/blog/_astro/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/pro/assets/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/pro/:path*",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
},
{
"source": "/pro",
"headers": [
{ "key": "Cache-Control", "value": "no-cache, no-store, must-revalidate" }
]
},
{
"source": "/favico/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=604800" }
]
},
{
"source": "/map-styles/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/data/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/textures/(.*)",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/offline.html",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=86400" }
]
},
{
"source": "/workbox-:hash.js",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }
]
},
{
"source": "/sw.js",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=0, must-revalidate" }
]
},
{
"source": "/manifest.webmanifest",
"headers": [
{ "key": "Cache-Control", "value": "public, max-age=86400" }
]
},
{
"source": "/wm-widget-sandbox.html",
"headers": [
{ "key": "Content-Security-Policy", "value": "default-src 'none'; script-src 'unsafe-inline' https://cdn.jsdelivr.net https://static.cloudflareinsights.com; style-src 'unsafe-inline'; img-src data:; connect-src https://cdn.jsdelivr.net;" },
{ "key": "Cache-Control", "value": "public, max-age=86400" }
]
}
]
}