mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🛡️ 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.
This commit is contained in:
parent
2d227dbc3b
commit
05f3a85191
8 changed files with 631 additions and 0 deletions
37
.env.example
37
.env.example
|
|
@ -101,6 +101,43 @@ 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.
|
||||
# CSP_ENABLED=false
|
||||
# CSP_REPORT_ONLY=true
|
||||
# CSP_REPORT_URI=
|
||||
|
||||
# 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"
|
||||
|
||||
# Minimum password length for user authentication
|
||||
# Default: 8
|
||||
# Note: When using LDAP authentication, you may want to set this to 1
|
||||
|
|
|
|||
189
api/server/csp.spec.js
Normal file
189
api/server/csp.spec.js
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const request = require('supertest');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
/** Mirrors the SPA shell: an inline style, an inline script, and a bundled script. */
|
||||
const INDEX_HTML =
|
||||
'<!DOCTYPE html><html lang="en-US"><head><title>LibreChat</title>' +
|
||||
'<style>body{margin:0}</style>' +
|
||||
'<script>window.theme="dark";</script>' +
|
||||
'<script defer src="/assets/app.js"></script>' +
|
||||
'</head><body><div id="root"></div></body></html>';
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
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('~/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';
|
||||
|
||||
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;
|
||||
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 unstamped so unsafe-inline keeps working', async () => {
|
||||
const response = await request(app).get('/');
|
||||
|
||||
expect(response.text).toContain('<style>body{margin:0}</style>');
|
||||
expect(response.headers['content-security-policy']).toContain(
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
);
|
||||
});
|
||||
|
||||
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('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,10 @@ const { logger, runAsSystem } = require('@librechat/data-schemas');
|
|||
const mongoSanitize = require('express-mongo-sanitize');
|
||||
const {
|
||||
isEnabled,
|
||||
issueCsp,
|
||||
apiNotFound,
|
||||
applyCspNonce,
|
||||
createCspPolicy,
|
||||
ErrorController,
|
||||
QUERY_DEVTOOLS_HEADER,
|
||||
createSecurityHeaders,
|
||||
|
|
@ -340,6 +343,8 @@ if (cluster.isMaster) {
|
|||
}
|
||||
}
|
||||
|
||||
const cspPolicy = createCspPolicy();
|
||||
|
||||
const sendIndexHtml = (req, res) => {
|
||||
res.set({
|
||||
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
|
||||
|
|
@ -353,6 +358,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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,8 +12,11 @@ const mongoSanitize = require('express-mongo-sanitize');
|
|||
const { logger, runAsSystem } = require('@librechat/data-schemas');
|
||||
const {
|
||||
isEnabled,
|
||||
issueCsp,
|
||||
apiNotFound,
|
||||
createMetrics,
|
||||
applyCspNonce,
|
||||
createCspPolicy,
|
||||
ErrorController,
|
||||
memoryDiagnostics,
|
||||
createSecurityHeaders,
|
||||
|
|
@ -180,6 +183,8 @@ const startServer = async () => {
|
|||
}
|
||||
}
|
||||
|
||||
const cspPolicy = createCspPolicy();
|
||||
|
||||
const sendIndexHtml = (req, res) => {
|
||||
res.set({
|
||||
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
|
||||
|
|
@ -193,6 +198,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);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -98,3 +98,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.
|
||||
|
|
|
|||
133
packages/api/src/security/csp.spec.ts
Normal file
133
packages/api/src/security/csp.spec.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import {
|
||||
issueCsp,
|
||||
applyCspNonce,
|
||||
createCspPolicy,
|
||||
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('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('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-[^']+' '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('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('applyCspNonce', () => {
|
||||
it('stamps script tags, preserves existing nonces, and leaves styles alone', () => {
|
||||
const html = [
|
||||
'<style>body { margin: 0; }</style>',
|
||||
'<script>window.theme = "dark";</script>',
|
||||
'<script defer src="/assets/app.js"></script>',
|
||||
'<script nonce="existing">window.ok = true;</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>',
|
||||
'<script nonce="existing">window.ok = true;</script>',
|
||||
].join(''),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the html untouched without a nonce', () => {
|
||||
const html = '<script src="/app.js"></script>';
|
||||
expect(applyCspNonce(html, '')).toBe(html);
|
||||
});
|
||||
});
|
||||
225
packages/api/src/security/csp.ts
Normal file
225
packages/api/src/security/csp.ts
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { randomBytes } from 'crypto';
|
||||
import { logger } from '@librechat/data-schemas';
|
||||
|
||||
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-]*$/;
|
||||
const SCRIPT_TAG_PATTERN = /<script\b([^>]*)>/gi;
|
||||
const NONCE_ATTRIBUTE_PATTERN = /\snonce\s*=/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);
|
||||
}
|
||||
|
||||
function isReportOnly(env: NodeJS.ProcessEnv): boolean {
|
||||
const value = env.CSP_REPORT_ONLY;
|
||||
if (value == null || value.trim() === '') {
|
||||
return true;
|
||||
}
|
||||
return isEnabled(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* `'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[]): string[] {
|
||||
if (scriptExtras.length === 0) {
|
||||
return [`'nonce-${NONCE_SLOT}'`, "'strict-dynamic'", "'self'"];
|
||||
}
|
||||
logger.info(
|
||||
"[CSP] CSP_SCRIPT_SRC_EXTRA is set; omitting 'strict-dynamic' so the configured script hosts take effect.",
|
||||
);
|
||||
return [`'nonce-${NONCE_SLOT}'`, "'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[]): CspDirective[] {
|
||||
return [
|
||||
['default-src', ["'self'"]],
|
||||
['base-uri', ["'self'"]],
|
||||
['object-src', ["'none'"]],
|
||||
['script-src', scriptSources(scriptExtras)],
|
||||
['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:']],
|
||||
['worker-src', ["'self'", 'blob:']],
|
||||
['manifest-src', ["'self'"]],
|
||||
['form-action', ["'self'", 'https:']],
|
||||
['frame-ancestors', ["'self'"]],
|
||||
];
|
||||
}
|
||||
|
||||
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 directives = defaultDirectives(scriptExtras);
|
||||
|
||||
for (const [directive, envName] of Object.entries(SOURCE_EXTRA_ENV)) {
|
||||
const extraSources = splitSourceList(env[envName]);
|
||||
if (extraSources.length > 0) {
|
||||
directives.push([directive, extraSources]);
|
||||
}
|
||||
}
|
||||
|
||||
const frameAncestors = splitSourceList(env.CSP_FRAME_ANCESTORS);
|
||||
if (frameAncestors.length > 0) {
|
||||
directives.push(['frame-ancestors', frameAncestors]);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const [prefix, suffix] = serializeCspDirectives(buildCspDirectives(env)).split(NONCE_SLOT);
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamps the nonce onto every `<script>` element in the SPA shell. Styles are left
|
||||
* alone; see `defaultDirectives` for why they stay on `'unsafe-inline'`.
|
||||
*/
|
||||
export function applyCspNonce(html: string, nonce: string): string {
|
||||
if (!nonce) {
|
||||
return html;
|
||||
}
|
||||
|
||||
return html.replace(SCRIPT_TAG_PATTERN, (match, attributes: string) => {
|
||||
if (NONCE_ATTRIBUTE_PATTERN.test(attributes)) {
|
||||
return match;
|
||||
}
|
||||
return `<script nonce="${nonce}"${attributes}>`;
|
||||
});
|
||||
}
|
||||
|
|
@ -1 +1,2 @@
|
|||
export * from './headers';
|
||||
export * from './csp';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue