Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions backend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,17 @@ REDIS_HOST=localhost
REDIS_PORT=6379
USE_REDIS_CACHE=true

# ─── Rate Limiting ──────────────────────────────────────────────────────────────
# TTL values are in milliseconds.
THROTTLE_TTL_MS=60000
THROTTLE_LIMIT=100
AUTH_LOGIN_THROTTLE_TTL_MS=60000
AUTH_LOGIN_THROTTLE_LIMIT=10
AUTH_REGISTER_THROTTLE_TTL_MS=60000
AUTH_REGISTER_THROTTLE_LIMIT=5
AUTH_FORGOT_THROTTLE_TTL_MS=60000
AUTH_FORGOT_THROTTLE_LIMIT=5

# ─── CORS / Frontend ────────────────────────────────────────────────────────────
# Origin allowed by CORS (frontend URL). Must be a valid URI.
ALLOWED_ORIGIN=http://localhost:5173
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/schedule": "^6.0.1",
"@nestjs/swagger": "^7.4.2",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^10.0.2",
"@types/bcrypt": "^6.0.0",
"axios": "^1.13.2",
Expand Down
32 changes: 31 additions & 1 deletion backend/src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { Module, DynamicModule } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheModule } from '@nestjs/cache-manager';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule } from '@nestjs/throttler';
import { CustomThrottlerGuard } from './common/guards/throttler.guard';
import { redisStore } from 'cache-manager-redis-yet';
import { UsersModule } from './modules/users/users.module';
import { AuthModule } from './modules/auth/auth.module';
Expand Down Expand Up @@ -39,6 +42,27 @@ if (!isTest) {
validationSchema: envValidationSchema,
validationOptions: { abortEarly: false },
}),
ThrottlerModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
// ConfigService may return a string when the value comes from an env
// file; Number() handles both string and numeric inputs, and
// isFinite guards against NaN / Infinity from invalid entries.
const toInt = (val: string | number | undefined, fallback: number) => {
const n = Number(val);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
};

return [
{
name: 'default',
ttl: toInt(configService.get('THROTTLE_TTL_MS'), 60_000),
limit: toInt(configService.get('THROTTLE_LIMIT'), 100),
},
Comment thread
GitAddRemote marked this conversation as resolved.
];
},
}),
...conditionalImports,
CacheModule.registerAsync({
isGlobal: true,
Expand Down Expand Up @@ -121,6 +145,12 @@ if (!isTest) {
OrgInventoryModule,
],
controllers: [AppController],
providers: [AppService],
providers: [
AppService,
{
provide: APP_GUARD,
useClass: CustomThrottlerGuard,
},
Comment thread
GitAddRemote marked this conversation as resolved.
],
})
export class AppModule {}
27 changes: 27 additions & 0 deletions backend/src/common/guards/throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Injectable } from '@nestjs/common';
import { ThrottlerGuard } from '@nestjs/throttler';
import { Request } from 'express';

/**
* Extends the default ThrottlerGuard to derive the client IP from the
* IP address resolved by Express. When the app is deployed behind a trusted
* reverse proxy or load balancer, Express populates `req.ips`; otherwise this
* falls back to `req.ip` for direct connections or local development.
*
* Production note: configure proxy trust correctly on the underlying Express
* adapter (for example, `app.set('trust proxy', 1)`) so forwarded addresses
* are only used when supplied by trusted infrastructure. Reading the raw
* X-Forwarded-For header directly is avoided here because it can be spoofed
* by any client that reaches the app without passing through the proxy.
*/
@Injectable()
export class CustomThrottlerGuard extends ThrottlerGuard {
protected async getTracker(req: Record<string, any>): Promise<string> {
const request = req as Request;
// req.ips is populated by Express only when trust proxy is configured;
// it contains the full chain of forwarded IPs with spoofed entries stripped.
// Fall back to req.ip (the direct connection address) when not behind a proxy.
const clientIp = request.ips?.length ? request.ips[0] : request.ip;
return clientIp ?? 'unknown';
}
}
36 changes: 35 additions & 1 deletion backend/src/modules/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
ApiBody,
ApiBearerAuth,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { ConfigService } from '@nestjs/config';
import { AuthService } from './auth.service';
import { LocalAuthGuard } from './local-auth.guard';
Expand All @@ -30,6 +31,37 @@ import {
ResetPasswordDto,
} from './dto/password-reset.dto';

// Parse throttle config once at module load time.
// Number() handles numeric strings and NaN from non-numeric input; the
// isFinite guard ensures an invalid env var falls back to the safe default
// rather than silently producing 0 or NaN-driven throttle windows.
const toThrottleInt = (value: string | undefined, fallback: number): number => {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
};

