🦺 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

@ -73,6 +73,38 @@ NO_INDEX=true
# Defaulted to 1.
TRUST_PROXY=1
#===============================#
# Security Headers #
#===============================#
# Baseline HTTP security headers (HSTS, X-Frame-Options, X-Content-Type-Options,
# COOP, CORP, Referrer-Policy) are sent on every response. Content-Security-Policy
# is never set here. Set to false to send no security headers at all.
# SECURITY_HEADERS=true
# Strict-Transport-Security. Only meaningful over HTTPS; browsers ignore it on
# plain HTTP. HSTS_INCLUDE_SUBDOMAINS applies the policy to every subdomain of
# this host for the full max-age, so enable it only if all of them serve HTTPS.
# HSTS_ENABLED=true
# HSTS_MAX_AGE=31536000
# HSTS_INCLUDE_SUBDOMAINS=false
# HSTS_PRELOAD=false
# X-Frame-Options. Set to DENY to block all framing, or to `off` if you embed
# LibreChat in an iframe on another origin.
# X_FRAME_OPTIONS=SAMEORIGIN
# Referrer-Policy. Any standard token, or `off` to omit the header.
# REFERRER_POLICY=no-referrer
# Cross-Origin-Opener-Policy. Use same-origin-allow-popups if a popup-based
# sign-in flow needs to reach back to the window that opened it.
# CROSS_ORIGIN_OPENER_POLICY=same-origin
# Cross-Origin-Resource-Policy. Use cross-origin if other sites need to load
# resources served by LibreChat, such as uploaded images.
# CROSS_ORIGIN_RESOURCE_POLICY=same-origin
# Trust X-Tenant-Id on unauthenticated routes. Disabled by default.
# Enable only when a trusted reverse proxy strips any client-supplied value and sets its own.
# TRUST_TENANT_HEADER=false

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);

13
package-lock.json generated
View file

@ -27506,6 +27506,18 @@
"integrity": "sha512-CxJE27BF6JcQvrL1giK478iSZr7EJNTnAN2Th1rAJiN1BSMYZxDLm4PL/p/ha3aSqVHvCo+YNk++5tIj0JVxLQ==",
"license": "LGPL-3.0"
},
"node_modules/helmet": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
"integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
"license": "MIT",
"engines": {
"node": ">=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/EvanHahn"
}
},
"node_modules/highlight.js": {
"version": "11.8.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
@ -42771,6 +42783,7 @@
"@langchain/langgraph-checkpoint-mongodb": "^1.4.0",
"cluster-key-slot": "^1.1.2",
"croner": "^10.0.1",
"helmet": "^8.3.0",
"proxy-from-env": "^2.1.0",
"re2js": "^2.8.6"
},

View file

@ -171,6 +171,7 @@
"@langchain/langgraph-checkpoint-mongodb": "^1.4.0",
"cluster-key-slot": "^1.1.2",
"croner": "^10.0.1",
"helmet": "^8.3.0",
"proxy-from-env": "^2.1.0",
"re2js": "^2.8.6"
}

View file

@ -43,6 +43,8 @@ export * from './crypto';
export * from './flow/manager';
/* Middleware */
export * from './middleware';
/* Security */
export * from './security';
/* Content protection */
export * from './protection';
/* Imports */

View file

