From 07be4ccf42c72752c6ddc31abd3f9f0cd67ed22e Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:46:28 +0200 Subject: [PATCH] Add rate limiting to authentication and sign up endpoints --- README.md | 43 ++++++++--------- apps/api/src/app/app.module.ts | 24 ++++++++++ apps/api/src/app/auth/auth.controller.ts | 4 ++ apps/api/src/app/user/user.controller.ts | 4 ++ apps/api/src/main.ts | 14 ++++++ .../configuration/configuration.service.ts | 1 + .../interfaces/environment.interface.ts | 1 + package-lock.json | 46 +++++++++++++++++++ package.json | 2 + 9 files changed, 118 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index a9b3a3055..ce89412a3 100644 --- a/README.md +++ b/README.md @@ -85,27 +85,28 @@ We provide official container images hosted on [Docker Hub](https://hub.docker.c ### 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 | -| `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. | +| 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 | +| `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/app/app.module.ts b/apps/api/src/app/app.module.ts index 04b1f3bf2..e57bd6201 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -2,6 +2,7 @@ import { EventsModule } from '@ghostfolio/api/events/events.module'; import { BullBoardAuthMiddleware } from '@ghostfolio/api/middlewares/bull-board-auth.middleware'; import { HtmlTemplateMiddleware } from '@ghostfolio/api/middlewares/html-template.middleware'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { CronModule } from '@ghostfolio/api/services/cron/cron.module'; import { DataProviderModule } from '@ghostfolio/api/services/data-provider/data-provider.module'; import { ExchangeRateDataModule } from '@ghostfolio/api/services/exchange-rate-data/exchange-rate-data.module'; @@ -18,13 +19,16 @@ import { import { ExpressAdapter } from '@bull-board/express'; import { BullBoardModule } from '@bull-board/nestjs'; +import { ThrottlerStorageRedisService } from '@nest-lab/throttler-storage-redis'; import { BullModule } from '@nestjs/bull'; import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; +import { ThrottlerModule } from '@nestjs/throttler'; import { StatusCodes } from 'http-status-codes'; +import ms from 'ms'; import { join } from 'node:path'; import { AccessModule } from './access/access.module'; @@ -168,6 +172,26 @@ import { UserModule } from './user/user.module'; SubscriptionModule, SymbolModule, TagsModule, + ThrottlerModule.forRootAsync({ + imports: [ConfigurationModule], + inject: [ConfigurationService], + useFactory: (configurationService: ConfigurationService) => { + return { + storage: new ThrottlerStorageRedisService({ + db: configurationService.get('REDIS_DB'), + host: configurationService.get('REDIS_HOST'), + password: configurationService.get('REDIS_PASSWORD'), + port: configurationService.get('REDIS_PORT') + }), + throttlers: [ + { + limit: 10, + ttl: ms('1 minute') + } + ] + }; + } + }), UserModule, WatchlistModule ], diff --git a/apps/api/src/app/auth/auth.controller.ts b/apps/api/src/app/auth/auth.controller.ts index 388f1dbd3..c66fd5b35 100644 --- a/apps/api/src/app/auth/auth.controller.ts +++ b/apps/api/src/app/auth/auth.controller.ts @@ -22,6 +22,7 @@ import { VERSION_NEUTRAL } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; +import { ThrottlerGuard } from '@nestjs/throttler'; import { Request, Response } from 'express'; import { getReasonPhrase, StatusCodes } from 'http-status-codes'; @@ -39,6 +40,7 @@ export class AuthController { * @deprecated */ @Get('anonymous/:accessToken') + @UseGuards(ThrottlerGuard) public async accessTokenLoginGet( @Param('accessToken') accessToken: string ): Promise { @@ -55,6 +57,7 @@ export class AuthController { } @Post('anonymous') + @UseGuards(ThrottlerGuard) public async accessTokenLogin( @Body() body: { accessToken: string } ): Promise { @@ -156,6 +159,7 @@ export class AuthController { } @Post('webauthn/verify-authentication') + @UseGuards(ThrottlerGuard) public async verifyAuthentication( @Body() body: { deviceId: string; credential: AssertionCredentialJSON } ) { diff --git a/apps/api/src/app/user/user.controller.ts b/apps/api/src/app/user/user.controller.ts index 6346ce43a..6cc159e99 100644 --- a/apps/api/src/app/user/user.controller.ts +++ b/apps/api/src/app/user/user.controller.ts @@ -37,9 +37,11 @@ import { import { REQUEST } from '@nestjs/core'; import { JwtService } from '@nestjs/jwt'; import { AuthGuard } from '@nestjs/passport'; +import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; import { User as UserModel } from '@prisma/client'; import { StatusCodes, getReasonPhrase } from 'http-status-codes'; import { merge, size } from 'lodash'; +import ms from 'ms'; import { UserService } from './user.service'; @@ -128,6 +130,8 @@ export class UserController { } @Post() + @Throttle({ default: { limit: 5, ttl: ms('1 hour') } }) + @UseGuards(ThrottlerGuard) public async signupUser(): Promise { const isUserSignupEnabled = await this.propertyService.isUserSignupEnabled(); diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 63185a48b..201b7a44c 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -97,6 +97,20 @@ async function bootstrap() { }); } + const TRUST_PROXY = configService.get('TRUST_PROXY'); + + if (TRUST_PROXY) { + let trustProxy: boolean | number | string = TRUST_PROXY; + + if (/^\d+$/.test(TRUST_PROXY)) { + trustProxy = Number(TRUST_PROXY); + } else if (TRUST_PROXY === 'true') { + trustProxy = true; + } + + app.set('trust proxy', trustProxy); + } + const HOST = configService.get('HOST') || DEFAULT_HOST; const PORT = configService.get('PORT') || DEFAULT_PORT; diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index b097627df..df35fafe2 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -114,6 +114,7 @@ export class ConfigurationService { default: environment.rootUrl }), STRIPE_SECRET_KEY: str({ default: '' }), + TRUST_PROXY: str({ default: '' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }), diff --git a/apps/api/src/services/interfaces/environment.interface.ts b/apps/api/src/services/interfaces/environment.interface.ts index 6147d4e45..6259c14f4 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -57,6 +57,7 @@ export interface Environment extends CleanedEnvAccessors { REQUEST_TIMEOUT: number; ROOT_URL: string; STRIPE_SECRET_KEY: string; + TRUST_PROXY: string; TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_API_KEY: string; diff --git a/package-lock.json b/package-lock.json index d8c4974d7..41c915bd1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,6 +29,7 @@ "@internationalized/number": "3.6.7", "@ionic/angular": "8.8.12", "@keyv/redis": "5.1.6", + "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.3", "@nestjs/common": "11.1.27", @@ -40,6 +41,7 @@ "@nestjs/platform-express": "11.1.27", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", + "@nestjs/throttler": "6.5.0", "@openrouter/ai-sdk-provider": "2.9.1", "@prisma/adapter-pg": "7.8.0", "@prisma/client": "7.8.0", @@ -6956,6 +6958,39 @@ "tslib": "^2.4.0" } }, + "node_modules/@nest-lab/throttler-storage-redis": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@nest-lab/throttler-storage-redis/-/throttler-storage-redis-1.2.0.tgz", + "integrity": "sha512-tMkUyo68NCKTR+zILk+EC35SMYBtDPZY2mCj7ZaCietWGVTnuP4zwq9ERYfvU6kJv6h8teNZrC6MJCmY6/dljw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/throttler": ">=6.0.0", + "ioredis": ">=5.0.0", + "reflect-metadata": "^0.2.1" + }, + "peerDependenciesMeta": { + "@nestjs/common": { + "optional": false + }, + "@nestjs/core": { + "optional": false + }, + "@nestjs/throttler": { + "optional": false + }, + "ioredis": { + "optional": false + }, + "reflect-metadata": { + "optional": false + } + } + }, "node_modules/@nestjs/bull": { "version": "11.0.4", "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.4.tgz", @@ -7441,6 +7476,17 @@ } } }, + "node_modules/@nestjs/throttler": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@nestjs/throttler/-/throttler-6.5.0.tgz", + "integrity": "sha512-9j0ZRfH0QE1qyrj9JjIRDz5gQLPqq9yVC2nHsrosDVAfI5HHw08/aUAWx9DZLSdQf4HDkmhTTEGLrRFHENvchQ==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "@nestjs/core": "^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0", + "reflect-metadata": "^0.1.13 || ^0.2.0" + } + }, "node_modules/@ngtools/webpack": { "version": "21.2.6", "resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-21.2.6.tgz", diff --git a/package.json b/package.json index 261d35b54..ae7d29e6e 100644 --- a/package.json +++ b/package.json @@ -73,6 +73,7 @@ "@internationalized/number": "3.6.7", "@ionic/angular": "8.8.12", "@keyv/redis": "5.1.6", + "@nest-lab/throttler-storage-redis": "1.2.0", "@nestjs/bull": "11.0.4", "@nestjs/cache-manager": "3.1.3", "@nestjs/common": "11.1.27", @@ -84,6 +85,7 @@ "@nestjs/platform-express": "11.1.27", "@nestjs/schedule": "6.1.3", "@nestjs/serve-static": "5.0.5", + "@nestjs/throttler": "6.5.0", "@openrouter/ai-sdk-provider": "2.9.1", "@prisma/adapter-pg": "7.8.0", "@prisma/client": "7.8.0",