mirror of
https://github.com/immich-app/immich.git
synced 2026-10-28 08:55:49 +08:00
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:
6 files changed
+133
-8
No files matched your search
Generated
+10
@@ -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
@@ -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'`] });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user