From 5a1ab301802a6fa3c1035f2588ffa26f619cd3d9 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:31:27 +0200 Subject: [PATCH 1/3] Bugfix/market price fallback (#7813) * Fix market price fallback * Update changelog --- CHANGELOG.md | 4 ++ .../app/activities/activities.service.spec.ts | 44 ++++++++++++++++++- .../src/app/activities/activities.service.ts | 14 +++--- .../portfolio/current-rate.service.spec.ts | 16 +++++-- .../src/app/portfolio/current-rate.service.ts | 6 ++- 5 files changed, 72 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6af0cf179b..f345550243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `Nx` from version `23.1.1` to `23.2.1` +### Fixed + +- Fixed an issue where holdings without a market price have been valued at the unit price of a dividend, a fee, an interest or a liability + ## 3.79.0 - 2026-10-04 ### Added diff --git a/apps/api/src/app/activities/activities.service.spec.ts b/apps/api/src/app/activities/activities.service.spec.ts index d1eb1b4873..09aa64b0e2 100644 --- a/apps/api/src/app/activities/activities.service.spec.ts +++ b/apps/api/src/app/activities/activities.service.spec.ts @@ -3,6 +3,7 @@ import { activityDummyData, assetProfileDummyData } from '@ghostfolio/api/app/portfolio/calculator/portfolio-calculator-test-utils'; +import { WHERE_ACTIVITY_NOT_DRAFT } from '@ghostfolio/api/helper/activity.helper'; import { AssetProfileSplitService } from '@ghostfolio/api/services/asset-profile-split/asset-profile-split.service'; import { ExchangeRateDataService } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.service'; import { PrismaService } from '@ghostfolio/api/services/prisma/prisma.service'; @@ -14,7 +15,11 @@ import { import { parseDate } from '@ghostfolio/common/helper'; import { Activity, Filter } from '@ghostfolio/common/interfaces'; -import { AssetProfileSplit, DataSource } from '@prisma/client'; +import { + AssetProfileSplit, + DataSource, + Type as ActivityType +} from '@prisma/client'; import { Big } from 'big.js'; import { ActivitiesService } from './activities.service'; @@ -381,6 +386,43 @@ describe('ActivitiesService', () => { return result.activities[0]; } }); + + describe('getLatestActivity', () => { + it('filters by type and unit price and excludes draft activities', async () => { + const findFirst = jest.fn().mockResolvedValue(null); + + const service = new ActivitiesService( + null, + null, + null, + null, + null, + null, + null, + null, + null, + { order: { findFirst } } as unknown as PrismaService, + null, + null + ); + + await service.getLatestActivity({ + dataSource: DataSource.YAHOO, + symbol: 'AAPL', + types: [ActivityType.BUY, ActivityType.SELL] + }); + + expect(findFirst).toHaveBeenCalledWith({ + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }], + where: { + ...WHERE_ACTIVITY_NOT_DRAFT, + SymbolProfile: { dataSource: DataSource.YAHOO, symbol: 'AAPL' }, + type: { in: [ActivityType.BUY, ActivityType.SELL] }, + unitPrice: { gt: 0 } + } + }); + }); + }); }); function createActivity({ diff --git a/apps/api/src/app/activities/activities.service.ts b/apps/api/src/app/activities/activities.service.ts index 9e1a1b8d4c..1c933956a8 100644 --- a/apps/api/src/app/activities/activities.service.ts +++ b/apps/api/src/app/activities/activities.service.ts @@ -581,14 +581,16 @@ export class ActivitiesService { public async getLatestActivity({ dataSource, - symbol - }: AssetProfileIdentifier) { + symbol, + types + }: AssetProfileIdentifier & { types: ActivityType[] }) { return this.prismaService.order.findFirst({ - orderBy: { - date: 'desc' - }, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }], where: { - SymbolProfile: { dataSource, symbol } + ...WHERE_ACTIVITY_NOT_DRAFT, + SymbolProfile: { dataSource, symbol }, + type: { in: types }, + unitPrice: { gt: 0 } } }); } diff --git a/apps/api/src/app/portfolio/current-rate.service.spec.ts b/apps/api/src/app/portfolio/current-rate.service.spec.ts index b2ec6cd2d8..e7128b1389 100644 --- a/apps/api/src/app/portfolio/current-rate.service.spec.ts +++ b/apps/api/src/app/portfolio/current-rate.service.spec.ts @@ -5,7 +5,12 @@ import { PropertyService } from '@ghostfolio/api/services/property/property.serv import { resetHours } from '@ghostfolio/common/helper'; import { AssetProfileIdentifier } from '@ghostfolio/common/interfaces'; -import { DataSource, MarketData, Order } from '@prisma/client'; +import { + DataSource, + MarketData, + Order, + Type as ActivityType +} from '@prisma/client'; import { addDays, subDays } from 'date-fns'; import { CurrentRateService } from './current-rate.service'; @@ -220,8 +225,8 @@ describe('CurrentRateService', () => { ]); }); - it('should fall back to the unit price of the latest activity without market data', async () => { - jest + it('should fall back to the unit price of the latest buy or sell activity without market data', async () => { + const getLatestActivity = jest .spyOn(activitiesService, 'getLatestActivity') .mockResolvedValue({ unitPrice: 1000 } as Order); @@ -232,6 +237,11 @@ describe('CurrentRateService', () => { dateQuery }); + expect(getLatestActivity).toHaveBeenCalledWith({ + dataSource: DataSource.YAHOO, + symbol: 'AMZN', + types: [ActivityType.BUY, ActivityType.SELL] + }); expect(errors).toEqual(dataGatheringItems); expect(values).toEqual([ { diff --git a/apps/api/src/app/portfolio/current-rate.service.ts b/apps/api/src/app/portfolio/current-rate.service.ts index 80d37259a4..5a3daddb57 100644 --- a/apps/api/src/app/portfolio/current-rate.service.ts +++ b/apps/api/src/app/portfolio/current-rate.service.ts @@ -13,6 +13,7 @@ import { } from '@ghostfolio/common/interfaces'; import { Injectable } from '@nestjs/common'; +import { Type as ActivityType } from '@prisma/client'; import { isBefore, isToday } from 'date-fns'; import { isEmpty, uniqBy } from 'lodash-es'; @@ -140,11 +141,12 @@ export class CurrentRateService { let marketPrice = latestMarketData?.marketPrice; if (!marketPrice) { - // Fallback to unit price of latest activity + // Fallback to unit price of latest buy or sell activity const latestActivity = await this.activitiesService.getLatestActivity({ dataSource, - symbol + symbol, + types: [ActivityType.BUY, ActivityType.SELL] }); marketPrice = latestActivity?.unitPrice ?? 0; From 71ac4126f005474ab6ce725304e3618e99b4de80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Antonio=20David=20Gonz=C3=A1lez?= Date: Mon, 5 Oct 2026 19:42:35 +0200 Subject: [PATCH 2/3] Bugfix/missing thousands separator in ES (#8053) * Fix missing thousands separator * Update changelog --- CHANGELOG.md | 1 + .../emergency-fund/emergency-fund-coverage.ts | 3 ++- .../src/models/rules/liquidity/buying-power.ts | 6 ++++-- .../admin-overview/admin-overview.component.ts | 3 ++- libs/common/src/lib/chart-helper.ts | 3 ++- .../fire-calculator/fire-calculator.component.ts | 6 ++++-- .../ui/src/lib/line-chart/line-chart.component.ts | 3 ++- .../portfolio-proportion-chart.component.ts | 3 ++- .../lib/treemap-chart/treemap-chart.component.ts | 15 ++++++++++----- libs/ui/src/lib/value/value.component.ts | 7 +++++-- 10 files changed, 34 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f345550243..0d11f881a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Fixed the missing thousands separator of 4-digit numbers in certain locales - Fixed an issue where holdings without a market price have been valued at the unit price of a dividend, a fee, an interest or a liability ## 3.79.0 - 2026-10-04 diff --git a/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts b/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts index bef127ca41..8bdc9307bc 100644 --- a/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts +++ b/apps/api/src/models/rules/emergency-fund/emergency-fund-coverage.ts @@ -53,7 +53,8 @@ export class EmergencyFundCoverage extends Rule { const placeholders = { baseCurrency: ruleSettings.baseCurrency, emergencyFund: this.emergencyFundInBaseCurrency.toLocaleString( - ruleSettings.locale + ruleSettings.locale, + { useGrouping: true } ) }; diff --git a/apps/api/src/models/rules/liquidity/buying-power.ts b/apps/api/src/models/rules/liquidity/buying-power.ts index b22654cfe9..0ad6ac0a0f 100644 --- a/apps/api/src/models/rules/liquidity/buying-power.ts +++ b/apps/api/src/models/rules/liquidity/buying-power.ts @@ -44,7 +44,8 @@ export class BuyingPower extends Rule { placeholders: { baseCurrency: ruleSettings.baseCurrency, thresholdMin: ruleSettings.thresholdMin.toLocaleString( - ruleSettings.locale + ruleSettings.locale, + { useGrouping: true } ) } }), @@ -59,7 +60,8 @@ export class BuyingPower extends Rule { placeholders: { baseCurrency: ruleSettings.baseCurrency, thresholdMin: ruleSettings.thresholdMin.toLocaleString( - ruleSettings.locale + ruleSettings.locale, + { useGrouping: true } ) } }), diff --git a/apps/client/src/app/components/admin-overview/admin-overview.component.ts b/apps/client/src/app/components/admin-overview/admin-overview.component.ts index 72b9194890..fd1b72fcaf 100644 --- a/apps/client/src/app/components/admin-overview/admin-overview.component.ts +++ b/apps/client/src/app/components/admin-overview/admin-overview.component.ts @@ -170,7 +170,8 @@ export class GfAdminOverviewComponent implements OnInit { this.activitiesCount / this.userCount ).toLocaleString(this.user?.settings?.locale, { maximumFractionDigits: 2, - minimumFractionDigits: 2 + minimumFractionDigits: 2, + useGrouping: true }); return `(${formattedActivitiesCountPerUser} ${$localize`per User`})`; diff --git a/libs/common/src/lib/chart-helper.ts b/libs/common/src/lib/chart-helper.ts index bad13d0bac..72b629556a 100644 --- a/libs/common/src/lib/chart-helper.ts +++ b/libs/common/src/lib/chart-helper.ts @@ -123,7 +123,8 @@ export function getTooltipOptions({ if (currency) { label += `${yPoint.toLocaleString(locale, { maximumFractionDigits: 2, - minimumFractionDigits: 2 + minimumFractionDigits: 2, + useGrouping: true })} ${currency}`; } else if (unit) { label += `${yPoint.toFixed(2)} ${unit}`; diff --git a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts index 64bcec2934..057ce796bb 100644 --- a/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts +++ b/libs/ui/src/lib/fire-calculator/fire-calculator.component.ts @@ -358,7 +358,8 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { return `Total: ${new Intl.NumberFormat(this.locale, { currency: this.currency ?? DEFAULT_CURRENCY, currencyDisplay: 'code', - style: 'currency' + style: 'currency', + useGrouping: true }).format(totalAmount)}`; }, label: (context) => { @@ -372,7 +373,8 @@ export class GfFireCalculatorComponent implements OnChanges, OnDestroy { label += new Intl.NumberFormat(this.locale, { currency: this.currency ?? DEFAULT_CURRENCY, currencyDisplay: 'code', - style: 'currency' + style: 'currency', + useGrouping: true }).format(context.parsed.y); } diff --git a/libs/ui/src/lib/line-chart/line-chart.component.ts b/libs/ui/src/lib/line-chart/line-chart.component.ts index 782d987856..e6f1b9b2e2 100644 --- a/libs/ui/src/lib/line-chart/line-chart.component.ts +++ b/libs/ui/src/lib/line-chart/line-chart.component.ts @@ -258,7 +258,8 @@ export class GfLineChartComponent if (typeof tickValue === 'number') { return tickValue.toLocaleString(this.locale, { maximumFractionDigits: 2, - minimumFractionDigits: 2 + minimumFractionDigits: 2, + useGrouping: true }); } diff --git a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts index e27097a701..b837d887b7 100644 --- a/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts +++ b/libs/ui/src/lib/portfolio-proportion-chart/portfolio-proportion-chart.component.ts @@ -496,7 +496,8 @@ export class GfPortfolioProportionChartComponent `${name ?? symbol}`, `${value.toLocaleString(this.locale, { maximumFractionDigits: 2, - minimumFractionDigits: 2 + minimumFractionDigits: 2, + useGrouping: true })} ${this.baseCurrency ?? ''} (${percentage.toFixed(2)}%)` ]; } diff --git a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts index 11c7edbf63..5eb7e1bf62 100644 --- a/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts +++ b/libs/ui/src/lib/treemap-chart/treemap-chart.component.ts @@ -324,7 +324,8 @@ export class GfTreemapChartComponent ).toLocaleString(this.locale(), { maximumFractionDigits: 2, minimumFractionDigits: 2, - signDisplay: 'exceptZero' + signDisplay: 'exceptZero', + useGrouping: true })}%` ]; }, @@ -401,7 +402,8 @@ export class GfTreemapChartComponent raw._data.allocationInPercentage * 100 ).toLocaleString(this.locale(), { maximumFractionDigits: 2, - minimumFractionDigits: 2 + minimumFractionDigits: 2, + useGrouping: true })}%`; const name = getHoldingName(raw._data.assetProfile); @@ -410,7 +412,8 @@ export class GfTreemapChartComponent ).toLocaleString(this.locale(), { maximumFractionDigits: 2, minimumFractionDigits: 2, - signDisplay: 'exceptZero' + signDisplay: 'exceptZero', + useGrouping: true })}%`; if (raw._data.valueInBaseCurrency !== null) { @@ -420,7 +423,8 @@ export class GfTreemapChartComponent `${name} (${allocationInPercentage})`, `${value?.toLocaleString(this.locale(), { maximumFractionDigits: 2, - minimumFractionDigits: 2 + minimumFractionDigits: 2, + useGrouping: true })} ${this.baseCurrency()}`, '', $localize`Change` + ' (' + $localize`Performance` + ')', @@ -429,7 +433,8 @@ export class GfTreemapChartComponent { maximumFractionDigits: 2, minimumFractionDigits: 2, - signDisplay: 'exceptZero' + signDisplay: 'exceptZero', + useGrouping: true } )} ${this.baseCurrency()} (${netPerformanceInPercentageWithSign})` ]; diff --git a/libs/ui/src/lib/value/value.component.ts b/libs/ui/src/lib/value/value.component.ts index dfbe461f60..baa588bbd9 100644 --- a/libs/ui/src/lib/value/value.component.ts +++ b/libs/ui/src/lib/value/value.component.ts @@ -83,7 +83,8 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { return { maximumFractionDigits: digits, - minimumFractionDigits: digits + minimumFractionDigits: digits, + useGrouping: true }; }); @@ -150,7 +151,9 @@ export class GfValueComponent implements AfterViewInit, OnChanges, OnDestroy { ); } catch {} } else { - this.formattedValue = this.value?.toLocaleString(this.locale); + this.formattedValue = this.value?.toLocaleString(this.locale, { + useGrouping: true + }); } if (this.isAbsolute) { 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 3/3] 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;