LibreChat/api/server/csp.spec.js
Danny Avila 877b9b2f1a
🐚 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.
2026-08-25 09:18:52 -04:00

232 lines
8 KiB
JavaScript

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.');
}
}