Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/test-frontend.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,11 @@ jobs:
sed -i 's/^ANONYMOUS_FIVE_SECOND_LIMIT=.*/ANONYMOUS_FIVE_SECOND_LIMIT=99999/' apps/api/.env
sed -i 's/^GLOBAL_MINUTE_LIMIT=.*/GLOBAL_MINUTE_LIMIT=99999/' apps/api/.env
sed -i 's/^GLOBAL_SECOND_LIMIT=.*/GLOBAL_SECOND_LIMIT=99999/' apps/api/.env
sed -i 's/^LOGIN_IP_MINUTE_LIMIT=.*/LOGIN_IP_MINUTE_LIMIT=99999/' apps/api/.env
sed -i 's/^LOGIN_IP_TEN_SECOND_LIMIT=.*/LOGIN_IP_TEN_SECOND_LIMIT=99999/' apps/api/.env
sed -i 's/^RESET_IP_MINUTE_LIMIT=.*/RESET_IP_MINUTE_LIMIT=99999/' apps/api/.env
sed -i 's/^RESET_IP_TEN_SECOND_LIMIT=.*/RESET_IP_TEN_SECOND_LIMIT=99999/' apps/api/.env
sed -i 's/^RESET_IP_UNIQUE_EMAIL_LIMIT=.*/RESET_IP_UNIQUE_EMAIL_LIMIT=99999/' apps/api/.env

export COMPOSE_PARALLEL_LIMIT=4

