diff --git a/.env.example b/.env.example index 7032199fd4..3c1f741ebd 100644 --- a/.env.example +++ b/.env.example @@ -108,10 +108,18 @@ TRUST_PROXY=1 # 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'. Drop them via CSP_ADDITIONAL_DIRECTIVES +# only if your deployment uses neither the artifact code editor nor HEIC uploads. + # Add deployment-specific sources on top of LibreChat's defaults; they are # appended, never replacing them. Comma- or space-separated. Quote values # containing spaces. diff --git a/api/server/csp.spec.js b/api/server/csp.spec.js index b1de7a8fdf..2bf550be25 100644 --- a/api/server/csp.spec.js +++ b/api/server/csp.spec.js @@ -4,11 +4,17 @@ 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. */ +/** + * 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 = 'LibreChat' + '' + '' + + '' + + '' + + '' + '' + '
'; @@ -112,15 +118,31 @@ describe('Content Security Policy', () => { expect(response.text).toContain(`', '', - '', ].join(''); expect(applyCspNonce(html, 'abc123')).toBe( @@ -128,7 +150,31 @@ describe('applyCspNonce', () => { '', '', '', - '', + ].join(''), + ); + }); + + it('replaces a stale nonce rather than preserving it', () => { + const html = ''; + + expect(applyCspNonce(html, 'abc123')).toBe(''); + expect(applyCspNonce(html, 'abc123')).not.toContain('from-the-build'); + }); + + it('stamps module preloads, which strict-dynamic does not cover', () => { + const html = [ + '', + '', + '', + '', + ].join(''); + + expect(applyCspNonce(html, 'abc123')).toBe( + [ + '', + '', + '', + '', ].join(''), ); }); diff --git a/packages/api/src/security/csp.ts b/packages/api/src/security/csp.ts index a00ecd4a76..fc8f673059 100644 --- a/packages/api/src/security/csp.ts +++ b/packages/api/src/security/csp.ts @@ -1,14 +1,17 @@ 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-]*$/; -const SCRIPT_TAG_PATTERN = /]*)>/gi; -const NONCE_ATTRIBUTE_PATTERN = /\snonce\s*=/i; +/** `` 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[]]; @@ -48,12 +51,13 @@ function splitSourceList(value: string | undefined): string[] { .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 { - const value = env.CSP_REPORT_ONLY; - if (value == null || value.trim() === '') { - return true; - } - return isEnabled(value); + return parseEnvSwitch('CSP_REPORT_ONLY', env.CSP_REPORT_ONLY, true); } /** @@ -62,13 +66,15 @@ function isReportOnly(env: NodeJS.ProcessEnv): boolean { * drop it and let the (now honored) `'self'` plus those hosts govern script loading. */ function scriptSources(scriptExtras: string[]): string[] { + /* 'wasm-unsafe-eval' permits WebAssembly compilation without permitting eval(); + * the HEIC upload path (client/src/utils/heicConverter.ts -> heic-to) needs it. */ if (scriptExtras.length === 0) { - return [`'nonce-${NONCE_SLOT}'`, "'strict-dynamic'", "'self'"]; + return [`'nonce-${NONCE_SLOT}'`, "'strict-dynamic'", "'wasm-unsafe-eval'", "'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'"]; + return [`'nonce-${NONCE_SLOT}'`, "'wasm-unsafe-eval'", "'self'"]; } /** @@ -89,7 +95,10 @@ function defaultDirectives(scriptExtras: string[], frameAncestors: string[]): Cs ['connect-src', ["'self'", 'https:', 'wss:']], ['media-src', ["'self'", 'data:', 'blob:']], ['frame-src', ["'self'", 'https:', 'blob:', 'data:', 'about:']], - ['worker-src', ["'self'", 'blob:']], + /* `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', ["'self'", 'blob:', 'data:']], ['manifest-src', ["'self'"]], ['form-action', ["'self'", 'https:']], /* Replaced wholesale, not appended: merging would turn a deliberate @@ -216,19 +225,36 @@ export function issueCsp(policy: CspPolicy): CspResponse { }; } +/** `` 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 nonce onto every `