mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(server): send HSTS header conditionally for HTTPS requests
Disable default HSTS header in fastifyHelmet and register an onSend hook that includes Strict-Transport-Security only when request protocol is HTTPS. This prevents unencrypted HTTP setups and other services on the host from being forced onto HTTPS by browser HSTS caching. Closes #760
This commit is contained in:
@@ -1,14 +1,19 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import fastifyHelmet from '@fastify/helmet';
|
||||
import { request } from 'http';
|
||||
import type { AddressInfo } from 'net';
|
||||
import {
|
||||
parseBooleanEnv,
|
||||
parseTrustProxy,
|
||||
buildCspDirectives,
|
||||
buildHelmetOptions,
|
||||
buildEmptyJsonBodyStream,
|
||||
registerEmptyBodyContentTypeParser,
|
||||
shouldInjectEmptyJsonBody,
|
||||
isSecureProtocol,
|
||||
applyConditionalHsts,
|
||||
registerConditionalHsts,
|
||||
} from './bootstrap.utils';
|
||||
|
||||
describe('parseBooleanEnv', () => {
|
||||
@@ -371,6 +376,93 @@ describe('empty request body bootstrap handling', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSecureProtocol', () => {
|
||||
it('returns true only for https', () => {
|
||||
expect(isSecureProtocol('https')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for http', () => {
|
||||
expect(isSecureProtocol('http')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for undefined', () => {
|
||||
expect(isSecureProtocol(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unexpected values', () => {
|
||||
expect(isSecureProtocol('HTTPS')).toBe(false);
|
||||
expect(isSecureProtocol('')).toBe(false);
|
||||
expect(isSecureProtocol('ftp')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyConditionalHsts', () => {
|
||||
it('sets the Strict-Transport-Security header for secure requests', () => {
|
||||
const reply = { header: vi.fn() };
|
||||
|
||||
applyConditionalHsts({ protocol: 'https' }, reply);
|
||||
|
||||
expect(reply.header).toHaveBeenCalledTimes(1);
|
||||
expect(reply.header).toHaveBeenCalledWith('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
});
|
||||
|
||||
it('does not set the header for plain HTTP requests', () => {
|
||||
const reply = { header: vi.fn() };
|
||||
|
||||
applyConditionalHsts({ protocol: 'http' }, reply);
|
||||
|
||||
expect(reply.header).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('registerConditionalHsts (integration)', () => {
|
||||
it('sends HSTS when a trusted proxy forwards a secure request', async () => {
|
||||
await withHstsApp({ trustProxy: true }, async (app) => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/ping',
|
||||
headers: { 'x-forwarded-proto': 'https' },
|
||||
});
|
||||
|
||||
expect(response.headers['strict-transport-security']).toBe('max-age=31536000; includeSubDomains');
|
||||
});
|
||||
});
|
||||
|
||||
it('does not send HSTS for a plain HTTP request with no proxy involved', async () => {
|
||||
await withHstsApp({ trustProxy: true }, async (app) => {
|
||||
const response = await app.inject({ method: 'GET', url: '/ping' });
|
||||
|
||||
expect(response.headers['strict-transport-security']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not trust a forwarded-proto header when the proxy is not configured as trusted', async () => {
|
||||
await withHstsApp({ trustProxy: false }, async (app) => {
|
||||
const response = await app.inject({
|
||||
method: 'GET',
|
||||
url: '/ping',
|
||||
headers: { 'x-forwarded-proto': 'https' },
|
||||
});
|
||||
|
||||
expect(response.headers['strict-transport-security']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function withHstsApp(options: { trustProxy: boolean }, run: (app: FastifyInstance) => Promise<void>): Promise<void> {
|
||||
const app = Fastify({ logger: false, trustProxy: options.trustProxy });
|
||||
await app.register(fastifyHelmet, buildHelmetOptions());
|
||||
registerConditionalHsts(app);
|
||||
app.get('/ping', () => ({ ok: true }));
|
||||
|
||||
try {
|
||||
await app.ready();
|
||||
await run(app);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function withParserApp(run: (app: FastifyInstance) => Promise<void>): Promise<void> {
|
||||
const app = Fastify({ logger: false });
|
||||
registerEmptyBodyContentTypeParser(app);
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { UnsupportedMediaTypeException } from '@nestjs/common';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
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 | number {
|
||||
const raw = value?.trim();
|
||||
@@ -68,6 +71,17 @@ export function buildCspDirectives(options: CspOptions = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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')) {
|
||||
@@ -97,3 +111,26 @@ export function registerEmptyBodyContentTypeParser(fastify: FastifyInstance): vo
|
||||
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<FastifyRequest, 'protocol'>, reply: Pick<FastifyReply, 'header'>): 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);
|
||||
});
|
||||
}
|
||||
|
||||
+4
-7
@@ -17,8 +17,9 @@ import { setupSwaggerDocs } from './swagger';
|
||||
import {
|
||||
parseBooleanEnv,
|
||||
parseTrustProxy,
|
||||
buildCspDirectives,
|
||||
buildHelmetOptions,
|
||||
buildEmptyJsonBodyStream,
|
||||
registerConditionalHsts,
|
||||
registerEmptyBodyContentTypeParser,
|
||||
shouldInjectEmptyJsonBody,
|
||||
} from './common/utils/bootstrap.utils';
|
||||
@@ -75,12 +76,8 @@ async function bootstrap() {
|
||||
await setupSwaggerDocs(app, appConfiguration);
|
||||
}
|
||||
|
||||
await app.register(fastifyHelmet as never, {
|
||||
crossOriginOpenerPolicy: false,
|
||||
contentSecurityPolicy: {
|
||||
directives: buildCspDirectives({ allowCloudflareInsights }),
|
||||
},
|
||||
});
|
||||
await app.register(fastifyHelmet as never, buildHelmetOptions({ allowCloudflareInsights }));
|
||||
registerConditionalHsts(fastify);
|
||||
|
||||
await app.register(fastifyCompress as never, { encodings: ['gzip', 'br'] });
|
||||
|
||||
|
||||
Reference in New Issue
Block a user