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',