[object Object]

← back to Dw Signup Fulfillment

TK-11185: rate-limit public /trade/apply (per-IP) — close the write_customers abuse vector

eba3bdcb0a62113203bdc5913f869b323114b32a · 2026-09-03 11:17:19 -0700 · Steve Abrams

/trade/apply now server-side creates a real Shopify customer per POST (write_customers),
so an unthrottled public endpoint could be sprayed to pollute the customer table / spam
the designer-welcome email. Extract the inline webhook limiter into a shared util and
throttle both endpoints consistently.

- lib/rate-limit.js: createRateLimiter({windowMs,max}) — per-key sliding window, self-pruning
  map (extracted verbatim-behavior from the inline webhook limiter).
- server.js: webhook now uses webhookLimiter (WEBHOOK_RATE_MAX/min, unchanged behavior);
  /trade/apply gains tradeApplyLimiter (429 on trip) via the same util + a shared clientIp().
- lib/config.js: TRADE_APPLY_RATE_MAX=5, TRADE_APPLY_RATE_WINDOW_MS=1h (5 applications/IP/hr —
  a real designer applies once; generous but blocks spray).
- selftest.js (c3): limiter allows N in-window, TRIPS N+1, buckets are per-IP independent,
  and the config default is 5/hour. Full suite green in DRY_RUN.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X3co77k7JzTkAJRdemt6Ru

Files touched

Diff

commit eba3bdcb0a62113203bdc5913f869b323114b32a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 3 11:17:19 2026 -0700

    TK-11185: rate-limit public /trade/apply (per-IP) — close the write_customers abuse vector
    
    /trade/apply now server-side creates a real Shopify customer per POST (write_customers),
    so an unthrottled public endpoint could be sprayed to pollute the customer table / spam
    the designer-welcome email. Extract the inline webhook limiter into a shared util and
    throttle both endpoints consistently.
    
    - lib/rate-limit.js: createRateLimiter({windowMs,max}) — per-key sliding window, self-pruning
      map (extracted verbatim-behavior from the inline webhook limiter).
    - server.js: webhook now uses webhookLimiter (WEBHOOK_RATE_MAX/min, unchanged behavior);
      /trade/apply gains tradeApplyLimiter (429 on trip) via the same util + a shared clientIp().
    - lib/config.js: TRADE_APPLY_RATE_MAX=5, TRADE_APPLY_RATE_WINDOW_MS=1h (5 applications/IP/hr —
      a real designer applies once; generous but blocks spray).
    - selftest.js (c3): limiter allows N in-window, TRIPS N+1, buckets are per-IP independent,
      and the config default is 5/hour. Full suite green in DRY_RUN.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01X3co77k7JzTkAJRdemt6Ru
---
 lib/config.js       |  8 ++++++++
 lib/rate-limit.js   | 23 +++++++++++++++++++++++
 scripts/selftest.js | 15 +++++++++++++++
 server.js           | 28 +++++++++++++++-------------
 4 files changed, 61 insertions(+), 13 deletions(-)

