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:
+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 });
|
||||
|
||||
Reference in New Issue
Block a user