Tasks 7 and 8: confirmation burns on POST, and the budgets hold
A GET renders a button and burns nothing. Link-scanning gateways fetch every URL in an arriving message, so confirming on GET would put people on the list who never clicked — breaking the first half of the one rule with the mechanism meant to enforce it — and would then hand the actual human a dead link. A test drives exactly that sequence. Spent, unknown and expired tokens render one page. Resubmitting a pending row mints a fresh token and kills the old one, so an old message stops being a live credential. The mail budget is enforced end to end: a fourth message to one address does not go, and the submission that triggered it looks identical to one that did. A sender that never returns does not block the next submission and does not roll back the row, which is what send-after-commit buys. Writing these found a race in the test helper rather than the code — it returned the last message to an address as soon as any existed, so a resubmission test could read the previous token and pass without proving anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+473
−11
internal/movement/app.go+11 −4internal/movement/config.go+8 −0internal/movement/confirm.go+89 −0internal/movement/render.go+1 −1internal/movement/templates/confirm.html+17 −0internal/movementtest/confirm_test.go+144 −0internal/movementtest/harness_test.go+92 −6internal/movementtest/mail_test.go+111 −0
diff --git a/internal/movement/app.go b/internal/movement/app.go| index b290915..f3c6b9e 100644 |
| --- a/internal/movement/app.go |
| +++ b/internal/movement/app.go |
| @@ -55,14 +55,19 @@ func App(d *db.DB, env Env) (*http.ServeMux, error) { |
| Warn("this build carries unreviewed placeholder copy") |
| } |
| + sender := env.Mailer |
| + if sender == nil { |
| + sender = mail.FromEnv("CARLOS", slogFor()) |
| + } |
| a := &app{ |
| db: d, |
| env: env, |
| challengeKey: ChallengeKey(env.InstanceKey), |
| - // Always passed explicitly. auth.New substitutes mail.Logged for a |
| - // nil Mailer, which writes the link and the address into carlos logs |
| - // — the same leak config.go refuses at boot, through another door. |
| - mailer: BudgetedSender(mail.FromEnv("CARLOS", slogFor()), d.G), |
| + // Always passed explicitly wherever a Mailer is wanted. auth.New |
| + // substitutes mail.Logged for a nil one, which writes the link and |
| + // the address into carlos logs — the same leak config.go refuses at |
| + // boot, arriving through another door. |
| + mailer: BudgetedSender(sender, d.G), |
| } |
| r := chi.NewRouter() |
| @@ -75,6 +80,8 @@ func App(d *db.DB, env Env) (*http.ServeMux, error) { |
| // copy has not been through review yet. |
| r.Get("/s/{stance}", a.stancePage) |
| r.Post("/signup", a.handleSignup) |
| + r.Get("/confirm", a.handleConfirmPage) |
| + r.Post("/confirm", a.handleConfirm) |
| mux := http.NewServeMux() |
| // The app serves its own static files — the framework never does. |
diff --git a/internal/movement/config.go b/internal/movement/config.go| index b6ce1e2..e16dcf0 100644 |
| --- a/internal/movement/config.go |
| +++ b/internal/movement/config.go |
| @@ -6,6 +6,8 @@ import ( |
| "net/url" |
| "os" |
| "strings" |
| + |
| + "github.com/carlosframework/rastrillo/mail" |
| ) |
| // CanonicalHost is the one host this app is meant to serve in production. |
| @@ -20,6 +22,12 @@ type Env struct { |
| Origin string |
| InstanceKey string |
| Admins []string |
| + |
| + // Mailer, when set, replaces the platform SMTP sender. LoadEnv never |
| + // fills it: it is the seam main.go could use to wire something else and |
| + // that tests use to read what was sent. Left nil, App builds the real one |
| + // from CARLOS_SMTP_*. |
| + Mailer mail.Sender |
| } |
| // loopback reports whether origin is a local development origin. |
diff --git a/internal/movement/confirm.go b/internal/movement/confirm.go| new file mode 100644 |
| index 0000000..da44c59 |
| --- /dev/null |
| +++ b/internal/movement/confirm.go |
| @@ -0,0 +1,89 @@ |
| +package movement |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// confirmData drives the one confirmation template through its three states. |
| +type confirmData struct { |
| + Token string |
| + State string // "offer", "done", "dead" |
| +} |
| + |
| +// handleConfirmPage renders a page with a single Confirm button. It burns |
| +// nothing. |
| +// |
| +// This is the whole reason confirmation is a POST. Link-scanning gateways — |
| +// Outlook Safe Links, corporate security proxies, some mail clients |
| +// prefetching — fetch every URL in a message as it arrives. Confirming on GET |
| +// would put people on the list who never clicked, which breaks the first half |
| +// of the one rule using the very mechanism meant to enforce it, and would |
| +// then show the actual human "this link is no longer valid". |
| +// |
| +// The lookup here is read-only, so a scanner still cannot change anything. It |
| +// exists only so somebody arriving with a dead link is told before they press |
| +// a button that was never going to work. |
| +func (a *app) handleConfirmPage(w http.ResponseWriter, r *http.Request) { |
| + token := r.URL.Query().Get("token") |
| + |
| + if _, err := a.liveRow(token, time.Now()); err != nil { |
| + render(w, "confirm", confirmData{State: "dead"}) |
| + return |
| + } |
| + render(w, "confirm", confirmData{Token: token, State: "offer"}) |
| +} |
| + |
| +// handleConfirm burns the token and puts the address on the list. |
| +func (a *app) handleConfirm(w http.ResponseWriter, r *http.Request) { |
| + now := time.Now() |
| + token := r.PostFormValue("token") |
| + |
| + row, err := a.liveRow(token, now) |
| + if err != nil { |
| + // Unknown, expired and already-burned all render the same page. There |
| + // is nothing useful to tell apart here, and plenty to leak by trying. |
| + render(w, "confirm", confirmData{State: "dead"}) |
| + return |
| + } |
| + |
| + // Clearing the hash is what makes the link single-use. |
| + err = a.db.G.Model(&Signup{}). |
| + Where("id = ? AND confirmed = ?", row.ID, false). |
| + Select("Confirmed", "ConfirmedAt", "TokenHash", "UpdatedAt"). |
| + Updates(map[string]any{ |
| + "confirmed": true, |
| + "confirmed_at": now, |
| + "token_hash": nil, |
| + "updated_at": now, |
| + }).Error |
| + if err != nil { |
| + render(w, "confirm", confirmData{State: "dead"}) |
| + return |
| + } |
| + |
| + render(w, "confirm", confirmData{State: "done"}) |
| +} |
| + |
| +// liveRow finds the unconfirmed row a token belongs to, if the token is still |
| +// good. Only the hash is stored, so the presented token is hashed and matched |
| +// rather than compared. |
| +func (a *app) liveRow(token string, now time.Time) (Signup, error) { |
| + if token == "" { |
| + return Signup{}, errors.New("movement: no token") |
| + } |
| + var row Signup |
| + err := a.db.G. |
| + Where("token_hash = ? AND confirmed = ? AND token_expiry > ?", hashToken(token), false, now). |
| + First(&row).Error |
| + if err != nil { |
| + if errors.Is(err, gorm.ErrRecordNotFound) { |
| + return Signup{}, err |
| + } |
| + return Signup{}, err |
| + } |
| + return row, nil |
| +} |
diff --git a/internal/movement/render.go b/internal/movement/render.go| index 0798312..f76a2c2 100644 |
| --- a/internal/movement/render.go |
| +++ b/internal/movement/render.go |
| @@ -26,7 +26,7 @@ var assets = rastrillo.NewAssets(appFS) |
| var pages = map[string]*template.Template{} |
| func init() { |
| - for _, name := range []string{"index", "stance", "sent", "errors"} { |
| + for _, name := range []string{"index", "stance", "sent", "confirm", "errors"} { |
| pages[name] = template.Must( |
| // ui's partials and their helpers first, so every page can |
| // call one without registering anything. WithIcons points |
diff --git a/internal/movement/templates/confirm.html b/internal/movement/templates/confirm.html| new file mode 100644 |
| index 0000000..31621b5 |
| --- /dev/null |
| +++ b/internal/movement/templates/confirm.html |
| @@ -0,0 +1,17 @@ |
| +{{define "title"}}{{say "confirm.title"}}{{end}} |
| +{{define "content"}} |
| +{{if eq .State "offer"}} |
| + <h1 class="rst-page-title">{{say "confirm.title"}}</h1> |
| + {{/* A button, not a redirect. Mail scanners fetch every link in an |
| + arriving message, and a GET that confirmed would put people on the |
| + list who never clicked. */}} |
| + <form class="rst-form" method="post" action="/confirm"> |
| + <input type="hidden" name="token" value="{{.Token}}"> |
| + <button type="submit">{{say "confirm.button"}}</button> |
| + </form> |
| +{{else if eq .State "done"}} |
| + <h1 class="rst-page-title">{{say "confirm.done"}}</h1> |
| +{{else}} |
| + <h1 class="rst-page-title">{{say "confirm.dead"}}</h1> |
| +{{end}} |
| +{{end}} |
diff --git a/internal/movementtest/confirm_test.go b/internal/movementtest/confirm_test.go| new file mode 100644 |
| index 0000000..9de1dd3 |
| --- /dev/null |
| +++ b/internal/movementtest/confirm_test.go |
| @@ -0,0 +1,144 @@ |
| +package movementtest |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + movement "amadan.net/carlos/movement/internal/movement" |
| +) |
| + |
| +var confirmLink = regexp.MustCompile(`/confirm\?token=([0-9a-f]{64})`) |
| + |
| +// signUpAndTakeToken drives a real signup and returns the token from the |
| +// message that went out. The token exists nowhere else — only its hash is |
| +// stored — so reading the mail is the only way to get one, which is the |
| +// point. |
| +func signUpAndTakeToken(t *testing.T, h *testApp, email string) string { |
| + t.Helper() |
| + want := h.Mail.countTo(email) + 1 |
| + postSignup(t, h, submission(t, email, "curious")) |
| + |
| + body := h.Mail.waitForMailTo(t, email, want) |
| + m := confirmLink.FindStringSubmatch(body) |
| + if m == nil { |
| + t.Fatalf("no confirmation link in the message to %s:\n%s", email, body) |
| + } |
| + return m[1] |
| +} |
| + |
| +func postConfirm(t *testing.T, h *testApp, token string) *httptest.ResponseRecorder { |
| + t.Helper() |
| + form := url.Values{"token": {token}} |
| + req := httptest.NewRequest(http.MethodPost, "/confirm", strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", testOrigin) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +// The reason confirmation is a POST at all. Link-scanning gateways fetch |
| +// every URL in a message as it arrives, so a GET that confirmed would put |
| +// people on the list who never clicked — breaking the first half of the one |
| +// rule with the very mechanism meant to enforce it — and would then show the |
| +// actual human a dead link. |
| +func TestGetDoesNotConfirm(t *testing.T) { |
| + h := newApp(t) |
| + token := signUpAndTakeToken(t, h, "scanned@example.com") |
| + |
| + rec := get(t, h, "/confirm?token="+token) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("GET /confirm: %d", rec.Code) |
| + } |
| + if loadSignup(t, h, "scanned@example.com").Confirmed { |
| + t.Fatal("a GET confirmed the address; a mail scanner would have joined for them") |
| + } |
| + |
| + // And the human's click still works afterwards. |
| + postConfirm(t, h, token) |
| + if !loadSignup(t, h, "scanned@example.com").Confirmed { |
| + t.Fatal("the real click failed after a scanner had fetched the link") |
| + } |
| +} |
| + |
| +func TestConfirmPutsTheAddressOnTheList(t *testing.T) { |
| + h := newApp(t) |
| + token := signUpAndTakeToken(t, h, "joining@example.com") |
| + |
| + rec := postConfirm(t, h, token) |
| + if rec.Code != http.StatusOK { |
| + t.Fatalf("POST /confirm: %d", rec.Code) |
| + } |
| + |
| + row := loadSignup(t, h, "joining@example.com") |
| + if !row.Confirmed { |
| + t.Fatal("the address is not on the list") |
| + } |
| + if row.ConfirmedAt == nil { |
| + t.Error("ConfirmedAt was not recorded") |
| + } |
| + if len(row.TokenHash) != 0 { |
| + t.Error("the token hash survived; the link is still live") |
| + } |
| +} |
| + |
| +// A burned token, an expired one and one that never existed are all the same |
| +// answer. There is nothing useful to tell apart and plenty to leak by trying. |
| +func TestSpentUnknownAndExpiredTokensAreIndistinguishable(t *testing.T) { |
| + h := newApp(t) |
| + token := signUpAndTakeToken(t, h, "once@example.com") |
| + postConfirm(t, h, token) |
| + |
| + spent := postConfirm(t, h, token).Body.String() |
| + unknown := postConfirm(t, h, strings.Repeat("a", 64)).Body.String() |
| + empty := postConfirm(t, h, "").Body.String() |
| + |
| + if spent != unknown || unknown != empty { |
| + t.Error("a spent, an unknown and an empty token must render the same page") |
| + } |
| + if !strings.Contains(spent, movement.T("confirm.dead")) { |
| + t.Error("a dead link should say so") |
| + } |
| +} |
| + |
| +func TestConfirmingTwiceDoesNotDoubleCount(t *testing.T) { |
| + h := newApp(t) |
| + token := signUpAndTakeToken(t, h, "twice@example.com") |
| + |
| + postConfirm(t, h, token) |
| + first := loadSignup(t, h, "twice@example.com") |
| + postConfirm(t, h, token) |
| + second := loadSignup(t, h, "twice@example.com") |
| + |
| + if !first.ConfirmedAt.Equal(*second.ConfirmedAt) { |
| + t.Error("a second click moved ConfirmedAt; the link is not single-use") |
| + } |
| +} |
| + |
| +// A pending row that is submitted again gets a fresh token, and the old one |
| +// stops working — otherwise every message ever sent to an address stays a |
| +// live credential for it. |
| +func TestResubmittingInvalidatesTheOldLink(t *testing.T) { |
| + h := newApp(t) |
| + const addr = "renewed@example.com" |
| + |
| + old := signUpAndTakeToken(t, h, addr) |
| + fresh := signUpAndTakeToken(t, h, addr) |
| + if old == fresh { |
| + t.Fatal("resubmitting reused the same token") |
| + } |
| + |
| + postConfirm(t, h, old) |
| + if loadSignup(t, h, addr).Confirmed { |
| + t.Error("the superseded link still confirmed the address") |
| + } |
| + |
| + postConfirm(t, h, fresh) |
| + if !loadSignup(t, h, addr).Confirmed { |
| + t.Error("the current link did not work") |
| + } |
| +} |
diff --git a/internal/movementtest/harness_test.go b/internal/movementtest/harness_test.go| index 93f4d78..b2c8e0a 100644 |
| --- a/internal/movementtest/harness_test.go |
| +++ b/internal/movementtest/harness_test.go |
| @@ -7,6 +7,7 @@ |
| package movementtest |
| import ( |
| + "context" |
| "io" |
| "log/slog" |
| "net/http" |
| @@ -14,10 +15,12 @@ import ( |
| "net/url" |
| "path/filepath" |
| "strings" |
| + "sync" |
| "testing" |
| "time" |
| "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/mail" |
| movement "amadan.net/carlos/movement/internal/movement" |
| ) |
| @@ -26,16 +29,95 @@ import ( |
| // same-origin evidence — the pair a browser form submission would be. |
| const testOrigin = "http://app.test" |
| -// newApp builds the whole app per test over a fresh temp database, |
| -// exactly as main.go does. |
| -// testApp is the handler plus the database behind it, so a test can assert on |
| -// what a request actually wrote rather than inferring it from a page. |
| +// testApp is the handler plus the database and the mailbox behind it, so a |
| +// test can assert on what a request actually wrote and actually sent rather |
| +// than inferring either from a page. |
| type testApp struct { |
| http.Handler |
| - DB *db.DB |
| + DB *db.DB |
| + Mail *recorder |
| } |
| +// recorder stands in for the platform's SMTP sender. Confirmation tokens are |
| +// stored only as hashes, so reading the message is the only way to get one — |
| +// which is exactly the property the design intends. |
| +type recorder struct { |
| + mu sync.Mutex |
| + sent []recorded |
| +} |
| + |
| +type recorded struct{ To, Subject, Body string } |
| + |
| +func (r *recorder) Send(ctx context.Context, to, subject, body string) error { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + r.sent = append(r.sent, recorded{to, subject, body}) |
| + return nil |
| +} |
| + |
| +func (r *recorder) count() int { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + return len(r.sent) |
| +} |
| + |
| +func (r *recorder) countTo(addr string) int { |
| + r.mu.Lock() |
| + defer r.mu.Unlock() |
| + n := 0 |
| + for _, m := range r.sent { |
| + if m.To == addr { |
| + n++ |
| + } |
| + } |
| + return n |
| +} |
| + |
| +// waitForMailTo blocks until at least atLeast messages have gone to addr, and |
| +// returns the latest one's body. |
| +// |
| +// The wait is needed because sends happen after the write transaction |
| +// commits, in a goroutine, so a message is not necessarily on the record by |
| +// the time the response has been written. The count matters as much as the |
| +// wait: returning as soon as any message to the address exists would hand |
| +// back a stale one, and a test reading yesterday's token would pass while |
| +// proving nothing. |
| +func (r *recorder) waitForMailTo(t *testing.T, addr string, atLeast int) string { |
| + t.Helper() |
| + deadline := time.Now().Add(2 * time.Second) |
| + for time.Now().Before(deadline) { |
| + r.mu.Lock() |
| + var last string |
| + n := 0 |
| + for _, m := range r.sent { |
| + if m.To == addr { |
| + n++ |
| + last = m.Body |
| + } |
| + } |
| + r.mu.Unlock() |
| + if n >= atLeast { |
| + return last |
| + } |
| + time.Sleep(5 * time.Millisecond) |
| + } |
| + t.Fatalf("fewer than %d messages reached %s", atLeast, addr) |
| + return "" |
| +} |
| + |
| +// newApp builds the whole app per test over a fresh temp database, exactly as |
| +// main.go does, with a recording mailer in place of the platform's SMTP. |
| func newApp(t *testing.T) *testApp { |
| + t.Helper() |
| + box := &recorder{} |
| + app := newAppWith(t, box) |
| + app.Mail = box |
| + return app |
| +} |
| + |
| +// newAppWith is newApp with a mailer of the test's choosing — for the cases |
| +// that need a sender which fails, or one that never returns at all. |
| +func newAppWith(t *testing.T, sender mail.Sender) *testApp { |
| t.Helper() |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| @@ -44,7 +126,11 @@ func newApp(t *testing.T) *testApp { |
| } |
| t.Cleanup(func() { d.Close() }) |
| movement.SetLogger(logger) |
| - mux, err := movement.App(d, testEnv()) |
| + |
| + env := testEnv() |
| + env.Mailer = sender |
| + |
| + mux, err := movement.App(d, env) |
| if err != nil { |
| t.Fatalf("App: %v", err) |
| } |
diff --git a/internal/movementtest/mail_test.go b/internal/movementtest/mail_test.go| new file mode 100644 |
| index 0000000..1f63a9f |
| --- /dev/null |
| +++ b/internal/movementtest/mail_test.go |
| @@ -0,0 +1,111 @@ |
| +package movementtest |
| + |
| +import ( |
| + "context" |
| + "testing" |
| + "time" |
| + |
| + movement "amadan.net/carlos/movement/internal/movement" |
| +) |
| + |
| +// The budget is what actually stands between a stranger's inbox and a flood |
| +// from our own verified sending identity. Proof of work is a speed bump — |
| +// 2^18 SHA-256 is under a millisecond on a commodity GPU — so this is the |
| +// defence, and it is persisted because the process hibernates and an |
| +// in-memory counter would forget on the next wake. |
| +func TestAnAddressCannotBeMailedPastItsAllowance(t *testing.T) { |
| + h := newApp(t) |
| + const addr = "flooded@example.com" |
| + |
| + for i := 0; i < movement.PerAddressPerDay+3; i++ { |
| + postSignup(t, h, submission(t, addr, "curious")) |
| + } |
| + |
| + // Give any in-flight sends a moment to land before counting. |
| + time.Sleep(100 * time.Millisecond) |
| + |
| + if got := h.Mail.countTo(addr); got != movement.PerAddressPerDay { |
| + t.Errorf("%d messages reached %s, want the budget of %d", |
| + got, addr, movement.PerAddressPerDay) |
| + } |
| +} |
| + |
| +// A suppressed send is invisible from the outside. If it were not, the budget |
| +// itself would become the oracle the one rule exists to prevent. |
| +func TestSuppressedSendRendersTheSamePage(t *testing.T) { |
| + h := newApp(t) |
| + const addr = "quiet@example.com" |
| + |
| + first := postSignup(t, h, submission(t, addr, "curious")).Body.String() |
| + for i := 0; i < movement.PerAddressPerDay; i++ { |
| + postSignup(t, h, submission(t, addr, "curious")) |
| + } |
| + suppressed := postSignup(t, h, submission(t, addr, "curious")).Body.String() |
| + |
| + if suppressed != first { |
| + t.Error("a submission whose message was suppressed looked different from one that was sent") |
| + } |
| +} |
| + |
| +// blocking never returns. mail.Sender.Send discards the context it is given |
| +// and smtp.SendMail dials with no deadline, so this is not a hypothetical |
| +// sender — it is what a hung SES connection looks like from in here. |
| +type blocking struct{ entered chan struct{} } |
| + |
| +func (b *blocking) Send(ctx context.Context, to, subject, body string) error { |
| + select { |
| + case b.entered <- struct{}{}: |
| + default: |
| + } |
| + <-ctx.Done() // held until the app's own deadline fires |
| + return ctx.Err() |
| +} |
| + |
| +// The send happens after the transaction commits, in a goroutine. Inside the |
| +// transaction it would hold the single SQLite writer and stall every other |
| +// signup and the scheduled tick behind it. |
| +func TestAHungSenderDoesNotBlockTheNextSubmission(t *testing.T) { |
| + hung := &blocking{entered: make(chan struct{}, 1)} |
| + h := newAppWith(t, hung) |
| + |
| + postSignup(t, h, submission(t, "first@example.com", "curious")) |
| + |
| + select { |
| + case <-hung.entered: |
| + case <-time.After(2 * time.Second): |
| + t.Fatal("the sender was never called") |
| + } |
| + |
| + // With the first send still hanging, a second submission must complete. |
| + done := make(chan struct{}) |
| + go func() { |
| + postSignup(t, h, submission(t, "second@example.com", "curious")) |
| + close(done) |
| + }() |
| + |
| + select { |
| + case <-done: |
| + case <-time.After(5 * time.Second): |
| + t.Fatal("a hung send blocked the next submission; it is holding the writer") |
| + } |
| + |
| + if got := countSignups(t, h, "second@example.com"); got != 1 { |
| + t.Errorf("the second submission wrote %d rows, want 1", got) |
| + } |
| +} |
| + |
| +// The row is committed before the message is attempted, so a send that never |
| +// succeeds still leaves something to re-submit against. |
| +func TestRowSurvivesASendThatNeverCompletes(t *testing.T) { |
| + hung := &blocking{entered: make(chan struct{}, 1)} |
| + h := newAppWith(t, hung) |
| + |
| + postSignup(t, h, submission(t, "stranded@example.com", "curious")) |
| + |
| + if got := countSignups(t, h, "stranded@example.com"); got != 1 { |
| + t.Fatal("the signup was rolled back when its message hung") |
| + } |
| + if row := loadSignup(t, h, "stranded@example.com"); len(row.TokenHash) == 0 { |
| + t.Error("no token was stored, so no future link could ever work") |
| + } |
| +} |