From 6224a0fd0116ff1385b25ba59fec79697d0da58b Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:46:04 +0200 Subject: [PATCH] Feature/security headers (#8041) * Expose ENABLE_FEATURE_SECURITY_HEADERS * Update changelog --- CHANGELOG.md | 4 + README.md | 47 ++++---- .../helper/security-headers.helper.spec.ts | 104 ++++++++++++++++++ .../api/src/helper/security-headers.helper.ts | 37 +++++++ apps/api/src/main.ts | 30 ++--- .../configuration/configuration.service.ts | 3 + .../interfaces/environment.interface.ts | 1 + 7 files changed, 188 insertions(+), 38 deletions(-) create mode 100644 apps/api/src/helper/security-headers.helper.spec.ts create mode 100644 apps/api/src/helper/security-headers.helper.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d11f881a2..9a4a6de61e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Exposed the `ENABLE_FEATURE_SECURITY_HEADERS` environment variable to enable HTTP security headers (experimental) + ### Changed - Upgraded `Nx` from version `23.1.1` to `23.2.1` diff --git a/README.md b/README.md index 59c7c5d2c6..7c88d99919 100644 --- a/README.md +++ b/README.md @@ -87,29 +87,30 @@ Find answers to commonly asked questions about self-hosting Ghostfolio in our [F ### Supported Environment Variables -| Name | Type | Default Value | Description | -| --------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | -| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | -| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | -| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | -| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | -| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | -| `ENABLE_FEATURE_MCP` | `boolean` (optional) | `false` | Enables the server of the _Model Context Protocol_ (MCP) at `/mcp` (experimental) | -| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | -| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | -| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | -| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | -| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | -| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | -| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | -| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | -| `REDIS_HOST` | `string` | | The host where _Redis_ is running | -| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | -| `REDIS_PORT` | `number` | | The port where _Redis_ is running | -| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | -| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | -| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy | +| Name | Type | Default Value | Description | +| --------------------------------- | --------------------- | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ACCESS_TOKEN_SALT` | `string` | | A random string used as salt for access tokens | +| `API_KEY_COINGECKO_DEMO` | `string` (optional) |   | The _CoinGecko_ Demo API key | +| `API_KEY_COINGECKO_PRO` | `string` (optional) | | The _CoinGecko_ Pro API key | +| `DATABASE_URL` | `string` | | The database connection URL. If using a connection pooler, use the pooled connection URL here. e.g. `postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:5432/${POSTGRES_DB}` | +| `DIRECT_URL` | `string` (optional) | | The direct database connection URL used by the _Prisma CLI_ (e.g. for schema migrations) and seeding, bypassing any connection poolers (falls back to `DATABASE_URL`) | +| `ENABLE_FEATURE_AUTH_TOKEN` | `boolean` (optional) | `true` | Enables authentication via security token | +| `ENABLE_FEATURE_MCP` | `boolean` (optional) | `false` | Enables the server of the _Model Context Protocol_ (MCP) at `/mcp` (experimental) | +| `ENABLE_FEATURE_SECURITY_HEADERS` | `boolean` (optional) | `false` | Enables HTTP security headers, e.g. `Content-Security-Policy` and `X-Frame-Options` (experimental). Other origins cannot embed pages (e.g. via `iframe`) or resources of Ghostfolio. `Strict-Transport-Security` is not set, because it requires HTTPS: set it in the reverse proxy | +| `HOST` | `string` (optional) | `0.0.0.0` | The host where the Ghostfolio application will run on | +| `JWT_SECRET_KEY` | `string` | | A random string used for _JSON Web Tokens_ (JWT) | +| `LOG_LEVELS` | `string[]` (optional) | | The logging levels for the Ghostfolio application, e.g. `["debug","error","log","warn"]` | +| `PORT` | `number` (optional) | `3333` | The port where the Ghostfolio application will run on | +| `POSTGRES_DB` | `string` | | The name of the _PostgreSQL_ database | +| `POSTGRES_PASSWORD` | `string` | | The password of the _PostgreSQL_ database | +| `POSTGRES_USER` | `string` | | The user of the _PostgreSQL_ database | +| `REDIS_DB` | `number` (optional) | `0` | The database index of _Redis_ | +| `REDIS_HOST` | `string` | | The host where _Redis_ is running | +| `REDIS_PASSWORD` | `string` | | The password of _Redis_ | +| `REDIS_PORT` | `number` | | The port where _Redis_ is running | +| `REQUEST_TIMEOUT` | `number` (optional) | `2000` | The timeout of network requests to data providers in milliseconds | +| `ROOT_URL` | `string` (optional) | `http://0.0.0.0:3333` | The root URL of the Ghostfolio application, used for generating callback URLs and external links. | +| `TRUST_PROXY` | `string` (optional) | | The [trust proxy](https://expressjs.com/en/guide/behind-proxies.html) setting of _Express.js_ to determine the client IP address for rate limiting, e.g. `1` if the Ghostfolio application runs behind a single reverse proxy | #### OpenID Connect OIDC (experimental) diff --git a/apps/api/src/helper/security-headers.helper.spec.ts b/apps/api/src/helper/security-headers.helper.spec.ts new file mode 100644 index 0000000000..1d5f0ac03f --- /dev/null +++ b/apps/api/src/helper/security-headers.helper.spec.ts @@ -0,0 +1,104 @@ +import helmet from 'helmet'; +import { IncomingMessage, ServerResponse } from 'node:http'; + +import { getHelmetOptions } from './security-headers.helper'; + +/** + * Gives the headers which the helmet middleware sets on a response with the + * options of the helper, and the function which it calls as next middleware. + */ +function getHeaders({ + isSubscriptionEnabled +}: { + isSubscriptionEnabled: boolean; +}) { + const headers = new Map(); + const next = jest.fn(); + + const response = { + removeHeader: (name: string) => { + headers.delete(name.toLowerCase()); + }, + setHeader: (name: string, value: string) => { + headers.set(name.toLowerCase(), value); + } + }; + + helmet(getHelmetOptions({ isSubscriptionEnabled }))( + {} as IncomingMessage, + response as unknown as ServerResponse, + next + ); + + return { headers, next }; +} + +describe('getHelmetOptions', () => { + describe('without the subscription', () => { + let headers: Map; + let next: jest.Mock; + + beforeAll(() => { + ({ headers, next } = getHeaders({ isSubscriptionEnabled: false })); + }); + + it('should call the next middleware without an error', () => { + expect(next).toHaveBeenCalledWith(); + }); + + it('should set the Content-Security-Policy header', () => { + expect(headers.get('content-security-policy')).toContain( + "default-src 'self'" + ); + }); + + it('should not upgrade insecure requests', () => { + expect(headers.get('content-security-policy')).not.toContain( + 'upgrade-insecure-requests' + ); + }); + + it('should not set the Strict-Transport-Security header', () => { + expect(headers.has('strict-transport-security')).toBe(false); + }); + + it('should not set the Cross-Origin-Opener-Policy header', () => { + expect(headers.has('cross-origin-opener-policy')).toBe(false); + }); + + it('should not allow resources of Stripe', () => { + expect(headers.get('content-security-policy')).not.toContain( + 'https://js.stripe.com' + ); + }); + }); + + describe('with the subscription', () => { + let headers: Map; + let next: jest.Mock; + + beforeAll(() => { + ({ headers, next } = getHeaders({ isSubscriptionEnabled: true })); + }); + + it('should call the next middleware without an error', () => { + expect(next).toHaveBeenCalledWith(); + }); + + it('should upgrade insecure requests', () => { + expect(headers.get('content-security-policy')).toContain( + 'upgrade-insecure-requests' + ); + }); + + it('should set the Strict-Transport-Security header', () => { + expect(headers.has('strict-transport-security')).toBe(true); + }); + + it('should allow resources of Stripe', () => { + expect(headers.get('content-security-policy')).toContain( + 'https://js.stripe.com' + ); + }); + }); +}); diff --git a/apps/api/src/helper/security-headers.helper.ts b/apps/api/src/helper/security-headers.helper.ts new file mode 100644 index 0000000000..f983bdc900 --- /dev/null +++ b/apps/api/src/helper/security-headers.helper.ts @@ -0,0 +1,37 @@ +import { HelmetOptions } from 'helmet'; + +export function getHelmetOptions({ + isSubscriptionEnabled +}: { + isSubscriptionEnabled: boolean; +}): HelmetOptions { + if (isSubscriptionEnabled) { + return { + contentSecurityPolicy: { + directives: { + connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe + frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe + scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe + scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers + styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles + } + }, + crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity) + }; + } + + // The self-hosted setup can run via HTTP, hence the headers which need HTTPS + // are disabled (see https://github.com/ghostfolio/ghostfolio/issues/2102) + return { + contentSecurityPolicy: { + directives: { + scriptSrc: ["'self'", "'unsafe-inline'"], // Allow inline scripts + scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers + styleSrc: ["'self'", "'unsafe-inline'"], // Allow inline styles + upgradeInsecureRequests: null // Disable upgrade-insecure-requests directive (the browser changes each request to HTTPS, which gives a blank page via HTTP) + } + }, + crossOriginOpenerPolicy: false, // Disable Cross-Origin-Opener-Policy header (the browser ignores it via HTTP) + strictTransportSecurity: false // Disable Strict-Transport-Security header (the reverse proxy sets it, if HTTPS is required) + }; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 1282304c33..72be15b9d9 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,3 +1,4 @@ +import { getHelmetOptions } from '@ghostfolio/api/helper/security-headers.helper'; import { languageRedirectMiddleware } from '@ghostfolio/api/middlewares/language-redirect.middleware'; import { createMcpAuthorizationMiddleware } from '@ghostfolio/api/middlewares/mcp-authorization.middleware'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -102,31 +103,30 @@ async function bootstrap() { app.use(cookieParser()); - if (configService.get('ENABLE_FEATURE_SUBSCRIPTION') === 'true') { + const configurationService = app.get(ConfigurationService); + + const isSubscriptionEnabled = + configService.get('ENABLE_FEATURE_SUBSCRIPTION') === 'true'; + + if ( + isSubscriptionEnabled || + configurationService.get('ENABLE_FEATURE_SECURITY_HEADERS') + ) { + const helmetMiddleware = helmet( + getHelmetOptions({ isSubscriptionEnabled }) + ); + app.use((req: Request, res: Response, next: NextFunction) => { if (req.path.startsWith(STORYBOOK_PATH)) { next(); } else { - helmet({ - contentSecurityPolicy: { - directives: { - connectSrc: ["'self'", 'https://js.stripe.com'], // Allow connections to Stripe - frameSrc: ["'self'", 'https://js.stripe.com'], // Allow loading frames from Stripe - scriptSrc: ["'self'", "'unsafe-inline'", 'https://js.stripe.com'], // Allow inline scripts and scripts from Stripe - scriptSrcAttr: ["'self'", "'unsafe-inline'"], // Allow inline event handlers - styleSrc: ["'self'", "'unsafe-inline'"] // Allow inline styles - } - }, - crossOriginOpenerPolicy: false // Disable Cross-Origin-Opener-Policy header (for Internet Identity) - })(req, res, next); + helmetMiddleware(req, res, next); } }); } app.use(languageRedirectMiddleware); - const configurationService = app.get(ConfigurationService); - const trustProxy = configurationService.get('TRUST_PROXY'); if (trustProxy) { diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index baf1e9c62d..5d67170e80 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -75,6 +75,9 @@ export class ConfigurationService { ENABLE_FEATURE_MCP: bool({ default: false }), ENABLE_FEATURE_RATE_LIMITING: bool({ default: false }), ENABLE_FEATURE_READ_ONLY_MODE: bool({ default: false }), + // TODO: Change the default to true, when the security headers were + // available for some releases without a report of a defect + ENABLE_FEATURE_SECURITY_HEADERS: bool({ default: false }), ENABLE_FEATURE_STATISTICS: bool({ default: false }), ENABLE_FEATURE_SUBSCRIPTION: bool({ default: false }), ENABLE_FEATURE_SYSTEM_MESSAGE: bool({ default: false }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index b4e00ce57e..f0a7f39f3f 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -26,6 +26,7 @@ export interface Environment extends CleanedEnvAccessors { ENABLE_FEATURE_MCP: boolean; ENABLE_FEATURE_RATE_LIMITING: boolean; ENABLE_FEATURE_READ_ONLY_MODE: boolean; + ENABLE_FEATURE_SECURITY_HEADERS: boolean; ENABLE_FEATURE_STATISTICS: boolean; ENABLE_FEATURE_SUBSCRIPTION: boolean; ENABLE_FEATURE_SYSTEM_MESSAGE: boolean;