diff --git a/.env.example b/.env.example index 8fdf932ddc..e7320aa3a4 100644 --- a/.env.example +++ b/.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 diff --git a/api/server/csp.spec.js b/api/server/csp.spec.js new file mode 100644 index 0000000000..6e2e4eef46 --- /dev/null +++ b/api/server/csp.spec.js @@ -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 = + 'LibreChat' + + '' + + '' + + '' + + '' + + '' + + '' + + '
'; + +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(``); + expect(response.text).toContain(`', + '', + ].join(''); + + expect(applyCspNonce(html, 'abc123')).toBe( + [ + '', + '', + '', + ].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(''), + ); + }); + + it('returns the html untouched without a nonce', () => { + const html = ''; + expect(applyCspNonce(html, '')).toBe(html); + }); +}); diff --git a/packages/api/src/security/csp.ts b/packages/api/src/security/csp.ts new file mode 100644 index 0000000000..11c5a1dbee --- /dev/null +++ b/packages/api/src/security/csp.ts @@ -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-]*$/; +/** `` 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 = { + '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 `