← 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
M 5x/REPORT.mdM lib/config.jsM lib/email.jsM lib/trade.js
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> · <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 →