feat(api): rate-limit, honeypot, коды ошибок

rateLimit.ts: 5 запросов / 10 минут на IP в памяти, фиксированное окно,
чистка устаревших записей по ходу запросов, 429 с Retry-After. IP из
X-Real-IP только от loopback-сокета (плюс TRUSTED_PROXIES для шлюза
docker), иначе адрес сокета; в базу IP не пишем. Honeypot → 201 без
записи и уведомления. isPaused() → 503 на время восстановления базы.
Тесты на каждый код ответа из таблицы плана.
This commit is contained in:
2026-10-17 15:41:04 +03:00
parent c20e40c333
commit ea0535001c
6 changed files with 203 additions and 9 deletions
+22
View File
@@ -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();
+32 -2
View File
@@ -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<string>([...LOOPBACK, ...(deps.trustedProxies ?? [])]);
const reply = (body: LeadResponse, status: Status, headers?: Record<string, string>) =>
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 });
+7
View File
@@ -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,
});
+75
View File
@@ -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<string>(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);
});
});
+52
View File
@@ -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<string, { start: number; count: number }>();
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>,
): string {
const realIp = realIpHeader?.trim();
if (socketAddress && trustedProxies.has(socketAddress) && realIp) return realIp;
return socketAddress ?? 'unknown';
}
+15 -7
View File
@@ -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<AppDeps> = {}) {
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<string, string> = {}) =>
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<string, string> = {}, 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);