Skip to content

Commit a0fc6dc

Browse files
committed
fix(worker): addresses PR review findings — security, perf, tests, docs
- adds Turnstile token length guard (>2048) with boundary tests - adds seal key derivation cache (_sealKeyCache Map, bounded by VALID_PURPOSES) - passes pre-parsed pathname to validateAndGuardProxyRoute (eliminates redundant URL parse) - unifies session key cache into single Map (removes duplicate getSessionHmacPrevKey) - fixes structured error logging in ensureSession catch path - removes dead options parameter from validateProxyRequest - corrects HKDF key material descriptions in CryptoEnv and DEPLOY.md - fixes test key comments to match actual decoded values - adds 14 new tests covering previously-untested paths
1 parent 7a40118 commit a0fc6dc

File tree

10 files changed

+238
-70
lines changed

10 files changed

+238
-70
lines changed

DEPLOY.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,13 @@ openssl rand -base64 32 # Run once per key below
191191
### Setting secrets
192192

193193
```bash
194-
wrangler secret put SESSION_KEY # HMAC key for session cookies
195-
wrangler secret put SEAL_KEY # AES-256-GCM key for sealed tokens
194+
wrangler secret put SESSION_KEY # HKDF input key material for session cookies
195+
wrangler secret put SEAL_KEY # HKDF input key material for sealed tokens
196196
wrangler secret put TURNSTILE_SECRET_KEY # From Cloudflare Turnstile dashboard
197197
```
198198

199-
- `SESSION_KEY`: HMAC-SHA256 key used to sign `__Host-session` cookies. Generate with `openssl rand -base64 32`.
200-
- `SEAL_KEY`: AES-256-GCM key used to encrypt Jira/GitLab API tokens stored client-side as sealed blobs. Generate with `openssl rand -base64 32`.
199+
- `SESSION_KEY`: HKDF input key material used to derive the HMAC-SHA256 key for signing `__Host-session` cookies. Generate with `openssl rand -base64 32`.
200+
- `SEAL_KEY`: HKDF input key material used to derive the AES-256-GCM key for encrypting API tokens stored client-side as sealed blobs. Generate with `openssl rand -base64 32`.
201201
- `TURNSTILE_SECRET_KEY`: From the Cloudflare Turnstile dashboard (Security → Turnstile → your widget → Secret key).
202202
- `VITE_TURNSTILE_SITE_KEY`: **Build-time env var (public)** — goes in `.env`, not a Worker secret. From the same Turnstile dashboard (Site key).
203203

src/worker/crypto.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
export interface CryptoEnv {
2-
SEAL_KEY: string; // base64-encoded 32-byte AES-256-GCM key
3-
SEAL_KEY_PREV?: string; // previous key for rotation
2+
SEAL_KEY: string; // base64-encoded HKDF input key material (32 bytes recommended)
3+
SEAL_KEY_PREV?: string; // previous HKDF key material for rotation
44
}
55

66
// ── Base64url utilities ────────────────────────────────────────────────────

