diff --git a/.claude/skills/verify-debatelab/features/README.md b/.claude/skills/verify-debatelab/features/README.md index 5ae0cecd8..b9a59cf77 100644 --- a/.claude/skills/verify-debatelab/features/README.md +++ b/.claude/skills/verify-debatelab/features/README.md @@ -52,7 +52,7 @@ Each feature file starts with an H1 title and one paragraph describing the user- - [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`). - 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`). - [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`). -- 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`). +- 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`). - [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`). - 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`). diff --git a/.claude/skills/verify-debatelab/recipes/billing.spec.ts b/.claude/skills/verify-debatelab/recipes/billing.spec.ts index 64661910b..ab11a3d45 100644 --- a/.claude/skills/verify-debatelab/recipes/billing.spec.ts +++ b/.claude/skills/verify-debatelab/recipes/billing.spec.ts @@ -45,3 +45,56 @@ test("bill-promo: an expired account is blocked, redeems a promo code, and gets await admin.from("promo_codes").delete().eq("id", promo.id); } }); + +test("bill-discount: a discount code is claimed once, stays in the dialog, and goes to Checkout", async ({ signedIn: page, user, evidence }) => { + const userId = await user.userId(); + // An ended trial, so Settings and the dialog both offer "Upgrade to Pro". + await admin.from("user_settings") + .update({ subscription_tier: "expired", trial_ends_at: null, grace_period_ends_at: null, daily_limit_override: null }) + .eq("user_id", userId); + const code = `VERIFYD${randomUUID().slice(0, 8).toUpperCase()}`; + const { data: promo, error } = await admin.from("promo_codes") + .insert({ code, type: "discount", config: { stripe_promotion_code: "promo_VerifyRecipe" }, max_redemptions: 1 }) + .select("id").single(); + if (error) throw error; + const claims = async () => { + const [{ data: row }, { count }] = await Promise.all([ + admin.from("promo_codes").select("redemptions_count").eq("id", promo.id).single(), + admin.from("user_promos").select("id", { count: "exact", head: true }).eq("promo_code_id", promo.id).eq("user_id", userId), + ]); + return { redemptions: row?.redemptions_count, held: count }; + }; + // Stripe Checkout is out of reach locally; record what the dialog sends instead. + const checkoutBodies: unknown[] = []; + await page.route("**/functions/v1/create-checkout", async (route) => { + checkoutBodies.push(route.request().postDataJSON()); + await route.fulfill({ status: 400, json: { error: "Invalid or expired promo code", code: "promo_invalid" } }); + }); + try { + await page.goto("/app/settings"); + await page.getByRole("button", { name: "Upgrade to Pro" }).click(); + const dialog = page.getByRole("dialog"); + await dialog.getByRole("button", { name: "Have a promo code?" }).click(); + await dialog.getByPlaceholder("Enter promo code").fill(code); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + await expect(page.getByText("Discount code saved! It will be applied when you upgrade.").first()).toBeVisible(); + await expect(dialog.getByPlaceholder("Enter promo code")).toHaveValue(code); + await evidence.shot("01-discount-saved"); + expect(await claims()).toEqual({ redemptions: 1, held: 1 }); + + // Applying again is not a second claim. + const again = page.waitForResponse((response) => response.url().includes("/functions/v1/redeem-promo")); + await dialog.getByRole("button", { name: "Apply", exact: true }).click(); + expect(await (await again).json()).toMatchObject({ success: true, code: "promo_discount_noted" }); + expect(await claims()).toEqual({ redemptions: 1, held: 1 }); + + await dialog.getByRole("button", { name: "Upgrade to Pro" }).click(); + await expect.poll(() => checkoutBodies.length).toBe(1); + expect(checkoutBodies[0]).toMatchObject({ promoCode: code }); + await evidence.shot("02-checkout-sent-code"); + } finally { + await page.unroute("**/functions/v1/create-checkout"); + await admin.from("user_promos").delete().eq("promo_code_id", promo.id); + await admin.from("promo_codes").delete().eq("id", promo.id); + } +}); diff --git a/docs/operations.md b/docs/operations.md index bec724261..5e3bf019d 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -172,6 +172,10 @@ Stop when the project target is ambiguous, rollback is unproven, or the operatio Destructive statements such as `DROP` or `TRUNCATE`, secret rotation, project deletion, and broad unrelated function deployment always need separate explicit approval. +### Keepalive monitor + +`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. + ## Edge Function deployment boundaries [`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. diff --git a/docs/standards/security.md b/docs/standards/security.md index cdba2878a..f771230cd 100644 --- a/docs/standards/security.md +++ b/docs/standards/security.md @@ -16,6 +16,7 @@ Part of the [coding standards](../../CODING_STANDARDS.md). AGENTS.md "Hard bound - 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)). - Documentation names the secret, never the account's email or password ([#68](https://github.com/Programcode/DebateLab/pull/68#discussion_r4095363422)). - 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)). +- 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. ## Recorded exceptions @@ -25,7 +26,6 @@ These were raised in review, judged valid, and accepted on purpose. Changing the - 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)). - 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)). - 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. -- `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. ## Telemetry and error reports diff --git a/src/components/features/subscription/PricingModal.tsx b/src/components/features/subscription/PricingModal.tsx index b896062c8..38b375b4f 100644 --- a/src/components/features/subscription/PricingModal.tsx +++ b/src/components/features/subscription/PricingModal.tsx @@ -114,6 +114,9 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod description: result.message, variant: result.success ? 'default' : 'destructive', }); + // A discount changes nothing until Checkout: the code stays in the + // field so the upgrade sends it, and Checkout applies the discount. + if (result.success && result.code === 'promo_discount_noted') return; if (result.success) { setPromoCode(''); setShowPromo(false); diff --git a/src/lib/server-errors.ts b/src/lib/server-errors.ts index fd5c34ced..6d3d4ede8 100644 --- a/src/lib/server-errors.ts +++ b/src/lib/server-errors.ts @@ -13,6 +13,7 @@ const SERVER_ERROR_KEYS: Record = { usage_check_failed: 'serverErrors.usageCheckFailed', access_check_failed: 'serverErrors.accessCheckFailed', no_active_subscription: 'settings.noSubscriptionDesc', + portal_failed: 'serverErrors.portalFailed', already_subscribed: 'serverErrors.alreadySubscribed', promo_expired: 'serverErrors.promoExpired', promo_exhausted: 'serverErrors.promoExhausted', diff --git a/src/lib/stripe.ts b/src/lib/stripe.ts index 627a78339..073e5a227 100644 --- a/src/lib/stripe.ts +++ b/src/lib/stripe.ts @@ -3,7 +3,7 @@ import { readEdgeFunctionError } from '@/lib/supabase/edge-function-error'; import { getWebAppRedirectUrl, openExternalUrl } from '@/lib/native'; import i18n from '@/i18n'; import { serverErrorMessage } from '@/lib/server-errors'; -import { promoResultCode } from '../../supabase/functions/_shared/promo-result'; +import { type PromoResultCode, promoResultCode } from '../../supabase/functions/_shared/promo-result'; /** A billing call that failed, with its message in the interface language and the server's code. */ export class BillingError extends Error { @@ -81,8 +81,10 @@ export async function redirectToPortal(): Promise { /** * Redeem a promo code for the current user. The message is in the interface * language when the answer is one the browser knows, else the server's text. + * A discount answers `promo_discount_noted`: it is claimed, and Checkout + * applies it when the same code is sent with the upgrade. */ -export async function redeemPromoCode(code: string): Promise<{ success: boolean; message: string }> { +export async function redeemPromoCode(code: string): Promise<{ success: boolean; message: string; code?: PromoResultCode }> { const { data: sessionData } = await supabase.auth.getSession(); const accessToken = sessionData.session?.access_token; if (!accessToken) throw new BillingError(i18n.t('serverErrors.signInRequired'), 'unauthenticated'); @@ -100,5 +102,6 @@ export async function redeemPromoCode(code: string): Promise<{ success: boolean; return { success: false, message: serverErrorMessage(promoResultCode(body), fallback) }; } const result = data as { success: boolean; message: string; code?: string }; - return { success: result.success, message: serverErrorMessage(promoResultCode(result), result.message) }; + const resultCode = promoResultCode(result); + return { success: result.success, message: serverErrorMessage(resultCode, result.message), code: resultCode }; } diff --git a/src/locales/en.json b/src/locales/en.json index 13c624810..66c76a36f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -758,6 +758,8 @@ "days": "Days", "dailyLimit": "Daily Limit", "maxRedemptions": "Max Redemptions (blank = unlimited)", + "stripePromotionCode": "Stripe promotion code ID", + "stripePromotionCodeHint": "The promo_… ID from the Stripe dashboard. Checkout applies exactly this discount.", "created": "Promo code \"{{code}}\" created", "createFailed": "Failed to create code", "createAction": "Create Code", @@ -2677,7 +2679,7 @@ "promoExpired": "This promo code has expired", "promoExhausted": "This promo code has reached its usage limit", "promoApplied": "Promo code applied!", - "promoDiscountNoted": "Discount code noted! Apply it at checkout for a discount.", + "promoDiscountNoted": "Discount code saved! It will be applied when you upgrade.", "promoInvalid": "Invalid or expired promo code", "promoAlreadyRedeemed": "You've already redeemed this code", "promoTooManyAttempts": "Too many promo code attempts. Try again in 15 minutes.", diff --git a/src/locales/es.json b/src/locales/es.json index 7a8addb11..8f7907ca5 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -758,6 +758,8 @@ "days": "Días", "dailyLimit": "Límite diario", "maxRedemptions": "Canjes máximos (vacío = ilimitado)", + "stripePromotionCode": "ID del código promocional de Stripe", + "stripePromotionCodeHint": "El ID promo_… del panel de Stripe. El pago aplica exactamente este descuento.", "created": "Código promocional \"{{code}}\" creado", "createFailed": "No se pudo crear el código", "createAction": "Crear código", @@ -2677,7 +2679,7 @@ "promoExpired": "Este código promocional ha caducado", "promoExhausted": "Este código promocional ha alcanzado su límite de usos", "promoApplied": "¡Código promocional aplicado!", - "promoDiscountNoted": "¡Código de descuento guardado! Aplícalo al pagar para obtener el descuento.", + "promoDiscountNoted": "¡Código de descuento guardado! Se aplicará cuando mejores tu plan.", "promoInvalid": "Código promocional no válido o caducado", "promoAlreadyRedeemed": "Ya has canjeado este código", "promoTooManyAttempts": "Demasiados intentos con códigos promocionales. Vuelve a intentarlo en 15 minutos.", diff --git a/src/locales/hu.json b/src/locales/hu.json index e99eb07ac..08df86126 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -758,6 +758,8 @@ "days": "Napok", "dailyLimit": "Napi korlát", "maxRedemptions": "Beváltások maximális száma (üres = korlátlan)", + "stripePromotionCode": "Stripe promóciós kód azonosító", + "stripePromotionCodeHint": "A promo_… azonosító a Stripe irányítópultjáról. A fizetés pontosan ezt a kedvezményt alkalmazza.", "created": "A(z) \"{{code}}\" promóciós kód létrehozva", "createFailed": "Nem sikerült létrehozni a kódot", "createAction": "Kód létrehozása", @@ -2677,7 +2679,7 @@ "promoExpired": "Ez a promóciós kód lejárt", "promoExhausted": "Ez a promóciós kód elérte a felhasználási korlátját", "promoApplied": "Promóciós kód beváltva!", - "promoDiscountNoted": "Kedvezménykód rögzítve! A kedvezményhez add meg fizetéskor.", + "promoDiscountNoted": "Kedvezménykód rögzítve! Előfizetéskor automatikusan érvényesül.", "promoInvalid": "Érvénytelen vagy lejárt promóciós kód", "promoAlreadyRedeemed": "Ezt a kódot már beváltottad", "promoTooManyAttempts": "Túl sok próbálkozás promóciós kóddal. Próbáld újra 15 perc múlva.", diff --git a/src/pages/AdminPage.tsx b/src/pages/AdminPage.tsx index 7d44329b7..2a63f0213 100644 --- a/src/pages/AdminPage.tsx +++ b/src/pages/AdminPage.tsx @@ -50,7 +50,7 @@ interface PromoCode { id: string; code: string; type: string; - config: Record; + config: Record; max_redemptions: number | null; redemptions_count: number; expires_at: string | null; @@ -89,6 +89,9 @@ interface DemoAccessForm { type DemoAction = 'disable' | 'reset'; +// A discount promo stands for one Stripe promotion code (redeem_promo_code_atomic). +const STRIPE_PROMOTION_CODE = /^promo_[A-Za-z0-9]+$/; + const EMPTY_DEMO_FORM: DemoAccessForm = { label: '', email: '', expiresAt: '', maxUses: '' }; function normalizeDemoCode(value: unknown): DemoAccessCode | null { @@ -451,7 +454,7 @@ export default function AdminPage() { const [demoCodes, setDemoCodes] = useState([]); const [demoCodesLoading, setDemoCodesLoading] = useState(true); const [demoCodesError, setDemoCodesError] = useState(null); - const [newPromo, setNewPromo] = useState({ code: '', type: 'extended_trial', configDays: 30, configLimit: 15, maxRedemptions: '' }); + const [newPromo, setNewPromo] = useState({ code: '', type: 'extended_trial', configDays: 30, configLimit: 15, maxRedemptions: '', stripePromotionCode: '' }); const [giftTarget, setGiftTarget] = useState(null); // Role changes are consequential and silent to the affected user, so each one asks first. const [roleChange, setRoleChange] = useState<{ user: User; role: 'admin' | 'banned' | 'user' } | null>(null); @@ -1145,6 +1148,19 @@ export default function AdminPage() { /> )} + {newPromo.type === 'discount' && ( +
+ + setNewPromo({ ...newPromo, stripePromotionCode: e.target.value.trim() })} + className="font-mono" + /> +

