fix(server): support Fastify 5.12.1 proxy rules

Reject numeric trust-proxy values that Fastify no longer honors and retain explicit boolean or address-based configuration. Bridge Nest and cookie type augmentation differences so the pre-push typecheck succeeds under TypeScript 6.
This commit is contained in:
neon
2026-08-30 06:13:31 +00:00
parent 3196454944
commit 5de4b9206b
6 changed files with 53 additions and 25 deletions
+14
View File
@@ -0,0 +1,14 @@
import type fastifyCookie from '@fastify/cookie';
// TypeScript 6 does not merge the plugin's package-level augmentation into Fastify's source interfaces.
declare module 'fastify/types/request' {
interface FastifyRequest {
cookies: Record<string, string | undefined>;
}
}
declare module 'fastify/types/reply' {
interface FastifyReply {
setCookie(name: string, value: string, options?: fastifyCookie.CookieSerializeOptions): this;
}
}
@@ -98,35 +98,22 @@ describe('parseTrustProxy', () => {
expect(parseTrustProxy(' ')).toBe('loopback,linklocal,uniquelocal');
});
it.each(['false', '0', 'no', 'off'])('returns false for falsy string %s', (val) => {
it.each(['false', 'no', 'off'])('returns false for falsy string %s', (val) => {
expect(parseTrustProxy(val)).toBe(false);
});
it.each(['true', '1', 'yes', 'on'])('returns true for truthy string %s', (val) => {
it.each(['true', 'yes', 'on'])('returns true for truthy string %s', (val) => {
expect(parseTrustProxy(val)).toBe(true);
});
it('parses non-negative integer hop counts', () => {
expect(parseTrustProxy('0')).toBe(false); // '0' is a falsy string, handled before hop-count check
expect(parseTrustProxy('1')).toBe(true); // '1' is a truthy string
expect(parseTrustProxy('2')).toBe(2);
expect(parseTrustProxy('10')).toBe(10);
});
it('returns the raw string for arbitrary proxy values', () => {
expect(parseTrustProxy('127.0.0.1')).toBe('127.0.0.1');
expect(parseTrustProxy('10.0.0.0/8')).toBe('10.0.0.0/8');
expect(parseTrustProxy('loopback')).toBe('loopback');
});
it('returns the raw string for decimal numbers (not valid integers)', () => {
expect(parseTrustProxy('1.5')).toBe('1.5');
expect(parseTrustProxy('2.9')).toBe('2.9');
});
it('returns the raw string for negative integers', () => {
expect(parseTrustProxy('-1')).toBe('-1');
expect(parseTrustProxy('-5')).toBe('-5');
it.each(['0', '1', '2', '10', '1.5', '2.9', '-1', '-5', '1e2'])('rejects numeric hop-count value %s', (value) => {
expect(() => parseTrustProxy(value)).toThrow('Numeric TRUST_PROXY hop counts are not supported; configure trusted proxy IP or CIDR ranges');
});
it('trims surrounding whitespace before evaluating', () => {
+6 -5
View File
@@ -10,16 +10,17 @@ const BODY_METHODS = new Set(['DELETE', 'PATCH', 'POST', 'PUT']);
const HSTS_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
const HSTS_HEADER_VALUE = `max-age=${HSTS_MAX_AGE_SECONDS}; includeSubDomains`;
export function parseTrustProxy(value: string | undefined): string | boolean | number {
export function parseTrustProxy(value: string | undefined): string | boolean {
const raw = value?.trim();
if (!raw) return DEFAULT_TRUST_PROXY;
const normalized = raw.toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
if (['false', 'no', 'off'].includes(normalized)) return false;
if (['true', 'yes', 'on'].includes(normalized)) return true;
const hopCount = Number(raw);
if (Number.isInteger(hopCount) && hopCount >= 0) return hopCount;
if (!Number.isNaN(Number(raw))) {
throw new Error('Numeric TRUST_PROXY hop counts are not supported; configure trusted proxy IP or CIDR ranges');
}
return raw;
}
+12
View File
@@ -99,6 +99,18 @@ describe('validateEnv', () => {
).toThrow('SWAGGER_ENABLED must be one of true/false/1/0/yes/no/on/off');
});
it('accepts explicit trusted proxy addresses and boolean values', () => {
for (const TRUST_PROXY of ['', 'true', 'false', 'yes', 'no', 'on', 'off', 'loopback,linklocal,uniquelocal', '127.0.0.1', '10.0.0.0/8']) {
expect(() => validateEnv({ ...BASE_ENV, TRUST_PROXY })).not.toThrow();
}
});
it.each(['0', '1', '2', '10', '1.5', '-1', '1e2'])('rejects numeric TRUST_PROXY hop count %s', (TRUST_PROXY) => {
expect(() => validateEnv({ ...BASE_ENV, TRUST_PROXY })).toThrow(
'TRUST_PROXY must be a boolean value or trusted proxy IP/CIDR; numeric hop counts are not supported',
);
});
it('accepts a custom Book Dock container path', () => {
expect(() =>
validateEnv({
+12 -1
View File
@@ -3,6 +3,7 @@ import { isAbsolute } from 'node:path';
import { z } from 'zod';
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
const TRUST_PROXY_BOOLEAN_VALUES = ['true', 'false', 'yes', 'no', 'on', 'off'];
function isValidPostgresConnectionString(value: string): boolean {
if (!value.trim()) {
@@ -32,6 +33,16 @@ function booleanEnvFlag(name: string) {
.optional();
}
function trustProxyEnv() {
return z
.string()
.trim()
.refine((value) => value === '' || TRUST_PROXY_BOOLEAN_VALUES.includes(value.toLowerCase()) || Number.isNaN(Number(value)), {
message: 'TRUST_PROXY must be a boolean value or trusted proxy IP/CIDR; numeric hop counts are not supported',
})
.optional();
}
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
DATABASE_URL: z.string().refine(isValidPostgresConnectionString, 'DATABASE_URL must be a valid PostgreSQL connection string').optional(),
@@ -58,7 +69,7 @@ const envSchema = z.object({
FILE_WRITE_MAX_CONCURRENT_WRITES: z.coerce.number().int().positive().optional(),
CLIENT_URL: z.string().url().optional(),
APP_URL: z.string().url().default('http://localhost:5173'),
TRUST_PROXY: z.string().optional(),
TRUST_PROXY: trustProxyEnv(),
EMAIL_ENCRYPTION_KEY: z.string().optional(),
MIGRATION_ENCRYPTION_KEY: z.string().optional(),
MIGRATION_IMPORT_ROOT: z
+5 -2
View File
@@ -12,6 +12,7 @@ import fastifyMultipart from '@fastify/multipart';
import fastifyStatic from '@fastify/static';
import fastifyHelmet from '@fastify/helmet';
import fastifyCompress from '@fastify/compress';
import type { FastifyInstance } from 'fastify';
import { appConfig } from './config/config';
import { setupSwaggerDocs } from './swagger';
import {
@@ -35,6 +36,8 @@ async function bootstrap() {
app.useLogger(app.get(Logger));
const fastify = adapter.getInstance();
// Nest adds originalUrl to the raw request, but these helpers only use standard Fastify APIs.
const standardFastify = fastify as unknown as FastifyInstance;
// Fastify's default JSON parser rejects empty bodies, so we inject '{}' before parsing.
fastify.addHook('preParsing', (request, _reply, payload, done) => {
@@ -45,7 +48,7 @@ async function bootstrap() {
done(null, payload);
});
// Reverse proxies can forward empty mutating requests with chunked transfer or an unsupported content type.
registerEmptyBodyContentTypeParser(fastify);
registerEmptyBodyContentTypeParser(standardFastify);
// Echo pino-http's request ID so clients can correlate errors with server logs.
fastify.addHook('onSend', (_request, reply, _payload, done) => {
@@ -78,7 +81,7 @@ async function bootstrap() {
}
await app.register(fastifyHelmet as never, buildHelmetOptions({ allowCloudflareInsights }));
registerConditionalHsts(fastify);
registerConditionalHsts(standardFastify);
await app.register(fastifyCompress as never, { encodings: ['gzip', 'br'] });