Skip to content
Open
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
6 changes: 6 additions & 0 deletions admin/src/app/api/[...proxy]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down
30 changes: 30 additions & 0 deletions backend/src/common/utils/client-ip.util.ts
Original file line number Diff line number Diff line change
@@ -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';
}
27 changes: 2 additions & 25 deletions backend/src/modules/articles/utils/client-ip.util.ts
Original file line number Diff line number Diff line change
@@ -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';
23 changes: 23 additions & 0 deletions backend/src/modules/race-result/guards/real-ip-throttler.guard.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
return getClientIp(req);
}
}
15 changes: 8 additions & 7 deletions backend/src/modules/race-result/race-result.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -766,7 +767,7 @@ export class RaceResultController {
}

@Post('share-count/:raceId')
@UseGuards(ThrottlerGuard)
@UseGuards(RealIpThrottlerGuard)
// @Throttle({ default: { ttl: 60_000, limit: 20 } })
@ApiOperation({
summary:
Expand All @@ -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:
Expand Down
18 changes: 10 additions & 8 deletions backend/src/modules/race-result/race-result.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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,
],
Expand Down
6 changes: 6 additions & 0 deletions frontend/app/api/[...proxy]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down