🦺 feat: Configurable Baseline HTTP Security Headers (#14445)

Adds helmet's CSP-independent headers (HSTS, X-Frame-Options,
X-Content-Type-Options, COOP, CORP, Referrer-Policy) on every response,
with contentSecurityPolicy explicitly disabled. Every header that can
break a deployment is configurable, so there is no allow-list to go
stale the way #7377's hardcoded CSP directives did.

HSTS includeSubDomains defaults off rather than matching helmet's
on-by-default: it would otherwise pin every sibling subdomain to HTTPS
for a year in every visitor's browser, and undoing that requires
serving max-age=0 from each affected host.
This commit is contained in:
Danny Avila 2026-08-25 08:21:39 -04:00 committed by GitHub
parent 3d2da403ce
commit 2ef12b1e1d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 431 additions and 0 deletions

View file

@ -17,6 +17,7 @@ const {
apiNotFound,
ErrorController,
QUERY_DEVTOOLS_HEADER,
createSecurityHeaders,
performStartupChecks,
handleJsonParseError,
initializeFileStorage,
@ -353,6 +354,12 @@ if (cluster.isMaster) {
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
/* Registered ahead of every route so health checks carry the headers too. */
const securityHeaders = createSecurityHeaders();
if (securityHeaders) {
app.use(securityHeaders);
}
if (isEnabled(process.env.TRUST_TENANT_HEADER)) {
logger.warn(
'[Security] TRUST_TENANT_HEADER is active. Ensure your reverse proxy strips and sets ' +

View file

@ -18,6 +18,7 @@ const {
createMetrics,
ErrorController,
memoryDiagnostics,
createSecurityHeaders,
performStartupChecks,
handleJsonParseError,
GenerationJobManager,
@ -155,6 +156,12 @@ const startServer = async () => {
app.disable('x-powered-by');
app.set('trust proxy', trusted_proxy);
/* Registered ahead of every route so health checks carry the headers too. */
const securityHeaders = createSecurityHeaders();
if (securityHeaders) {
app.use(securityHeaders);
}
if (isEnabled(process.env.TRUST_TENANT_HEADER)) {
logger.warn(
'[Security] TRUST_TENANT_HEADER is active. Ensure your reverse proxy strips and sets ' +

View file

@ -170,6 +170,22 @@ describe('Startup readiness wiring', () => {
expect(timeoutConfigIndex).toBeLessThan(shutdownIndex);
});
it('registers security headers ahead of the health endpoints in both server entries', () => {
const experimental = fs.readFileSync(path.join(__dirname, 'experimental.js'), 'utf8');
for (const [name, contents] of [
['index.js', source],
['experimental.js', experimental],
]) {
const headersIndex = contents.indexOf('const securityHeaders = createSecurityHeaders();');
const healthIndex = contents.indexOf("app.get('/health'");
expect([name, headersIndex > -1]).toEqual([name, true]);
expect([name, healthIndex > -1]).toEqual([name, true]);
expect([name, headersIndex < healthIndex]).toEqual([name, true]);
}
});
it('mounts the chat-start readiness gate before agent routes', () => {
const readinessGateIndex = source.indexOf(
"app.use('/api/agents/chat', rejectChatStartsUntilReady);",
@ -250,6 +266,27 @@ describe('Server Configuration', () => {
expect(response.text).toBe('OK');
});
it('should set baseline security headers on health checks', async () => {
const response = await request(app).get('/health');
expect(response.headers['strict-transport-security']).toBe('max-age=31536000');
expect(response.headers['x-frame-options']).toBe('SAMEORIGIN');
expect(response.headers['x-content-type-options']).toBe('nosniff');
expect(response.headers['cross-origin-opener-policy']).toBe('same-origin');
expect(response.headers['cross-origin-resource-policy']).toBe('same-origin');
expect(response.headers['referrer-policy']).toBe('no-referrer');
});
it('should set baseline security headers on the index page without a CSP', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);
expect(response.headers['x-frame-options']).toBe('SAMEORIGIN');
expect(response.headers['x-content-type-options']).toBe('nosniff');
expect(response.headers['content-security-policy']).toBeUndefined();
expect(response.headers['content-security-policy-report-only']).toBeUndefined();
});
it('should not cache index page', async () => {
const response = await request(app).get('/');
expect(response.status).toBe(200);