From b33874ef12337db5ca6c786f97c716d6aba3d622 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 26 Mar 2026 13:41:23 -0400 Subject: [PATCH] feat: add support for helmet configuration (#27058) --- docker/docker-compose.dev.yml | 1 + docs/docs/install/environment-variables.md | 33 ++++++++++--------- pnpm-lock.yaml | 9 +++++ server/helmet.json | 21 ++++++++++++ server/package.json | 4 ++- server/src/app.common.ts | 9 ++++- server/src/dtos/env.dto.ts | 4 +++ server/src/repositories/config.repository.ts | 29 ++++++++++++++++ .../repositories/config.repository.mock.ts | 4 +++ 9 files changed, 96 insertions(+), 18 deletions(-) create mode 100644 server/helmet.json diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 6e435b3c6b..1dac8f2c50 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -90,6 +90,7 @@ services: IMMICH_THIRD_PARTY_BUG_FEATURE_URL: https://github.com/immich-app/immich/issues IMMICH_THIRD_PARTY_DOCUMENTATION_URL: https://docs.immich.app IMMICH_THIRD_PARTY_SUPPORT_URL: https://docs.immich.app/community-guides + IMMICH_HELMET_FILE: 'true' ports: - 9230:9230 - 9231:9231 diff --git a/docs/docs/install/environment-variables.md b/docs/docs/install/environment-variables.md index e9e3bb032c..41068dee97 100644 --- a/docs/docs/install/environment-variables.md +++ b/docs/docs/install/environment-variables.md @@ -29,22 +29,23 @@ These environment variables are used by the `docker-compose.yml` file and do **N ## General -| Variable | Description | Default | Containers | Workers | -| :---------------------------------- | :---------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | -| `TZ` | Timezone | \*1 | server | microservices | -| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | -| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | -| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices | -| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `/data` | server | api, microservices | -| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | -| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | -| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | | -| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api | -| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices | -| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | -| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api | -| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices | -| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api | +| Variable | Description | Default | Containers | Workers | +| :---------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------: | :----------------------- | :----------------- | +| `TZ` | Timezone | \*1 | server | microservices | +| `IMMICH_ENV` | Environment (production, development) | `production` | server, machine learning | api, microservices | +| `IMMICH_LOG_LEVEL` | Log level (verbose, debug, log, warn, error) | `log` | server, machine learning | api, microservices | +| `IMMICH_LOG_FORMAT` | Log output format (`console`, `json`) | `console` | server | api, microservices | +| `IMMICH_MEDIA_LOCATION` | Media location inside the container ⚠️**You probably shouldn't set this**\*2⚠️ | `/data` | server | api, microservices | +| `IMMICH_CONFIG_FILE` | Path to config file | | server | api, microservices | +| `IMMICH_HELMET_FILE` | Path to a json file with [helmet](https://www.npmjs.com/package/helmet) options. Set to `false` to disable. Set to `true` to use `server/helmet.json`. | `false` | server | api, microservices | +| `NO_COLOR` | Set to `true` to disable color-coded log output | `false` | server, machine learning | | +| `CPU_CORES` | Number of cores available to the Immich server | auto-detected CPU core count | server | | +| `IMMICH_API_METRICS_PORT` | Port for the OTEL metrics | `8081` | server | api | +| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices | +| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices | +| `IMMICH_TRUSTED_PROXIES` | List of comma-separated IPs set as trusted proxies | | server | api | +| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/administration/system-integrity) | | server | api, microservices | +| `IMMICH_ALLOW_SETUP` | When `false` disables the `/auth/admin-sign-up` endpoint | `true` | server | api | \*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`. `TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f315c58bf0..e8bd9f5bc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -469,6 +469,9 @@ importers: handlebars: specifier: ^4.7.8 version: 4.7.8 + helmet: + specifier: ^8.1.0 + version: 8.1.0 i18n-iso-countries: specifier: ^7.6.0 version: 7.14.0 @@ -7837,6 +7840,10 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + helmet@8.1.0: + resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==} + engines: {node: '>=18.0.0'} + highlight.js@11.11.1: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} @@ -20556,6 +20563,8 @@ snapshots: he@1.2.0: {} + helmet@8.1.0: {} + highlight.js@11.11.1: {} history@4.10.1: diff --git a/server/helmet.json b/server/helmet.json new file mode 100644 index 0000000000..ec31752a52 --- /dev/null +++ b/server/helmet.json @@ -0,0 +1,21 @@ +{ + "contentSecurityPolicy": { + "directives": { + "default-src": ["'self'"], + "script-src": ["'self'", "'wasm-unsafe-eval", "'unsafe-inline'", "https://www.gstatic.com"], + "style-src": ["'self'", "'unsafe-inline'"], + "img-src": ["'self'", "'data:'", "'blob:'"], + "connect-src": [ + "'self'", + "blob:", + "https://pay.futo.org", + "https://static.immich.cloud", + "https://tiles.immich.cloud" + ], + "worker-src": ["'self'", "blob:"], + "frame-src": ["'none'"], + "object-src": ["'none'"], + "base-uri": ["'self'"] + } + } +} diff --git a/server/package.json b/server/package.json index a553052046..c209384aef 100644 --- a/server/package.json +++ b/server/package.json @@ -7,7 +7,8 @@ "license": "GNU Affero General Public License version 3", "files": [ "bin", - "dist" + "dist", + "helmet.json" ], "scripts": { "build": "nest build", @@ -81,6 +82,7 @@ "fluent-ffmpeg": "^2.1.2", "geo-tz": "^8.0.0", "handlebars": "^4.7.8", + "helmet": "^8.1.0", "i18n-iso-countries": "^7.6.0", "ioredis": "^5.8.2", "jose": "^5.10.0", diff --git a/server/src/app.common.ts b/server/src/app.common.ts index 98161f69d1..2159721932 100644 --- a/server/src/app.common.ts +++ b/server/src/app.common.ts @@ -2,6 +2,7 @@ import { NestExpressApplication } from '@nestjs/platform-express'; import { json } from 'body-parser'; import compression from 'compression'; import cookieParser from 'cookie-parser'; +import helmetMiddleware from 'helmet'; import { existsSync } from 'node:fs'; import sirv from 'sirv'; import { IMMICH_SERVER_START, excludePaths, serverVersion } from 'src/constants'; @@ -39,7 +40,7 @@ export async function configureExpress( }, ) { const configRepository = app.get(ConfigRepository); - const { environment, host, port, resourcePaths, network } = configRepository.getEnv(); + const { environment, host, port, helmet, resourcePaths, network } = configRepository.getEnv(); const logger = await app.resolve(LoggingRepository); logger.setContext('Bootstrap'); @@ -47,6 +48,12 @@ export async function configureExpress( app.set('trust proxy', ['loopback', ...network.trustedProxies]); app.set('etag', 'strong'); + + if (helmet.config) { + app.use(helmetMiddleware(helmet.config)); + logger.log('Initialized helmet middleware'); + } + app.use(cookieParser()); app.use(json({ limit: '10mb' })); diff --git a/server/src/dtos/env.dto.ts b/server/src/dtos/env.dto.ts index b04366c273..bdcf3614fd 100644 --- a/server/src/dtos/env.dto.ts +++ b/server/src/dtos/env.dto.ts @@ -42,6 +42,10 @@ export class EnvDto { @Optional() IMMICH_CONFIG_FILE?: string; + @IsString() + @Optional() + IMMICH_HELMET_FILE?: string; + @IsEnum(ImmichEnvironment) @Optional() IMMICH_ENV?: ImmichEnvironment; diff --git a/server/src/repositories/config.repository.ts b/server/src/repositories/config.repository.ts index 7e8082a582..1864733f87 100644 --- a/server/src/repositories/config.repository.ts +++ b/server/src/repositories/config.repository.ts @@ -5,9 +5,11 @@ import { QueueOptions } from 'bullmq'; import { plainToInstance } from 'class-transformer'; import { validateSync } from 'class-validator'; import { Request, Response } from 'express'; +import { HelmetOptions } from 'helmet'; import { RedisOptions } from 'ioredis'; import { CLS_ID, ClsModuleOptions } from 'nestjs-cls'; import { OpenTelemetryModuleOptions } from 'nestjs-otel/lib/interfaces'; +import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { citiesFile, excludePaths, IWorker } from 'src/constants'; import { Telemetry } from 'src/decorators'; @@ -58,6 +60,10 @@ export interface EnvData { config: ClsModuleOptions; }; + helmet: { + config?: HelmetOptions; + }; + database: { config: DatabaseConnectionParams; skipMigrations: boolean; @@ -143,6 +149,25 @@ const asSet = (value: string | undefined, defaults: T[]) => { return new Set(values.length === 0 ? defaults : (values as T[])); }; +const resolveHelmetFile = (helmetFile: 'true' | 'false' | string | undefined) => { + // default is off + if (!helmetFile || helmetFile === 'false') { + return; + } + + helmetFile = + helmetFile === 'true' + ? // eslint-disable-next-line unicorn/prefer-module + join(__dirname, '..', '..', 'helmet.json') + : helmetFile; + + try { + return JSON.parse(readFileSync(helmetFile).toString()) as HelmetOptions; + } catch (error) { + throw new Error(`Failed to read helmet file: ${helmetFile}`, { cause: error }); + } +}; + const getEnv = (): EnvData => { const dto = plainToInstance(EnvDto, process.env); const errors = validateSync(dto); @@ -289,6 +314,10 @@ const getEnv = (): EnvData => { vectorExtension, }, + helmet: { + config: resolveHelmetFile(dto.IMMICH_HELMET_FILE), + }, + licensePublicKey: isProd ? productionKeys : stagingKeys, network: { diff --git a/server/test/repositories/config.repository.mock.ts b/server/test/repositories/config.repository.mock.ts index 62e498372e..b5ab6e2054 100644 --- a/server/test/repositories/config.repository.mock.ts +++ b/server/test/repositories/config.repository.mock.ts @@ -35,6 +35,10 @@ const envData: EnvData = { vectorExtension: DatabaseExtension.Vectors, }, + helmet: { + config: {}, + }, + licensePublicKey: { client: 'client-public-key', server: 'server-public-key',