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>
(cherry picked from commit 02a5d4b2060dad6f5779c196b7bb2b43f871ffb2)
This commit is contained in:
bo0tzz
2026-10-27 23:12:41 +03:00
committed by Codex
parent 1707635743
commit a93bbdf3e7
5 changed files with 123 additions and 8 deletions
+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';
@@ -152,7 +152,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;
@@ -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<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 => {
@@ -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,