feat(security): Task 02 client identity and user-scoped rate limiting

This commit is contained in:
Ochenstarik 2026-08-21 18:34:13 +07:00
parent 92f6d19225
commit 6f3fd44333
11 changed files with 449 additions and 30 deletions

View file

@ -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).

View file

@ -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`.

View file

@ -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).

View file

@ -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') {

View file

@ -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);

View file

@ -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) {

View file

@ -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}"`);

View file

@ -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);

View file

@ -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

View file

@ -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

View 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);
});
});
});