Browse Source

Add rate limiting to authentication and sign up endpoints

pull/7263/head
Thomas Kaul 4 days ago
parent
commit
48b082ec39
  1. 9
      apps/api/src/app/app.module.ts
  2. 11
      apps/api/src/app/redis-cache/redis-cache.module.ts
  3. 10
      apps/api/src/helper/redis.helper.ts
  4. 19
      apps/api/src/main.ts
  5. 26
      apps/api/src/services/configuration/configuration.service.ts
  6. 2
      apps/api/src/services/interfaces/environment.interface.ts
  7. 26
      apps/client/src/app/core/http-response.interceptor.ts

9
apps/api/src/app/app.module.ts

@ -188,9 +188,12 @@ import { UserModule } from './user/user.module';
return !isRateLimitingEnabled; return !isRateLimitingEnabled;
}, },
storage: isRateLimitingEnabled storage: isRateLimitingEnabled
? new ThrottlerStorageRedisService( ? new ThrottlerStorageRedisService({
getRedisConnectionOptions(configurationService) ...getRedisConnectionOptions(configurationService),
) // Reject commands immediately while Redis is unavailable
enableOfflineQueue: false,
maxRetriesPerRequest: 1
})
: undefined, : undefined,
throttlers: [ throttlers: [
{ {

11
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 { ConfigurationModule } from '@ghostfolio/api/services/configuration/configuration.module';
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service'; import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
@ -14,16 +15,8 @@ import { RedisCacheService } from './redis-cache.service';
imports: [ConfigurationModule], imports: [ConfigurationModule],
inject: [ConfigurationService], inject: [ConfigurationService],
useFactory: async (configurationService: ConfigurationService) => { useFactory: async (configurationService: ConfigurationService) => {
const redisPassword = encodeURIComponent(
configurationService.get('REDIS_PASSWORD')
);
return { return {
stores: [ stores: [createKeyv(getRedisConnectionUrl(configurationService))],
createKeyv(
`redis://${redisPassword ? `:${redisPassword}` : ''}@${configurationService.get('REDIS_HOST')}:${configurationService.get('REDIS_PORT')}/${configurationService.get('REDIS_DB')}`
)
],
ttl: configurationService.get('CACHE_TTL') ttl: configurationService.get('CACHE_TTL')
}; };
} }

10
apps/api/src/helper/redis.helper.ts

@ -10,3 +10,13 @@ export function getRedisConnectionOptions(
port: configurationService.get('REDIS_PORT') 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}`;
}

19
apps/api/src/main.ts

@ -1,3 +1,4 @@
import { ConfigurationService } from '@ghostfolio/api/services/configuration/configuration.service';
import { import {
BULL_BOARD_ROUTE, BULL_BOARD_ROUTE,
DEFAULT_HOST, DEFAULT_HOST,
@ -99,25 +100,17 @@ async function bootstrap() {
}); });
} }
const TRUST_PROXY = configService.get<string>('TRUST_PROXY'); const configurationService = app.get(ConfigurationService);
if (TRUST_PROXY) { const trustProxy = configurationService.get('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;
}
if (trustProxy) {
app.set('trust proxy', trustProxy); app.set('trust proxy', trustProxy);
} }
if ( if (
configService.get<string>('ENABLE_FEATURE_RATE_LIMITING') === 'true' && configurationService.get('ENABLE_FEATURE_RATE_LIMITING') &&
!TRUST_PROXY trustProxy === ''
) { ) {
logger.warn( 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.' '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.'

26
apps/api/src/services/configuration/configuration.service.ts

@ -13,9 +13,31 @@ import {
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { DataSource } from '@prisma/client'; 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'; import ms from 'ms';
const trustProxy = makeValidator<boolean | number | string>((input) => {
if (/^\d+$/.test(input)) {
return Number(input);
} else if (input === 'false') {
return false;
} else if (input === 'true') {
return true;
}
return input;
});
@Injectable() @Injectable()
export class ConfigurationService { export class ConfigurationService {
private readonly environmentConfiguration: Environment; private readonly environmentConfiguration: Environment;
@ -115,7 +137,7 @@ export class ConfigurationService {
default: environment.rootUrl default: environment.rootUrl
}), }),
STRIPE_SECRET_KEY: str({ default: '' }), STRIPE_SECRET_KEY: str({ default: '' }),
TRUST_PROXY: str({ default: '' }), TRUST_PROXY: trustProxy({ default: '' }),
TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }), TWITTER_ACCESS_TOKEN: str({ default: 'dummyAccessToken' }),
TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }), TWITTER_ACCESS_TOKEN_SECRET: str({ default: 'dummyAccessTokenSecret' }),
TWITTER_API_KEY: str({ default: 'dummyApiKey' }), TWITTER_API_KEY: str({ default: 'dummyApiKey' }),

2
apps/api/src/services/interfaces/environment.interface.ts

@ -58,7 +58,7 @@ export interface Environment extends CleanedEnvAccessors {
REQUEST_TIMEOUT: number; REQUEST_TIMEOUT: number;
ROOT_URL: string; ROOT_URL: string;
STRIPE_SECRET_KEY: string; STRIPE_SECRET_KEY: string;
TRUST_PROXY: string; TRUST_PROXY: boolean | number | string;
TWITTER_ACCESS_TOKEN: string; TWITTER_ACCESS_TOKEN: string;
TWITTER_ACCESS_TOKEN_SECRET: string; TWITTER_ACCESS_TOKEN_SECRET: string;
TWITTER_API_KEY: string; TWITTER_API_KEY: string;

26
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) { } else if (error.status === StatusCodes.TOO_MANY_REQUESTS) {
if (!this.snackBarRef) { // Replace an already visible snack bar so that the rate limiting
this.snackBarRef = this.snackBar.open( // feedback is not swallowed
$localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`, const snackBarRef = this.snackBar.open(
undefined, $localize`Oops! It looks like you’re making too many requests. Please slow down a bit.`,
{ undefined,
duration: ms('6 seconds') {
} duration: ms('6 seconds')
); }
);
this.snackBarRef?.afterDismissed().subscribe(() => { snackBarRef.afterDismissed().subscribe(() => {
if (this.snackBarRef === snackBarRef) {
this.snackBarRef = undefined; this.snackBarRef = undefined;
}); }
} });
this.snackBarRef = snackBarRef;
} else if (error.status === StatusCodes.UNAUTHORIZED) { } else if (error.status === StatusCodes.UNAUTHORIZED) {
if (!error.url?.includes('/data-providers/ghostfolio/status')) { if (!error.url?.includes('/data-providers/ghostfolio/status')) {
if (this.webAuthnService.isEnabled()) { if (this.webAuthnService.isEnabled()) {

Loading…
Cancel
Save