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 96a4520a7..0e5cb6e66 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 9ce9330f8..fd24e7b1f 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 7391a775a..4b7dfcfe5 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 @@ -5292,6 +5295,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==} @@ -15595,6 +15602,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 4ccc6fed1..7c08ec110 100644 --- a/server/index.ts +++ b/server/index.ts @@ -32,6 +32,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'; @@ -159,6 +160,28 @@ app }); } + // Setup Content-Security-Policy + server.use( + helmet.contentSecurityPolicy({ + useDefaults: false, + directives: { + 'default-src': ["'self'", "'unsafe-inline'"], + 'script-src': [ + "'self'", + "'unsafe-inline'", + ...(dev ? ["'unsafe-eval'"] : []), + ], + 'img-src': ["'self'", "'unsafe-inline'", 'data:', '*'], + 'frame-ancestors': [ + "'self'", + ...(settings.main.cspFrameAncestorDomains + ? [settings.main.cspFrameAncestorDomains] + : []), + ], + }, + }) + ); + // Set up sessions const sessionRespository = getRepository(Session); server.use( @@ -170,8 +193,12 @@ app cookie: { maxAge: 1000 * 60 * 60 * 24 * 30, httpOnly: true, - sameSite: settings.main.csrfProtection ? 'strict' : 'lax', - secure: 'auto', + sameSite: settings.main.cspFrameAncestorDomains + ? 'none' + : settings.main.csrfProtection + ? 'strict' + : 'lax', + secure: settings.main.cspFrameAncestorDomains ? true : 'auto', }, store: new TypeormStore({ cleanupLimit: 2, diff --git a/server/lib/settings/index.ts b/server/lib/settings/index.ts index 074a4fcdc..6f7e7e1f5 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: { @@ -310,6 +311,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 387ec5ce4..e2f52ebeb 100644 --- a/server/utils/restartFlag.ts +++ b/server/utils/restartFlag.ts @@ -13,7 +13,8 @@ class RestartFlag { return ( this.settings.csrfProtection !== settings.csrfProtection || - this.settings.trustProxy !== settings.trustProxy + this.settings.trustProxy !== settings.trustProxy || + this.settings.cspFrameAncestorDomains !== settings.cspFrameAncestorDomains ); } } diff --git a/src/components/Settings/SettingsMain/index.tsx b/src/components/Settings/SettingsMain/index.tsx index f7aac0d96..e30210fcd 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', cacheImages: 'Enable Image Caching', cacheImagesTip: 'Cache externally sourced images (requires a significant amount of disk space)', @@ -130,6 +133,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, @@ -151,6 +155,7 @@ const SettingsMain = () => { applicationTitle: values.applicationTitle, applicationUrl: values.applicationUrl, csrfProtection: values.csrfProtection, + cspFrameAncestorDomains: values.cspFrameAncestorDomains, hideAvailable: values.hideAvailable, locale: values.locale, region: values.region, @@ -318,6 +323,30 @@ const SettingsMain = () => { +