From a5ad865faadad10039a29c28d3d97f9e34248f10 Mon Sep 17 00:00:00 2001 From: Daniel Ehrhardt Date: Sun, 18 Oct 2026 17:14:20 +0200 Subject: [PATCH] Don't open Stripe checkout sessions for scripts and scanners POST /api/checkout now applies the same bot check as GET, and the GET handoff keeps the visitor's headers so the per-IP rate limit and the bot check still see the real IP and user agent. --- apps/website/src/pages/api/checkout.ts | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/website/src/pages/api/checkout.ts b/apps/website/src/pages/api/checkout.ts index 8da9f197..e2b863f3 100644 --- a/apps/website/src/pages/api/checkout.ts +++ b/apps/website/src/pages/api/checkout.ts @@ -34,9 +34,18 @@ const unavailable = (wantsJson: boolean) => ? Response.json({ error: 'plan_unavailable' }, { status: 410 }) : new Response(null, { status: 303, headers: { location: '/#pricing' } }); +// Scripts, scanners and uptime checks must not open live Stripe sessions: they pile up as expired +// checkouts with no email and make the funnel look like real people quit at the card step. +const isBot = (request: Request) => BOTS.test(request.headers.get('user-agent') ?? ''); + export const POST: APIRoute = async ({ request, url }) => { const wantsJson = (request.headers.get('accept') ?? '').includes('application/json'); let plan: Plan = 'monthly'; + if (isBot(request)) { + return wantsJson + ? Response.json({ error: 'not_a_browser' }, { status: 403 }) + : new Response(null, { status: 303, headers: { location: '/#pricing' } }); + } try { if (!(await allowed('CHECKOUT_LIMIT', request))) { @@ -132,7 +141,7 @@ export const POST: APIRoute = async ({ request, url }) => { export const GET: APIRoute = async (ctx) => { if (RETIRED.has(ctx.url.searchParams.get('plan') ?? '')) return unavailable(false); const plan = planFrom(ctx.url.searchParams.get('plan')); - if (BOTS.test(ctx.request.headers.get('user-agent') ?? '')) { + if (isBot(ctx.request)) { return new Response(null, { status: 303, headers: { location: '/#pricing' } }); } const form = new URLSearchParams({ plan }); @@ -140,10 +149,10 @@ export const GET: APIRoute = async (ctx) => { const v = ctx.url.searchParams.get(k); if (v) form.set(k, v); } - const request = new Request(ctx.request.url, { - method: 'POST', - headers: { 'content-type': 'application/x-www-form-urlencoded' }, - body: form, - }); + // Keep the visitor's IP and user agent so the rate limit and bot check still apply. + const headers = new Headers(ctx.request.headers); + headers.set('content-type', 'application/x-www-form-urlencoded'); + headers.delete('content-length'); + const request = new Request(ctx.request.url, { method: 'POST', headers, body: form }); return POST({ ...ctx, request }); };