Files
guestguard/test/integration/rsvp_forwarded_link_test.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

324 lines
11 KiB
Go

//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=<nonce> 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)