diff --git a/admin/src/app/api/[...proxy]/route.ts b/admin/src/app/api/[...proxy]/route.ts index 75991ff2..2de96934 100644 --- a/admin/src/app/api/[...proxy]/route.ts +++ b/admin/src/app/api/[...proxy]/route.ts @@ -42,6 +42,12 @@ async function proxyRequest(req: NextRequest) { const contentType = req.headers.get("content-type"); if (contentType) headers.set("Content-Type", contentType); + // Forward real client IP so backend throttler can rate-limit per user, + // not per proxy container. nginx sets X-Forwarded-For; we pass it through. + const clientIp = + req.headers.get("x-forwarded-for") ?? req.headers.get("x-real-ip") ?? ""; + if (clientIp) headers.set("x-forwarded-for", clientIp); + const init: RequestInit = { method: req.method, headers }; if (req.method !== "GET" && req.method !== "HEAD") { diff --git a/backend/src/common/utils/client-ip.util.ts b/backend/src/common/utils/client-ip.util.ts new file mode 100644 index 00000000..4f2f6033 --- /dev/null +++ b/backend/src/common/utils/client-ip.util.ts @@ -0,0 +1,30 @@ +import type { Request } from 'express'; + +/** + * Extract real client IP from a request, respecting reverse-proxy headers. + * Order of trust: `X-Forwarded-For` (first hop) → `X-Real-IP` → `req.ip` → + * connection remote address. Returns 'unknown' if all fail (still usable as + * a Redis key — just dedups all anonymous-source traffic together). + * + * NOTE: We don't enable Express `trust proxy` globally because it would + * affect every module's `req.ip`. Reading the header directly here is + * scoped to each guard/controller that uses this utility. + * + * Deployment context: + * User → nginx → Next.js proxy → backend + * nginx sets X-Forwarded-For; Next.js proxy forwards it verbatim. + * The first entry in the header chain is the real client IP. + */ +export function getClientIp(req: Request): string { + const xff = req.headers['x-forwarded-for']; + if (typeof xff === 'string' && xff.length > 0) { + return xff.split(',')[0].trim(); + } + if (Array.isArray(xff) && xff.length > 0) { + return xff[0].split(',')[0].trim(); + } + const xri = req.headers['x-real-ip']; + if (typeof xri === 'string' && xri.length > 0) return xri.trim(); + + return req.ip || req.socket?.remoteAddress || 'unknown'; +} diff --git a/backend/src/modules/articles/utils/client-ip.util.ts b/backend/src/modules/articles/utils/client-ip.util.ts index 22018922..5b7ba4dc 100644 --- a/backend/src/modules/articles/utils/client-ip.util.ts +++ b/backend/src/modules/articles/utils/client-ip.util.ts @@ -1,25 +1,2 @@ -import type { Request } from 'express'; - -/** - * Extract real client IP from a request, respecting reverse-proxy headers. - * Order of trust: `X-Forwarded-For` (first hop) → `X-Real-IP` → `req.ip` → - * connection remote address. Returns 'unknown' if all fail (still usable as - * a Redis key — just dedups all anonymous-source traffic together). - * - * NOTE: We don't enable Express `trust proxy` globally because it would - * affect every module's `req.ip`. Reading the header directly here is - * scoped to articles' rate-limit logic only. - */ -export function getClientIp(req: Request): string { - const xff = req.headers['x-forwarded-for']; - if (typeof xff === 'string' && xff.length > 0) { - return xff.split(',')[0].trim(); - } - if (Array.isArray(xff) && xff.length > 0) { - return xff[0].split(',')[0].trim(); - } - const xri = req.headers['x-real-ip']; - if (typeof xri === 'string' && xri.length > 0) return xri.trim(); - - return req.ip || req.socket?.remoteAddress || 'unknown'; -} +// Re-export from shared common utility — logic lives in src/common/utils/client-ip.util.ts +export { getClientIp } from '../../../common/utils/client-ip.util'; diff --git a/backend/src/modules/race-result/guards/real-ip-throttler.guard.ts b/backend/src/modules/race-result/guards/real-ip-throttler.guard.ts new file mode 100644 index 00000000..22729ac9 --- /dev/null +++ b/backend/src/modules/race-result/guards/real-ip-throttler.guard.ts @@ -0,0 +1,23 @@ +import { Injectable } from '@nestjs/common'; +import { ThrottlerGuard } from '@nestjs/throttler'; +import type { Request } from 'express'; +import { getClientIp } from '../../../common/utils/client-ip.util'; + +/** + * ThrottlerGuard override that extracts the real client IP from + * `X-Forwarded-For` / `X-Real-IP` headers instead of `req.ip`. + * + * Required because requests arrive through the Next.js proxy + * (frontend / admin), which means `req.ip` always resolves to the + * container IP of that proxy — not the end-user's IP. Using this + * guard ensures rate limits are enforced per real user, not per proxy. + * + * The Next.js proxy routes forward the original `x-forwarded-for` header + * that nginx injected, so the first hop in that header is the real client IP. + */ +@Injectable() +export class RealIpThrottlerGuard extends ThrottlerGuard { + protected async getTracker(req: Request): Promise { + return getClientIp(req); + } +} diff --git a/backend/src/modules/race-result/race-result.controller.ts b/backend/src/modules/race-result/race-result.controller.ts index 1c54bdae..78a8d9a1 100644 --- a/backend/src/modules/race-result/race-result.controller.ts +++ b/backend/src/modules/race-result/race-result.controller.ts @@ -24,7 +24,8 @@ import { ApiConsumes, ApiBody, } from '@nestjs/swagger'; -import { Throttle, ThrottlerGuard } from '@nestjs/throttler'; +import { Throttle } from '@nestjs/throttler'; +import { RealIpThrottlerGuard } from './guards/real-ip-throttler.guard'; import { FileInterceptor, FileFieldsInterceptor, @@ -455,7 +456,7 @@ export class RaceResultController { // Rate-limited per IP to protect against scraping + brute-force cache busting. @Post('result-image/upload-bg') - @UseGuards(ThrottlerGuard) + @UseGuards(RealIpThrottlerGuard) @ApiOperation({ summary: 'Upload a custom background photo, get back a photoId for reuse', description: @@ -503,7 +504,7 @@ export class RaceResultController { } @Get('result-image/:raceId/:bib') - @UseGuards(ThrottlerGuard) + @UseGuards(RealIpThrottlerGuard) // Generous limit — a single modal open = 7 preview requests (6 thumbs + 1 // main), and toggling gradient/template bumps the token → fresh requests. // 120/min allows ~15 modal interactions per minute per IP before throttling. @@ -571,7 +572,7 @@ export class RaceResultController { } @Post('result-image/:raceId/:bib') - @UseGuards(ThrottlerGuard) + @UseGuards(RealIpThrottlerGuard) // @Throttle({ default: { ttl: 60_000, limit: 20 } }) @ApiOperation({ summary: 'Generate full-res result image for an athlete (S3-cached)', @@ -731,7 +732,7 @@ export class RaceResultController { } @Get('badges/:raceId/:bib') - @UseGuards(ThrottlerGuard) + @UseGuards(RealIpThrottlerGuard) // @Throttle({ default: { ttl: 60_000, limit: 60 } }) @ApiOperation({ summary: 'Get badges (PB / Podium / Sub-X / Ultra / Streak) for an athlete', @@ -766,7 +767,7 @@ export class RaceResultController { } @Post('share-count/:raceId') - @UseGuards(ThrottlerGuard) + @UseGuards(RealIpThrottlerGuard) // @Throttle({ default: { ttl: 60_000, limit: 20 } }) @ApiOperation({ summary: @@ -783,7 +784,7 @@ export class RaceResultController { // ─── Analytics (D-1) + Admin stats (D-3) ────────────────────── @Post('result-image-share') - @UseGuards(ThrottlerGuard) + @UseGuards(RealIpThrottlerGuard) // @Throttle({ default: { ttl: 60_000, limit: 30 } }) @ApiOperation({ summary: diff --git a/backend/src/modules/race-result/race-result.module.ts b/backend/src/modules/race-result/race-result.module.ts index 6cf2689a..93f060f6 100644 --- a/backend/src/modules/race-result/race-result.module.ts +++ b/backend/src/modules/race-result/race-result.module.ts @@ -13,6 +13,7 @@ import { BadgeService } from './services/badge.service'; import { RaceSyncCron } from './services/race-sync.cron'; import { ShareEventService } from './services/share-event.service'; import { ShareNurtureCron } from './services/share-nurture.cron'; +import { RealIpThrottlerGuard } from './guards/real-ip-throttler.guard'; import { RacesModule } from '../races/races.module'; import { UploadModule } from '../upload/upload.module'; @@ -28,14 +29,15 @@ import { UploadModule } from '../upload/upload.module'; // Module-scoped throttler so @Throttle decorators on result-image / // share-count endpoints apply without colliding with other modules. // Default cap is an umbrella; per-endpoint @Throttle() decorators override. - - // temporatyly disable throttler to unblock sync while we investigate performance issues - // ThrottlerModule.forRoot([ - // { - // ttl: 60_000, - // limit: 60*20, - // }, - // ]), + // RealIpThrottlerGuard (registered in providers) overrides getTracker() to + // extract the real client IP from X-Forwarded-For instead of req.ip, since + // all requests arrive via the Next.js proxy container. + ThrottlerModule.forRoot([ + { + ttl: 60_000, + limit: 60 * 20, + }, + ]), RacesModule, UploadModule, ], diff --git a/frontend/app/api/[...proxy]/route.ts b/frontend/app/api/[...proxy]/route.ts index e5bfe60a..04df635e 100644 --- a/frontend/app/api/[...proxy]/route.ts +++ b/frontend/app/api/[...proxy]/route.ts @@ -32,6 +32,12 @@ async function proxyRequest(req: NextRequest) { const contentType = req.headers.get("content-type"); if (contentType) headers.set("Content-Type", contentType); + // Forward real client IP so backend throttler can rate-limit per user, + // not per proxy container. nginx sets X-Forwarded-For; we pass it through. + const clientIp = + req.headers.get("x-forwarded-for") ?? req.headers.get("x-real-ip") ?? ""; + if (clientIp) headers.set("x-forwarded-for", clientIp); + const init: RequestInit = { method: req.method, headers }; if (req.method !== "GET" && req.method !== "HEAD") {