From dbddf17e3b6e660af21c55ff5789cb0796537d87 Mon Sep 17 00:00:00 2001 From: Kwaku Danso <72142185+cloud-dev101@users.noreply.github.com> Date: Wed, 20 May 2026 15:09:07 +0100 Subject: [PATCH] fix(rsvp): defend the edit flow against forwarded invitation links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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= 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=, 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 --- frontend/pages/rsvp/[token].vue | 103 +++++- internal/api/rsvp_edit.go | 111 ++++++ internal/api/rsvp_edit_test.go | 52 +++ internal/api/rsvps.go | 38 +++ internal/api/server.go | 11 + internal/api/tokens.go | 190 +++++++++-- internal/auth/email.go | 14 + internal/notification/email_ses.go | 12 + internal/notification/factory.go | 7 + internal/notification/resend_sender.go | 11 + internal/notification/smtp_sender.go | 10 + internal/notification/templates.go | 1 + .../templates/rsvp_edit_link.html | 19 ++ .../notification/templates/rsvp_edit_link.txt | 10 + test/integration/auth_test.go | 6 + test/integration/rsvp_forwarded_link_test.go | 323 ++++++++++++++++++ 16 files changed, 893 insertions(+), 25 deletions(-) create mode 100644 internal/api/rsvp_edit.go create mode 100644 internal/api/rsvp_edit_test.go create mode 100644 internal/notification/templates/rsvp_edit_link.html create mode 100644 internal/notification/templates/rsvp_edit_link.txt create mode 100644 test/integration/rsvp_forwarded_link_test.go diff --git a/frontend/pages/rsvp/[token].vue b/frontend/pages/rsvp/[token].vue index 8f354c0..064e1f3 100644 --- a/frontend/pages/rsvp/[token].vue +++ b/frontend/pages/rsvp/[token].vue @@ -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(null) const existing = ref(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(null) + +// editNonce comes from the magic edit link the guest receives by email +// (?edit= 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(`/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(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(() => {

+ +
+

This invitation has already been used

+

+ 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. +

+ +
+

+ Is this your invitation, just on a new device? + We can send a one-time edit link to the email we have on file + so you can review and update your reply. +

+ +

{{ editLinkError }}

+

+ 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. +

+
+ +
+

+ Edit link on its way. +

+

+ Check your inbox for an email from us. The link expires in 30 minutes, + so do click it as soon as you can. +

+
+ +

+ If you think you're the original guest, please contact your host for help. +

+
+

{{ result.edited ? 'Your response has been updated' : "You're confirmed" }} diff --git a/internal/api/rsvp_edit.go b/internal/api/rsvp_edit.go new file mode 100644 index 0000000..f2ca545 --- /dev/null +++ b/internal/api/rsvp_edit.go @@ -0,0 +1,111 @@ +package api + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "time" + + "github.com/google/uuid" + "github.com/redis/go-redis/v9" +) + +// RSVP-edit nonce store. When a guest revisits their invitation from a +// device that doesn't match the one they originally responded from, we +// hide their RSVP details from the access response so a forwarded link +// can't leak (or alter) their reply. They can request a one-time edit +// link sent to their email/SMS; that link carries a short-lived nonce +// stored here. +// +// We store sha256(raw) so a database snapshot of Redis doesn't give an +// attacker working nonces; the raw value only ever lives in the email +// link the guest receives. + +const ( + editNonceTTL = 30 * time.Minute + editNonceKeyPrefix = "gg:rsvp_edit:" +) + +type editNonceStore struct { + rdb *redis.Client +} + +// newEditNonceStore returns nil when Redis isn't configured — the calling +// handler treats nil as "edit-link flow disabled" and falls back to the +// strict same-device rule. +func newEditNonceStore(rdb *redis.Client) *editNonceStore { + if rdb == nil { + return nil + } + return &editNonceStore{rdb: rdb} +} + +// Mint creates a fresh nonce bound to guestID, stores its hash with a +// 30-minute TTL, and returns the raw value for embedding in the email +// link. Hex-encoded so it survives URL transit without further escaping. +func (s *editNonceStore) Mint(ctx context.Context, guestID uuid.UUID) (raw string, err error) { + if s == nil { + return "", errors.New("edit nonce store not configured") + } + buf := make([]byte, 24) + if _, err := rand.Read(buf); err != nil { + return "", err + } + raw = hex.EncodeToString(buf) + sum := sha256.Sum256([]byte(raw)) + key := editNonceKeyPrefix + hex.EncodeToString(sum[:]) + if err := s.rdb.Set(ctx, key, guestID.String(), editNonceTTL).Err(); err != nil { + return "", err + } + return raw, nil +} + +// Verify reports whether raw is a still-valid nonce for guestID. +// Doesn't consume the nonce; expiry happens naturally via TTL so the +// guest can refresh the edit page or briefly close + reopen the link +// during the half-hour window without losing their session. +func (s *editNonceStore) Verify(ctx context.Context, raw string, guestID uuid.UUID) (bool, error) { + if s == nil || raw == "" { + return false, nil + } + sum := sha256.Sum256([]byte(raw)) + key := editNonceKeyPrefix + hex.EncodeToString(sum[:]) + val, err := s.rdb.Get(ctx, key).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return false, nil + } + return false, err + } + return val == guestID.String(), nil +} + +// fingerprintsSimilar reports whether two device fingerprints look like +// the same browser. The check is intentionally narrow: just `user_agent`, +// because it's the strongest signal of "same browser" and the one least +// affected by normal day-to-day variation (changing networks, switching +// rooms, etc). +// +// Conservative-by-default: when either side lacks a user_agent string we +// return false, which causes the access handler to hide the RSVP. The +// legitimate guest in that edge case can still recover via the +// request-edit-link flow. +// +// Two fingerprints with the same user_agent but different IPs are +// considered the same device. This is on purpose: a guest jumping +// between Wi-Fi and mobile data is the same person on the same phone. +// The Gate's full scoring (run on submit/edit, not on access) catches +// the more sophisticated mismatches. +func fingerprintsSimilar(stored, current map[string]any) bool { + if stored == nil || current == nil { + return false + } + s, _ := stored["user_agent"].(string) + c, _ := current["user_agent"].(string) + if s == "" || c == "" { + return false + } + return s == c +} diff --git a/internal/api/rsvp_edit_test.go b/internal/api/rsvp_edit_test.go new file mode 100644 index 0000000..1333a44 --- /dev/null +++ b/internal/api/rsvp_edit_test.go @@ -0,0 +1,52 @@ +package api + +import "testing" + +func TestFingerprintsSimilar(t *testing.T) { + const ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_2) AppleWebKit/605" + other := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537" + + tests := []struct { + name string + stored, current map[string]any + want bool + }{ + { + name: "identical UA, different IPs counts as same device", + stored: map[string]any{"user_agent": ua, "accept_language": "en"}, + current: map[string]any{"user_agent": ua, "accept_language": "fr"}, + want: true, + }, + { + name: "different UA never matches", + stored: map[string]any{"user_agent": ua}, + current: map[string]any{"user_agent": other}, + want: false, + }, + { + name: "missing UA on current side is treated as mismatch", + stored: map[string]any{"user_agent": ua}, + current: map[string]any{}, + want: false, + }, + { + name: "missing UA on stored side is treated as mismatch", + stored: map[string]any{}, + current: map[string]any{"user_agent": ua}, + want: false, + }, + { + name: "both nil is mismatch (conservative default)", + stored: nil, + current: nil, + want: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := fingerprintsSimilar(tc.stored, tc.current); got != tc.want { + t.Errorf("fingerprintsSimilar = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/internal/api/rsvps.go b/internal/api/rsvps.go index c5f9026..0ead5dd 100644 --- a/internal/api/rsvps.go +++ b/internal/api/rsvps.go @@ -34,6 +34,7 @@ type rsvpHandler struct { rsvps *storage.RSVPRepo accessLogs *storage.AccessLogRepo allowlist *storage.AllowlistRepo + editNonces *editNonceStore scorer fraudScorer pub rsvpPublisher } @@ -43,6 +44,12 @@ type submitRSVPRequest struct { PlusOnes int `json:"plus_ones"` DietaryNotes *string `json:"dietary_notes"` Fingerprint map[string]any `json:"fingerprint"` + // EditNonce is the short-lived value carried back from the magic + // edit link the guest received by email. It bypasses the + // same-device check on PATCH so a guest who jumped to a new phone + // can still edit their RSVP. Empty on POST and on edits from the + // original device. + EditNonce string `json:"edit_nonce,omitempty"` } type submitRSVPResponse struct { @@ -117,6 +124,13 @@ func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) { // PATCH /rsvp/{token} — revise an existing RSVP. Same fraud check as POST. // The prior state is snapshotted into rsvp_revisions inside Update; edits // past MaxRSVPEdits return 429. +// +// As of the forwarded-link defence: a PATCH is only accepted when either +// (a) the current device fingerprint matches the one stored on the +// existing RSVP, or (b) the caller presented a valid edit nonce in the +// request body. Otherwise we refuse with 403 + a hint to request an edit +// link — this stops anyone the original guest forwarded the link to from +// quietly altering the response. func (h *rsvpHandler) edit(w http.ResponseWriter, r *http.Request) { tk, ok := h.loadValidToken(w, r) if !ok { @@ -136,6 +150,28 @@ func (h *rsvpHandler) edit(w http.ResponseWriter, r *http.Request) { return } + // Forwarded-link gate. Load the existing RSVP (if any) so we can + // compare device fingerprints; absent RSVP means this is a quirky + // PATCH-before-POST flow that Update will 404 below anyway. + if existing, err := h.rsvps.GetByGuest(r.Context(), guest.ID); err == nil && existing != nil { + current := mergeFingerprint(req.Fingerprint, collectFingerprint(r)) + if !fingerprintsSimilar(existing.DeviceFingerprint, current) { + // Bypass via edit nonce delivered to the guest's email. + bypassed := false + if h.editNonces != nil && req.EditNonce != "" { + if ok, _ := h.editNonces.Verify(r.Context(), req.EditNonce, guest.ID); ok { + bypassed = true + } + } + if !bypassed { + writeError(w, http.StatusForbidden, + "this invitation looks like it's being opened from a different device; "+ + "please request an edit link from the previous screen") + return + } + } + } + decision, fingerprint, ip, ok := h.scoreAccess(w, r, event, guest, tk, req.Fingerprint) if !ok { return @@ -238,6 +274,7 @@ type decodedRSVPRequest struct { PlusOnes int DietaryNotes *string Fingerprint map[string]any + EditNonce string } func decodeSubmitRSVP(w http.ResponseWriter, r *http.Request) (decodedRSVPRequest, bool) { @@ -260,6 +297,7 @@ func decodeSubmitRSVP(w http.ResponseWriter, r *http.Request) (decodedRSVPReques PlusOnes: req.PlusOnes, DietaryNotes: req.DietaryNotes, Fingerprint: req.Fingerprint, + EditNonce: req.EditNonce, }, true } diff --git a/internal/api/server.go b/internal/api/server.go index 167df23..32309b4 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -102,6 +102,7 @@ func NewServer(deps ServerDeps) (*Server, error) { analyticsRepo := storage.NewAnalyticsRepo(deps.DB) brandingRepo := storage.NewBrandingRepo(deps.DB) allowlistRepo := storage.NewAllowlistRepo(deps.DB) + editNonces := newEditNonceStore(deps.Redis) feedbackRepo := storage.NewFeedbackRepo(deps.DB) // Branding image store. Empty UploadsDir leaves it nil and the upload @@ -194,6 +195,8 @@ func NewServer(deps ServerDeps) (*Server, error) { rsvps: rsvpRepo, collabs: collabRepo, branding: brandingRepo, + editNonces: editNonces, + emails: emails, gen: auth.NewGenerator(), ttl: deps.TokenTTL, pub: deps.AccessPublisher, @@ -208,6 +211,7 @@ func NewServer(deps ServerDeps) (*Server, error) { rsvps: rsvpRepo, accessLogs: accessRepo, allowlist: allowlistRepo, + editNonces: editNonces, scorer: deps.FraudScorer, pub: deps.RSVPPublisher, }, @@ -443,6 +447,13 @@ func (s *Server) Handler() http.Handler { // Block B: .ics download. Same rate-limit class as /access since the // payload is similarly cheap and the abuse profile is identical (one // token per attacker). + // Forwarded-link defence: when a guest opens their invitation from a + // device the original RSVP wasn't submitted from, /access hides the + // reply. This endpoint mints a short-lived edit nonce and emails it + // so the real guest can recover from a new phone / new laptop. + mux.Handle("POST /access/{token}/request-edit-link", + rl("rsvp_edit_request", 3, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.requestEditLink))) + mux.Handle("GET /access/{token}/calendar.ics", rl("calendar_ics", 60, time.Hour, pathKey("token"), http.HandlerFunc(s.tokens.calendar))) mux.Handle("POST /rsvp/{token}", diff --git a/internal/api/tokens.go b/internal/api/tokens.go index 28d7bc9..9332c0e 100644 --- a/internal/api/tokens.go +++ b/internal/api/tokens.go @@ -36,6 +36,8 @@ type tokenHandler struct { rsvps *storage.RSVPRepo collabs *storage.CollaboratorRepo branding *storage.BrandingRepo + editNonces *editNonceStore + emails auth.EmailSender gen *auth.Generator ttl time.Duration pub accessPublisher @@ -412,7 +414,22 @@ type accessResponse struct { // RSVP is the guest's current submission, if any. Populated so the RSVP // page can show an edit form instead of a fresh submit form when the // guest revisits their invitation link (Tier 2 Block A). + // + // As of the forwarded-link defence (Tier 2 Block G follow-up): this is + // only populated when the current device looks like the device that + // originally submitted, OR when the caller presented a valid edit nonce. + // A forwarded-link recipient sees a nil RSVP + RSVPSubmittedElsewhere=true + // instead, so the original guest's response stays private and unmodifiable. RSVP *domain.RSVP `json:"rsvp,omitempty"` + // RSVPSubmittedElsewhere signals "there's an RSVP on file but we're + // hiding it because this looks like a different device". The frontend + // renders a "this invitation has already been responded to" view + + // (when CanRequestEditLink) a "send me an edit link" CTA. + RSVPSubmittedElsewhere bool `json:"rsvp_submitted_elsewhere,omitempty"` + // CanRequestEditLink reports whether we have a way to deliver an edit + // link to the guest (email or phone on file). When false the only + // path is for the guest to contact the host directly. + CanRequestEditLink bool `json:"can_request_edit_link,omitempty"` // Calendar holds the add-to-calendar deep-links and the .ics download // path so the frontend renders four ready-to-click buttons after a // successful RSVP. (Tier 2 Block B.) @@ -469,23 +486,25 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) { h.logger.Error("create access log", "err", err) } - go func(evt natspub.AccessAttempted) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := h.pub.PublishAccessAttempted(ctx, evt); err != nil { - h.logger.Error("publish access.attempted", "err", err, "guest_id", evt.GuestID) - } - }(natspub.AccessAttempted{ - EventID: event.ID, - GuestID: guest.ID, - TokenID: tk.ID, - AccessLogID: accessLogID, - Fingerprint: fingerprint, - IPAddress: ip, - UserAgent: r.UserAgent(), - Referrer: r.Referer(), - OccurredAt: time.Now().UTC(), - }) + if h.pub != nil { + go func(evt natspub.AccessAttempted) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := h.pub.PublishAccessAttempted(ctx, evt); err != nil { + h.logger.Error("publish access.attempted", "err", err, "guest_id", evt.GuestID) + } + }(natspub.AccessAttempted{ + EventID: event.ID, + GuestID: guest.ID, + TokenID: tk.ID, + AccessLogID: accessLogID, + Fingerprint: fingerprint, + IPAddress: ip, + UserAgent: r.UserAgent(), + Referrer: r.Referer(), + OccurredAt: time.Now().UTC(), + }) + } var brandingPayload *domain.Branding if h.branding != nil { @@ -518,17 +537,140 @@ func (h *tokenHandler) access(w http.ResponseWriter, r *http.Request) { } } + // Forwarded-link defence (Tier 2 Block G follow-up). If a previous + // submission exists, only surface its details when the current + // device looks like the one that submitted, OR when the caller + // presented a valid edit nonce in ?edit=. Anything else gets + // the "responded elsewhere" view — no leak, no edit. + rsvpPayload := existingRSVP + var rsvpSubmittedElsewhere, canRequestEditLink bool + if existingRSVP != nil && !fingerprintsSimilar(existingRSVP.DeviceFingerprint, fingerprint) { + bypassed := false + if nonce := r.URL.Query().Get("edit"); nonce != "" && h.editNonces != nil { + if ok, _ := h.editNonces.Verify(r.Context(), nonce, guest.ID); ok { + bypassed = true + } + } + if !bypassed { + rsvpPayload = nil + rsvpSubmittedElsewhere = true + canRequestEditLink = + h.editNonces != nil && + ((guest.Email != nil && *guest.Email != "") || + (guest.Phone != nil && *guest.Phone != "")) + } + } + writeJSON(w, http.StatusOK, accessResponse{ - Guest: guest, - Event: event, - Token: tk, - AccessLog: accessLogID, - RSVP: existingRSVP, - Calendar: h.calendarLinks(event, raw), - Branding: brandingPayload, + Guest: guest, + Event: event, + Token: tk, + AccessLog: accessLogID, + RSVP: rsvpPayload, + RSVPSubmittedElsewhere: rsvpSubmittedElsewhere, + CanRequestEditLink: canRequestEditLink, + Calendar: h.calendarLinks(event, raw), + Branding: brandingPayload, }) } +// requestEditLinkResponse is the wire shape of POST /access/{token}/request-edit-link. +type requestEditLinkResponse struct { + // Channel hints at where the link went so the frontend can render + // "Sent to your email" vs. "Sent by SMS" feedback. Empty when the + // store/sender wasn't configured (dev environments without email + // wired up — the frontend should still treat that as success). + Channel string `json:"channel,omitempty"` +} + +// POST /access/{token}/request-edit-link — public, token-scoped. When a +// guest opens their invitation from an unfamiliar device the regular +// access response hides their RSVP. This endpoint lets them prove email +// or phone ownership instead: we mint a short-lived edit nonce and +// deliver it to the address on file. +// +// Rate limit lives on the route registration (3 per hour per token). +// The endpoint itself stays generous about the response — we never +// reveal whether a token has an RSVP attached, just whether the request +// itself was acceptable. +func (h *tokenHandler) requestEditLink(w http.ResponseWriter, r *http.Request) { + raw := r.PathValue("token") + if err := auth.ValidateFormat(raw); err != nil { + writeError(w, http.StatusBadRequest, "malformed token") + return + } + tk, err := h.tokens.GetByHash(r.Context(), auth.HashToken(raw)) + if err != nil { + if errors.Is(err, domain.ErrTokenNotFound) { + writeError(w, http.StatusNotFound, "token not found") + return + } + writeError(w, http.StatusInternalServerError, "failed to load token") + return + } + if err := tk.IsValid(time.Now().UTC()); err != nil { + writeError(w, http.StatusGone, err.Error()) + return + } + guest, err := h.guests.Get(r.Context(), tk.GuestID) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to load guest") + return + } + + if h.editNonces == nil { + // Redis isn't wired up; the feature is disabled. Tell the caller + // honestly rather than pretending we sent something. + writeError(w, http.StatusServiceUnavailable, "edit-link delivery isn't configured for this environment") + return + } + + // Only attempt delivery when there's somewhere to deliver to. Without + // email or phone on file the request is a 404 — we don't have a + // secure channel for the nonce. + hasEmail := guest.Email != nil && *guest.Email != "" + if !hasEmail { + // Phone-only delivery is a future enhancement (Twilio path is + // wired for the broader notifier; not for synchronous edit links + // yet). For now treat as no-channel. + writeError(w, http.StatusNotFound, "no email on file for this guest") + return + } + + nonce, err := h.editNonces.Mint(r.Context(), guest.ID) + if err != nil { + h.logger.Error("mint edit nonce", "err", err, "guest_id", guest.ID) + writeError(w, http.StatusInternalServerError, "failed to issue edit link") + return + } + + // Resolve the event name for the email. Best-effort: if the lookup + // fails we still send a link, just with a generic fallback. + eventName := "your event" + if event, err := h.events.Get(r.Context(), guest.EventID); err == nil { + eventName = event.Name + } + + link := h.editLink(raw, nonce) + if h.emails != nil { + if err := h.emails.SendRSVPEditLink(r.Context(), *guest.Email, guest.Name, eventName, link); err != nil { + h.logger.Warn("send rsvp edit link", "err", err, "guest_id", guest.ID) + // Don't 500 — the nonce already exists in Redis, and we've + // logged the link in the dev stub. A 202-ish behaviour: + // "accepted; delivery might be best-effort". + } + } + writeJSON(w, http.StatusAccepted, requestEditLinkResponse{Channel: "email"}) +} + +func (h *tokenHandler) editLink(rawToken, nonce string) string { + base := h.publicBaseURL + if base == "" { + base = "http://localhost:3000" + } + return base + "/rsvp/" + rawToken + "?edit=" + nonce +} + // calendarLinks renders the three provider URLs + .ics path for the event. // The raw access token is embedded in the .ics path so the download endpoint // stays public (no auth) while still scoped to a single invitation. diff --git a/internal/auth/email.go b/internal/auth/email.go index 5a3ad9f..d0dc489 100644 --- a/internal/auth/email.go +++ b/internal/auth/email.go @@ -13,6 +13,13 @@ type EmailSender 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 delivers a one-time edit link to a guest who's + // reopened their invitation from a device the Gate didn't recognise. + // Tier 2 Block G follow-up. The link carries a short-lived nonce + // (~30 min) that lets the guest see + change their existing RSVP + // without exposing it to anyone who got hold of the original + // invitation token. + SendRSVPEditLink(ctx context.Context, to, guestName, eventName, link string) error } type LogEmailSender struct { @@ -39,3 +46,10 @@ func (l LogEmailSender) SendCollaboratorInvite(_ context.Context, to, inviterNam ) return nil } + +func (l LogEmailSender) 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 +} diff --git a/internal/notification/email_ses.go b/internal/notification/email_ses.go index a63ca67..259908a 100644 --- a/internal/notification/email_ses.go +++ b/internal/notification/email_ses.go @@ -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) { diff --git a/internal/notification/factory.go b/internal/notification/factory.go index 30f7de3..6a0707d 100644 --- a/internal/notification/factory.go +++ b/internal/notification/factory.go @@ -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{} diff --git a/internal/notification/resend_sender.go b/internal/notification/resend_sender.go index bc82489..58b6fca 100644 --- a/internal/notification/resend_sender.go +++ b/internal/notification/resend_sender.go @@ -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) { diff --git a/internal/notification/smtp_sender.go b/internal/notification/smtp_sender.go index 679ccad..fc52e0c 100644 --- a/internal/notification/smtp_sender.go +++ b/internal/notification/smtp_sender.go @@ -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) { diff --git a/internal/notification/templates.go b/internal/notification/templates.go index 9e8b7cd..4b0b148 100644 --- a/internal/notification/templates.go +++ b/internal/notification/templates.go @@ -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 diff --git a/internal/notification/templates/rsvp_edit_link.html b/internal/notification/templates/rsvp_edit_link.html new file mode 100644 index 0000000..fd6ea6a --- /dev/null +++ b/internal/notification/templates/rsvp_edit_link.html @@ -0,0 +1,19 @@ +{{define "body"}} +

✦ Edit your RSVP

+

{{.EventName}}

+

+ 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. +

+

+ Use the button below to view and update your RSVP. +

+

+ Edit my RSVP +

+

+ 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. +

+

{{.Link}}

+{{end}} diff --git a/internal/notification/templates/rsvp_edit_link.txt b/internal/notification/templates/rsvp_edit_link.txt new file mode 100644 index 0000000..42278fd --- /dev/null +++ b/internal/notification/templates/rsvp_edit_link.txt @@ -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. diff --git a/test/integration/auth_test.go b/test/integration/auth_test.go index fe08d9c..8344d0f 100644 --- a/test/integration/auth_test.go +++ b/test/integration/auth_test.go @@ -47,6 +47,12 @@ func (s *recordingEmailSender) SendCollaboratorInvite(_ context.Context, _, _, _ return nil } +func (s *recordingEmailSender) SendRSVPEditLink(_ context.Context, _, _, _, _ string) error { + // Block G follow-up — recording-only stub; we don't track the URL here + // because the dedicated forwarded-link test has its own capture sender. + return nil +} + func TestAuthFlow(t *testing.T) { if testing.Short() { t.Skip("skipping integration test in -short mode") diff --git a/test/integration/rsvp_forwarded_link_test.go b/test/integration/rsvp_forwarded_link_test.go new file mode 100644 index 0000000..b523f5d --- /dev/null +++ b/test/integration/rsvp_forwarded_link_test.go @@ -0,0 +1,323 @@ +//go:build integration + +package integration_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/alicebob/miniredis/v2" + "github.com/google/uuid" + "github.com/redis/go-redis/v9" + "google.golang.org/grpc" + + "github.com/alchemistkay/guestguard/internal/api" + "github.com/alchemistkay/guestguard/internal/auth" + "github.com/alchemistkay/guestguard/internal/fraud" + pb "github.com/alchemistkay/guestguard/internal/fraudpb" + "github.com/alchemistkay/guestguard/internal/storage" +) + +// recordingRSVPSender captures the most recent RSVP edit-link send so the +// test can pull the magic URL out of "the email" without standing up an +// SMTP server. +type recordingRSVPSender struct { + lastEditLink string +} + +func (s *recordingRSVPSender) SendVerification(context.Context, string, string, string) error { return nil } +func (s *recordingRSVPSender) SendPasswordReset(context.Context, string, string, string) error { return nil } +func (s *recordingRSVPSender) SendCollaboratorInvite(context.Context, string, string, string, string, string) error { return nil } +func (s *recordingRSVPSender) SendRSVPEditLink(_ context.Context, _, _, _, link string) error { + s.lastEditLink = link + return nil +} + +// TestForwardedInvitationLinkDefence covers the Block G follow-up: +// - A guest submits their RSVP from device A. +// - A different device opens the same invitation link. +// - The /access response no longer surfaces the RSVP details. +// - A PATCH from the foreign device is refused with 403. +// - The original guest can request an edit link to their email, follow +// the magic URL, and successfully edit from the new device. +func TestForwardedInvitationLinkDefence(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in -short mode") + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + t.Cleanup(cancel) + + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + dsn := startPostgres(t, ctx) + db, err := storage.NewDB(ctx, dsn) + must(t, err, "connect db") + t.Cleanup(db.Close) + must(t, db.Migrate(ctx), "migrate") + + // Redis: miniredis is enough — the edit-nonce store only does + // SET / GET with a TTL. No need for a real Redis container here. + mr, err := miniredis.Run() + must(t, err, "miniredis") + t.Cleanup(mr.Close) + rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()}) + + // In-process stub fraud scorer so PATCH/POST score paths don't reach + // for the Python service. We make it return LOW for every call. + stub := startStubFraudGRPCLocal(t) + fraudClient, err := fraud.Dial(ctx, stub.Addr, 2*time.Second, logger) + must(t, err, "dial fraud") + t.Cleanup(func() { _ = fraudClient.Close() }) + + // Capture-the-link email sender so the test can pull the magic + // edit URL out of "the inbox" without standing up SMTP. + emails := &recordingRSVPSender{} + + apiSrv, err := api.NewServer(api.ServerDeps{ + Logger: logger, + DB: db, + FraudScorer: fraudClient, + TokenTTL: 24 * time.Hour, + JWTSecret: testJWTSecret, + JWTIssuer: testJWTIssuer, + AccessTokenTTL: 5 * time.Minute, + RefreshTokenTTL: 24 * time.Hour, + EmailVerificationTTL: 1 * time.Hour, + PasswordResetTTL: 1 * time.Hour, + PublicBaseURL: "http://localhost", + Redis: rdb, + EmailSender: emails, + }) + must(t, err, "build api server") + srv := httptest.NewServer(apiSrv.Handler()) + t.Cleanup(srv.Close) + + // Set up host, event, guest with email on file. + host := insertHost(t, ctx, db.Pool) + hostToken := issueHostToken(t, host) + eventID := createEvent(t, srv.URL, hostToken, "Forwarded Link", "forwarded-link") + guestEmail := fmt.Sprintf("guest+%d@guestguard.test", time.Now().UnixNano()) + guestID := createGuestWithFields(t, srv.URL, hostToken, eventID, "Sam", guestEmail) + token := issueToken(t, srv.URL, hostToken, eventID, guestID) + + const uaA = "Mozilla/5.0 (Macintosh) AppleWebKit/605 Browser-A" + const uaB = "Mozilla/5.0 (X11; Linux) Firefox/120 Browser-B" + + // 1. Submit from device A. + stub.SetNext(5, "low", nil) + submitFromUA(t, srv.URL, token, uaA, "attending", 0, http.StatusCreated) + + // 2. Open the same invitation from device B — RSVP is hidden, + // rsvp_submitted_elsewhere=true, can_request_edit_link=true. + access := accessFromUA(t, srv.URL, token, uaB) + if access.RSVP != nil { + t.Fatalf("expected hidden rsvp on different device; got %+v", access.RSVP) + } + if !access.RSVPSubmittedElsewhere { + t.Fatalf("expected rsvp_submitted_elsewhere=true; got false") + } + if !access.CanRequestEditLink { + t.Fatalf("expected can_request_edit_link=true (email on file)") + } + + // 3. Device A still sees its own RSVP (sanity). + accessA := accessFromUA(t, srv.URL, token, uaA) + if accessA.RSVP == nil { + t.Fatalf("device A should still see its own RSVP") + } + + // 4. PATCH from device B without a nonce — refused. + patchFromUA(t, srv.URL, token, uaB, map[string]any{ + "response": "declined", + "plus_ones": 0, + }, http.StatusForbidden) + + // 5. Request an edit link from device B. + resp := postRequestEditLink(t, srv.URL, token, http.StatusAccepted) + if resp.Channel != "email" { + t.Errorf("expected channel=email, got %q", resp.Channel) + } + if emails.lastEditLink == "" { + t.Fatal("expected email sender to have captured a link") + } + + // Extract nonce from the magic URL the email contained. + nonce := extractEditNonce(emails.lastEditLink) + if nonce == "" { + t.Fatalf("could not parse nonce from %q", emails.lastEditLink) + } + + // 6. GET /access?edit= from device B unhides the RSVP. + accessWithNonce := accessFromUAWithNonce(t, srv.URL, token, uaB, nonce) + if accessWithNonce.RSVP == nil { + t.Fatalf("nonce should have unhidden the RSVP") + } + + // 7. PATCH from device B carrying the nonce — accepted. + stub.SetNext(5, "low", nil) + patchFromUAWithNonce(t, srv.URL, token, uaB, nonce, map[string]any{ + "response": "declined", + "plus_ones": 0, + }, http.StatusOK) + + // 8. A third device (UA-C) with a made-up nonce stays refused. + // At this point the stored fingerprint is UA-B (step 7 legitimately + // updated it), so UA-C is again a stranger and the gate fires. + const uaC = "Mozilla/5.0 (iPhone) Safari/17 Browser-C" + patchFromUAWithNonce(t, srv.URL, token, uaC, "deadbeefdeadbeef", map[string]any{ + "response": "attending", + "plus_ones": 0, + }, http.StatusForbidden) +} + +// --- helpers (kept local to this file so we don't bloat the shared scaffold) --- + +func submitFromUA(t *testing.T, base, token, ua, response string, plusOnes int, want int) { + t.Helper() + body, _ := json.Marshal(map[string]any{ + "response": response, + "plus_ones": plusOnes, + }) + req, _ := http.NewRequest(http.MethodPost, base+"/rsvp/"+token, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", ua) + resp, err := http.DefaultClient.Do(req) + must(t, err, "submit") + defer resp.Body.Close() + if resp.StatusCode != want { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("submit status=%d want=%d body=%s", resp.StatusCode, want, b) + } +} + +func patchFromUA(t *testing.T, base, token, ua string, body map[string]any, want int) { + t.Helper() + b, _ := json.Marshal(body) + req, _ := http.NewRequest(http.MethodPatch, base+"/rsvp/"+token, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", ua) + resp, err := http.DefaultClient.Do(req) + must(t, err, "patch") + defer resp.Body.Close() + if resp.StatusCode != want { + body, _ := io.ReadAll(resp.Body) + t.Fatalf("patch status=%d want=%d body=%s", resp.StatusCode, want, body) + } +} + +func patchFromUAWithNonce(t *testing.T, base, token, ua, nonce string, body map[string]any, want int) { + t.Helper() + body["edit_nonce"] = nonce + patchFromUA(t, base, token, ua, body, want) +} + +type accessLite struct { + RSVP *struct{ Response string `json:"response"` } `json:"rsvp"` + RSVPSubmittedElsewhere bool `json:"rsvp_submitted_elsewhere"` + CanRequestEditLink bool `json:"can_request_edit_link"` +} + +func accessFromUA(t *testing.T, base, token, ua string) accessLite { + t.Helper() + return accessFromUAWithNonce(t, base, token, ua, "") +} + +func accessFromUAWithNonce(t *testing.T, base, token, ua, nonce string) accessLite { + t.Helper() + url := base + "/access/" + token + if nonce != "" { + url += "?edit=" + nonce + } + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set("User-Agent", ua) + resp, err := http.DefaultClient.Do(req) + must(t, err, "access") + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("access status=%d body=%s", resp.StatusCode, b) + } + var out accessLite + must(t, json.NewDecoder(resp.Body).Decode(&out), "decode access") + return out +} + +type editLinkResp struct { + Channel string `json:"channel"` +} + +func postRequestEditLink(t *testing.T, base, token string, want int) editLinkResp { + t.Helper() + req, _ := http.NewRequest(http.MethodPost, base+"/access/"+token+"/request-edit-link", nil) + resp, err := http.DefaultClient.Do(req) + must(t, err, "request edit link") + defer resp.Body.Close() + if resp.StatusCode != want { + b, _ := io.ReadAll(resp.Body) + t.Fatalf("request-edit-link status=%d want=%d body=%s", resp.StatusCode, want, b) + } + var out editLinkResp + if resp.ContentLength != 0 { + _ = json.NewDecoder(resp.Body).Decode(&out) + } + return out +} + +func extractEditNonce(link string) string { + i := strings.Index(link, "edit=") + if i < 0 { + return "" + } + rest := link[i+len("edit="):] + // Strip any trailing fragment / extra query separators. + if j := strings.IndexAny(rest, "&#"); j > 0 { + return rest[:j] + } + return rest +} + +// createGuestWithFields lets us pass an email so the request-edit-link +// path has somewhere to deliver to. The shared createGuest helper doesn't +// accept extras. +func createGuestWithFields(t *testing.T, base, hostToken string, eventID uuid.UUID, name, email string) uuid.UUID { + t.Helper() + var out struct{ ID uuid.UUID `json:"id"` } + postJSONAuthed(t, + fmt.Sprintf("%s/events/%s/guests", base, eventID), + hostToken, + map[string]any{"name": name, "email": email}, + http.StatusCreated, &out) + return out.ID +} + +// startStubFraudGRPCLocal is a copy of the e2e_test scaffold's stub +// scorer. Replicated here so this file is self-contained when run on +// its own (go test -run TestForwarded...). +func startStubFraudGRPCLocal(t *testing.T) *stubFraud { + t.Helper() + lis, err := net.Listen("tcp", "127.0.0.1:0") + must(t, err, "listen for stub fraud") + + s := &stubFraud{Addr: lis.Addr().String()} + s.risk.Store("low") + s.reasons.Store([]string(nil)) + + s.server = grpc.NewServer() + pb.RegisterFraudServiceServer(s.server, s) + + go func() { _ = s.server.Serve(lis) }() + t.Cleanup(s.server.Stop) + return s +} + +// satisfy the EmailSender interface check at compile time. +var _ auth.EmailSender = (*recordingRSVPSender)(nil)