mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
+14
@@ -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');
|
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);
|
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);
|
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', () => {
|
it('returns the raw string for arbitrary proxy values', () => {
|
||||||
expect(parseTrustProxy('127.0.0.1')).toBe('127.0.0.1');
|
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('10.0.0.0/8')).toBe('10.0.0.0/8');
|
||||||
expect(parseTrustProxy('loopback')).toBe('loopback');
|
expect(parseTrustProxy('loopback')).toBe('loopback');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the raw string for decimal numbers (not valid integers)', () => {
|
it.each(['0', '1', '2', '10', '1.5', '2.9', '-1', '-5', '1e2'])('rejects numeric hop-count value %s', (value) => {
|
||||||
expect(parseTrustProxy('1.5')).toBe('1.5');
|
expect(() => parseTrustProxy(value)).toThrow('Numeric TRUST_PROXY hop counts are not supported; configure trusted proxy IP or CIDR ranges');
|
||||||
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('trims surrounding whitespace before evaluating', () => {
|
it('trims surrounding whitespace before evaluating', () => {
|
||||||
|
|||||||
@@ -10,16 +10,17 @@ const BODY_METHODS = new Set(['DELETE', 'PATCH', 'POST', 'PUT']);
|
|||||||
const HSTS_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
const HSTS_MAX_AGE_SECONDS = 365 * 24 * 60 * 60;
|
||||||
const HSTS_HEADER_VALUE = `max-age=${HSTS_MAX_AGE_SECONDS}; includeSubDomains`;
|
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();
|
const raw = value?.trim();
|
||||||
if (!raw) return DEFAULT_TRUST_PROXY;
|
if (!raw) return DEFAULT_TRUST_PROXY;
|
||||||
|
|
||||||
const normalized = raw.toLowerCase();
|
const normalized = raw.toLowerCase();
|
||||||
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
if (['false', 'no', 'off'].includes(normalized)) return false;
|
||||||
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
if (['true', 'yes', 'on'].includes(normalized)) return true;
|
||||||
|
|
||||||
const hopCount = Number(raw);
|
if (!Number.isNaN(Number(raw))) {
|
||||||
if (Number.isInteger(hopCount) && hopCount >= 0) return hopCount;
|
throw new Error('Numeric TRUST_PROXY hop counts are not supported; configure trusted proxy IP or CIDR ranges');
|
||||||
|
}
|
||||||
|
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,18 @@ describe('validateEnv', () => {
|
|||||||
).toThrow('SWAGGER_ENABLED must be one of true/false/1/0/yes/no/on/off');
|
).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', () => {
|
it('accepts a custom Book Dock container path', () => {
|
||||||
expect(() =>
|
expect(() =>
|
||||||
validateEnv({
|
validateEnv({
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { isAbsolute } from 'node:path';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
|
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 {
|
function isValidPostgresConnectionString(value: string): boolean {
|
||||||
if (!value.trim()) {
|
if (!value.trim()) {
|
||||||
@@ -32,6 +33,16 @@ function booleanEnvFlag(name: string) {
|
|||||||
.optional();
|
.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({
|
const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
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(),
|
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(),
|
FILE_WRITE_MAX_CONCURRENT_WRITES: z.coerce.number().int().positive().optional(),
|
||||||
CLIENT_URL: z.string().url().optional(),
|
CLIENT_URL: z.string().url().optional(),
|
||||||
APP_URL: z.string().url().default('http://localhost:5173'),
|
APP_URL: z.string().url().default('http://localhost:5173'),
|
||||||
TRUST_PROXY: z.string().optional(),
|
TRUST_PROXY: trustProxyEnv(),
|
||||||
EMAIL_ENCRYPTION_KEY: z.string().optional(),
|
EMAIL_ENCRYPTION_KEY: z.string().optional(),
|
||||||
MIGRATION_ENCRYPTION_KEY: z.string().optional(),
|
MIGRATION_ENCRYPTION_KEY: z.string().optional(),
|
||||||
MIGRATION_IMPORT_ROOT: z
|
MIGRATION_IMPORT_ROOT: z
|
||||||
|
|||||||
+5
-2
@@ -12,6 +12,7 @@ import fastifyMultipart from '@fastify/multipart';
|
|||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import fastifyHelmet from '@fastify/helmet';
|
import fastifyHelmet from '@fastify/helmet';
|
||||||
import fastifyCompress from '@fastify/compress';
|
import fastifyCompress from '@fastify/compress';
|
||||||
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { appConfig } from './config/config';
|
import { appConfig } from './config/config';
|
||||||
import { setupSwaggerDocs } from './swagger';
|
import { setupSwaggerDocs } from './swagger';
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +36,8 @@ async function bootstrap() {
|
|||||||
app.useLogger(app.get(Logger));
|
app.useLogger(app.get(Logger));
|
||||||
|
|
||||||
const fastify = adapter.getInstance();
|
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's default JSON parser rejects empty bodies, so we inject '{}' before parsing.
|
||||||
fastify.addHook('preParsing', (request, _reply, payload, done) => {
|
fastify.addHook('preParsing', (request, _reply, payload, done) => {
|
||||||
@@ -45,7 +48,7 @@ async function bootstrap() {
|
|||||||
done(null, payload);
|
done(null, payload);
|
||||||
});
|
});
|
||||||
// Reverse proxies can forward empty mutating requests with chunked transfer or an unsupported content type.
|
// 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.
|
// Echo pino-http's request ID so clients can correlate errors with server logs.
|
||||||
fastify.addHook('onSend', (_request, reply, _payload, done) => {
|
fastify.addHook('onSend', (_request, reply, _payload, done) => {
|
||||||
@@ -78,7 +81,7 @@ async function bootstrap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await app.register(fastifyHelmet as never, buildHelmetOptions({ allowCloudflareInsights }));
|
await app.register(fastifyHelmet as never, buildHelmetOptions({ allowCloudflareInsights }));
|
||||||
registerConditionalHsts(fastify);
|
registerConditionalHsts(standardFastify);
|
||||||
|
|
||||||
await app.register(fastifyCompress as never, { encodings: ['gzip', 'br'] });
|
await app.register(fastifyCompress as never, { encodings: ['gzip', 'br'] });
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user