-
Notifications
You must be signed in to change notification settings - Fork 0
feat: rate limit authentication endpoints #115
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
4602f86
feat: rate limit authentication endpoints
GitAddRemote 2d2a085
fix: address PR #115 review comments and resolve merge conflicts
GitAddRemote fe6198c
fix: move imports before module-level constants in auth.controller.ts
GitAddRemote c679ad8
fix: address PR #115 review comments (round 2)
GitAddRemote 2b39a11
test: harden auth-rate-limit e2e spec
GitAddRemote 6e04523
chore: merge main into fix/ISSUE-95-rate-limiting
GitAddRemote File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
|
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); | ||
|
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); | ||
| }); | ||
| }); | ||
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.