Files
guestguard/internal/api/rsvps.go
T
Kwaku Danso dbddf17e3b 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>
2026-05-20 15:09:07 +01:00

460 lines
13 KiB
Go

package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"time"
"github.com/google/uuid"
"github.com/alchemistkay/guestguard/internal/auth"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/fraud"
"github.com/alchemistkay/guestguard/internal/natspub"
"github.com/alchemistkay/guestguard/internal/storage"
)
type rsvpPublisher interface {
PublishRSVPConfirmed(ctx context.Context, evt natspub.RSVPConfirmed) error
}
type fraudScorer interface {
Score(ctx context.Context, in fraud.ScoreInput) fraud.Decision
}
type rsvpHandler struct {
logger *slog.Logger
guests *storage.GuestRepo
tokens *storage.TokenRepo
events *storage.EventRepo
rsvps *storage.RSVPRepo
accessLogs *storage.AccessLogRepo
allowlist *storage.AllowlistRepo
editNonces *editNonceStore
scorer fraudScorer
pub rsvpPublisher
}
type submitRSVPRequest struct {
Response string `json:"response"`
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 {
RSVP *domain.RSVP `json:"rsvp"`
Decision fraud.Decision `json:"fraud"`
Blocked bool `json:"blocked"`
Edited bool `json:"edited"`
}
// POST /rsvp/{token} — synchronous fraud check + RSVP recording.
func (h *rsvpHandler) submit(w http.ResponseWriter, r *http.Request) {
tk, ok := h.loadValidToken(w, r)
if !ok {
return
}
req, ok := decodeSubmitRSVP(w, r)
if !ok {
return
}
guest, event, ok := h.loadGuestEvent(w, r, tk.GuestID)
if !ok {
return
}
if req.PlusOnes > guest.PlusOnes {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("you may bring up to %d plus-one(s)", guest.PlusOnes))
return
}
decision, fingerprint, ip, ok := h.scoreAccess(w, r, event, guest, tk, req.Fingerprint)
if !ok {
return
}
score := decision.Score
rsvp, err := h.rsvps.Create(r.Context(), storage.CreateRSVPParams{
GuestID: guest.ID,
Response: req.Resp,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
DeviceFingerprint: fingerprint,
IPAddress: ip,
RiskScore: &score,
})
if err != nil {
if errors.Is(err, domain.ErrRSVPAlreadySubmitted) {
// Tier 2 Block A: guests edit an existing RSVP via PATCH. Tell
// the client to switch endpoints rather than swallowing the
// retry silently.
writeError(w, http.StatusConflict, "rsvp already submitted — use PATCH to edit")
return
}
h.logger.Error("create rsvp", "err", err, "guest_id", guest.ID)
writeError(w, http.StatusInternalServerError, "failed to record rsvp")
return
}
// Block A: we intentionally do NOT mark the token used here any more.
// The token must remain valid so the guest can come back to the same
// link and edit their response.
h.publishRSVPConfirmed(event.ID, guest.ID, rsvp, &score)
writeJSON(w, http.StatusCreated, submitRSVPResponse{
RSVP: rsvp,
Decision: decision,
Blocked: false,
Edited: false,
})
}
// 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 {
return
}
req, ok := decodeSubmitRSVP(w, r)
if !ok {
return
}
guest, event, ok := h.loadGuestEvent(w, r, tk.GuestID)
if !ok {
return
}
if req.PlusOnes > guest.PlusOnes {
writeError(w, http.StatusBadRequest,
fmt.Sprintf("you may bring up to %d plus-one(s)", guest.PlusOnes))
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
}
score := decision.Score
rsvp, err := h.rsvps.Update(r.Context(), storage.UpdateRSVPParams{
GuestID: guest.ID,
Response: req.Resp,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
DeviceFingerprint: fingerprint,
IPAddress: ip,
RiskScore: &score,
})
if err != nil {
switch {
case errors.Is(err, domain.ErrRSVPNotFound):
writeError(w, http.StatusNotFound, "no rsvp to edit — submit first")
case errors.Is(err, domain.ErrRSVPEditLimitReached):
writeError(w, http.StatusTooManyRequests,
fmt.Sprintf("edit limit reached (%d edits)", domain.MaxRSVPEdits))
default:
h.logger.Error("update rsvp", "err", err, "guest_id", guest.ID)
writeError(w, http.StatusInternalServerError, "failed to update rsvp")
}
return
}
h.publishRSVPConfirmed(event.ID, guest.ID, rsvp, &score)
writeJSON(w, http.StatusOK, submitRSVPResponse{
RSVP: rsvp,
Decision: decision,
Blocked: false,
Edited: true,
})
}
type rsvpHistoryResponse struct {
RSVP *domain.RSVP `json:"rsvp"`
Revisions []domain.RSVPRevision `json:"revisions"`
}
// GET /events/{id}/guests/{guest_id}/rsvp/history — host view of the
// current RSVP plus every prior revision newest-first.
func (h *rsvpHandler) history(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
guestID, ok := parseIDParam(w, r, "guest_id")
if !ok {
return
}
if _, ok := requireEventOwner(w, r, h.events, eventID, hostID); !ok {
return
}
guest, err := h.guests.Get(r.Context(), guestID)
if err != nil {
if errors.Is(err, domain.ErrGuestNotFound) {
writeError(w, http.StatusNotFound, "guest not found")
return
}
writeError(w, http.StatusInternalServerError, "failed to load guest")
return
}
if guest.EventID != eventID {
writeError(w, http.StatusNotFound, "guest not found in event")
return
}
rsvp, err := h.rsvps.GetByGuest(r.Context(), guestID)
if err != nil {
if errors.Is(err, domain.ErrRSVPNotFound) {
writeJSON(w, http.StatusOK, rsvpHistoryResponse{Revisions: []domain.RSVPRevision{}})
return
}
writeError(w, http.StatusInternalServerError, "failed to load rsvp")
return
}
revisions, err := h.rsvps.ListRevisions(r.Context(), rsvp.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load revisions")
return
}
writeJSON(w, http.StatusOK, rsvpHistoryResponse{RSVP: rsvp, Revisions: revisions})
}
// --- shared helpers ---
type decodedRSVPRequest struct {
Resp domain.RSVPResponse
PlusOnes int
DietaryNotes *string
Fingerprint map[string]any
EditNonce string
}
func decodeSubmitRSVP(w http.ResponseWriter, r *http.Request) (decodedRSVPRequest, bool) {
var req submitRSVPRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid json")
return decodedRSVPRequest{}, false
}
resp := domain.RSVPResponse(req.Response)
if !resp.Valid() {
writeError(w, http.StatusBadRequest, "response must be attending|declined|maybe")
return decodedRSVPRequest{}, false
}
if req.PlusOnes < 0 {
writeError(w, http.StatusBadRequest, "plus_ones must be >= 0")
return decodedRSVPRequest{}, false
}
return decodedRSVPRequest{
Resp: resp,
PlusOnes: req.PlusOnes,
DietaryNotes: req.DietaryNotes,
Fingerprint: req.Fingerprint,
EditNonce: req.EditNonce,
}, true
}
func (h *rsvpHandler) loadValidToken(w http.ResponseWriter, r *http.Request) (*domain.Token, bool) {
raw := r.PathValue("token")
if err := auth.ValidateFormat(raw); err != nil {
writeError(w, http.StatusBadRequest, "malformed token")
return nil, false
}
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 nil, false
}
writeError(w, http.StatusInternalServerError, "failed to load token")
return nil, false
}
if err := tk.IsValid(time.Now().UTC()); err != nil {
writeError(w, http.StatusGone, err.Error())
return nil, false
}
return tk, true
}
func (h *rsvpHandler) loadGuestEvent(w http.ResponseWriter, r *http.Request, guestID uuid.UUID) (*domain.Guest, *domain.Event, bool) {
guest, err := h.guests.Get(r.Context(), guestID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load guest")
return nil, nil, false
}
event, err := h.events.Get(r.Context(), guest.EventID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load event")
return nil, nil, false
}
return guest, event, true
}
// scoreAccess logs the access attempt, scores it, and short-circuits with a
// 403 on a BLOCK decision. Returns the decision and the merged fingerprint /
// IP so the caller can persist them on the RSVP row.
func (h *rsvpHandler) scoreAccess(
w http.ResponseWriter,
r *http.Request,
event *domain.Event,
guest *domain.Guest,
tk *domain.Token,
clientFP map[string]any,
) (fraud.Decision, map[string]any, string, bool) {
fingerprint := mergeFingerprint(clientFP, collectFingerprint(r))
ip := clientIP(r)
accessLogID, err := h.accessLogs.Create(r.Context(), storage.CreateAccessLogParams{
GuestID: guest.ID,
TokenID: tk.ID,
Fingerprint: fingerprint,
IPAddress: ip,
})
if err != nil {
h.logger.Error("create access log", "err", err)
}
// Block G: allowlist short-circuit. If the request IP matches a CIDR
// the host has explicitly trusted (office Wi-Fi, family network), we
// skip the fraud engine entirely — score 0, low band. Best-effort:
// any error reading the allowlist falls through to normal scoring so a
// dropped DB connection doesn't lock guests out of an event.
if h.allowlist != nil {
if matched, label, err := h.allowlist.Matches(r.Context(), event.ID, ip); err == nil && matched {
reason := "allowlisted"
if label != "" {
reason = "allowlisted: " + label
}
return fraud.Decision{
Score: 0,
Risk: "low",
Reasons: []string{reason},
Used: true,
}, fingerprint, ip, true
}
}
decision := h.scorer.Score(r.Context(), fraud.ScoreInput{
EventID: event.ID,
GuestID: guest.ID,
TokenID: tk.ID,
AccessLogID: accessLogID,
Fingerprint: stringifyFingerprint(fingerprint),
IPAddress: ip,
UserAgent: r.UserAgent(),
Referrer: r.Referer(),
})
// Block G: re-band the score using this event's thresholds. The
// engine's `Risk` field becomes advisory; the API is the source of
// truth for "what counts as block here". This lets a strict-event
// host set Block=70 while a casual-event host sets it to 95 without
// touching the engine.
decision.Risk = event.Thresholds().Band(decision.Score)
if fraud.IsBlock(decision) {
writeJSON(w, http.StatusForbidden, submitRSVPResponse{
Decision: decision,
Blocked: true,
})
return decision, nil, "", false
}
return decision, fingerprint, ip, true
}
func (h *rsvpHandler) publishRSVPConfirmed(eventID, guestID uuid.UUID, rsvp *domain.RSVP, score *int) {
if h.pub == nil {
return
}
go func(evt natspub.RSVPConfirmed) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := h.pub.PublishRSVPConfirmed(ctx, evt); err != nil {
h.logger.Error("publish rsvp.confirmed", "err", err, "rsvp_id", evt.RSVPID)
}
}(natspub.RSVPConfirmed{
EventID: eventID,
GuestID: guestID,
RSVPID: rsvp.ID,
Response: string(rsvp.Response),
PlusOnes: rsvp.PlusOnes,
RiskScore: score,
SubmittedAt: rsvp.SubmittedAt,
})
}
func mergeFingerprint(client map[string]any, server map[string]any) map[string]any {
out := make(map[string]any, len(server)+len(client))
for k, v := range server {
out[k] = v
}
for k, v := range client {
out["client_"+k] = v
}
return out
}
func stringifyFingerprint(fp map[string]any) map[string]string {
if fp == nil {
return nil
}
out := make(map[string]string, len(fp))
for k, v := range fp {
switch tv := v.(type) {
case string:
out[k] = tv
default:
b, _ := json.Marshal(tv)
out[k] = string(b)
}
}
return out
}