Skip to content

Commit 6f93aa5

Browse files
authored
Merge pull request #230 from Programcode/fix/security-audit-findings
Fix the security audit findings: promo caps, keepalive anon access, error text, import DNS pinning
2 parents 25db8f9 + 4b0245d commit 6f93aa5

33 files changed

Lines changed: 1201 additions & 124 deletions

‎.claude/skills/verify-debatelab/features/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Each feature file starts with an H1 title and one paragraph describing the user-
5252
- [Account data](./account-data.md): Download my data and Delete account, each proven against a bystander (`recipes/account-data.spec.ts`); Delete All My Data scoped against a bystander (`e2e/full-stack/account-data.spec.ts`).
5353
- Account: output language reaching the model from a new device, flowchart node delete (at once, with an `Undo` toast; no confirm dialog), save and load, and the speech timer (`R` resets only while paused) (`recipes/account.spec.ts`).
5454
- [Usage limits](./usage-limits.md): Opus refused when its weight does not fit today while Haiku still runs, and the weekly AI budget dialog (`recipes/usage-limits.spec.ts`).
55-
- Billing: expired account blocked, promo code restores access (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
55+
- Billing: expired account blocked, promo code restores access, and a discount code claimed once (a second Apply does not count again) stays in the pricing dialog and is sent with the upgrade (`recipes/billing.spec.ts`); each billing state (trial, extended access, active Pro, Pro set to cancel, promo Pro without Stripe, payment failed, subscription ended) shown the same way by Settings, the pricing dialog (its one plan button and Current badge) and a refused AI request, in English and Hungarian, light and dark, at 360 and 375 px (mobile project) and 768 and 1280 px (desktop project); the usage line on every tool page matching Settings and updating after a run; the trial banner; and `/app/billing` landing on the Settings subscription card (`recipes/billing-states.spec.ts`). The pricing dialog's Manage path stubs `create-portal` with its own no-subscription answer, since the portal needs Stripe; signed, forged, and replayed Stripe webhooks, including a failed renewal that keeps Pro (`e2e/full-stack/billing.spec.ts`).
5656
- [Localization](./localization.md): tab titles per route and language, translated form validation and server refusals, the language switcher on sign-in and 404, and `?lang=` with the `/es` and `/hu` home pages (`recipes/localization.spec.ts`).
5757
- Admin announcements and the non-admin block (`e2e/full-stack/admin.spec.ts`), public share links (`e2e/full-stack/public.spec.ts`), and Workspaces with live collaboration and invites (`e2e/workspace-collaboration.spec.ts`).
5858

‎.claude/skills/verify-debatelab/recipes/billing.spec.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,56 @@ test("bill-promo: an expired account is blocked, redeems a promo code, and gets
4545
await admin.from("promo_codes").delete().eq("id", promo.id);
4646
}
4747
});
48+
49+
test("bill-discount: a discount code is claimed once, stays in the dialog, and goes to Checkout", async ({ signedIn: page, user, evidence }) => {
50+
const userId = await user.userId();
51+
// An ended trial, so Settings and the dialog both offer "Upgrade to Pro".
52+
await admin.from("user_settings")
53+
.update({ subscription_tier: "expired", trial_ends_at: null, grace_period_ends_at: null, daily_limit_override: null })
54+
.eq("user_id", userId);
55+
const code = `VERIFYD${randomUUID().slice(0, 8).toUpperCase()}`;
56+
const { data: promo, error } = await admin.from("promo_codes")
57+
.insert({ code, type: "discount", config: { stripe_promotion_code: "promo_VerifyRecipe" }, max_redemptions: 1 })
58+
.select("id").single();
59+
if (error) throw error;
60+
const claims = async () => {
61+
const [{ data: row }, { count }] = await Promise.all([
62+
admin.from("promo_codes").select("redemptions_count").eq("id", promo.id).single(),
63+
admin.from("user_promos").select("id", { count: "exact", head: true }).eq("promo_code_id", promo.id).eq("user_id", userId),
64+
]);
65+
return { redemptions: row?.redemptions_count, held: count };
66+
};
67+
// Stripe Checkout is out of reach locally; record what the dialog sends instead.
68+
const checkoutBodies: unknown[] = [];
69+
await page.route("**/functions/v1/create-checkout", async (route) => {
70+
checkoutBodies.push(route.request().postDataJSON());
71+
await route.fulfill({ status: 400, json: { error: "Invalid or expired promo code", code: "promo_invalid" } });
72+
});
73+
try {
74+
await page.goto("/app/settings");
75+
await page.getByRole("button", { name: "Upgrade to Pro" }).click();
76+
const dialog = page.getByRole("dialog");
77+
await dialog.getByRole("button", { name: "Have a promo code?" }).click();
78+
await dialog.getByPlaceholder("Enter promo code").fill(code);
79+
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
80+
await expect(page.getByText("Discount code saved! It will be applied when you upgrade.").first()).toBeVisible();
81+
await expect(dialog.getByPlaceholder("Enter promo code")).toHaveValue(code);
82+
await evidence.shot("01-discount-saved");
83+
expect(await claims()).toEqual({ redemptions: 1, held: 1 });
84+
85+
// Applying again is not a second claim.
86+
const again = page.waitForResponse((response) => response.url().includes("/functions/v1/redeem-promo"));
87+
await dialog.getByRole("button", { name: "Apply", exact: true }).click();
88+
expect(await (await again).json()).toMatchObject({ success: true, code: "promo_discount_noted" });
89+
expect(await claims()).toEqual({ redemptions: 1, held: 1 });
90+
91+
await dialog.getByRole("button", { name: "Upgrade to Pro" }).click();
92+
await expect.poll(() => checkoutBodies.length).toBe(1);
93+
expect(checkoutBodies[0]).toMatchObject({ promoCode: code });
94+
await evidence.shot("02-checkout-sent-code");
95+
} finally {
96+
await page.unroute("**/functions/v1/create-checkout");
97+
await admin.from("user_promos").delete().eq("promo_code_id", promo.id);
98+
await admin.from("promo_codes").delete().eq("id", promo.id);
99+
}
100+
});