@ -0,0 +1,134 @@
import express from 'express';
import request from 'supertest';
import type { Express } from 'express';
import { buildSecurityHeaderOptions, createSecurityHeaders } from './headers';
function appWith(env: NodeJS.ProcessEnv): Express {
const app = express();
const securityHeaders = createSecurityHeaders(env);
if (securityHeaders) {
app.use(securityHeaders);
}
app.get('/health', (_req, res) => {
res.status(200).send('OK');
});
return app;
}
describe('buildSecurityHeaderOptions', () => {
it('always disables CSP so no directive allow-list can go stale', () => {
expect(buildSecurityHeaderOptions({})?.contentSecurityPolicy).toBe(false);
expect(
buildSecurityHeaderOptions({ CONTENT_SECURITY_POLICY: 'true' })?.contentSecurityPolicy,
).toBe(false);
});
it('returns null when disabled outright', () => {
expect(buildSecurityHeaderOptions({ SECURITY_HEADERS: 'false' })).toBeNull();
expect(buildSecurityHeaderOptions({ SECURITY_HEADERS: 'off' })).toBeNull();
expect(createSecurityHeaders({ SECURITY_HEADERS: 'false' })).toBeNull();
});
it('leaves HSTS includeSubDomains off unless opted in', () => {
expect(buildSecurityHeaderOptions({})?.hsts).toEqual({
maxAge: 31536000,
includeSubDomains: false,
preload: false,
});
expect(buildSecurityHeaderOptions({ HSTS_INCLUDE_SUBDOMAINS: 'true' })?.hsts).toMatchObject({
includeSubDomains: true,
});
});
it('falls back to defaults for unparseable values', () => {
const options = buildSecurityHeaderOptions({
HSTS_MAX_AGE: 'forever',
X_FRAME_OPTIONS: 'ALLOW-FROM https://portal.example.com',
REFERRER_POLICY: 'whatever',
SECURITY_HEADERS: 'maybe',
});
expect(options?.hsts).toMatchObject({ maxAge: 31536000 });
expect(options?.frameguard).toEqual({ action: 'sameorigin' });
expect(options?.referrerPolicy).toEqual({ policy: 'no-referrer' });
});
it('disables individual headers without disabling the rest', () => {
const options = buildSecurityHeaderOptions({
HSTS_ENABLED: 'false',
X_FRAME_OPTIONS: 'off',
CROSS_ORIGIN_RESOURCE_POLICY: 'false',
});
expect(options?.hsts).toBe(false);
expect(options?.frameguard).toBe(false);
expect(options?.crossOriginResourcePolicy).toBe(false);
expect(options?.crossOriginOpenerPolicy).toEqual({ policy: 'same-origin' });
expect(options?.referrerPolicy).toEqual({ policy: 'no-referrer' });
});
});
describe('createSecurityHeaders', () => {
it('sets the baseline headers and never sets CSP', async () => {
const response = await request(appWith({})).get('/health');
expect(response.status).toBe(200);
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');
expect(response.headers['origin-agent-cluster']).toBe('?1');
expect(response.headers['content-security-policy']).toBeUndefined();
expect(response.headers['content-security-policy-report-only']).toBeUndefined();
});
it('honors per-header overrides', async () => {
const response = await request(
appWith({
HSTS_MAX_AGE: '600',
HSTS_INCLUDE_SUBDOMAINS: 'true',
HSTS_PRELOAD: 'true',
X_FRAME_OPTIONS: 'DENY',
CROSS_ORIGIN_RESOURCE_POLICY: 'cross-origin',
CROSS_ORIGIN_OPENER_POLICY: 'same-origin-allow-popups',
REFERRER_POLICY: 'strict-origin-when-cross-origin',
}),
).get('/health');
expect(response.headers['strict-transport-security']).toBe(
'max-age=600; includeSubDomains; preload',
);
expect(response.headers['x-frame-options']).toBe('DENY');
expect(response.headers['cross-origin-resource-policy']).toBe('cross-origin');
expect(response.headers['cross-origin-opener-policy']).toBe('same-origin-allow-popups');
expect(response.headers['referrer-policy']).toBe('strict-origin-when-cross-origin');
});
it('omits headers the operator turned off', async () => {
const response = await request(
appWith({
HSTS_ENABLED: 'false',
X_FRAME_OPTIONS: 'off',
CROSS_ORIGIN_RESOURCE_POLICY: 'off',
}),
).get('/health');
expect(response.headers['strict-transport-security']).toBeUndefined();
expect(response.headers['x-frame-options']).toBeUndefined();
expect(response.headers['cross-origin-resource-policy']).toBeUndefined();
expect(response.headers['x-content-type-options']).toBe('nosniff');
});
it('sets no headers at all when SECURITY_HEADERS is false', async () => {
const response = await request(appWith({ SECURITY_HEADERS: 'false' })).get('/health');
expect(response.status).toBe(200);
expect(response.headers['x-content-type-options']).toBeUndefined();
expect(response.headers['x-frame-options']).toBeUndefined();
expect(response.headers['strict-transport-security']).toBeUndefined();
});
});

View file

