dbddf17e3b
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>
324 lines
11 KiB
Go
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)
|