[object Object]

← back to Dw Signup Fulfillment

Magic-links now expire (48h, tamper-proof signed expiry) — closes contrarian LOW-MED. Verified fresh/expired/tampered/oldfmt/forged all behave. Only live-Shopify smoke test remains before go-live.

608509e750c4317c2330c7cc70703cad1d2c933b · 2026-07-28 14:06:05 -0700 · Steve Abrams

Files touched

Diff

commit 608509e750c4317c2330c7cc70703cad1d2c933b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jul 28 14:06:05 2026 -0700

    Magic-links now expire (48h, tamper-proof signed expiry) — closes contrarian LOW-MED. Verified fresh/expired/tampered/oldfmt/forged all behave. Only live-Shopify smoke test remains before go-live.
---
 5x/REPORT.md  |  4 ++--
 lib/config.js |  3 +++
 lib/email.js  |  2 +-
 lib/trade.js  | 30 ++++++++++++++++++++++--------
 4 files changed, 28 insertions(+), 11 deletions(-)

diff --git a/5x/REPORT.md b/5x/REPORT.md
index 24ec24c..445c0bc 100644
--- a/5x/REPORT.md
+++ b/5x/REPORT.md
@@ -62,8 +62,8 @@ Open (go-live wiring, not defects): set `PUBLIC_URL` to the Kamatera host so mag
 
 - **[CRITICAL — FIXED]** magic-link signing secret defaulted to `'dw-trade::' + ADMIN_PASS` = `dw-trade::DW2024!`, a publicly-documented password → every approve token forgeable from the public /trade/apply id. Panel forged a live token. **Fix:** 256-bit random `APPROVE_LINK_SECRET` in secrets master, fail-closed when unset (routes 403 / links → admin panel), NEVER derived from ADMIN_PASS. **Verified: forged old-secret token → 403; properly-signed → valid.** (commit 3561f86)
 - **[MEDIUM — FIXED]** fire-and-forget notify: `sendEmail` resolves `{ok:false}` on a George outage without throwing, so a failed notify was silent → application rots unseen. **Fix:** dead-letter to `data/trade-notify-failures.jsonl` + loud console error. (3561f86)
-- **[LOW-MED — DEFERRED]** magic-links never expire (stable HMAC). Residual risk now low: forging is impossible (strong secret) and `approve()` is idempotent (already-decided → no-op), so the only window is a legitimately-issued, not-yet-clicked link being forwarded. Recommend a 48h embedded-expiry as a fast-follow.
+- **[LOW-MED — FIXED]** magic-links now expire after `APPROVE_LINK_TTL_HOURS` (48h default). Token format `<exp>.<sig>` with the expiry INSIDE the HMAC payload (tamper-proof). Verified: fresh→valid, expired→403, tampered-expiry→403, old-format→403, forged→403.
 - **[GO-LIVE GATE]** the happy-path Shopify write (tagsAdd `trade`, metafield, rep+applicant emails) has NEVER run against a live customer — `cannot_resolve_customer` intercepts every DRY_RUN approve. Mandatory pre-go-live: one DRY_RUN=0 smoke test against a scratch Shopify customer to confirm `SHOPIFY_FULFILLMENT_TOKEN` actually has `write_customers` + tagsAdd returns 200.
 - **[MINOR — DEFERRED]** email `esc()` doesn't escape `"`; internal-email context, low risk.
 
-**Net: the ship-stopper (forgeable token) is FIXED and verified. Two go-live gates remain (live Shopify smoke test; optional link expiry).**
+**Net: forgeable-token (CRITICAL) + silent-rot (MEDIUM) + no-expiry (LOW-MED) all FIXED and verified. ONE go-live gate remains: the live Shopify happy-path smoke test at DRY_RUN=0.**
diff --git a/lib/config.js b/lib/config.js
index 677c783..6db47d9 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -97,6 +97,9 @@ const config = {
   // verifyActionToken() fails closed and the magic-link routes 503, so a missing secret
   // disables one-click approval rather than silently trusting a guessable key.
   APPROVE_LINK_SECRET: firstEnv('APPROVE_LINK_SECRET', SECRETS_ENVS),
+  // Approve/reject magic-links expire after this many hours (contrarian LOW-MED fix) so a
+  // 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 webhook hardening (the mint endpoint is public + secret-less) ---
   // 1) URL-token auth: register the webhook at /webhooks/customers/create/<token>.
diff --git a/lib/email.js b/lib/email.js
index f29acb5..ae886d3 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -151,7 +151,7 @@ function tradeApplicationEmail({ app, approveUrl, rejectUrl, adminUrl }) {
     `<a href="${esc(approveUrl)}" style="background:#16a34a;color:#fff;text-decoration:none;padding:12px 24px;border-radius:6px;font-weight:600;font-size:15px;display:inline-block">✓ Approve trade account</a>`,
     `</p>`,
     `<p style="font-size:13px;color:#6b7280">or <a href="${esc(rejectUrl)}" style="color:#b91c1c">reject this application</a> &nbsp;·&nbsp; <a href="${esc(adminUrl)}">open the admin panel</a></p>`,
-    `<p style="font-size:12px;color:#9ca3af">Approving assigns the house account and tags the customer <code>trade</code> in Shopify (unlimited free memo samples). One click — no login needed.</p>`,
+    `<p style="font-size:12px;color:#9ca3af">Approving assigns the house account and tags the customer <code>trade</code> in Shopify (unlimited free memo samples). One click — no login needed. These links expire in ${config.APPROVE_LINK_TTL_HOURS} hours; after that, use the admin panel.</p>`,
   ].join('\n');
   return { subject, html };
 }
