diff --git a/cypress/config/settings.cypress.json b/cypress/config/settings.cypress.json index 45e38a29e..8e529c3d3 100644 --- a/cypress/config/settings.cypress.json +++ b/cypress/config/settings.cypress.json @@ -7,6 +7,7 @@ "applicationTitle": "Overseerr", "applicationUrl": "", "csrfProtection": false, + "cspFrameAncestorDomains": "", "cacheImages": false, "defaultPermissions": 32, "defaultQuotas": { diff --git a/overseerr-api.yml b/overseerr-api.yml index ef3ccf8b3..5ad439e2f 100644 --- a/overseerr-api.yml +++ b/overseerr-api.yml @@ -168,6 +168,9 @@ components: csrfProtection: type: boolean example: false + cspFrameAncestorDomains: + type: string + example: 'example.com' hideAvailable: type: boolean example: false diff --git a/package.json b/package.json index f6af746db..f7500dfd6 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "express-session": "1.17.3", "formik": "^2.4.6", "gravatar-url": "3.1.0", + "helmet": "^7.1.0", "lodash": "4.17.21", "mime": "3", "next": "^14.2.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b68e8b5e..9bdee3733 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -95,6 +95,9 @@ importers: gravatar-url: specifier: 3.1.0 version: 3.1.0 + helmet: + specifier: ^7.1.0 + version: 7.1.0 lodash: specifier: 4.17.21 version: 4.17.21 @@ -5291,6 +5294,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + helmet@7.1.0: + resolution: {integrity: sha512-g+HZqgfbpXdCkme/Cd/mZkV0aV3BZZZSugecH03kl38m/Kmdx8jKjBikpDj2cr+Iynv4KpYEviojNdTJActJAg==} + engines: {node: '>=16.0.0'} + hermes-estree@0.19.1: resolution: {integrity: sha512-daLGV3Q2MKk8w4evNMKwS8zBE/rcpA800nu1Q5kM08IKijoSnPe9Uo1iIxzPKRkn95IxxsgBMPeYHt3VG4ej2g==} @@ -15586,6 +15593,8 @@ snapshots: he@1.2.0: {} + helmet@7.1.0: {} + hermes-estree@0.19.1: {} hermes-estree@0.20.1: {} diff --git a/server/index.ts b/server/index.ts index f37d7522c..58f8858f0 100644 --- a/server/index.ts +++ b/server/index.ts @@ -33,6 +33,7 @@ import express from 'express'; import * as OpenApiValidator from 'express-openapi-validator'; import type { Store } from 'express-session'; import session from 'express-session'; +import helmet from 'helmet'; import next from 'next'; import dns from 'node:dns'; import net from 'node:net'; @@ -172,6 +173,23 @@ app }); } + // Setup Content-Security-Policy + server.use( + helmet.contentSecurityPolicy({ + useDefaults: false, + directives: { + 'default-src': + helmet.contentSecurityPolicy.dangerouslyDisableDefaultSrc, + 'frame-ancestors': [ + "'self'", + ...(settings.main.cspFrameAncestorDomains + ? [settings.main.cspFrameAncestorDomains] + : []), + ], + }, + }) + ); + // Set up sessions const sessionRespository = getRepository(Session); server.use( @@ -183,7 +201,11 @@ app cookie: { maxAge: 1000 * 60 * 60 * 24 * 30, httpOnly: true, - sameSite: settings.main.csrfProtection ? 'strict' : 'lax', + sameSite: settings.main.csrfProtection + ? 'strict' + : settings.main.cspFrameAncestorDomains + ? 'none' + : 'lax', secure: 'auto', }, store: new TypeormStore({ diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 360aeb29d..6283d0f4b 100644 --- a/server/lib/settings/index.ts +++ b/server/lib/settings/index.ts @@ -104,6 +104,7 @@ export interface MainSettings { applicationTitle: string; applicationUrl: string; csrfProtection: boolean; + cspFrameAncestorDomains: string; cacheImages: boolean; defaultPermissions: number; defaultQuotas: { @@ -311,6 +312,7 @@ class Settings { applicationTitle: 'Jellyseerr', applicationUrl: '', csrfProtection: false, + cspFrameAncestorDomains: '', cacheImages: false, defaultPermissions: Permission.REQUEST, defaultQuotas: { diff --git a/server/utils/restartFlag.ts b/server/utils/restartFlag.ts index bb5f011d5..2318af2aa 100644 --- a/server/utils/restartFlag.ts +++ b/server/utils/restartFlag.ts @@ -14,7 +14,8 @@ class RestartFlag { return ( this.settings.csrfProtection !== settings.csrfProtection || this.settings.trustProxy !== settings.trustProxy || - this.settings.httpProxy !== settings.httpProxy + this.settings.httpProxy !== settings.httpProxy || + this.settings.cspFrameAncestorDomains !== settings.cspFrameAncestorDomains ); } } diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index b4fdea783..38eeddd03 100644 --- a/src/components/Settings/SettingsMain/index.tsx +++ b/src/components/Settings/SettingsMain/index.tsx @@ -44,6 +44,9 @@ const messages = defineMessages('components.Settings.SettingsMain', { csrfProtectionTip: 'Set external API access to read-only (requires HTTPS)', csrfProtectionHoverTip: 'Do NOT enable this setting unless you understand what you are doing!', + cspFrameAncestorDomains: 'Frame-Ancestor Domains', + cspFrameAncestorDomainsTip: + 'Domains to allow embedding Jellyseer as iframe, object or embed. Incompatible with CSRF-Protection', cacheImages: 'Enable Image Caching', cacheImagesTip: 'Cache externally sourced images (requires a significant amount of disk space)', @@ -135,6 +138,7 @@ const SettingsMain = () => { applicationTitle: data?.applicationTitle, applicationUrl: data?.applicationUrl, csrfProtection: data?.csrfProtection, + cspFrameAncestorDomains: data?.cspFrameAncestorDomains, hideAvailable: data?.hideAvailable, locale: data?.locale ?? 'en', region: data?.region, @@ -157,6 +161,7 @@ const SettingsMain = () => { applicationTitle: values.applicationTitle, applicationUrl: values.applicationUrl, csrfProtection: values.csrfProtection, + cspFrameAncestorDomains: values.cspFrameAncestorDomains, hideAvailable: values.hideAvailable, locale: values.locale, region: values.region, @@ -325,6 +330,31 @@ const SettingsMain = () => { +