diff --git a/server/helmet.json b/server/helmet.json index 29b74d224..f71c77ad1 100644 --- a/server/helmet.json +++ b/server/helmet.json @@ -2,8 +2,9 @@ "contentSecurityPolicy": { "directives": { "default-src": ["'self'"], - "script-src": ["'self'", "'wasm-unsafe-eval'", "'unsafe-inline'", "https://www.gstatic.com"], - "style-src": ["'self'", "'unsafe-inline'"], + "script-src": ["'self'", "'wasm-unsafe-eval'", "https://www.gstatic.com"], + "style-src": ["'self'"], + "style-src-attr": ["'unsafe-inline'"], "img-src": ["'self'", "data:", "blob:"], "media-src": ["'self'", "data:", "blob:"], "connect-src": [ diff --git a/server/src/repositories/config.repository.spec.ts b/server/src/repositories/config.repository.spec.ts index caa82be9f..a0a7407f7 100644 --- a/server/src/repositories/config.repository.spec.ts +++ b/server/src/repositories/config.repository.spec.ts @@ -1,5 +1,5 @@ import { ImmichTelemetry } from 'src/enum.js'; -import { ConfigRepository, clearEnvCache } from 'src/repositories/config.repository.js'; +import { ConfigRepository, applyCspHashes, clearEnvCache } from 'src/repositories/config.repository.js'; const getEnv = () => { clearEnvCache(); @@ -325,3 +325,35 @@ describe('getEnv', () => { }); }); }); + +describe('applyCspHashes', () => { + const manifest = { 'script-src': [`'sha256-script'`], 'style-src': [`'sha256-style'`] }; + + it('should append the hashes of the inline content', () => { + const directives = applyCspHashes({ 'script-src': [`'self'`], 'style-src': [`'self'`] }, manifest); + + expect(directives).toEqual({ + 'script-src': [`'self'`, `'sha256-script'`], + 'style-src': [`'self'`, `'sha256-style'`], + }); + }); + + it('should skip a directive that allows unsafe-inline, which a hash would disable', () => { + const directives = applyCspHashes({ 'script-src': [`'self'`, `'unsafe-inline'`] }, manifest); + + expect(directives['script-src']).toEqual([`'self'`, `'unsafe-inline'`]); + }); + + it('should skip a directive the policy does not declare', () => { + const directives = applyCspHashes({ 'script-src': [`'self'`] }, manifest); + + expect(directives).not.toHaveProperty('style-src'); + }); + + it('should not mutate the directives it is given', () => { + const directives = { 'script-src': [`'self'`] }; + applyCspHashes(directives, manifest); + + expect(directives).toEqual({ 'script-src': [`'self'`] }); + }); +}); diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 85621179b..584bb2664 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -7,7 +7,7 @@ import { HelmetOptions } from 'helmet'; import { RedisOptions } from 'ioredis'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces/index.js'; -import { readFileSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import type { VectorExtension } from 'src/types.js'; import { IWorker, citiesFile } from 'src/constants.js'; @@ -152,7 +152,22 @@ const asSet = (value: string | undefined, defaults: T[]) => { return new Set(values.length === 0 ? defaults : (values as T[])); }; -const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined) => { +type CspOptions = Exclude, boolean>; +type CspDirectives = NonNullable; + +export const applyCspHashes = (directives: CspDirectives, manifest: Record): CspDirectives => { + const merged = { ...directives }; + for (const [directive, hashes] of Object.entries(manifest)) { + const sources = merged[directive]; + if (Array.isArray(sources) && !sources.includes(`'unsafe-inline'`)) { + merged[directive] = [...sources, ...hashes]; + } + } + + return merged; +}; + +const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined, webRoot: string) => { // default is off if (!helmetFile || helmetFile === 'false') { return; @@ -160,11 +175,35 @@ const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined) => helmetFile = helmetFile === 'true' ? join(import.meta.dirname, '..', '..', 'helmet.json') : helmetFile; + let config: HelmetOptions; try { - return JSON.parse(readFileSync(helmetFile).toString()) as HelmetOptions; + config = JSON.parse(readFileSync(helmetFile).toString()) as HelmetOptions; } catch (error) { throw new Error(`Failed to read helmet file: ${helmetFile}`, { cause: error }); } + + const contentSecurityPolicy = config.contentSecurityPolicy; + if (!contentSecurityPolicy || typeof contentSecurityPolicy === 'boolean' || !contentSecurityPolicy.directives) { + return config; + } + + // merge with csp build-time metadata + const manifestFile = join(webRoot, '.csp.json'); + + if (!existsSync(join(webRoot, 'index.html')) || !existsSync(manifestFile)) { + return config; + } + + let manifest: Record; + try { + manifest = JSON.parse(readFileSync(manifestFile).toString()) as Record; + } catch (error) { + throw new Error(`Failed to read CSP manifest: ${manifestFile}`, { cause: error }); + } + + const directives = applyCspHashes(contentSecurityPolicy.directives, manifest); + + return { ...config, contentSecurityPolicy: { ...contentSecurityPolicy, directives } }; }; const getEnv = (): EnvData => { @@ -311,7 +350,7 @@ const getEnv = (): EnvData => { }, helmet: { - config: resolveHelmetFile(dto.IMMICH_HELMET_FILE), + config: resolveHelmetFile(dto.IMMICH_HELMET_FILE, folders.web), }, licensePublicKey: isProd ? productionKeys : stagingKeys, diff --git a/web/package.json b/web/package.json index 20e04dfca..7f7ff9815 100644 --- a/web/package.json +++ b/web/package.json @@ -114,6 +114,7 @@ "factory.ts": "^1.4.1", "globals": "^17.0.0", "happy-dom": "^21.0.0", + "parse5": "^8.0.1", "prettier": "^3.7.4", "prettier-plugin-sort-json": "^4.1.1", "prettier-plugin-svelte": "^4.0.0", diff --git a/web/vite.config.ts b/web/vite.config.ts index 6a81b5bd2..7c1ab5555 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,8 +5,49 @@ import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import tailwindcss from '@tailwindcss/vite'; import { svelteTesting } from '@testing-library/svelte/vite'; import dotenv from 'dotenv'; +import { defaultTreeAdapter, parse, type DefaultTreeAdapterMap } from 'parse5'; import { visualizer } from 'rollup-plugin-visualizer'; -import { defineConfig, type ProxyOptions, type UserConfig } from 'vite'; +import { defineConfig, type Plugin, type ProxyOptions, type UserConfig } from 'vite'; +import { createHash } from 'node:crypto'; +import { readFileSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; + +const buildDir = 'build'; +const inlineTags = ['script', 'style']; + +const cspManifest = (): Plugin => ({ + name: 'immich:csp-manifest', + apply: 'build', + buildApp: { + order: 'post', + async handler() { + const indexHtml = path.join(buildDir, 'index.html'); + const hashes: Record = Object.fromEntries(inlineTags.map((tag) => [`${tag}-src`, []])); + + const walk = (node: DefaultTreeAdapterMap['node']) => { + if ( + defaultTreeAdapter.isElementNode(node) && + inlineTags.includes(node.tagName) && + !node.attrs.some(({ name }) => name === 'src') + ) { + const [child] = node.childNodes; + const content = child && defaultTreeAdapter.isTextNode(child) ? child.value : ''; + hashes[`${node.tagName}-src`].push(`'sha256-${createHash('sha256').update(content).digest('base64')}'`); + } + + if ('childNodes' in node) { + for (const child of node.childNodes) { + walk(child); + } + } + }; + + walk(parse(readFileSync(indexHtml, 'utf8'))); + + writeFileSync(path.join(buildDir, '.csp.json'), JSON.stringify(hashes, undefined, 2) + '\n'); + }, + }, +}); const upstream = { target: process.env.IMMICH_SERVER_URL || 'http://immich-server:2283/', @@ -71,6 +112,7 @@ export default defineConfig({ 'chromecast-caf-sender': './node_modules/@types/chromecast-caf-sender/index.d.ts', }, }), + cspManifest(), process.env.BUILD_STATS === 'true' ? visualizer({