mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +00:00
🐚 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:
parent
bf6144c9e1
commit
877b9b2f1a
10 changed files with 926 additions and 39 deletions
55
.env.example
55
.env.example
|
|
@ -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
232
api/server/csp.spec.js
Normal 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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
228
packages/api/src/security/csp.spec.ts
Normal file
228
packages/api/src/security/csp.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
308
packages/api/src/security/csp.ts
Normal file
308
packages/api/src/security/csp.ts
Normal 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}>`;
|
||||
});
|
||||
}
|
||||
36
packages/api/src/security/env.ts
Normal file
36
packages/api/src/security/env.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +1,3 @@
|
|||
export * from './env';
|
||||
export * from './headers';
|
||||
export * from './csp';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue