import { UnsupportedMediaTypeException } from '@nestjs/common'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import type { IncomingHttpHeaders } from 'http'; import { Readable } from 'stream'; const DEFAULT_TRUST_PROXY = 'loopback,linklocal,uniquelocal'; const EMPTY_JSON_BODY = '{}'; const BODY_METHODS = new Set(['DELETE', 'PATCH', 'POST', 'PUT']); // Matches @fastify/helmet's default Strict-Transport-Security max-age (1 year). 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 { const raw = value?.trim(); if (!raw) return DEFAULT_TRUST_PROXY; const normalized = raw.toLowerCase(); if (['false', 'no', 'off'].includes(normalized)) return false; if (['true', 'yes', 'on'].includes(normalized)) return true; 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; } export function parseBooleanEnv(value: string | undefined, fallback = false): boolean { const raw = value?.trim(); if (!raw) return fallback; const normalized = raw.toLowerCase(); if (['false', '0', 'no', 'off'].includes(normalized)) return false; if (['true', '1', 'yes', 'on'].includes(normalized)) return true; return fallback; } export interface CspOptions { allowCloudflareInsights?: boolean; } const CLOUDFLARE_INSIGHTS_SCRIPT_SRC = 'https://static.cloudflareinsights.com'; const CLOUDFLARE_INSIGHTS_CONNECT_SRC = 'https://cloudflareinsights.com'; const DICTIONARY_CONNECT_SRC = ['https://api.dictionaryapi.dev', 'https://*.wiktionary.org']; const TRANSLATE_CONNECT_SRC = 'https://translate.googleapis.com'; export function buildCspDirectives(options: CspOptions = {}) { const { allowCloudflareInsights = false } = options; const scriptSrc = ["'self'", "'wasm-unsafe-eval'", ...(allowCloudflareInsights ? [CLOUDFLARE_INSIGHTS_SCRIPT_SRC] : [])]; const connectSrc = [ "'self'", 'ws:', 'wss:', 'https://cdn.jsdelivr.net', ...DICTIONARY_CONNECT_SRC, TRANSLATE_CONNECT_SRC, ...(allowCloudflareInsights ? [CLOUDFLARE_INSIGHTS_CONNECT_SRC] : []), ]; return { defaultSrc: ["'self'"], scriptSrc, styleSrc: ["'self'", "'unsafe-inline'", 'blob:', 'https://fonts.googleapis.com'], imgSrc: ["'self'", 'data:', 'blob:', 'https:'], connectSrc, mediaSrc: ["'self'", 'data:', 'blob:'], fontSrc: ["'self'", 'data:', 'blob:', 'https://fonts.gstatic.com'], objectSrc: ["'none'"], frameSrc: ["'self'", 'blob:'], frameAncestors: ["'self'"], workerSrc: ["'self'", 'blob:'], upgradeInsecureRequests: null, }; } export function buildHelmetOptions(options: CspOptions = {}) { return { crossOriginOpenerPolicy: false, // Helmet sends HSTS over plain HTTP by default. The conditional hook adds it only for HTTPS. hsts: false, contentSecurityPolicy: { directives: buildCspDirectives(options), }, }; } /** * Decides whether an unmatched request should be answered with the SPA shell. * * Serving `index.html` for everything means a hashed bundle that no longer exists after an upgrade * answers `200 OK` with an HTML body under a `.js` URL. A 200 is storable, so both the browser HTTP * cache and the Workbox precache keep it, dynamic imports of that chunk fail, and the broken state * survives reloads until the user clears site data. Anything that names a file gets a real 404. * * Every BookOrbit route parameter is numeric, so "has an extension" reliably separates asset * requests from client-side routes such as `/book/123/files`. */ export function isStaticAssetPath(url: string): boolean { const pathname = url.split('?')[0].split('#')[0]; const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1); return lastSegment.includes('.'); } export function shouldServeSpaFallback(url: string): boolean { return !isStaticAssetPath(url); } export function shouldInjectEmptyJsonBody(method: string, headers: IncomingHttpHeaders): boolean { const contentType = getHeaderValue(headers['content-type'])?.toLowerCase(); if (!BODY_METHODS.has(method.toUpperCase()) || !contentType?.startsWith('application/json')) { return false; } const contentLength = getHeaderValue(headers['content-length'])?.trim(); return contentLength === undefined || contentLength === '0'; } export function buildEmptyJsonBodyStream(headers: IncomingHttpHeaders): Readable { headers['content-length'] = String(Buffer.byteLength(EMPTY_JSON_BODY)); return Readable.from([Buffer.from(EMPTY_JSON_BODY)]); } export function registerEmptyBodyContentTypeParser(fastify: FastifyInstance): void { fastify.addContentTypeParser('*', { parseAs: 'string' }, (request, body, done) => { if (BODY_METHODS.has(request.method.toUpperCase()) && body.length === 0) { done(null, {}); return; } done(new UnsupportedMediaTypeException('Unsupported Media Type')); }); } function getHeaderValue(value: string | string[] | undefined): string | undefined { return Array.isArray(value) ? value[0] : value; } // Sending Strict-Transport-Security over a plain HTTP connection is harmful: browsers cache the // directive per-hostname and force HTTPS for that host (and, with includeSubDomains, its subdomains) // for up to a year, breaking access to BookOrbit and any other unrelated service on the same host // when it isn't actually served over TLS. Only emit the header for requests that are genuinely // secure, i.e. terminated over TLS directly or forwarded by a trusted proxy (see `request.protocol`, // which already honours the app's `TRUST_PROXY` setting). export function isSecureProtocol(protocol: string | undefined): boolean { return protocol === 'https'; } export function applyConditionalHsts(request: Pick, reply: Pick): void { if (isSecureProtocol(request.protocol)) { reply.header('Strict-Transport-Security', HSTS_HEADER_VALUE); } } export function registerConditionalHsts(fastify: FastifyInstance): void { fastify.addHook('onSend', (request, reply, payload, done) => { applyConditionalHsts(request, reply); done(null, payload); }); } /** * Nest's `@RouteConfig({ bodyLimit })` only reaches Fastify's user-space `config` object, while * Fastify reads `bodyLimit` from the route options. Without copying it across, a route that asks * for a larger body silently keeps the 1 MiB default and its DTO length limit is unreachable. */ export function applyDeclaredBodyLimit(route: { config?: unknown; bodyLimit?: number }): void { const declared = (route.config as { bodyLimit?: unknown } | undefined)?.bodyLimit; if (typeof declared === 'number' && Number.isInteger(declared) && declared > 0) route.bodyLimit = declared; } export function registerDeclaredBodyLimits(fastify: FastifyInstance): void { fastify.addHook('onRoute', applyDeclaredBodyLimit); } /** * `@fastify/compress` reads its per-route opt-out from the route options, which Nest's * `@RouteConfig({ compress: false })` cannot reach. Raw file routes need it: the plugin's default * type check gzips `+xml` and `octet-stream` bodies (FB2, unknown formats) and drops Content-Length. * Register before the compress plugin so this hook runs first. */ export function applyDeclaredCompressionOptOut(route: { config?: unknown; compress?: unknown }): void { if ((route.config as { compress?: unknown } | undefined)?.compress === false) route.compress = false; } export function registerDeclaredCompressionOptOuts(fastify: FastifyInstance): void { fastify.addHook('onRoute', applyDeclaredCompressionOptOut); }