Files
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

112 lines
3.9 KiB
Go

package notification
import (
"context"
"log/slog"
)
// EmailBackend names the chosen email delivery channel for telemetry +
// startup logging. Mostly a debugging aid — code paths don't branch on
// this value.
type EmailBackend string
const (
BackendResend EmailBackend = "resend"
BackendSMTP EmailBackend = "smtp"
BackendSES EmailBackend = "ses"
BackendLog EmailBackend = "log"
)
// EmailSenderConfig collects every email-related env var so the picker
// has a single, ordered place to decide which backend wins. Priority is
// Resend > SMTP > SES > Log — the first one with non-empty creds is used.
type EmailSenderConfig struct {
Resend ResendConfig
SMTP SMTPConfig
SES SESConfig
}
// CombinedEmailSender satisfies both the auth.EmailSender interface (for
// verification + reset emails) and GuestEmailDispatcher (for invitation,
// confirmation, reminder). One concrete value handles both audiences so
// callers don't end up with two configurations.
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)
}
// PickEmailSender returns the configured email sender + which backend was
// chosen. Falls back to a logger stub if nothing is configured, so the
// service stays bootable in stripped-down dev environments.
func PickEmailSender(ctx context.Context, cfg EmailSenderConfig, tpls *Templates, logger *slog.Logger) (CombinedEmailSender, EmailBackend, error) {
switch {
case cfg.Resend.APIKey != "":
s, err := NewResendEmailSender(cfg.Resend, tpls)
if err != nil {
return nil, "", err
}
return s, BackendResend, nil
case cfg.SMTP.Host != "":
s, err := NewSMTPEmailSender(cfg.SMTP, tpls)
if err != nil {
return nil, "", err
}
return s, BackendSMTP, nil
case cfg.SES.FromEmail != "":
s, err := NewSESEmailSender(ctx, cfg.SES, tpls)
if err != nil {
return nil, "", err
}
return s, BackendSES, nil
}
return &logCombinedSender{logger: logger, tpls: tpls}, BackendLog, nil
}
// logCombinedSender is the dev fallback. Verification + reset emails come
// through as structured log lines (preserving the Block A behaviour);
// guest emails get rendered + dumped so engineers can eyeball the output.
type logCombinedSender struct {
logger *slog.Logger
tpls *Templates
}
func (l *logCombinedSender) SendVerification(_ context.Context, to, name, link string) error {
l.logger.Info("auth email (stub): verification", "to", to, "name", name, "link", link)
return nil
}
func (l *logCombinedSender) SendPasswordReset(_ context.Context, to, name, link string) error {
l.logger.Info("auth email (stub): password reset", "to", to, "name", name, "link", link)
return nil
}
func (l *logCombinedSender) SendCollaboratorInvite(_ context.Context, to, inviterName, eventName, role, link string) error {
l.logger.Info("auth email (stub): collaborator invite",
"to", to, "inviter", inviterName, "event", eventName, "role", role, "link", link)
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{}
}
data["Subject"] = subject
_, text, err := l.tpls.Render(name, data)
if err != nil {
return "", err
}
l.logger.Info("guest email (stub)",
"to", to, "subject", subject, "template", string(name), "text_body", text,
)
return "log:" + string(name), nil
}