From d749484e28cf9f487bb779d7ec3ef899fd93baa2 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:18:54 -0400 Subject: [PATCH 1/2] fix(site): accept only same-origin JSON signups and require double opt-in - fix(site): reject cross-site requests (Sec-Fetch-Site or a foreign Origin) with 403 and non-JSON bodies with 415, before the rate limit; a text/plain form post from any page used to reach the provider call - fix(site): create EmailOctopus contacts as pending so the confirmation email gates the list - test(site): cover cross-site, missing and text/plain content types, and same-origin JSON still reaching validation - docs(deploy): note the double opt-in dependency and the per-instance limit --- frontend/README-VERCEL.md | 5 +++ frontend/app/api/subscribe/route.ts | 36 +++++++++++++++++++-- frontend/test/site-browser.test.mjs | 50 +++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) diff --git a/frontend/README-VERCEL.md b/frontend/README-VERCEL.md index 4c7a821..bd3bcb6 100644 --- a/frontend/README-VERCEL.md +++ b/frontend/README-VERCEL.md @@ -126,6 +126,11 @@ The `vercel.json` file includes: ### Email Signup Not Working +- New contacts are created as `pending` (double opt-in). Double opt-in must be + enabled on the EmailOctopus list so the confirmation email goes out; + otherwise signups sit in Pending. +- `/api/subscribe` only accepts same-origin `application/json` requests (403 + cross-site, 415 otherwise). The in-memory rate limit is per instance only. - Verify EmailOctopus credentials - Check function logs in Vercel dashboard - Ensure API routes are correctly configured diff --git a/frontend/app/api/subscribe/route.ts b/frontend/app/api/subscribe/route.ts index 307c2af..093036b 100644 --- a/frontend/app/api/subscribe/route.ts +++ b/frontend/app/api/subscribe/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -// Simple in-memory rate limiting (consider Redis for production) +// Best-effort, per-instance in-memory limit; not effective across serverless instances, a real limit needs an edge rule. const rateLimitMap = new Map(); function getRateLimitKey(request: Request): string { @@ -32,8 +32,38 @@ function checkRateLimit(key: string, limit = 5, windowMs = 60000): boolean { return true; } +function isCrossSite(request: Request): boolean { + if (request.headers.get("sec-fetch-site") === "cross-site") { + return true; + } + const origin = request.headers.get("origin"); + if (origin === null) { + return false; + } + try { + // Host header first: request.url can carry a rewritten hostname behind the server. + const host = request.headers.get("host") ?? new URL(request.url).host; + return new URL(origin).host !== host; + } catch { + return true; + } +} + export async function POST(request: Request) { try { + // Same-origin JSON only, checked before the rate limit so rejected + // cross-site traffic doesn't use up a visitor's allowance. + if (isCrossSite(request)) { + return NextResponse.json({ error: "Cross-site requests are not allowed" }, { status: 403 }); + } + const mediaType = (request.headers.get("content-type") ?? "") + .split(";")[0] + .trim() + .toLowerCase(); + if (mediaType !== "application/json") { + return NextResponse.json({ error: "Content-Type must be application/json" }, { status: 415 }); + } + // Rate limiting check const rateLimitKey = getRateLimitKey(request); if (!checkRateLimit(rateLimitKey)) { @@ -200,7 +230,9 @@ export async function POST(request: Request) { }, body: JSON.stringify({ email_address: normalizedEmail, - status: "subscribed", + // Double opt-in: EmailOctopus holds the contact as pending until the + // confirmation link is clicked, so nobody can subscribe someone else. + status: "pending", }), }, ); diff --git a/frontend/test/site-browser.test.mjs b/frontend/test/site-browser.test.mjs index b9ce1e3..3809487 100644 --- a/frontend/test/site-browser.test.mjs +++ b/frontend/test/site-browser.test.mjs @@ -316,3 +316,53 @@ test("subscription rejects nonobject and malformed bodies without calling a prov assert.equal(typeof (await response.json()).error, "string"); } }); + +test("subscription rejects cross-site and non-JSON requests without calling a provider", { + timeout: 15_000, +}, async () => { + const validBody = '{"email":"someone@example.com"}'; + const invalidBody = '{"email":"not-an-email"}'; + const sameOrigin = new URL(baseUrl).origin; + const cases = [ + { name: "text/plain", headers: { "content-type": "text/plain" }, body: validBody, status: 415 }, + { name: "no content-type", headers: {}, body: validBody, status: 415 }, + { + name: "json with charset", + headers: { "content-type": "application/json; charset=utf-8" }, + body: invalidBody, + status: 400, + }, + { + name: "foreign origin", + headers: { "content-type": "application/json", origin: "https://evil.example" }, + body: validBody, + status: 403, + }, + { + name: "sec-fetch-site cross-site", + headers: { "content-type": "application/json", "sec-fetch-site": "cross-site" }, + body: validBody, + status: 403, + }, + { + name: "same-origin browser", + headers: { + "content-type": "application/json", + origin: sameOrigin, + "sec-fetch-site": "same-origin", + }, + body: invalidBody, + status: 400, + }, + ]; + for (const [index, { name, headers, body, status }] of cases.entries()) { + const response = await fetch(`${baseUrl}/api/subscribe`, { + signal: AbortSignal.timeout(5_000), + method: "POST", + headers: { ...headers, "x-forwarded-for": `198.51.100.${index + 1}` }, + body, + }); + assert.equal(response.status, status, name); + assert.equal(typeof (await response.json()).error, "string", name); + } +}); From 15592c00fc59d8b548bbe1f4f0367dfa4996ffa5 Mon Sep 17 00:00:00 2001 From: scttbnsn <80784472+scttbnsn@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:35:41 -0400 Subject: [PATCH 2/2] test(site): send a truly headerless body and describe the origin check accurately - test(site): use a byte body for the no-content-type case so fetch doesn't add its default text/plain header - docs(deploy): say the route rejects cross-site and foreign-Origin requests, and that requests with neither header are still accepted --- frontend/README-VERCEL.md | 6 ++++-- frontend/test/site-browser.test.mjs | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/frontend/README-VERCEL.md b/frontend/README-VERCEL.md index bd3bcb6..7412e26 100644 --- a/frontend/README-VERCEL.md +++ b/frontend/README-VERCEL.md @@ -129,8 +129,10 @@ The `vercel.json` file includes: - New contacts are created as `pending` (double opt-in). Double opt-in must be enabled on the EmailOctopus list so the confirmation email goes out; otherwise signups sit in Pending. -- `/api/subscribe` only accepts same-origin `application/json` requests (403 - cross-site, 415 otherwise). The in-memory rate limit is per instance only. +- `/api/subscribe` rejects requests marked `Sec-Fetch-Site: cross-site` or + carrying a foreign `Origin` (403), and anything that isn't + `application/json` (415). Requests with neither header, such as curl, are + still accepted. The in-memory rate limit is per instance only. - Verify EmailOctopus credentials - Check function logs in Vercel dashboard - Ensure API routes are correctly configured diff --git a/frontend/test/site-browser.test.mjs b/frontend/test/site-browser.test.mjs index 3809487..110cdbd 100644 --- a/frontend/test/site-browser.test.mjs +++ b/frontend/test/site-browser.test.mjs @@ -325,7 +325,13 @@ test("subscription rejects cross-site and non-JSON requests without calling a pr const sameOrigin = new URL(baseUrl).origin; const cases = [ { name: "text/plain", headers: { "content-type": "text/plain" }, body: validBody, status: 415 }, - { name: "no content-type", headers: {}, body: validBody, status: 415 }, + // A Uint8Array body keeps fetch from adding its default text/plain header. + { + name: "no content-type", + headers: {}, + body: new TextEncoder().encode(validBody), + status: 415, + }, { name: "json with charset", headers: { "content-type": "application/json; charset=utf-8" },