const LOGIN_TTL = toThrottleInt(
process.env['AUTH_LOGIN_THROTTLE_TTL_MS'],
60_000,
);
const LOGIN_LIMIT = toThrottleInt(process.env['AUTH_LOGIN_THROTTLE_LIMIT'], 10);
const REGISTER_TTL = toThrottleInt(
process.env['AUTH_REGISTER_THROTTLE_TTL_MS'],
60_000,
);
const REGISTER_LIMIT = toThrottleInt(
process.env['AUTH_REGISTER_THROTTLE_LIMIT'],
5,
);
const FORGOT_TTL = toThrottleInt(
process.env['AUTH_FORGOT_THROTTLE_TTL_MS'],
60_000,
);
const FORGOT_LIMIT = toThrottleInt(
process.env['AUTH_FORGOT_THROTTLE_LIMIT'],
5,
);
Comment thread
GitAddRemote marked this conversation as resolved.

@ApiTags('auth')
@Controller('auth')
export class AuthController {
Expand Down Expand Up @@ -60,6 +92,7 @@ export class AuthController {
})
@ApiResponse({ status: 200, description: 'Successfully logged in' })
@ApiResponse({ status: 401, description: 'Invalid credentials' })
@Throttle({ default: { ttl: LOGIN_TTL, limit: LOGIN_LIMIT } })
@UseGuards(LocalAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('login')
Expand All @@ -85,7 +118,7 @@ export class AuthController {
@ApiOperation({ summary: 'Register new user' })
@ApiResponse({ status: 201, description: 'User successfully registered' })
@ApiResponse({ status: 400, description: 'Invalid input data' })
// Registration Route: No guards required
@Throttle({ default: { ttl: REGISTER_TTL, limit: REGISTER_LIMIT } })
@Post('register')
async register(@Body() userDto: UserDto) {
return this.authService.register(userDto);
Expand Down Expand Up @@ -148,6 +181,7 @@ export class AuthController {
description:
'If an account with that email exists, a password reset link has been sent',
})
@Throttle({ default: { ttl: FORGOT_TTL, limit: FORGOT_LIMIT } })
@HttpCode(HttpStatus.OK)
@Post('forgot-password')
async forgotPassword(@Body() forgotPasswordDto: ForgotPasswordDto) {
Expand Down
96 changes: 96 additions & 0 deletions backend/test/auth-rate-limit.e2e-spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import request from 'supertest';
import cookieParser from 'cookie-parser';
import { AppModule } from '../src/app.module';
import { DataSource } from 'typeorm';
import { seedSystemUser } from './helpers/seed-system-user';

/**
* Exercises the rate-limiting behaviour on auth endpoints.
*
* The default login limit is 10 requests per TTL window. This suite makes
* (limit + 1) requests with invalid credentials so it can assert a 429
* response without needing real user credentials. Because each test file
* spins up its own application instance the throttle state is isolated and
* will not bleed into other e2e suites.
*/

// Mirror the same parsing logic used by auth.controller.ts so the test stays
// in sync with production behaviour (floats are floored, non-finite values
// fall back to the default, matching toThrottleInt in the controller).
const toThrottleInt = (value: string | undefined, fallback: number): number => {
const n = Number(value);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback;
};

describe('Auth - Rate Limiting (e2e)', () => {
let app: INestApplication;

// LOGIN_LIMIT and LOGIN_TTL_MS must match the AUTH_LOGIN_THROTTLE_* defaults
// (10 requests / 60 s) or the env vars set in the test environment. Using
// toThrottleInt() ensures we mirror the exact same rounding/validation as the
// production controller, so a misconfigured env value (e.g. 'Infinity') will
// produce the same safe fallback in both places rather than hanging the loop.
const loginLimit = toThrottleInt(
process.env['AUTH_LOGIN_THROTTLE_LIMIT'],
10,
);
const loginTtlMs = toThrottleInt(
process.env['AUTH_LOGIN_THROTTLE_TTL_MS'],
60_000,
);

beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).compile();

app = moduleFixture.createNestApplication();
app.use(cookieParser());
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.init();

const dataSource = moduleFixture.get<DataSource>(DataSource);
await seedSystemUser(dataSource);
});

afterAll(async () => {
await app.close();
});

it('should return 429 after exceeding the login rate limit', async () => {
const payload = { username: '__rate_limit_test__', password: 'x' };

// Exhaust the limit — each request returns 401 (wrong creds) but still
// counts against the throttle bucket.
for (let i = 0; i < loginLimit; i++) {
await request(app.getHttpServer()).post('/auth/login').send(payload);
}

// The next request must be rejected by the throttler before reaching auth.
const response = await request(app.getHttpServer())
.post('/auth/login')
.send(payload);

expect(response.status).toBe(429);
Comment thread
GitAddRemote marked this conversation as resolved.

// Throttler must include a Retry-After header so clients know when to retry.
expect(response.headers['retry-after']).toBeDefined();
const retryAfter = Number(response.headers['retry-after']);
expect(Number.isInteger(retryAfter)).toBe(true);
expect(retryAfter).toBeGreaterThan(0);
Comment thread
GitAddRemote marked this conversation as resolved.

// Retry-After must not exceed the configured TTL window. A value larger
// than the window indicates a misconfiguration or unit mismatch (e.g. ms
// instead of seconds) in the throttler setup.
const loginTtlSeconds = Math.ceil(loginTtlMs / 1000);
expect(retryAfter).toBeLessThanOrEqual(loginTtlSeconds);
});
});
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading