🛡️ 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:
Danny Avila 2026-07-26 23:03:30 -04:00
parent 2d227dbc3b
commit 05f3a85191
8 changed files with 631 additions and 0 deletions

View file

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

View file

@ -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);
};

View file

@ -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);
};

View file

@ -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.

View 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);
});
});

View 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}>`;
});
}

View file

@ -1 +1,2 @@
export * from './headers';
export * from './csp';