From 76ab7d0e77d06cbc31b66fc5c0cdd80282d7f496 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Fri, 21 Aug 2026 22:27:33 +0700 Subject: [PATCH] fix(security): Task 12 add pre-authentication rate limiting to protect session store from flood --- .../TASK-2026-08-21-12-pre-auth-rate-limit.md | 63 +++++ .../TASK-2026-08-21-12-pre-auth-rate-limit.md | 16 ++ src/app/api/posts/preview/route.ts | 5 +- src/lib/auth/auth-guard.ts | 22 +- src/lib/rate-limiter.ts | 5 + tests/pre-auth-rate-limit.test.ts | 232 ++++++++++++++++++ tests/rate-limit-identity.test.ts | 3 +- 7 files changed, 340 insertions(+), 6 deletions(-) create mode 100644 agents/antigravity/done/TASK-2026-08-21-12-pre-auth-rate-limit.md create mode 100644 agents/antigravity/inbox/TASK-2026-08-21-12-pre-auth-rate-limit.md create mode 100644 tests/pre-auth-rate-limit.test.ts diff --git a/agents/antigravity/done/TASK-2026-08-21-12-pre-auth-rate-limit.md b/agents/antigravity/done/TASK-2026-08-21-12-pre-auth-rate-limit.md new file mode 100644 index 0000000..ff95e84 --- /dev/null +++ b/agents/antigravity/done/TASK-2026-08-21-12-pre-auth-rate-limit.md @@ -0,0 +1,63 @@ +# Task 12: Rate limit до аутентификации Report + +**Date:** 2026-08-21 +**Base Commit SHA:** `906148813ee66f6f52d7c291f335c1ebdca2a057` +**Status:** COMPLETED / PASS +**Assigned Agent:** Antigravity (Implementation Orchestrator) + +--- + +## 1. Executive Summary + +Устранена уязвимость неограниченной нагрузки на Session Store / базу данных при неаутентифицированном флуде: + +1. **Pre-Authentication Rate Limiter (`src/lib/rate-limiter.ts` & `src/lib/auth/auth-guard.ts`):** + - Экспортирован `preAuthRateLimiter` со скользящим окном 60 запросов / 60 секунд на клиентский IP (`resolveClientIp(req)`). + - В `requireAuthenticatedUser(req)` проверка лимитера вынесена **до** обращения к Session Store (`prisma.session`): + - Запросы без сессионной куки сразу проверяются через `preAuthRateLimiter.assertAllowed('pre-auth:' + clientIp)` без единого обращения к Session Store / БД (0 запросов в БД). При превышении лимита возвращается `429 RATE_LIMIT_EXCEEDED` вместо `401`. + - Запросы с недействительными/поддельными куками после проверки в Session Store списывают попытку из `preAuthRateLimiter`, ограничивая максимальное число недействительных запросов к БД числом 60 в минуту. +2. **Защита аутентифицированных пользователей на общем ключе (`direct-client` / NAT):** + - При наличии валидной сессионной куки запрос успешно аутентифицируется и **не попадает под штрафной лимит `pre-auth`**. + - Аутентифицированный пользователь подчиняется исключительно своему изолированному `user-scoped` лимиту (`sessionUser.id`), что полностью исключает возможность DoS легальных пользователей через анонимный флуд с того же IP / `direct-client`. +3. **Оптимизация в `POST /api/posts/preview` (`src/app/api/posts/preview/route.ts`):** + - Поиск сессии в `getSessionFromRequest` теперь вызывается только при наличии заголовка `Cookie: randomayzer_session=...`, исключая холостые вызовы при анонимных превью. +4. **Тестирование (`tests/pre-auth-rate-limit.test.ts`):** + - Добавлено 5 всесторонних тестов, проверяющих: + - 60 запросов без куки -> 61-й запрос возвращает `429` с кодом `RATE_LIMIT_EXCEEDED`, 0 вызовов `sessionStore.getSession`; + - 60 запросов с фейковыми куками -> ровно 60 вызовов `sessionStore.getSession`, затем блокировка `429`; + - Аутентифицированный пользователь на том же `direct-client` успешно выполняет запросы (`200 OK`) при исчерпанном анонимном лимите; + - Защита всех 8 защищенных эндпоинтов (`/api/giveaways`, `/api/giveaways/[id]`, `/api/giveaways/[id]/participants`, `/api/giveaways/[id]/draw`, `/api/giveaways/[id]/snapshot`, `/api/giveaways/[id]/unlock`); + - Сохранение изоляции user-scoped лимитов. + +--- + +## 2. Архитектурные решения + +### Почему `requireAuthenticatedUser` вместо `middleware.ts`? +1. **Совместимость с Next.js 16 и тестами:** В Next.js 16 middleware по умолчанию работает в Edge runtime, в то время как Vitest и интеграционные тесты запускают route handlers напрямую как функции. Размещение pre-auth guard внутри `requireAuthenticatedUser` гарантирует 100% покрытие во всех тестах, одинаковое поведение в dev/test/production и отсутствие накладных расходов на сериализацию между runtime-слоями. +2. **Детерминированность:** Все защищенные маршруты используют `requireAuthenticatedUser` или `requireGiveawayOwner` (который внутри вызывает `requireAuthenticatedUser`), что обеспечивает единую точку входа и невозможность обойти лимитер. + +--- + +## 3. Modified & Created Files + +| File | Status | Description | +|------|--------|-------------| +| `src/lib/rate-limiter.ts` | MODIFIED | Экспортирован `preAuthRateLimiter` (60 req / 60s). | +| `src/lib/auth/auth-guard.ts` | MODIFIED | Добавлена проверка `preAuthRateLimiter` до Session Store и для failed auth. | +| `src/app/api/posts/preview/route.ts` | MODIFIED | Пропуск `getSessionFromRequest` при отсутствии куки `SESSION_COOKIE_NAME`. | +| `tests/pre-auth-rate-limit.test.ts` | NEW | 5 тестов покрытия pre-auth лимитирования и защиты Session Store. | +| `tests/rate-limit-identity.test.ts` | MODIFIED | Сброс `preAuthRateLimiter` в `beforeEach`. | + +--- + +## 4. Verification Evidence + +```text +npx prisma generate -> EXIT 0 (Prisma Client v5.22.0) +npx tsc --noEmit -> EXIT 0 (0 ошибок типизации во всех 58 тестовых файлах и кодовой базе) +npm test -> EXIT 0 (58 сьютов, 338 тестов пройдены успешно без БД) +npm run lint -> EXIT 0 (0 ошибок, 6 warnings на no-img-element) +npm run build -> EXIT 0 (Все 17 маршрутов скомпилированы успешно в Next.js 16.3.2) +npm audit --omit=dev -> EXIT 0 (0 vulnerabilities) +``` diff --git a/agents/antigravity/inbox/TASK-2026-08-21-12-pre-auth-rate-limit.md b/agents/antigravity/inbox/TASK-2026-08-21-12-pre-auth-rate-limit.md new file mode 100644 index 0000000..f20d5bd --- /dev/null +++ b/agents/antigravity/inbox/TASK-2026-08-21-12-pre-auth-rate-limit.md @@ -0,0 +1,16 @@ +# Task 12: Rate limit до аутентификации + +**Assigned to:** Antigravity (Implementation Orchestrator) +**Priority:** MEDIUM (доступность / защита Session Store) +**Date:** 2026-08-21 +**Base SHA:** `906148813ee66f6f52d7c291f335c1ebdca2a057` + +## Проблема +После перехода Session Store на базу данных (Prisma) и переноса rate limiter на user-scoped ключи (`sessionUser.id`), запросы без cookie/сессии сначала обращаются к Session Store / базе данных (в `requireAuthenticatedUser`), и только потом попадают под rate limiter. Неаутентифицированный флуд создает нелимитированную нагрузку на Session Store / БД. + +## Scope +1. Ввести дешёвый pre-auth rate limit по идентичности клиента (`resolveClientIp(req)`), срабатывающий ДО обращения к session store на защищенных маршрутах. +2. Сохранить все существующие user-scoped лимиты (второй рубеж). +3. Проанализировать варианты архитектуры: pre-auth guard в хендлерах / helper vs `middleware.ts` / `requireAuthenticatedUser` / rate-limiter. +4. Написать тесты `tests/pre-auth-rate-limit.test.ts`. +5. Полный verification gate и отчет в `agents/antigravity/done/TASK-2026-08-21-12-pre-auth-rate-limit.md`. diff --git a/src/app/api/posts/preview/route.ts b/src/app/api/posts/preview/route.ts index 0ce9c26..f2056c4 100644 --- a/src/app/api/posts/preview/route.ts +++ b/src/app/api/posts/preview/route.ts @@ -4,7 +4,7 @@ import { postPreviewSchema } from '@/core/validation/giveaway-schemas'; import { handleApiError } from '@/core/errors/http-errors'; import { expensiveApiRateLimiter, generalApiRateLimiter } from '@/lib/rate-limiter'; import { resolveClientIp } from '@/lib/client-ip'; -import { getSessionFromRequest } from '@/lib/auth/session'; +import { getSessionFromRequest, SESSION_COOKIE_NAME } from '@/lib/auth/session'; import { validateCsrfOrigin } from '@/lib/auth/csrf-guard'; import { resolveEffectiveCapabilities } from '@/providers/vk/vk-capabilities'; @@ -15,7 +15,8 @@ export async function POST(req: NextRequest) { // 1. Enforce CSRF Origin validation for mutating request (protects against cross-site exploitation) validateCsrfOrigin(req); - const sessionUser = await getSessionFromRequest(req); + const sessionId = req.cookies.get(SESSION_COOKIE_NAME)?.value; + const sessionUser = sessionId ? await getSessionFromRequest(req) : null; const clientIp = resolveClientIp(req); // 2. Strict Rate Limiting: diff --git a/src/lib/auth/auth-guard.ts b/src/lib/auth/auth-guard.ts index 823e85f..a390ae0 100644 --- a/src/lib/auth/auth-guard.ts +++ b/src/lib/auth/auth-guard.ts @@ -1,12 +1,15 @@ import { NextRequest } from 'next/server'; -import { getSessionFromRequest, SessionUser } from './session'; +import { getSessionFromRequest, SessionUser, SESSION_COOKIE_NAME } from './session'; import { GiveawayStore, StoredGiveaway } from '@/lib/giveaway-store'; import { UnauthorizedError, ForbiddenError, NotFoundError } from '@/core/errors/http-errors'; import { validateCsrfOrigin } from './csrf-guard'; +import { resolveClientIp } from '@/lib/client-ip'; +import { preAuthRateLimiter } from '@/lib/rate-limiter'; /** * Enforces that a request is authenticated with a valid active session. - * Also enforces CSRF origin checks for state-mutating HTTP methods. + * Also enforces CSRF origin checks for state-mutating HTTP methods + * and pre-auth rate limiting to protect the database/session store from unauthenticated flood. */ export async function requireAuthenticatedUser(req: NextRequest): Promise { // 1. Enforce CSRF guard on state-mutating requests @@ -14,10 +17,23 @@ export async function requireAuthenticatedUser(req: NextRequest): Promise { + return this.delegate.createSession(user, ttlMs); + } + + public async getSession(sessionId: string): Promise { + this.getSessionCalls++; + return this.delegate.getSession(sessionId); + } + + public async destroySession(sessionId: string): Promise { + return this.delegate.destroySession(sessionId); + } + + public cleanupExpired(): number { + return this.delegate.cleanupExpired(); + } + + public clear(): void { + this.getSessionCalls = 0; + this.delegate.clear(); + } + + public size(): number { + return this.delegate.size(); + } +} + +describe('Task 12: Pre-Authentication Rate Limiting & Session Store Protection', () => { + let userRepo: MemoryUserRepository; + let countingSessionStore: CountingSessionStore; + let repo: MemoryGiveawayRepository; + const originalEnv = { ...process.env }; + + beforeEach(async () => { + process.env = { ...originalEnv }; + delete process.env.TRUST_PROXY; + + userRepo = new MemoryUserRepository(); + setUserRepository(userRepo); + + countingSessionStore = new CountingSessionStore(); + setSessionStore(countingSessionStore); + + repo = new MemoryGiveawayRepository(); + GiveawayStore.setRepository(repo); + + expensiveApiRateLimiter.reset(); + generalApiRateLimiter.reset(); + preAuthRateLimiter.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 countingSessionStore.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 Giveaway', + text: 'Description', + likesCount: 10, + commentsCount: 0, + repostsCount: 0, + }, + filterRules: DEFAULT_FILTER_RULES, + winnersCount: 1, + reserveWinnersCount: 0, + organizerId, + }); + return gw; + } + + // ─── 1. Anonymous Flood on /api/giveaways ────────────────────────────────── + it('N requests without cookie on /api/giveaways hit 429 after threshold without touching session store', async () => { + // Threshold is 60 requests + for (let i = 0; i < 60; i++) { + const req = new NextRequest('http://localhost/api/giveaways', { method: 'GET' }); + const res = await giveawaysGet(req); + expect(res.status).toBe(401); + } + + // 61st request must trigger pre-auth rate limit (429) + const blockedReq = new NextRequest('http://localhost/api/giveaways', { method: 'GET' }); + const blockedRes = await giveawaysGet(blockedReq); + expect(blockedRes.status).toBe(429); + const body = await blockedRes.json(); + expect(body.error?.code).toBe('RATE_LIMIT_EXCEEDED'); + + // Crucial: 0 session store calls for requests without cookies + expect(countingSessionStore.getSessionCalls).toBe(0); + }); + + // ─── 2. Fake Cookie Flood Caps Session Store Lookups ──────────────────────── + it('flood with random fake cookies is capped by pre-auth rate limiter protecting DB', async () => { + // 60 requests with invalid/fake cookies + for (let i = 0; i < 60; i++) { + const req = new NextRequest('http://localhost/api/giveaways', { + method: 'GET', + headers: { cookie: `${SESSION_COOKIE_NAME}=fake_cookie_${i}` }, + }); + const res = await giveawaysGet(req); + expect(res.status).toBe(401); + } + + // Exactly 60 session store calls were made before limit tripped + expect(countingSessionStore.getSessionCalls).toBe(60); + + // 61st request must be rejected with 429 + const req61 = new NextRequest('http://localhost/api/giveaways', { + method: 'GET', + headers: { cookie: `${SESSION_COOKIE_NAME}=fake_cookie_61` }, + }); + const res61 = await giveawaysGet(req61); + expect(res61.status).toBe(429); + }); + + // ─── 3. Authenticated User is NOT Blocked by Anonymous Flood ───────────────── + it('authenticated organizer on the same IP is not blocked by another client anonymous flood', async () => { + const alice = await createOrganizerWithSession('1001', 'Alice'); + + // Anonymous attacker floods /api/giveaways from default 'direct-client' + for (let i = 0; i < 60; i++) { + const unauthReq = new NextRequest('http://localhost/api/giveaways', { method: 'GET' }); + const unauthRes = await giveawaysGet(unauthReq); + expect(unauthRes.status).toBe(401); + } + + // Anonymous is now rate-limited (429) + const blockedAnonReq = new NextRequest('http://localhost/api/giveaways', { method: 'GET' }); + const blockedAnonRes = await giveawaysGet(blockedAnonReq); + expect(blockedAnonRes.status).toBe(429); + + // Alice sends request with valid session cookie from the same 'direct-client' IP -> SUCCEEDS (200) + const aliceReq = new NextRequest('http://localhost/api/giveaways', { + method: 'GET', + headers: { cookie: `${SESSION_COOKIE_NAME}=${alice.sessionId}` }, + }); + const aliceRes = await giveawaysGet(aliceReq); + expect(aliceRes.status).toBe(200); + const aliceData = await aliceRes.json(); + expect(aliceData.success).toBe(true); + }); + + // ─── 4. Pre-Auth Rate Limiting on All Protected Endpoints ─────────────────── + it('pre-auth rate limiting protects all protected API routes', async () => { + const alice = await createOrganizerWithSession('1001', 'Alice'); + const gw = await createReadyGiveaway(alice.user.id); + + // Exhaust preAuthRateLimiter (60 requests) + for (let i = 0; i < 60; i++) { + preAuthRateLimiter.check('pre-auth:direct-client'); + } + + // Check that every protected route returns 429 when unauthenticated + const testCases = [ + { name: 'GET /api/giveaways', fn: () => giveawaysGet(new NextRequest('http://localhost/api/giveaways')) }, + { name: 'POST /api/giveaways', fn: () => giveawaysPost(new NextRequest('http://localhost/api/giveaways', { method: 'POST' })) }, + { name: 'GET /api/giveaways/[id]', fn: () => giveawayGet(new NextRequest(`http://localhost/api/giveaways/${gw.id}`), { params: { id: gw.id } }) }, + { name: 'POST /api/giveaways/[id]/draw', fn: () => drawPost(new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, { method: 'POST' }), { params: { id: gw.id } }) }, + { name: 'GET /api/giveaways/[id]/participants', fn: () => participantsGet(new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`), { params: { id: gw.id } }) }, + { name: 'POST /api/giveaways/[id]/participants', fn: () => participantsPost(new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, { method: 'POST' }), { params: { id: gw.id } }) }, + { name: 'POST /api/giveaways/[id]/snapshot', fn: () => snapshotPost(new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, { method: 'POST' }), { params: { id: gw.id } }) }, + { name: 'POST /api/giveaways/[id]/unlock', fn: () => unlockPost(new NextRequest(`http://localhost/api/giveaways/${gw.id}/unlock`, { method: 'POST' }), { params: { id: gw.id } }) }, + ]; + + for (const tc of testCases) { + const res = await tc.fn(); + expect(res.status, `Endpoint ${tc.name} must return 429 when pre-auth limit is reached`).toBe(429); + } + }); + + // ─── 5. Regression: User-Scoped Isolation Remains Intact ───────────────────── + it('user-scoped rate limit isolation remains intact after pre-auth layer', async () => { + const alice = await createOrganizerWithSession('1001', 'Alice'); + const bob = await createOrganizerWithSession('1002', 'Bob'); + + // Alice exhausts her user-scoped general bucket (120 requests) + for (let i = 0; i < 120; i++) { + generalApiRateLimiter.check(`giveaways-list:${alice.user.id}`); + } + + // Alice is rate-limited (429) + 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 is NOT rate-limited (200) + 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); + }); +}); diff --git a/tests/rate-limit-identity.test.ts b/tests/rate-limit-identity.test.ts index 03188d8..81ff0c9 100644 --- a/tests/rate-limit-identity.test.ts +++ b/tests/rate-limit-identity.test.ts @@ -8,7 +8,7 @@ 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 { expensiveApiRateLimiter, generalApiRateLimiter, preAuthRateLimiter } from '../src/lib/rate-limiter'; import { ProviderFactory } from '../src/providers/factory'; import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway'; @@ -33,6 +33,7 @@ describe('Task 02: Client Identity for Rate Limiting', () => { expensiveApiRateLimiter.reset(); generalApiRateLimiter.reset(); + preAuthRateLimiter.reset(); }); afterEach(() => {