Expand Down
3 changes: 3 additions & 0 deletions back-end/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ To setup the frontend application, Follow the complete setup process below..
python -m setuptools --version
```

- **Redis 7.0+**
- Required for IP-based rate limiting on sensitive endpoints. The `IpResetPasswordUniqueEmailGuard` uses the `EXPIRE ... NX` option introduced in Redis 7.0. Earlier versions don't support this syntax and will return an error, so this guard requires Redis 7.0+ to function correctly.

- **Docker Desktop with Kubernetes enabled**
- Enable Kubernetes: Docker Desktop → Settings → Kubernetes → Enable Kubernetes → Apply & Restart.

Expand Down
15 changes: 15 additions & 0 deletions back-end/apps/api/example.env
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,21 @@ GLOBAL_SECOND_LIMIT=1000
USER_MINUTE_LIMIT=100
USER_SECOND_LIMIT=10

# IP-based throttler limits (shared across all users behind the same IP)
LOGIN_IP_MINUTE_LIMIT=20
LOGIN_IP_TEN_SECOND_LIMIT=5
RESET_IP_MINUTE_LIMIT=5
RESET_IP_TEN_SECOND_LIMIT=1
RESET_IP_UNIQUE_EMAIL_LIMIT=3

# Which edge/CDN provider's header convention IpResolverService should trust to
# resolve the real client IP (see libs/common/src/ip-resolution). Only "cloudflare"
# (CF-Connecting-IP) is implemented today; it's also the default if unset. Add a new
# IpResolutionStrategy + a case in IpResolverService's constructor if a second provider
# is ever needed. Only trustworthy once the origin is unreachable except through
# Cloudflare (mTLS between Cloudflare and Traefik, configured separately in infra).
# IP_TRUST_PROVIDER=cloudflare

# Postgres database
# service name for postgres service in docker
POSTGRES_HOST=database
Expand Down
2 changes: 2 additions & 0 deletions back-end/apps/api/src/api.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import * as Joi from 'joi';

import {
DatabaseModule,
IpResolutionModule,
LoggerMiddleware,
LoggerModule,
NatsModule,
Expand Down Expand Up @@ -69,6 +70,7 @@ export const config = ConfigModule.forRoot({
ReportsModule,
ReviewerGroupsModule,
HealthModule,
IpResolutionModule,
ThrottlerStorageModule,
BlacklistModule.register({ isGlobal: true }),
SchedulerModule.register({ isGlobal: true }),
Expand Down
13 changes: 12 additions & 1 deletion back-end/apps/api/src/auth/auth.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import { AuthController } from './auth.controller';

import { AuthService } from './auth.service';

import { EmailThrottlerGuard } from '../guards';
import {
EmailThrottlerGuard,
IpLoginThrottlerGuard,
IpResetPasswordThrottlerGuard,
IpResetPasswordUniqueEmailGuard,
} from '../guards';

jest.mock('passport-jwt', () => ({
ExtractJwt: {
Expand Down Expand Up @@ -47,6 +52,12 @@ describe('AuthController', () => {
})
.overrideGuard(EmailThrottlerGuard)
.useValue(guardMock())
.overrideGuard(IpLoginThrottlerGuard)
.useValue(guardMock())
.overrideGuard(IpResetPasswordThrottlerGuard)
.useValue(guardMock())
.overrideGuard(IpResetPasswordUniqueEmailGuard)
.useValue(guardMock())
.compile();

controller = module.get<AuthController>(AuthController);
Expand Down
7 changes: 5 additions & 2 deletions back-end/apps/api/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
EmailThrottlerGuard,
extractJwtAuth,
extractJwtOtp,
IpLoginThrottlerGuard,
IpResetPasswordThrottlerGuard,
IpResetPasswordUniqueEmailGuard,
JwtAuthGuard,
JwtBlackListAuthGuard,
JwtBlackListOtpGuard,
Expand Down Expand Up @@ -80,7 +83,7 @@ export class AuthController {
})
@Post('/login')
@HttpCode(200)
@UseGuards(LocalAuthGuard, EmailThrottlerGuard)
@UseGuards(EmailThrottlerGuard, IpLoginThrottlerGuard, LocalAuthGuard)
@Serialize(LoginResponseDto)
async login(@GetUser() user: User) {
const accessToken = await this.authService.login(user);
Expand Down Expand Up @@ -133,7 +136,7 @@ export class AuthController {
})
@Post('/reset-password')
@HttpCode(200)
@UseGuards(EmailThrottlerGuard)
@UseGuards(EmailThrottlerGuard, IpResetPasswordThrottlerGuard, IpResetPasswordUniqueEmailGuard)
async createOtp(@Body() { email }: OtpLocalDto) {
return this.authService.createOtp(email);
}
Expand Down
13 changes: 6 additions & 7 deletions back-end/apps/api/src/guards/frontend-version.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ describe('FrontendVersionGuard', () => {
switchToHttp: () => ({
getRequest: () => ({
headers,
ip: '127.0.0.1',
clientIp: '127.0.0.1',
}),
}),
} as unknown as ExecutionContext;
Expand Down Expand Up @@ -245,15 +245,14 @@ describe('FrontendVersionGuard', () => {
);
});

it('should include x-forwarded-for IP in log when present', () => {
it('should include the resolved client IP in log when present', () => {
const context = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
'x-frontend-version': '0.5.0',
'x-forwarded-for': '192.168.1.100',
},
ip: '127.0.0.1',
clientIp: '192.168.1.100',
}),
}),
} as unknown as ExecutionContext;
Expand All @@ -267,15 +266,15 @@ describe('FrontendVersionGuard', () => {
expect(loggerWarnSpy).toHaveBeenCalledWith(expect.stringContaining('192.168.1.100'));
});

it('should log "unknown" when both x-forwarded-for and ip are missing', () => {
// Create context with no x-forwarded-for header and no ip property
it('should log "unknown" when the resolved client IP is missing', () => {
// Create context with no clientIp property (ClientIpMiddleware didn't run)
const context = {
switchToHttp: () => ({
getRequest: () => ({
headers: {
'x-frontend-version': '0.5.0',
},
// No ip property - will fallback to 'unknown'
// No clientIp property - will fallback to 'unknown'
}),
}),
} as unknown as ExecutionContext;
Expand Down
3 changes: 2 additions & 1 deletion back-end/apps/api/src/guards/frontend-version.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CLIENT_IP_KEY } from '@app/common';
import * as semver from 'semver';

@Injectable()
Expand Down Expand Up @@ -38,7 +39,7 @@ export class FrontendVersionGuard implements CanActivate {
const frontendVersion = request.headers['x-frontend-version'];
const minimumVersion = this.configService.get<string>('MINIMUM_SUPPORTED_FRONTEND_VERSION');
const latestVersion = this.configService.get<string>('LATEST_SUPPORTED_FRONTEND_VERSION');
const clientIp = request.headers['x-forwarded-for'] || request.ip || 'unknown';
const clientIp = request[CLIENT_IP_KEY] || 'unknown';

const UPGRADE_REQUIRED = 426;
const updateUrl = this.getUpdateUrl();
Expand Down
3 changes: 3 additions & 0 deletions back-end/apps/api/src/guards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ export * from './admin.guard';
export * from './email-throttler.guard';
export * from './frontend-version.guard';
export * from './has-key.guard';
export * from './ip-login-throttler.guard';
export * from './ip-reset-password-throttler.guard';
export * from './ip-reset-password-unique-email.guard';
export * from './ip-throttler.guard';
export * from './jwt-auth.guard';
export * from './jwt-blacklist.guard';
Expand Down
28 changes: 28 additions & 0 deletions back-end/apps/api/src/guards/ip-login-throttler.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { ThrottlerStorage } from '@nestjs/throttler';
import { CLIENT_IP_KEY } from '@app/common';
import { IpLoginThrottlerGuard } from './ip-login-throttler.guard';

describe('IpLoginThrottlerGuard', () => {
let guard: IpLoginThrottlerGuard;

beforeEach(() => {
const storageMock: Partial<ThrottlerStorage> = {};

const configServiceMock = {
get: jest.fn().mockReturnValue(100),
} as unknown as ConfigService;

const reflector = new Reflector();

guard = new IpLoginThrottlerGuard(configServiceMock, storageMock as ThrottlerStorage, reflector);
});

it('returns the IP resolved by ClientIpMiddleware, never a raw header or req.ip', async () => {
const req = { [CLIENT_IP_KEY]: '203.0.113.5', ip: '10.0.0.1', headers: { 'x-forwarded-for': '198.51.100.1' } };

const result = await (guard as unknown as { getTracker(request: Record<string, unknown>): Promise<string> }).getTracker(req);
expect(result).toBe('203.0.113.5');
});
});
39 changes: 39 additions & 0 deletions back-end/apps/api/src/guards/ip-login-throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { seconds, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler';
import { CLIENT_IP_KEY } from '@app/common';

@Injectable()
export class IpLoginThrottlerGuard extends ThrottlerGuard {
constructor(
@Inject(ConfigService) configService: ConfigService,
@Inject(ThrottlerStorage) storageService: ThrottlerStorage,
reflector: Reflector,
) {
super(
{
throttlers: [
{
name: 'login-ip-minute',
ttl: seconds(60),
limit: Number(configService.get('LOGIN_IP_MINUTE_LIMIT', 20)),
},
{
name: 'login-ip-ten-second',
ttl: seconds(10),
limit: Number(configService.get('LOGIN_IP_TEN_SECOND_LIMIT', 5)),
},
],
},
storageService,
reflector,
);
}

protected getTracker(req: Record<string, any>): Promise<string> {
// Set by ClientIpMiddleware; never touch a raw header or req.ip here directly.
return Promise.resolve(req[CLIENT_IP_KEY]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { ThrottlerStorage } from '@nestjs/throttler';
import { CLIENT_IP_KEY } from '@app/common';
import { IpResetPasswordThrottlerGuard } from './ip-reset-password-throttler.guard';

describe('IpResetPasswordThrottlerGuard', () => {
let guard: IpResetPasswordThrottlerGuard;

beforeEach(() => {
const storageMock: Partial<ThrottlerStorage> = {};

const configServiceMock = {
get: jest.fn().mockReturnValue(100),
} as unknown as ConfigService;

const reflector = new Reflector();

guard = new IpResetPasswordThrottlerGuard(configServiceMock, storageMock as ThrottlerStorage, reflector);
});

it('returns the IP resolved by ClientIpMiddleware, never a raw header or req.ip', async () => {
const req = { [CLIENT_IP_KEY]: '203.0.113.5', ip: '10.0.0.1', headers: { 'x-forwarded-for': '198.51.100.1' } };

const result = await (guard as unknown as { getTracker(request: Record<string, unknown>): Promise<string> }).getTracker(req);
expect(result).toBe('203.0.113.5');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { seconds, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler';
import { CLIENT_IP_KEY } from '@app/common';

@Injectable()
export class IpResetPasswordThrottlerGuard extends ThrottlerGuard {
constructor(
@Inject(ConfigService) configService: ConfigService,
@Inject(ThrottlerStorage) storageService: ThrottlerStorage,
reflector: Reflector,
) {
super(
{
throttlers: [
{
name: 'reset-ip-minute',
ttl: seconds(60),
limit: Number(configService.get('RESET_IP_MINUTE_LIMIT', 5)),
},
{
name: 'reset-ip-ten-second',
ttl: seconds(10),
limit: Number(configService.get('RESET_IP_TEN_SECOND_LIMIT', 1)),
},
],
},
storageService,
reflector,
);
}

protected getTracker(req: Record<string, any>): Promise<string> {
// Set by ClientIpMiddleware; never touch a raw header or req.ip here directly.
return Promise.resolve(req[CLIENT_IP_KEY]);
}
}
Loading
Loading