src/worker/index.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -152,10 +152,7 @@ function isProxyPath(pathname: string): boolean {
152152

153153
// ── Validation gate for proxy routes ─────────────────────────────────────────
154154
// Returns a Response if rejected, null if validation passes.
155-
function validateAndGuardProxyRoute(request: Request, env: Env): Response | null {
156-
const url = new URL(request.url);
157-
const pathname = url.pathname;
158-
155+
function validateAndGuardProxyRoute(request: Request, env: Env, pathname: string): Response | null {
159156
if (!isProxyPath(pathname)) return null;
160157

161158
const origin = request.headers.get("Origin");
@@ -187,18 +184,26 @@ function validateAndGuardProxyRoute(request: Request, env: Env): Response | null
187184
// ── Sealed-token endpoint ────────────────────────────────────────────────────
188185
const VALID_PURPOSES = new Set(["jira-api-token", "jira-refresh-token", "gitlab-pat"]);
189186

187+
// Module-level cache for derived seal keys, keyed by "<raw>:<purpose>".
188+
// SEAL_KEY is a deployment constant — safe to cache per-isolate (follows _sessionKeyCache pattern).
189+
const _sealKeyCache = new Map<string, CryptoKey>();
190+
190191
async function handleProxySeal(request: Request, env: Env, sessionId: string): Promise<Response> {
191192
if (request.method !== "POST") {
192193
return errorResponse("method_not_allowed", 405);
193194
}
194195

195196
// Session + rate limiting (done by caller, sessionId passed in)
196-
// Extract Turnstile token and verify (Step 6)
197+
// Extract Turnstile token and verify
197198
const turnstileToken = extractTurnstileToken(request);
198199
if (!turnstileToken) {
199200
log("warn", "seal_turnstile_missing", {}, request);
200201
return errorResponse("turnstile_failed", 403);
201202
}
203+
if (turnstileToken.length > 2048) {
204+
log("warn", "seal_turnstile_token_too_long", { token_length: turnstileToken.length }, request);
205+
return errorResponse("turnstile_failed", 403);
206+
}
202207

203208
const ip = request.headers.get("CF-Connecting-IP");
204209
const turnstileResult = await verifyTurnstile(turnstileToken, ip, env);
@@ -237,8 +242,13 @@ async function handleProxySeal(request: Request, env: Env, sessionId: string): P
237242

238243
let sealed: string;
239244
try {
240-
// SC-8: derive key with purpose-scoped info string
241-
const key = await deriveKey(env.SEAL_KEY, SEAL_SALT, "aes-gcm-key:" + purpose, "encrypt");
245+
// SC-8: derive key with purpose-scoped info string (cached per-isolate, bounded by VALID_PURPOSES size)
246+
const cacheKey = env.SEAL_KEY + ":" + purpose;
247+
let key = _sealKeyCache.get(cacheKey);
248+
if (key === undefined) {
249+
key = await deriveKey(env.SEAL_KEY, SEAL_SALT, "aes-gcm-key:" + purpose, "encrypt");
250+
_sealKeyCache.set(cacheKey, key);
251+
}
242252
sealed = await sealToken(token, key);
243253
} catch (err) {
244254
// SC-9: log error server-side but DO NOT include crypto error in response
@@ -696,7 +706,7 @@ export default {
696706
// ── Proxy routes: validation, session, and rate limiting ─────────────────
697707
// Applies to /api/proxy/*, /api/jira/*, /api/gitlab/*
698708
// validateAndGuardProxyRoute handles OPTIONS preflight for proxy routes.
699-
const guardResponse = validateAndGuardProxyRoute(request, env);
709+
const guardResponse = validateAndGuardProxyRoute(request, env, url.pathname);
700710
if (guardResponse !== null) return guardResponse;
701711

702712
if (isProxyPath(url.pathname)) {

src/worker/session.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,14 @@ const SESSION_MAX_AGE = 28800; // 8 hours in seconds
3333

3434
// Module-level cache for derived session HMAC keys.
3535
// SESSION_KEY is a deployment constant — safe to cache per-isolate (follows _dsnCache pattern).
36-
let _sessionKeyCache: { raw: string; key: CryptoKey } | undefined;
37-
let _sessionKeyPrevCache: { raw: string; key: CryptoKey } | undefined;
36+
// Keyed by raw secret string; supports both current and previous key without duplicate logic.
37+
const _sessionKeyCache = new Map<string, CryptoKey>();
3838

3939
async function getSessionHmacKey(raw: string): Promise<CryptoKey> {
40-
if (_sessionKeyCache?.raw === raw) return _sessionKeyCache.key;
40+
const cached = _sessionKeyCache.get(raw);
41+
if (cached !== undefined) return cached;
4142
const key = await deriveKey(raw, SESSION_HMAC_SALT, SESSION_HMAC_INFO, "sign");
42-
_sessionKeyCache = { raw, key };
43-
return key;
44-
}
45-
46-
async function getSessionHmacPrevKey(raw: string): Promise<CryptoKey> {
47-
if (_sessionKeyPrevCache?.raw === raw) return _sessionKeyPrevCache.key;
48-
const key = await deriveKey(raw, SESSION_HMAC_SALT, SESSION_HMAC_INFO, "sign");
49-
_sessionKeyPrevCache = { raw, key };
43+
_sessionKeyCache.set(raw, key);
5044
return key;
5145
}
5246

@@ -116,7 +110,7 @@ export async function parseSession(
116110
const currentKey = await getSessionHmacKey(env.SESSION_KEY);
117111
let valid = await verifySession(json, signature, currentKey);
118112
if (!valid && env.SESSION_KEY_PREV !== undefined) {
119-
const prevKey = await getSessionHmacPrevKey(env.SESSION_KEY_PREV);
113+
const prevKey = await getSessionHmacKey(env.SESSION_KEY_PREV);
120114
valid = await verifySession(json, signature, prevKey);
121115
}
122116
if (!valid) return null;
@@ -150,7 +144,11 @@ export async function ensureSession(
150144
const { cookie, sessionId } = await issueSession(env);
151145
return { sessionId, setCookie: cookie };
152146
} catch (error) {
153-
console.error("session_issue_failed", error);
147+
console.error(JSON.stringify({
148+
worker: "github-tracker",
149+
event: "session_issue_failed",
150+
error: error instanceof Error ? error.message : "unknown",
151+
}));
154152
return { sessionId: crypto.randomUUID() };
155153
}
156154
}

src/worker/validation.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,8 +65,7 @@ const METHODS_REQUIRING_CONTENT_TYPE = new Set(["POST", "PUT", "PATCH"]);
6565
*/
6666
export function validateProxyRequest(
6767
request: Request,
68-
allowedOrigin: string,
69-
options?: { expectedContentType?: string }
68+
allowedOrigin: string
7069
): ValidationResult {
7170
const originResult = validateOrigin(request, allowedOrigin);
7271
if (!originResult.ok) return originResult;
@@ -78,8 +77,7 @@ export function validateProxyRequest(
7877
if (!customHeaderResult.ok) return customHeaderResult;
7978

8079
if (METHODS_REQUIRING_CONTENT_TYPE.has(request.method)) {
81-
const expected = options?.expectedContentType ?? "application/json";
82-
const contentTypeResult = validateContentType(request, expected);
80+
const contentTypeResult = validateContentType(request, "application/json");
8381
if (!contentTypeResult.ok) return contentTypeResult;
8482
}
8583

tests/app/lib/proxy.test.ts

Lines changed: 87 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,24 @@ interface MockTurnstile {
2222
_resolveToken(token: string): void;
2323
/** Trigger the error callback for the most-recently rendered widget. */
2424
_rejectWithError(code: string): void;
25+
/** Trigger the expired-callback for the most-recently rendered widget. */
26+
_triggerExpired(): void;
2527
}
2628

2729
function makeMockTurnstile(): MockTurnstile {
2830
let _successCb: ((token: string) => void) | undefined;
2931
let _errorCb: ((code: string) => void) | undefined;
32+
let _expiredCb: (() => void) | undefined;
3033

3134
const mock: MockTurnstile = {
32-
render: vi.fn((_container: HTMLElement, options: { callback?: (token: string) => void; "error-callback"?: (code: string) => void }) => {
35+
render: vi.fn((_container: HTMLElement, options: {
36+
callback?: (token: string) => void;
37+
"error-callback"?: (code: string) => void;
38+
"expired-callback"?: () => void;
39+
}) => {
3340
_successCb = options.callback;
3441
_errorCb = options["error-callback"];
42+
_expiredCb = options["expired-callback"];
3543
return "widget-id-1";
3644
}),
3745
execute: vi.fn(),
@@ -43,6 +51,9 @@ function makeMockTurnstile(): MockTurnstile {
4351
_rejectWithError(code: string) {
4452
_errorCb?.(code);
4553
},
54+
_triggerExpired() {
55+
_expiredCb?.();
56+
},
4657
};
4758

4859
return mock;
@@ -115,6 +126,21 @@ describe("proxyFetch", () => {
115126
expect(headers["cf-turnstile-response"]).toBe("tok123");
116127
});
117128

129+
it("merges Headers instance caller headers without dropping defaults", async () => {
130+
const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
131+
vi.stubGlobal("fetch", mockFetch);
132+
133+
await mod.proxyFetch("/api/proxy/seal", {
134+
headers: new Headers({ "cf-turnstile-response": "tok" }),
135+
});
136+
137+
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
138+
const headers = init.headers as Record<string, string>;
139+
expect(headers["X-Requested-With"]).toBe("fetch");
140+
expect(headers["Content-Type"]).toBe("application/json");
141+
expect(headers["cf-turnstile-response"]).toBe("tok");
142+
});
143+
118144
it("passes the path to fetch unchanged", async () => {
119145
const mockFetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
120146
vi.stubGlobal("fetch", mockFetch);
@@ -229,6 +255,47 @@ describe("acquireTurnstileToken", () => {
229255

230256
await expect(tokenPromise).rejects.toThrow("Turnstile error: invalid-input-response");
231257
});
258+
259+
it("rejects when Turnstile fires expired-callback", async () => {
260+
const mockTurnstile = makeMockTurnstile();
261+
vi.stubGlobal("window", {
262+
...window,
263+
turnstile: mockTurnstile,
264+
});
265+
266+
vi.spyOn(document.head, "appendChild").mockImplementation((node) => {
267+
const el = node as HTMLScriptElement;
268+
if (el.tagName === "SCRIPT") {
269+
(el as unknown as { onload: (() => void) | null }).onload?.();
270+
return node;
271+
}
272+
return node;
273+
});
274+
275+
const tokenPromise = mod.acquireTurnstileToken("test-site-key");
276+
277+
await Promise.resolve();
278+
await Promise.resolve();
279+
280+
mockTurnstile._triggerExpired();
281+
282+
await expect(tokenPromise).rejects.toThrow("Turnstile token expired before submission");
283+
});
284+
285+
it("rejects when the Turnstile script fails to load (onerror)", async () => {
286+
vi.spyOn(document.head, "appendChild").mockImplementation((node) => {
287+
const el = node as HTMLScriptElement;
288+
if (el.tagName === "SCRIPT") {
289+
(el as unknown as { onerror: (() => void) | null }).onerror?.();
290+
return node;
291+
}
292+
return node;
293+
});
294+
295+
await expect(mod.acquireTurnstileToken("test-site-key")).rejects.toThrow(
296+
"Failed to load Turnstile script",
297+
);
298+
});
232299
});
233300

234301
// ── sealApiToken tests ────────────────────────────────────────────────────────
@@ -306,10 +373,9 @@ describe("sealApiToken", () => {
306373
);
307374
vi.stubGlobal("fetch", mockFetch);
308375

309-
await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({
310-
status: 429,
311-
message: "rate_limited",
312-
});
376+
const err = await mod.sealApiToken("my-token", "jira-api-token").catch((e: unknown) => e);
377+
expect(err).toBeInstanceOf(mod.SealError);
378+
expect(err).toMatchObject({ status: 429, message: "rate_limited" });
313379
});
314380

315381
it("throws SealError on 500 response", async () => {
@@ -320,10 +386,9 @@ describe("sealApiToken", () => {
320386
);
321387
vi.stubGlobal("fetch", mockFetch);
322388

323-
await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toMatchObject({
324-
status: 500,
325-
message: "seal_failed",
326-
});
389+
const err = await mod.sealApiToken("my-token", "jira-api-token").catch((e: unknown) => e);
390+
expect(err).toBeInstanceOf(mod.SealError);
391+
expect(err).toMatchObject({ status: 500, message: "seal_failed" });
327392
});
328393

329394
it("rejects when fetch throws a network error", async () => {
@@ -335,6 +400,19 @@ describe("sealApiToken", () => {
335400
await expect(mod.sealApiToken("my-token", "jira-api-token")).rejects.toThrow("Failed to fetch");
336401
});
337402

403+
it("throws SealError with 'unknown_error' when error response body is non-JSON", async () => {
404+
setupMockedTurnstile("turnstile-tok");
405+
406+
const mockFetch = vi.fn().mockResolvedValue(
407+
new Response("Service Unavailable", { status: 503 }),
408+
);
409+
vi.stubGlobal("fetch", mockFetch);
410+
411+
const err = await mod.sealApiToken("my-token", "jira-api-token").catch((e: unknown) => e);
412+
expect(err).toBeInstanceOf(mod.SealError);
413+
expect(err).toMatchObject({ status: 503, message: "unknown_error" });
414+
});
415+
338416
it("includes cf-turnstile-response header in POST body", async () => {
339417
setupMockedTurnstile("expected-turnstile-token");
340418

0 commit comments

Comments
 (0)