[object Object]

← 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

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 →