← 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
M lib/config.jsA lib/rate-limit.jsM scripts/selftest.jsM server.js
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 →