← back to NationalPaperHangers
Carry the IDOR-gate token on the customer's booking-confirmation link
362b0cceb330b04c541f82c828993b71804c6310 · 2026-05-18 20:15:48 -0700 · SteveStudio2
/bookings/:uuid is access-gated three ways; for a customer (who is not
the owning installer) the only way in is the HMAC ?t= token — without it
the page 404s so booking UUIDs can't be enumerated. But the customer
confirmation email built the link as a bare plain-text URL,
`${publicUrl}/bookings/${booking.uuid}`, with no ?t= param. Every
customer who clicked through from their own confirmation email hit a
404 and could not view, reschedule, or cancel their booking.
The booking object passed to bookingConfirmationCustomer already carries
view_token (set in routes/api.js after signing). The link now appends
?t=<view_token> and is a real <a> anchor, matching the installer
notification email's idiom. Unit test asserts the emitted HTML links to
/bookings/:uuid?t=<token> and that the embedded token verifies against
the uuid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Files touched
M lib/email.jsM tests/smoke.test.js
Diff
commit 362b0cceb330b04c541f82c828993b71804c6310
Author: SteveStudio2 <steve@designerwallcoverings.com>
Date: Mon May 18 20:15:48 2026 -0700
Carry the IDOR-gate token on the customer's booking-confirmation link
/bookings/:uuid is access-gated three ways; for a customer (who is not
the owning installer) the only way in is the HMAC ?t= token — without it
the page 404s so booking UUIDs can't be enumerated. But the customer
confirmation email built the link as a bare plain-text URL,
`${publicUrl}/bookings/${booking.uuid}`, with no ?t= param. Every
customer who clicked through from their own confirmation email hit a
404 and could not view, reschedule, or cancel their booking.
The booking object passed to bookingConfirmationCustomer already carries
view_token (set in routes/api.js after signing). The link now appends
?t=<view_token> and is a real <a> anchor, matching the installer
notification email's idiom. Unit test asserts the emitted HTML links to
/bookings/:uuid?t=<token> and that the embedded token verifies against
the uuid.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
---
lib/email.js | 7 ++++++-
tests/smoke.test.js | 25 +++++++++++++++++++++++++
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/lib/email.js b/lib/email.js
index c082837..addc8ac 100644
--- a/lib/email.js
+++ b/lib/email.js
@@ -57,6 +57,11 @@ function bookingConfirmationCustomer({ booking, installer, publicUrl }) {
const when = new Date(booking.scheduled_start).toLocaleString('en-US', {
weekday: 'long', month: 'long', day: 'numeric', hour: 'numeric', minute: '2-digit'
});
+ // The /bookings/:uuid page is IDOR-gated: a customer (not the owning
+ // installer) can only open it with the HMAC ?t= token, else it 404s. The
+ // token MUST ride along on the link or the confirmation email is a dead end.
+ const bookingUrl = `${publicUrl}/bookings/${booking.uuid}`
+ + (booking.view_token ? `?t=${encodeURIComponent(booking.view_token)}` : '');
return {
subject: `Booking confirmed with ${installer.business_name}`,
html: `<div style="font-family:Georgia,serif;max-width:560px;margin:0 auto;padding:32px 24px;color:#0e0e0e">
@@ -69,7 +74,7 @@ function bookingConfirmationCustomer({ booking, installer, publicUrl }) {
<tr><td style="padding:8px 0;color:#666">Installer</td><td style="padding:8px 0">${escapeHtml(installer.business_name)}</td></tr>
${installer.phone ? `<tr><td style="padding:8px 0;color:#666">Phone</td><td style="padding:8px 0">${escapeHtml(installer.phone)}</td></tr>` : ''}
</table>
- <p style="font-size:13px;color:#666;line-height:1.6">If you need to reschedule or cancel, reply to this email or visit ${publicUrl}/bookings/${booking.uuid}.</p>
+ <p style="font-size:13px;color:#666;line-height:1.6">If you need to reschedule or cancel, reply to this email or <a href="${bookingUrl}" style="color:#0e0e0e">view your booking</a>.</p>
<hr style="border:none;border-top:1px solid #ddd;margin:32px 0">
<p style="font-size:12px;color:#999">National Paper Hangers · info@nationalpaperhangers.com</p>
</div>`
diff --git a/tests/smoke.test.js b/tests/smoke.test.js
index ef78fae..b60e11f 100644
--- a/tests/smoke.test.js
+++ b/tests/smoke.test.js
@@ -281,6 +281,31 @@ test('clockMinutes parses valid times and rejects malformed/inverted input', ()
assert.ok(clockMinutes('09:00') < clockMinutes('09:30'));
});
+// ─────────────────────────────────────────────────────────────────────────────
+// 5c. bookingConfirmationCustomer — the customer's link must carry the
+// IDOR-gate ?t= token, or /bookings/:uuid 404s for the customer.
+// ─────────────────────────────────────────────────────────────────────────────
+test('booking confirmation email links to /bookings/:uuid with a valid ?t= token', () => {
+ const email = require('../lib/email');
+ const bookingToken = require('../lib/booking-token');
+
+ const uuid = crypto.randomUUID();
+ const view_token = bookingToken.sign(uuid);
+ const out = email.bookingConfirmationCustomer({
+ booking: {
+ uuid, view_token, customer_name: 'Jane Doe',
+ scheduled_start: new Date(Date.now() + 864e5), project_type: 'consultation'
+ },
+ installer: { business_name: 'Atelier Bond' },
+ publicUrl: 'https://www.nationalpaperhangers.com'
+ });
+
+ const m = /\/bookings\/([0-9a-f-]+)\?t=([A-Za-z0-9_-]+)/.exec(out.html);
+ assert.ok(m, 'confirmation email must link to /bookings/:uuid?t=<token>');
+ assert.equal(m[1], uuid, 'link uuid matches the booking');
+ assert.ok(bookingToken.verify(m[1], m[2]), 'embedded token must verify against the uuid');
+});
+
// ─────────────────────────────────────────────────────────────────────────────
// 6. Claim token: generate, verify, consume, second-use fails
// ─────────────────────────────────────────────────────────────────────────────
← da5acf0 Validate availability time windows instead of silently break
·
back to NationalPaperHangers
·
Guard booking lifecycle transitions against reviving termina 47d0f2e →