fix(rsvp): defend the edit flow against forwarded invitation links

When a guest submitted via their invitation and then forwarded the link
(or someone copied the URL), the recipient was shown the original guest's
response and a "Change my response" button. Two real problems:
  - Privacy leak: the original guest's reply was visible
  - Integrity: the recipient could silently overwrite the response

The fix is two layered defences plus a recovery path, matching the
industry pattern used by Eventbrite / Partiful / Lu.ma:

Backend
  - GET /access/{token} now compares the device fingerprint of the
    current request to the fingerprint stored on the existing RSVP.
    When they don't match, the rsvp field is omitted from the
    response and a new rsvp_submitted_elsewhere flag is set instead.
    The original guest's reply stays private.
  - PATCH /rsvp/{token} runs the same gate before scoring. A foreign
    device gets 403 with a hint to request an edit link.
  - The fingerprint check is intentionally narrow (user_agent only),
    so a guest jumping between Wi-Fi and mobile data on the same
    phone still sails through.

Recovery path
  - New POST /access/{token}/request-edit-link mints a short-lived
    edit nonce (Redis, 30-min TTL, SHA-256-hashed), then emails it
    to the guest's address on file via the existing EmailSender.
    Rate-limited to 3 per token per hour.
  - GET /access/{token}?edit=<nonce> and PATCH /rsvp with edit_nonce
    in the body both accept the nonce as a bypass for the
    same-device check. Lets the real guest edit from a new phone
    when their original device is gone.
  - New SendRSVPEditLink method on auth.EmailSender, implemented by
    every concrete sender (log stub / Resend / SMTP / SES), with a
    branded HTML+text template that explains the "we sent this
    because we didn't recognise the device" framing.

Frontend
  - rsvp/[token].vue learns the new "responded elsewhere" state.
    Renders "This invitation has already been used" + a
    "Send me an edit link" CTA when the access response says we
    have somewhere to deliver it. Empty-state copy reads "If you
    forwarded the link, please ask the original guest to reach
    out to the host".
  - When the URL carries ?edit=<nonce>, the page passes it on the
    /access call (so the backend unhides the RSVP), opens the edit
    form pre-populated, and forwards the nonce on PATCH.
  - Removed two leftover leaks from earlier — the page no longer
    shows internal "Risk score N · band" to confirmed or blocked
    guests; the blocked-attempt copy now reads "Something about
    this attempt looked off" rather than "suspicious access
    attempt".

Defensive nil-guard
  - The access handler's NATS publish goroutine now skips when
    deps.AccessPublisher is nil (matches the rsvp publisher's
    existing guard); without it the handler nil-panicked in tests
    that don't wire NATS.

Tests
  - TestFingerprintsSimilar (unit) covers the same-UA / different-UA
    / missing-UA matrix.
  - TestForwardedInvitationLinkDefence (integration) walks the full
    flow: submit from UA-A, hide on UA-B, request link, follow nonce
    from UA-B and edit, then verify a UA-C with a forged nonce is
    still refused.
  - Full integration suite passes (183.5s).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Kwaku Danso
