Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions frontend/README-VERCEL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- Verify EmailOctopus credentials
- Check function logs in Vercel dashboard
- Ensure API routes are correctly configured
36 changes: 34 additions & 2 deletions frontend/app/api/subscribe/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, { count: number; resetTime: number }>();

function getRateLimitKey(request: Request): string {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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",
}),
},
);
Expand Down
50 changes: 50 additions & 0 deletions frontend/test/site-browser.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
});
Loading