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