@ -0,0 +1,197 @@
import helmet from 'helmet';
import { logger } from '@librechat/data-schemas';
import type { RequestHandler } from 'express';
const DEFAULT_HSTS_MAX_AGE = 31536000;
export type FrameOptionsAction = 'deny' | 'sameorigin';
export type OpenerPolicy =
| 'same-origin'
| 'same-origin-allow-popups'
| 'noopener-allow-popups'
| 'unsafe-none';
export type ResourcePolicy = 'same-origin' | 'same-site' | 'cross-origin';
export type ReferrerPolicyToken =
| 'no-referrer'
| 'no-referrer-when-downgrade'
| 'same-origin'
| 'origin'
| 'strict-origin'
| 'origin-when-cross-origin'
| 'strict-origin-when-cross-origin'
| 'unsafe-url';
export interface HstsOptions {
maxAge: number;
includeSubDomains: boolean;
preload: boolean;
}
export interface SecurityHeaderOptions {
contentSecurityPolicy: false;
hsts: HstsOptions | false;
frameguard: { action: FrameOptionsAction } | false;
crossOriginOpenerPolicy: { policy: OpenerPolicy } | false;
crossOriginResourcePolicy: { policy: ResourcePolicy } | false;
referrerPolicy: { policy: ReferrerPolicyToken } | false;
}
const TRUTHY = new Set(['true', '1', 'yes', 'on', 'enabled']);
const FALSY = new Set(['false', '0', 'no', 'off', 'disabled', 'none']);
const FRAME_ACTIONS = new Set<FrameOptionsAction>(['deny', 'sameorigin']);
const OPENER_POLICIES = new Set<OpenerPolicy>([
'same-origin',
'same-origin-allow-popups',
'noopener-allow-popups',
'unsafe-none',
]);
const RESOURCE_POLICIES = new Set<ResourcePolicy>(['same-origin', 'same-site', 'cross-origin']);
const REFERRER_TOKENS = new Set<ReferrerPolicyToken>([
'no-referrer',
'no-referrer-when-downgrade',
'same-origin',
'origin',
'strict-origin',
'origin-when-cross-origin',
'strict-origin-when-cross-origin',
'unsafe-url',
]);
function normalize(value: string | undefined): string {
return value == null ? '' : value.trim().toLowerCase();
}
function parseSwitch(name: string, value: string | undefined, fallback: boolean): boolean {
const normalized = normalize(value);
if (normalized === '') {
return fallback;
}
if (TRUTHY.has(normalized)) {
return true;
}
if (FALSY.has(normalized)) {
return false;
}
logger.warn(`[SecurityHeaders] Ignoring invalid ${name}="${value}"; using ${fallback}.`);
return fallback;
}
function parseMaxAge(value: string | undefined, fallback: number): number {
const normalized = normalize(value);
if (normalized === '') {
return fallback;
}
const parsed = Number(normalized);
if (!Number.isInteger(parsed) || parsed < 0) {
logger.warn(`[SecurityHeaders] Ignoring invalid HSTS_MAX_AGE="${value}"; using ${fallback}.`);
return fallback;
}
return parsed;
}
/**
* Resolves a header that is either disabled outright or set to one of a fixed
* set of policy tokens. Returns `false` when the operator disabled it.
*/
function parsePolicy<T extends string>(
name: string,
value: string | undefined,
allowed: ReadonlySet<T>,
fallback: T,
): T | false {
const normalized = normalize(value);
if (normalized === '') {
return fallback;
}
if (FALSY.has(normalized)) {
return false;
}
if (allowed.has(normalized as T)) {
return normalized as T;
}
logger.warn(`[SecurityHeaders] Ignoring invalid ${name}="${value}"; using "${fallback}".`);
return fallback;
}
function buildHsts(env: NodeJS.ProcessEnv): HstsOptions | false {
if (!parseSwitch('HSTS_ENABLED', env.HSTS_ENABLED, true)) {
return false;
}
return {
maxAge: parseMaxAge(env.HSTS_MAX_AGE, DEFAULT_HSTS_MAX_AGE),
/* Opt-in rather than helmet's on-by-default: a bare-domain or `chat.example.com`
* deployment would otherwise pin every sibling subdomain to HTTPS for a year in
* every visitor's browser, and reversing that means serving `max-age=0` from each
* affected host. */
includeSubDomains: parseSwitch('HSTS_INCLUDE_SUBDOMAINS', env.HSTS_INCLUDE_SUBDOMAINS, false),
preload: parseSwitch('HSTS_PRELOAD', env.HSTS_PRELOAD, false),
};
}
/**
* Builds helmet options from the environment. CSP is always disabled here so the
* CSP-independent headers never depend on a directive allow-list staying current
* with whichever optional features a deployment has enabled.
*/
export function buildSecurityHeaderOptions(
env: NodeJS.ProcessEnv = process.env,
): SecurityHeaderOptions | null {
if (!parseSwitch('SECURITY_HEADERS', env.SECURITY_HEADERS, true)) {
return null;
}
const frameAction = parsePolicy(
'X_FRAME_OPTIONS',
env.X_FRAME_OPTIONS,
FRAME_ACTIONS,
'sameorigin',
);
const openerPolicy = parsePolicy(
'CROSS_ORIGIN_OPENER_POLICY',
env.CROSS_ORIGIN_OPENER_POLICY,
OPENER_POLICIES,
'same-origin',
);
const resourcePolicy = parsePolicy(
'CROSS_ORIGIN_RESOURCE_POLICY',
env.CROSS_ORIGIN_RESOURCE_POLICY,
RESOURCE_POLICIES,
'same-origin',
);
const referrerToken = parsePolicy(
'REFERRER_POLICY',
env.REFERRER_POLICY,
REFERRER_TOKENS,
'no-referrer',
);
return {
contentSecurityPolicy: false,
hsts: buildHsts(env),
frameguard: frameAction === false ? false : { action: frameAction },
crossOriginOpenerPolicy: openerPolicy === false ? false : { policy: openerPolicy },
crossOriginResourcePolicy: resourcePolicy === false ? false : { policy: resourcePolicy },
referrerPolicy: referrerToken === false ? false : { policy: referrerToken },
};
}
/**
* Creates the baseline security-header middleware, or `null` when the operator
* disabled it via `SECURITY_HEADERS=false`.
*
* @example
* const securityHeaders = createSecurityHeaders();
* if (securityHeaders) {
* app.use(securityHeaders);
* }
*/
export function createSecurityHeaders(env: NodeJS.ProcessEnv = process.env): RequestHandler | null {
const options = buildSecurityHeaderOptions(env);
if (!options) {
logger.warn('[SecurityHeaders] Disabled via SECURITY_HEADERS; no baseline headers are set.');
return null;
}
return helmet(options) as RequestHandler;
}

View file

@ -0,0 +1 @@
export * from './headers';