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)
165 lines
6.5 KiB
TypeScript
165 lines
6.5 KiB
TypeScript
const BOT_UA =
|
|
/bot|crawl|spider|slurp|archiver|wget|curl\/|python-requests|scrapy|httpclient|go-http|java\/|libwww|perl|ruby|php\/|ahrefsbot|semrushbot|mj12bot|dotbot|baiduspider|yandexbot|sogou|bytespider|petalbot|gptbot|claudebot|ccbot/i;
|
|
|
|
const SOCIAL_PREVIEW_UA =
|
|
/twitterbot|facebookexternalhit|linkedinbot|slackbot|telegrambot|whatsapp|discordbot|redditbot/i;
|
|
|
|
const SOCIAL_PREVIEW_PATHS = new Set(['/api/story', '/api/og-story']);
|
|
|
|
// Paths that bypass bot/script UA filtering below. Each must carry its own
|
|
// auth (API key, shared secret, or intentionally-public semantics) because
|
|
// this list disables the middleware's generic bot gate.
|
|
// - /api/version, /api/health: intentionally public, monitoring-friendly.
|
|
// - /api/seed-contract-probe: requires RELAY_SHARED_SECRET header; called by
|
|
// UptimeRobot + ops curl. Was blocked by the curl/bot UA regex before this
|
|
// exception landed (Vercel log 2026-04-15: "Middleware 403 Forbidden" on
|
|
// /api/seed-contract-probe).
|
|
const PUBLIC_API_PATHS = new Set(['/api/version', '/api/health', '/api/seed-contract-probe']);
|
|
|
|
const SOCIAL_IMAGE_UA =
|
|
/Slack-ImgProxy|Slackbot|twitterbot|facebookexternalhit|linkedinbot|telegrambot|whatsapp|discordbot|redditbot/i;
|
|
|
|
const VARIANT_HOST_MAP: Record<string, string> = {
|
|
'tech.worldmonitor.app': 'tech',
|
|
'finance.worldmonitor.app': 'finance',
|
|
'commodity.worldmonitor.app': 'commodity',
|
|
'happy.worldmonitor.app': 'happy',
|
|
};
|
|
|
|
// Source of truth: src/config/variant-meta.ts — keep in sync when variant metadata changes.
|
|
const VARIANT_OG: Record<string, { title: string; description: string; image: string; url: string }> = {
|
|
tech: {
|
|
title: 'Tech Monitor - Real-Time AI & Tech Industry Dashboard',
|
|
description: 'Real-time AI and tech industry dashboard tracking tech giants, AI labs, startup ecosystems, funding rounds, and tech events worldwide.',
|
|
image: 'https://tech.worldmonitor.app/favico/tech/og-image.png',
|
|
url: 'https://tech.worldmonitor.app/',
|
|
},
|
|
finance: {
|
|
title: 'Finance Monitor - Real-Time Markets & Trading Dashboard',
|
|
description: 'Real-time finance and trading dashboard tracking global markets, stock exchanges, central banks, commodities, forex, crypto, and economic indicators worldwide.',
|
|
image: 'https://finance.worldmonitor.app/favico/finance/og-image.png',
|
|
url: 'https://finance.worldmonitor.app/',
|
|
},
|
|
commodity: {
|
|
title: 'Commodity Monitor - Real-Time Commodity Markets & Supply Chain Dashboard',
|
|
description: 'Real-time commodity markets dashboard tracking mining sites, processing plants, commodity ports, supply chains, and global commodity trade flows.',
|
|
image: 'https://commodity.worldmonitor.app/favico/commodity/og-image.png',
|
|
url: 'https://commodity.worldmonitor.app/',
|
|
},
|
|
happy: {
|
|
title: 'Happy Monitor - Good News & Global Progress',
|
|
description: 'Curated positive news, progress data, and uplifting stories from around the world.',
|
|
image: 'https://happy.worldmonitor.app/favico/happy/og-image.png',
|
|
url: 'https://happy.worldmonitor.app/',
|
|
},
|
|
};
|
|
|
|
const ALLOWED_HOSTS = new Set([
|
|
'worldmonitor.app',
|
|
...Object.keys(VARIANT_HOST_MAP),
|
|
]);
|
|
const VERCEL_PREVIEW_RE = /^[a-z0-9-]+-[a-z0-9]{8,}\.vercel\.app$/;
|
|
|
|
function normalizeHost(raw: string): string {
|
|
return raw.toLowerCase().replace(/:\d+$/, '');
|
|
}
|
|
|
|
function isAllowedHost(host: string): boolean {
|
|
return ALLOWED_HOSTS.has(host) || VERCEL_PREVIEW_RE.test(host);
|
|
}
|
|
|
|
export default function middleware(request: Request) {
|
|
const url = new URL(request.url);
|
|
const ua = request.headers.get('user-agent') ?? '';
|
|
const path = url.pathname;
|
|
const host = normalizeHost(request.headers.get('host') ?? url.hostname);
|
|
|
|
// Social bot OG response for variant subdomain root pages
|
|
if (path === '/' && SOCIAL_PREVIEW_UA.test(ua)) {
|
|
const variant = VARIANT_HOST_MAP[host];
|
|
if (variant && isAllowedHost(host)) {
|
|
const og = VARIANT_OG[variant as keyof typeof VARIANT_OG];
|
|
if (og) {
|
|
const html = `<!DOCTYPE html><html><head>
|
|
<meta property="og:type" content="website"/>
|
|
<meta property="og:title" content="${og.title}"/>
|
|
<meta property="og:description" content="${og.description}"/>
|
|
<meta property="og:image" content="${og.image}"/>
|
|
<meta property="og:url" content="${og.url}"/>
|
|
<meta name="twitter:card" content="summary_large_image"/>
|
|
<meta name="twitter:title" content="${og.title}"/>
|
|
<meta name="twitter:description" content="${og.description}"/>
|
|
<meta name="twitter:image" content="${og.image}"/>
|
|
<title>${og.title}</title>
|
|
</head><body></body></html>`;
|
|
return new Response(html, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=utf-8',
|
|
'Cache-Control': 'no-store',
|
|
'Vary': 'User-Agent, Host',
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Only apply bot filtering to /api/* and /favico/* paths
|
|
if (!path.startsWith('/api/') && !path.startsWith('/favico/')) {
|
|
return;
|
|
}
|
|
|
|
// Allow social preview/image bots on OG image assets
|
|
if (path.startsWith('/favico/') || path.endsWith('.png')) {
|
|
if (SOCIAL_IMAGE_UA.test(ua)) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Allow social preview bots on exact OG routes only
|
|
if (SOCIAL_PREVIEW_UA.test(ua) && SOCIAL_PREVIEW_PATHS.has(path)) {
|
|
return;
|
|
}
|
|
|
|
// Public endpoints bypass all bot filtering
|
|
if (PUBLIC_API_PATHS.has(path)) {
|
|
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"}', {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
|
|
// No user-agent or suspiciously short — likely a script
|
|
if (!ua || ua.length < 10) {
|
|
return new Response('{"error":"Forbidden"}', {
|
|
status: 403,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
});
|
|
}
|
|
}
|
|
|
|
export const config = {
|
|
matcher: ['/', '/api/:path*', '/favico/:path*'],
|
|
};
|