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:
@@ -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" }}
|
||||
|
||||
Reference in New Issue
Block a user