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. 12
      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;
},
storage: isRateLimitingEnabled
? new ThrottlerStorageRedisService(
getRedisConnectionOptions(configurationService)
)
? new ThrottlerStorageRedisService({
...getRedisConnectionOptions(configurationService),
// Reject commands immediately while Redis is unavailable
enableOfflineQueue: false,
maxRetriesPerRequest: 1
})
: undefined,
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 { 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')
};
}

10
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}`;
}

19
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<string>('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<string>('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.'

26
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<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()
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' }),

2
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;

12
apps/client/src/app/core/http-response.interceptor.ts

@ -98,8 +98,9 @@ export class HttpResponseInterceptor implements HttpInterceptor {
});
}
} else if (error.status === StatusCodes.TOO_MANY_REQUESTS) {
if (!this.snackBarRef) {
this.snackBarRef = this.snackBar.open(
// 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,
{
@ -107,10 +108,13 @@ export class HttpResponseInterceptor implements HttpInterceptor {
}
);
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()) {

Loading…
Cancel
Save