diff --git a/.env.example b/.env.example index 0f1c5b9005..7032199fd4 100644 --- a/.env.example +++ b/.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 diff --git a/api/server/csp.spec.js b/api/server/csp.spec.js new file mode 100644 index 0000000000..b1de7a8fdf --- /dev/null +++ b/api/server/csp.spec.js @@ -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 = + 'LibreChat' + + '' + + '' + + '' + + '
'; + +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(``); + expect(response.text).toContain(`', + '', + '', + ].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..eb4ee78b6e --- /dev/null +++ b/packages/api/src/security/csp.ts @@ -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 = /]*)>/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 = { + '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 `