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 = /