2026-05-20 15:09:07 +01:00
parent b34715f152
commit dbddf17e3b
16 changed files with 893 additions and 25 deletions
+102 -1
View File
@@ -30,6 +30,11 @@ interface AccessResponse {
token: { id: string; status: string; expires_at: string }
access_log_id: string
rsvp?: ExistingRSVP | null
// Forwarded-link defence (Block G follow-up). When the Gate didn't
// recognise the device, the existing RSVP is hidden from the
// response and these flags drive the alternative UX.
rsvp_submitted_elsewhere?: boolean
can_request_edit_link?: boolean
calendar?: CalendarLinks
branding?: BrandingPayload | null
}
@@ -63,6 +68,25 @@ const submitError = ref<string | null>(null)
const existing = ref<ExistingRSVP | null>(null)
const editing = ref(false)
// Forwarded-link defence state.
// When the access response sets rsvp_submitted_elsewhere=true, an RSVP
// exists but the Gate didn't recognise this device, so we don't surface
// the response details. The original guest (if it really is them on a
// new phone) can request a one-time edit link to their email.
const respondedElsewhere = ref(false)
const canRequestEditLink = ref(false)
const requestingEditLink = ref(false)
const editLinkSent = ref(false)
const editLinkError = ref<string | null>(null)
// editNonce comes from the magic edit link the guest receives by email
// (?edit=<nonce> in the URL). When present we pass it on /access and
// later on PATCH so the backend treats this device as the original.
const editNonce = computed(() => {
const q = route.query.edit
return typeof q === 'string' ? q : ''
})
const editsRemaining = computed(() => {
const used = existing.value?.edit_count ?? 0
return Math.max(0, MAX_EDITS - used)
@@ -77,12 +101,23 @@ function prefillFromRSVP(rsvp: ExistingRSVP) {
onMounted(async () => {
try {
access.value = await useApi<AccessResponse>(`/access/${token}`)
// Pass through the edit nonce if the guest arrived via the magic
// edit link. The backend uses it to unhide the existing RSVP.
const path = editNonce.value
? `/access/${token}?edit=${encodeURIComponent(editNonce.value)}`
: `/access/${token}`
access.value = await useApi<AccessResponse>(path)
if (!access.value) return
plusOnes.value = access.value.guest.plus_ones || 0
if (access.value.rsvp) {
existing.value = access.value.rsvp
prefillFromRSVP(access.value.rsvp)
// Magic-link arrival: open the form straight into edit mode so
// the guest doesn't have to click "Change my response" first.
if (editNonce.value) editing.value = true
} else if (access.value.rsvp_submitted_elsewhere) {
respondedElsewhere.value = true
canRequestEditLink.value = !!access.value.can_request_edit_link
}
} catch (e: any) {
loadError.value = e?.data?.error || e?.message || 'Invitation not found'
@@ -91,6 +126,19 @@ onMounted(async () => {
}
})
async function requestEditLink() {
requestingEditLink.value = true
editLinkError.value = null
try {
await useApi(`/access/${token}/request-edit-link`, { method: 'POST' })
editLinkSent.value = true
} catch (e: any) {
editLinkError.value = e?.data?.error || e?.message || 'Could not send the edit link'
} finally {
requestingEditLink.value = false
}
}
async function submit() {
submitting.value = true
submitError.value = null
@@ -104,6 +152,11 @@ async function submit() {
plus_ones: plusOnes.value,
dietary_notes: dietary.value || null,
fingerprint: fp,
// Forwarded-link defence: pass the edit nonce on PATCH so the
// backend can bypass the same-device check when the guest is
// legitimately on a new device. Empty on POST and on
// same-device edits.
...(editNonce.value ? { edit_nonce: editNonce.value } : {}),
},
})
// Refresh the cached "existing" view so a back-to-summary toggle shows
@@ -195,6 +248,54 @@ const submitLabel = computed(() => {
</p>
</div>
<!-- Forwarded-link landing. There's an RSVP on file for this invitation,
but we don't recognise this device, so we don't show the response
or let it be changed here. If we have the original guest's email
on file, they can request a one-time edit link to recover. -->
<div v-else-if="respondedElsewhere" class="card">
<h1 class="mb-2 text-xl font-semibold">This invitation has already been used</h1>
<p class="mb-4 text-sm text-zinc-300">
Someone has already replied with this invitation, so the response
is private. If you forwarded the link, please ask the original guest
to reach out to the host.
</p>
<div v-if="canRequestEditLink && !editLinkSent" class="rounded-md border border-brand-900/40 bg-brand-500/[0.04] p-4">
<p class="mb-3 text-sm text-zinc-200">
<strong>Is this your invitation, just on a new device?</strong>
We can send a one-time edit link to the email we have on file
so you can review and update your reply.
</p>
<button
type="button"
class="btn-primary text-sm"
:disabled="requestingEditLink"
@click="requestEditLink"
>
{{ requestingEditLink ? 'Sending' : 'Send me an edit link' }}
</button>
<p v-if="editLinkError" class="mt-3 text-sm text-red-400">{{ editLinkError }}</p>
<p class="mt-3 text-xs text-zinc-500">
The link expires in 30 minutes. If you don't see the email shortly,
check your spam folder or ask your host to resend your invitation.
</p>
</div>
<div v-else-if="editLinkSent" class="rounded-md border border-brand-900/40 bg-brand-500/[0.04] p-4">
<p class="text-sm text-brand-200">
<strong>Edit link on its way.</strong>
</p>
<p class="mt-2 text-sm text-zinc-300">
Check your inbox for an email from us. The link expires in 30 minutes,
so do click it as soon as you can.
</p>
</div>
<p v-else class="text-xs text-zinc-500">
If you think you're the original guest, please contact your host for help.
</p>
</div>
<div v-else-if="result?.rsvp && !editing" class="card border-brand-900/60 bg-brand-950/20">
<h1 class="mb-2 text-xl font-semibold text-brand-200">
{{ result.edited ? 'Your response has been updated' : "You're confirmed" }}