feat: inline hashes in helmet CSP (#30529)

* feat: inline hashes in helmet CSP

* tests

* fix: svelte3 merge

* chore: clean up

---------

Co-authored-by: Daniel Dietzler <mail@ddietzler.dev>
Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
authored and GitHub committed 2026-10-27 13:23:20 -04:00
1 parent a7afb4c9e9
commit 02a5d4b206
6 files changed
+133 -8

No files matched your search

+10
View File
@@ -1188,6 +1188,9 @@ importers:
globals:
specifier: ^17.0.0
version: 17.13.0
parse5:
specifier: ^8.0.1
version: 8.0.1
prettier:
specifier: ^3.7.4
version: 3.9.10
@@ -10379,6 +10382,9 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
parseley@0.12.1:
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
@@ -24166,6 +24172,10 @@ snapshots:
dependencies:
entities: 6.0.1
parse5@8.0.1:
dependencies:
entities: 8.1.0
parseley@0.12.1:
dependencies:
leac: 0.6.0
+3 -2
View File
@@ -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": [
@@ -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'`] });
});
});
+43 -4
View File
@@ -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';
@@ -151,7 +151,22 @@ const asSet = <T>(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<NonNullable<HelmetOptions['contentSecurityPolicy']>, boolean>;
type CspDirectives = NonNullable<CspOptions['directives']>;
export const applyCspHashes = (directives: CspDirectives, manifest: Record<string, string[]>): 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;
@@ -159,11 +174,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<string, string[]>;
try {
manifest = JSON.parse(readFileSync(manifestFile).toString()) as Record<string, string[]>;
} 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 => {
@@ -310,7 +349,7 @@ const getEnv = (): EnvData => {
},
helmet: {
config: resolveHelmetFile(dto.IMMICH_HELMET_FILE),
config: resolveHelmetFile(dto.IMMICH_HELMET_FILE, folders.web),
},
licensePublicKey: isProd ? productionKeys : stagingKeys,
+1
View File
@@ -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",
+43 -1
View File
@@ -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<string, string[]> = 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({