‎docs/operations.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,10 @@ Stop when the project target is ambiguous, rollback is unproven, or the operatio
172172

173173
Destructive statements such as `DROP` or `TRUNCATE`, secret rotation, project deletion, and broad unrelated function deployment always need separate explicit approval.
174174

175+
### Keepalive monitor
176+
177+
`keepalive_events` and `keepalive_heartbeat` belong to the external Supabase keepalive monitor (`Programcode/supabase-keepalive-monitor`), not the app. Since `20261024120000_keepalive_server_only.sql` only the service role can read or write them. The monitor's primary run, a systemd user timer on Adam's VPS, uses the service-role key. Any other copy of the monitor (the Pi's GitHub Actions backup) must also use the service-role key for the DebateLab projects; with the publishable key its REST and RPC steps get 401 or 403.
178+
175179
## Edge Function deployment boundaries
176180

177181
[`supabase/config.toml`](../supabase/config.toml) disables gateway JWT verification for functions that authenticate or validate requests in their own handlers. Deployment must preserve those handler checks.

‎docs/standards/security.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Part of the [coding standards](../../CODING_STANDARDS.md). AGENTS.md "Hard bound
1616
- A suite that writes a real session into a browser restricts its base URL to loopback ([#21](https://github.com/Programcode/DebateLab/pull/21#discussion_r3896484019)).
1717
- Documentation names the secret, never the account's email or password ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4095363422)).
1818
- One secret, one purpose. Do not derive signing keys from the service-role key; rotating it would break every outstanding token ([#13](https://github.com/Programcode/DebateLab/pull/13#discussion_r3893649683)).
19+
- An Edge Function that fetches a user-supplied URL connects only to an address it has just checked against the IANA special-purpose ranges, never to the hostname: `fetch` resolves the name again, so a DNS answer that changes in between (rebinding) would reach a blocked range. `import-source` dials the checked address with [`pinned-fetch.ts`](../../supabase/functions/import-source/pinned-fetch.ts), on every redirect hop. Citation verification does not fetch at all (`verifyUrl` in [`supabase/functions/_shared/research.ts`](../../supabase/functions/_shared/research.ts)). Closed finding [7] of the 2026-08-19 review.
1920

2021
## Recorded exceptions
2122

@@ -25,7 +26,6 @@ These were raised in review, judged valid, and accepted on purpose. Changing the
2526
- The production canary account's credentials reach `prod-canary.yml` through Actions secrets, approved 2026-09-24 and recorded in AGENTS.md ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4090664336)).
2627
- Manual canary runs can exhaust the account's daily quota; that shows as a quota error, not a false pass ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4093482054)).
2728
- CodeRabbit rated the shared preview-origin allowlist from PR #95 as a high retained concern: an approved production preview runs unmerged code that can receive demo-session handoffs ([#95 security review](https://github.com/Programcode/DebateLab/pull/95)). [docs/operations.md](../operations.md#opt-in-previews) describes the current setup. This one has no recorded decision yet.
28-
- `import-source` checks every resolved A and AAAA address against the IANA special-purpose ranges before each fetch, but the Edge runtime cannot pin the connection to the checked address. A hostname whose DNS answer changes between the check and the connection can still reach a blocked range. Citation verification avoids the same gap by not fetching at all (`verifyUrl` in [`supabase/functions/_shared/research.ts`](../../supabase/functions/_shared/research.ts)). Closing it needs an egress proxy outside the runtime. Raised as finding [7] of the 2026-08-19 security review; this needs Adam's decision.
2929

3030
## Telemetry and error reports
3131

‎src/components/features/subscription/PricingModal.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,9 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod
114114
description: result.message,
115115
variant: result.success ? 'default' : 'destructive',
116116
});
117+
// A discount changes nothing until Checkout: the code stays in the
118+
// field so the upgrade sends it, and Checkout applies the discount.
119+
if (result.success && result.code === 'promo_discount_noted') return;
117120
if (result.success) {
118121
setPromoCode('');
119122
setShowPromo(false);

‎src/lib/server-errors.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const SERVER_ERROR_KEYS: Record<string, string> = {
1313
usage_check_failed: 'serverErrors.usageCheckFailed',
1414
access_check_failed: 'serverErrors.accessCheckFailed',
1515
no_active_subscription: 'settings.noSubscriptionDesc',
16+
portal_failed: 'serverErrors.portalFailed',
1617
already_subscribed: 'serverErrors.alreadySubscribed',
1718
promo_expired: 'serverErrors.promoExpired',
1819
promo_exhausted: 'serverErrors.promoExhausted',

‎src/lib/stripe.ts‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { readEdgeFunctionError } from '@/lib/supabase/edge-function-error';
33
import { getWebAppRedirectUrl, openExternalUrl } from '@/lib/native';
44
import i18n from '@/i18n';
55
import { serverErrorMessage } from '@/lib/server-errors';
6-
import { promoResultCode } from '../../supabase/functions/_shared/promo-result';
6+
import { type PromoResultCode, promoResultCode } from '../../supabase/functions/_shared/promo-result';
77

88
/** A billing call that failed, with its message in the interface language and the server's code. */
99
export class BillingError extends Error {
@@ -81,8 +81,10 @@ export async function redirectToPortal(): Promise<void> {
8181
/**
8282
* Redeem a promo code for the current user. The message is in the interface
8383
* language when the answer is one the browser knows, else the server's text.
84+
* A discount answers `promo_discount_noted`: it is claimed, and Checkout
85+
* applies it when the same code is sent with the upgrade.
8486
*/
85-
export async function redeemPromoCode(code: string): Promise<{ success: boolean; message: string }> {
87+
export async function redeemPromoCode(code: string): Promise<{ success: boolean; message: string; code?: PromoResultCode }> {
8688
const { data: sessionData } = await supabase.auth.getSession();
8789
const accessToken = sessionData.session?.access_token;
8890
if (!accessToken) throw new BillingError(i18n.t('serverErrors.signInRequired'), 'unauthenticated');
@@ -100,5 +102,6 @@ export async function redeemPromoCode(code: string): Promise<{ success: boolean;
100102
return { success: false, message: serverErrorMessage(promoResultCode(body), fallback) };
101103
}
102104
const result = data as { success: boolean; message: string; code?: string };
103-
return { success: result.success, message: serverErrorMessage(promoResultCode(result), result.message) };
105+
const resultCode = promoResultCode(result);
106+
return { success: result.success, message: serverErrorMessage(resultCode, result.message), code: resultCode };
104107
}

‎src/locales/en.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,8 @@
758758
"days": "Days",
759759
"dailyLimit": "Daily Limit",
760760
"maxRedemptions": "Max Redemptions (blank = unlimited)",
761+
"stripePromotionCode": "Stripe promotion code ID",
762+
"stripePromotionCodeHint": "The promo_… ID from the Stripe dashboard. Checkout applies exactly this discount.",
761763
"created": "Promo code \"{{code}}\" created",
762764
"createFailed": "Failed to create code",
763765
"createAction": "Create Code",
@@ -2677,7 +2679,7 @@
26772679
"promoExpired": "This promo code has expired",
26782680
"promoExhausted": "This promo code has reached its usage limit",
26792681
"promoApplied": "Promo code applied!",
2680-
"promoDiscountNoted": "Discount code noted! Apply it at checkout for a discount.",
2682+
"promoDiscountNoted": "Discount code saved! It will be applied when you upgrade.",
26812683
"promoInvalid": "Invalid or expired promo code",
26822684
"promoAlreadyRedeemed": "You've already redeemed this code",
26832685
"promoTooManyAttempts": "Too many promo code attempts. Try again in 15 minutes.",

‎src/locales/es.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,8 @@
758758
"days": "Días",
759759
"dailyLimit": "Límite diario",
760760
"maxRedemptions": "Canjes máximos (vacío = ilimitado)",
761+
"stripePromotionCode": "ID del código promocional de Stripe",
762+
"stripePromotionCodeHint": "El ID promo_… del panel de Stripe. El pago aplica exactamente este descuento.",
761763
"created": "Código promocional \"{{code}}\" creado",
762764
"createFailed": "No se pudo crear el código",
763765
"createAction": "Crear código",
@@ -2677,7 +2679,7 @@
26772679
"promoExpired": "Este código promocional ha caducado",
26782680
"promoExhausted": "Este código promocional ha alcanzado su límite de usos",
26792681
"promoApplied": "¡Código promocional aplicado!",
2680-
"promoDiscountNoted": "¡Código de descuento guardado! Aplícalo al pagar para obtener el descuento.",
2682+
"promoDiscountNoted": "¡Código de descuento guardado! Se aplicará cuando mejores tu plan.",
26812683
"promoInvalid": "Código promocional no válido o caducado",
26822684
"promoAlreadyRedeemed": "Ya has canjeado este código",
26832685
"promoTooManyAttempts": "Demasiados intentos con códigos promocionales. Vuelve a intentarlo en 15 minutos.",

‎src/locales/hu.json‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -758,6 +758,8 @@
758758
"days": "Napok",
759759
"dailyLimit": "Napi korlát",
760760
"maxRedemptions": "Beváltások maximális száma (üres = korlátlan)",
761+
"stripePromotionCode": "Stripe promóciós kód azonosító",
762+
"stripePromotionCodeHint": "A promo_… azonosító a Stripe irányítópultjáról. A fizetés pontosan ezt a kedvezményt alkalmazza.",
761763
"created": "A(z) \"{{code}}\" promóciós kód létrehozva",
762764
"createFailed": "Nem sikerült létrehozni a kódot",
763765
"createAction": "Kód létrehozása",
@@ -2677,7 +2679,7 @@
26772679
"promoExpired": "Ez a promóciós kód lejárt",
26782680
"promoExhausted": "Ez a promóciós kód elérte a felhasználási korlátját",
26792681
"promoApplied": "Promóciós kód beváltva!",
2680-
"promoDiscountNoted": "Kedvezménykód rögzítve! A kedvezményhez add meg fizetéskor.",
2682+
"promoDiscountNoted": "Kedvezménykód rögzítve! Előfizetéskor automatikusan érvényesül.",
26812683
"promoInvalid": "Érvénytelen vagy lejárt promóciós kód",
26822684
"promoAlreadyRedeemed": "Ezt a kódot már beváltottad",
26832685
"promoTooManyAttempts": "Túl sok próbálkozás promóciós kóddal. Próbáld újra 15 perc múlva.",

0 commit comments

Comments
 (0)