diff --git a/server/src/app.ts b/server/src/app.ts index 1b3efab..538f5d8 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -1,6 +1,6 @@ import cors from 'cors'; import express from 'express'; -import { rateLimit } from 'express-rate-limit'; +import { ipKeyGenerator, rateLimit } from 'express-rate-limit'; import helmet from 'helmet'; import { env } from './config/env.js'; @@ -15,6 +15,10 @@ interface CreateAppOptions { export function createApp(options: CreateAppOptions = {}): express.Express { const app = express(); + // The public backend endpoint can receive arbitrary forwarded headers. Keep them + // untrusted until Apply.Build provides an authoritative proxy trust configuration. + app.set('trust proxy', false); + app.use(helmet()); app.use(express.json({ limit: '10kb' })); app.use( @@ -45,6 +49,11 @@ export function createApp(options: CreateAppOptions = {}): express.Express { limit: options.contactRateLimit ?? 5, standardHeaders: 'draft-8', legacyHeaders: false, + keyGenerator: (request) => { + const peerAddress = request.socket.remoteAddress; + + return peerAddress ? ipKeyGenerator(peerAddress) : 'unknown-peer'; + }, message: { success: false, message: 'Too many requests. Please try again later.', diff --git a/server/test/contact.integration.test.ts b/server/test/contact.integration.test.ts index 35c4d99..d9d99c0 100644 --- a/server/test/contact.integration.test.ts +++ b/server/test/contact.integration.test.ts @@ -130,9 +130,15 @@ test( let attempt = 0; const emailSender: ContactEmailSender = async () => { attempt += 1; - throw attempt === 1 - ? new EmailDeliveryError('provider-response', 500) - : new EmailDeliveryError('timeout'); + if (attempt === 1) { + throw new EmailDeliveryError('provider-response', 500); + } + + if (attempt === 2) { + throw new EmailDeliveryError('timeout'); + } + + throw new EmailDeliveryError('network'); }; const originalConsoleError = console.error; const diagnostics: unknown[] = []; @@ -144,6 +150,7 @@ test( await withApp(emailSender, async (baseUrl) => { const providerResponse = await postContact(baseUrl, validContactRequest); const timeoutResponse = await postContact(baseUrl, validContactRequest); + const networkResponse = await postContact(baseUrl, validContactRequest); const providerBody = (await providerResponse.json()) as { success: boolean; message: string; @@ -152,9 +159,14 @@ test( success: boolean; message: string; }; + const networkBody = (await networkResponse.json()) as { + success: boolean; + message: string; + }; assert.equal(providerResponse.status, 502); assert.equal(timeoutResponse.status, 502); + assert.equal(networkResponse.status, 502); assert.deepEqual(providerBody, { success: false, message: 'Your message could not be sent. Please try again in a moment.', @@ -163,6 +175,10 @@ test( success: false, message: 'Your message could not be sent. Please try again in a moment.', }); + assert.deepEqual(networkBody, { + success: false, + message: 'Your message could not be sent. Please try again in a moment.', + }); }); } finally { console.error = originalConsoleError; @@ -171,6 +187,7 @@ test( assert.deepEqual(diagnostics, [ { category: 'provider-response', providerStatus: 500 }, { category: 'timeout' }, + { category: 'network' }, ]); }, ); @@ -194,6 +211,51 @@ test('limits repeated contact submissions', async () => { assert.equal(sendCount, 5); }); +test('ignores forwarded headers for rate-limit identity without disabling limiting', async () => { + let sendCount = 0; + const emailSender: ContactEmailSender = async () => { + sendCount += 1; + }; + + await withApp( + emailSender, + async (baseUrl) => { + for (const forwardedFor of ['203.0.113.10', '203.0.113.11']) { + const response = await postContact(baseUrl, validContactRequest, { + 'x-forwarded-for': forwardedFor, + }); + assert.equal(response.status, 200); + } + + const limitedResponse = await postContact(baseUrl, validContactRequest, { + 'x-forwarded-for': '203.0.113.12', + }); + assert.equal(limitedResponse.status, 429); + }, + 2, + ); + + assert.equal(sendCount, 2); +}); + +test('keeps the health endpoint independent from contact email delivery', async () => { + const emailSender: ContactEmailSender = async () => { + throw new EmailDeliveryError('network'); + }; + + await withApp(emailSender, async (baseUrl) => { + const healthResponse = await fetch(`${baseUrl}/api/health`); + assert.equal(healthResponse.status, 200); + assert.deepEqual(await healthResponse.json(), { + success: true, + message: 'Server is running.', + }); + + assert.equal((await postContact(baseUrl, validContactRequest)).status, 502); + assert.equal((await fetch(`${baseUrl}/api/health`)).status, 200); + }); +}); + test( 'aborts a stalled Brevo request after the configured timeout', { concurrency: false }, @@ -235,8 +297,9 @@ test( async function withApp( emailSender: ContactEmailSender, run: (baseUrl: string) => Promise, + contactRateLimit?: number, ): Promise { - const app = createApp({ contactEmailSender: emailSender }); + const app = createApp({ contactEmailSender: emailSender, contactRateLimit }); const server = app.listen(0, '127.0.0.1'); await once(server, 'listening'); @@ -254,7 +317,11 @@ interface TestResponse { json(): Promise; } -function postContact(baseUrl: string, body: object): Promise { +function postContact( + baseUrl: string, + body: object, + headers: Record = {}, +): Promise { const requestBody = JSON.stringify(body); return new Promise((resolve, reject) => { @@ -264,6 +331,7 @@ function postContact(baseUrl: string, body: object): Promise { 'content-length': Buffer.byteLength(requestBody), 'content-type': 'application/json', origin: 'http://localhost:4200', + ...headers, }, }); const chunks: Buffer[] = [];