mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-06 22:49:29 +00:00
* 🛡️ 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.
232 lines
8 KiB
JavaScript
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.');
|
|
}
|
|
}
|