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-07-26 23:54:08 -04:00
parent b5757cfeac
commit 62cb4f4d37
6 changed files with 125 additions and 18 deletions

View file

@ -117,8 +117,18 @@ TRUST_PROXY=1
# 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'. Drop them via CSP_ADDITIONAL_DIRECTIVES
# only if your deployment uses neither the artifact code editor nor HEIC uploads.
# 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

View file

@ -92,6 +92,8 @@ describe('Content Security Policy', () => {
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);
@ -102,6 +104,7 @@ describe('Content Security Policy', () => {
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();
});
@ -153,6 +156,14 @@ describe('Content Security Policy', () => {
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('/'),

View file

@ -18,6 +18,7 @@ const {
apiNotFound,
applyCspNonce,
createCspPolicy,
shellCacheHeaders,
ErrorController,
QUERY_DEVTOOLS_HEADER,
createSecurityHeaders,
@ -344,13 +345,10 @@ 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';

View file

@ -17,6 +17,7 @@ const {
createMetrics,
applyCspNonce,
createCspPolicy,
shellCacheHeaders,
ErrorController,
memoryDiagnostics,
createSecurityHeaders,
@ -184,13 +185,10 @@ 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';

View file

@ -2,6 +2,7 @@ import {
issueCsp,
applyCspNonce,
createCspPolicy,
shellCacheHeaders,
buildCspDirectives,
serializeCspDirectives,
} from './csp';
@ -43,6 +44,12 @@ describe('createCspPolicy', () => {
}
});
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) {
@ -77,6 +84,15 @@ describe('policy directives', () => {
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'));
@ -137,6 +153,32 @@ describe('policy directives', () => {
});
});
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 = [

View file

@ -65,16 +65,17 @@ function isReportOnly(env: NodeJS.ProcessEnv): boolean {
* 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[]): string[] {
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-unsafe-eval'", "'self'"];
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-unsafe-eval'", "'self'"];
return [`'nonce-${NONCE_SLOT}'`, ...wasm, "'self'"];
}
/**
@ -82,12 +83,17 @@ function scriptSources(scriptExtras: string[]): string[] {
* `'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[]): CspDirective[] {
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)],
['script-src', scriptSources(scriptExtras, allowWasm)],
['script-src-attr', ["'none'"]],
['style-src', ["'self'", "'unsafe-inline'"]],
['img-src', ["'self'", 'data:', 'blob:', 'https:']],
@ -98,7 +104,7 @@ function defaultDirectives(scriptExtras: string[], frameAncestors: string[]): Cs
/* `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', ["'self'", 'blob:', 'data:']],
['worker-src', allowDataWorkers ? ["'self'", 'blob:', 'data:'] : ["'self'", 'blob:']],
['manifest-src', ["'self'"]],
['form-action', ["'self'", 'https:']],
/* Replaced wholesale, not appended: merging would turn a deliberate
@ -159,6 +165,8 @@ export function buildCspDirectives(env: NodeJS.ProcessEnv = process.env): CspDir
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)) {
@ -194,6 +202,13 @@ export function createCspPolicy(env: NodeJS.ProcessEnv = process.env): CspPolicy
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) {
@ -215,6 +230,39 @@ export function createCspPolicy(env: NodeJS.ProcessEnv = process.env): CspPolicy
};
}
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');