diff --git a/server/src/common/types/fastify-cookie.d.ts b/server/src/common/types/fastify-cookie.d.ts new file mode 100644 index 000000000..16bfda76c --- /dev/null +++ b/server/src/common/types/fastify-cookie.d.ts @@ -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; + } +} + +declare module 'fastify/types/reply' { + interface FastifyReply { + setCookie(name: string, value: string, options?: fastifyCookie.CookieSerializeOptions): this; + } +} diff --git a/server/src/common/utils/bootstrap.utils.test.ts b/server/src/common/utils/bootstrap.utils.test.ts index 37d71c45b..391d4fb38 100644 --- a/server/src/common/utils/bootstrap.utils.test.ts +++ b/server/src/common/utils/bootstrap.utils.test.ts @@ -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', () => { diff --git a/server/src/common/utils/bootstrap.utils.ts b/server/src/common/utils/bootstrap.utils.ts index aba5c6c2f..f2d58c4a9 100644 --- a/server/src/common/utils/bootstrap.utils.ts +++ b/server/src/common/utils/bootstrap.utils.ts @@ -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; } diff --git a/server/src/config/env.validation.test.ts b/server/src/config/env.validation.test.ts index da2e7d90f..b06259e01 100644 --- a/server/src/config/env.validation.test.ts +++ b/server/src/config/env.validation.test.ts @@ -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({ diff --git a/server/src/config/env.validation.ts b/server/src/config/env.validation.ts index d62db42a9..dd8323060 100644 --- a/server/src/config/env.validation.ts +++ b/server/src/config/env.validation.ts @@ -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 diff --git a/server/src/main.ts b/server/src/main.ts index f3c6fa78c..2d5ca3be3 100644 --- a/server/src/main.ts +++ b/server/src/main.ts @@ -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'] });