Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
11 changes: 10 additions & 1 deletion server/src/app.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -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.',
Expand Down
78 changes: 73 additions & 5 deletions server/test/contact.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];
Expand All @@ -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;
Expand All @@ -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.',
Expand All @@ -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;
Expand All @@ -171,6 +187,7 @@ test(
assert.deepEqual(diagnostics, [
{ category: 'provider-response', providerStatus: 500 },
{ category: 'timeout' },
{ category: 'network' },
]);
},
);
Expand All @@ -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 },
Expand Down Expand Up @@ -235,8 +297,9 @@ test(
async function withApp(
emailSender: ContactEmailSender,
run: (baseUrl: string) => Promise<void>,
contactRateLimit?: number,
): Promise<void> {
const app = createApp({ contactEmailSender: emailSender });
const app = createApp({ contactEmailSender: emailSender, contactRateLimit });
const server = app.listen(0, '127.0.0.1');
await once(server, 'listening');

Expand All @@ -254,7 +317,11 @@ interface TestResponse {
json(): Promise<unknown>;
}

function postContact(baseUrl: string, body: object): Promise<TestResponse> {
function postContact(
baseUrl: string,
body: object,
headers: Record<string, string> = {},
): Promise<TestResponse> {
const requestBody = JSON.stringify(body);

return new Promise((resolve, reject) => {
Expand All @@ -264,6 +331,7 @@ function postContact(baseUrl: string, body: object): Promise<TestResponse> {
'content-length': Buffer.byteLength(requestBody),
'content-type': 'application/json',
origin: 'http://localhost:4200',
...headers,
},
});
const chunks: Buffer[] = [];
Expand Down