🐚 feat: Nonce-Based Content Security Policy for the SPA Shell (#14446)

* 🛡️ feat: Configurable Baseline HTTP Security Headers

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.

* 🛡️ feat: Nonce-Based Content Security Policy for the SPA Shell

Adds an opt-in, per-response nonce CSP on the HTML response, resolved once
at startup so each request only mints a nonce and concatenates the header.
Report-only by default, since that is the rollout step #7377 skipped.

Rebase and correctness pass over #13226:

- Styles carry no nonce. A nonce in style-src makes browsers ignore
  'unsafe-inline', which would have blocked the <style> element the theme
  script injects at runtime, plus every style third-party components inject.
- frame-ancestors 'self' is now a default rather than opt-in, so enabling
  CSP actually covers the clickjacking half of #7110.
- CSP_SCRIPT_SRC_EXTRA now drops 'strict-dynamic', which would otherwise
  make browsers ignore the very hosts the operator configured.
- Nonce stamping runs after the query-devtools bootstrap injection so that
  injected script is covered too.

* fix: replace frame-ancestors instead of merging it

Merging the configured value into the default turned a deliberate
CSP_FRAME_ANCESTORS='none' into `frame-ancestors 'self' 'none'`, which
browsers resolve back to 'self'. Also bail out if the serialized policy
somehow lacks the nonce slot rather than emitting a header the shell
cannot match.

* fix: address Codex review findings on the CSP defaults

All five were real against LibreChat's actual runtime:

- CSP_REPORT_ONLY now only enforces on an explicit false/off/0/no. A typo
  or `1` previously fell through isEnabled() to enforcing, turning a
  config slip into a blocked SPA. Shares the parse helper with
  headers.ts via a new security/env.ts.
- Module preloads are stamped. A production client/dist/index.html
  carries 32 parser-inserted `<link rel="modulepreload">` tags, which
  'strict-dynamic' does not cover and 'self' cannot rescue.
- Stale nonce attributes are replaced rather than preserved; only the
  current response's nonce is authorized.
- worker-src allows data:, which Monaco's default CDN loader needs to
  bootstrap its workers (there is no loader.config() in the client).
- script-src allows 'wasm-unsafe-eval' for the HEIC upload path, which
  compiles WebAssembly through heic-to. Narrower than 'unsafe-eval'.

Verified against the real built shell: 4 scripts and all 32 preloads
nonced, stylesheets/icons/manifest and <style> untouched.

* fix: address second Codex round on CSP rollout controls

- SECURITY_HEADERS=false now disables CSP too. It is documented as the
  global kill switch, and an operator reaching for it to recover a shell
  broken by an enforcing policy must not be left with that policy on.
- The SPA shell is forced to `no-store` while CSP is enabled, ignoring
  INDEX_CACHE_CONTROL/INDEX_PRAGMA/INDEX_EXPIRES and warning when they
  are set. A cacheable shell pins one nonce across page loads and users,
  which is the whole thing a nonce policy defends against.
- Added CSP_ALLOW_WASM and CSP_ALLOW_DATA_WORKERS. The previous commit's
  .env.example claimed CSP_ADDITIONAL_DIRECTIVES could drop
  'wasm-unsafe-eval' and data:, but merging only ever appends sources, so
  the documented hardening step was impossible. These toggles make it real.
This commit is contained in:
Danny Avila 2026-08-25 09:18:52 -04:00 committed by GitHub
parent bf6144c9e1
commit 877b9b2f1a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 926 additions and 39 deletions

View file

@ -105,6 +105,61 @@ TRUST_PROXY=1
# resources served by LibreChat, such as uploaded images.
# CROSS_ORIGIN_RESOURCE_POLICY=same-origin
#===============================#
# Content Security Policy #
#===============================#
# Nonce-based CSP for the SPA HTML response. Off by default so existing
# deployments are unaffected. Turn it on in report-only mode first, review the
# violations your deployment actually produces, then set CSP_REPORT_ONLY=false.
# Only an explicit false/off/0/no enforces; anything unrecognized warns and stays
# report-only, so a typo cannot silently start blocking scripts.
# CSP_ENABLED=false
# CSP_REPORT_ONLY=true
# CSP_REPORT_URI=
# The default policy accommodates what LibreChat actually loads at runtime:
# script-src 'wasm-unsafe-eval' HEIC image conversion compiles WebAssembly
# worker-src data: Monaco's loader bootstraps workers from data:
# Both are narrower than 'unsafe-eval'. Set these to false to drop them if your
# deployment uses neither HEIC uploads nor the artifact code editor. (The CSP_*_EXTRA
# and CSP_ADDITIONAL_DIRECTIVES variables only add sources; they cannot remove one.)
# CSP_ALLOW_WASM=true
# CSP_ALLOW_DATA_WORKERS=true
# While CSP is enabled the SPA shell is always sent as `no-store` and the
# INDEX_CACHE_CONTROL / INDEX_PRAGMA / INDEX_EXPIRES overrides are ignored for it.
# A cached shell would pin a single nonce across page loads and users, which is
# precisely what a nonce policy exists to prevent.
#
# SECURITY_HEADERS=false disables CSP too; it is the global kill switch.
# Add deployment-specific sources on top of LibreChat's defaults; they are
# appended, never replacing them. Comma- or space-separated. Quote values
# containing spaces.
# CSP_CONNECT_SRC_EXTRA="https://telemetry.example.com wss://stream.example.com"
# CSP_FRAME_SRC_EXTRA="https://tenant.sharepoint.com"
# CSP_IMG_SRC_EXTRA="https://cdn.example.com"
# CSP_STYLE_SRC_EXTRA=
# CSP_FONT_SRC_EXTRA=
# CSP_MEDIA_SRC_EXTRA=
# CSP_WORKER_SRC_EXTRA=
# CSP_FORM_ACTION_EXTRA=
# CSP_DEFAULT_SRC_EXTRA=
# Script hosts get their own note: the default policy uses 'strict-dynamic',
# which makes browsers ignore every host source in script-src. Setting this
# drops 'strict-dynamic' so the hosts you list actually take effect.
# CSP_SCRIPT_SRC_EXTRA="https://trusted-scripts.example.com"
# Who may frame LibreChat. Defaults to 'self'. Replace it if you embed LibreChat
# in a portal on another origin, and set X_FRAME_OPTIONS=off alongside it since
# older browsers honor that header instead.
# CSP_FRAME_ANCESTORS="'self' https://portal.example.com"
# Raw directives appended to the policy, separated by semicolons.
# CSP_ADDITIONAL_DIRECTIVES="upgrade-insecure-requests"
# 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

232
api/server/csp.spec.js Normal file
View file

@ -0,0 +1,232 @@
const fs = require('fs');
const path = require('path');
const request = require('supertest');
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
/**
* Mirrors what a production `client/dist/index.html` actually contains: inline
* style, inline script, a module entry, and the module preloads Vite emits.
*/
const INDEX_HTML =
'<!DOCTYPE html><html lang="en-US"><head><title>LibreChat</title>' +
'<style>body{margin:0}</style>' +
'<script>window.theme="dark";</script>' +
'<link rel="modulepreload" crossorigin href="./assets/chunk.js">' +
'<link rel="stylesheet" crossorigin href="./assets/app.css">' +
'<script type="module" crossorigin src="./assets/index.js"></script>' +
'<script defer src="/assets/app.js"></script>' +
'</head><body><div id="root"></div></body></html>';
jest.mock('~/server/services/Config', () => ({
syncStaticTools: jest.fn().mockResolvedValue(undefined),
mergeAppTools: jest.fn().mockResolvedValue(undefined),
loadCustomConfig: jest.fn(() => Promise.resolve({})),
getAppConfig: jest.fn().mockResolvedValue({
paths: {
uploads: '/tmp',
dist: '/tmp/dist-csp',
fonts: '/tmp/fonts-csp',
assets: '/tmp/assets-csp',
},
fileStrategy: 'local',
imageOutputType: 'PNG',
}),
setCachedTools: jest.fn(),
}));
jest.mock('~/server/services/Agents/triggers', () => ({
initializeAgentTriggerService: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('~/server/services/Schedules', () => ({
initializeScheduleEngine: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('~/app/clients/tools', () => ({
createOpenAIImageTools: jest.fn(() => []),
createYouTubeTools: jest.fn(() => []),
manifestToolMap: {},
toolkits: [],
}));
jest.mock('~/config', () => ({
createMCPServersRegistry: jest.fn(),
createMCPManager: jest.fn().mockResolvedValue({
getAppToolFunctions: jest.fn().mockResolvedValue({}),
}),
}));
jest.mock(
'@librechat/api/telemetry',
() => ({
initializeTelemetry: jest.fn(() => ({
enabled: false,
status: 'disabled',
shutdown: jest.fn(),
})),
telemetryMiddleware: jest.fn((_req, _res, next) => next()),
telemetryErrorMiddleware: jest.fn((err, _req, _res, next) => next(err)),
}),
{ virtual: true },
);
describe('Content Security Policy', () => {
jest.setTimeout(30_000);
let mongoServer;
let app;
const originalReadFileSync = fs.readFileSync;
beforeAll(async () => {
fs.readFileSync = function (filepath, options) {
if (filepath.includes('index.html')) {
return INDEX_HTML;
}
return originalReadFileSync(filepath, options);
};
for (const dir of ['/tmp/dist-csp', '/tmp/fonts-csp', '/tmp/assets-csp']) {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
fs.writeFileSync(path.join('/tmp/dist-csp', 'index.html'), INDEX_HTML);
mongoServer = await MongoMemoryServer.create();
process.env.MONGO_URI = mongoServer.getUri();
process.env.PORT = '0';
/* Read once at startup, so they must be set before the server module loads. */
process.env.CSP_ENABLED = 'true';
process.env.CSP_REPORT_ONLY = 'false';
process.env.CSP_CONNECT_SRC_EXTRA = 'https://telemetry.example.com';
/* A cacheable override that CSP must refuse for the shell. */
process.env.INDEX_CACHE_CONTROL = 'public, max-age=3600';
app = require('~/server');
await healthCheckPoll(app);
});
afterAll(async () => {
fs.readFileSync = originalReadFileSync;
delete process.env.CSP_ENABLED;
delete process.env.CSP_REPORT_ONLY;
delete process.env.CSP_CONNECT_SRC_EXTRA;
delete process.env.INDEX_CACHE_CONTROL;
await mongoServer.stop();
await mongoose.disconnect();
});
it('sends an enforcing policy whose nonce matches the served scripts', async () => {
const response = await request(app).get('/');
const csp = response.headers['content-security-policy'];
const nonce = csp?.match(/script-src 'nonce-([^']+)'/)?.[1];
expect(response.status).toBe(200);
expect(response.headers['content-security-policy-report-only']).toBeUndefined();
expect(nonce).toBeTruthy();
expect(response.text).toContain(`<script nonce="${nonce}">window.theme="dark";</script>`);
expect(response.text).toContain(`<script nonce="${nonce}" defer src="/assets/app.js">`);
});
it('leaves style tags and stylesheet links unstamped', async () => {
const response = await request(app).get('/');
expect(response.text).toContain('<style>body{margin:0}</style>');
expect(response.text).toContain('<link rel="stylesheet" crossorigin href="./assets/app.css">');
expect(response.headers['content-security-policy']).toContain(
"style-src 'self' 'unsafe-inline'",
);
});
it("stamps module preloads, which 'strict-dynamic' does not cover", async () => {
const response = await request(app).get('/');
const nonce = response.headers['content-security-policy']?.match(
/script-src 'nonce-([^']+)'/,
)?.[1];
expect(nonce).toBeTruthy();
expect(response.text).toContain(
`<link nonce="${nonce}" rel="modulepreload" crossorigin href="./assets/chunk.js">`,
);
expect(response.text).toContain(
`<script nonce="${nonce}" type="module" crossorigin src="./assets/index.js">`,
);
});
it('stamps scripts injected after the shell is read', async () => {
const response = await request(app).get('/').set('x-librechat-enable-query-devtools', '1');
const nonce = response.headers['content-security-policy']?.match(
/script-src 'nonce-([^']+)'/,
)?.[1];
expect(response.text).toContain('data-librechat-query-devtools="true"');
expect(response.text).toContain(`<script nonce="${nonce}" data-librechat-query-devtools`);
});
it('keeps the shell non-storable despite a cacheable INDEX_CACHE_CONTROL', async () => {
const response = await request(app).get('/');
expect(response.headers['cache-control']).toBe('no-store');
expect(response.headers['cache-control']).not.toContain('max-age=3600');
expect(response.headers['expires']).toBe('0');
});
it('rotates the nonce on every response', async () => {
const [first, second] = await Promise.all([
request(app).get('/'),
request(app).get('/index.html'),
]);
const nonceOf = (res) =>
res.headers['content-security-policy']?.match(/script-src 'nonce-([^']+)'/)?.[1];
expect(nonceOf(first)).toBeTruthy();
expect(nonceOf(second)).toBeTruthy();
expect(nonceOf(first)).not.toBe(nonceOf(second));
});
it('serves /index.html through the same nonce-aware handler', async () => {
const response = await request(app).get('/index.html');
expect(response.status).toBe(200);
expect(response.headers['content-security-policy']).toContain("script-src 'nonce-");
});
it('carries deployment-specific sources and the clickjacking default', async () => {
const csp = (await request(app).get('/')).headers['content-security-policy'];
expect(csp).toContain("frame-ancestors 'self'");
expect(csp).toContain("connect-src 'self' https: wss: https://telemetry.example.com");
expect(csp).toContain("object-src 'none'");
});
it('does not attach the policy to API responses', async () => {
const response = await request(app).get('/api/does-not-exist');
expect(response.status).toBe(404);
expect(response.headers['content-security-policy']).toBeUndefined();
});
});
// Polls the /health endpoint every 30ms for up to 10 seconds to wait for the server to start completely
async function healthCheckPoll(app, retries = 0) {
const maxRetries = Math.floor(10000 / 30);
try {
const response = await request(app).get('/health');
if (response.status === 200) {
return;
}
} catch {
// Ignore connection errors during polling
}
if (retries < maxRetries) {
await new Promise((resolve) => setTimeout(resolve, 30));
await healthCheckPoll(app, retries + 1);
} else {
throw new Error('App did not become healthy within 10 seconds.');
}
}

View file

@ -14,7 +14,11 @@ const { logger, runAsSystem } = require('@librechat/data-schemas');
const mongoSanitize = require('express-mongo-sanitize');
const {
isEnabled,
issueCsp,
apiNotFound,
applyCspNonce,
createCspPolicy,
shellCacheHeaders,
ErrorController,
QUERY_DEVTOOLS_HEADER,
createSecurityHeaders,
@ -417,12 +421,11 @@ if (cluster.isMaster) {
}
}
const cspPolicy = createCspPolicy();
const shellCache = shellCacheHeaders(cspPolicy != null);
const sendIndexHtml = (req, res) => {
res.set({
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
Pragma: process.env.INDEX_PRAGMA || 'no-cache',
Expires: process.env.INDEX_EXPIRES || '0',
});
res.set(shellCache);
res.vary(QUERY_DEVTOOLS_HEADER);
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
@ -430,6 +433,13 @@ if (cluster.isMaster) {
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req);
/* Nonce last: every injected script above must be stamped too. */
if (cspPolicy) {
const csp = issueCsp(cspPolicy);
res.set(csp.headerName, csp.headerValue);
updatedIndexHtml = applyCspNonce(updatedIndexHtml, csp.nonce);
}
res.type('html');
res.send(updatedIndexHtml);
};

View file

@ -14,8 +14,12 @@ const mongoSanitize = require('express-mongo-sanitize');
const { logger, runAsSystem } = require('@librechat/data-schemas');
const {
isEnabled,
issueCsp,
apiNotFound,
createMetrics,
applyCspNonce,
createCspPolicy,
shellCacheHeaders,
ErrorController,
memoryDiagnostics,
createSecurityHeaders,
@ -235,12 +239,11 @@ const startServer = async () => {
}
}
const cspPolicy = createCspPolicy();
const shellCache = shellCacheHeaders(cspPolicy != null);
const sendIndexHtml = (req, res) => {
res.set({
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
Pragma: process.env.INDEX_PRAGMA || 'no-cache',
Expires: process.env.INDEX_EXPIRES || '0',
});
res.set(shellCache);
res.vary(QUERY_DEVTOOLS_HEADER);
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
@ -248,6 +251,13 @@ const startServer = async () => {
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req);
/* Nonce last: every injected script above must be stamped too. */
if (cspPolicy) {
const csp = issueCsp(cspPolicy);
res.set(csp.headerName, csp.headerValue);
updatedIndexHtml = applyCspNonce(updatedIndexHtml, csp.nonce);
}
res.type('html');
res.send(updatedIndexHtml);
};

View file

@ -113,3 +113,25 @@ uses annotation-based discovery. The gateway container also has configurable
See [`otel/langfuse-fanout/README.md`](../../otel/langfuse-fanout/README.md)
for the central Langfuse secret and values example.
## Content Security Policy
LibreChat's application-level CSP is disabled by default. Enable it through
`librechat.configEnv` so Kubernetes rollouts can start in report-only mode
before enforcing:
```yaml
librechat:
configEnv:
CSP_ENABLED: "true"
CSP_REPORT_ONLY: "true"
CSP_REPORT_URI: "https://reports.example.com/csp"
```
After reviewing the reports, set `CSP_REPORT_ONLY: "false"` to enforce. Use the
`CSP_*_EXTRA` variables from `.env.example` for deployment-specific CDNs,
analytics endpoints, or embedded frames.
The chart does not set CSP at the ingress layer: the policy carries a nonce that
has to be freshly generated for each HTML response and matched against the
`<script>` tags in that same response, which only the app can do.

View file

@ -0,0 +1,228 @@
import {
issueCsp,
applyCspNonce,
createCspPolicy,
shellCacheHeaders,
buildCspDirectives,
serializeCspDirectives,
} from './csp';
function headerFor(env: NodeJS.ProcessEnv): string {
const policy = createCspPolicy({ CSP_ENABLED: 'true', ...env });
if (!policy) {
throw new Error('expected a policy');
}
return issueCsp(policy).headerValue;
}
describe('createCspPolicy', () => {
it('stays off unless explicitly enabled', () => {
expect(createCspPolicy({})).toBeNull();
expect(createCspPolicy({ CSP_ENABLED: 'false' })).toBeNull();
});
it('defaults to report-only and switches to enforcing on request', () => {
expect(createCspPolicy({ CSP_ENABLED: 'true' })?.headerName).toBe(
'Content-Security-Policy-Report-Only',
);
expect(createCspPolicy({ CSP_ENABLED: 'true', CSP_REPORT_ONLY: 'false' })?.headerName).toBe(
'Content-Security-Policy',
);
});
it('only enforces on a recognized false value, never on a typo', () => {
for (const value of ['1', 'yes', 'on', 'treu', 'report-only', 'maybe']) {
expect(createCspPolicy({ CSP_ENABLED: 'true', CSP_REPORT_ONLY: value })?.headerName).toBe(
'Content-Security-Policy-Report-Only',
);
}
for (const value of ['false', 'off', '0', 'no']) {
expect(createCspPolicy({ CSP_ENABLED: 'true', CSP_REPORT_ONLY: value })?.headerName).toBe(
'Content-Security-Policy',
);
}
});
it('yields to the global SECURITY_HEADERS kill switch', () => {
expect(createCspPolicy({ CSP_ENABLED: 'true', SECURITY_HEADERS: 'false' })).toBeNull();
expect(createCspPolicy({ CSP_ENABLED: 'true', SECURITY_HEADERS: 'off' })).toBeNull();
expect(createCspPolicy({ CSP_ENABLED: 'true', SECURITY_HEADERS: 'true' })).not.toBeNull();
});
it('mints a fresh nonce per response', () => {
const policy = createCspPolicy({ CSP_ENABLED: 'true' });
if (!policy) {
throw new Error('expected a policy');
}
const first = issueCsp(policy);
const second = issueCsp(policy);
expect(first.nonce).not.toBe(second.nonce);
expect(first.headerValue).toContain(`'nonce-${first.nonce}'`);
expect(second.headerValue).toContain(`'nonce-${second.nonce}'`);
expect(first.headerValue).not.toContain(second.nonce);
});
});
describe('policy directives', () => {
it('locks down the directives that carry the XSS and clickjacking value', () => {
const header = headerFor({});
expect(header).toContain("'strict-dynamic'");
expect(header).toContain("script-src-attr 'none'");
expect(header).toContain("object-src 'none'");
expect(header).toContain("base-uri 'self'");
expect(header).toContain("frame-ancestors 'self'");
});
it('permits the runtime dependencies the app actually ships', () => {
const header = headerFor({});
expect(header).toContain("'wasm-unsafe-eval'");
expect(header).toContain("worker-src 'self' blob: data:");
});
it('lets a deployment that needs neither drop them', () => {
const header = headerFor({ CSP_ALLOW_WASM: 'false', CSP_ALLOW_DATA_WORKERS: 'false' });
expect(header).not.toContain("'wasm-unsafe-eval'");
expect(header).toContain("worker-src 'self' blob:");
expect(header).not.toContain("worker-src 'self' blob: data:");
expect(header).toContain("'strict-dynamic'");
});
it('keeps styles on unsafe-inline with no nonce', () => {
const header = headerFor({});
const styleSrc = header.split('; ').find((directive) => directive.startsWith('style-src'));
expect(styleSrc).toBe("style-src 'self' 'unsafe-inline'");
expect(header).not.toContain('style-src-elem');
});
it("drops 'strict-dynamic' when script hosts are configured, since it would ignore them", () => {
const header = headerFor({ CSP_SCRIPT_SRC_EXTRA: 'https://scripts.example.com' });
expect(header).not.toContain("'strict-dynamic'");
expect(header).toContain('https://scripts.example.com');
expect(header).toMatch(
/script-src 'nonce-[^']+' 'wasm-unsafe-eval' 'self' https:\/\/scripts\.example\.com/,
);
});
it('adds deployment-specific sources without dropping the safe defaults', () => {
const header = headerFor({
CSP_CONNECT_SRC_EXTRA: 'https://telemetry.example.com,wss://stream.example.com',
CSP_FRAME_SRC_EXTRA: 'https://tenant.sharepoint.com',
CSP_REPORT_URI: 'https://reports.example.com/csp',
});
expect(header).toContain(
"connect-src 'self' https: wss: https://telemetry.example.com wss://stream.example.com",
);
expect(header).toContain('https://tenant.sharepoint.com');
expect(header).toContain("frame-src 'self' https: blob: data: about:");
expect(header).toContain('report-uri https://reports.example.com/csp');
});
it('replaces frame-ancestors when the deployment is embedded elsewhere', () => {
const header = headerFor({ CSP_FRAME_ANCESTORS: "'self' https://portal.example.com" });
expect(header).toContain("frame-ancestors 'self' https://portal.example.com");
});
it("replaces rather than merges frame-ancestors, so 'none' is not diluted by 'self'", () => {
const header = headerFor({ CSP_FRAME_ANCESTORS: "'none'" });
expect(header).toContain("frame-ancestors 'none'");
expect(header).not.toContain("frame-ancestors 'self'");
});
it('appends additional directives and skips malformed ones', () => {
const header = serializeCspDirectives(
buildCspDirectives({
CSP_ADDITIONAL_DIRECTIVES:
"upgrade-insecure-requests; require-trusted-types-for 'script'; 99-bogus 'self'",
}),
);
expect(header).toContain('upgrade-insecure-requests');
expect(header).toContain("require-trusted-types-for 'script'");
expect(header).not.toContain('99-bogus');
});
});
describe('shellCacheHeaders', () => {
it('honors the documented overrides when CSP is off', () => {
expect(shellCacheHeaders(false, {})).toEqual({
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
});
expect(
shellCacheHeaders(false, { INDEX_CACHE_CONTROL: 'public, max-age=3600' })['Cache-Control'],
).toBe('public, max-age=3600');
});
it('refuses a cacheable shell when CSP is on, so a nonce cannot be replayed', () => {
expect(
shellCacheHeaders(true, {
INDEX_CACHE_CONTROL: 'public, max-age=3600',
INDEX_EXPIRES: '900',
}),
).toEqual({
'Cache-Control': 'no-store',
Pragma: 'no-cache',
Expires: '0',
});
});
});
describe('applyCspNonce', () => {
it('stamps script tags and leaves styles alone', () => {
const html = [
'<style>body { margin: 0; }</style>',
'<script>window.theme = "dark";</script>',
'<script defer src="/assets/app.js"></script>',
].join('');
expect(applyCspNonce(html, 'abc123')).toBe(
[
'<style>body { margin: 0; }</style>',
'<script nonce="abc123">window.theme = "dark";</script>',
'<script nonce="abc123" defer src="/assets/app.js"></script>',
].join(''),
);
});
it('replaces a stale nonce rather than preserving it', () => {
const html = '<script nonce="from-the-build">window.ok = true;</script>';
expect(applyCspNonce(html, 'abc123')).toBe('<script nonce="abc123">window.ok = true;</script>');
expect(applyCspNonce(html, 'abc123')).not.toContain('from-the-build');
});
it('stamps module preloads, which strict-dynamic does not cover', () => {
const html = [
'<link rel="modulepreload" crossorigin href="/assets/chunk.js">',
'<link rel="preload" as="script" href="/assets/other.js">',
'<link rel="stylesheet" crossorigin href="/assets/app.css">',
'<link rel="icon" type="image/png" href="/favicon.png">',
].join('');
expect(applyCspNonce(html, 'abc123')).toBe(
[
'<link nonce="abc123" rel="modulepreload" crossorigin href="/assets/chunk.js">',
'<link nonce="abc123" rel="preload" as="script" href="/assets/other.js">',
'<link rel="stylesheet" crossorigin href="/assets/app.css">',
'<link rel="icon" type="image/png" href="/favicon.png">',
].join(''),
);
});
it('returns the html untouched without a nonce', () => {
const html = '<script src="/app.js"></script>';
expect(applyCspNonce(html, '')).toBe(html);
});
});

View file

@ -0,0 +1,308 @@
import { randomBytes } from 'crypto';
import { logger } from '@librechat/data-schemas';
import { parseEnvSwitch } from './env';
import { isEnabled } from '../utils';
/** Split point for the per-request nonce. Randomized so no env value can collide. */
const NONCE_SLOT = `__csp_nonce_${randomBytes(8).toString('hex')}__`;
const DIRECTIVE_NAME_PATTERN = /^[a-z][a-z0-9-]*$/;
/** `<link>` is in here because module preloads are fetched under `script-src`. */
const NONCEABLE_TAG_PATTERN = /<(script|link)\b([^>]*)>/gi;
const NONCE_ATTRIBUTE_PATTERN = /\snonce\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi;
const REL_PATTERN = /\srel\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/i;
const AS_SCRIPT_PATTERN = /\sas\s*=\s*(?:"script"|'script'|script\b)/i;
type CspDirective = [string, string[]];
/** Precomputed once at startup; only the nonce varies per response. */
export interface CspPolicy {
headerName: 'Content-Security-Policy' | 'Content-Security-Policy-Report-Only';
prefix: string;
suffix: string;
}
export interface CspResponse {
headerName: CspPolicy['headerName'];
headerValue: string;
nonce: string;
}
const SOURCE_EXTRA_ENV: Record<string, string> = {
'default-src': 'CSP_DEFAULT_SRC_EXTRA',
'script-src': 'CSP_SCRIPT_SRC_EXTRA',
'style-src': 'CSP_STYLE_SRC_EXTRA',
'img-src': 'CSP_IMG_SRC_EXTRA',
'font-src': 'CSP_FONT_SRC_EXTRA',
'connect-src': 'CSP_CONNECT_SRC_EXTRA',
'media-src': 'CSP_MEDIA_SRC_EXTRA',
'frame-src': 'CSP_FRAME_SRC_EXTRA',
'worker-src': 'CSP_WORKER_SRC_EXTRA',
'form-action': 'CSP_FORM_ACTION_EXTRA',
};
function splitSourceList(value: string | undefined): string[] {
if (!value) {
return [];
}
return value
.split(/[,\s]+/)
.map((source) => source.trim())
.filter(Boolean);
}
/**
* Only an explicitly recognized false value enforces. A typo or an unrecognized
* truthy spelling stays report-only, so a config slip cannot turn a rollout into
* a blocked SPA.
*/
function isReportOnly(env: NodeJS.ProcessEnv): boolean {
return parseEnvSwitch('CSP_REPORT_ONLY', env.CSP_REPORT_ONLY, true);
}
/**
* `'strict-dynamic'` makes browsers ignore every host source in `script-src`, so it
* cannot coexist with operator-supplied script hosts. When extras are configured we
* drop it and let the (now honored) `'self'` plus those hosts govern script loading.
*/
function scriptSources(scriptExtras: string[], allowWasm: boolean): string[] {
/* 'wasm-unsafe-eval' permits WebAssembly compilation without permitting eval();
* the HEIC upload path (client/src/utils/heicConverter.ts -> heic-to) needs it. */
const wasm = allowWasm ? ["'wasm-unsafe-eval'"] : [];
if (scriptExtras.length === 0) {
return [`'nonce-${NONCE_SLOT}'`, "'strict-dynamic'", ...wasm, "'self'"];
}
logger.info(
"[CSP] CSP_SCRIPT_SRC_EXTRA is set; omitting 'strict-dynamic' so the configured script hosts take effect.",
);
return [`'nonce-${NONCE_SLOT}'`, ...wasm, "'self'"];
}
/**
* Styles intentionally carry no nonce. A nonce in `style-src` makes browsers ignore
* `'unsafe-inline'`, which would block every `<style>` element injected at runtime by
* the app shell and by third-party components that cannot know our nonce.
*/
function defaultDirectives(
scriptExtras: string[],
frameAncestors: string[],
allowWasm: boolean,
allowDataWorkers: boolean,
): CspDirective[] {
return [
['default-src', ["'self'"]],
['base-uri', ["'self'"]],
['object-src', ["'none'"]],
['script-src', scriptSources(scriptExtras, allowWasm)],
['script-src-attr', ["'none'"]],
['style-src', ["'self'", "'unsafe-inline'"]],
['img-src', ["'self'", 'data:', 'blob:', 'https:']],
['font-src', ["'self'", 'data:']],
['connect-src', ["'self'", 'https:', 'wss:']],
['media-src', ["'self'", 'data:', 'blob:']],
['frame-src', ["'self'", 'https:', 'blob:', 'data:', 'about:']],
/* `data:` is required by Monaco's default CDN loader, which bootstraps its
* workers from a data: URL; without it the artifact editor silently drops to
* running worker tasks on the UI thread. */
['worker-src', allowDataWorkers ? ["'self'", 'blob:', 'data:'] : ["'self'", 'blob:']],
['manifest-src', ["'self'"]],
['form-action', ["'self'", 'https:']],
/* Replaced wholesale, not appended: merging would turn a deliberate
* `'none'` into `'self' 'none'`, which browsers resolve back to `'self'`. */
['frame-ancestors', frameAncestors],
];
}
function parseAdditionalDirectives(value: string | undefined): CspDirective[] {
if (!value) {
return [];
}
const directives: CspDirective[] = [];
for (const directive of value.split(';')) {
const trimmed = directive.trim();
if (!trimmed) {
continue;
}
const [name, ...sources] = trimmed.split(/\s+/);
if (!DIRECTIVE_NAME_PATTERN.test(name)) {
logger.warn(`[CSP] Ignoring invalid directive name "${name}" in CSP_ADDITIONAL_DIRECTIVES.`);
continue;
}
directives.push([name, sources]);
}
return directives;
}
function mergeDirectives(directives: CspDirective[]): CspDirective[] {
const order: string[] = [];
const merged = new Map<string, string[]>();
for (const [name, values] of directives) {
let current = merged.get(name);
if (!current) {
current = [];
order.push(name);
merged.set(name, current);
}
for (const value of values) {
if (!current.includes(value)) {
current.push(value);
}
}
}
return order.map((name) => [name, merged.get(name) ?? []]);
}
export function buildCspDirectives(env: NodeJS.ProcessEnv = process.env): CspDirective[] {
const scriptExtras = splitSourceList(env.CSP_SCRIPT_SRC_EXTRA);
const frameAncestors = splitSourceList(env.CSP_FRAME_ANCESTORS);
const directives = defaultDirectives(
scriptExtras,
frameAncestors.length > 0 ? frameAncestors : ["'self'"],
parseEnvSwitch('CSP_ALLOW_WASM', env.CSP_ALLOW_WASM, true),
parseEnvSwitch('CSP_ALLOW_DATA_WORKERS', env.CSP_ALLOW_DATA_WORKERS, true),
);
for (const [directive, envName] of Object.entries(SOURCE_EXTRA_ENV)) {
const extraSources = splitSourceList(env[envName]);
if (extraSources.length > 0) {
directives.push([directive, extraSources]);
}
}
const reportUri = env.CSP_REPORT_URI?.trim();
if (reportUri) {
directives.push(['report-uri', [reportUri]]);
}
return mergeDirectives([
...directives,
...parseAdditionalDirectives(env.CSP_ADDITIONAL_DIRECTIVES),
]);
}
export function serializeCspDirectives(directives: CspDirective[]): string {
return directives
.map(([name, values]) => (values.length > 0 ? `${name} ${values.join(' ')}` : name))
.join('; ');
}
/**
* Resolves the policy once at startup. Returns `null` unless `CSP_ENABLED` is set,
* so existing deployments are unaffected until they opt in.
*/
export function createCspPolicy(env: NodeJS.ProcessEnv = process.env): CspPolicy | null {
if (!isEnabled(env.CSP_ENABLED)) {
return null;
}
/* SECURITY_HEADERS is the documented global kill switch; an operator reaching for
* it to recover a broken shell must not be left with an enforcing policy. */
if (!parseEnvSwitch('SECURITY_HEADERS', env.SECURITY_HEADERS, true)) {
logger.warn('[CSP] SECURITY_HEADERS is false; CSP is disabled despite CSP_ENABLED.');
return null;
}
const serialized = serializeCspDirectives(buildCspDirectives(env));
const [prefix, suffix] = serialized.split(NONCE_SLOT);
if (suffix == null) {
logger.error(
'[CSP] Policy has no nonce slot; refusing to send a policy the shell cannot match.',
);
return null;
}
const reportOnly = isReportOnly(env);
logger.info(
`[CSP] Content Security Policy enabled in ${reportOnly ? 'report-only' : 'enforcing'} mode.`,
);
return {
headerName: reportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy',
prefix,
suffix,
};
}
export interface ShellCacheHeaders {
'Cache-Control': string;
Pragma: string;
Expires: string;
}
/**
* Cache headers for the SPA shell. A nonce policy is only as strong as the
* response being unique, so when CSP is on the shell is forced non-storable and a
* cacheable `INDEX_CACHE_CONTROL` override is refused: a cached shell would pin one
* nonce across page loads and users, which is exactly what an injected script needs.
*/
export function shellCacheHeaders(
cspEnabled: boolean,
env: NodeJS.ProcessEnv = process.env,
): ShellCacheHeaders {
if (!cspEnabled) {
return {
'Cache-Control': env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
Pragma: env.INDEX_PRAGMA || 'no-cache',
Expires: env.INDEX_EXPIRES || '0',
};
}
if (env.INDEX_CACHE_CONTROL || env.INDEX_PRAGMA || env.INDEX_EXPIRES) {
logger.warn(
'[CSP] Ignoring INDEX_CACHE_CONTROL/INDEX_PRAGMA/INDEX_EXPIRES for the SPA shell: a cached shell would reuse one nonce across responses.',
);
}
return { 'Cache-Control': 'no-store', Pragma: 'no-cache', Expires: '0' };
}
/** Mints the per-response nonce and its header value. */
export function issueCsp(policy: CspPolicy): CspResponse {
const nonce = randomBytes(16).toString('base64');
return {
headerName: policy.headerName,
headerValue: `${policy.prefix}${nonce}${policy.suffix}`,
nonce,
};
}
/** `<link>` elements that the browser fetches under `script-src`. */
function isScriptPreload(attributes: string): boolean {
const match = REL_PATTERN.exec(attributes);
const rel = (match?.[1] ?? match?.[2] ?? match?.[3] ?? '').toLowerCase();
if (rel === 'modulepreload') {
return true;
}
return rel === 'preload' && AS_SCRIPT_PATTERN.test(attributes);
}
/**
* Stamps the response nonce onto the shell's `<script>` elements and onto the
* module preloads a production build emits, which are parser-inserted and so are
* not covered by `'strict-dynamic'`. Any nonce already in the markup is replaced,
* never kept: only the current response's nonce is authorized by the header.
*
* Styles are deliberately untouched; see `defaultDirectives`.
*/
export function applyCspNonce(html: string, nonce: string): string {
if (!nonce) {
return html;
}
return html.replace(NONCEABLE_TAG_PATTERN, (match, tag: string, attributes: string) => {
const isScript = tag.toLowerCase() === 'script';
if (!isScript && !isScriptPreload(attributes)) {
return match;
}
const stripped = attributes.replace(NONCE_ATTRIBUTE_PATTERN, '');
return `<${tag} nonce="${nonce}"${stripped}>`;
});
}

View file

@ -0,0 +1,36 @@
import { logger } from '@librechat/data-schemas';
const TRUTHY = new Set(['true', '1', 'yes', 'on', 'enabled']);
const FALSY = new Set(['false', '0', 'no', 'off', 'disabled', 'none']);
export function normalizeEnvValue(value: string | undefined): string {
return value == null ? '' : value.trim().toLowerCase();
}
export function isFalsyEnvValue(value: string): boolean {
return FALSY.has(value);
}
/**
* Resolves a security toggle. Unrecognized values warn and fall back rather than
* being treated as the opposite of the default, so a typo cannot silently flip a
* deployment into the riskier setting.
*/
export function parseEnvSwitch(
name: string,
value: string | undefined,
fallback: boolean,
): boolean {
const normalized = normalizeEnvValue(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;
}

View file

@ -3,6 +3,8 @@ import { logger } from '@librechat/data-schemas';
import type { RequestHandler } from 'express';
import { parseEnvSwitch, normalizeEnvValue, isFalsyEnvValue } from './env';
const DEFAULT_HSTS_MAX_AGE = 31536000;
export type FrameOptionsAction = 'deny' | 'sameorigin';
@ -37,9 +39,6 @@ export interface SecurityHeaderOptions {
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',
@ -59,27 +58,8 @@ const REFERRER_TOKENS = new Set<ReferrerPolicyToken>([
'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);
const normalized = normalizeEnvValue(value);
if (normalized === '') {
return fallback;
}
@ -101,11 +81,11 @@ function parsePolicy<T extends string>(
allowed: ReadonlySet<T>,
fallback: T,
): T | false {
const normalized = normalize(value);
const normalized = normalizeEnvValue(value);
if (normalized === '') {
return fallback;
}
if (FALSY.has(normalized)) {
if (isFalsyEnvValue(normalized)) {
return false;
}
if (allowed.has(normalized as T)) {
@ -116,7 +96,7 @@ function parsePolicy<T extends string>(
}
function buildHsts(env: NodeJS.ProcessEnv): HstsOptions | false {
if (!parseSwitch('HSTS_ENABLED', env.HSTS_ENABLED, true)) {
if (!parseEnvSwitch('HSTS_ENABLED', env.HSTS_ENABLED, true)) {
return false;
}
return {
@ -125,8 +105,12 @@ function buildHsts(env: NodeJS.ProcessEnv): HstsOptions | false {
* 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),
includeSubDomains: parseEnvSwitch(
'HSTS_INCLUDE_SUBDOMAINS',
env.HSTS_INCLUDE_SUBDOMAINS,
false,
),
preload: parseEnvSwitch('HSTS_PRELOAD', env.HSTS_PRELOAD, false),
};
}
@ -138,7 +122,7 @@ function buildHsts(env: NodeJS.ProcessEnv): HstsOptions | false {
export function buildSecurityHeaderOptions(
env: NodeJS.ProcessEnv = process.env,
): SecurityHeaderOptions | null {
if (!parseSwitch('SECURITY_HEADERS', env.SECURITY_HEADERS, true)) {
if (!parseEnvSwitch('SECURITY_HEADERS', env.SECURITY_HEADERS, true)) {
return null;
}

View file

@ -1 +1,3 @@
export * from './env';
export * from './headers';
export * from './csp';