fix(security): Task 12 add pre-authentication rate limiting to protect session store from flood

This commit is contained in:
Ochenstarik 2026-08-21 22:27:33 +07:00
parent 906148813e
commit 76ab7d0e77
7 changed files with 340 additions and 6 deletions

View file

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

View file

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

View file

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

View file

@ -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<SessionUser> {
// 1. Enforce CSRF guard on state-mutating requests
@ -14,10 +17,23 @@ export async function requireAuthenticatedUser(req: NextRequest): Promise<Sessio
validateCsrfOrigin(req);
}
// 2. Resolve active session user
const clientIp = resolveClientIp(req);
const sessionId = req.cookies.get(SESSION_COOKIE_NAME)?.value;
// 2. Pre-auth rate limit for anonymous requests (no cookie):
// Asserts rate limit BEFORE throwing 401, preventing unauthenticated flood from consuming resources.
if (!sessionId) {
preAuthRateLimiter.assertAllowed(`pre-auth:${clientIp}`);
throw new UnauthorizedError('Authentication required: please log in via VK ID');
}
// 3. Resolve active session user
const sessionUser = await getSessionFromRequest(req);
// 4. Pre-auth rate limit for invalid/fake session cookies:
// Charges the pre-auth limiter for the client IP to prevent brute-force / fake cookie flood against the DB.
if (!sessionUser) {
preAuthRateLimiter.assertAllowed(`pre-auth:${clientIp}`);
throw new UnauthorizedError('Authentication required: please log in via VK ID');
}

View file

@ -122,3 +122,8 @@ export const oauthStartRateLimiter = new SlidingWindowRateLimiter({
windowMs: 60 * 1000,
maxRequests: 10,
});
export const preAuthRateLimiter = new SlidingWindowRateLimiter({
windowMs: 60_000, // 1 minute
maxRequests: 60, // 60 unauthenticated/failed-auth requests per minute per client identity
});

View file

@ -0,0 +1,232 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { NextRequest } from 'next/server';
import { GET as giveawaysGet, POST as giveawaysPost } from '../src/app/api/giveaways/route';
import { GET as giveawayGet } from '../src/app/api/giveaways/[id]/route';
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
import { GET as participantsGet, POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
import { POST as unlockPost } from '../src/app/api/giveaways/[id]/unlock/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, ISessionStore, SessionUser } from '../src/lib/auth/session';
import { expensiveApiRateLimiter, generalApiRateLimiter, preAuthRateLimiter } from '../src/lib/rate-limiter';
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
class CountingSessionStore implements ISessionStore {
public getSessionCalls = 0;
private delegate = new MemorySessionStore();
public async createSession(user: SessionUser, ttlMs?: number): Promise<string> {
return this.delegate.createSession(user, ttlMs);
}
public async getSession(sessionId: string): Promise<SessionUser | null> {
this.getSessionCalls++;
return this.delegate.getSession(sessionId);
}
public async destroySession(sessionId: string): Promise<void> {
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);
});
});

View file

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