From ea0535001cf86319a91be5dc2d5c83a5ae6c5b1e Mon Sep 17 00:00:00 2001 From: Nikolay Unuchek Date: Sat, 17 Oct 2026 15:41:04 +0300 Subject: [PATCH] =?UTF-8?q?feat(api):=20rate-limit,=20honeypot,=20=D0=BA?= =?UTF-8?q?=D0=BE=D0=B4=D1=8B=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rateLimit.ts: 5 запросов / 10 минут на IP в памяти, фиксированное окно, чистка устаревших записей по ходу запросов, 429 с Retry-After. IP из X-Real-IP только от loopback-сокета (плюс TRUSTED_PROXIES для шлюза docker), иначе адрес сокета; в базу IP не пишем. Honeypot → 201 без записи и уведомления. isPaused() → 503 на время восстановления базы. Тесты на каждый код ответа из таблицы плана. --- apps/api/src/app.test.ts | 22 ++++++++++ apps/api/src/app.ts | 34 ++++++++++++++- apps/api/src/index.ts | 7 ++++ apps/api/src/rateLimit.test.ts | 75 ++++++++++++++++++++++++++++++++++ apps/api/src/rateLimit.ts | 52 +++++++++++++++++++++++ apps/api/src/testing.ts | 22 ++++++---- 6 files changed, 203 insertions(+), 9 deletions(-) create mode 100644 apps/api/src/rateLimit.test.ts create mode 100644 apps/api/src/rateLimit.ts diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 2251709..532ebb5 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -99,6 +99,28 @@ describe('POST /api/lead', () => { expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 }); }); + it('honeypot: 201 как на обычную заявку, ничего не пишет, notifier молчит', async () => { + const { post, db, events } = setup(); + const res = await post(validBody({ company: 'ООО Спам' })); + await flush(); + expect(res.status).toBe(201); + expect(await res.json()).toEqual({ ok: true, duplicate: false }); + expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 }); + expect(events).toEqual([]); + }); + + it('503 unavailable: приём на паузе (восстановление базы)', async () => { + let paused = true; + const { post, db } = setup({ isPaused: () => paused }); + const res = await post(validBody()); + expect(res.status).toBe(503); + expect(await res.json()).toEqual({ ok: false, error: 'unavailable' }); + expect(db.prepare('SELECT count(*) AS n FROM leads').get()).toEqual({ n: 0 }); + + paused = false; + expect((await post(validBody())).status).toBe(201); + }); + it('500 server: исключение при записи', async () => { const { post, db } = setup(); db.close(); diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ccc019b..5f6302d 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -1,23 +1,43 @@ -import { Hono } from 'hono'; +import { getConnInfo } from '@hono/node-server/conninfo'; +import { Hono, type Context } from 'hono'; import { bodyLimit } from 'hono/body-limit'; import { validateLead, type LeadResponse } from '@kvarta/shared/lead'; import type { Db } from './db/index.js'; import { saveLead } from './leads.js'; import { notifyLater, type Notifier } from './notifier.js'; +import { clientIp, LOOPBACK, type RateLimiter } from './rateLimit.js'; export type AppDeps = { db: Db; now: () => Date; + rateLimiter: RateLimiter; notifier: Notifier; + /** true на время восстановления базы: приём заявок отвечает 503. */ + isPaused?: () => boolean; + /** Адреса, от которых верим X-Real-IP, помимо loopback. */ + trustedProxies?: readonly string[]; version?: string; }; export const MAX_BODY_BYTES = 16 * 1024; const USER_AGENT_MAX = 500; +type Status = 200 | 201 | 400 | 413 | 415 | 429 | 500 | 503; + +function socketAddress(c: Context): string | undefined { + try { + return getConnInfo(c).remote.address; + } catch { + // app.request() в тестах без node-сокета + return undefined; + } +} + export function createApp(deps: AppDeps) { const app = new Hono(); - const reply = (body: LeadResponse, status: 200 | 201 | 400 | 413 | 415 | 500) => Response.json(body, { status }); + const trusted = new Set([...LOOPBACK, ...(deps.trustedProxies ?? [])]); + const reply = (body: LeadResponse, status: Status, headers?: Record) => + Response.json(body, { status, headers }); app.onError((err) => { console.error(err); @@ -29,6 +49,14 @@ export function createApp(deps: AppDeps) { app.post( '/api/lead', async (c, next) => { + if (deps.isPaused?.()) return reply({ ok: false, error: 'unavailable' }, 503); + + const ip = clientIp(socketAddress(c), c.req.header('x-real-ip'), trusted); + const decision = deps.rateLimiter.hit(ip, deps.now()); + if (!decision.ok) { + return reply({ ok: false, error: 'rate_limited' }, 429, { 'Retry-After': String(decision.retryAfterSec) }); + } + const type = c.req.header('content-type')?.split(';')[0]?.trim().toLowerCase(); if (type !== 'application/json') return reply({ ok: false, error: 'bad_request' }, 415); await next(); @@ -44,6 +72,8 @@ export function createApp(deps: AppDeps) { const result = validateLead(body); if (!result.ok) return reply({ ok: false, error: 'validation', fieldErrors: result.fieldErrors }, 400); + // Бот не должен понять, что его раскусили: тот же ответ, что и на настоящую заявку. + if (result.data.honeypot) return reply({ ok: true, duplicate: false }, 201); const userAgent = c.req.header('user-agent')?.slice(0, USER_AGENT_MAX) ?? null; const saved = saveLead(deps.db, result.data, { now: deps.now(), userAgent }); diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 045a784..35968c1 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,6 +2,7 @@ import { serve } from '@hono/node-server'; import { createApp } from './app.js'; import { DEFAULT_DB_PATH, openDb } from './db/index.js'; import { noopNotifier } from './notifier.js'; +import { createRateLimiter } from './rateLimit.js'; const port = Number(process.env.PORT ?? 3001); const db = openDb(process.env.DB_PATH ?? DEFAULT_DB_PATH); @@ -9,7 +10,13 @@ const db = openDb(process.env.DB_PATH ?? DEFAULT_DB_PATH); const app = createApp({ db, now: () => new Date(), + rateLimiter: createRateLimiter(), notifier: noopNotifier, + // В docker сокет nginx приходит не с 127.0.0.1, а со шлюза сети контейнера — его задаём явно. + trustedProxies: (process.env.TRUSTED_PROXIES ?? '') + .split(',') + .map((s) => s.trim()) + .filter(Boolean), version: process.env.APP_VERSION, }); diff --git a/apps/api/src/rateLimit.test.ts b/apps/api/src/rateLimit.test.ts new file mode 100644 index 0000000..ddf2452 --- /dev/null +++ b/apps/api/src/rateLimit.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { clientIp, createRateLimiter, LOOPBACK } from './rateLimit.js'; +import { setup, T0, validBody } from './testing.js'; + +const MINUTE = 60 * 1000; +const at = (ms: number) => new Date(T0.getTime() + ms); + +describe('createRateLimiter', () => { + it('5 запросов за 10 минут, шестой — с Retry-After до конца окна', () => { + const limiter = createRateLimiter(); + for (let i = 0; i < 5; i++) expect(limiter.hit('1.1.1.1', at(i * MINUTE))).toEqual({ ok: true }); + expect(limiter.hit('1.1.1.1', at(6 * MINUTE))).toEqual({ ok: false, retryAfterSec: 4 * 60 }); + // Другой IP считается отдельно. + expect(limiter.hit('2.2.2.2', at(6 * MINUTE))).toEqual({ ok: true }); + // Окно кончилось — снова можно. + expect(limiter.hit('1.1.1.1', at(10 * MINUTE))).toEqual({ ok: true }); + }); + + it('Retry-After не бывает нулём', () => { + const limiter = createRateLimiter({ limit: 1 }); + limiter.hit('ip', at(0)); + expect(limiter.hit('ip', at(10 * MINUTE - 1))).toEqual({ ok: false, retryAfterSec: 1 }); + }); + + it('старые записи вычищаются, память не растёт', () => { + const limiter = createRateLimiter(); + for (let i = 0; i < 100; i++) limiter.hit(`10.0.0.${i}`, at(0)); + expect(limiter.size()).toBe(100); + limiter.hit('10.0.1.1', at(11 * MINUTE)); + expect(limiter.size()).toBe(1); + }); +}); + +describe('clientIp', () => { + const trusted = new Set(LOOPBACK); + + it('X-Real-IP берётся только с loopback-сокета', () => { + for (const socket of LOOPBACK) expect(clientIp(socket, '203.0.113.7', trusted)).toBe('203.0.113.7'); + expect(clientIp('198.51.100.1', '203.0.113.7', trusted)).toBe('198.51.100.1'); + }); + + it('без заголовка — адрес сокета', () => { + expect(clientIp('127.0.0.1', undefined, trusted)).toBe('127.0.0.1'); + expect(clientIp(undefined, '203.0.113.7', trusted)).toBe('unknown'); + }); +}); + +describe('POST /api/lead: 429', () => { + it('шестой запрос с того же IP за nginx — 429 и Retry-After в секундах', async () => { + const { post, advance } = setup({ rateLimiter: createRateLimiter() }); + const viaNginx = (ip: string) => post(validBody(), { 'x-real-ip': ip }, '127.0.0.1'); + + for (let i = 0; i < 5; i++) expect((await viaNginx('203.0.113.7')).status).toBeLessThan(300); + advance(MINUTE); + const res = await viaNginx('203.0.113.7'); + expect(res.status).toBe(429); + expect(res.headers.get('retry-after')).toBe('540'); + expect(await res.json()).toEqual({ ok: false, error: 'rate_limited' }); + + expect((await viaNginx('203.0.113.8')).status).toBeLessThan(300); + }); + + it('подставной X-Real-IP с чужого сокета не обходит лимит', async () => { + const { post } = setup({ rateLimiter: createRateLimiter() }); + for (let i = 0; i < 5; i++) await post(validBody(), { 'x-real-ip': `203.0.113.${i}` }, '198.51.100.1'); + const res = await post(validBody(), { 'x-real-ip': '203.0.113.99' }, '198.51.100.1'); + expect(res.status).toBe(429); + }); + + it('trustedProxies: шлюз docker-сети тоже может передать X-Real-IP', async () => { + const { post } = setup({ rateLimiter: createRateLimiter(), trustedProxies: ['172.18.0.1'] }); + for (let i = 0; i < 5; i++) await post(validBody(), { 'x-real-ip': '203.0.113.7' }, '172.18.0.1'); + expect((await post(validBody(), { 'x-real-ip': '203.0.113.8' }, '172.18.0.1')).status).toBeLessThan(300); + }); +}); diff --git a/apps/api/src/rateLimit.ts b/apps/api/src/rateLimit.ts new file mode 100644 index 0000000..a324893 --- /dev/null +++ b/apps/api/src/rateLimit.ts @@ -0,0 +1,52 @@ +// Лимит запросов с IP в памяти процесса. Фиксированное окно: проще скользящего, +// а для защиты формы от перебора разницы нет. После рестарта счётчики обнуляются — это нормально. + +export type RateDecision = { ok: true } | { ok: false; retryAfterSec: number }; + +export interface RateLimiter { + hit(key: string, now: Date): RateDecision; +} + +export function createRateLimiter({ limit = 5, windowMs = 10 * 60 * 1000 } = {}) { + const buckets = new Map(); + let lastSweep = 0; + + return { + hit(key: string, now: Date): RateDecision { + const t = now.getTime(); + // Чистим не таймером, а по ходу запросов: без трафика нечему и расти. + if (t - lastSweep >= windowMs) { + for (const [k, b] of buckets) if (t - b.start >= windowMs) buckets.delete(k); + lastSweep = t; + } + + const bucket = buckets.get(key); + if (!bucket || t - bucket.start >= windowMs) { + buckets.set(key, { start: t, count: 1 }); + return { ok: true }; + } + if (bucket.count < limit) { + bucket.count++; + return { ok: true }; + } + return { ok: false, retryAfterSec: Math.max(1, Math.ceil((bucket.start + windowMs - t) / 1000)) }; + }, + size: () => buckets.size, + }; +} + +export const LOOPBACK = ['127.0.0.1', '::1', '::ffff:127.0.0.1'] as const; + +/** + * IP посетителя. X-Real-IP ставит nginx на той же машине; верим заголовку только от доверенного + * адреса (loopback и явно перечисленные), иначе любой подставит чужой адрес и обойдёт лимит. + */ +export function clientIp( + socketAddress: string | undefined, + realIpHeader: string | undefined, + trustedProxies: ReadonlySet, +): string { + const realIp = realIpHeader?.trim(); + if (socketAddress && trustedProxies.has(socketAddress) && realIp) return realIp; + return socketAddress ?? 'unknown'; +} diff --git a/apps/api/src/testing.ts b/apps/api/src/testing.ts index d84ece2..2743bda 100644 --- a/apps/api/src/testing.ts +++ b/apps/api/src/testing.ts @@ -3,6 +3,7 @@ import type { LeadRequest } from '@kvarta/shared/lead'; import { createApp, type AppDeps } from './app.js'; import { openDb } from './db/index.js'; import type { LeadEvent, Notifier } from './notifier.js'; +import type { RateLimiter } from './rateLimit.js'; export const T0 = new Date('2026-10-17T12:00:00.000Z'); @@ -23,14 +24,21 @@ export function setup(overrides: Partial = {}) { const clock = { now: T0 }; const events: LeadEvent[] = []; const notifier: Notifier = { leadSaved: (e) => void events.push(e) }; - const app = createApp({ db, now: () => clock.now, notifier, ...overrides }); + // Лимит проверяется отдельно; в остальных тестах он бы мешал сериям запросов. + const rateLimiter: RateLimiter = { hit: () => ({ ok: true }) }; + const app = createApp({ db, now: () => clock.now, rateLimiter, notifier, ...overrides }); - const post = (body: unknown, headers: Record = {}) => - app.request('/api/lead', { - method: 'POST', - headers: { 'content-type': 'application/json', ...headers }, - body: typeof body === 'string' ? body : JSON.stringify(body), - }); + /** socket — адрес, который увидел бы getConnInfo в node-server. */ + const post = (body: unknown, headers: Record = {}, socket?: string) => + app.request( + '/api/lead', + { + method: 'POST', + headers: { 'content-type': 'application/json', ...headers }, + body: typeof body === 'string' ? body : JSON.stringify(body), + }, + socket ? { incoming: { socket: { remoteAddress: socket } } } : undefined, + ); const advance = (ms: number) => { clock.now = new Date(clock.now.getTime() + ms);