[object Object]

← back to Rentv 2026

rentv: per-IP write-form rate limits on /api/subscribe + /api/ad/reserve (TK-10559)

5a59077e18b15f2833b0a3f9c7aa9d782f0ed22b · 2026-08-15 04:10:39 -0700 · Steve Abrams

TK-10559 approved 2026-08-14 by rentv-approver triage session.

/api/subscribe: writeThrottle(10 req/hr per IP) — prevents JSONL list flooding;
existing subSet.size cap still applies for dedup.

/api/ad/reserve: adReserveThrottle(5 req/hr per IP) — prioritized (not idempotent;
each inquiry appends to ad-reservations.jsonl and marks slot reserved).

Both throttle with Retry-After header. No external dep (plain Map + Date.now).

Co-Authored-By: 4am-fix-loop <noreply@anthropic.com>

Files touched

Diff

commit 5a59077e18b15f2833b0a3f9c7aa9d782f0ed22b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 15 04:10:39 2026 -0700

    rentv: per-IP write-form rate limits on /api/subscribe + /api/ad/reserve (TK-10559)
    
    TK-10559 approved 2026-08-14 by rentv-approver triage session.
    
    /api/subscribe: writeThrottle(10 req/hr per IP) — prevents JSONL list flooding;
    existing subSet.size cap still applies for dedup.
    
    /api/ad/reserve: adReserveThrottle(5 req/hr per IP) — prioritized (not idempotent;
    each inquiry appends to ad-reservations.jsonl and marks slot reserved).
    
    Both throttle with Retry-After header. No external dep (plain Map + Date.now).
    
    Co-Authored-By: 4am-fix-loop <noreply@anthropic.com>
---
 server.js         | 19 ++++++++++++++++++-
 src/ad-system.cjs | 12 +++++++++++-
 2 files changed, 29 insertions(+), 2 deletions(-)

diff --git a/server.js b/server.js
index 5359b305..89a406fc 100644
--- a/server.js
+++ b/server.js
@@ -788,7 +788,24 @@ try {
     try { const e = JSON.parse(l).email; if (e) subSet.add(e); } catch { /* skip */ }
   });
 } catch { /* no file yet */ }
-app.post('/api/subscribe', (req, res) => {
+// P3 — per-IP throttle on public write endpoints (TK-10559, approved 2026-08-14)
+// /api/subscribe: 10 calls/hr per IP (idempotent; cap already guards list size)
+// /api/ad/reserve is in ad-system.cjs; throttle lives here via shared Map
+const _writeFails = new Map(); // ip → { count, first, win_ms }
+function writeThrottle(maxPerWin, winMs) {
+  return (req, res, next) => {
+    const ip = req.ip || '?'; const now = Date.now();
+    const f = _writeFails.get(ip + winMs) || { count: 0, first: now };
+    if (now - f.first > winMs) { f.count = 0; f.first = now; }
+    f.count++; _writeFails.set(ip + winMs, f);
+    if (f.count > maxPerWin) {
+      const retryAfter = Math.ceil((f.first + winMs - now) / 1000);
+      return res.status(429).set('Retry-After', retryAfter).json({ ok: false, error: 'Too many requests.' });
+    }
+    next();
+  };
+}
+app.post('/api/subscribe', writeThrottle(10, 60 * 60 * 1000), (req, res) => {
   const b = req.body || {};
   const email = String(b.email || '').trim().toLowerCase();
   if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) return res.status(400).json({ ok: false, error: 'valid email required' });
diff --git a/src/ad-system.cjs b/src/ad-system.cjs
index 7714689d..e3b5b18b 100644
--- a/src/ad-system.cjs
+++ b/src/ad-system.cjs
@@ -228,7 +228,17 @@ module.exports = function mountAdSystem(app, opts) {
   });
 
   // Buy / reserve inquiry — records intent, NO charge (Steve-gated payment).
-  app.post('/api/ad/reserve', (req, r) => {
+  // TK-10559 per-IP rate limit: 5 reserve inquiries / hr (not idempotent — each appends)
+  const _adReserveMap = new Map();
+  const adReserveThrottle = (req, r, next) => {
+    const ip = (req.ip || '?'); const now = Date.now(); const WIN = 60 * 60 * 1000, MAX = 5;
+    const f = _adReserveMap.get(ip) || { count: 0, first: now };
+    if (now - f.first > WIN) { f.count = 0; f.first = now; }
+    f.count++; _adReserveMap.set(ip, f);
+    if (f.count > MAX) return r.status(429).set('Retry-After', Math.ceil((f.first + WIN - now) / 1000)).json({ ok: false, error: 'Too many requests.' });
+    next();
+  };
+  app.post('/api/ad/reserve', adReserveThrottle, (req, r) => {
     const b = req.body || {};
     const rec = {
       at: nowISO(),

← f2bf7551 rentv: P1+P2+P3 public-launch hardening (TK-10564/10575, Ste  ·  back to Rentv 2026  ·  TK-10569: port /api/advertise + /api/advertise-inquiries fro 437aa93c →