diff --git a/README.md b/README.md index fd8340b..044ad30 100644 --- a/README.md +++ b/README.md @@ -46,11 +46,16 @@ pnpm test:smoke ``` Smoke tests run against `scripts/preview-server.ts` over HTTP: the subscribe proxy -(including invalid input, provider conflicts, outages and the unconfigured case) and +(including validation, rate limiting, duplicate submissions, provider outages and the unconfigured case) and the OG image endpoint (rendering, query escaping, cache headers and the failure response). Buttondown is stubbed, so no mail is sent and no test touches the network. The suite runs on every pull request in CI. +The subscribe endpoint accepts JSON bodies up to 2,048 bytes and the documented `newsletter` +(default) and `careers` tags. It allows five submissions per Vercel client IP per +15-minute window; the in-memory, HMAC-keyed limiter avoids retaining raw IP addresses +and is scoped to each warm serverless instance. + ## Link checks ```bash diff --git a/api/subscribe.ts b/api/subscribe.ts index 4f2239a..6e97ed9 100644 --- a/api/subscribe.ts +++ b/api/subscribe.ts @@ -1,4 +1,5 @@ -import { randomUUID } from 'node:crypto'; +import { createHmac, randomBytes, randomUUID } from 'node:crypto'; +import { isIP } from 'node:net'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { SUBSCRIBE_API_SECURITY_HEADERS } from '../src/utils/security-headers'; @@ -18,8 +19,19 @@ import { SUBSCRIBE_API_SECURITY_HEADERS } from '../src/utils/security-headers'; * - Free tier covers the initial subscriber volume; no vendor lock-in */ -type SubscribeBody = { email?: string; tag?: string }; -type SubscribeRequest = IncomingMessage & { body?: SubscribeBody }; +type SubscribeBody = { email?: unknown; tag?: unknown }; +type SubscribeRequest = IncomingMessage; + +const SUPPORTED_TAGS = new Set(['newsletter', 'careers']); +export const MAX_REQUEST_BODY_BYTES = 2048; +export const RATE_LIMIT_REQUESTS = 5; +export const RATE_LIMIT_WINDOW_MS = 15 * 60 * 1000; +const MAX_RATE_LIMIT_CLIENTS = 10_000; + +const rateLimitSecret = randomBytes(32); +const rateLimitBuckets = new Map(); +const inFlightSubmissions = new Map>(); +let rateLimitChecks = 0; export const BUTTONDOWN_API_URL = 'https://api.buttondown.email/v1/subscribers'; @@ -36,16 +48,24 @@ const EMAIL_RE = /^[^\s@]+@[^\s@][^@]*\.[^\s@]+$/; export type SubscribeErrorCode = | 'method_not_allowed' | 'not_configured' + | 'unsupported_media_type' + | 'payload_too_large' + | 'invalid_request' | 'invalid_email' - | 'already_subscribed' + | 'invalid_tag' + | 'rate_limited' | 'upstream_timeout' | 'upstream_error'; const ERROR_STATUS: Record = { method_not_allowed: 405, not_configured: 500, + unsupported_media_type: 415, + payload_too_large: 413, + invalid_request: 400, invalid_email: 422, - already_subscribed: 409, + invalid_tag: 422, + rate_limited: 429, upstream_timeout: 504, upstream_error: 502, }; @@ -53,8 +73,12 @@ const ERROR_STATUS: Record = { const ERROR_MESSAGE: Record = { method_not_allowed: 'Method not allowed.', not_configured: 'Subscription service is not configured.', + unsupported_media_type: 'Content-Type must be application/json.', + payload_too_large: 'Request body must be 2,048 bytes or less.', + invalid_request: 'Invalid request.', invalid_email: 'Enter a valid email address.', - already_subscribed: 'This email is already subscribed.', + invalid_tag: 'Unsupported subscription tag.', + rate_limited: 'Too many requests. Please try again later.', upstream_timeout: 'Subscription service timed out. Please try again.', upstream_error: 'Subscription service is unavailable.', }; @@ -96,31 +120,98 @@ function sendError(res: ServerResponse, requestId: string, code: SubscribeErrorC sendJson(res, requestId, ERROR_STATUS[code], { error: ERROR_MESSAGE[code], code }); } -export default async function handler(req: SubscribeRequest, res: ServerResponse) { - const requestId = resolveRequestId(req); +function resolveClientKey(req: SubscribeRequest): string { + const forwardedFor = req.headers['x-vercel-forwarded-for']; + const clientIp = typeof forwardedFor === 'string' ? forwardedFor.trim() : ''; + const trustedIp = isIP(clientIp) ? clientIp : 'unknown'; + return createHmac('sha256', rateLimitSecret).update(trustedIp).digest('hex'); +} - if (req.method !== 'POST') { - logEvent(requestId, 'method_not_allowed', 405); - sendError(res, requestId, 'method_not_allowed'); - return; +function checkRateLimit(clientKey: string, now = Date.now()): number | null { + rateLimitChecks += 1; + if (rateLimitChecks % 100 === 0 || rateLimitBuckets.size >= MAX_RATE_LIMIT_CLIENTS) { + for (const [key, bucket] of rateLimitBuckets) { + if (now - bucket.windowStartedAt >= RATE_LIMIT_WINDOW_MS) { + rateLimitBuckets.delete(key); + } + } } - const apiKey = process.env.BUTTONDOWN_API_KEY; - if (!apiKey) { - logEvent(requestId, 'not_configured', 500); - sendError(res, requestId, 'not_configured'); - return; + const bucket = rateLimitBuckets.get(clientKey); + if (!bucket || now - bucket.windowStartedAt >= RATE_LIMIT_WINDOW_MS) { + if (rateLimitBuckets.size >= MAX_RATE_LIMIT_CLIENTS) { + return Math.ceil(RATE_LIMIT_WINDOW_MS / 1000); + } + rateLimitBuckets.set(clientKey, { windowStartedAt: now, count: 1 }); + return null; } - const rawEmail = typeof req.body?.email === 'string' ? req.body.email.trim().toLowerCase() : ''; - const tag = typeof req.body?.tag === 'string' ? req.body.tag.trim() : 'newsletter'; - - if (!rawEmail || !EMAIL_RE.test(rawEmail)) { - logEvent(requestId, 'invalid_email', 422); - sendError(res, requestId, 'invalid_email'); - return; + if (bucket.count >= RATE_LIMIT_REQUESTS) { + return Math.max(1, Math.ceil((bucket.windowStartedAt + RATE_LIMIT_WINDOW_MS - now) / 1000)); } + bucket.count += 1; + return null; +} + +type SubscribeResult = { status: number; payload: unknown }; + +function isSubscribeBody(value: unknown): value is SubscribeBody { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +type RawBodyResult = { kind: 'ok'; text: string } | { kind: 'too_large' } | { kind: 'error' }; + +/** + * Reads the raw request bytes and stops as soon as `limit` is crossed, before + * any JSON parsing happens. Counting raw bytes covers whitespace padding and + * chunked transfer encoding, which a post-parse size check cannot see. + * `req.body` is deliberately never touched: on Vercel it is a lazy getter that + * would buffer and parse the whole stream first. + */ +function readRawBody(req: SubscribeRequest, limit: number): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + let received = 0; + let settled = false; + + const finish = (result: RawBodyResult) => { + if (settled) return; + settled = true; + req.off('data', onData); + req.off('end', onEnd); + req.off('error', onError); + req.off('aborted', onError); + resolve(result); + }; + + const onData = (chunk: Buffer | string) => { + const buffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk; + received += buffer.byteLength; + if (received > limit) { + chunks.length = 0; + req.pause(); + finish({ kind: 'too_large' }); + return; + } + chunks.push(buffer); + }; + const onEnd = () => finish({ kind: 'ok', text: Buffer.concat(chunks).toString('utf8') }); + const onError = () => finish({ kind: 'error' }); + + req.on('data', onData); + req.on('end', onEnd); + req.on('error', onError); + req.on('aborted', onError); + }); +} + +async function submitToProvider( + rawEmail: string, + tag: string, + apiKey: string, + requestId: string, +): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), UPSTREAM_TIMEOUT_MS); @@ -131,25 +222,13 @@ export default async function handler(req: SubscribeRequest, res: ServerResponse Authorization: `Token ${apiKey}`, 'Content-Type': 'application/json', }, - // Ask Buttondown to send the double opt-in confirmation email. body: JSON.stringify({ email: rawEmail, tags: [tag], type: 'unconfirmed' }), signal: controller.signal, }); - // 201 Created — subscription queued, confirmation email sent. - if (bdRes.status === 201) { + if (bdRes.status === 201 || bdRes.status === 409) { logEvent(requestId, 'subscribed', 201); - sendJson(res, requestId, 201, { ok: true }); - return; - } - - // Buttondown returns 409 when the address is already subscribed. Treat it - // as a distinct code so clients can show a friendly message without - // revealing list membership (the client decides whether to surface it). - if (bdRes.status === 409) { - logEvent(requestId, 'already_subscribed', 409); - sendError(res, requestId, 'already_subscribed'); - return; + return { status: 201, payload: { ok: true } }; } if (bdRes.status === 400 || bdRes.status === 422) { @@ -158,36 +237,152 @@ export default async function handler(req: SubscribeRequest, res: ServerResponse const body = (await bdRes.json()) as Record | null; code = body?.code; } catch { - // Malformed or non-JSON provider body — fall through to invalid_email. code = undefined; } if (code === 'email_already_exists' || code === 'subscriber_already_exists') { - logEvent(requestId, 'already_subscribed', 409); - sendError(res, requestId, 'already_subscribed'); - return; + logEvent(requestId, 'subscribed', 201); + return { status: 201, payload: { ok: true } }; } logEvent(requestId, 'invalid_email', 422); - sendError(res, requestId, 'invalid_email'); - return; + return { + status: 422, + payload: { error: ERROR_MESSAGE.invalid_email, code: 'invalid_email' }, + }; } - // Every other provider response (auth failures, 429, 5xx, unexpected - // shapes) collapses into one stable client error so nothing about the - // upstream is exposed. logEvent(requestId, 'upstream_error', bdRes.status); - sendError(res, requestId, 'upstream_error'); + return { + status: 502, + payload: { error: ERROR_MESSAGE.upstream_error, code: 'upstream_error' }, + }; } catch { - // AbortController only fires on the timeout path here. if (controller.signal.aborted) { logEvent(requestId, 'upstream_timeout', 504); - sendError(res, requestId, 'upstream_timeout'); - return; + return { + status: 504, + payload: { error: ERROR_MESSAGE.upstream_timeout, code: 'upstream_timeout' }, + }; } + logEvent(requestId, 'upstream_error', 502); - sendError(res, requestId, 'upstream_error'); + return { + status: 502, + payload: { error: ERROR_MESSAGE.upstream_error, code: 'upstream_error' }, + }; } finally { clearTimeout(timeout); } } + +export default async function handler(req: SubscribeRequest, res: ServerResponse) { + const requestId = resolveRequestId(req); + + if (req.method !== 'POST') { + logEvent(requestId, 'method_not_allowed', 405); + sendError(res, requestId, 'method_not_allowed'); + return; + } + + const apiKey = process.env.BUTTONDOWN_API_KEY; + if (!apiKey) { + logEvent(requestId, 'not_configured', 500); + sendError(res, requestId, 'not_configured'); + return; + } + + const contentType = req.headers['content-type']; + const mediaType = typeof contentType === 'string' ? contentType.split(';', 1)[0]?.trim() : ''; + if (mediaType?.toLowerCase() !== 'application/json') { + logEvent(requestId, 'unsupported_media_type', 415); + sendError(res, requestId, 'unsupported_media_type'); + return; + } + + const declaredLength = req.headers['content-length']; + if ( + declaredLength !== undefined && + (typeof declaredLength !== 'string' || !/^\d+$/.test(declaredLength)) + ) { + logEvent(requestId, 'invalid_request', 400); + sendError(res, requestId, 'invalid_request'); + return; + } + + if (declaredLength !== undefined && Number(declaredLength) > MAX_REQUEST_BODY_BYTES) { + logEvent(requestId, 'payload_too_large', 413); + sendError(res, requestId, 'payload_too_large'); + return; + } + + const rawBody = await readRawBody(req, MAX_REQUEST_BODY_BYTES); + if (rawBody.kind === 'too_large') { + logEvent(requestId, 'payload_too_large', 413); + res.setHeader('Connection', 'close'); + sendError(res, requestId, 'payload_too_large'); + return; + } + if (rawBody.kind === 'error') { + logEvent(requestId, 'invalid_request', 400); + sendError(res, requestId, 'invalid_request'); + return; + } + + let parsedBody: unknown; + try { + parsedBody = JSON.parse(rawBody.text); + } catch { + logEvent(requestId, 'invalid_request', 400); + sendError(res, requestId, 'invalid_request'); + return; + } + + const body = isSubscribeBody(parsedBody) ? parsedBody : {}; + const rawEmail = typeof body.email === 'string' ? body.email.trim().toLowerCase() : ''; + const tag = + body.tag === undefined ? 'newsletter' : typeof body.tag === 'string' ? body.tag.trim() : ''; + + if (!rawEmail || !EMAIL_RE.test(rawEmail)) { + logEvent(requestId, 'invalid_email', 422); + sendError(res, requestId, 'invalid_email'); + return; + } + + if (!SUPPORTED_TAGS.has(tag)) { + logEvent(requestId, 'invalid_tag', 422); + sendError(res, requestId, 'invalid_tag'); + return; + } + + const retryAfter = checkRateLimit(resolveClientKey(req)); + if (retryAfter !== null) { + logEvent(requestId, 'rate_limited', 429); + res.setHeader('Retry-After', String(retryAfter)); + sendJson(res, requestId, 429, { + error: ERROR_MESSAGE.rate_limited, + code: 'rate_limited', + retryAfter, + }); + return; + } + + // Deduplicate on email plus tag: concurrent requests for the same address + // with different tags are distinct intents and must each reach the provider. + // The email regex rejects whitespace, so a newline separator cannot collide. + const submissionKey = `${rawEmail}\n${tag}`; + let submission = inFlightSubmissions.get(submissionKey); + if (!submission) { + submission = submitToProvider(rawEmail, tag, apiKey, requestId); + inFlightSubmissions.set(submissionKey, submission); + const clearSubmission = () => { + if (inFlightSubmissions.get(submissionKey) === submission) { + inFlightSubmissions.delete(submissionKey); + } + }; + void submission.then(clearSubmission, clearSubmission); + } + + const result = await submission; + sendJson(res, requestId, result.status, result.payload); +} diff --git a/public/feed.xml b/public/feed.xml index d00b5e3..620905d 100644 --- a/public/feed.xml +++ b/public/feed.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Sun, 04 Oct 2026 01:17:44 GMT + Mon, 19 Oct 2026 15:57:04 GMT diff --git a/public/feed/tag/announcements.xml b/public/feed/tag/announcements.xml index d51b432..10821fb 100644 --- a/public/feed/tag/announcements.xml +++ b/public/feed/tag/announcements.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/announcements Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Sun, 04 Oct 2026 01:17:44 GMT + Mon, 19 Oct 2026 15:57:04 GMT diff --git a/public/feed/tag/cryptography.xml b/public/feed/tag/cryptography.xml index 4881df4..6eabacf 100644 --- a/public/feed/tag/cryptography.xml +++ b/public/feed/tag/cryptography.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/cryptography Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Sun, 04 Oct 2026 01:17:44 GMT + Mon, 19 Oct 2026 15:57:04 GMT diff --git a/public/feed/tag/privacy.xml b/public/feed/tag/privacy.xml index c9a22ef..02bd2bf 100644 --- a/public/feed/tag/privacy.xml +++ b/public/feed/tag/privacy.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/privacy Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Sun, 04 Oct 2026 01:17:44 GMT + Mon, 19 Oct 2026 15:57:04 GMT diff --git a/public/feed/tag/sdk.xml b/public/feed/tag/sdk.xml index 32680ad..3cf4d29 100644 --- a/public/feed/tag/sdk.xml +++ b/public/feed/tag/sdk.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/sdk Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Sun, 04 Oct 2026 01:17:44 GMT + Mon, 19 Oct 2026 15:57:04 GMT diff --git a/public/feed/tag/stealth-payments.xml b/public/feed/tag/stealth-payments.xml index 9cd900c..d65cb77 100644 --- a/public/feed/tag/stealth-payments.xml +++ b/public/feed/tag/stealth-payments.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/stealth-payments Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Sun, 04 Oct 2026 01:17:44 GMT + Mon, 19 Oct 2026 15:57:04 GMT diff --git a/public/feed/tag/wave-6.xml b/public/feed/tag/wave-6.xml index 56f9bee..c39e2ce 100644 --- a/public/feed/tag/wave-6.xml +++ b/public/feed/tag/wave-6.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/wave-6 Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Sun, 04 Oct 2026 01:17:44 GMT + Mon, 19 Oct 2026 15:57:04 GMT diff --git a/public/feed/tag/wave-7.xml b/public/feed/tag/wave-7.xml index dc3c517..8caf923 100644 --- a/public/feed/tag/wave-7.xml +++ b/public/feed/tag/wave-7.xml @@ -5,7 +5,7 @@ https://usewraith.xyz/blog/tag/wave-7 Notes on stealth payments, private infrastructure, and the Wraith ecosystem. en-us - Sun, 04 Oct 2026 01:17:44 GMT + Mon, 19 Oct 2026 15:57:04 GMT diff --git a/public/sitemap.xml b/public/sitemap.xml index 94e8577..5711950 100644 --- a/public/sitemap.xml +++ b/public/sitemap.xml @@ -2,205 +2,211 @@ https://usewraith.xyz - 2026-10-04 + 2026-10-19 daily 1.0 https://usewraith.xyz/faq - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/privacy - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/use-cases - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/use-cases/calculator - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/roadmap - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/case-studies - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/stellar - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/careers - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/press - 2026-10-04 + 2026-10-19 + weekly + 0.8 + + + https://usewraith.xyz/terms + 2026-10-19 weekly 0.8 https://usewraith.xyz/case-studies/payroll-processor - 2026-10-04 + 2026-10-19 weekly 0.7 https://usewraith.xyz/blog/author/lena-vogt - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/tag/privacy - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/tag/stealth-payments - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/tag/cryptography - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/tag/announcements - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/tag/wave-7 - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/tag/wave-6 - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/tag/sdk - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/about - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/privacy-by-default - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/stealth-addresses-explained - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/blog/wave-7-kickoff - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/chains - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/contributors - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/ecosystem - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/governance - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/grants - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/newsletter - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/security - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/status - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/threat-model - 2026-10-04 + 2026-10-19 weekly 0.8 https://usewraith.xyz/vitals - 2026-10-04 + 2026-10-19 weekly 0.8 diff --git a/scripts/preview-server.ts b/scripts/preview-server.ts index a444627..360413a 100644 --- a/scripts/preview-server.ts +++ b/scripts/preview-server.ts @@ -37,11 +37,7 @@ import { createServer, type IncomingMessage, type ServerResponse } from 'node:ht import { readFile } from 'node:fs/promises'; import { dirname, extname, join, normalize, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { - PAGE_SECURITY_HEADERS, - SUBSCRIBE_API_SECURITY_HEADERS, - resolveStaticRouteHeaders, -} from '../src/utils/security-headers.ts'; +import { PAGE_SECURITY_HEADERS, resolveStaticRouteHeaders } from '../src/utils/security-headers.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const rootDir = join(__dirname, '..'); @@ -237,29 +233,9 @@ async function serveStatic(req: IncomingMessage, res: ServerResponse): Promise { - const chunks: Buffer[] = []; - for await (const chunk of req) { - chunks.push(chunk as Buffer); - } - return Buffer.concat(chunks).toString('utf8'); -} - async function handleSubscribe(req: IncomingMessage, res: ServerResponse): Promise { - const raw = await readRequestBody(req); - if (raw.length > 0) { - try { - // Vercel parses JSON request bodies before invoking the function. - (req as IncomingMessage & { body?: unknown }).body = JSON.parse(raw); - } catch { - res.statusCode = 400; - applyHeaders(res, SUBSCRIBE_API_SECURITY_HEADERS); - res.setHeader('Content-Type', 'application/json; charset=utf-8'); - res.end(JSON.stringify({ error: 'Invalid JSON body' })); - return; - } - } - + // The handler reads and size-limits the raw stream itself, so the request is + // passed through unread. Pre-buffering here would hide the limit from tests. const { default: subscribe } = await import('../api/subscribe.ts'); await subscribe(req as Parameters[0], res); } diff --git a/src/__tests__/newsletter.test.tsx b/src/__tests__/newsletter.test.tsx index d843712..0e9f35e 100644 --- a/src/__tests__/newsletter.test.tsx +++ b/src/__tests__/newsletter.test.tsx @@ -116,11 +116,11 @@ describe('Newsletter page', () => { expect(screen.queryByRole('button', { name: /newsletter\.submit/i })).not.toBeInTheDocument(); }); - it('shows an "already subscribed" error on 409 response', async () => { + it('shows the same success state for an address already subscribed', async () => { vi.mocked(fetch).mockResolvedValueOnce({ - ok: false, - status: 409, - json: async () => ({ error: 'already_subscribed' }), + ok: true, + status: 201, + json: async () => ({ ok: true }), } as Response); const user = userEvent.setup(); @@ -130,7 +130,7 @@ describe('Newsletter page', () => { await user.type(emailInput, 'existing@example.com'); await user.click(screen.getByRole('button', { name: /newsletter\.submit/i })); - expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect(await screen.findByRole('status')).toBeInTheDocument(); }); it('shows a generic error on 500 response', async () => { diff --git a/src/i18n/en.json b/src/i18n/en.json index 425d1a7..35bbaf6 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -645,7 +645,6 @@ "successBody": "We've sent a confirmation email. Click the link inside to complete your signup.", "errorGeneric": "Something went wrong. Please try again.", "errorInvalidEmail": "Please enter a valid email address.", - "errorAlreadySubscribed": "This email is already subscribed.", "footerWidget": { "label": "NEWSLETTER", "placeholder": "your@email.com", diff --git a/src/i18n/es.json b/src/i18n/es.json index d2f4086..a090643 100644 --- a/src/i18n/es.json +++ b/src/i18n/es.json @@ -645,7 +645,6 @@ "successBody": "Enviamos un correo de confirmación. Haz clic en el enlace para completar tu suscripción.", "errorGeneric": "Algo salió mal. Intenta de nuevo.", "errorInvalidEmail": "Ingresa un correo electrónico válido.", - "errorAlreadySubscribed": "Este correo ya está suscrito.", "footerWidget": { "label": "BOLETÍN", "placeholder": "tu@email.com", diff --git a/src/i18n/pt.json b/src/i18n/pt.json index 3a034da..ba916e5 100644 --- a/src/i18n/pt.json +++ b/src/i18n/pt.json @@ -645,7 +645,6 @@ "successBody": "Enviamos um e-mail de confirmação. Clique no link dentro para concluir sua inscrição.", "errorGeneric": "Algo deu errado. Por favor, tente novamente.", "errorInvalidEmail": "Por favor, insira um endereço de e-mail válido.", - "errorAlreadySubscribed": "Este e-mail já está inscrito.", "footerWidget": { "label": "BOLETIM", "placeholder": "seu@email.com", diff --git a/src/pages/Newsletter.tsx b/src/pages/Newsletter.tsx index 8802c6f..43d827b 100644 --- a/src/pages/Newsletter.tsx +++ b/src/pages/Newsletter.tsx @@ -6,13 +6,7 @@ import { track } from '../utils/track'; // ─── Types ──────────────────────────────────────────────────────────────────── -type FormState = - | 'idle' - | 'submitting' - | 'success' - | 'error_invalid' - | 'error_duplicate' - | 'error_generic'; +type FormState = 'idle' | 'submitting' | 'success' | 'error_invalid' | 'error_generic'; // ─── Constants ──────────────────────────────────────────────────────────────── @@ -32,8 +26,6 @@ export default function Newsletter() { switch (formState) { case 'error_invalid': return t('newsletter.errorInvalidEmail'); - case 'error_duplicate': - return t('newsletter.errorAlreadySubscribed'); case 'error_generic': return t('newsletter.errorGeneric'); default: @@ -63,10 +55,8 @@ export default function Newsletter() { body: JSON.stringify({ email: trimmed }), }); - // 201 = subscription accepted by the backend and a double opt-in - // confirmation email has been queued by Buttondown. This is the successful - // submission conversion. (The actual email-confirmation step is handled by - // Buttondown's email flow, which is outside this SPA — see newsletter_confirm.) + // The backend returns the same success response for new and existing + // addresses so list membership is not disclosed. if (res.status === 201) { submittedRef.current = true; track('newsletter_submit', { source: 'newsletter-page' }); @@ -74,11 +64,6 @@ export default function Newsletter() { return; } - if (res.status === 409) { - setFormState('error_duplicate'); - return; - } - if (res.status === 422) { setFormState('error_invalid'); return; diff --git a/tests/smoke/serverless.smoke.test.ts b/tests/smoke/serverless.smoke.test.ts index 2fbbe49..0b80acb 100644 --- a/tests/smoke/serverless.smoke.test.ts +++ b/tests/smoke/serverless.smoke.test.ts @@ -92,10 +92,22 @@ function get(url: string): Promise { return fetch(url, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) }); } -function postJson(url: string, body: unknown): Promise { +let nextSmokeClientIp = 1; + +function postJson( + url: string, + body: unknown, + headers: Record = {}, +): Promise { + const clientIp = `198.51.100.${nextSmokeClientIp}`; + nextSmokeClientIp = (nextSmokeClientIp % 200) + 1; return fetch(url, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { + 'Content-Type': 'application/json', + 'x-vercel-forwarded-for': clientIp, + ...headers, + }, body: JSON.stringify(body), signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), }); @@ -198,6 +210,38 @@ describe('POST /api/subscribe', () => { expectCommonSecurityHeaders(response, SUBSCRIBE_API_SECURITY_HEADERS); }); + it('rejects a chunked body padded past the raw byte limit without calling the provider', async () => { + const before = (await recordedUpstreamCalls()).length; + const encoder = new TextEncoder(); + const parts = [ + ' '.repeat(2048), + ' '.repeat(2048), + JSON.stringify({ email: 'padded@example.com' }), + ]; + const body = new ReadableStream({ + start(controller) { + for (const part of parts) controller.enqueue(encoder.encode(part)); + controller.close(); + }, + }); + + const response = await fetch(subscribeUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-vercel-forwarded-for': '198.51.100.250', + }, + body, + duplex: 'half', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + } as RequestInit & { duplex: 'half' }); + + expect(response.status).toBe(413); + expect(await response.json()).toMatchObject({ code: 'payload_too_large' }); + expectCommonSecurityHeaders(response, SUBSCRIBE_API_SECURITY_HEADERS); + expect((await recordedUpstreamCalls()).length).toBe(before); + }); + it('answers 405 for non-POST methods', async () => { for (const method of ['GET', 'PUT', 'DELETE']) { const response = await fetch(subscribeUrl, { method }); @@ -210,22 +254,61 @@ describe('POST /api/subscribe', () => { } }); - it('maps provider conflicts to already_subscribed', async () => { + it('returns the same success response for addresses already subscribed', async () => { const conflict = await postJson(subscribeUrl, { email: 'dupe@stub-conflict.example' }); - expect(conflict.status).toBe(409); - expect(await conflict.json()).toEqual({ - error: 'This email is already subscribed.', - code: 'already_subscribed', - }); + expect(conflict.status).toBe(201); + expect(await conflict.json()).toEqual({ ok: true }); const duplicateCode = await postJson(subscribeUrl, { email: 'dupe@stub-duplicate-code.example', }); - expect(duplicateCode.status).toBe(409); - expect(await duplicateCode.json()).toEqual({ - error: 'This email is already subscribed.', - code: 'already_subscribed', + expect(duplicateCode.status).toBe(201); + expect(await duplicateCode.json()).toEqual({ ok: true }); + }); + + it('enforces the client request limit with Retry-After', async () => { + const headers = { 'x-vercel-forwarded-for': '198.51.100.240' }; + + for (let attempt = 0; attempt < 5; attempt += 1) { + const response = await postJson( + subscribeUrl, + { email: `limited-${attempt}@example.com` }, + headers, + ); + expect(response.status).toBe(201); + } + + const limited = await postJson(subscribeUrl, { email: 'beyond-limit@example.com' }, headers); + + expect(limited.status).toBe(429); + const retryAfter = Number(limited.headers.get('retry-after')); + expect(retryAfter).toBeGreaterThan(0); + expect(retryAfter).toBeLessThanOrEqual(900); + expect(await limited.json()).toEqual({ + error: 'Too many requests. Please try again later.', + code: 'rate_limited', + retryAfter, + }); + }); + + it('rejects unsupported content types and tags', async () => { + const before = (await recordedUpstreamCalls()).length; + const unsupportedType = await fetch(subscribeUrl, { + method: 'POST', + headers: { 'Content-Type': 'text/plain' }, + body: JSON.stringify({ email: 'reader@example.com' }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + expect(unsupportedType.status).toBe(415); + expect((await unsupportedType.json()).code).toBe('unsupported_media_type'); + + const unsupportedTag = await postJson(subscribeUrl, { + email: 'reader@example.com', + tag: 'unapproved', }); + expect(unsupportedTag.status).toBe(422); + expect((await unsupportedTag.json()).code).toBe('invalid_tag'); + expect((await recordedUpstreamCalls()).length).toBe(before); }); it('maps provider outages and network failures to 502', async () => { diff --git a/tests/subscribe.test.ts b/tests/subscribe.test.ts index 741dd23..6a50785 100644 --- a/tests/subscribe.test.ts +++ b/tests/subscribe.test.ts @@ -1,14 +1,26 @@ // Unit tests for the newsletter proxy. Lives outside `src` because the Vercel // function in `api/` is intentionally excluded from the site's tsc/Vite build. +import { Readable } from 'node:stream'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import handler, { BUTTONDOWN_API_URL, UPSTREAM_TIMEOUT_MS } from '../api/subscribe'; +import handler, { + BUTTONDOWN_API_URL, + MAX_REQUEST_BODY_BYTES, + RATE_LIMIT_REQUESTS, + RATE_LIMIT_WINDOW_MS, + UPSTREAM_TIMEOUT_MS, +} from '../api/subscribe'; // ─── Harness ───────────────────────────────────────────────────────────────── type HandlerRequest = Parameters[0]; type HandlerResponse = Parameters[1]; -type SubscribeResponseBody = { error?: string; code?: string; ok?: boolean }; +type SubscribeResponseBody = { + error?: string; + code?: string; + ok?: boolean; + retryAfter?: number; +}; interface CapturedResponse { statusCode: number; @@ -36,12 +48,48 @@ function createResponse(): CapturedResponse { }; } -function createRequest(body: unknown, method = 'POST'): HandlerRequest { - return { method, headers: {}, body } as unknown as HandlerRequest; +let requestIp = 0; + +function createRequest( + body: unknown, + method = 'POST', + headers: Record = {}, +): HandlerRequest { + requestIp = (requestIp % 200) + 1; + const raw = typeof body === 'string' ? body : JSON.stringify(body); + return createStreamRequest([raw], method, { + 'x-vercel-forwarded-for': `198.51.100.${requestIp}`, + ...headers, + }); } -async function invoke(body: unknown, method = 'POST', response = createResponse()) { - await handler(createRequest(body, method), response as unknown as HandlerResponse); +/** + * Builds a request whose body arrives as the given raw chunks with no + * Content-Length, like a chunked transfer-encoded upload. + */ +function createStreamRequest( + chunks: Iterable, + method = 'POST', + headers: Record = {}, +): HandlerRequest { + const stream = Readable.from( + (function* () { + for (const chunk of chunks) yield Buffer.from(chunk); + })(), + ); + return Object.assign(stream, { + method, + headers: { 'content-type': 'application/json', ...headers }, + }) as unknown as HandlerRequest; +} + +async function invoke( + body: unknown, + method = 'POST', + response = createResponse(), + headers: Record = {}, +) { + await handler(createRequest(body, method, headers), response as unknown as HandlerResponse); return response; } @@ -109,6 +157,93 @@ describe('subscribe proxy — request validation', () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it('rejects unsupported content types', async () => { + const response = await invoke({ email: VALID_EMAIL }, 'POST', createResponse(), { + 'content-type': 'text/plain', + }); + + expect(response.statusCode).toBe(415); + expect(response.json()).toMatchObject({ code: 'unsupported_media_type' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects bodies over the configured limit', async () => { + const declaredOversize = await invoke({ email: VALID_EMAIL }, 'POST', createResponse(), { + 'content-length': String(MAX_REQUEST_BODY_BYTES + 1), + }); + const actualOversize = await invoke({ + email: `${'a'.repeat(MAX_REQUEST_BODY_BYTES)}@example.com`, + }); + + expect(declaredOversize.statusCode).toBe(413); + expect(declaredOversize.json()).toMatchObject({ code: 'payload_too_large' }); + expect(actualOversize.statusCode).toBe(413); + expect(actualOversize.json()).toMatchObject({ code: 'payload_too_large' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects chunked whitespace padding past the raw byte limit without calling the provider', async () => { + fetchMock.mockResolvedValue(providerResponse(201)); + const padding = ' '.repeat(4096); + const payload = JSON.stringify({ email: VALID_EMAIL }); + const response = createResponse(); + + await handler( + createStreamRequest([padding.slice(0, 2048), padding.slice(2048), payload]), + response as unknown as HandlerResponse, + ); + + expect(response.statusCode).toBe(413); + expect(response.json()).toMatchObject({ code: 'payload_too_large' }); + expect(response.headers['connection']).toBe('close'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('stops reading an unbounded chunked body once the limit is crossed', async () => { + let chunksPulled = 0; + const endless = (function* () { + for (;;) { + chunksPulled += 1; + yield ' '.repeat(256); + } + })(); + const response = createResponse(); + + await handler(createStreamRequest(endless), response as unknown as HandlerResponse); + + expect(response.statusCode).toBe(413); + expect(chunksPulled).toBeLessThan(64); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('accepts a whitespace-padded body that stays within the raw byte limit', async () => { + fetchMock.mockResolvedValue(providerResponse(201)); + const payload = JSON.stringify({ email: VALID_EMAIL }); + const padding = ' '.repeat(MAX_REQUEST_BODY_BYTES - payload.length); + const response = createResponse(); + + await handler(createStreamRequest([padding, payload]), response as unknown as HandlerResponse); + + expect(response.statusCode).toBe(201); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('rejects a malformed JSON body with 400', async () => { + const response = await invoke('{ not json'); + + expect(response.statusCode).toBe(400); + expect(response.json()).toMatchObject({ code: 'invalid_request' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it.each(['admin', 'newsletter,careers', 42])('rejects unsupported tags (%s)', async (tag) => { + const response = await invoke({ email: VALID_EMAIL, tag }); + + expect(response.statusCode).toBe(422); + expect(response.json()).toMatchObject({ code: 'invalid_tag' }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + it('exposes a request id and security headers on every response', async () => { fetchMock.mockResolvedValue(providerResponse(201)); @@ -147,27 +282,127 @@ describe('subscribe proxy — success', () => { }); }); +describe('subscribe proxy — abuse protection', () => { + it('deduplicates concurrent submissions for the same normalized email', async () => { + let resolveProvider!: (response: Response) => void; + fetchMock.mockImplementation( + () => + new Promise((resolve) => { + resolveProvider = resolve; + }), + ); + + const first = invoke({ email: ' User@Example.com ' }, 'POST', createResponse(), { + 'x-vercel-forwarded-for': '198.51.100.220', + }); + const second = invoke({ email: VALID_EMAIL }, 'POST', createResponse(), { + 'x-vercel-forwarded-for': '198.51.100.221', + }); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(fetchMock).toHaveBeenCalledTimes(1); + resolveProvider(providerResponse(201)); + const responses = await Promise.all([first, second]); + + expect(responses.map(({ statusCode, body }) => [statusCode, body])).toEqual([ + [201, '{"ok":true}'], + [201, '{"ok":true}'], + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('does not collapse concurrent submissions for the same email with different tags', async () => { + const resolvers: Array<(response: Response) => void> = []; + fetchMock.mockImplementation( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + + const newsletter = invoke( + { email: ' User@Example.com ', tag: 'newsletter' }, + 'POST', + createResponse(), + { 'x-vercel-forwarded-for': '198.51.100.222' }, + ); + const careers = invoke({ email: VALID_EMAIL, tag: 'careers' }, 'POST', createResponse(), { + 'x-vercel-forwarded-for': '198.51.100.223', + }); + + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + for (const resolve of resolvers) resolve(providerResponse(201)); + const responses = await Promise.all([newsletter, careers]); + + expect(responses.map(({ statusCode }) => statusCode)).toEqual([201, 201]); + const forwarded = fetchMock.mock.calls.map(([, init]) => JSON.parse(init?.body as string)); + expect(forwarded).toEqual( + expect.arrayContaining([ + { email: VALID_EMAIL, tags: ['newsletter'], type: 'unconfirmed' }, + { email: VALID_EMAIL, tags: ['careers'], type: 'unconfirmed' }, + ]), + ); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it('rate limits by the trusted Vercel IP and returns stable retry information', async () => { + fetchMock.mockResolvedValue(providerResponse(201)); + const trustedIp = '198.51.100.230'; + + for (let attempt = 0; attempt < RATE_LIMIT_REQUESTS; attempt += 1) { + const response = await invoke( + { email: `reader-${attempt}@example.com` }, + 'POST', + createResponse(), + { + 'x-vercel-forwarded-for': trustedIp, + 'x-forwarded-for': `203.0.113.${attempt + 1}`, + }, + ); + expect(response.statusCode).toBe(201); + } + + const limited = await invoke({ email: 'another@example.com' }, 'POST', createResponse(), { + 'x-vercel-forwarded-for': trustedIp, + 'x-forwarded-for': '203.0.113.99', + }); + + expect(limited.statusCode).toBe(429); + const retryAfter = Number(limited.headers['retry-after']); + expect(retryAfter).toBeGreaterThan(0); + expect(retryAfter).toBeLessThanOrEqual(RATE_LIMIT_WINDOW_MS / 1000); + expect(limited.json()).toEqual({ + error: 'Too many requests. Please try again later.', + code: 'rate_limited', + retryAfter, + }); + expect(fetchMock).toHaveBeenCalledTimes(RATE_LIMIT_REQUESTS); + expect(limited.body).not.toContain('subscribed'); + }); +}); + // ─── Provider 4xx ──────────────────────────────────────────────────────────── describe('subscribe proxy — provider 4xx', () => { - it('maps a 409 to already_subscribed', async () => { + it('returns the same success response for addresses already on the list', async () => { fetchMock.mockResolvedValue(providerResponse(409)); const response = await invoke({ email: VALID_EMAIL }); - expect(response.statusCode).toBe(409); - expect(response.json()).toMatchObject({ code: 'already_subscribed' }); + expect(response.statusCode).toBe(201); + expect(response.json()).toEqual({ ok: true }); }); it.each(['email_already_exists', 'subscriber_already_exists'])( - 'maps a 400 with code %s to already_subscribed', + 'does not reveal an existing address from provider code %s', async (code) => { fetchMock.mockResolvedValue(providerResponse(400, { code })); const response = await invoke({ email: VALID_EMAIL }); - expect(response.statusCode).toBe(409); - expect(response.json()).toMatchObject({ code: 'already_subscribed' }); + expect(response.statusCode).toBe(201); + expect(response.json()).toEqual({ ok: true }); }, );