mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
* 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)
71 lines
2.6 KiB
JavaScript
71 lines
2.6 KiB
JavaScript
const DESKTOP_ORIGIN_PATTERNS = [
|
|
/^https?:\/\/tauri\.localhost(:\d+)?$/,
|
|
/^https?:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i,
|
|
/^tauri:\/\/localhost$/,
|
|
/^asset:\/\/localhost$/,
|
|
];
|
|
|
|
const BROWSER_ORIGIN_PATTERNS = [
|
|
/^https:\/\/(.*\.)?worldmonitor\.app$/,
|
|
/^https:\/\/worldmonitor-[a-z0-9-]+-elie-[a-z0-9]+\.vercel\.app$/,
|
|
...(process.env.NODE_ENV === 'production' ? [] : [
|
|
/^https?:\/\/localhost(:\d+)?$/,
|
|
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
|
|
]),
|
|
];
|
|
|
|
function isDesktopOrigin(origin) {
|
|
return Boolean(origin) && DESKTOP_ORIGIN_PATTERNS.some(p => p.test(origin));
|
|
}
|
|
|
|
function isTrustedBrowserOrigin(origin) {
|
|
return Boolean(origin) && BROWSER_ORIGIN_PATTERNS.some(p => p.test(origin));
|
|
}
|
|
|
|
function extractOriginFromReferer(referer) {
|
|
if (!referer) return '';
|
|
try {
|
|
return new URL(referer).origin;
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
export function validateApiKey(req, options = {}) {
|
|
const forceKey = options.forceKey === true;
|
|
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')) || '';
|
|
|
|
// Desktop app — always require API key
|
|
if (isDesktopOrigin(origin)) {
|
|
if (!key) return { valid: false, required: true, error: 'API key required for desktop access' };
|
|
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
|
|
if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' };
|
|
return { valid: true, required: true };
|
|
}
|
|
|
|
// Trusted browser origin (worldmonitor.app, Vercel previews, localhost dev) — no key needed
|
|
if (isTrustedBrowserOrigin(origin)) {
|
|
if (forceKey && !key) {
|
|
return { valid: false, required: true, error: 'API key required' };
|
|
}
|
|
if (key) {
|
|
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
|
|
if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' };
|
|
}
|
|
return { valid: true, required: forceKey };
|
|
}
|
|
|
|
// Explicit key provided from unknown origin — validate it
|
|
if (key) {
|
|
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
|
|
if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' };
|
|
return { valid: true, required: true };
|
|
}
|
|
|
|
// No origin, no key — require API key (blocks unauthenticated curl/scripts)
|
|
return { valid: false, required: true, error: 'API key required' };
|
|
}
|