From 6f3fd44333cbb200d82efa665d191f660b100144 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 21 Aug 2026 18:34:13 +0700 Subject: [PATCH] feat(security): Task 02 client identity and user-scoped rate limiting --- ...026-08-21-02-rate-limit-client-identity.md | 77 +++++ ...026-08-21-02-rate-limit-client-identity.md | 23 ++ docs/PRODUCTION_GUARDS.md | 14 +- src/app/api/giveaways/[id]/draw/route.ts | 9 +- .../api/giveaways/[id]/participants/route.ts | 16 +- src/app/api/giveaways/[id]/route.ts | 7 +- src/app/api/giveaways/[id]/snapshot/route.ts | 9 +- src/app/api/giveaways/route.ts | 14 +- src/app/api/posts/preview/route.ts | 10 +- src/lib/client-ip.ts | 16 +- tests/rate-limit-identity.test.ts | 284 ++++++++++++++++++ 11 files changed, 449 insertions(+), 30 deletions(-) create mode 100644 agents/antigravity/done/TASK-2026-08-21-02-rate-limit-client-identity.md create mode 100644 agents/antigravity/inbox/TASK-2026-08-21-02-rate-limit-client-identity.md create mode 100644 tests/rate-limit-identity.test.ts diff --git a/agents/antigravity/done/TASK-2026-08-21-02-rate-limit-client-identity.md b/agents/antigravity/done/TASK-2026-08-21-02-rate-limit-client-identity.md new file mode 100644 index 0000000..317a621 --- /dev/null +++ b/agents/antigravity/done/TASK-2026-08-21-02-rate-limit-client-identity.md @@ -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). diff --git a/agents/antigravity/inbox/TASK-2026-08-21-02-rate-limit-client-identity.md b/agents/antigravity/inbox/TASK-2026-08-21-02-rate-limit-client-identity.md new file mode 100644 index 0000000..8ff88be --- /dev/null +++ b/agents/antigravity/inbox/TASK-2026-08-21-02-rate-limit-client-identity.md @@ -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`. diff --git a/docs/PRODUCTION_GUARDS.md b/docs/PRODUCTION_GUARDS.md index da3243d..3db167a 100644 --- a/docs/PRODUCTION_GUARDS.md +++ b/docs/PRODUCTION_GUARDS.md @@ -31,9 +31,21 @@ The API supports the standard `Idempotency-Key` HTTP header on state-mutating en ## 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`) - **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`)**: 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). diff --git a/src/app/api/giveaways/[id]/draw/route.ts b/src/app/api/giveaways/[id]/draw/route.ts index f78dd06..95df25d 100644 --- a/src/app/api/giveaways/[id]/draw/route.ts +++ b/src/app/api/giveaways/[id]/draw/route.ts @@ -21,11 +21,12 @@ export async function POST( ) { try { const { id } = params; - const clientIp = resolveClientIp(req); - expensiveApiRateLimiter.assertAllowed(`draw-execute:${clientIp}:${id}`); - // Enforce giveaway ownership authorization - const { giveaway } = await requireGiveawayOwner(req, id); + // 1. Enforce giveaway ownership authorization (extracts trusted sessionUser) + 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 if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') { diff --git a/src/app/api/giveaways/[id]/participants/route.ts b/src/app/api/giveaways/[id]/participants/route.ts index dfea719..785de0e 100644 --- a/src/app/api/giveaways/[id]/participants/route.ts +++ b/src/app/api/giveaways/[id]/participants/route.ts @@ -17,11 +17,12 @@ export async function GET( ) { try { const { id } = params; - const clientIp = resolveClientIp(req); - generalApiRateLimiter.assertAllowed(`participants-get:${clientIp}`); - // Enforce giveaway ownership authorization (private participant PII data) - await requireGiveawayOwner(req, id); + // 1. Enforce giveaway ownership authorization (private participant PII data) + const { sessionUser } = await requireGiveawayOwner(req, id); + + // 2. User-scoped rate limiter + generalApiRateLimiter.assertAllowed(`participants-get:${sessionUser.id}`); const { searchParams } = new URL(req.url); const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10)); @@ -46,12 +47,13 @@ export async function POST( ) { try { 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); + // 2. User-scoped rate limiter + expensiveApiRateLimiter.assertAllowed(`participants-import:${sessionUser.id}:${id}`); + const rawBody = await req.json(); const validated = fetchParticipantsSchema.parse(rawBody); diff --git a/src/app/api/giveaways/[id]/route.ts b/src/app/api/giveaways/[id]/route.ts index 35b9893..04be6d8 100644 --- a/src/app/api/giveaways/[id]/route.ts +++ b/src/app/api/giveaways/[id]/route.ts @@ -15,12 +15,13 @@ export async function GET( ) { try { 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); + // 2. User-scoped rate limiter + generalApiRateLimiter.assertAllowed(`giveaway-get:${sessionUser.id}`); + // Resolve runtime effective capabilities truthfully based on stored organizer credential status let credentialStatus: 'AVAILABLE' | 'REFRESHABLE' | 'REAUTH_REQUIRED' | 'MISSING' = 'MISSING'; if (sessionUser?.id) { diff --git a/src/app/api/giveaways/[id]/snapshot/route.ts b/src/app/api/giveaways/[id]/snapshot/route.ts index c4e049a..586af01 100644 --- a/src/app/api/giveaways/[id]/snapshot/route.ts +++ b/src/app/api/giveaways/[id]/snapshot/route.ts @@ -17,11 +17,12 @@ export async function POST( ) { try { const { id } = params; - const clientIp = resolveClientIp(req); - expensiveApiRateLimiter.assertAllowed(`snapshot-lock:${clientIp}:${id}`); - // Enforce giveaway ownership authorization - const { giveaway } = await requireGiveawayOwner(req, id); + // 1. Enforce giveaway ownership authorization + 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') { throw new ConflictError(`Cannot create new snapshot for giveaway in status "${giveaway.status}"`); diff --git a/src/app/api/giveaways/route.ts b/src/app/api/giveaways/route.ts index c3cc7bd..76de06b 100644 --- a/src/app/api/giveaways/route.ts +++ b/src/app/api/giveaways/route.ts @@ -12,13 +12,13 @@ export const dynamic = 'force-dynamic'; export async function GET(req: NextRequest) { try { - const clientIp = resolveClientIp(req); - generalApiRateLimiter.assertAllowed(`giveaways-list:${clientIp}`); - // 1. Mandatory authentication guard: anonymous listing returns 401 Unauthorized 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); return NextResponse.json({ @@ -33,12 +33,12 @@ export async function GET(req: NextRequest) { export async function POST(req: NextRequest) { try { - const clientIp = resolveClientIp(req); - generalApiRateLimiter.assertAllowed(`giveaway-create:${clientIp}`); - // 1. Mandatory authentication guard for giveaway creation const sessionUser = await requireAuthenticatedUser(req); + // 2. User-scoped rate limiter + generalApiRateLimiter.assertAllowed(`giveaway-create:${sessionUser.id}`); + const rawBody = await req.json(); const validated = createGiveawaySchema.parse(rawBody); diff --git a/src/app/api/posts/preview/route.ts b/src/app/api/posts/preview/route.ts index ff5c6cf..3bc227d 100644 --- a/src/app/api/posts/preview/route.ts +++ b/src/app/api/posts/preview/route.ts @@ -9,13 +9,17 @@ import { resolveEffectiveCapabilities } from '@/providers/vk/vk-capabilities'; export async function POST(req: NextRequest) { try { + const sessionUser = await getSessionFromRequest(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 validated = postPreviewSchema.parse(rawBody); - - const sessionUser = await getSessionFromRequest(req); const provider = ProviderFactory.getVkProvider(); // Fetch post with optional organizer session context for private/restricted access probe diff --git a/src/lib/client-ip.ts b/src/lib/client-ip.ts index 56bcc22..3a4dfb2 100644 --- a/src/lib/client-ip.ts +++ b/src/lib/client-ip.ts @@ -37,6 +37,8 @@ export function normalizeIp(rawIp: string): string { return ip; } +let hasWarnedMissingProxy = false; + /** * Resolves the client identity IP address. * 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'; 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 - return req.ip || 'direct-client'; + return 'direct-client'; } // Proxy is trusted: extract and validate header diff --git a/tests/rate-limit-identity.test.ts b/tests/rate-limit-identity.test.ts new file mode 100644 index 0000000..932a78f --- /dev/null +++ b/tests/rate-limit-identity.test.ts @@ -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); + }); + }); +});