diff --git a/lib/trade.js b/lib/trade.js
index a47eecd..b9ad72e 100644
--- a/lib/trade.js
+++ b/lib/trade.js
@@ -19,24 +19,38 @@ const shopify = require('./shopify');
 const email = require('./email');
 const config = require('./config');
 
-// One-click email approve/reject magic-links: HMAC over `action:id` with a server
+// One-click email approve/reject magic-links: HMAC over `action:id:exp` with a server
 // secret, so the button in the notification email approves/rejects WITHOUT a login.
-// Token is per-application AND per-action, so an approve link can't be replayed as a
-// reject and vice-versa. Truncated to 32 hex chars (128-bit) — plenty for a link.
+// Token is per-application AND per-action (an approve link can't be replayed as a
+// reject) AND time-boxed. Format: `<exp-ms>.<sig>` where sig = HMAC(action:id:exp)
+// truncated to 32 hex (128-bit). The expiry is IN the signed payload, so it can't be
+// extended without the secret; the verifier reads exp from the token and rejects it
+// once past. Stateless — no per-token record needed.
 //
 // FAIL-CLOSED (contrarian critical, 2026-07-28): if APPROVE_LINK_SECRET is unset we
 // return null (mint) / false (verify) rather than signing with an empty key — a token
 // signed with '' is trivially forgeable. Callers treat null/false as "magic-links off".
+// EXPIRY (contrarian LOW-MED, 2026-07-28): links die after APPROVE_LINK_TTL_HOURS (48h
+// default) so a forwarded/leaked, not-yet-clicked email can't be redeemed indefinitely.
 function secretOk() { return typeof config.APPROVE_LINK_SECRET === 'string' && config.APPROVE_LINK_SECRET.length >= 16; }
-function actionToken(id, action) {
+function sign(id, action, exp) {
+  return crypto.createHmac('sha256', config.APPROVE_LINK_SECRET).update(`${action}:${id}:${exp}`).digest('hex').slice(0, 32);
+}
+function actionToken(id, action, ttlMs) {
   if (!secretOk()) return null;
-  return crypto.createHmac('sha256', config.APPROVE_LINK_SECRET).update(action + ':' + id).digest('hex').slice(0, 32);
+  const exp = Date.now() + (ttlMs || config.APPROVE_LINK_TTL_HOURS * 3600 * 1000);
+  return `${exp}.${sign(id, action, exp)}`;
 }
 function verifyActionToken(id, action, token) {
   if (!token || !secretOk()) return false;
-  const expected = actionToken(id, action);
-  if (!expected) return false;
-  const a = Buffer.from(expected), b = Buffer.from(String(token));
+  const s = String(token);
+  const dot = s.indexOf('.');
+  if (dot < 1) return false;                       // malformed (old single-value format also fails → invalid)
+  const exp = Number(s.slice(0, dot));
+  const sig = s.slice(dot + 1);
+  if (!Number.isFinite(exp) || exp < Date.now()) return false;  // malformed or EXPIRED
+  const expected = sign(id, action, exp);
+  const a = Buffer.from(expected), b = Buffer.from(sig);
   return a.length === b.length && crypto.timingSafeEqual(a, b);
 }
 

← 44974d4 5x report: append contrarian gate outcome (critical secret f  ·  back to Dw Signup Fulfillment  ·  chore: lint safe-fixes (redact gift code in logs, esc gift c e723240 →