Skip to content

Commit 90b0ccd

Browse files
authored
fix(security): replace forgeable header trust with HMAC session token (koala73#3541) (koala73#3557)
* fix(security): replace forgeable header trust with HMAC session token (koala73#3541) The previous validateApiKey trusted the Origin header (and a Referer fallback) to grant trusted-browser access without an API key. Both headers are entirely client-controlled at the wire level — curl can set either with one flag, bypassing the gate. Closed PR koala73#3554 attempted to move the trust to Sec-Fetch-Site; same problem (Forbidden Header is a browser-side JS API restriction, not a wire-level guarantee). Only cryptographic proof closes that bypass class. The fix: - Add api/_session.js: HMAC-SHA256 (Web Crypto) token issuer/validator. Tokens are wms_<base64url(payload)>.<sig>, payload {iat,exp,n}, 12h TTL. Fails closed when WM_SESSION_SECRET is unset (operator-visible 503). - Add api/wm-session.js: POST endpoint that mints a token, rate-limited per IP via the existing checkRateLimit. Returns 503 if secret unset. - Rewrite api/_api-key.js: drop the Origin/Referer no-key trust paths entirely. Browsers now authenticate via wms_ token, wm_ user key, enterprise key, or (via the gateway) a valid Clerk JWT. - Add server-side gateway override: tier-gated routes with a resolved Clerk session pass without a key (the Clerk JWT is the auth). - Add src/services/wm-session.ts: thin client that fetches+caches the token and patches globalThis.fetch ONCE at boot to attach X-WorldMonitor-Key on calls to our API origin. Avoids touching ~50 individual fetch sites. Skips when caller already set Authorization / X-WorldMonitor-Key / X-Api-Key (Clerk Bearer takes precedence). - Wire into App.ts boot before fetchBootstrapData (skipped on desktop which uses its own enterprise-key path). - Migrate validateApiKey to async + update all 7 call sites; add 35 unit tests covering the bypass regression, defense-in-depth (combined header forgeries), and existing flows (Tauri, Bearer, forceKey). - Update tests/premium-stock-gateway, tests/gateway-cdn-origin-policy, tests/usage-telemetry-emission to provide a valid wms_ token where they previously relied on the trusted-origin bypass — mirrors the production fetch-interceptor flow. Operator action required before merge: set WM_SESSION_SECRET (≥32 chars) in Vercel env. Without it, /api/wm-session returns 503 and anonymous browser users cannot authenticate. Closes koala73#3541. Replaces closed PR koala73#3554. * fix(security): close wms_ entitlement-bypass + canonical b64url defence (koala73#3557 review) Two BLOCKING findings from PR koala73#3557 review: 1. Premium-endpoint bypass via anonymous session token ---------------------------------------------------- validateApiKey returned valid:true for any wms_ token; gateway treated any non-wm_ valid key as enterprise/admin and skipped entitlement checks. Result: a freshly minted anonymous wms_ token got 200 on /api/market/v1/analyze-stock and /api/resilience/v1/get-resilience-score with no Clerk session, no Pro entitlement, no enterprise key. Fix: - validateApiKey result now carries kind: 'session' | 'user' | 'enterprise'. Only kind='enterprise' bypasses entitlement. - validateApiKey rejects wms_ when forceKey=true (premium / tier-gated endpoints set forceKey exactly because they need user-bound auth). - gateway.ts entitlement-skip condition tightened to require kind === 'enterprise', not just "valid && not isUserApiKey". 2. Tamper test was non-deterministic (intermittent CI failure) ----------------------------------------------------------- _session.test.mjs flipped the last base64url char of the signature. For SHA-256 (32 bytes -> 43 b64url chars, no padding), the last char encodes 2 high bits of byte 32 plus 4 unused padding bits -- flipping only padding bits decodes to identical signature bytes and passes HMAC verification. Whether the test caught tampering depended on the random nonce of that run. Fix: - Tamper test now decodes the signature bytes, XORs the first byte, and re-encodes -- guaranteed to differ. - Added a defence-in-depth canonical-encoding check inside validateSessionToken: re-encode decoded bytes and require an exact match. Rejects any non-canonical b64url that would otherwise sneak through atob's permissive decoder. - Added an explicit non-canonical-padding-bit-flip test. Regression coverage: - _api-key.test.mjs: forceKey=true + wms_ -> reject; wms_ result kind !== 'enterprise'; enterprise key kind === 'enterprise'. - premium-stock-gateway.test.mts: explicit gateway-level test that anonymous wms_ MUST NOT unlock /analyze-stock or /get-resilience-score (locks the entitlement-bypass anti-regression). All checks: typecheck, typecheck:api, lint, bundle, test:data (7820 / 7820 pass), api session/key/wm-session unit tests (37 / 37 pass). * chore(api-contract): list api/wm-session.js as ops-admin exception Sebuf-API-contract lint flagged api/wm-session.js as a non-proto JSON endpoint without an exception entry. Add it under ops-admin: a session- bootstrap mint with a 2-field response is below the bar where a sebuf RPC adds value, and shaped by Vercel Edge / fetch interceptor needs rather than a product surface. Reviewer: @SebastienMelki per CODEOWNERS. Context: PR koala73#3557, issue koala73#3541. Fixes the biome-job lint:api-contract failure on PR koala73#3557. * fix(security): wms_ wrapper steps aside on premium routes + probe mints token (koala73#3557 review) Two BLOCKING findings from PR koala73#3557 review: 1. wms_ wrapper suppressed Clerk auth on premium routes ---------------------------------------------------- installWebApiRedirect (main.ts:712) installs the premium-auth injector first; my installWmSessionFetchInterceptor (App.ts:946) wraps that patched fetch second. Result: wms_ wrapper runs as the OUTER layer and sets X-WorldMonitor-Key: wms_... on every API call before delegating. The inner premium injector (enrichInitForPremium) then sees an existing X-WorldMonitor-Key and returns early, so it never adds the Clerk Authorization: Bearer ... header. Plain or generated premium RPC fetches from signed-in Pro users were being sent with only the anonymous wms_ token and got 401 from the server (combined with the previous review fix that rejects wms_ on forceKey=true premium endpoints). Fix: wms_ wrapper now early-returns for any path in PREMIUM_RPC_PATHS (the same canonical set the premium injector consumes), letting the inner layer attach Clerk Bearer / API key / tester key as appropriate. 2. seed-contract-probe public-boundary check now 401s /api/bootstrap ------------------------------------------------------------------ checkPublicBoundary() sent /api/bootstrap with only an Origin header, relying on the now-removed trusted-browser path. Post-PR, /api/bootstrap requires a wms_ token, so the probe marked the boundary check as bad status 401 and returned 503. Fix: probe now mints its own wms_ token in-process via issueSessionToken() and passes it as X-WorldMonitor-Key. Same WM_SESSION_SECRET environment as /api/wm-session, so this is equivalent to the round-trip without the extra hop. Falls back gracefully if the secret is unset — the resulting 401 surfaces as the right operator signal for missing config. All checks: typecheck x 2, lint, lint:api-contract, bundle (clean), test:data 7820/7820, sidecar 37/37. * fix(telemetry): require kind=enterprise to set usage.enterpriseApiKey (koala73#3557 review) The enterprise-key marker condition (gateway.ts:475) was: keyCheck.valid && wmKey && !isUserApiKey && !wmKey.startsWith('wm_') Anonymous wms_ tokens slip through every clause: validateApiKey returns valid:true, wmKey is set, isUserApiKey only flips for wm_ user keys, and 'wms_' does NOT start with 'wm_' (third char is 's', not '_'). Result: usage.enterpriseApiKey gets the wms_ string and downstream telemetry emits auth_kind:'enterprise_api_key' with customer_id:'enterprise-unmapped' for every anonymous browser request. Fix: tighten the condition to require keyCheck.kind === 'enterprise' -- the kind field already added in the previous review pass for the entitlement-bypass fix. Telemetry now correctly emits auth_kind:'anon' for anonymous wms_ traffic. Regression test added in tests/usage-telemetry-emission.test.mts that locks the contract: anon wms_ token MUST telemeter as anon, MUST NOT telemeter as enterprise-unmapped.
1 parent 26ed611 commit 90b0ccd

24 files changed

Lines changed: 951 additions & 75 deletions

api/_api-key.js

Lines changed: 53 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,70 +1,82 @@
1+
import { isSessionTokenShape, validateSessionToken } from './_session.js';
2+
13
const DESKTOP_ORIGIN_PATTERNS = [
24
/^https?:\/\/tauri\.localhost(:\d+)?$/,
35
/^https?:\/\/[a-z0-9-]+\.tauri\.localhost(:\d+)?$/i,
46
/^tauri:\/\/localhost$/,
57
/^asset:\/\/localhost$/,
68
];
79

8-
const BROWSER_ORIGIN_PATTERNS = [
9-
/^https:\/\/(.*\.)?worldmonitor\.app$/,
10-
/^https:\/\/worldmonitor-[a-z0-9-]+-elie-[a-z0-9]+\.vercel\.app$/,
11-
...(process.env.NODE_ENV === 'production' ? [] : [
12-
/^https?:\/\/localhost(:\d+)?$/,
13-
/^https?:\/\/127\.0\.0\.1(:\d+)?$/,
14-
]),
15-
];
16-
1710
function isDesktopOrigin(origin) {
1811
return Boolean(origin) && DESKTOP_ORIGIN_PATTERNS.some(p => p.test(origin));
1912
}
2013

21-
function isTrustedBrowserOrigin(origin) {
22-
return Boolean(origin) && BROWSER_ORIGIN_PATTERNS.some(p => p.test(origin));
23-
}
24-
25-
function extractOriginFromReferer(referer) {
26-
if (!referer) return '';
27-
try {
28-
return new URL(referer).origin;
29-
} catch {
30-
return '';
31-
}
14+
function isValidEnterpriseKey(key) {
15+
if (!key) return false;
16+
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
17+
return validKeys.includes(key);
3218
}
3319

34-
export function validateApiKey(req, options = {}) {
20+
// Note: HTTP headers like Origin / Referer / Sec-Fetch-Site are entirely
21+
// client-controlled at the wire level (see issue #3541 / closed PR #3554).
22+
// Trusting any of them as a "this is a real browser" signal is forgeable by
23+
// curl in one line. The previous Referer-origin fallback and Origin-pattern
24+
// no-key trust path are both gone. Browsers now authenticate via:
25+
// 1. A short-lived wms_-prefixed session token (HMAC-signed by /api/wm-session).
26+
// Kind: 'session'. Anonymous; satisfies basic gate, but downstream
27+
// entitlement / premium checks must STILL run (a session token is freely
28+
// mintable by anyone who can hit /api/wm-session — it is NOT proof of a
29+
// paying user). Rejected when forceKey=true.
30+
// 2. A wm_-prefixed user API key (validated against the user-key table by gateway).
31+
// Kind: 'user'. Returns required:true/valid:false here so the gateway's
32+
// fallback at server/gateway.ts:~440 triggers validateUserApiKey().
33+
// 3. An enterprise key (WORLDMONITOR_VALID_KEYS). Kind: 'enterprise'. The
34+
// ONLY kind that bypasses entitlement checks (operator-issued).
35+
// Tauri desktop continues to authenticate via enterprise key.
36+
//
37+
// Async because session validation uses Web Crypto (crypto.subtle.sign).
38+
// All call sites await this — see grep for migration history.
39+
export async function validateApiKey(req, options = {}) {
3540
const forceKey = options.forceKey === true;
3641
const key = req.headers.get('X-WorldMonitor-Key') || req.headers.get('X-Api-Key');
37-
// Same-origin browser requests don't send Origin (per CORS spec).
38-
// Fall back to Referer to identify trusted same-origin callers.
39-
const origin = req.headers.get('Origin') || extractOriginFromReferer(req.headers.get('Referer')) || '';
42+
const origin = req.headers.get('Origin') || '';
4043

41-
// Desktop app — always require API key
44+
// Desktop app — always require an enterprise key.
4245
if (isDesktopOrigin(origin)) {
4346
if (!key) return { valid: false, required: true, error: 'API key required for desktop access' };
44-
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
45-
if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' };
46-
return { valid: true, required: true };
47+
if (!isValidEnterpriseKey(key)) return { valid: false, required: true, error: 'Invalid API key' };
48+
return { valid: true, required: true, kind: 'enterprise' };
4749
}
4850

49-
// Trusted browser origin (worldmonitor.app, Vercel previews, localhost dev) — no key needed
50-
if (isTrustedBrowserOrigin(origin)) {
51-
if (forceKey && !key) {
52-
return { valid: false, required: true, error: 'API key required' };
51+
// Browser anonymous session: HMAC-signed token from /api/wm-session.
52+
// Validation is purely cryptographic — no DB lookup, no header trust.
53+
if (isSessionTokenShape(key)) {
54+
// Anonymous session tokens are NOT proof of any specific user identity
55+
// — anyone can mint one via POST /api/wm-session. Reject when the caller
56+
// demands a "real" key (premium / tier-gated endpoints set forceKey=true
57+
// exactly because they need user-bound auth or a Pro-grade Bearer JWT).
58+
if (forceKey) {
59+
return { valid: false, required: true, error: 'Pro authentication required' };
5360
}
54-
if (key) {
55-
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
56-
if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' };
61+
if (await validateSessionToken(key)) {
62+
return { valid: true, required: false, kind: 'session' };
5763
}
58-
return { valid: true, required: forceKey };
64+
return { valid: false, required: true, error: 'Invalid session token' };
65+
}
66+
67+
// wm_-prefixed user API keys — gateway re-validates against the user-key
68+
// table. We must return required:true / valid:false for the gateway's
69+
// fallback at server/gateway.ts:~440 to trigger validateUserApiKey().
70+
if (key && key.startsWith('wm_')) {
71+
return { valid: false, required: true, error: 'User API key requires gateway validation' };
5972
}
6073

61-
// Explicit key provided from unknown origin — validate it
74+
// Enterprise key (WORLDMONITOR_VALID_KEYS).
6275
if (key) {
63-
const validKeys = (process.env.WORLDMONITOR_VALID_KEYS || '').split(',').filter(Boolean);
64-
if (!validKeys.includes(key)) return { valid: false, required: true, error: 'Invalid API key' };
65-
return { valid: true, required: true };
76+
if (!isValidEnterpriseKey(key)) return { valid: false, required: true, error: 'Invalid API key' };
77+
return { valid: true, required: true, kind: 'enterprise' };
6678
}
6779

68-
// No origin, no key — require API key (blocks unauthenticated curl/scripts)
80+
// No credentials at all.
6981
return { valid: false, required: true, error: 'API key required' };
7082
}

api/_api-key.test.mjs

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { strict as assert } from 'node:assert';
2+
import test from 'node:test';
3+
4+
const SECRET = 'test-secret-must-be-at-least-32-chars-long-xxx';
5+
const ENTERPRISE_KEY = 'enterprise-test-key-123';
6+
process.env.WM_SESSION_SECRET = SECRET;
7+
process.env.WORLDMONITOR_VALID_KEYS = ENTERPRISE_KEY;
8+
9+
const { validateApiKey } = await import('./_api-key.js');
10+
const { issueSessionToken } = await import('./_session.js');
11+
12+
function makeReq({ origin, referer, secFetchSite, key } = {}) {
13+
const headers = new Headers();
14+
if (origin) headers.set('origin', origin);
15+
if (referer) headers.set('referer', referer);
16+
if (secFetchSite) headers.set('sec-fetch-site', secFetchSite);
17+
if (key) headers.set('x-worldmonitor-key', key);
18+
return new Request('https://api.worldmonitor.app/api/test', { headers });
19+
}
20+
21+
// ── #3541 regression: header-only signals must NEVER pass ──────────────────
22+
23+
test('#3541: forged Referer alone is rejected', async () => {
24+
const r = await validateApiKey(makeReq({ referer: 'https://worldmonitor.app/' }));
25+
assert.equal(r.valid, false);
26+
assert.equal(r.required, true);
27+
});
28+
29+
test('#3541: forged Sec-Fetch-Site: same-origin alone is rejected (this was the closed-PR bug)', async () => {
30+
const r = await validateApiKey(makeReq({ secFetchSite: 'same-origin' }));
31+
assert.equal(r.valid, false);
32+
});
33+
34+
test('#3541: forged Origin: https://worldmonitor.app alone is rejected (no key, no session)', async () => {
35+
const r = await validateApiKey(makeReq({ origin: 'https://worldmonitor.app' }));
36+
assert.equal(r.valid, false);
37+
});
38+
39+
test('#3541: combined forged Origin + Sec-Fetch-Site + Referer all together is still rejected', async () => {
40+
const r = await validateApiKey(makeReq({
41+
origin: 'https://worldmonitor.app',
42+
referer: 'https://worldmonitor.app/',
43+
secFetchSite: 'same-origin',
44+
}));
45+
assert.equal(r.valid, false);
46+
});
47+
48+
test('#3541: Sec-Fetch-Site: cross-site alone is rejected', async () => {
49+
const r = await validateApiKey(makeReq({ secFetchSite: 'cross-site' }));
50+
assert.equal(r.valid, false);
51+
});
52+
53+
test('#3541: Sec-Fetch-Site: none alone is rejected', async () => {
54+
const r = await validateApiKey(makeReq({ secFetchSite: 'none' }));
55+
assert.equal(r.valid, false);
56+
});
57+
58+
// ── Anonymous browser session token (the new trust path) ────────────────────
59+
60+
test('valid wms_ session token from any origin is accepted (forceKey=false)', async () => {
61+
const { token } = await issueSessionToken();
62+
const r = await validateApiKey(makeReq({ key: token }));
63+
assert.equal(r.valid, true);
64+
assert.equal(r.required, false);
65+
assert.equal(r.kind, 'session', 'must tag as session — gateway uses kind to decide entitlement bypass');
66+
});
67+
68+
test('PR #3557 review: wms_ session token is REJECTED when forceKey=true (premium endpoints)', async () => {
69+
// wms_ tokens are anonymous and freely mintable via /api/wm-session — they
70+
// are NOT proof of a paying user. forceKey=true means the route demands a
71+
// user-bound credential (Pro Bearer JWT, wm_ user key, or enterprise key).
72+
const { token } = await issueSessionToken();
73+
const r = await validateApiKey(makeReq({ key: token }), { forceKey: true });
74+
assert.equal(r.valid, false);
75+
assert.equal(r.required, true);
76+
assert.match(r.error, /Pro authentication/);
77+
});
78+
79+
test('PR #3557 review: wms_ result must NOT carry kind=enterprise (gateway entitlement-bypass anti-regression)', async () => {
80+
// Gateway skips entitlement check ONLY for kind:'enterprise'. If a future
81+
// refactor mislabels wms_ as enterprise, anonymous tokens silently unlock
82+
// premium endpoints. Lock the contract here.
83+
const { token } = await issueSessionToken();
84+
const r = await validateApiKey(makeReq({ key: token }));
85+
assert.notEqual(r.kind, 'enterprise');
86+
});
87+
88+
test('enterprise key carries kind=enterprise (the only key kind that bypasses entitlement)', async () => {
89+
const r = await validateApiKey(makeReq({ key: ENTERPRISE_KEY }));
90+
assert.equal(r.valid, true);
91+
assert.equal(r.kind, 'enterprise');
92+
});
93+
94+
test('valid wms_ session token works even when Origin is also forged (not redundant — no privilege escalation)', async () => {
95+
const { token } = await issueSessionToken();
96+
const r = await validateApiKey(makeReq({ origin: 'https://evil.example.com', key: token }));
97+
assert.equal(r.valid, true);
98+
});
99+
100+
test('tampered wms_ token is rejected', async () => {
101+
const { token } = await issueSessionToken();
102+
const tampered = token.slice(0, -1) + (token.slice(-1) === 'A' ? 'B' : 'A');
103+
const r = await validateApiKey(makeReq({ key: tampered }));
104+
assert.equal(r.valid, false);
105+
assert.equal(r.error, 'Invalid session token');
106+
});
107+
108+
test('garbage wms_ shape is rejected', async () => {
109+
const r = await validateApiKey(makeReq({ key: 'wms_garbage' }));
110+
assert.equal(r.valid, false);
111+
});
112+
113+
// ── Enterprise key (WORLDMONITOR_VALID_KEYS) ────────────────────────────────
114+
115+
test('valid enterprise key is accepted from any origin', async () => {
116+
const r = await validateApiKey(makeReq({ origin: 'https://evil.example.com', key: ENTERPRISE_KEY }));
117+
assert.equal(r.valid, true);
118+
assert.equal(r.required, true);
119+
});
120+
121+
test('invalid enterprise-shape key is rejected', async () => {
122+
const r = await validateApiKey(makeReq({ key: 'random-string' }));
123+
assert.equal(r.valid, false);
124+
});
125+
126+
// ── User API key (wm_-prefix) — gateway handles validation ──────────────────
127+
128+
test('wm_-prefixed user key returns required:true / valid:false so gateway can fall back', async () => {
129+
// Gateway code at server/gateway.ts:440 does:
130+
// if (keyCheck.required && !keyCheck.valid && wmKey.startsWith('wm_')) { ...validateUserApiKey... }
131+
// So validateApiKey must return that exact shape for wm_ keys to trigger the fallback.
132+
const r = await validateApiKey(makeReq({ key: 'wm_user_abc123' }));
133+
assert.equal(r.required, true);
134+
assert.equal(r.valid, false);
135+
});
136+
137+
// ── Desktop (Tauri) — always requires enterprise key ────────────────────────
138+
139+
test('desktop Tauri origin without key is rejected', async () => {
140+
const r = await validateApiKey(makeReq({ origin: 'tauri://localhost' }));
141+
assert.equal(r.valid, false);
142+
assert.equal(r.error, 'API key required for desktop access');
143+
});
144+
145+
test('desktop Tauri origin with valid enterprise key is accepted', async () => {
146+
const r = await validateApiKey(makeReq({ origin: 'tauri://localhost', key: ENTERPRISE_KEY }));
147+
assert.equal(r.valid, true);
148+
});
149+
150+
test('desktop Tauri origin with wms_ session token is rejected (desktop must use enterprise key)', async () => {
151+
const { token } = await issueSessionToken();
152+
const r = await validateApiKey(makeReq({ origin: 'tauri://localhost', key: token }));
153+
assert.equal(r.valid, false);
154+
});
155+
156+
// ── Total absence of credentials ────────────────────────────────────────────
157+
158+
test('completely unauthenticated request is rejected', async () => {
159+
const r = await validateApiKey(makeReq({}));
160+
assert.equal(r.valid, false);
161+
assert.equal(r.required, true);
162+
});
163+
164+
// ── forceKey option ─────────────────────────────────────────────────────────
165+
166+
// forceKey=true behavior is exercised above:
167+
// - wms_ token + forceKey=true → REJECTED (PR #3557 review fix)
168+
// - wms_ token + forceKey=false → accepted
169+
// - enterprise key + forceKey=true → accepted (covered by enterprise tests)

api/_relay.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ export function createRelayHandler(cfg) {
7272
}
7373

7474
if (cfg.requireApiKey) {
75-
const keyCheck = validateApiKey(req);
75+
const keyCheck = await validateApiKey(req);
7676
if (keyCheck.required && !keyCheck.valid) {
7777
return jsonResponse({ error: keyCheck.error }, 401, corsHeaders);
7878
}

0 commit comments

Comments
 (0)