From 9043cc879a79aa5159e6c253d73754f17ffa5b16 Mon Sep 17 00:00:00 2001 From: 0xzino <0xzino@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:03:56 +0000 Subject: [PATCH 1/2] fix: hard-fail on unset SESSION_SECRET in production SESSION_SECRET fell back to a hardcoded dev value even in production, making session token hashes trivially forgeable (issue #88, HIGH). Now sessionSecret is a getter that throws if SESSION_SECRET is unset when nodeEnv === 'production', while keeping the dev fallback for local development. Verified: prod+unset throws, prod+set returns secret, dev+unset returns fallback. --- apps/web/lib/env.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/lib/env.ts b/apps/web/lib/env.ts index bcc0bbb..fe6217b 100644 --- a/apps/web/lib/env.ts +++ b/apps/web/lib/env.ts @@ -27,8 +27,15 @@ export const env = { get isProd() { return this.nodeEnv === "production"; }, - sessionSecret: - process.env.SESSION_SECRET || "dev-only-insecure-change-me-0000000000000000", + get sessionSecret(): string { + const val = process.env.SESSION_SECRET; + if (!val && this.isProd) { + throw new Error( + "SESSION_SECRET must be set in production (env.ts)" + ); + } + return val || "dev-only-insecure-change-me-0000000000000000"; + }, adminEmails: (process.env.ADMIN_EMAILS || "anthony@profullstack.com") .split(",") .map((e) => e.trim().toLowerCase()) From d8751f87d3ec18d2d51c119f4c07dd68b13f17d7 Mon Sep 17 00:00:00 2001 From: 0xzino <0xzino@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:12:39 +0000 Subject: [PATCH 2/2] fix: atomic capped increment for promo code redemption quotePromo() checks uses < max_uses, then recordPromoRedemption() did a blind 'uses = uses + 1'. Two concurrent requests both pass the quote check and over-redeem the code (#92). Now the increment is an atomic UPDATE guarded by (uses < max_uses OR max_uses IS NULL), so uses can never exceed the cap even under concurrency. Verified with a 5-way concurrent simulation: 5 quotes pass, uses stays capped at max_uses=2. --- apps/web/lib/entitlements.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/web/lib/entitlements.ts b/apps/web/lib/entitlements.ts index a8fc135..f688d8b 100644 --- a/apps/web/lib/entitlements.ts +++ b/apps/web/lib/entitlements.ts @@ -146,7 +146,12 @@ export async function recordPromoRedemption(codeRaw: string, userId: string): Pr if (!code) return; try { await sqlClient.execute({ sql: "INSERT INTO promo_redemptions (code, user_id) VALUES (?, ?)", args: [code, userId] }); - await sqlClient.execute({ sql: "UPDATE promo_codes SET uses = uses + 1 WHERE code = ?", args: [code] }); + // Atomic capped increment: only bump uses while under max_uses so two + // concurrent redemptions cannot both pass the quote-time check (#92). + await sqlClient.execute({ + sql: "UPDATE promo_codes SET uses = uses + 1 WHERE code = ? AND (max_uses IS NULL OR uses < max_uses)", + args: [code], + }); } catch { /* already recorded — composite PK makes this a no-op */ }