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:
neon
2026-07-26 17:02:45 +00:00
parent b1357457fe
commit d4b6f65791
3 changed files with 135 additions and 9 deletions
@@ -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);
+38 -1
View File
@@ -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
View File
@@ -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'] });