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
|
||||
|
||||
### 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).
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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