feat(security): Task 02 client identity and user-scoped rate limiting
This commit is contained in:
parent
92f6d19225
commit
6f3fd44333
11 changed files with 449 additions and 30 deletions
|
|
@ -0,0 +1,77 @@
|
||||||
|
# Task 02: Client Identity for Rate Limiting Report
|
||||||
|
|
||||||
|
**Date:** 2026-08-21
|
||||||
|
**Base Commit SHA:** `92f6d1922500791ef221cc11ed63f606afc01b53`
|
||||||
|
**Status:** IMPLEMENTED / PASS
|
||||||
|
**Assigned Agent:** Antigravity (Implementation Orchestrator)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Executive Summary
|
||||||
|
|
||||||
|
Устранена проблема совместного использования одного общего bucket (`'direct-client'`) в механизме rate limiting при неизвестном IP (`req.ip` пуст и `TRUST_PROXY !== 'true'`), из-за которой в дефолтной конфигурации self-hosted `next start` один пользователь мог заблокировать всех остальных организаторов (выдав `429 Too Many Requests`).
|
||||||
|
|
||||||
|
Реализована модель **User-Scoped & Role-Isolated Rate Limiting**:
|
||||||
|
1. Для аутентифицированных маршрутов (`/api/giveaways*`) ключ лимита формируется строго на основе доверенного серверного идентификатора организатора `sessionUser.id`:
|
||||||
|
- `draw-execute:${sessionUser.id}:${id}`
|
||||||
|
- `snapshot-lock:${sessionUser.id}:${id}`
|
||||||
|
- `participants-import:${sessionUser.id}:${id}`
|
||||||
|
- `participants-get:${sessionUser.id}`
|
||||||
|
- `giveaway-get:${sessionUser.id}`
|
||||||
|
- `giveaways-list:${sessionUser.id}`
|
||||||
|
- `giveaway-create:${sessionUser.id}`
|
||||||
|
2. **Порядок вызовов**: аутентификация и проверка владения (`requireAuthenticatedUser` / `requireGiveawayOwner`) теперь выполняются **до** вызова рейт-лимитера. Неаутентифицированные запросы сразу отклоняются со статусом `401 Unauthorized` и не имеют возможности исчерпать или затронуть квоту организатора.
|
||||||
|
3. Для гибридного эндпоинта `POST /api/posts/preview`: при наличии активной сессии используется ключ `post-preview:user:${sessionUser.id}`, а для анонимных пользователей — `post-preview:anon:${clientIp}`. Анонимный спам не блокирует авторизованных пользователей.
|
||||||
|
4. В `src/lib/client-ip.ts` добавлен однократный `[SECURITY CONFIGURATION WARNING]` в production, если `TRUST_PROXY !== 'true'` и `req.ip` пуст. Поведение задокументировано в `docs/PRODUCTION_GUARDS.md`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Modified Files
|
||||||
|
|
||||||
|
| File | Type | Description |
|
||||||
|
|------|------|-------------|
|
||||||
|
| `src/lib/client-ip.ts` | Security | Добавлено предупреждение в production при отсутствии `TRUST_PROXY` и пустом `req.ip`. |
|
||||||
|
| `src/app/api/giveaways/[id]/draw/route.ts` | API Route | Перенесён вызов `requireGiveawayOwner` перед лимитером; ключ лимита `draw-execute:${sessionUser.id}:${id}`. |
|
||||||
|
| `src/app/api/giveaways/[id]/snapshot/route.ts` | API Route | Перенесён вызов `requireGiveawayOwner` перед лимитером; ключ лимита `snapshot-lock:${sessionUser.id}:${id}`. |
|
||||||
|
| `src/app/api/giveaways/[id]/participants/route.ts` | API Route | Аутентификация перед лимитером; ключи `participants-get:${sessionUser.id}` и `participants-import:${sessionUser.id}:${id}`. |
|
||||||
|
| `src/app/api/giveaways/[id]/route.ts` | API Route | Аутентификация перед лимитером; ключ `giveaway-get:${sessionUser.id}`. |
|
||||||
|
| `src/app/api/giveaways/route.ts` | API Route | Аутентификация перед лимитером; ключи `giveaways-list:${sessionUser.id}` и `giveaway-create:${sessionUser.id}`. |
|
||||||
|
| `src/app/api/posts/preview/route.ts` | API Route | Разделение ключей на `post-preview:user:${sessionUser.id}` и `post-preview:anon:${clientIp}`. |
|
||||||
|
| `docs/PRODUCTION_GUARDS.md` | Docs | Обновлен раздел 2 (Client Identity Scoping Architecture, Trust Proxy Modes). |
|
||||||
|
| `tests/rate-limit-identity.test.ts` | Tests (NEW) | Набор тестов изоляции лимитов организаторов, анонимных пользователей и защиты от обхода (5 тестов). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Verification Evidence & Test Gate
|
||||||
|
|
||||||
|
Фактически выполненные команды:
|
||||||
|
|
||||||
|
```text
|
||||||
|
npx prisma generate -> EXIT 0 (Prisma Client v5.22.0)
|
||||||
|
npx tsc --noEmit -> EXIT 0 (Clean TypeScript check, 0 errors)
|
||||||
|
npm test -> EXIT 0 (51 test files, 300 tests passed, 0 failed)
|
||||||
|
npm run lint -> EXIT 0 (Next.js ESLint passed clean)
|
||||||
|
npm run build -> EXIT 0 (Next.js production build compiled successfully)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Summary of New Tests (`tests/rate-limit-identity.test.ts`):
|
||||||
|
- `two distinct organizers with empty req.ip have independent draw rate limits` → **PASS**
|
||||||
|
- `organizer listing rate limit is scoped by sessionUser.id` → **PASS**
|
||||||
|
- `exhausting anonymous rate limit on post preview does not block authenticated organizers` → **PASS**
|
||||||
|
- `unauthenticated request fails with 401 without affecting organizer rate limit bucket` → **PASS**
|
||||||
|
- `uses validated client IP for anonymous endpoints when TRUST_PROXY=true` → **PASS**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Core Invariants & Security
|
||||||
|
|
||||||
|
- **Randomizer / Audit Proof Invariants:** Алгоритмы `HMAC_SHA256_FY_V1`, `executeDeterministicDrawV1`, `verifyDrawResult` сохранены без изменений.
|
||||||
|
- **Fail-Closed Authorization:** Любой неаутентифицированный или cross-tenant запрос отклоняется `401`/`403` до изменения счётчиков лимитера.
|
||||||
|
- **Proxy Header Integrity:** При `TRUST_PROXY !== 'true'` клиентские заголовки `X-Forwarded-For` по-прежнему строго игнорируются во избежание IP-spoofing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. UNVERIFIED Assertions & Tech Debt
|
||||||
|
|
||||||
|
1. **UNVERIFIED: Distributed Edge / Redis Rate Limiter:**
|
||||||
|
- В текущей реализации лимитер остаётся in-memory (`SlidingWindowRateLimiter`). Для горизонтально масштабируемых кластеров рекомендуется подключение Redis/Valkey или edge-уровня (Cloudflare Rate Limiting).
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
# Task 02: Client Identity for Rate Limiting
|
||||||
|
|
||||||
|
**Assigned to:** Antigravity (Implementation Orchestrator)
|
||||||
|
**Priority:** HIGH (availability)
|
||||||
|
**Date:** 2026-08-21
|
||||||
|
**Base SHA:** `92f6d1922500791ef221cc11ed63f606afc01b53`
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
1. User-scoped rate limiting for authenticated routes (`/api/giveaways*`):
|
||||||
|
- Scope rate limit key by `sessionUser.id` instead of IP (`draw:${sessionUser.id}:${id}`, `snapshot-lock:${sessionUser.id}:${id}`, `participants:${sessionUser.id}:${id}`, `giveaways:${sessionUser.id}`).
|
||||||
|
- Order of execution: `requireAuthenticatedUser` / `requireGiveawayOwner` authenticates the request and extracts `sessionUser`, then user-scoped rate limiter runs. Unauthenticated requests fail with 401 immediately and cannot exhaust organizer rate limit buckets.
|
||||||
|
2. Anonymous route rate limiting (`/api/auth/vk/start`, `/api/posts/preview`):
|
||||||
|
- When IP cannot be resolved (empty `req.ip` and `TRUST_PROXY !== 'true'`), use a dedicated anonymous fallback bucket (`anon:direct-client` or similar) separate from user buckets.
|
||||||
|
3. Production configuration guard & documentation:
|
||||||
|
- In `docs/PRODUCTION_GUARDS.md`, document proxy configuration and IP resolution behavior.
|
||||||
|
4. Concurrency & Isolation tests in `tests/rate-limit-identity.test.ts`:
|
||||||
|
- Two authenticated organizers with empty `req.ip` do not affect each other's rate limits.
|
||||||
|
- Exhausting anonymous rate limit does not affect authenticated organizers.
|
||||||
|
- Unauthenticated requests cannot bypass authentication or drain organizer limits.
|
||||||
|
- Existing `TRUST_PROXY=true` behavior and tests remain green.
|
||||||
|
5. Verification:
|
||||||
|
- `npm ci`, `npx prisma generate`, `npm test`, `npm run lint`, `npm run build`, `npx tsc --noEmit`.
|
||||||
|
6. Output report in `agents/antigravity/done/TASK-2026-08-21-02-rate-limit-client-identity.md`.
|
||||||
|
|
@ -31,9 +31,21 @@ The API supports the standard `Idempotency-Key` HTTP header on state-mutating en
|
||||||
|
|
||||||
## 2. Rate Limiting & Client Identity Resolution
|
## 2. Rate Limiting & Client Identity Resolution
|
||||||
|
|
||||||
|
### Client Identity Scoping Architecture
|
||||||
|
- **Authenticated Routes (`/api/giveaways*`)**:
|
||||||
|
Rate limits are keyed strictly by trusted server-side `sessionUser.id` (e.g. `draw-execute:${sessionUser.id}:${id}`, `giveaways-list:${sessionUser.id}`) **after** session authentication and ownership checks.
|
||||||
|
This ensures:
|
||||||
|
1. Different organizers have isolated rate limit buckets and never block each other, even when `req.ip` is unpopulated.
|
||||||
|
2. Unauthenticated attackers receive `401 Unauthorized` before reaching the rate limiter and cannot drain any organizer's quota.
|
||||||
|
- **Anonymous / Hybrid Routes (`/api/posts/preview`, `/api/auth/vk/start`, `/api/giveaways/[id]/verify`)**:
|
||||||
|
- `POST /api/posts/preview`: Uses `post-preview:user:${sessionUser.id}` if a valid session exists, and `post-preview:anon:${clientIp}` if anonymous. An anonymous attacker consuming the IP limit cannot affect authenticated organizers.
|
||||||
|
- `GET /api/auth/vk/start`: Rate-limited per resolved client IP (`oauth-start:${clientIp}`).
|
||||||
|
- `GET /api/giveaways/[id]/verify`: Rate-limited per resolved client IP and giveaway (`verify-get:${clientIp}:${id}`).
|
||||||
|
|
||||||
### Centralized Client IP Resolution (`src/lib/client-ip.ts`)
|
### Centralized Client IP Resolution (`src/lib/client-ip.ts`)
|
||||||
- **Untrusted Proxy Mode (Default)**:
|
- **Untrusted Proxy Mode (Default)**:
|
||||||
When `TRUST_PROXY !== 'true'`, user-supplied `X-Forwarded-For`, `X-Real-IP`, or `CF-Connecting-IP` headers are **strictly ignored** to prevent IP spoofing attacks. The direct socket connection IP is used.
|
When `TRUST_PROXY !== 'true'`, user-supplied `X-Forwarded-For`, `X-Real-IP`, or `CF-Connecting-IP` headers are **strictly ignored** to prevent IP spoofing attacks. The direct socket connection `req.ip` is used.
|
||||||
|
- *Production Behavior*: If `NODE_ENV=production`, `TRUST_PROXY !== 'true'`, and direct `req.ip` is unavailable (e.g. in self-hosted Node.js / `next start` behind a reverse proxy), the server emits a `[SECURITY CONFIGURATION WARNING]` and falls back to `'direct-client'`. For production deployments behind reverse proxies, setting `TRUST_PROXY=true` is required.
|
||||||
- **Trusted Proxy Mode (`TRUST_PROXY=true`)**:
|
- **Trusted Proxy Mode (`TRUST_PROXY=true`)**:
|
||||||
When deployed behind a verified reverse proxy (e.g. Nginx, Cloudflare, AWS ALB), `TRUST_PROXY=true` must be set. The resolver:
|
When deployed behind a verified reverse proxy (e.g. Nginx, Cloudflare, AWS ALB), `TRUST_PROXY=true` must be set. The resolver:
|
||||||
- Enforces a maximum header length of 1024 characters (oversized headers are rejected as malformed).
|
- Enforces a maximum header length of 1024 characters (oversized headers are rejected as malformed).
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,12 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const clientIp = resolveClientIp(req);
|
|
||||||
expensiveApiRateLimiter.assertAllowed(`draw-execute:${clientIp}:${id}`);
|
|
||||||
|
|
||||||
// Enforce giveaway ownership authorization
|
// 1. Enforce giveaway ownership authorization (extracts trusted sessionUser)
|
||||||
const { giveaway } = await requireGiveawayOwner(req, id);
|
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
|
||||||
|
|
||||||
|
// 2. User-scoped rate limiter: isolates organizer quota
|
||||||
|
expensiveApiRateLimiter.assertAllowed(`draw-execute:${sessionUser.id}:${id}`);
|
||||||
|
|
||||||
// 1. Strict Terminal State Guard: If already DRAWN or PUBLISHED, return 409 DRAW_ALREADY_COMPLETED
|
// 1. Strict Terminal State Guard: If already DRAWN or PUBLISHED, return 409 DRAW_ALREADY_COMPLETED
|
||||||
if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') {
|
if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') {
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,12 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const clientIp = resolveClientIp(req);
|
|
||||||
generalApiRateLimiter.assertAllowed(`participants-get:${clientIp}`);
|
|
||||||
|
|
||||||
// Enforce giveaway ownership authorization (private participant PII data)
|
// 1. Enforce giveaway ownership authorization (private participant PII data)
|
||||||
await requireGiveawayOwner(req, id);
|
const { sessionUser } = await requireGiveawayOwner(req, id);
|
||||||
|
|
||||||
|
// 2. User-scoped rate limiter
|
||||||
|
generalApiRateLimiter.assertAllowed(`participants-get:${sessionUser.id}`);
|
||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||||
|
|
@ -46,12 +47,13 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const clientIp = resolveClientIp(req);
|
|
||||||
expensiveApiRateLimiter.assertAllowed(`participants-import:${clientIp}:${id}`);
|
|
||||||
|
|
||||||
// Enforce giveaway ownership authorization for importing participants
|
// 1. Enforce giveaway ownership authorization for importing participants
|
||||||
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
|
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
|
||||||
|
|
||||||
|
// 2. User-scoped rate limiter
|
||||||
|
expensiveApiRateLimiter.assertAllowed(`participants-import:${sessionUser.id}:${id}`);
|
||||||
|
|
||||||
const rawBody = await req.json();
|
const rawBody = await req.json();
|
||||||
const validated = fetchParticipantsSchema.parse(rawBody);
|
const validated = fetchParticipantsSchema.parse(rawBody);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,13 @@ export async function GET(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const clientIp = resolveClientIp(req);
|
|
||||||
generalApiRateLimiter.assertAllowed(`giveaway-get:${clientIp}`);
|
|
||||||
|
|
||||||
// Enforce giveaway ownership authorization
|
// 1. Enforce giveaway ownership authorization
|
||||||
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
|
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
|
||||||
|
|
||||||
|
// 2. User-scoped rate limiter
|
||||||
|
generalApiRateLimiter.assertAllowed(`giveaway-get:${sessionUser.id}`);
|
||||||
|
|
||||||
// Resolve runtime effective capabilities truthfully based on stored organizer credential status
|
// Resolve runtime effective capabilities truthfully based on stored organizer credential status
|
||||||
let credentialStatus: 'AVAILABLE' | 'REFRESHABLE' | 'REAUTH_REQUIRED' | 'MISSING' = 'MISSING';
|
let credentialStatus: 'AVAILABLE' | 'REFRESHABLE' | 'REAUTH_REQUIRED' | 'MISSING' = 'MISSING';
|
||||||
if (sessionUser?.id) {
|
if (sessionUser?.id) {
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,12 @@ export async function POST(
|
||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const { id } = params;
|
const { id } = params;
|
||||||
const clientIp = resolveClientIp(req);
|
|
||||||
expensiveApiRateLimiter.assertAllowed(`snapshot-lock:${clientIp}:${id}`);
|
|
||||||
|
|
||||||
// Enforce giveaway ownership authorization
|
// 1. Enforce giveaway ownership authorization
|
||||||
const { giveaway } = await requireGiveawayOwner(req, id);
|
const { giveaway, sessionUser } = await requireGiveawayOwner(req, id);
|
||||||
|
|
||||||
|
// 2. User-scoped rate limiter
|
||||||
|
expensiveApiRateLimiter.assertAllowed(`snapshot-lock:${sessionUser.id}:${id}`);
|
||||||
|
|
||||||
if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') {
|
if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') {
|
||||||
throw new ConflictError(`Cannot create new snapshot for giveaway in status "${giveaway.status}"`);
|
throw new ConflictError(`Cannot create new snapshot for giveaway in status "${giveaway.status}"`);
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,13 @@ export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const clientIp = resolveClientIp(req);
|
|
||||||
generalApiRateLimiter.assertAllowed(`giveaways-list:${clientIp}`);
|
|
||||||
|
|
||||||
// 1. Mandatory authentication guard: anonymous listing returns 401 Unauthorized
|
// 1. Mandatory authentication guard: anonymous listing returns 401 Unauthorized
|
||||||
const sessionUser = await requireAuthenticatedUser(req);
|
const sessionUser = await requireAuthenticatedUser(req);
|
||||||
|
|
||||||
// 2. Query scoped strictly by organizerId at repository/database level
|
// 2. User-scoped rate limiter
|
||||||
|
generalApiRateLimiter.assertAllowed(`giveaways-list:${sessionUser.id}`);
|
||||||
|
|
||||||
|
// 3. Query scoped strictly by organizerId at repository/database level
|
||||||
const summaries = await GiveawayStore.listSummaries(sessionUser.id);
|
const summaries = await GiveawayStore.listSummaries(sessionUser.id);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
|
|
@ -33,12 +33,12 @@ export async function GET(req: NextRequest) {
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const clientIp = resolveClientIp(req);
|
|
||||||
generalApiRateLimiter.assertAllowed(`giveaway-create:${clientIp}`);
|
|
||||||
|
|
||||||
// 1. Mandatory authentication guard for giveaway creation
|
// 1. Mandatory authentication guard for giveaway creation
|
||||||
const sessionUser = await requireAuthenticatedUser(req);
|
const sessionUser = await requireAuthenticatedUser(req);
|
||||||
|
|
||||||
|
// 2. User-scoped rate limiter
|
||||||
|
generalApiRateLimiter.assertAllowed(`giveaway-create:${sessionUser.id}`);
|
||||||
|
|
||||||
const rawBody = await req.json();
|
const rawBody = await req.json();
|
||||||
const validated = createGiveawaySchema.parse(rawBody);
|
const validated = createGiveawaySchema.parse(rawBody);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,13 +9,17 @@ import { resolveEffectiveCapabilities } from '@/providers/vk/vk-capabilities';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
|
const sessionUser = await getSessionFromRequest(req);
|
||||||
const clientIp = resolveClientIp(req);
|
const clientIp = resolveClientIp(req);
|
||||||
generalApiRateLimiter.assertAllowed(`post-preview:${clientIp}`);
|
|
||||||
|
// Rate limiting: isolate authenticated user bucket from anonymous IP bucket
|
||||||
|
const rateLimitKey = sessionUser
|
||||||
|
? `post-preview:user:${sessionUser.id}`
|
||||||
|
: `post-preview:anon:${clientIp}`;
|
||||||
|
generalApiRateLimiter.assertAllowed(rateLimitKey);
|
||||||
|
|
||||||
const rawBody = await req.json();
|
const rawBody = await req.json();
|
||||||
const validated = postPreviewSchema.parse(rawBody);
|
const validated = postPreviewSchema.parse(rawBody);
|
||||||
|
|
||||||
const sessionUser = await getSessionFromRequest(req);
|
|
||||||
const provider = ProviderFactory.getVkProvider();
|
const provider = ProviderFactory.getVkProvider();
|
||||||
|
|
||||||
// Fetch post with optional organizer session context for private/restricted access probe
|
// Fetch post with optional organizer session context for private/restricted access probe
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ export function normalizeIp(rawIp: string): string {
|
||||||
return ip;
|
return ip;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let hasWarnedMissingProxy = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the client identity IP address.
|
* Resolves the client identity IP address.
|
||||||
* Strictly ignores untrusted X-Forwarded-For headers unless TRUST_PROXY=true is configured.
|
* Strictly ignores untrusted X-Forwarded-For headers unless TRUST_PROXY=true is configured.
|
||||||
|
|
@ -45,8 +47,20 @@ export function resolveClientIp(req: NextRequest): string {
|
||||||
const isTrustProxy = process.env.TRUST_PROXY === 'true';
|
const isTrustProxy = process.env.TRUST_PROXY === 'true';
|
||||||
|
|
||||||
if (!isTrustProxy) {
|
if (!isTrustProxy) {
|
||||||
|
if (req.ip) {
|
||||||
|
return normalizeIp(req.ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.env.NODE_ENV === 'production' && !hasWarnedMissingProxy) {
|
||||||
|
hasWarnedMissingProxy = true;
|
||||||
|
console.warn(
|
||||||
|
'[SECURITY CONFIGURATION WARNING] TRUST_PROXY is not set to "true" and direct req.ip is unavailable. ' +
|
||||||
|
'In reverse-proxy environments (Nginx, Caddy, Cloudflare, AWS ALB), configure TRUST_PROXY=true to resolve client IPs correctly.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// When proxy is not trusted, ignore spoofable headers from the client
|
// When proxy is not trusted, ignore spoofable headers from the client
|
||||||
return req.ip || 'direct-client';
|
return 'direct-client';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Proxy is trusted: extract and validate header
|
// Proxy is trusted: extract and validate header
|
||||||
|
|
|
||||||
284
tests/rate-limit-identity.test.ts
Normal file
284
tests/rate-limit-identity.test.ts
Normal file
|
|
@ -0,0 +1,284 @@
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
|
||||||
|
import { POST as previewPost } from '../src/app/api/posts/preview/route';
|
||||||
|
import { GET as giveawaysGet } from '../src/app/api/giveaways/route';
|
||||||
|
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
|
||||||
|
import { GiveawayStore } from '../src/lib/giveaway-store';
|
||||||
|
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||||
|
import { MemoryUserRepository, setUserRepository } from '../src/lib/repository/user-repository';
|
||||||
|
import { MemorySessionStore, setSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
||||||
|
import { expensiveApiRateLimiter, generalApiRateLimiter } from '../src/lib/rate-limiter';
|
||||||
|
import { ProviderFactory } from '../src/providers/factory';
|
||||||
|
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||||
|
|
||||||
|
describe('Task 02: Client Identity for Rate Limiting', () => {
|
||||||
|
let userRepo: MemoryUserRepository;
|
||||||
|
let sessionStore: MemorySessionStore;
|
||||||
|
let repo: MemoryGiveawayRepository;
|
||||||
|
const originalEnv = { ...process.env };
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
delete process.env.TRUST_PROXY;
|
||||||
|
|
||||||
|
userRepo = new MemoryUserRepository();
|
||||||
|
setUserRepository(userRepo);
|
||||||
|
|
||||||
|
sessionStore = new MemorySessionStore();
|
||||||
|
setSessionStore(sessionStore);
|
||||||
|
|
||||||
|
repo = new MemoryGiveawayRepository();
|
||||||
|
GiveawayStore.setRepository(repo);
|
||||||
|
|
||||||
|
expensiveApiRateLimiter.reset();
|
||||||
|
generalApiRateLimiter.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env = { ...originalEnv };
|
||||||
|
});
|
||||||
|
|
||||||
|
async function createOrganizerWithSession(vkUserId: string, name: string) {
|
||||||
|
const user = await userRepo.upsertUserWithTokens({
|
||||||
|
vkUserId,
|
||||||
|
firstName: name,
|
||||||
|
lastName: 'Organizer',
|
||||||
|
encryptedAccessToken: 'enc_token',
|
||||||
|
expiresIn: 86400,
|
||||||
|
});
|
||||||
|
const sessionId = await sessionStore.createSession(user);
|
||||||
|
return { user, sessionId };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createReadyGiveaway(organizerId: string) {
|
||||||
|
const gw = await GiveawayStore.create({
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_1',
|
||||||
|
post: {
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '1',
|
||||||
|
sourceUrl: 'https://vk.com/wall-100_1',
|
||||||
|
title: 'Test',
|
||||||
|
text: 'Test post',
|
||||||
|
imageUrl: 'https://example.com/1.jpg',
|
||||||
|
likesCount: 10,
|
||||||
|
commentsCount: 0,
|
||||||
|
repostsCount: 0,
|
||||||
|
},
|
||||||
|
filterRules: DEFAULT_FILTER_RULES,
|
||||||
|
winnersCount: 1,
|
||||||
|
reserveWinnersCount: 0,
|
||||||
|
organizerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const participants = Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
platformUserId: `user_${i + 1}`,
|
||||||
|
firstName: `User${i + 1}`,
|
||||||
|
lastName: 'Participant',
|
||||||
|
source: 'LIKES' as const,
|
||||||
|
liked: true,
|
||||||
|
commented: false,
|
||||||
|
reposted: false,
|
||||||
|
subscribed: false,
|
||||||
|
eligible: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
await GiveawayStore.updateParticipants(gw.id, participants as any);
|
||||||
|
await GiveawayStore.createAndLockSnapshot(gw.id, participants as any, DEFAULT_FILTER_RULES);
|
||||||
|
return gw;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── 1. Authenticated User Isolation with Empty req.ip ───────────────────────
|
||||||
|
describe('User-Scoped Rate Limiting on Authenticated Routes', () => {
|
||||||
|
it('two distinct organizers with empty req.ip have independent draw rate limits', async () => {
|
||||||
|
const alice = await createOrganizerWithSession('1001', 'Alice');
|
||||||
|
const bob = await createOrganizerWithSession('1002', 'Bob');
|
||||||
|
|
||||||
|
const aliceGw = await createReadyGiveaway(alice.user.id);
|
||||||
|
const bobGw = await createReadyGiveaway(bob.user.id);
|
||||||
|
|
||||||
|
// Alice exhausts expensiveApiRateLimiter (15 requests limit)
|
||||||
|
// We simulate requests from Alice with NO req.ip (defaulting to 'direct-client')
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
expensiveApiRateLimiter.check(`draw-execute:${alice.user.id}:${aliceGw.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice's next draw request must be rate-limited (429)
|
||||||
|
const aliceReq = new NextRequest(`http://localhost/api/giveaways/${aliceGw.id}/draw`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
cookie: `${SESSION_COOKIE_NAME}=${alice.sessionId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const aliceRes = await drawPost(aliceReq, { params: { id: aliceGw.id } });
|
||||||
|
expect(aliceRes.status).toBe(429);
|
||||||
|
|
||||||
|
// Bob's draw request with same empty req.ip must SUCCEED (not blocked by Alice)
|
||||||
|
const bobReq = new NextRequest(`http://localhost/api/giveaways/${bobGw.id}/draw`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
cookie: `${SESSION_COOKIE_NAME}=${bob.sessionId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const bobRes = await drawPost(bobReq, { params: { id: bobGw.id } });
|
||||||
|
expect(bobRes.status).toBe(200);
|
||||||
|
const bobData = await bobRes.json();
|
||||||
|
expect(bobData.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('organizer listing rate limit is scoped by sessionUser.id', async () => {
|
||||||
|
const alice = await createOrganizerWithSession('1001', 'Alice');
|
||||||
|
const bob = await createOrganizerWithSession('1002', 'Bob');
|
||||||
|
|
||||||
|
// Alice exhausts generalApiRateLimiter (120 requests limit)
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
generalApiRateLimiter.check(`giveaways-list:${alice.user.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const aliceReq = new NextRequest('http://localhost/api/giveaways', {
|
||||||
|
headers: { cookie: `${SESSION_COOKIE_NAME}=${alice.sessionId}` },
|
||||||
|
});
|
||||||
|
const aliceRes = await giveawaysGet(aliceReq);
|
||||||
|
expect(aliceRes.status).toBe(429);
|
||||||
|
|
||||||
|
// Bob can still list his giveaways
|
||||||
|
const bobReq = new NextRequest('http://localhost/api/giveaways', {
|
||||||
|
headers: { cookie: `${SESSION_COOKIE_NAME}=${bob.sessionId}` },
|
||||||
|
});
|
||||||
|
const bobRes = await giveawaysGet(bobReq);
|
||||||
|
expect(bobRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 2. Anonymous vs Authenticated Bucket Isolation ──────────────────────────
|
||||||
|
describe('Anonymous vs Authenticated Bucket Isolation', () => {
|
||||||
|
it('exhausting anonymous rate limit on post preview does not block authenticated organizers', async () => {
|
||||||
|
const alice = await createOrganizerWithSession('1001', 'Alice');
|
||||||
|
|
||||||
|
const mockProvider = {
|
||||||
|
fetchPost: vi.fn().mockResolvedValue({
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '1',
|
||||||
|
title: 'Test Post',
|
||||||
|
text: 'Hello world',
|
||||||
|
imageUrl: 'https://example.com/img.png',
|
||||||
|
likesCount: 10,
|
||||||
|
commentsCount: 2,
|
||||||
|
repostsCount: 1,
|
||||||
|
resolvedAuthType: 'SERVICE',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.spyOn(ProviderFactory, 'getVkProvider').mockReturnValue(mockProvider as any);
|
||||||
|
|
||||||
|
// Exhaust anonymous IP bucket
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
generalApiRateLimiter.check('post-preview:anon:direct-client');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Anonymous preview request is rate-limited (429)
|
||||||
|
const anonReq = new NextRequest('http://localhost/api/posts/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ url: 'https://vk.com/wall-100_1' }),
|
||||||
|
});
|
||||||
|
const anonRes = await previewPost(anonReq);
|
||||||
|
expect(anonRes.status).toBe(429);
|
||||||
|
|
||||||
|
// Authenticated organizer preview request SUCCEEDS (200)
|
||||||
|
const authReq = new NextRequest('http://localhost/api/posts/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
cookie: `${SESSION_COOKIE_NAME}=${alice.sessionId}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: 'https://vk.com/wall-100_1' }),
|
||||||
|
});
|
||||||
|
const authRes = await previewPost(authReq);
|
||||||
|
expect(authRes.status).toBe(200);
|
||||||
|
const data = await authRes.json();
|
||||||
|
expect(data.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 3. Unauthenticated Rejection & Bucket Integrity ─────────────────────────
|
||||||
|
describe('Unauthenticated Request Handling', () => {
|
||||||
|
it('unauthenticated request fails with 401 without affecting organizer rate limit bucket', async () => {
|
||||||
|
const alice = await createOrganizerWithSession('1001', 'Alice');
|
||||||
|
const aliceGw = await createReadyGiveaway(alice.user.id);
|
||||||
|
|
||||||
|
// Attack: unauthenticated requests sent to draw endpoint
|
||||||
|
for (let i = 0; i < 50; i++) {
|
||||||
|
const unauthReq = new NextRequest(`http://localhost/api/giveaways/${aliceGw.id}/draw`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
const unauthRes = await drawPost(unauthReq, { params: { id: aliceGw.id } });
|
||||||
|
expect(unauthRes.status).toBe(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alice's draw bucket is completely untouched and succeeds
|
||||||
|
const aliceReq = new NextRequest(`http://localhost/api/giveaways/${aliceGw.id}/draw`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
cookie: `${SESSION_COOKIE_NAME}=${alice.sessionId}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const aliceRes = await drawPost(aliceReq, { params: { id: aliceGw.id } });
|
||||||
|
expect(aliceRes.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── 4. TRUST_PROXY=true IP Resolution & Rate Limiting ────────────────────────
|
||||||
|
describe('TRUST_PROXY=true Behavior', () => {
|
||||||
|
it('uses validated client IP for anonymous endpoints when TRUST_PROXY=true', async () => {
|
||||||
|
process.env.TRUST_PROXY = 'true';
|
||||||
|
|
||||||
|
const req1 = new NextRequest('http://localhost/api/posts/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '203.0.113.195, 198.51.100.1',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: 'https://vk.com/wall-100_1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const req2 = new NextRequest('http://localhost/api/posts/preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'x-forwarded-for': '198.51.100.25',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: 'https://vk.com/wall-100_1' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Exhaust IP 203.0.113.195
|
||||||
|
for (let i = 0; i < 120; i++) {
|
||||||
|
generalApiRateLimiter.check('post-preview:anon:203.0.113.195');
|
||||||
|
}
|
||||||
|
|
||||||
|
const res1 = await previewPost(req1);
|
||||||
|
expect(res1.status).toBe(429);
|
||||||
|
|
||||||
|
// req2 from different IP 198.51.100.25 is NOT blocked
|
||||||
|
const mockProvider = {
|
||||||
|
fetchPost: vi.fn().mockResolvedValue({
|
||||||
|
platform: 'VK',
|
||||||
|
ownerId: '-100',
|
||||||
|
postId: '1',
|
||||||
|
title: 'Test',
|
||||||
|
text: 'Hello',
|
||||||
|
imageUrl: 'https://example.com/img.png',
|
||||||
|
likesCount: 5,
|
||||||
|
commentsCount: 1,
|
||||||
|
repostsCount: 0,
|
||||||
|
resolvedAuthType: 'SERVICE',
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
vi.spyOn(ProviderFactory, 'getVkProvider').mockReturnValue(mockProvider as any);
|
||||||
|
|
||||||
|
const res2 = await previewPost(req2);
|
||||||
|
expect(res2.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue