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
+12
View File
@@ -102,6 +102,18 @@ func (s *SESEmailSender) SendCollaboratorInvite(ctx context.Context, to, inviter
})
}
// SendRSVPEditLink delivers a one-time edit link to a guest who opened
// their invitation from a device the Gate didn't recognise.
func (s *SESEmailSender) SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error {
return s.sendTemplated(ctx, to,
"Edit your RSVP for "+eventName,
TmplRSVPEditLink, map[string]any{
"GuestName": guestName,
"EventName": eventName,
"Link": link,
})
}
// SendGuest is used by the notifier worker for invitation / confirmation /
// reminder emails — anything addressed at a guest.
func (s *SESEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (providerMessageID string, err error) {
+7
View File
@@ -34,6 +34,7 @@ type CombinedEmailSender interface {
SendVerification(ctx context.Context, to, name, link string) error
SendPasswordReset(ctx context.Context, to, name, link string) error
SendCollaboratorInvite(ctx context.Context, to, inviterName, eventName, role, link string) error
SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error
SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error)
}
@@ -88,6 +89,12 @@ func (l *logCombinedSender) SendCollaboratorInvite(_ context.Context, to, invite
return nil
}
func (l *logCombinedSender) SendRSVPEditLink(_ context.Context, to, guestName, eventName, link string) error {
l.logger.Info("auth email (stub): rsvp edit link",
"to", to, "guest", guestName, "event", eventName, "link", link)
return nil
}
func (l *logCombinedSender) SendGuest(_ context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
if data == nil {
data = map[string]any{}
+11
View File
@@ -83,6 +83,17 @@ func (s *ResendEmailSender) SendCollaboratorInvite(ctx context.Context, to, invi
return err
}
func (s *ResendEmailSender) SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error {
_, err := s.sendTemplated(ctx, to,
"Edit your RSVP for "+eventName,
TmplRSVPEditLink, map[string]any{
"GuestName": guestName,
"EventName": eventName,
"Link": link,
})
return err
}
// --- GuestEmailDispatcher ---
func (s *ResendEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
+10
View File
@@ -83,6 +83,16 @@ func (s *SMTPEmailSender) SendCollaboratorInvite(ctx context.Context, to, invite
})
}
func (s *SMTPEmailSender) SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error {
return s.sendTemplated(ctx, to,
"Edit your RSVP for "+eventName,
TmplRSVPEditLink, map[string]any{
"GuestName": guestName,
"EventName": eventName,
"Link": link,
})
}
// --- GuestEmailDispatcher ---
func (s *SMTPEmailSender) SendGuest(ctx context.Context, to, subject string, name TemplateName, data map[string]any) (string, error) {
+1
View File
@@ -25,6 +25,7 @@ const (
TmplConfirmation TemplateName = "confirmation"
TmplReminder TemplateName = "reminder"
TmplCollaboratorInvite TemplateName = "collaborator_invite"
TmplRSVPEditLink TemplateName = "rsvp_edit_link"
)
// Templates renders branded transactional emails for both HTML and
@@ -0,0 +1,19 @@
{{define "body"}}
<p style="font-size:13px;letter-spacing:0.2em;text-transform:uppercase;color:#16a34a;margin:0 0 16px;">✦ Edit your RSVP</p>
<h1 style="font-size:24px;margin:0 0 4px;color:#0a0a0a;">{{.EventName}}</h1>
<p style="margin:18px 0 18px;">
Hi {{.GuestName}}, you opened your invitation from a device we hadn't seen
before, so we've sent this link to make sure it's really you.
</p>
<p style="margin:0 0 12px;">
Use the button below to view and update your RSVP.
</p>
<p style="margin:0 0 24px;text-align:center;">
<a href="{{.Link}}" style="background:#22c55e;color:#0a0a0a;padding:12px 22px;border-radius:8px;font-weight:600;text-decoration:none;display:inline-block;">Edit my RSVP</a>
</p>
<p style="margin:0 0 8px;color:#64748b;font-size:13px;">
This link expires in 30 minutes. If you didn't request it, you can ignore this
email — your existing reply stays the way you left it.
</p>
<p style="margin:8px 0 0;word-break:break-all;font-size:12px;color:#0f172a;">{{.Link}}</p>
{{end}}
@@ -0,0 +1,10 @@
Hi {{.GuestName}},
You opened your invitation to "{{.EventName}}" from a device we hadn't seen
before, so we've sent this link to make sure it's really you.
Use this link to view and update your RSVP:
{{.Link}}
The link expires in 30 minutes. If you didn't request it, you can ignore
this email — your existing reply stays the way you left it.