From de769ce8e18f33a4156038e9ba6e8017981bf452 Mon Sep 17 00:00:00 2001 From: Elie Habib Date: Sat, 18 Apr 2026 08:18:49 +0400 Subject: [PATCH] fix(api): unblock Pro API clients at edge + accept x-api-key alias (#3155) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(api): unblock Pro API clients at edge + accept x-api-key alias Fixes #3146: Pro API subscriber getting 403 when calling from Railway. Two independent layers were blocking server-side callers: 1. Vercel Edge Middleware (middleware.ts) blocks any UA matching /bot|curl\/|python-requests|go-http|java\//, which killed every legitimate server-to-server API client before the gateway even saw the request. Add bypass: requests carrying an `x-worldmonitor-key` or `x-api-key` header that starts with `wm_` skip the UA gate. The prefix is a cheap client-side signal, not auth — downstream server/gateway.ts still hashes the key and validates against the Convex `userApiKeys` table + entitlement check. 2. Header name mismatch. Docs/gateway only accepted `X-WorldMonitor-Key`, but most API clients default to `x-api-key`. Accept both header names in: - api/_api-key.js (legacy static-key allowlist) - server/gateway.ts (user-issued Convex-backed keys) - server/_shared/premium-check.ts (isCallerPremium) Add `X-Api-Key` to CORS Allow-Headers in server/cors.ts and api/_cors.js so browser preflights succeed. Follow-up outside this PR (Cloudflare dashboard, not in repo): - Extend the "Allow api access with WM" custom WAF rule to also match `starts_with(http.request.headers["x-api-key"][0], "wm_")`, so CF Managed Rules don't block requests using the x-api-key header name. - Update the api-cors-preflight CF Worker's corsHeaders to include `X-Api-Key` (memory: cors-cloudflare-worker.md — Worker overrides repo CORS on api.worldmonitor.app). * fix(api): tighten middleware bypass shape + finish x-api-key alias coverage Addresses review findings on #3155: 1. middleware.ts bypass was too loose. "Starts with wm_" let any caller send X-Api-Key: wm_fake and skip the UA gate, shifting unauthenticated scraper load onto the gateway's Convex lookup. Tighten to the exact key format emitted by src/services/api-keys.ts:generateKey — `^wm_[a-f0-9]{40}$` (wm_ + 20 random bytes as hex). Still a cheap edge heuristic (no hash lookup in middleware), but raises spoofing from trivial prefix match to a specific 43-char shape. 2. Alias was incomplete on bespoke endpoints outside the shared gateway: - api/v2/shipping/route-intelligence.ts: async wm_ user-key fallback now reads X-Api-Key as well - api/v2/shipping/webhooks.ts: webhook ownership fingerprint now reads X-Api-Key as well (same key value → same SHA-256 → same ownerTag, so a user registering with either header can manage their webhook from the other) - api/widget-agent.ts: accept X-Api-Key in the auth read AND in the OPTIONS Allow-Headers list - api/chat-analyst.ts: add X-Api-Key to the OPTIONS Allow-Headers list (auth path goes through shared helpers already aliased) --- api/_api-key.js | 2 +- api/_cors.js | 4 ++-- api/chat-analyst.ts | 2 +- api/v2/shipping/route-intelligence.ts | 5 ++++- api/v2/shipping/webhooks.ts | 5 ++++- api/widget-agent.ts | 7 +++++-- middleware.ts | 16 ++++++++++++++++ server/_shared/premium-check.ts | 5 ++++- server/cors.ts | 2 +- server/gateway.ts | 5 ++++- 10 files changed, 42 insertions(+), 11 deletions(-) diff --git a/api/_api-key.js b/api/_api-key.js index 56eb1f84b..5036d2992 100644 --- a/api/_api-key.js +++ b/api/_api-key.js @@ -33,7 +33,7 @@ function extractOriginFromReferer(referer) { export function validateApiKey(req, options = {}) { const forceKey = options.forceKey === true; - const key = req.headers.get('X-WorldMonitor-Key'); + const key = req.headers.get('X-WorldMonitor-Key') || req.headers.get('X-Api-Key'); // Same-origin browser requests don't send Origin (per CORS spec). // Fall back to Referer to identify trusted same-origin callers. const origin = req.headers.get('Origin') || extractOriginFromReferer(req.headers.get('Referer')) || ''; diff --git a/api/_cors.js b/api/_cors.js index c7b45855e..0771ab4f1 100644 --- a/api/_cors.js +++ b/api/_cors.js @@ -22,7 +22,7 @@ export function getCorsHeaders(req, methods = 'GET, OPTIONS') { return { 'Access-Control-Allow-Origin': allowOrigin, 'Access-Control-Allow-Methods': methods, - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Widget-Key, X-Pro-Key', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Api-Key, X-Widget-Key, X-Pro-Key', 'Access-Control-Max-Age': '3600', 'Vary': 'Origin', }; @@ -40,7 +40,7 @@ export function getPublicCorsHeaders(methods = 'GET, OPTIONS') { return { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': methods, - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Widget-Key, X-Pro-Key', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Api-Key, X-Widget-Key, X-Pro-Key', 'Access-Control-Max-Age': '3600', }; } diff --git a/api/chat-analyst.ts b/api/chat-analyst.ts index 48633bb06..a44bb2cee 100644 --- a/api/chat-analyst.ts +++ b/api/chat-analyst.ts @@ -76,7 +76,7 @@ export default async function handler(req: Request): Promise { headers: { ...corsHeaders, 'Access-Control-Allow-Methods': 'POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Api-Key', }, }); } diff --git a/api/v2/shipping/route-intelligence.ts b/api/v2/shipping/route-intelligence.ts index 897677d37..3d7bd685b 100644 --- a/api/v2/shipping/route-intelligence.ts +++ b/api/v2/shipping/route-intelligence.ts @@ -51,7 +51,10 @@ export default async function handler(req: Request): Promise { let apiKeyResult = validateApiKey(req, { forceKey: true }); // Fallback: wm_ user keys are validated async via Convex, not in the static key list - const wmKey = req.headers.get('X-WorldMonitor-Key') ?? ''; + const wmKey = + req.headers.get('X-WorldMonitor-Key') ?? + req.headers.get('X-Api-Key') ?? + ''; if (apiKeyResult.required && !apiKeyResult.valid && wmKey.startsWith('wm_')) { const { validateUserApiKey } = await import('../../../server/_shared/user-api-key'); const userKeyResult = await validateUserApiKey(wmKey); diff --git a/api/v2/shipping/webhooks.ts b/api/v2/shipping/webhooks.ts index fa10ac8f3..3ef6f5416 100644 --- a/api/v2/shipping/webhooks.ts +++ b/api/v2/shipping/webhooks.ts @@ -111,7 +111,10 @@ function ownerIndexKey(ownerHash: string): string { /** SHA-256 hash of the caller's API key — used as ownerTag and owner index key. Never secret. */ async function callerFingerprint(req: Request): Promise { - const key = req.headers.get('X-WorldMonitor-Key') ?? ''; + const key = + req.headers.get('X-WorldMonitor-Key') ?? + req.headers.get('X-Api-Key') ?? + ''; if (!key) return 'anon'; const encoded = new TextEncoder().encode(key); const hashBuffer = await crypto.subtle.digest('SHA-256', encoded); diff --git a/api/widget-agent.ts b/api/widget-agent.ts index 13291f32b..50d9becc9 100644 --- a/api/widget-agent.ts +++ b/api/widget-agent.ts @@ -51,7 +51,7 @@ export default async function handler(req: Request): Promise { headers: { ...corsHeaders, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Widget-Key, X-Pro-Key', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Api-Key, X-Widget-Key, X-Pro-Key', }, }); } @@ -59,7 +59,10 @@ export default async function handler(req: Request): Promise { // ── Auth ────────────────────────────────────────────────────────────────── let isPro = false; - const worldMonitorKey = req.headers.get('X-WorldMonitor-Key') ?? ''; + const worldMonitorKey = + req.headers.get('X-WorldMonitor-Key') ?? + req.headers.get('X-Api-Key') ?? + ''; if (hasValidWorldMonitorKey(worldMonitorKey)) { isPro = true; } else { diff --git a/middleware.ts b/middleware.ts index 7aaab7121..600b612b4 100644 --- a/middleware.ts +++ b/middleware.ts @@ -126,6 +126,22 @@ export default function middleware(request: Request) { return; } + // Authenticated Pro API clients bypass UA filtering. This is a cheap + // edge heuristic, not auth — real validation (SHA-256 hash vs Convex + // userApiKeys + entitlement) happens in server/gateway.ts. To keep the + // bot-UA shield meaningful, require the exact key shape emitted by + // src/services/api-keys.ts:generateKey: `wm_` + 40 lowercase hex chars. + // A random scraper would have to guess a specific 43-char format, and + // spoofed-but-well-shaped keys still 401 at the gateway. + const WM_KEY_SHAPE = /^wm_[a-f0-9]{40}$/; + const apiKey = + request.headers.get('x-worldmonitor-key') ?? + request.headers.get('x-api-key') ?? + ''; + if (WM_KEY_SHAPE.test(apiKey)) { + return; + } + // Block bots from all API routes if (BOT_UA.test(ua)) { return new Response('{"error":"Forbidden"}', { diff --git a/server/_shared/premium-check.ts b/server/_shared/premium-check.ts index 6ef702289..c95cdb3ae 100644 --- a/server/_shared/premium-check.ts +++ b/server/_shared/premium-check.ts @@ -12,7 +12,10 @@ import { validateUserApiKey } from './user-api-key'; export async function isCallerPremium(request: Request): Promise { // Browser tester keys — validateApiKey returns required:false for trusted origins // even when a valid key is present, so we check the header directly first. - const wmKey = request.headers.get('X-WorldMonitor-Key') ?? ''; + const wmKey = + request.headers.get('X-WorldMonitor-Key') ?? + request.headers.get('X-Api-Key') ?? + ''; if (wmKey) { const validKeys = (process.env.WORLDMONITOR_VALID_KEYS ?? '') .split(',').map((k) => k.trim()).filter(Boolean); diff --git a/server/cors.ts b/server/cors.ts index 02ff381b8..436967e2c 100644 --- a/server/cors.ts +++ b/server/cors.ts @@ -34,7 +34,7 @@ export function getCorsHeaders(req: Request): Record { return { 'Access-Control-Allow-Origin': allowOrigin, 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Widget-Key, X-Pro-Key', + 'Access-Control-Allow-Headers': 'Content-Type, Authorization, X-WorldMonitor-Key, X-Api-Key, X-Widget-Key, X-Pro-Key', 'Access-Control-Max-Age': '3600', 'Vary': 'Origin', }; diff --git a/server/gateway.ts b/server/gateway.ts index 70af0a6f2..23a39d16e 100644 --- a/server/gateway.ts +++ b/server/gateway.ts @@ -312,7 +312,10 @@ export function createDomainGateway( // User-owned API keys (wm_ prefix): when the static WORLDMONITOR_VALID_KEYS // check fails, try async Convex-backed validation for user-issued keys. let isUserApiKey = false; - const wmKey = request.headers.get('X-WorldMonitor-Key') ?? ''; + const wmKey = + request.headers.get('X-WorldMonitor-Key') ?? + request.headers.get('X-Api-Key') ?? + ''; if (keyCheck.required && !keyCheck.valid && wmKey.startsWith('wm_')) { const { validateUserApiKey } = await import('./_shared/user-api-key'); const userKeyResult = await validateUserApiKey(wmKey);