{t('admin.promos.stripePromotionCodeHint')}

+
+ )}
{ try { - const config: Record = {}; + const config: Record = {}; if (newPromo.type === 'extended_trial' || newPromo.type === 'free_month') { config.days = newPromo.configDays; } else if (newPromo.type === 'lifetime_pro') { config.limit = 50; } else if (newPromo.type === 'extra_limit') { config.limit = newPromo.configLimit; + } else if (newPromo.type === 'discount') { + config.stripe_promotion_code = newPromo.stripePromotionCode; } await postAdminData('create-promo', { code: newPromo.code, @@ -1174,12 +1192,12 @@ export default function AdminPage() { toast({ title: t('admin.success'), description: t('admin.promos.created', { code: newPromo.code }) }); const data = await fetchAdminData('promo-codes'); setPromoCodes(data.codes || []); - setNewPromo({ code: '', type: 'extended_trial', configDays: 30, configLimit: 15, maxRedemptions: '' }); + setNewPromo({ code: '', type: 'extended_trial', configDays: 30, configLimit: 15, maxRedemptions: '', stripePromotionCode: '' }); } catch (err) { toast({ title: t('admin.error'), description: err instanceof Error ? err.message : t('admin.promos.createFailed'), variant: 'destructive' }); } }} - disabled={!newPromo.code.trim()} + disabled={!newPromo.code.trim() || (newPromo.type === 'discount' && !STRIPE_PROMOTION_CODE.test(newPromo.stripePromotionCode))} className="w-full" > {t('admin.promos.createAction')} diff --git a/src/test/checkout-security.test.ts b/src/test/checkout-security.test.ts index 1d85bd560..667363f15 100644 --- a/src/test/checkout-security.test.ts +++ b/src/test/checkout-security.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { - discountPromoRefusal, + discountPromotionCode, findCustomerOwnedByUser, hasOpenSubscription, isAllowedCheckoutRedirectUrl, @@ -42,12 +42,14 @@ describe("checkout security", () => { expect(hasOpenSubscription(null)).toBe(false); }); - it("refuses a discount promo past its expiry or its redemption cap (QB-08)", () => { - const now = new Date("2026-10-22T12:00:00.000Z"); - expect(discountPromoRefusal({ expires_at: "2026-10-21T00:00:00.000Z" }, now)).toBe("expired"); - expect(discountPromoRefusal({ max_redemptions: 10, redemptions_count: 10 }, now)).toBe("exhausted"); - expect(discountPromoRefusal({ max_redemptions: 0, redemptions_count: null }, now)).toBe("exhausted"); - expect(discountPromoRefusal({ expires_at: "2026-10-23T00:00:00.000Z", max_redemptions: 10, redemptions_count: 9 }, now)).toBeNull(); - expect(discountPromoRefusal({ expires_at: null, max_redemptions: null }, now)).toBeNull(); + it("applies only the Stripe promotion code a successful discount claim returns", () => { + const claim = { success: true, promoType: "discount", stripePromotionCode: "promo_1AbC" }; + expect(discountPromotionCode(claim)).toBe("promo_1AbC"); + expect(discountPromotionCode({ ...claim, success: false })).toBeNull(); + expect(discountPromotionCode({ ...claim, promoType: "free_month" })).toBeNull(); + expect(discountPromotionCode({ ...claim, stripePromotionCode: "coupon_50OFF" })).toBeNull(); + expect(discountPromotionCode({ ...claim, stripePromotionCode: "promo_1AbC&allow_promotion_codes=true" })).toBeNull(); + expect(discountPromotionCode({ success: false, code: "too_many_attempts" })).toBeNull(); + expect(discountPromotionCode(null)).toBeNull(); }); }); diff --git a/supabase/README.md b/supabase/README.md index 0931e6bbf..c034fe24c 100644 --- a/supabase/README.md +++ b/supabase/README.md @@ -46,7 +46,7 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh ### Content and sharing -- `import-source`: authenticated public-URL import with SSRF protection (resolved addresses checked against the IANA special-purpose ranges on every redirect), one 10-second deadline covering DNS, redirects and the body, content-type and 1 MB size limits, readable-text extraction, a daily quota, and logging. +- `import-source`: authenticated public-URL import with SSRF protection (resolved addresses checked against the IANA special-purpose ranges on every redirect, and the connection made to a checked address, not the hostname, so DNS cannot change in between), one 10-second deadline covering DNS, redirects and the body, content-type and 1 MB size limits, readable-text extraction, a daily quota, and logging. - `share`: authenticated creation of share codes for supported saved content, with a 512 KB payload cap, expiry from 1 to 90 days (30 when omitted), and a daily quota of 50 per user. - `workspace-invites`: part of the hidden Workspace. Authenticated workspace code and email invitation creation, rotation, revocation, and redemption. The handler keeps raw invite values and email addresses out of database calls and uses service-role-only transactional RPCs for limits and membership changes. - `export-gdoc`: authenticated Google Docs export using a short-lived Google access token. It rejects refresh tokens. @@ -56,13 +56,13 @@ The browser boundary is [`../src/lib/ai/client.ts`](../src/lib/ai/client.ts). Sh - `demo-code-login`: bounded pre-session access-code flow with normalized hashing, fail-closed attempt limits, atomic use consumption, server-owned demo-user bindings in Auth `app_metadata`, and a prohibition on granting administrator status. - `admin`: authenticated administrator operations with server-side role checks, including redacted demo-code list/create/disable/reset actions. - `announcements`: authenticated announcement listing and dismissal. -- `redeem-promo`: authenticated atomic promo redemption. +- `redeem-promo`: authenticated atomic promo redemption through `redeem_promo_code_atomic`. A `discount` code is claimed there too (it counts against `max_redemptions` and is single use per user) and must carry the Stripe promotion-code ID it stands for in `config.stripe_promotion_code`. - `account`: authenticated data export and account deletion for the caller only. It never reads a user ID from the request. Deletion cancels Stripe subscriptions first and stops if that fails, then removes Storage objects, clears the user's workspaces, and deletes the auth user. Export and the deletion plan use service-role-only RPCs. See [Account export and deletion](../docs/architecture.md#account-export-and-deletion). ### Billing -- `create-checkout`: authenticated Stripe Checkout creation. Price selection is server-controlled and redirects are validated. It records the customer in `stripe_customers` before opening a session so that account deletion can stop its billing. -- `create-portal`: authenticated Stripe Billing Portal creation with customer ownership checks. +- `create-checkout`: authenticated Stripe Checkout creation. Price selection is server-controlled and redirects are validated. A promo code is claimed as the user through `redeem_promo_code_atomic` with `p_discount_only`, and the session applies only that code's `promo_` ID through `discounts`; Stripe's own promotion-code field is never enabled. Apply `20261024120100_discount_promo_claims.sql` before deploying it. It records the customer in `stripe_customers` before opening a session so that account deletion can stop its billing. +- `create-portal`: authenticated Stripe Billing Portal creation with customer ownership checks. Stripe failures answer with a fixed `portal_failed` error; Stripe's message stays in the log. - `stripe-webhook`: Stripe-signature-validated subscription and invoice event handling. It claims each event in `webhook_events` before running handlers, reads subscription state from Stripe rather than the event payload, marks the claim complete, and lets a retry take over a claim left incomplete for 10 minutes. It needs the `webhook_events.completed_at` column, so apply `20261016120000_security_preship_hardening.sql` before deploying it. ## Shared backend modules diff --git a/supabase/functions/_shared/testing/bearer-auth.ts b/supabase/functions/_shared/testing/bearer-auth.ts index 1869b5b63..84165d835 100644 --- a/supabase/functions/_shared/testing/bearer-auth.ts +++ b/supabase/functions/_shared/testing/bearer-auth.ts @@ -13,6 +13,18 @@ export const TEST_USER = { email: "user@example.test", }; +/** + * Stripe's fetch client keeps the fetch it saw when the function module was + * imported. Call this before that import so Stripe's calls reach whatever fetch + * a test installs later (runBearerCases's `otherFetch`, for one). + */ +export function forwardFetchToCurrentStub(): void { + const realFetch = globalThis.fetch; + const forwardingFetch: typeof fetch = (input, init) => + globalThis.fetch === forwardingFetch ? realFetch(input, init) : globalThis.fetch(input, init); + globalThis.fetch = forwardingFetch; +} + export type BearerCase = { label: string; authorization?: string; diff --git a/supabase/functions/admin/index.ts b/supabase/functions/admin/index.ts index f81e3e234..36c33f821 100644 --- a/supabase/functions/admin/index.ts +++ b/supabase/functions/admin/index.ts @@ -935,6 +935,16 @@ export async function handleRequest(req: Request): Promise { }); } + // A discount is useless without the Stripe promotion code Checkout + // applies for it; redeem_promo_code_atomic refuses one without it. + const stripePromotionCode = config?.stripe_promotion_code; + if (type === "discount" && (typeof stripePromotionCode !== "string" || !/^promo_[A-Za-z0-9]+$/.test(stripePromotionCode))) { + return new Response(JSON.stringify({ error: "A discount code needs a Stripe promotion code ID (promo_...)" }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); + } + await assertCurrentAdmin(user.id); const { error } = await supabase.from("promo_codes").insert({ code: code.toUpperCase(), diff --git a/supabase/functions/create-checkout/auth_test.ts b/supabase/functions/create-checkout/auth_test.ts index 4dd1ca354..15ff97594 100644 --- a/supabase/functions/create-checkout/auth_test.ts +++ b/supabase/functions/create-checkout/auth_test.ts @@ -1,8 +1,11 @@ +import { assertEquals } from "jsr:@std/assert@1.0.19"; import { + forwardFetchToCurrentStub, rejectedBearerCases, runBearerCases, TEST_ANON_KEY, TEST_SUPABASE_URL, + TEST_USER, VALID_TOKEN, } from "../_shared/testing/bearer-auth.ts"; @@ -11,8 +14,10 @@ for (const [key, value] of Object.entries({ SUPABASE_ANON_KEY: TEST_ANON_KEY, SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", STRIPE_SECRET_KEY: "sk_test_not_used", + STRIPE_PRICE_ID: "price_test123", })) Deno.env.set(key, value); +forwardFetchToCurrentStub(); const { handleRequest } = await import("./index.ts"); const send = (authorization?: string) => @@ -38,3 +43,68 @@ Deno.test("create-checkout lets a valid token through to request validation", as authCalls: 1, }]); }); + +// One Checkout with a promo code against fake Supabase and Stripe backends. +// Returns what the claim RPC and Stripe's session create were sent. +async function checkoutWithPromo(claim: unknown, expected: { status: number; body: unknown }) { + const sent = { claims: [] as { body: unknown; authorization: string | null }[], sessions: [] as URLSearchParams[] }; + const request = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/create-checkout", { + method: "POST", + headers: { "Content-Type": "application/json", ...(authorization ? { Authorization: authorization } : {}) }, + body: JSON.stringify({ + successUrl: "https://debate.pi-stack.org/app/settings?checkout=success", + cancelUrl: "https://debate.pi-stack.org/app/settings?checkout=canceled", + promoCode: "SPRING", + }), + }); + await runBearerCases(handleRequest, request, [{ + label: "checkout with a promo code", + authorization: `Bearer ${VALID_TOKEN}`, + ...expected, + authCalls: 1, + }], (url, headers, init) => { + if (url.origin === TEST_SUPABASE_URL) { + if (url.pathname === "/rest/v1/subscriptions") return Response.json([]); + if (url.pathname === "/rest/v1/rpc/redeem_promo_code_atomic") { + sent.claims.push({ body: JSON.parse(String(init?.body)), authorization: headers.get("authorization") }); + return Response.json(claim); + } + if (url.pathname === "/rest/v1/stripe_customers") return new Response(null, { status: 201 }); + } + if (url.hostname === "api.stripe.com") { + if (url.pathname === "/v1/prices/price_test123") { + return Response.json({ id: "price_test123", object: "price", active: true, type: "recurring" }); + } + if (url.pathname === "/v1/customers/search") { + return Response.json({ object: "search_result", data: [{ id: "cus_test", metadata: { user_id: TEST_USER.id } }] }); + } + if (url.pathname === "/v1/checkout/sessions") { + sent.sessions.push(new URLSearchParams(String(init?.body))); + return Response.json({ id: "cs_test", object: "checkout.session", url: "https://checkout.stripe.test/cs_test" }); + } + } + throw new Error(`Unexpected request: ${url}`); + }); + return sent; +} + +Deno.test("create-checkout applies exactly the claimed Stripe promotion code and never opens Stripe's promo field", async () => { + const sent = await checkoutWithPromo( + { success: true, promoType: "discount", stripePromotionCode: "promo_Spring1" }, + { status: 200, body: { url: "https://checkout.stripe.test/cs_test", priceId: "price_test123" } }, + ); + assertEquals(sent.claims, [{ body: { p_code: "SPRING", p_discount_only: true }, authorization: `Bearer ${VALID_TOKEN}` }]); + assertEquals(sent.sessions.length, 1); + assertEquals(sent.sessions[0].get("discounts[0][promotion_code]"), "promo_Spring1"); + assertEquals(sent.sessions[0].has("allow_promotion_codes"), false); +}); + +Deno.test("create-checkout refuses a promo code the claim RPC refuses, before any Checkout session", async () => { + const sent = await checkoutWithPromo( + { success: false, message: "Invalid or expired promo code" }, + { status: 400, body: { error: "Invalid or expired promo code", code: "promo_invalid" } }, + ); + assertEquals(sent.claims.length, 1); + assertEquals(sent.sessions.length, 0); +}); diff --git a/supabase/functions/create-checkout/checkout-security.ts b/supabase/functions/create-checkout/checkout-security.ts index 9e3a4a1f3..e06357bdf 100644 --- a/supabase/functions/create-checkout/checkout-security.ts +++ b/supabase/functions/create-checkout/checkout-security.ts @@ -41,20 +41,15 @@ export function findCustomerOwnedByUser( return customers.find((customer) => customer.deleted !== true && customer.metadata?.user_id === userId)?.id ?? null; } -export interface DiscountPromo { - expires_at?: string | null; - max_redemptions?: number | null; - redemptions_count?: number | null; -} - /** - * Why a discount promo cannot be used at Checkout, or null when it can. The - * same checks redeem_promo_code_atomic applies to every other promo type: - * the code's expiry and its redemption cap (QB-08). + * The Stripe promotion code a redeem_promo_code_atomic answer grants at + * Checkout, or null when the answer is not a successful discount claim. */ -export function discountPromoRefusal(promo: DiscountPromo, now: Date = new Date()): "expired" | "exhausted" | null { - const expires = promo.expires_at ? Date.parse(promo.expires_at) : Number.NaN; - if (!Number.isNaN(expires) && expires < now.getTime()) return "expired"; - if (typeof promo.max_redemptions === "number" && (promo.redemptions_count ?? 0) >= promo.max_redemptions) return "exhausted"; - return null; +export function discountPromotionCode(claim: unknown): string | null { + if (!claim || typeof claim !== "object") return null; + const { success, promoType, stripePromotionCode } = claim as Record; + if (success !== true || promoType !== "discount") return null; + return typeof stripePromotionCode === "string" && /^promo_[A-Za-z0-9]+$/.test(stripePromotionCode) + ? stripePromotionCode + : null; } diff --git a/supabase/functions/create-checkout/index.ts b/supabase/functions/create-checkout/index.ts index 6922a8a07..7241d540a 100644 --- a/supabase/functions/create-checkout/index.ts +++ b/supabase/functions/create-checkout/index.ts @@ -1,12 +1,12 @@ // Supabase Edge Function: Create Stripe Checkout Session // Creates a Checkout Session for subscription purchase. -import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2.114.0"; -import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; +import { createClient } from "https://esm.sh/@supabase/supabase-js@2.114.0"; +import { type AuthContext, HttpError, jsonError, requireUser } from "../_shared/auth.ts"; import { createEdgeStripe, Stripe } from "../_shared/stripe.ts"; import { getCorsHeaders } from "../_shared/cors.ts"; import { - discountPromoRefusal, + discountPromotionCode, findCustomerOwnedByUser, hasOpenSubscription, isAllowedCheckoutRedirectUrl, @@ -204,14 +204,15 @@ export async function handleRequest(req: Request): Promise { } // Authenticate user - let user: User; + let auth: AuthContext; try { - user = (await requireUser(req)).user; + auth = await requireUser(req); } catch (error) { if (!(error instanceof HttpError)) throw error; console.warn("[CHECKOUT] Authentication rejected:", error.message); return jsonError(error, corsHeaders); } + const { user, userClient } = auth; try { let body: unknown; @@ -272,50 +273,31 @@ export async function handleRequest(req: Request): Promise { }); } - // Check a promo code before any Stripe customer is created for this attempt. - let allowPromotionCodes = false; + // A promo code is claimed through redeem_promo_code_atomic as the user, so + // its expiry, cap, single use and failure lockout apply here too. Only a + // discount is accepted, and Checkout applies exactly the Stripe promotion + // code stored with it; Stripe's own promo-code field stays closed. A claim + // stays counted if Stripe fails below, which keeps the cap. + let promotionCode: string | undefined; if (promoCode) { - const { data: promo, error: promoLookupError } = await supabase.from("promo_codes") - .select("*") - .eq("code", promoCode.toUpperCase()) - .eq("is_active", true) - .maybeSingle(); - - if (promoLookupError) { - console.warn(`[CHECKOUT] Promo lookup failed`); + const { data: claim, error: claimError } = await userClient.rpc("redeem_promo_code_atomic", { + p_code: promoCode, + p_discount_only: true, + }); + if (claimError) { + console.error(`[CHECKOUT] Promo claim failed for ${user.id}: ${claimError.code ?? "unknown"}`); } - - if (promo && promo.type === "discount") { - const refusal = discountPromoRefusal(promo); - if (refusal) { - return new Response(JSON.stringify({ - error: refusal === "expired" ? "This promo code has expired" : "This promo code has reached its usage limit", - code: refusal === "expired" ? "promo_expired" : "promo_exhausted", - }), { - status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } - - const { data: existingRedemption, error: redemptionLookupError } = await supabase.from("user_promos") - .select("id") - .eq("user_id", user.id) - .eq("promo_code_id", promo.id) - .maybeSingle(); - - if (redemptionLookupError) { - console.warn(`[CHECKOUT] Promo redemption lookup failed`); - } - - if (existingRedemption) { - return new Response(JSON.stringify({ error: "You've already redeemed this promo code" }), { - status: 400, - headers: { ...corsHeaders, "Content-Type": "application/json" }, - }); - } - - allowPromotionCodes = true; + const claimed = discountPromotionCode(claimError ? null : claim); + if (!claimed) { + return new Response(JSON.stringify({ + error: "Invalid or expired promo code", + code: "promo_invalid", + }), { + status: 400, + headers: { ...corsHeaders, "Content-Type": "application/json" }, + }); } + promotionCode = claimed; } // Get or create Stripe customer @@ -356,7 +338,7 @@ export async function handleRequest(req: Request): Promise { subscription_data: { metadata: { user_id: user.id }, }, - ...(allowPromotionCodes && { allow_promotion_codes: true }), + ...(promotionCode && { discounts: [{ promotion_code: promotionCode }] }), }; const session = await stripe.checkout.sessions.create(sessionParams); diff --git a/supabase/functions/create-portal/auth_test.ts b/supabase/functions/create-portal/auth_test.ts index 0df0adfad..8a58a7c07 100644 --- a/supabase/functions/create-portal/auth_test.ts +++ b/supabase/functions/create-portal/auth_test.ts @@ -1,4 +1,5 @@ import { + forwardFetchToCurrentStub, rejectedBearerCases, runBearerCases, TEST_ANON_KEY, @@ -13,6 +14,7 @@ for (const [key, value] of Object.entries({ STRIPE_SECRET_KEY: "sk_test_not_used", })) Deno.env.set(key, value); +forwardFetchToCurrentStub(); const { handleRequest } = await import("./index.ts"); const send = (authorization?: string) => @@ -38,3 +40,27 @@ Deno.test("create-portal lets a valid token through to request validation", asyn authCalls: 1, }]); }); + +Deno.test("create-portal answers a Stripe failure with a fixed error, not Stripe's message", async () => { + const sendWithReturnUrl = (authorization?: string) => + new Request("http://127.0.0.1/functions/v1/create-portal", { + method: "POST", + headers: { "Content-Type": "application/json", ...(authorization ? { Authorization: authorization } : {}) }, + body: JSON.stringify({ returnUrl: "https://debate.pi-stack.org/app/settings" }), + }); + await runBearerCases(handleRequest, sendWithReturnUrl, [{ + label: "Stripe rejects the portal session", + authorization: `Bearer ${VALID_TOKEN}`, + status: 400, + body: { error: "Could not open the billing portal. Please try again.", code: "portal_failed" }, + authCalls: 1, + }], (url) => { + if (url.pathname === "/rest/v1/subscriptions") return Response.json({ stripe_customer_id: "cus_leaky123" }); + if (url.hostname === "api.stripe.com" && url.pathname === "/v1/billing_portal/sessions") { + return Response.json({ + error: { type: "invalid_request_error", message: "No configuration provided for customer cus_leaky123", param: "configuration" }, + }, { status: 400 }); + } + throw new Error(`Unexpected request: ${url}`); + }); +}); diff --git a/supabase/functions/create-portal/index.ts b/supabase/functions/create-portal/index.ts index 53f0089a2..51b7c78a4 100644 --- a/supabase/functions/create-portal/index.ts +++ b/supabase/functions/create-portal/index.ts @@ -56,32 +56,33 @@ const NO_ACTIVE_SUBSCRIPTION = { code: "no_active_subscription", }; -function classifyStripeError(err: unknown): { status: number; message: string; code?: string } { +// Any other failure. Stripe's own message can name the customer or the +// rejected parameter, so it goes to the log, not the browser. +const PORTAL_FAILED = { + error: "Could not open the billing portal. Please try again.", + code: "portal_failed", +}; + +function classifyStripeError(err: unknown): { status: number; body: { error: string; code: string } } { const stripeErr = err as { - type?: string; code?: string; - message?: string; statusCode?: number; }; - const message = typeof stripeErr.message === "string" && stripeErr.message.trim() - ? stripeErr.message - : "Failed to create billing portal session"; - if (stripeErr.code === "resource_missing") { - return { status: 400, message: NO_ACTIVE_SUBSCRIPTION.error, code: NO_ACTIVE_SUBSCRIPTION.code }; + return { status: 400, body: NO_ACTIVE_SUBSCRIPTION }; } if (typeof stripeErr.statusCode === "number") { if (stripeErr.statusCode >= 400 && stripeErr.statusCode < 500) { - return { status: 400, message }; + return { status: 400, body: PORTAL_FAILED }; } if (stripeErr.statusCode >= 500) { - return { status: 502, message }; + return { status: 502, body: PORTAL_FAILED }; } } - return { status: 500, message }; + return { status: 500, body: PORTAL_FAILED }; } export async function handleRequest(req: Request): Promise { @@ -154,9 +155,15 @@ export async function handleRequest(req: Request): Promise { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } catch (err) { - const { status, message, code } = classifyStripeError(err); - console.error("[PORTAL] Error:", message); - return new Response(JSON.stringify({ error: message, ...(code ? { code } : {}) }), { + const { status, body } = classifyStripeError(err); + const stripeErr = err as { type?: string; code?: string; requestId?: string; message?: string }; + console.error("[PORTAL] Error:", JSON.stringify({ + type: stripeErr.type, + code: stripeErr.code, + requestId: stripeErr.requestId, + message: typeof stripeErr.message === "string" ? stripeErr.message : String(err), + }).slice(0, 2000)); + return new Response(JSON.stringify(body), { status, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); diff --git a/supabase/functions/import-source/deadline_test.ts b/supabase/functions/import-source/deadline_test.ts index 3e166c284..584ab8797 100644 --- a/supabase/functions/import-source/deadline_test.ts +++ b/supabase/functions/import-source/deadline_test.ts @@ -6,7 +6,15 @@ for (const [key, value] of Object.entries({ SUPABASE_SERVICE_ROLE_KEY: "test-service-role-key", })) Deno.env.set(key, value); -const { handleRequest } = await import("./index.ts"); +const { handleRequest, sourceTransport } = await import("./index.ts"); + +// These tests stub the source with globalThis.fetch. Route the pinned transport +// there, recording the addresses each hop was allowed to dial. +const dialedAddresses: string[][] = []; +sourceTransport.get = (url, addresses, headers, signal) => { + dialedAddresses.push(addresses); + return fetch(url, { headers, signal, redirect: "manual" }); +}; // A body that sends one small chunk every 500 ms forever, or never sends at all. function slowBody(mode: "drip" | "stall", onCancel: () => void) { diff --git a/supabase/functions/import-source/index.ts b/supabase/functions/import-source/index.ts index 62aec1a74..e3656885e 100644 --- a/supabase/functions/import-source/index.ts +++ b/supabase/functions/import-source/index.ts @@ -1,6 +1,7 @@ import { createClient, type User } from "https://esm.sh/@supabase/supabase-js@2.114.0"; import { isPrivateIpv4, isPrivateIpv6, validatePublicHttpUrl, validateResolvedPublicHost } from "./url-validation.ts"; import { readBoundedText } from "./resource-limits.ts"; +import { pinnedGet } from "./pinned-fetch.ts"; import { getMeta, getTitle, stripHtml } from "./html-text.ts"; import { BodyTooLargeError, discardRequestBody, readBoundedBody, untilAborted } from "../_shared/bounded-body.ts"; import { HttpError, jsonError, requireUser } from "../_shared/auth.ts"; @@ -77,21 +78,22 @@ class SourceTimeoutError extends Error { } } +// How a source is fetched. Tests replace `get`; the function always uses pinnedGet. +export const sourceTransport = { get: pinnedGet }; + // The caller's signal is the import's one deadline: it covers DNS, every -// redirect hop and the body read that follows. +// redirect hop and the body read that follows. Each hop connects only to the +// addresses its own check just passed, so DNS cannot change between the check +// and the connection. async function fetchWithRedirects(initialUrl: URL, signal: AbortSignal, onOutboundRequest?: () => void) { let currentUrl = initialUrl; for (let hop = 0; hop <= MAX_REDIRECTS; hop += 1) { - await validateResolvedPublicHost(currentUrl, signal); + const addresses = await validateResolvedPublicHost(currentUrl, signal); onOutboundRequest?.(); - const response = await untilAborted(fetch(currentUrl.toString(), { - headers: { - "Accept": "text/html, text/plain;q=0.9, application/xhtml+xml;q=0.8", - "User-Agent": "DebateLabSourceImporter/1.0", - }, - signal, - redirect: "manual", - }), signal); + const response = await untilAborted(sourceTransport.get(currentUrl, addresses, { + "Accept": "text/html, text/plain;q=0.9, application/xhtml+xml;q=0.8", + "User-Agent": "DebateLabSourceImporter/1.0", + }, signal), signal); // Manual redirect handling so we can re-validate against SSRF on every hop. if (response.status >= 300 && response.status < 400) { diff --git a/supabase/functions/import-source/index_test.ts b/supabase/functions/import-source/index_test.ts index 4f956bf8c..d78cd2b95 100644 --- a/supabase/functions/import-source/index_test.ts +++ b/supabase/functions/import-source/index_test.ts @@ -8,7 +8,15 @@ for (const [key, value] of Object.entries(environment)) { Deno.env.set(key, value); } -const { handleRequest, limitImportedText } = await import("./index.ts"); +const { handleRequest, sourceTransport, limitImportedText } = await import("./index.ts"); + +// These tests stub the source with globalThis.fetch. Route the pinned transport +// there, recording the addresses each hop was allowed to dial. +const dialedAddresses: string[][] = []; +sourceTransport.get = (url, addresses, headers, signal) => { + dialedAddresses.push(addresses); + return fetch(url, { headers, signal, redirect: "manual" }); +}; Deno.test("limitImportedText sets a truncated flag and keeps a note inside the cap", () => { const long = "word ".repeat(20_000); @@ -186,3 +194,20 @@ Deno.test("import-source refunds the slot when it fails before fetching the sour throw new Error(`quota was not refunded: ${JSON.stringify(result.refunds)}`); } }); + +Deno.test("import-source dials only the addresses the check just passed, on every redirect hop", async () => { + dialedAddresses.length = 0; + let hop = 0; + const result = await importWithStubs( + () => hop++ === 0 + ? new Response(null, { status: 302, headers: { location: "https://example.com/next" } }) + : new Response("

" + "word ".repeat(40) + "

", { headers: { "content-type": "text/html" } }), + // Every lookup answers differently; each hop must use the answer it checked. + (_host, type, call) => type === "A" ? [`93.184.216.${call}`] : [], + ); + if (result.status !== 200) throw new Error(`import returned ${result.status}`); + // Lookup 1 is the check before the quota; lookups 2 and 3 are the two hops. + if (JSON.stringify(dialedAddresses) !== JSON.stringify([["93.184.216.2"], ["93.184.216.3"]])) { + throw new Error(`dialed ${JSON.stringify(dialedAddresses)}`); + } +}); diff --git a/supabase/functions/import-source/pinned-fetch.ts b/supabase/functions/import-source/pinned-fetch.ts new file mode 100644 index 000000000..5321a0c29 --- /dev/null +++ b/supabase/functions/import-source/pinned-fetch.ts @@ -0,0 +1,315 @@ +// GET a URL over a connection to an address the caller has already checked. +// +// fetch() resolves the hostname again when it connects, so a name that answers +// with a public address for validateResolvedPublicHost and a private one a +// moment later (DNS rebinding) would reach the private address. Here the +// socket goes to the checked address itself; TLS still verifies the +// certificate for the hostname, and Host carries the hostname. +// +// This is a deliberately small HTTP/1.1 client for one GET per connection +// (Connection: close): Content-Length, chunked and close-delimited bodies, +// gzip and deflate, and 1xx interim responses skipped. Anything else is +// refused rather than guessed at. + +import { untilAborted } from "../_shared/bounded-body.ts"; + +const MAX_HEAD_BYTES = 32 * 1024; +const READ_CHUNK_BYTES = 16 * 1024; +const NULL_BODY_STATUSES = new Set([204, 205, 304]); + +// Each address gets this long to connect (and finish TLS) before the next one +// is tried, so one address that drops packets cannot spend the whole deadline. +export const CONNECT_TIMEOUT_MS = 3_000; + +export type Dial = (address: string, url: URL, signal: AbortSignal) => Promise; + +/** TCP to `address`; for https, TLS with SNI and certificate checks for the URL's hostname. */ +export const dialAddress: Dial = async (address, url, signal) => { + const port = url.port ? Number(url.port) : url.protocol === "https:" ? 443 : 80; + const tcp = await Deno.connect({ hostname: address, port }); + if (url.protocol !== "https:") return tcp; + // A handshake that stalls is cancelled by closing the connection when the + // signal aborts. (Deno then still holds the TCP socket until the worker exits; + // the per-address budget and the import quota bound how many that can be.) + let current: Deno.Conn = tcp; + const abort = () => closeQuietly(current); + signal.addEventListener("abort", abort, { once: true }); + try { + if (signal.aborted) throw signal.reason; + // Without ALPN a server may pick HTTP/2, which this client does not speak. + const tls = await Deno.startTls(tcp, { hostname: url.hostname, alpnProtocols: ["http/1.1"] }); + current = tls; + // startTls defers the handshake; finish it here so a certificate that is + // not valid for the hostname fails the dial. + await tls.handshake(); + return tls; + } catch (error) { + closeQuietly(current); + throw error; + } finally { + signal.removeEventListener("abort", abort); + } +}; + +function closeQuietly(conn: Deno.Conn) { + try { + conn.close(); + } catch { + // Already closed. + } +} + +class SourceProtocolError extends Error { + constructor() { + super("The source sent a response that could not be read."); + } +} + +// Buffers reads from the connection so the head can be parsed line by line. +class ConnReader { + private buffered = new Uint8Array(0); + + constructor(private readonly conn: Deno.Conn) {} + + private async fill(): Promise { + const chunk = new Uint8Array(READ_CHUNK_BYTES); + const n = await this.conn.read(chunk); + if (n === null) return false; + const next = new Uint8Array(this.buffered.length + n); + next.set(this.buffered); + next.set(chunk.subarray(0, n), this.buffered.length); + this.buffered = next; + return true; + } + + /** One CRLF- or LF-terminated line, without the terminator. */ + async line(maxBytes: number): Promise { + while (true) { + const end = this.buffered.indexOf(0x0a); + if (end !== -1) { + if (end > maxBytes) throw new SourceProtocolError(); + const raw = this.buffered.subarray(0, end); + this.buffered = this.buffered.subarray(end + 1); + // One character per byte, so a header value stays a valid ByteString. + // (TextDecoder's "latin1" is windows-1252 and maps some bytes past U+00FF.) + let text = ""; + for (const byte of raw) text += String.fromCharCode(byte); + return text.endsWith("\r") ? text.slice(0, -1) : text; + } + if (this.buffered.length > maxBytes) throw new SourceProtocolError(); + if (!(await this.fill())) throw new SourceProtocolError(); + } + } + + /** Up to `max` bytes, or null at end of stream. */ + async some(max = Infinity): Promise { + if (this.buffered.length === 0 && !(await this.fill())) return null; + const take = Math.min(this.buffered.length, max); + const out = this.buffered.slice(0, take); + this.buffered = this.buffered.subarray(take); + return out; + } +} + +type Head = { status: number; headers: Headers }; + +async function readHead(reader: ConnReader): Promise { + let budget = MAX_HEAD_BYTES; + const take = async () => { + const line = await reader.line(budget); + budget -= line.length + 2; + if (budget < 0) throw new SourceProtocolError(); + return line; + }; + const statusMatch = /^HTTP\/1\.[01] ([1-5]\d\d)(?: .*)?$/.exec(await take()); + if (!statusMatch) throw new SourceProtocolError(); + const headers = new Headers(); + while (true) { + const line = await take(); + if (line === "") break; + // Obsolete line folding and malformed names are refused, not repaired. + const match = /^([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*(.*?)[ \t]*$/.exec(line); + if (!match) throw new SourceProtocolError(); + try { + headers.append(match[1], match[2]); + } catch { + throw new SourceProtocolError(); + } + } + return { status: Number(statusMatch[1]), headers }; +} + +function contentLength(headers: Headers): number | null { + const value = headers.get("content-length"); + if (value === null) return null; + const values = value.split(",").map((part) => part.trim()); + if (!values.every((part) => /^\d+$/.test(part) && part === values[0])) throw new SourceProtocolError(); + return Number(values[0]); +} + +async function* chunkedBody(reader: ConnReader): AsyncGenerator { + while (true) { + const sizeMatch = /^([0-9A-Fa-f]{1,8})(?:[ \t]*;.*)?$/.exec(await reader.line(1024)); + if (!sizeMatch) throw new SourceProtocolError(); + let remaining = parseInt(sizeMatch[1], 16); + if (remaining === 0) { + // Trailer fields are read and dropped. + let budget = MAX_HEAD_BYTES; + while (true) { + const line = await reader.line(budget); + if (line === "") return; + budget -= line.length + 2; + } + } + while (remaining > 0) { + const piece = await reader.some(remaining); + if (!piece) throw new SourceProtocolError(); + remaining -= piece.length; + yield piece; + } + if ((await reader.line(2)) !== "") throw new SourceProtocolError(); + } +} + +async function* lengthBody(reader: ConnReader, length: number): AsyncGenerator { + let remaining = length; + while (remaining > 0) { + const piece = await reader.some(remaining); + if (!piece) throw new SourceProtocolError(); + remaining -= piece.length; + yield piece; + } +} + +async function* untilClose(reader: ConnReader): AsyncGenerator { + while (true) { + const piece = await reader.some(); + if (!piece) return; + yield piece; + } +} + +function bodyStream(reader: ConnReader, head: Head, onDone: () => void): ReadableStream { + const transferEncoding = head.headers.get("transfer-encoding")?.trim().toLowerCase(); + let pieces: AsyncGenerator; + if (transferEncoding !== undefined) { + if (transferEncoding !== "chunked") throw new SourceProtocolError(); + pieces = chunkedBody(reader); + } else { + const length = contentLength(head.headers); + pieces = length === null ? untilClose(reader) : lengthBody(reader, length); + } + + let stream = new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await pieces.next(); + if (done) { + controller.close(); + onDone(); + } else { + controller.enqueue(value); + } + } catch (error) { + onDone(); + // Socket errors carry OS detail; the import shows this message to the user. + controller.error(error instanceof SourceProtocolError ? error : new SourceProtocolError()); + } + }, + cancel() { + onDone(); + }, + }); + + const encoding = head.headers.get("content-encoding")?.trim().toLowerCase(); + const decompress = (format: CompressionFormat) => + new DecompressionStream(format) as unknown as ReadableWritablePair; + if (encoding === "gzip" || encoding === "x-gzip") { + stream = stream.pipeThrough(decompress("gzip")); + } else if (encoding === "deflate") { + stream = stream.pipeThrough(decompress("deflate")); + } else if (encoding !== undefined && encoding !== "identity") { + throw new SourceProtocolError(); + } + if (encoding !== undefined) { + // The lengths described the encoded bytes, not what the stream now yields. + head.headers.delete("content-encoding"); + head.headers.delete("content-length"); + } + head.headers.delete("transfer-encoding"); + return stream; +} + +async function connectFirst( + addresses: string[], + url: URL, + signal: AbortSignal, + dial: Dial, + connectTimeoutMs: number, +): Promise { + for (const address of addresses) { + if (signal.aborted) break; + const attemptSignal = AbortSignal.any([signal, AbortSignal.timeout(connectTimeoutMs)]); + const attempt = dial(address, url, attemptSignal); + try { + return await untilAborted(attempt, attemptSignal); + } catch { + // A connection that opens after its attempt gave up is closed, not leaked. + attempt.then(closeQuietly, () => undefined); + } + } + if (signal.aborted) throw signal.reason; + throw new Error("Could not connect to the source."); +} + +/** + * GET `url` from the first of `addresses` that accepts a connection. Every + * address must already have passed the private-range checks; the hostname is + * never resolved here. + */ +export async function pinnedGet( + url: URL, + addresses: string[], + headers: Record, + signal: AbortSignal, + dial: Dial = dialAddress, + connectTimeoutMs = CONNECT_TIMEOUT_MS, +): Promise { + if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Only http and https URLs can be imported."); + const conn = await connectFirst(addresses, url, signal, dial, connectTimeoutMs); + let closed = false; + const close = () => { + if (closed) return; + closed = true; + signal.removeEventListener("abort", close); + closeQuietly(conn); + }; + signal.addEventListener("abort", close, { once: true }); + + try { + // pathname and search come out of the URL parser percent-encoded, so they + // cannot carry CR, LF or spaces into the request line. + const lines = [`GET ${url.pathname}${url.search} HTTP/1.1`, `Host: ${url.host}`]; + for (const [name, value] of Object.entries(headers)) lines.push(`${name}: ${value}`); + lines.push("Accept-Encoding: gzip, deflate", "Connection: close", "", ""); + const request = new TextEncoder().encode(lines.join("\r\n")); + for (let written = 0; written < request.length;) { + written += await untilAborted(conn.write(request.subarray(written)), signal); + } + + const reader = new ConnReader(conn); + let head = await untilAborted(readHead(reader), signal); + while (head.status < 200) head = await untilAborted(readHead(reader), signal); + + if (NULL_BODY_STATUSES.has(head.status)) { + close(); + return new Response(null, { status: head.status, headers: head.headers }); + } + const body = bodyStream(reader, head, close); + return new Response(body, { status: head.status, headers: head.headers }); + } catch (error) { + close(); + if (signal.aborted || error instanceof SourceProtocolError) throw error; + throw new Error("Could not connect to the source."); + } +} diff --git a/supabase/functions/import-source/pinned-fetch_test.ts b/supabase/functions/import-source/pinned-fetch_test.ts new file mode 100644 index 000000000..8f988e83e --- /dev/null +++ b/supabase/functions/import-source/pinned-fetch_test.ts @@ -0,0 +1,177 @@ +import { assertEquals, assertRejects } from "jsr:@std/assert@1.0.19"; +import { dialAddress, pinnedGet } from "./pinned-fetch.ts"; + +const encoder = new TextEncoder(); +const HEADERS = { "User-Agent": "DebateLabSourceImporter/1.0" }; + +// A one-connection HTTP server on 127.0.0.1 that answers with raw bytes, and +// records the request head it received. +async function rawServer(answer: (Uint8Array | string)[] | "never") { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + let requestHead = ""; + const served = (async () => { + const conn = await listener.accept(); + try { + const buf = new Uint8Array(8192); + while (!requestHead.includes("\r\n\r\n")) { + const n = await conn.read(buf); + if (n === null) break; + requestHead += new TextDecoder().decode(buf.subarray(0, n)); + } + if (answer === "never") { + await conn.read(buf).catch(() => null); + return; + } + for (const part of answer) await conn.write(typeof part === "string" ? encoder.encode(part) : part); + } finally { + try { + conn.close(); + } catch { + // The client closed it first. + } + listener.close(); + } + })(); + return { port, served, requestHead: () => requestHead }; +} + +async function gzip(text: string): Promise { + const stream = new Blob([text]).stream().pipeThrough(new CompressionStream("gzip")); + return new Uint8Array(await new Response(stream).arrayBuffer()); +} + +Deno.test("pinnedGet connects to the given address and never resolves the hostname", async () => { + const originalResolveDns = Deno.resolveDns; + (Deno as unknown as { resolveDns: unknown }).resolveDns = () => { + throw new Error("pinnedGet resolved the hostname"); + }; + const server = await rawServer(["HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 11\r\n\r\nhello world"]); + try { + // rebind.invalid cannot resolve, so the connection can only have used the address. + const url = new URL(`http://rebind.invalid:${server.port}/article?id=1`); + const response = await pinnedGet(url, ["127.0.0.1"], HEADERS, new AbortController().signal); + assertEquals([response.status, await response.text()], [200, "hello world"]); + await server.served; + const [requestLine, ...headerLines] = server.requestHead().split("\r\n"); + assertEquals(requestLine, "GET /article?id=1 HTTP/1.1"); + assertEquals(headerLines.includes(`Host: rebind.invalid:${server.port}`), true); + } finally { + (Deno as unknown as { resolveDns: unknown }).resolveDns = originalResolveDns; + } +}); + +Deno.test("pinnedGet skips 1xx answers and decodes a chunked, gzipped body", async () => { + const page = "

" + "Pinned connections keep DNS from changing under the import. ".repeat(20) + "

"; + const compressed = await gzip(page); + const half = Math.floor(compressed.length / 2); + const server = await rawServer([ + "HTTP/1.1 103 Early Hints\r\nLink: ; rel=preload\r\n\r\n", + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Encoding: gzip\r\nTransfer-Encoding: chunked\r\n\r\n", + `${half.toString(16)};ext=1\r\n`, compressed.subarray(0, half), "\r\n", + `${(compressed.length - half).toString(16)}\r\n`, compressed.subarray(half), "\r\n", + "0\r\nX-Trailer: dropped\r\n\r\n", + ]); + const response = await pinnedGet(new URL(`http://source.invalid:${server.port}/`), ["127.0.0.1"], HEADERS, new AbortController().signal); + assertEquals(response.status, 200); + assertEquals(response.headers.get("content-encoding"), null); + assertEquals(response.headers.get("content-type"), "text/html"); + assertEquals(await response.text(), page); + await server.served; +}); + +Deno.test("pinnedGet reads a body that ends when the server closes", async () => { + const server = await rawServer(["HTTP/1.0 200 OK\r\nContent-Type: text/plain\r\n\r\n", "first part, ", "second part"]); + const response = await pinnedGet(new URL(`http://source.invalid:${server.port}/`), ["127.0.0.1"], HEADERS, new AbortController().signal); + assertEquals(await response.text(), "first part, second part"); + await server.served; +}); + +Deno.test("pinnedGet refuses responses it cannot read safely", async () => { + for (const answer of [ + ["HTTP/1.1 200 OK\r\nX-Big: " + "a".repeat(40_000) + "\r\n\r\n"], + ["HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip, chunked\r\n\r\n"], + ["HTTP/1.1 200 OK\r\nContent-Length: 5, 6\r\n\r\nhello"], + ["HTTP/1.1 200 OK\r\nX-Folded: one\r\n two\r\n\r\n"], + ["SSH-2.0-OpenSSH_9.6\r\n\r\n"], + ]) { + const server = await rawServer(answer); + await assertRejects(async () => { + const response = await pinnedGet(new URL(`http://source.invalid:${server.port}/`), ["127.0.0.1"], HEADERS, new AbortController().signal); + await response.text(); + }, Error, "could not be read"); + await server.served; + } +}); + +Deno.test("pinnedGet tries the next checked address when one refuses", async () => { + const server = await rawServer(["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]); + const dialed: string[] = []; + const response = await pinnedGet( + new URL(`http://source.invalid:${server.port}/`), + ["192.0.2.1", "127.0.0.1"], + HEADERS, + new AbortController().signal, + (address, url, signal) => { + dialed.push(address); + return address === "127.0.0.1" ? dialAddress(address, url, signal) : Promise.reject(new Error("refused")); + }, + ); + assertEquals([dialed, await response.text()], [["192.0.2.1", "127.0.0.1"], "ok"]); + await server.served; +}); + +Deno.test("pinnedGet stops and closes the connection when the deadline aborts", async () => { + const server = await rawServer("never"); + const deadline = new AbortController(); + const pending = pinnedGet(new URL(`http://source.invalid:${server.port}/`), ["127.0.0.1"], HEADERS, deadline.signal); + setTimeout(() => deadline.abort(new Error("deadline")), 50); + await assertRejects(() => pending, Error, "deadline"); + // The server's pending read ends only because the client closed the socket. + await server.served; +}); + +Deno.test("pinnedGet moves on from an address that never answers before the deadline runs out", async () => { + const server = await rawServer(["HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"]); + let stalledSignal: AbortSignal | undefined; + const response = await pinnedGet( + new URL(`http://source.invalid:${server.port}/`), + ["192.0.2.1", "127.0.0.1"], + HEADERS, + AbortSignal.timeout(5_000), + (address, url, signal) => { + if (address === "127.0.0.1") return dialAddress(address, url, signal); + stalledSignal = signal; + return new Promise(() => {}); + }, + 100, + ); + assertEquals(await response.text(), "ok"); + assertEquals(stalledSignal?.aborted, true); + await server.served; +}); + +Deno.test("pinnedGet closes a TLS connection whose handshake stalls when the deadline aborts", async () => { + // Deno keeps the TCP socket of a cancelled handshake open until the process + // exits, so a server cannot observe this; check that close() is called. + const server = await rawServer("never"); + const originalStartTls = Deno.startTls; + let tlsClosed = false; + (Deno as unknown as { startTls: unknown }).startTls = (tcp: Deno.TcpConn) => Promise.resolve({ + handshake: () => new Promise(() => {}), + close: () => { + tlsClosed = true; + tcp.close(); + }, + }); + try { + const deadline = new AbortController(); + const pending = pinnedGet(new URL(`https://source.invalid:${server.port}/`), ["127.0.0.1"], HEADERS, deadline.signal); + setTimeout(() => deadline.abort(new Error("deadline")), 50); + await assertRejects(() => pending, Error, "deadline"); + assertEquals(tlsClosed, true); + await server.served; + } finally { + (Deno as unknown as { startTls: unknown }).startTls = originalStartTls; + } +}); diff --git a/supabase/functions/import-source/url-validation.test.ts b/supabase/functions/import-source/url-validation.test.ts index 014c65921..fc8a19b69 100644 --- a/supabase/functions/import-source/url-validation.test.ts +++ b/supabase/functions/import-source/url-validation.test.ts @@ -136,9 +136,14 @@ Deno.test("validateResolvedPublicHost rejects a public name that resolves into a } }); -Deno.test("validateResolvedPublicHost accepts a name that resolves only to public addresses", async () => { - await withResolvedAddresses({ A: ["93.184.216.34"], AAAA: ["2606:2800:21f:cb07:6820:80da:af6b:8b2c"] }, () => - validateResolvedPublicHost(new URL("https://example.com/"))); +Deno.test("validateResolvedPublicHost accepts a name that resolves only to public addresses and returns them", async () => { + await withResolvedAddresses({ A: ["93.184.216.34"], AAAA: ["2606:2800:21f:cb07:6820:80da:af6b:8b2c"] }, async () => { + const addresses = await validateResolvedPublicHost(new URL("https://example.com/")); + assert( + JSON.stringify(addresses) === JSON.stringify(["93.184.216.34", "2606:2800:21f:cb07:6820:80da:af6b:8b2c"]), + `returned ${JSON.stringify(addresses)}`, + ); + }); }); Deno.test("validatePublicHttpUrl accepts plain public https URLs", () => { diff --git a/supabase/functions/import-source/url-validation.ts b/supabase/functions/import-source/url-validation.ts index cfe3dbbea..bcdcae59f 100644 --- a/supabase/functions/import-source/url-validation.ts +++ b/supabase/functions/import-source/url-validation.ts @@ -41,11 +41,10 @@ export function validatePublicHttpUrl(rawUrl: string): URL { // Re-checks the hostname's *resolved* addresses, not just its literal text, so a // public-looking domain that resolves to a private, reserved or link-local IP is -// still rejected. Call this immediately before every fetch, including redirect -// hops. The runtime cannot pin the checked address for the connection, so a -// resolver that answers differently a moment later is not covered; see -// docs/standards/security.md "Recorded exceptions". -export async function validateResolvedPublicHost(url: URL, signal?: AbortSignal): Promise { +// still rejected. Returns the checked addresses: connect only to one of those +// (pinned-fetch.ts), never to the hostname, or a resolver that answers +// differently a moment later reaches whatever it names (DNS rebinding). +export async function validateResolvedPublicHost(url: URL, signal?: AbortSignal): Promise { const hostname = url.hostname.toLowerCase(); if (isPrivateIpv4(hostname) || isPrivateIpv6(hostname)) { throw new Error("Private and local network URLs cannot be imported."); @@ -63,4 +62,5 @@ export async function validateResolvedPublicHost(url: URL, signal?: AbortSignal) if (addresses.some((address) => isPrivateIpv4(address) || isPrivateIpv6(address))) { throw new Error("Private and local network URLs cannot be imported."); } + return addresses; } diff --git a/supabase/functions/share/index.ts b/supabase/functions/share/index.ts index 9636856aa..cc3f82322 100644 --- a/supabase/functions/share/index.ts +++ b/supabase/functions/share/index.ts @@ -199,9 +199,10 @@ export async function handleRequest(req: Request): Promise { headers: { ...corsHeaders, "Content-Type": "application/json" }, }); } catch (err) { + // The thrown text can be PostgREST or Postgres detail; it stays in the log. const message = err instanceof Error ? err.message : "Unknown error"; - console.error("[SHARE API ERROR]", message); - return new Response(JSON.stringify({ error: message }), { + console.error("[SHARE API ERROR]", message.slice(0, 2000)); + return new Response(JSON.stringify({ error: "Failed to create share link" }), { status: 500, headers: { ...corsHeaders, "Content-Type": "application/json" }, }); diff --git a/supabase/migrations/20261024120000_keepalive_server_only.sql b/supabase/migrations/20261024120000_keepalive_server_only.sql new file mode 100644 index 000000000..aa1a59e5c --- /dev/null +++ b/supabase/migrations/20261024120000_keepalive_server_only.sql @@ -0,0 +1,41 @@ +-- Keepalive tables are server-only (security audit 2026-10-24, finding 4). +-- +-- 20260820070000 recorded TO anon policies and GRANT ALL on keepalive_events +-- and keepalive_heartbeat, so the publishable key could insert unbounded +-- event rows, read them, and overwrite the heartbeat with no session. +-- +-- The writer is the external monitor (Programcode/supabase-keepalive-monitor). +-- Its primary run, the VPS systemd timer, authenticates with the service +-- role, which keeps its grants and bypasses RLS, so it is unaffected. A +-- monitor configured with the publishable key gets 401/403 after this; give +-- it the service-role key for these projects. +-- +-- Nothing is dropped except the anon policies; rows stay. To reopen, re-grant +-- and recreate the policies from 20260820070000. + +REVOKE ALL ON TABLE public.keepalive_events FROM PUBLIC, anon, authenticated; +REVOKE ALL ON TABLE public.keepalive_heartbeat FROM PUBLIC, anon, authenticated; +REVOKE ALL ON SEQUENCE public.keepalive_events_id_seq FROM PUBLIC, anon, authenticated; + +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.keepalive_events TO service_role; +GRANT SELECT, INSERT, UPDATE, DELETE ON TABLE public.keepalive_heartbeat TO service_role; +GRANT USAGE, SELECT ON SEQUENCE public.keepalive_events_id_seq TO service_role; + +-- With the grants gone these no longer match anyone; drop them so a later +-- blanket grant does not quietly reopen the tables. +DROP POLICY IF EXISTS "keepalive_events_anon_insert" ON public.keepalive_events; +DROP POLICY IF EXISTS "keepalive_events_anon_select" ON public.keepalive_events; +DROP POLICY IF EXISTS "keepalive_heartbeat_anon_insert" ON public.keepalive_heartbeat; +DROP POLICY IF EXISTS "keepalive_heartbeat_anon_select" ON public.keepalive_heartbeat; +DROP POLICY IF EXISTS "keepalive_heartbeat_anon_update" ON public.keepalive_heartbeat; + +-- The monitor's keepalive_touch() RPC exists only in the hosted projects (its +-- bootstrap SQL creates it; no migration here does). Keep it service-role only. +DO $$ +BEGIN + IF to_regprocedure('public.keepalive_touch(text, jsonb)') IS NOT NULL THEN + REVOKE ALL ON FUNCTION public.keepalive_touch(text, jsonb) FROM PUBLIC, anon, authenticated; + GRANT EXECUTE ON FUNCTION public.keepalive_touch(text, jsonb) TO service_role; + END IF; +END; +$$; diff --git a/supabase/migrations/20261024120100_discount_promo_claims.sql b/supabase/migrations/20261024120100_discount_promo_claims.sql new file mode 100644 index 000000000..0bd3cb4a5 --- /dev/null +++ b/supabase/migrations/20261024120100_discount_promo_claims.sql @@ -0,0 +1,163 @@ +-- Discount promo codes are claimed like every other promo (security audit +-- 2026-10-24, finding 1). +-- +-- The discount branch returned before user_promos was written and +-- redemptions_count was incremented, so max_redemptions and single use never +-- applied, and create-checkout then opened Stripe's own promotion-code field +-- (allow_promotion_codes), which accepts any code on the Stripe account. +-- +-- Now a discount row must carry the Stripe promotion-code id it stands for in +-- config.stripe_promotion_code (promo_...). Claiming it records the user and +-- counts against the cap, and the answer returns that id so create-checkout +-- can apply exactly that discount. Asking again for a code the user already +-- holds returns the same id without counting again; that is how Checkout gets +-- the id after the pricing dialog has claimed it. A discount row without a +-- valid id answers like an unknown code. +-- +-- p_discount_only is for create-checkout: it refuses every other promo type +-- instead of granting it, so a code typed into the Checkout path never +-- changes an entitlement. The parameter changes the signature, so the old +-- one-argument function is dropped first; leaving it would make PostgREST +-- refuse every call as ambiguous. Both run in this migration's transaction. +-- +-- Existing discount rows have no stripe_promotion_code and stop working until +-- an admin sets one: +-- UPDATE public.promo_codes +-- SET config = config || '{"stripe_promotion_code": "promo_..."}' +-- WHERE code = '...' AND type = 'discount'; + +-- The new function takes p_code as before and defaults the new argument, so +-- callers sending only p_code (redeem-promo) keep working. +-- squawk-ignore ban-drop-function +DROP FUNCTION public.redeem_promo_code_atomic(text); + +CREATE FUNCTION public.redeem_promo_code_atomic(p_code text, p_discount_only boolean DEFAULT false) +RETURNS jsonb +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = '' +AS $$ +DECLARE + v_user_id uuid := auth.uid(); + v_promo public.promo_codes%ROWTYPE; + v_config jsonb; + v_days integer; + v_limit integer; + v_failure text; + v_stripe_code text; + v_max_failures constant integer := 5; + v_window constant interval := interval '15 minutes'; + v_discount_message constant text := 'Discount code noted! Apply it at checkout for a discount.'; +BEGIN + IF v_user_id IS NULL THEN RAISE EXCEPTION 'authentication required'; END IF; + + -- One redemption per user at a time, so parallel guesses cannot all slip + -- under the failure limit before any of them is recorded. + PERFORM pg_advisory_xact_lock(hashtextextended('redeem_promo:' || v_user_id::text, 0)); + + DELETE FROM private.promo_redeem_failures + WHERE user_id = v_user_id AND failed_at < now() - interval '1 day'; + + IF (SELECT count(*) FROM private.promo_redeem_failures + WHERE user_id = v_user_id AND failed_at > now() - v_window) >= v_max_failures THEN + RETURN jsonb_build_object('success', false, 'code', 'too_many_attempts', + 'message', 'Too many promo code attempts. Try again in 15 minutes.'); + END IF; + + SELECT * INTO v_promo FROM public.promo_codes + WHERE code = upper(trim(p_code)) AND is_active = true + FOR UPDATE; + + v_config := COALESCE(v_promo.config, '{}'::jsonb); + v_stripe_code := v_config->>'stripe_promotion_code'; + + -- Unknown, expired, exhausted and unusable codes get the same answer, so + -- guessing cannot tell a real code from a wrong one. A user who already + -- holds a discount gets its id back even once the cap is reached; the claim + -- was counted when they made it. + IF v_promo.id IS NULL + OR (v_promo.expires_at IS NOT NULL AND v_promo.expires_at < now()) + OR (p_discount_only AND v_promo.type::text <> 'discount') + OR (v_promo.type::text = 'discount' + AND COALESCE(v_stripe_code, '') !~ '^promo_[A-Za-z0-9]+$') THEN + v_failure := 'Invalid or expired promo code'; + ELSIF EXISTS (SELECT 1 FROM public.user_promos + WHERE user_id = v_user_id AND promo_code_id = v_promo.id) THEN + IF v_promo.type::text = 'discount' THEN + RETURN jsonb_build_object('success', true, 'message', v_discount_message, + 'promoCodeId', v_promo.id, 'promoType', 'discount', + 'stripePromotionCode', v_stripe_code); + END IF; + v_failure := 'You''ve already redeemed this code'; + ELSIF v_promo.max_redemptions IS NOT NULL + AND COALESCE(v_promo.redemptions_count, 0) >= v_promo.max_redemptions THEN + v_failure := 'Invalid or expired promo code'; + END IF; + + IF v_failure IS NOT NULL THEN + INSERT INTO private.promo_redeem_failures (user_id) VALUES (v_user_id); + RETURN jsonb_build_object('success', false, 'message', v_failure); + END IF; + + -- Each grant only ever raises what the user has: later end dates, higher + -- daily limits, and a Pro or legacy tier is never replaced by a trial. + CASE v_promo.type::text + WHEN 'extended_trial' THEN + v_days := COALESCE((v_config->>'days')::integer, 30); + INSERT INTO public.user_settings AS s (user_id, trial_ends_at, subscription_tier, updated_at) + VALUES (v_user_id, now() + make_interval(days => v_days), 'trial', now()) + ON CONFLICT (user_id) DO UPDATE SET + trial_ends_at = GREATEST(s.trial_ends_at, EXCLUDED.trial_ends_at), + subscription_tier = CASE WHEN s.subscription_tier IN ('pro', 'legacy_free') + THEN s.subscription_tier ELSE EXCLUDED.subscription_tier END, + updated_at = EXCLUDED.updated_at; + WHEN 'extra_limit' THEN + v_limit := COALESCE((v_config->>'limit')::integer, 15); + INSERT INTO public.user_settings AS s (user_id, daily_limit_override, updated_at) + VALUES (v_user_id, v_limit, now()) + ON CONFLICT (user_id) DO UPDATE SET + daily_limit_override = GREATEST(s.daily_limit_override, EXCLUDED.daily_limit_override), + updated_at = EXCLUDED.updated_at; + WHEN 'free_month' THEN + v_days := COALESCE((v_config->>'days')::integer, 30); + INSERT INTO public.user_settings AS s (user_id, subscription_tier, grace_period_ends_at, updated_at) + VALUES (v_user_id, 'pro', now() + make_interval(days => v_days), now()) + ON CONFLICT (user_id) DO UPDATE SET + subscription_tier = EXCLUDED.subscription_tier, + grace_period_ends_at = GREATEST(s.grace_period_ends_at, EXCLUDED.grace_period_ends_at), + updated_at = EXCLUDED.updated_at; + WHEN 'lifetime_pro' THEN + v_limit := COALESCE((v_config->>'limit')::integer, 50); + INSERT INTO public.user_settings AS s (user_id, subscription_tier, daily_limit_override, grace_period_ends_at, updated_at) + VALUES (v_user_id, 'pro', v_limit, '9999-12-31T23:59:59.999Z'::timestamptz, now()) + ON CONFLICT (user_id) DO UPDATE SET + subscription_tier = EXCLUDED.subscription_tier, + daily_limit_override = GREATEST(s.daily_limit_override, EXCLUDED.daily_limit_override), + grace_period_ends_at = EXCLUDED.grace_period_ends_at, + updated_at = EXCLUDED.updated_at; + WHEN 'discount' THEN + -- No settings change: the claim below is the whole redemption. + NULL; + ELSE + RETURN jsonb_build_object('success', false, 'message', 'Unsupported promo code type'); + END CASE; + + INSERT INTO public.user_promos (user_id, promo_code_id) VALUES (v_user_id, v_promo.id) + ON CONFLICT (user_id, promo_code_id) DO NOTHING; + IF NOT FOUND THEN + RETURN jsonb_build_object('success', false, 'message', 'You''ve already redeemed this code'); + END IF; + UPDATE public.promo_codes SET redemptions_count = COALESCE(redemptions_count, 0) + 1 + WHERE id = v_promo.id; + + IF v_promo.type::text = 'discount' THEN + RETURN jsonb_build_object('success', true, 'message', v_discount_message, + 'promoCodeId', v_promo.id, 'promoType', 'discount', + 'stripePromotionCode', v_stripe_code); + END IF; + RETURN jsonb_build_object('success', true, 'message', 'Promo code applied!', + 'promoCodeId', v_promo.id, 'promoType', v_promo.type::text); +END; +$$; +REVOKE ALL ON FUNCTION public.redeem_promo_code_atomic(text, boolean) FROM PUBLIC, anon; +GRANT EXECUTE ON FUNCTION public.redeem_promo_code_atomic(text, boolean) TO authenticated; diff --git a/supabase/tests/discount_promo_claims_test.sql b/supabase/tests/discount_promo_claims_test.sql new file mode 100644 index 000000000..c689fa02f --- /dev/null +++ b/supabase/tests/discount_promo_claims_test.sql @@ -0,0 +1,93 @@ +BEGIN; +SELECT plan(12); + +INSERT INTO auth.users (id, email) VALUES + ('a6000000-0000-4000-8000-000000000001', 'first@example.test'), + ('a6000000-0000-4000-8000-000000000002', 'second@example.test'), + ('a6000000-0000-4000-8000-000000000003', 'third@example.test'); +INSERT INTO public.promo_codes (code, type, config, is_active, max_redemptions) VALUES + ('A6TWOSEATS', 'discount', '{"stripe_promotion_code":"promo_A6Two"}', true, 2), + ('A6NOSTRIPE', 'discount', '{}', true, NULL), + ('A6FREEMONTH', 'free_month', '{"days":30}', true, NULL); + +CREATE FUNCTION pg_temp.claim(p_user uuid, p_code text, p_discount_only boolean DEFAULT false) +RETURNS jsonb LANGUAGE plpgsql AS $$ +BEGIN + PERFORM set_config('request.jwt.claim.sub', p_user::text, true); + RETURN public.redeem_promo_code_atomic(p_code, p_discount_only); +END; +$$; +GRANT EXECUTE ON FUNCTION pg_temp.claim(uuid, text, boolean) TO authenticated; + +SET LOCAL ROLE authenticated; +SELECT set_config('request.jwt.claim.role', 'authenticated', true); + +SELECT is( + pg_temp.claim('a6000000-0000-4000-8000-000000000001', 'a6twoseats') ->> 'stripePromotionCode', + 'promo_A6Two', + 'claiming a discount returns the Stripe promotion code it stands for' +); +SELECT is( + pg_temp.claim('a6000000-0000-4000-8000-000000000001', 'A6TWOSEATS', true) ->> 'stripePromotionCode', + 'promo_A6Two', + 'Checkout asking again for a held discount gets the same id' +); +SELECT is( + pg_temp.claim('a6000000-0000-4000-8000-000000000002', 'A6TWOSEATS') ->> 'success', + 'true', + 'the last seat under the cap can be claimed' +); +SELECT is( + pg_temp.claim('a6000000-0000-4000-8000-000000000003', 'A6TWOSEATS'), + jsonb_build_object('success', false, 'message', 'Invalid or expired promo code'), + 'a claim past max_redemptions is refused like an unknown code' +); +SELECT is( + pg_temp.claim('a6000000-0000-4000-8000-000000000003', 'A6TWOSEATS', true) ->> 'success', + 'false', + 'Checkout cannot claim a discount past its cap either' +); +SELECT is( + pg_temp.claim('a6000000-0000-4000-8000-000000000001', 'A6TWOSEATS', true) ->> 'stripePromotionCode', + 'promo_A6Two', + 'a user who claimed before the cap still gets the id at Checkout' +); +SELECT is( + pg_temp.claim('a6000000-0000-4000-8000-000000000002', 'A6NOSTRIPE') ->> 'message', + pg_temp.claim('a6000000-0000-4000-8000-000000000002', 'A6NOSUCHCODE') ->> 'message', + 'a discount row without a Stripe promotion code answers like an unknown code' +); +SELECT is( + pg_temp.claim('a6000000-0000-4000-8000-000000000003', 'A6FREEMONTH', true) ->> 'success', + 'false', + 'the Checkout path refuses a non-discount code' +); +RESET ROLE; + +SELECT is( + (SELECT redemptions_count FROM public.promo_codes WHERE code = 'A6TWOSEATS'), + 2, + 'redemptions_count equals the cap: repeat calls by a holder do not count again' +); +SELECT is( + (SELECT count(*) FROM public.user_promos up JOIN public.promo_codes pc ON pc.id = up.promo_code_id + WHERE pc.code = 'A6TWOSEATS'), + 2::bigint, + 'each claimed seat is recorded in user_promos' +); +SELECT ok( + NOT EXISTS (SELECT 1 FROM public.user_promos up JOIN public.promo_codes pc ON pc.id = up.promo_code_id + WHERE pc.code = 'A6FREEMONTH') + AND NOT EXISTS (SELECT 1 FROM public.user_settings + WHERE user_id = 'a6000000-0000-4000-8000-000000000003' AND subscription_tier = 'pro'), + 'a non-discount code sent through Checkout grants nothing' +); +SELECT is( + (SELECT count(*) FROM pg_proc p JOIN pg_namespace n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' AND p.proname = 'redeem_promo_code_atomic'), + 1::bigint, + 'one redeem_promo_code_atomic, so PostgREST can resolve the call' +); + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/keepalive_server_only_test.sql b/supabase/tests/keepalive_server_only_test.sql new file mode 100644 index 000000000..da2a872ce --- /dev/null +++ b/supabase/tests/keepalive_server_only_test.sql @@ -0,0 +1,54 @@ +BEGIN; +SELECT plan(7); + +SELECT ok( + NOT has_table_privilege('anon', 'public.keepalive_events', 'SELECT,INSERT,UPDATE,DELETE') + AND NOT has_table_privilege('anon', 'public.keepalive_heartbeat', 'SELECT,INSERT,UPDATE,DELETE') + AND NOT has_table_privilege('authenticated', 'public.keepalive_events', 'SELECT,INSERT,UPDATE,DELETE') + AND NOT has_table_privilege('authenticated', 'public.keepalive_heartbeat', 'SELECT,INSERT,UPDATE,DELETE'), + 'clients hold no privilege on the keepalive tables' +); +SELECT is( + (SELECT count(*) FROM pg_policies + WHERE schemaname = 'public' AND tablename IN ('keepalive_events', 'keepalive_heartbeat')), + 0::bigint, + 'no policy opens the keepalive tables to a client role' +); + +INSERT INTO public.keepalive_heartbeat (id, source) VALUES ('external-keepalive', 'external-keepalive-monitor'); +INSERT INTO public.keepalive_events (source) VALUES ('external-keepalive-monitor'); + +-- The publishable key: what the audit showed it could do. +SET LOCAL ROLE anon; +SELECT set_config('request.jwt.claim.role', 'anon', true); +SELECT throws_ok( + $$INSERT INTO public.keepalive_events (source, details) VALUES ('external-keepalive-monitor', '{}')$$, + '42501', NULL, 'the publishable key cannot insert keepalive events' +); +SELECT throws_ok( + $$SELECT 1 FROM public.keepalive_events$$, + '42501', NULL, 'the publishable key cannot read keepalive events' +); +SELECT throws_ok( + $$UPDATE public.keepalive_heartbeat SET touched_at = now() WHERE id = 'external-keepalive'$$, + '42501', NULL, 'the publishable key cannot overwrite the heartbeat' +); +RESET ROLE; + +-- The monitor's own path: REST upsert of the heartbeat and an event insert. +SET LOCAL ROLE service_role; +SELECT set_config('request.jwt.claim.role', 'service_role', true); +SELECT lives_ok( + $$INSERT INTO public.keepalive_heartbeat (id, touched_at, source) + VALUES ('external-keepalive', now(), 'external-keepalive-monitor') + ON CONFLICT (id) DO UPDATE SET touched_at = excluded.touched_at, source = excluded.source$$, + 'the service role can still upsert the heartbeat' +); +SELECT lives_ok( + $$INSERT INTO public.keepalive_events (source) VALUES ('external-keepalive-monitor')$$, + 'the service role can still record a keepalive event' +); +RESET ROLE; + +SELECT * FROM finish(); +ROLLBACK; diff --git a/supabase/tests/legacy_table_grants_test.sql b/supabase/tests/legacy_table_grants_test.sql index ce0f81fa2..25d754723 100644 --- a/supabase/tests/legacy_table_grants_test.sql +++ b/supabase/tests/legacy_table_grants_test.sql @@ -31,8 +31,9 @@ SELECT ok( 'subscription status, redeemed promos, and usage remain client-readable' ); SELECT ok( - has_sequence_privilege('anon', 'public.keepalive_events_id_seq', 'USAGE'), - 'anonymous keepalive inserts can generate an event id' + NOT has_sequence_privilege('anon', 'public.keepalive_events_id_seq', 'USAGE') + AND has_sequence_privilege('service_role', 'public.keepalive_events_id_seq', 'USAGE'), + 'only the service role can generate a keepalive event id' ); INSERT INTO auth.users (id) VALUES