fix(api): unblock Pro API clients at edge + accept x-api-key alias (#3155)

* 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)
This commit is contained in:
Elie Habib
2026-04-18 08:18:49 +04:00
committed by GitHub
parent 1732d0554b
commit de769ce8e1
10 changed files with 42 additions and 11 deletions

View File

@@ -33,7 +33,7 @@ function extractOriginFromReferer(referer) {
export function validateApiKey(req, options = {}) { export function validateApiKey(req, options = {}) {
const forceKey = options.forceKey === true; 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). // Same-origin browser requests don't send Origin (per CORS spec).
// Fall back to Referer to identify trusted same-origin callers. // Fall back to Referer to identify trusted same-origin callers.
const origin = req.headers.get('Origin') || extractOriginFromReferer(req.headers.get('Referer')) || ''; const origin = req.headers.get('Origin') || extractOriginFromReferer(req.headers.get('Referer')) || '';

View File

@@ -22,7 +22,7 @@ export function getCorsHeaders(req, methods = 'GET, OPTIONS') {
return { return {
'Access-Control-Allow-Origin': allowOrigin, 'Access-Control-Allow-Origin': allowOrigin,
'Access-Control-Allow-Methods': methods, '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', 'Access-Control-Max-Age': '3600',
'Vary': 'Origin', 'Vary': 'Origin',
}; };
@@ -40,7 +40,7 @@ export function getPublicCorsHeaders(methods = 'GET, OPTIONS') {
return { return {
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': methods, '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', 'Access-Control-Max-Age': '3600',
}; };
} }

View File

@@ -76,7 +76,7 @@ export default async function handler(req: Request): Promise<Response> {
headers: { headers: {
...corsHeaders, ...corsHeaders,
'Access-Control-Allow-Methods': 'POST, OPTIONS', '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',
}, },
}); });
} }

View File

@@ -51,7 +51,10 @@ export default async function handler(req: Request): Promise<Response> {
let apiKeyResult = validateApiKey(req, { forceKey: true }); let apiKeyResult = validateApiKey(req, { forceKey: true });
// Fallback: wm_ user keys are validated async via Convex, not in the static key list // 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_')) { if (apiKeyResult.required && !apiKeyResult.valid && wmKey.startsWith('wm_')) {
const { validateUserApiKey } = await import('../../../server/_shared/user-api-key'); const { validateUserApiKey } = await import('../../../server/_shared/user-api-key');
const userKeyResult = await validateUserApiKey(wmKey); const userKeyResult = await validateUserApiKey(wmKey);

View File

@@ -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. */ /** SHA-256 hash of the caller's API key — used as ownerTag and owner index key. Never secret. */
async function callerFingerprint(req: Request): Promise<string> { async function callerFingerprint(req: Request): Promise<string> {
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'; if (!key) return 'anon';
const encoded = new TextEncoder().encode(key); const encoded = new TextEncoder().encode(key);
const hashBuffer = await crypto.subtle.digest('SHA-256', encoded); const hashBuffer = await crypto.subtle.digest('SHA-256', encoded);

View File

@@ -51,7 +51,7 @@ export default async function handler(req: Request): Promise<Response> {
headers: { headers: {
...corsHeaders, ...corsHeaders,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', '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<Response> {
// ── Auth ────────────────────────────────────────────────────────────────── // ── Auth ──────────────────────────────────────────────────────────────────
let isPro = false; 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)) { if (hasValidWorldMonitorKey(worldMonitorKey)) {
isPro = true; isPro = true;
} else { } else {

View File

@@ -126,6 +126,22 @@ export default function middleware(request: Request) {
return; 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 // Block bots from all API routes
if (BOT_UA.test(ua)) { if (BOT_UA.test(ua)) {
return new Response('{"error":"Forbidden"}', { return new Response('{"error":"Forbidden"}', {

View File

@@ -12,7 +12,10 @@ import { validateUserApiKey } from './user-api-key';
export async function isCallerPremium(request: Request): Promise<boolean> { export async function isCallerPremium(request: Request): Promise<boolean> {
// Browser tester keys — validateApiKey returns required:false for trusted origins // Browser tester keys — validateApiKey returns required:false for trusted origins
// even when a valid key is present, so we check the header directly first. // 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) { if (wmKey) {
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS ?? '') const validKeys = (process.env.WORLDMONITOR_VALID_KEYS ?? '')
.split(',').map((k) => k.trim()).filter(Boolean); .split(',').map((k) => k.trim()).filter(Boolean);

View File

@@ -34,7 +34,7 @@ export function getCorsHeaders(req: Request): Record<string, string> {
return { return {
'Access-Control-Allow-Origin': allowOrigin, 'Access-Control-Allow-Origin': allowOrigin,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', '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', 'Access-Control-Max-Age': '3600',
'Vary': 'Origin', 'Vary': 'Origin',
}; };

View File

@@ -312,7 +312,10 @@ export function createDomainGateway(
// User-owned API keys (wm_ prefix): when the static WORLDMONITOR_VALID_KEYS // User-owned API keys (wm_ prefix): when the static WORLDMONITOR_VALID_KEYS
// check fails, try async Convex-backed validation for user-issued keys. // check fails, try async Convex-backed validation for user-issued keys.
let isUserApiKey = false; 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_')) { if (keyCheck.required && !keyCheck.valid && wmKey.startsWith('wm_')) {
const { validateUserApiKey } = await import('./_shared/user-api-key'); const { validateUserApiKey } = await import('./_shared/user-api-key');
const userKeyResult = await validateUserApiKey(wmKey); const userKeyResult = await validateUserApiKey(wmKey);