/** * TASK-0107 §6 — the public-form submit rate limit (5/min per IP per form). * * A REDIS SLIDING-WINDOW counter (`form:rl:{slug}:{ip}`, 60 s) — the same * pattern as the global `RateLimitGuard` (shared `RedisService`), but * per-form: one abusive form cannot starve another (spec §Security). The * IP comes from `req.ip` (Express trusts the proxy chain per * `app.set("trust proxy")` — the same source the global limiter uses). * * Redis down → degrade OPEN (availability over strictness), matching the * global limiter's documented behavior; the honeypot + server validation * still hold. */ import { Inject, Injectable } from "@nestjs/common"; import { RedisService } from "../../common/redis.service.js"; import { logger } from "../../common/logger.js"; export const FORM_SUBMIT_LIMIT = 5; export const FORM_RL_WINDOW_MS = 60_000; export interface RateCheck { allowed: boolean; remaining: number; /** Seconds until the window frees a slot (present when `allowed` is false). */ retryAfterSeconds: number; } @Injectable() export class FormRateLimiter { constructor(@Inject(RedisService) private readonly redis: RedisService) {} private lastRedisWarn = 0; /** * Charge one submission against `form:rl:{slug}:{ip}`. Call AFTER the * request is known to be a real (non-honeypot) attempt — spec: the * limiter gates the expensive submit path, and a bot tripping the * honeypot is silently dropped WITHOUT consuming a legitimate slot. */ async check(slug: string, ip: string): Promise { const key = `form:rl:${slug}:${ip}`; let count: number; try { const now = Date.now(); const pipe = this.redis.getRedis().pipeline(); pipe.zremrangebyscore(key, 0, now - FORM_RL_WINDOW_MS); pipe.zadd(key, now, `${now}:${Math.random().toString(36).slice(2)}`); pipe.zcard(key); pipe.pexpire(key, FORM_RL_WINDOW_MS); const results = await pipe.exec(); const card = results?.[2]?.[1]; count = typeof card === "number" ? card : 1; } catch (err) { if (Date.now() - this.lastRedisWarn > 60_000) { this.lastRedisWarn = Date.now(); logger.warn(`form rate limiter degraded (redis unavailable): ${(err as Error).message}`); } return { allowed: true, remaining: FORM_SUBMIT_LIMIT, retryAfterSeconds: 0 }; } const remaining = Math.max(0, FORM_SUBMIT_LIMIT - count); const retryAfterSeconds = Math.ceil(FORM_RL_WINDOW_MS / 1000); return { allowed: count <= FORM_SUBMIT_LIMIT, remaining, retryAfterSeconds: count <= FORM_SUBMIT_LIMIT ? 0 : retryAfterSeconds }; } }