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:
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
+12
-4
@@ -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', {
|
||||
/** 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);
|
||||
|
||||
Reference in New Issue
Block a user