From 2ef12b1e1d7d7674d12a7ba1776e32ecedc460e5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 25 Aug 2026 08:21:39 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A6=BA=20feat:=20Configurable=20Baseline?= =?UTF-8?q?=20HTTP=20Security=20Headers=20(#14445)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .env.example | 32 ++++ api/server/experimental.js | 7 + api/server/index.js | 7 + api/server/index.spec.js | 37 ++++ package-lock.json | 13 ++ packages/api/package.json | 1 + packages/api/src/index.ts | 2 + packages/api/src/security/headers.spec.ts | 134 +++++++++++++++ packages/api/src/security/headers.ts | 197 ++++++++++++++++++++++ packages/api/src/security/index.ts | 1 + 10 files changed, 431 insertions(+) create mode 100644 packages/api/src/security/headers.spec.ts create mode 100644 packages/api/src/security/headers.ts create mode 100644 packages/api/src/security/index.ts diff --git a/.env.example b/.env.example index eb7350a93e..8fdf932ddc 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/api/server/experimental.js b/api/server/experimental.js index 8d03f8ae59..1885235ca7 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -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 ' + diff --git a/api/server/index.js b/api/server/index.js index 1c4a91244f..68d37111e7 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -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 ' + diff --git a/api/server/index.spec.js b/api/server/index.spec.js index 73ad042865..adb8a0359a 100644 --- a/api/server/index.spec.js +++ b/api/server/index.spec.js @@ -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); diff --git a/package-lock.json b/package-lock.json index 0b0a01e35d..5574f1fdf6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" }, diff --git a/packages/api/package.json b/packages/api/package.json index 990268d677..0cb25628af 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -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" } diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index dcac12cda0..febecf5405 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -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 */ diff --git a/packages/api/src/security/headers.spec.ts b/packages/api/src/security/headers.spec.ts new file mode 100644 index 0000000000..51ad3b1b69 --- /dev/null +++ b/packages/api/src/security/headers.spec.ts @@ -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(); + }); +}); diff --git a/packages/api/src/security/headers.ts b/packages/api/src/security/headers.ts new file mode 100644 index 0000000000..4453e1e28c --- /dev/null +++ b/packages/api/src/security/headers.ts @@ -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(['deny', 'sameorigin']); +const OPENER_POLICIES = new Set([ + 'same-origin', + 'same-origin-allow-popups', + 'noopener-allow-popups', + 'unsafe-none', +]); +const RESOURCE_POLICIES = new Set(['same-origin', 'same-site', 'cross-origin']); +const REFERRER_TOKENS = new Set([ + '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( + name: string, + value: string | undefined, + allowed: ReadonlySet, + 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; +} diff --git a/packages/api/src/security/index.ts b/packages/api/src/security/index.ts new file mode 100644 index 0000000000..356854ae0c --- /dev/null +++ b/packages/api/src/security/index.ts @@ -0,0 +1 @@ +export * from './headers';