From 48b082ec3959be4559fa8e891dde4a4ec09ae080 Mon Sep 17 00:00:00 2001 From: Thomas Kaul <4159106+dtslvr@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:07:37 +0200 Subject: [PATCH] Add rate limiting to authentication and sign up endpoints --- apps/api/src/app/app.module.ts | 9 ++++--- .../src/app/redis-cache/redis-cache.module.ts | 11 ++------ apps/api/src/helper/redis.helper.ts | 10 +++++++ apps/api/src/main.ts | 19 +++++--------- .../configuration/configuration.service.ts | 26 +++++++++++++++++-- .../interfaces/environment.interface.ts | 2 +- .../src/app/core/http-response.interceptor.ts | 26 +++++++++++-------- 7 files changed, 64 insertions(+), 39 deletions(-) diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index 9a5f72b41..12bf1b14c 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -188,9 +188,12 @@ import { UserModule } from './user/user.module'; return !isRateLimitingEnabled; }, storage: isRateLimitingEnabled - ? new ThrottlerStorageRedisService( - getRedisConnectionOptions(configurationService) - ) + ? new ThrottlerStorageRedisService({ + ...getRedisConnectionOptions(configurationService), + // Reject commands immediately while Redis is unavailable + enableOfflineQueue: false, + maxRetriesPerRequest: 1 + }) : undefined, throttlers: [ { diff --git a/apps/api/src/app/redis-cache/redis-cache.module.ts b/apps/api/src/app/redis-cache/redis-cache.module.ts index d0e3228b7..8d56c7c51 100644 --- a/apps/api/src/app/redis-cache/redis-cache.module.ts +++ b/apps/api/src/app/redis-cache/redis-cache.module.ts @@ -1,3 +1,4 @@ +import { getRedisConnectionUrl } from '@ghostfolio/api/helper/redis.helper'; import { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; @@ -14,16 +15,8 @@ import { RedisCacheService } from './redis-cache.service'; imports: [ConfigurationModule], inject: [ConfigurationService], useFactory: async (configurationService: ConfigurationService) => { - const redisPassword = encodeURIComponent( - configurationService.get('REDIS_PASSWORD') - ); - return { - stores: [ - createKeyv( - `redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}` - ) - ], + stores: [createKeyv(getRedisConnectionUrl(configurationService))], ttl: configurationService.get('CACHE_TTL') }; } diff --git a/apps/api/src/helper/redis.helper.ts b/apps/api/src/helper/redis.helper.ts index 076522316..81fe905eb 100644 --- a/apps/api/src/helper/redis.helper.ts +++ b/apps/api/src/helper/redis.helper.ts @@ -10,3 +10,13 @@ export function getRedisConnectionOptions( port: configurationService.get('REDIS_PORT') }; } + +export function getRedisConnectionUrl( + configurationService: ConfigurationService +): string { + const { db, host, password, port } = + getRedisConnectionOptions(configurationService); + const encodedPassword = encodeURIComponent(password); + + return `redis://${encodedPassword ? `:${encodedPassword}` : ''}@${host}:${port}/${db}`; +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index b0b204281..33ad032e9 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -1,3 +1,4 @@ +import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { BULL_BOARD_ROUTE, DEFAULT_HOST, @@ -99,25 +100,17 @@ async function bootstrap() { }); } - const TRUST_PROXY = configService.get('TRUST_PROXY'); + const configurationService = app.get(ConfigurationService); - if (TRUST_PROXY) { - let trustProxy: boolean | number | string = TRUST_PROXY; - - if (/^\d+$/.test(TRUST_PROXY)) { - trustProxy = Number(TRUST_PROXY); - } else if (TRUST_PROXY === 'false') { - trustProxy = false; - } else if (TRUST_PROXY === 'true') { - trustProxy = true; - } + const trustProxy = configurationService.get('TRUST_PROXY'); + if (trustProxy) { app.set('trust proxy', trustProxy); } if ( - configService.get('ENABLE_FEATURE_RATE_LIMITING') === 'true' && - !TRUST_PROXY + configurationService.get('ENABLE_FEATURE_RATE_LIMITING') && + trustProxy === '' ) { logger.warn( 'Rate limiting is enabled, but TRUST_PROXY is not set. If the Ghostfolio application runs behind a reverse proxy, the rate limits are shared across all clients.' diff --git a/apps/api/src/services/configuration/configuration.service.ts b/apps/api/src/services/configuration/configuration.service.ts index 82552c29d..497dc6040 100644 --- a/apps/api/src/services/configuration/configuration.service.ts +++ b/apps/api/src/services/configuration/configuration.service.ts @@ -13,9 +13,31 @@ import { import { Injectable } from '@nestjs/common'; import { DataSource } from '@prisma/client'; -import { bool, cleanEnv, host, json, num, port, str, url } from 'envalid'; +import { + bool, + cleanEnv, + host, + json, + makeValidator, + num, + port, + str, + url +} from 'envalid'; import ms from 'ms'; +const trustProxy = makeValidator((input) => { + if (/^\d+$/.test(input)) { + return Number(input); + } else if (input === 'false') { + return false; + } else if (input === 'true') { + return true; + } + + return input; +}); + @Injectable() export class ConfigurationService { private readonly environmentConfiguration: Environment; @@ -115,7 +137,7 @@ export class ConfigurationService { default: environment.rootUrl }), STRIPE_SECRET_KEY: str({ default: '' }), - TRUST_PROXY: str({ default: '' }), + TRUST_PROXY: trustProxy({ 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 d5c8f8c2d..bb58b01ab 100644 --- a/apps/api/src/services/interfaces/environment.interface.ts +++ b/apps/api/src/services/interfaces/environment.interface.ts @@ -58,7 +58,7 @@ export interface Environment extends CleanedEnvAccessors { REQUEST_TIMEOUT: number; ROOT_URL: string; STRIPE_SECRET_KEY: string; - TRUST_PROXY: string; + TRUST_PROXY: boolean | number | string; TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_API_KEY: string; diff --git a/apps/client/src/app/core/http-response.interceptor.ts b/apps/client/src/app/core/http-response.interceptor.ts index 548e4e052..7385e090c 100644 --- a/apps/client/src/app/core/http-response.interceptor.ts +++ b/apps/client/src/app/core/http-response.interceptor.ts @@ -98,19 +98,23 @@ export class HttpResponseInterceptor implements HttpInterceptor { }); } } else if (error.status === StatusCodes.TOO_MANY_REQUESTS) { - if (!this.snackBarRef) { - this.snackBarRef = this.snackBar.open( - $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`, - undefined, - { - duration: ms('6 seconds') - } - ); + // Replace an already visible snack bar so that the rate limiting + // feedback is not swallowed + const snackBarRef = this.snackBar.open( + $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`, + undefined, + { + duration: ms('6 seconds') + } + ); - this.snackBarRef?.afterDismissed().subscribe(() => { + snackBarRef.afterDismissed().subscribe(() => { + if (this.snackBarRef === snackBarRef) { this.snackBarRef = undefined; - }); - } + } + }); + + this.snackBarRef = snackBarRef; } else if (error.status === StatusCodes.UNAUTHORIZED) { if (!error.url?.includes('/data-providers/ghostfolio/status')) { if (this.webAuthnService.isEnabled()) {