mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
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:
parent
b5757cfeac
commit
62cb4f4d37
6 changed files with 125 additions and 18 deletions
14
.env.example
14
.env.example
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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('/'),
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue