Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 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
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
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, IpResetPasswordUniqueEmailGuard, IpResetPasswordThrottlerGuard)
async createOtp(@Body() { email }: OtpLocalDto) {
return this.authService.createOtp(email);
}
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
42 changes: 42 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,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { seconds, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler';
import { extractClientIp } from '@app/common';

@Injectable()
export class IpLoginThrottlerGuard extends ThrottlerGuard {
constructor(
@Inject(ConfigService) configService: ConfigService,
@Inject(ThrottlerStorage) storageService: ThrottlerStorage,
reflector: Reflector,

Check warning on line 13 in back-end/apps/api/src/guards/ip-login-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-login-throttler.guard.ts#L13

Added line #L13 was not covered by tests
) {
super(

Check warning on line 15 in back-end/apps/api/src/guards/ip-login-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-login-throttler.guard.ts#L15

Added line #L15 was not covered by tests
{
throttlers: [
{
name: 'login-ip-minute',
ttl: seconds(60),
limit: Number(configService.get('LOGIN_IP_MINUTE_LIMIT', 10)),
},
{
name: 'login-ip-ten-second',
ttl: seconds(10),
limit: Number(configService.get('LOGIN_IP_TEN_SECOND_LIMIT', 2)),
},
],
},
storageService,
reflector,
);
}

protected getTracker(req: Record<string, any>): Promise<string> {
const clientIp = extractClientIp(req);

Check warning on line 36 in back-end/apps/api/src/guards/ip-login-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-login-throttler.guard.ts#L35-L36

Added lines #L35 - L36 were not covered by tests
if (!clientIp) {
throw new HttpException('Unable to determine client IP', HttpStatus.INTERNAL_SERVER_ERROR);

Check warning on line 38 in back-end/apps/api/src/guards/ip-login-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-login-throttler.guard.ts#L38

Added line #L38 was not covered by tests
}
return Promise.resolve(clientIp);

Check warning on line 40 in back-end/apps/api/src/guards/ip-login-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-login-throttler.guard.ts#L40

Added line #L40 was not covered by tests
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { seconds, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler';
import { extractClientIp } from '@app/common';

@Injectable()
export class IpResetPasswordThrottlerGuard extends ThrottlerGuard {
constructor(
@Inject(ConfigService) configService: ConfigService,
@Inject(ThrottlerStorage) storageService: ThrottlerStorage,
reflector: Reflector,

Check warning on line 13 in back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts#L13

Added line #L13 was not covered by tests
) {
super(

Check warning on line 15 in back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts#L15

Added line #L15 was not covered by tests
{
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> {
const clientIp = extractClientIp(req);

Check warning on line 36 in back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts#L35-L36

Added lines #L35 - L36 were not covered by tests
if (!clientIp) {
throw new HttpException('Unable to determine client IP', HttpStatus.INTERNAL_SERVER_ERROR);

Check warning on line 38 in back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts#L38

Added line #L38 was not covered by tests
}
return Promise.resolve(clientIp);

Check warning on line 40 in back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-throttler.guard.ts#L40

Added line #L40 was not covered by tests
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { CanActivate, ExecutionContext, HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { extractClientIp } from '@app/common';
import { Redis } from 'ioredis';

const TEN_MINUTES_SECONDS = 600;

@Injectable()
export class IpResetPasswordUniqueEmailGuard implements CanActivate {
private readonly redis: Redis;
private readonly limit: number;

constructor(@Inject(ConfigService) configService: ConfigService) {
this.redis = new Redis(configService.getOrThrow('REDIS_URL'));
this.limit = Number(configService.get('RESET_IP_UNIQUE_EMAIL_LIMIT', 3));

Check warning on line 15 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L13-L15

Added lines #L13 - L15 were not covered by tests
}

async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest();

Check warning on line 19 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L18-L19

Added lines #L18 - L19 were not covered by tests

const ip = extractClientIp(req);

Check warning on line 21 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L21

Added line #L21 was not covered by tests
if (!ip) {
throw new HttpException('Unable to determine client IP', HttpStatus.INTERNAL_SERVER_ERROR);

Check warning on line 23 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L23

Added line #L23 was not covered by tests
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
}

const email: string = req.body?.email;

Check warning on line 26 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L26

Added line #L26 was not covered by tests
if (!email) return true; // let EmailThrottlerGuard handle the missing email case

const key = `reset:ip-unique-email:${ip}`;

Check warning on line 29 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L29

Added line #L29 was not covered by tests

// Check current unique email count before adding
const countBefore = await this.redis.scard(key);

Check warning on line 32 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L32

Added line #L32 was not covered by tests
if (countBefore >= this.limit) {
throw new HttpException('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);

Check warning on line 34 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L34

Added line #L34 was not covered by tests
}

// Add email to the set and set TTL on first entry.
// The 'NX' option sets the expiry only if one is not already present, preserving
// the fixed 10-minute window. Requires Redis 7.0+.
await this.redis.sadd(key, email);
await this.redis.expire(key, TEN_MINUTES_SECONDS, 'NX');

Check warning on line 41 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L40-L41

Added lines #L40 - L41 were not covered by tests

// Re-check after adding to handle concurrent requests
const countAfter = await this.redis.scard(key);

Check warning on line 44 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L44

Added line #L44 was not covered by tests
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
if (countAfter > this.limit) {
throw new HttpException('Too Many Requests', HttpStatus.TOO_MANY_REQUESTS);

Check warning on line 46 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L46

Added line #L46 was not covered by tests
}

return true;

Check warning on line 49 in back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts

View check run for this annotation

Codecov / codecov/patch

back-end/apps/api/src/guards/ip-reset-password-unique-email.guard.ts#L49

Added line #L49 was not covered by tests
}
}
3 changes: 2 additions & 1 deletion back-end/apps/api/src/guards/ip-throttler.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { HttpException, HttpStatus, Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Reflector } from '@nestjs/core';
import { seconds, ThrottlerGuard, ThrottlerStorage } from '@nestjs/throttler';
import { extractClientIp } from '@app/common';

@Injectable()
export class IpThrottlerGuard extends ThrottlerGuard {
Expand Down Expand Up @@ -32,7 +33,7 @@ export class IpThrottlerGuard extends ThrottlerGuard {
}

protected getTracker(req: Record<string, any>): Promise<string> {
const clientIp = req.headers['x-forwarded-for'] || req.ip;
const clientIp = extractClientIp(req);
if (!clientIp) {
throw new HttpException('Unable to determine client IP', HttpStatus.INTERNAL_SERVER_ERROR);
}
Expand Down
99 changes: 99 additions & 0 deletions back-end/libs/common/src/utils/extractClientIp.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { extractClientIp } from './extractClientIp';

describe('extractClientIp', () => {
it('returns the CF-Connecting-IP header when present', () => {
const req = {
headers: { 'cf-connecting-ip': '203.0.113.9' },
ip: '127.0.0.1',
};

expect(extractClientIp(req)).toBe('203.0.113.9');
});

it('trims whitespace from the CF-Connecting-IP header', () => {
const req = {
headers: { 'cf-connecting-ip': ' 203.0.113.9 ' },
ip: '127.0.0.1',
};

expect(extractClientIp(req)).toBe('203.0.113.9');
});

it('prefers CF-Connecting-IP over X-Forwarded-For and req.ip', () => {
const req = {
headers: {
'cf-connecting-ip': '203.0.113.9',
'x-forwarded-for': '198.51.100.1, 10.0.0.1',
},
ip: '127.0.0.1',
};

expect(extractClientIp(req)).toBe('203.0.113.9');
});

it('falls back to X-Forwarded-For when CF-Connecting-IP is absent', () => {
const req = {
headers: { 'x-forwarded-for': '198.51.100.1, 10.0.0.1' },
ip: '127.0.0.1',
};

expect(extractClientIp(req)).toBe('198.51.100.1');
});

it('uses the leftmost value and trims it from a multi-value X-Forwarded-For header', () => {
const req = {
headers: { 'x-forwarded-for': ' 198.51.100.1 , 10.0.0.1, 10.0.0.2' },
ip: '127.0.0.1',
};

expect(extractClientIp(req)).toBe('198.51.100.1');
});

it('ignores an empty CF-Connecting-IP header and falls back to X-Forwarded-For', () => {
const req = {
headers: { 'cf-connecting-ip': ' ', 'x-forwarded-for': '198.51.100.1' },
ip: '127.0.0.1',
};

expect(extractClientIp(req)).toBe('198.51.100.1');
});

it('ignores an empty X-Forwarded-For header and falls back to req.ip', () => {
const req = {
headers: { 'x-forwarded-for': '' },
ip: '127.0.0.1',
};

expect(extractClientIp(req)).toBe('127.0.0.1');
});

it('falls back to req.ip when headers are missing entirely', () => {
const req = { headers: {}, ip: '127.0.0.1' };

expect(extractClientIp(req)).toBe('127.0.0.1');
});

it('falls back to req.ip when CF-Connecting-IP is provided as a header array (string[])', () => {
const req = {
headers: { 'cf-connecting-ip': ['203.0.113.9', '203.0.113.10'] },
ip: '127.0.0.1',
};

expect(extractClientIp(req)).toBe('127.0.0.1');
});

it('falls back to req.ip when X-Forwarded-For is provided as a header array (string[])', () => {
const req = {
headers: { 'x-forwarded-for': ['198.51.100.1', '10.0.0.1'] },
ip: '127.0.0.1',
};

expect(extractClientIp(req)).toBe('127.0.0.1');
});

it('returns undefined when no IP can be determined', () => {
const req = { headers: {}, ip: undefined };

expect(extractClientIp(req)).toBeUndefined();
});
});
27 changes: 27 additions & 0 deletions back-end/libs/common/src/utils/extractClientIp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Extracts the real client IP from a request.
*
* Resolution order:
* 1. CF-Connecting-IP — set by Cloudflare; not client-controllable.
* 2. X-Forwarded-For — first (leftmost) value, which is the original client
* IP appended by the first proxy in the chain.
* 3. req.ip — Express fallback (direct connection address).
*
* Note: X-Forwarded-For is client-controllable when no trusted proxy is
* configured. CF-Connecting-IP takes precedence precisely because Cloudflare
* strips and rewrites it, making it safe to trust.
*/
export function extractClientIp(req: Record<string, any>): string | undefined {
Comment thread
jbair06 marked this conversation as resolved.
Outdated
const cf = req.headers?.['cf-connecting-ip'];
if (cf && typeof cf === 'string' && cf.trim()) {
return cf.trim();
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
}

const forwarded = req.headers?.['x-forwarded-for'];
if (forwarded && typeof forwarded === 'string') {
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
const first = forwarded.split(',')[0].trim();
if (first) return first;
}

return req.ip;
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
}
Comment thread
steven-sheehy marked this conversation as resolved.
Outdated
1 change: 1 addition & 0 deletions back-end/libs/common/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './buffer';
export * from './extractClientIp';
export * from './sdk';
export * from './mirrorNode';
export * from './typeORM';
Expand Down
Loading