feat(listings): rate limit feature-listing via @nestjs/throttler (TEC-2930)
- Wire ThrottlerModule to a Redis-backed storage (shared across API
instances) using @nest-lab/throttler-storage-redis.
- Add FeatureListingThrottlerGuard that tracks per-user when JWT is
present, falling back to the real client IP behind the reverse proxy —
keeps per-user and per-IP buckets independent.
- Apply @Throttle({ default: { limit: 10, ttl: 60_000 } }) + the guard
to POST /listings/:id/feature and document 429 in Swagger.
- Integration test (feature-listing-throttle.integration.spec.ts)
verifies: 10 reqs pass / 11th returns 429 with Retry-After, separate
IPs keep their own quotas, and the tracker key logic.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { type Request } from 'express';
|
||||
|
||||
interface AuthenticatedRequest extends Request {
|
||||
user?: { sub?: string; role?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Throttler guard for the `feature-listing` endpoint.
|
||||
*
|
||||
* Extends the default ThrottlerGuard so rate limits are enforced via the
|
||||
* globally-configured Redis-backed storage (see AppModule ThrottlerModule
|
||||
* wiring). The tracker splits traffic per authenticated user when a JWT
|
||||
* payload is present, otherwise falls back to the real client IP behind
|
||||
* the reverse proxy. This produces independent per-user and per-IP buckets
|
||||
* as required by TEC-2930.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FeatureListingThrottlerGuard extends ThrottlerGuard {
|
||||
protected override getTracker(req: AuthenticatedRequest): Promise<string> {
|
||||
const userId = req.user?.sub;
|
||||
if (userId) {
|
||||
return Promise.resolve(`user:${userId}`);
|
||||
}
|
||||
|
||||
const forwarded = req.headers['x-forwarded-for'];
|
||||
const ip =
|
||||
typeof forwarded === 'string'
|
||||
? (forwarded.split(',')[0]?.trim() ?? req.ip ?? '127.0.0.1')
|
||||
: (req.ip ?? '127.0.0.1');
|
||||
|
||||
return Promise.resolve(`ip:${ip}`);
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ export { SanitizeInputMiddleware } from './middleware/sanitize-input.middleware'
|
||||
export { CsrfMiddleware } from './middleware/csrf.middleware';
|
||||
export { maskPii } from './pii-masker';
|
||||
export { ThrottlerBehindProxyGuard } from './guards/throttler-behind-proxy.guard';
|
||||
export { FeatureListingThrottlerGuard } from './guards/feature-listing-throttler.guard';
|
||||
export {
|
||||
UserRateLimitGuard,
|
||||
DEFAULT_ROLE_LIMITS,
|
||||
|
||||
Reference in New Issue
Block a user