diff --git a/lib/config.js b/lib/config.js
index e52e9b7..c375b0c 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -143,6 +143,14 @@ const config = {
   // forwarded/leaked, not-yet-clicked email can't be redeemed forever. 48h default.
   APPROVE_LINK_TTL_HOURS: parseInt(process.env.APPROVE_LINK_TTL_HOURS || '48', 10),
 
+  // --- Public /trade/apply throttle (TK-11185) ---
+  // The intake now server-side creates a Shopify customer per POST (write_customers), so
+  // the public endpoint is throttled per IP like the webhook to block customer-table
+  // pollution / designer-welcome-email spam. A real designer applies once — 5/hour/IP is
+  // generous. Same sliding-window util (lib/rate-limit) the webhook uses.
+  TRADE_APPLY_RATE_MAX: parseInt(process.env.TRADE_APPLY_RATE_MAX || '5', 10),
+  TRADE_APPLY_RATE_WINDOW_MS: parseInt(process.env.TRADE_APPLY_RATE_WINDOW_MS || String(60 * 60 * 1000), 10),
+
   // --- Public webhook hardening (the mint endpoint is public + secret-less) ---
   // 1) URL-token auth: register the webhook at /webhooks/customers/create/<token>.
   //    Only Shopify (and whoever set it) knows the token → a caller who doesn't have
diff --git a/lib/rate-limit.js b/lib/rate-limit.js
new file mode 100644
index 0000000..4be7fe9
--- /dev/null
+++ b/lib/rate-limit.js
@@ -0,0 +1,23 @@
+'use strict';
+// Shared in-memory per-key sliding-window rate limiter. Used by BOTH the
+// customers/create webhook and the public /trade/apply intake so the two throttle
+// consistently (extracted from the inline webhook limiter, TK-11185). Each caller
+// gets its own bucket via a separate factory instance, so buckets never cross.
+//
+// limited(key) records `now`, prunes timestamps older than windowMs, and returns true
+// once the key has exceeded `max` hits inside the window. The map self-prunes stale
+// keys once it grows past mapMaxEntries so it can't leak memory under a spray of IPs.
+function createRateLimiter({ windowMs, max, mapMaxEntries = 5000 }) {
+  const hits = new Map(); // key -> [timestamps(ms)]
+  return function limited(key) {
+    const k = key || '';
+    const now = Date.now();
+    const arr = (hits.get(k) || []).filter(t => now - t < windowMs);
+    arr.push(now);
+    hits.set(k, arr);
+    if (hits.size > mapMaxEntries) for (const [kk, v] of hits) if (!v.some(t => now - t < windowMs)) hits.delete(kk);
+    return arr.length > max;
+  };
+}
+
+module.exports = { createRateLimiter };
diff --git a/scripts/selftest.js b/scripts/selftest.js
index 2782e87..e0623e9 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -58,6 +58,7 @@ const verify = require('../lib/verify');          // WIRED retail path (Option C
 const email = require('../lib/email');
 const trade = require('../lib/trade');
 const reps = require('../lib/reps');
+const { createRateLimiter } = require('../lib/rate-limit');
 
 function hr(title) { console.log('\n' + '='.repeat(72) + '\n' + title + '\n' + '='.repeat(72)); }
 function ok(msg) { console.log('  ✔ ' + msg); }
@@ -223,6 +224,20 @@ async function main() {
 
   shopify.findOrCreateCustomer = _foc; // restore
 
+  // ---------------------------------------------------------------------------
+  // TK-11185 — /trade/apply per-IP rate-limit (the intake now creates a real Shopify
+  // customer per POST, so it's throttled like the webhook to block spray abuse).
+  hr('(c3) TK-11185 — public /trade/apply rate-limiter trips past the per-IP max');
+  const lim = createRateLimiter({ windowMs: 60000, max: 3 });
+  const seq = [lim('1.2.3.4'), lim('1.2.3.4'), lim('1.2.3.4'), lim('1.2.3.4')]; // 3 allowed, 4th trips
+  if (JSON.stringify(seq) === JSON.stringify([false, false, false, true])) ok('limiter allows first 3 in-window, TRIPS the 4th (returns 429-worthy true)');
+  else fail('limiter did not trip on the 4th: ' + JSON.stringify(seq));
+  if (lim('9.9.9.9') === false) ok('a different IP is independent (own bucket, not throttled by 1.2.3.4)');
+  else fail('limiter leaked across IPs');
+  // The real config wiring: default max is 5/hour and both endpoints share the util.
+  if (config.TRADE_APPLY_RATE_MAX === 5 && config.TRADE_APPLY_RATE_WINDOW_MS === 3600000) ok('config default = 5 applications per IP per hour');
+  else fail('unexpected trade-apply rate config: max=' + config.TRADE_APPLY_RATE_MAX + ' window=' + config.TRADE_APPLY_RATE_WINDOW_MS);
+
   // ---------------------------------------------------------------------------
   hr('(d) fixed assignment to the DW House Account');
   const picks = [];
diff --git a/server.js b/server.js
index c758e08..738d84d 100644
--- a/server.js
+++ b/server.js
@@ -17,6 +17,7 @@ const giftcard = require('./lib/giftcard');            // legacy alternate — s
 const retailCode = require('./lib/retail-code');       // legacy alternate — shared function code
 const giftcodeDiscount = require('./lib/giftcode-discount'); // legacy alternate — collection-scoped code (unsafe: samples share a product with the roll)
 const trade = require('./lib/trade');
+const { createRateLimiter } = require('./lib/rate-limit'); // shared per-IP sliding-window limiter
 const reps = require('./lib/reps');
 const email = require('./lib/email');
 const sampleLedger = require('./lib/sample-ledger');
@@ -57,17 +58,14 @@ app.get('/', (_req, res) => {
 //   (3) The handler then re-fetches the customer (forged ids rejected), gifts the REAL
 //       on-file email, enforces a created_at freshness gate + a daily mint cap, and is
 //       idempotent (one gift per customer). ---
-const rateHits = new Map(); // ip -> [timestamps(ms)]
-const RATE_MAP_MAX_ENTRIES = 5000; // prune the map when it grows beyond this
-const RATE_WINDOW_MS = 60000;      // 1-minute sliding window
-function rateLimited(ip) {
-  const now = Date.now();
-  const arr = (rateHits.get(ip) || []).filter(t => now - t < RATE_WINDOW_MS);
-  arr.push(now);
-  rateHits.set(ip, arr);
-  if (rateHits.size > RATE_MAP_MAX_ENTRIES) for (const [k, v] of rateHits) if (!v.some(t => now - t < RATE_WINDOW_MS)) rateHits.delete(k);
-  return arr.length > config.WEBHOOK_RATE_MAX;
-}
+// Shared per-IP sliding-window limiters (lib/rate-limit). Webhook = WEBHOOK_RATE_MAX/min;
+// public /trade/apply = TRADE_APPLY_RATE_MAX per TRADE_APPLY_RATE_WINDOW_MS (default 5/hr) —
+// TK-11185: the intake now server-side creates a Shopify customer per POST (write_customers),
+// so throttle it like the webhook to block customer-table pollution / welcome-email spam.
+const RATE_WINDOW_MS = 60000; // 1-minute sliding window (webhook)
+const webhookLimiter = createRateLimiter({ windowMs: RATE_WINDOW_MS, max: config.WEBHOOK_RATE_MAX });
+const tradeApplyLimiter = createRateLimiter({ windowMs: config.TRADE_APPLY_RATE_WINDOW_MS, max: config.TRADE_APPLY_RATE_MAX });
+function clientIp(req) { return (req.get('x-forwarded-for') || req.ip || '').split(',')[0].trim(); }
 function webhookAuth(req, res, next) {
   const tok = config.WEBHOOK_URL_TOKEN;
   if (tok) {
@@ -76,8 +74,8 @@ function webhookAuth(req, res, next) {
     // Live with no URL token configured → refuse rather than run the mint endpoint open.
     return res.status(503).json({ ok: false, error: 'webhook_url_token_unset' });
   }
-  const ip = (req.get('x-forwarded-for') || req.ip || '').split(',')[0].trim();
-  if (rateLimited(ip)) return res.status(429).json({ ok: false, error: 'rate_limited' });
+  const ip = clientIp(req);
+  if (webhookLimiter(ip)) return res.status(429).json({ ok: false, error: 'rate_limited' });
   next();
 }
 async function webhookHandler(req, res) {
@@ -149,6 +147,10 @@ app.options('/trade/apply', tradeCors);
 app.post('/trade/apply', tradeCors, async (req, res) => {
   const b = req.body || {};
   if (!b.email) return res.status(400).json({ ok: false, error: 'email required' });
+  // TK-11185: throttle per IP — the intake now creates a real Shopify customer per POST,
+  // so an unthrottled public endpoint could be sprayed to pollute the customer table /
+  // spam the designer-welcome email. Same sliding-window util as the webhook.
+  if (tradeApplyLimiter(clientIp(req))) return res.status(429).json({ ok: false, error: 'rate_limited' });
   // TK-11185: synchronously find-or-create the Shopify customer + stamp the id, so the
   // application is born LINKED and approve() can never hard-fail cannot_resolve_customer.
   // DW is on New Customer Accounts (OTP) so the account is minted server-side (Admin API),

← 31cd4dd TK-11185: server-side find-or-create customer at trade-apply  ·  back to Dw Signup Fulfillment  ·  TK-11185: back up live dw-signin-modal snippet + stage patch 6e51634 →