Task 6: one handler, four checks, one transaction, one page
The honeypot is checked first because it is free and must not sit behind anything that writes. The seal is checked before the clock, because until the signature holds IssuedAt is a number the submitter chose. Then bounds, then the proof of work, then a single transaction that spends the nonce, writes the row, and claims the mail budget. Proving the enumeration gate could fail found a hole in the test rather than the code: the address named "confirmed" was only ever pending, so the path that writes nothing and sends nothing was never exercised. With that fixed and a deliberate leak added, the gate caught both the confirmed and the budget-suppressed responses. That is the whole reason for the step. A confirmed row is untouched by /signup and gets no mail. The budget columns are excluded from the pending-row update allowlist, so resubmitting cannot reset a counter. Addresses containing CR or LF are refused before they can reach an SMTP header.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
19 files changed,
+1089
−20
internal/movement/app.go+14 −1internal/movement/budget.go+87 −0internal/movement/copy.go+14 −9internal/movement/handlers.go+7 −0internal/movement/logging.go+9 −0internal/movement/mailer.go+86 −0internal/movement/migrations/0002_create_signups.sql+6 −0internal/movement/migrations/schema.sql+13 −0internal/movement/models.go+78 −0internal/movement/pages.go+49 −0internal/movement/render.go+1 −1internal/movement/seal.go+2 −2internal/movement/seal_test.go+5 −5internal/movement/signup.go+269 −0internal/movement/templates/sent.html+9 −0internal/movement/templates/stance.html+58 −0internal/movement/token.go+40 −0internal/movementtest/harness_test.go+54 −2internal/movementtest/signup_test.go+288 −0
diff --git a/internal/movement/app.go b/internal/movement/app.go| index 6adfdc6..b290915 100644 |
| --- a/internal/movement/app.go |
| +++ b/internal/movement/app.go |
| @@ -8,6 +8,7 @@ import ( |
| "github.com/carlosframework/rastrillo/csrf" |
| "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/mail" |
| "github.com/carlosframework/rastrillo/migrate" |
| ) |
| @@ -54,7 +55,15 @@ func App(d *db.DB, env Env) (*http.ServeMux, error) { |
| Warn("this build carries unreviewed placeholder copy") |
| } |
| - a := &app{db: d, env: env} |
| + 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), |
| + } |
| r := chi.NewRouter() |
| // App-wide, from day one: every state-changing route added below |
| @@ -62,6 +71,10 @@ func App(d *db.DB, env Env) (*http.ServeMux, error) { |
| // or forget in a form. |
| r.Use(csrf.Protect(env.Origin)) |
| r.Get("/", a.index) |
| + // Mounted by internal stance key. The reader-facing slugs are copy, and |
| + // copy has not been through review yet. |
| + r.Get("/s/{stance}", a.stancePage) |
| + r.Post("/signup", a.handleSignup) |
| mux := http.NewServeMux() |
| // The app serves its own static files — the framework never does. |
diff --git a/internal/movement/budget.go b/internal/movement/budget.go| new file mode 100644 |
| index 0000000..29dafaf |
| --- /dev/null |
| +++ b/internal/movement/budget.go |
| @@ -0,0 +1,87 @@ |
| +package movement |
| + |
| +import ( |
| + "time" |
| + |
| + "gorm.io/gorm" |
| +) |
| + |
| +// Two budgets, both persisted, because the process this runs in hibernates as |
| +// its normal state and an in-memory counter forgets everything on the next |
| +// wake — which is exactly when somebody attacking it would like it to. |
| +// |
| +// They exist because proof of work is a speed bump, not a wall: 2^18 SHA-256 |
| +// is under a millisecond on a commodity GPU. What a determined bulk attack |
| +// actually costs us is not junk rows, it is thousands of messages to |
| +// strangers from our own verified sending identity, and then the sending |
| +// domain suspended. These are the thing standing in front of that. |
| + |
| +const ( |
| + // PerAddressPerDay bounds how often one address can be mailed. Three is |
| + // room for a genuine retry or two after a transient failure, and no room |
| + // for using someone's inbox as a weapon. |
| + PerAddressPerDay = 3 |
| + |
| + // GlobalPerHour bounds everything the app sends, our own confirmations |
| + // and auth's sign-in links together. |
| + GlobalPerHour = 200 |
| + |
| + perAddressWindow = 24 * time.Hour |
| +) |
| + |
| +// hourBucket names the global counter's row. Fixed buckets rather than a |
| +// sliding window: a burst straddling a boundary can send up to twice the |
| +// ceiling within one rolling hour, which at these numbers is acceptable and |
| +// is written down here rather than discovered later. |
| +func hourBucket(t time.Time) string { |
| + return t.UTC().Format("2006-01-02T15") |
| +} |
| + |
| +// addressAllowed reports whether row may be mailed now. |
| +// |
| +// The window rolls from the last message rather than from a fixed clock |
| +// boundary, so someone mailed three times in a minute waits a full day rather |
| +// than until the top of the hour. |
| +func addressAllowed(row *Signup, now time.Time) bool { |
| + if row.LastMailedAt == nil || now.Sub(*row.LastMailedAt) > perAddressWindow { |
| + return true |
| + } |
| + return row.MailCount < PerAddressPerDay |
| +} |
| + |
| +// nextAddressCount returns what MailCount becomes after this send. |
| +func nextAddressCount(row *Signup, now time.Time) int { |
| + if row.LastMailedAt == nil || now.Sub(*row.LastMailedAt) > perAddressWindow { |
| + return 1 |
| + } |
| + return row.MailCount + 1 |
| +} |
| + |
| +// globalAllowed reads the hour's count and, when there is room, claims one. |
| +// |
| +// Read and increment are separate statements, which is safe here and only |
| +// here: db.Open caps the writer pool at a single connection and this runs |
| +// inside that connection's transaction, so no second writer can interleave. |
| +// The increment is still expressed as count = count + 1 rather than a value |
| +// computed in Go, so it stays correct if that ever stops being true. |
| +func globalAllowed(tx *gorm.DB, now time.Time) (bool, error) { |
| + bucket := hourBucket(now) |
| + |
| + var row MailCounter |
| + err := tx.Where("hour_bucket = ?", bucket).First(&row).Error |
| + switch { |
| + case err == gorm.ErrRecordNotFound: |
| + row = MailCounter{HourBucket: bucket, Count: 0} |
| + case err != nil: |
| + return false, err |
| + } |
| + |
| + if row.Count >= GlobalPerHour { |
| + return false, nil |
| + } |
| + |
| + res := tx.Exec( |
| + `INSERT INTO mail_counters (hour_bucket, count) VALUES (?, 1) |
| + ON CONFLICT(hour_bucket) DO UPDATE SET count = count + 1`, bucket) |
| + return true, res.Error |
| +} |
diff --git a/internal/movement/copy.go b/internal/movement/copy.go| index 7228db2..9e953ae 100644 |
| --- a/internal/movement/copy.go |
| +++ b/internal/movement/copy.go |
| @@ -29,15 +29,19 @@ var table = map[string]entry{ |
| // at copy review; the third label especially, because "loves AI, hasn't |
| // considered the ethics" describes a real group that will never |
| // self-select under that description. |
| - "index.headline": {"PLACEHOLDER headline", true}, |
| - "index.lede": {"PLACEHOLDER lede", true}, |
| - "index.link": {"PLACEHOLDER link to carlosframework.com", true}, |
| - "stance.a.label": {"PLACEHOLDER stance A label", true}, |
| - "stance.b.label": {"PLACEHOLDER stance B label", true}, |
| - "stance.c.label": {"PLACEHOLDER stance C label", true}, |
| - "stance.a.message": {"PLACEHOLDER stance A message", true}, |
| - "stance.b.message": {"PLACEHOLDER stance B message", true}, |
| - "stance.c.message": {"PLACEHOLDER stance C message", true}, |
| + "index.headline": {"PLACEHOLDER headline", true}, |
| + "index.lede": {"PLACEHOLDER lede", true}, |
| + "index.link": {"PLACEHOLDER link to carlosframework.com", true}, |
| + |
| + // Keyed by the internal stance key, so a template can look one up from |
| + // the value it already holds. The reader-facing slugs are a separate |
| + // question and are still open. |
| + "stance.never.label": {"PLACEHOLDER never label", true}, |
| + "stance.curious.label": {"PLACEHOLDER curious label", true}, |
| + "stance.enthusiast.label": {"PLACEHOLDER enthusiast label", true}, |
| + "stance.never.message": {"PLACEHOLDER never message", true}, |
| + "stance.curious.message": {"PLACEHOLDER curious message", true}, |
| + "stance.enthusiast.message": {"PLACEHOLDER enthusiast message", true}, |
| // The form. |
| "form.email": {"PLACEHOLDER email label", true}, |
| @@ -46,6 +50,7 @@ var table = map[string]entry{ |
| "form.working": {"PLACEHOLDER working", true}, |
| "form.nojs": {"PLACEHOLDER javascript is required to sign up", true}, |
| "form.bademail": {"PLACEHOLDER that address does not look right", true}, |
| + "form.honeypot": {"PLACEHOLDER leave this field empty", true}, |
| // The one page every outcome renders. Its wording is load-bearing: it |
| // is shown to an unknown address, a pending one and a confirmed one |
diff --git a/internal/movement/handlers.go b/internal/movement/handlers.go| index 76200c2..425bbd4 100644 |
| --- a/internal/movement/handlers.go |
| +++ b/internal/movement/handlers.go |
| @@ -4,6 +4,7 @@ import ( |
| "net/http" |
| "github.com/carlosframework/rastrillo/db" |
| + "github.com/carlosframework/rastrillo/mail" |
| ) |
| // app holds what every handler needs. |
| @@ -14,6 +15,12 @@ import ( |
| type app struct { |
| db *db.DB |
| env Env |
| + |
| + // challengeKey is derived from the instance key with its own label, so a |
| + // seal minted here can never verify inside rastrillo/auth and vice versa. |
| + challengeKey []byte |
| + |
| + mailer mail.Sender |
| } |
| func (a *app) index(w http.ResponseWriter, r *http.Request) { |
diff --git a/internal/movement/logging.go b/internal/movement/logging.go| index b6faf1e..b3dea36 100644 |
| --- a/internal/movement/logging.go |
| +++ b/internal/movement/logging.go |
| @@ -61,6 +61,15 @@ func Migrated(applied []string, skipped int, adopted bool) { |
| logger.Info("migrate", "applied", applied, "skipped", skipped, "adopted", adopted) |
| } |
| +// slogFor hands the raw logger to framework packages that require one — |
| +// rastrillo/mail and rastrillo/auth both take an *slog.Logger and there is no |
| +// way to give them anything else. |
| +// |
| +// This is the one hole in the rule above, and it is a known, bounded one: the |
| +// addresses those packages log are admins signing in, which are never paired |
| +// with a stance. Nothing in this app may call it to log its own lines. |
| +func slogFor() *slog.Logger { return logger } |
| + |
| // Warn carries a fixed message. Callers pass literals, never request data. |
| func Warn(msg string) { |
| logger.Warn(msg) |
diff --git a/internal/movement/mailer.go b/internal/movement/mailer.go| new file mode 100644 |
| index 0000000..0bfc628 |
| --- /dev/null |
| +++ b/internal/movement/mailer.go |
| @@ -0,0 +1,86 @@ |
| +package movement |
| + |
| +import ( |
| + "context" |
| + "fmt" |
| + "time" |
| + |
| + "github.com/carlosframework/rastrillo/mail" |
| + "gorm.io/gorm" |
| +) |
| + |
| +// sendTimeout bounds a send we start after the response has gone out. |
| +// |
| +// It has to be well under the platform's idle window. A goroutine outliving |
| +// its response is exactly the shape the scheduled tick refuses, and it is |
| +// only safe here because it is short: seconds against minutes. A SIGTERM |
| +// landing mid-send loses that one message after its budget has been spent, |
| +// and re-submitting is the recovery path. |
| +const sendTimeout = 15 * time.Second |
| + |
| +// sendAfterCommit delivers a confirmation once the write transaction has |
| +// committed. |
| +// |
| +// Never inside the transaction. mail.Sender.Send takes a context and discards |
| +// it, smtp.SendMail underneath dials with no deadline, and rastrillo.Serve |
| +// sets no write timeout — so a hung SES connection inside the transaction |
| +// would hold the single writer connection and stall every other signup and |
| +// the tick behind it. |
| +func (a *app) sendAfterCommit(m pendingMail) { |
| + go func() { |
| + ctx, cancel := context.WithTimeout(context.Background(), sendTimeout) |
| + defer cancel() |
| + |
| + link := a.env.Origin + "/confirm?token=" + m.token |
| + body := fmt.Sprintf("%s\n\n%s\n", T("mail.confirm.body"), link) |
| + |
| + if err := a.mailer.Send(ctx, m.to, T("mail.confirm.subject"), body); err != nil { |
| + // The error is dropped on purpose. rastrillo/mail formats its |
| + // failures as "send to <address>", so logging it here would print |
| + // the address beside a request that carries the stance — the |
| + // sensitive pair, assembled by accident. |
| + Rejected(ReasonMailFailed) |
| + } |
| + }() |
| +} |
| + |
| +// budgetedSender wraps the sender handed to rastrillo/auth so that sign-in |
| +// links draw on the same global budget as confirmations. A budget that counts |
| +// only the messages we remember sending is not a budget. |
| +type budgetedSender struct { |
| + inner mail.Sender |
| + db *gorm.DB |
| +} |
| + |
| +func BudgetedSender(inner mail.Sender, db *gorm.DB) mail.Sender { |
| + return &budgetedSender{inner: inner, db: db} |
| +} |
| + |
| +func (s *budgetedSender) Send(ctx context.Context, to, subject, body string) error { |
| + now := time.Now() |
| + |
| + // The count is committed before the send is attempted, so a hung |
| + // connection cannot leave the budget unspent while holding a slot. |
| + var allowed bool |
| + err := s.db.Transaction(func(tx *gorm.DB) error { |
| + var err error |
| + allowed, err = globalAllowed(tx, now) |
| + return err |
| + }) |
| + if err != nil { |
| + return err |
| + } |
| + if !allowed { |
| + Rejected(ReasonMailBudget) |
| + // nil, not an error. auth.Begin turns an error into a different |
| + // redirect — ?err= rather than ?sent=1 — and logs the failure with |
| + // the address attached. Both of those are visible differences between |
| + // one address and another, which is the thing this app must not have. |
| + return nil |
| + } |
| + |
| + // auth calls this synchronously and applies no deadline of its own. |
| + ctx, cancel := context.WithTimeout(ctx, sendTimeout) |
| + defer cancel() |
| + return s.inner.Send(ctx, to, subject, body) |
| +} |
diff --git a/internal/movement/migrations/0002_create_signups.sql b/internal/movement/migrations/0002_create_signups.sql| new file mode 100644 |
| index 0000000..15a41c9 |
| --- /dev/null |
| +++ b/internal/movement/migrations/0002_create_signups.sql |
| @@ -0,0 +1,6 @@ |
| +CREATE TABLE `signups` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`stance` text,`background` text,`confirmed` numeric,`confirmed_at` datetime,`token_hash` blob,`token_expiry` datetime,`last_mailed_at` datetime,`mail_count` integer,`created_at` datetime,`updated_at` datetime); |
| + |
| +CREATE UNIQUE INDEX `idx_signups_email` ON `signups`(`email`); |
| + |
| +CREATE TABLE `mail_counters` (`hour_bucket` text,`count` integer,PRIMARY KEY (`hour_bucket`)); |
| + |
diff --git a/internal/movement/migrations/schema.sql b/internal/movement/migrations/schema.sql| new file mode 100644 |
| index 0000000..f150f7b |
| --- /dev/null |
| +++ b/internal/movement/migrations/schema.sql |
| @@ -0,0 +1,13 @@ |
| +-- Generated by rastrillo migration generate; DO NOT EDIT. |
| +-- The schema every migration in this directory adds up to. |
| + |
| +CREATE TABLE `mail_counters` (`hour_bucket` text,`count` integer,PRIMARY KEY (`hour_bucket`)); |
| + |
| +CREATE TABLE `signups` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text,`stance` text,`background` text,`confirmed` numeric,`confirmed_at` datetime,`token_hash` blob,`token_expiry` datetime,`last_mailed_at` datetime,`mail_count` integer,`created_at` datetime,`updated_at` datetime); |
| + |
| +CREATE TABLE `spent_nonces` (`nonce` text,`expires_at` datetime,PRIMARY KEY (`nonce`)); |
| + |
| +CREATE UNIQUE INDEX `idx_signups_email` ON `signups`(`email`); |
| + |
| +CREATE INDEX `idx_spent_nonces_expires_at` ON `spent_nonces`(`expires_at`); |
| + |
diff --git a/internal/movement/models.go b/internal/movement/models.go| index 5eb8416..01e89c2 100644 |
| --- a/internal/movement/models.go |
| +++ b/internal/movement/models.go |
| @@ -8,7 +8,85 @@ import "time" |
| // `rastrillo migration check` fails CI when it and the |
| // migrations disagree. |
| var Models = []any{ |
| + &Signup{}, |
| &SpentNonce{}, |
| + &MailCounter{}, |
| +} |
| + |
| +// Stance is which of the three positions somebody picked. The stored value is |
| +// an internal key, never the label they read: labels are copy, copy changes, |
| +// and a column that changes with it is a migration for every wording tweak |
| +// and a corrupted history for the rows already written. |
| +type Stance string |
| + |
| +const ( |
| + StanceNever Stance = "never" |
| + StanceCurious Stance = "curious" |
| + StanceEnthusiast Stance = "enthusiast" |
| +) |
| + |
| +// Stances is the closed set, in the order the landing page offers them. |
| +var Stances = []Stance{StanceNever, StanceCurious, StanceEnthusiast} |
| + |
| +// ValidStance reports whether s is one of the three. Anything else renders |
| +// the same page as every other rejection — a made-up stance is a submission |
| +// we refuse, not an error we explain. |
| +func ValidStance(s string) bool { |
| + for _, k := range Stances { |
| + if string(k) == s { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// Signup is one person's answer. |
| +// |
| +// There is no owner column and no User model, because nothing here is owned: |
| +// the public surface writes rows nobody is signed in for, and the admin view |
| +// reads all of them. Worth naming, because rastrillo's most expensive trap is |
| +// sessions.UserID silently returning (0, false) under the auth plugin, where |
| +// the session subject is a verified address rather than a numeric id. This |
| +// app never calls it. Reading the whole table is correct here and would be a |
| +// severe bug in an app with tenants. |
| +type Signup struct { |
| + ID int64 |
| + Email string `gorm:"uniqueIndex"` // lowercased; the index is the dedupe |
| + |
| + Stance string |
| + Background string // capped and stripped of control characters on the way in |
| + |
| + // A row is inert until Confirmed. It is never exported, never counted, |
| + // and never mailed beyond its own confirmation — because proof of work |
| + // and honeypots say nothing about whether an address is real or whether |
| + // its owner agreed. Only a click from inside that inbox does. |
| + Confirmed bool |
| + ConfirmedAt *time.Time |
| + |
| + TokenHash []byte // SHA-256 of a random token; cleared when it is burned |
| + TokenExpiry time.Time |
| + |
| + // The per-address mail budget. These two columns are deliberately |
| + // excluded from the pending-row update allowlist: a counter somebody can |
| + // reset by submitting the form again is not a budget. |
| + LastMailedAt *time.Time |
| + MailCount int |
| + |
| + CreatedAt time.Time |
| + UpdatedAt time.Time |
| +} |
| + |
| +// MailCounter is the global hourly budget. |
| +// |
| +// It exists because the real damage from a bulk attack is not junk rows, it |
| +// is thousands of messages to strangers from our own verified sending |
| +// identity, followed by the sending domain being suspended. It is persisted |
| +// rather than in memory because this process hibernates as its normal state, |
| +// and an in-memory counter would forget everything on the next wake — which |
| +// is precisely when an attacker would like it to. |
| +type MailCounter struct { |
| + HourBucket string `gorm:"primaryKey"` // UTC, "2006-01-02T15" |
| + Count int |
| } |
| // SpentNonce records a challenge that has already been accepted. |
diff --git a/internal/movement/pages.go b/internal/movement/pages.go| new file mode 100644 |
| index 0000000..8cf47d3 |
| --- /dev/null |
| +++ b/internal/movement/pages.go |
| @@ -0,0 +1,49 @@ |
| +package movement |
| + |
| +import ( |
| + "net/http" |
| + "time" |
| + |
| + "github.com/go-chi/chi/v5" |
| +) |
| + |
| +// stanceData is what a stance page needs to render its form. |
| +// |
| +// The challenge travels as four hidden fields. Nothing is written when it is |
| +// minted — the seal already proves the server issued it, so a row per page |
| +// view would buy nothing and would turn any crawler into a stream of |
| +// serialised writes against the one SQLite writer every signup needs. |
| +type stanceData struct { |
| + Stance string |
| + Challenge Challenge |
| + Honeypot string |
| + Error string |
| +} |
| + |
| +// stancePage renders one of the three positions and the form beneath it. |
| +// |
| +// Routes are mounted by internal stance key for now. The reader-facing slugs |
| +// are copy, and copy has not been through review yet. |
| +func (a *app) stancePage(w http.ResponseWriter, r *http.Request) { |
| + stance := chi.URLParam(r, "stance") |
| + if !ValidStance(stance) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + a.renderStance(w, Stance(stance), "") |
| +} |
| + |
| +func (a *app) renderStance(w http.ResponseWriter, stance Stance, errMsg string) { |
| + render(w, "stance", stanceData{ |
| + Stance: string(stance), |
| + Challenge: NewChallenge(a.challengeKey, time.Now(), DefaultDifficulty), |
| + Honeypot: honeypotField, |
| + Error: errMsg, |
| + }) |
| +} |
| + |
| +// renderSent is the page every submission ends on: accepted, suppressed, |
| +// replayed, already confirmed, or refused. One page, one set of bytes. |
| +func (a *app) renderSent(w http.ResponseWriter) { |
| + render(w, "sent", nil) |
| +} |
diff --git a/internal/movement/render.go b/internal/movement/render.go| index 7655cfa..0798312 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", "errors"} { |
| + for _, name := range []string{"index", "stance", "sent", "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/seal.go b/internal/movement/seal.go| index 4f2115b..3562e6d 100644 |
| --- a/internal/movement/seal.go |
| +++ b/internal/movement/seal.go |
| @@ -33,12 +33,12 @@ const ( |
| MaxAge = 2 * time.Hour |
| ) |
| -// challengeKey derives the HMAC key for seals from the instance key. |
| +// ChallengeKey derives the HMAC key for seals from the instance key. |
| // |
| // The instance key is shared with rastrillo/auth, which hashes it with a |
| // label of its own. Deriving here with a different label is what stops a |
| // token minted by one subsystem from ever verifying in the other. |
| -func challengeKey(instanceKey string) []byte { |
| +func ChallengeKey(instanceKey string) []byte { |
| sum := sha256.Sum256([]byte("movement/challenge\x00" + instanceKey)) |
| return sum[:] |
| } |
diff --git a/internal/movement/seal_test.go b/internal/movement/seal_test.go| index ce23e72..bab9416 100644 |
| --- a/internal/movement/seal_test.go |
| +++ b/internal/movement/seal_test.go |
| @@ -7,7 +7,7 @@ import ( |
| "time" |
| ) |
| -func testKey() []byte { return challengeKey(strings.Repeat("k", 64)) } |
| +func testKey() []byte { return ChallengeKey(strings.Repeat("k", 64)) } |
| func TestFreshSealVerifies(t *testing.T) { |
| key, now := testKey(), time.Now() |
| @@ -53,9 +53,9 @@ func TestSealCoversEveryField(t *testing.T) { |
| func TestSealFromAnotherKeyIsRefused(t *testing.T) { |
| now := time.Now() |
| - c := NewChallenge(challengeKey("one"), now, DefaultDifficulty) |
| + c := NewChallenge(ChallengeKey("one"), now, DefaultDifficulty) |
| - if r, ok := VerifySeal(challengeKey("two"), c, now.Add(10*time.Second)); ok { |
| + if r, ok := VerifySeal(ChallengeKey("two"), c, now.Add(10*time.Second)); ok { |
| t.Errorf("a seal from a different instance key must not verify, got %q", r) |
| } |
| } |
| @@ -65,10 +65,10 @@ func TestSealFromAnotherKeyIsRefused(t *testing.T) { |
| // subsystem carry weight in the other. |
| func TestChallengeKeyIsDerivedNotRaw(t *testing.T) { |
| raw := strings.Repeat("k", 64) |
| - if bytes.Equal(challengeKey(raw), []byte(raw)) { |
| + if bytes.Equal(ChallengeKey(raw), []byte(raw)) { |
| t.Fatal("the challenge key must be derived from the instance key, not be it") |
| } |
| - if bytes.Equal(challengeKey("a"), challengeKey("b")) { |
| + if bytes.Equal(ChallengeKey("a"), ChallengeKey("b")) { |
| t.Fatal("different instance keys must derive different challenge keys") |
| } |
| } |
diff --git a/internal/movement/signup.go b/internal/movement/signup.go| new file mode 100644 |
| index 0000000..a188746 |
| --- /dev/null |
| +++ b/internal/movement/signup.go |
| @@ -0,0 +1,269 @@ |
| +package movement |
| + |
| +import ( |
| + "errors" |
| + "net/http" |
| + "net/mail" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "gorm.io/gorm" |
| + "gorm.io/gorm/clause" |
| +) |
| + |
| +// honeypotField is a real input, hidden with CSS, that a person never fills |
| +// in. Its name is chosen to be invisible to browser autofill and password |
| +// managers: anything resembling "company", "organisation" or "url" gets |
| +// filled for real people, and a filled honeypot silently discards a genuine |
| +// signup behind a cheerful success page — the same "looks live, does nothing" |
| +// failure the <noscript> rule exists to prevent. |
| +const honeypotField = "hp" |
| + |
| +// maxBackground caps the free-text field. Somebody with more to say than this |
| +// can say it in a reply once they are on the list. |
| +const maxBackground = 2000 |
| + |
| +// maxBody bounds the whole request. Without it, the free-text field is an |
| +// invitation to post a gigabyte. |
| +const maxBody = 64 << 10 |
| + |
| +var errReplay = errors.New("movement: nonce already spent") |
| + |
| +// handleSignup is the one handler all three stance pages post to. |
| +// |
| +// Every outcome renders the same page. That is not politeness — it is the |
| +// second half of the one rule. If a returning visitor were told "you're |
| +// already signed up", this form would become a way for anybody to check who |
| +// has taken a public position on AI, one address at a time. The cost is real |
| +// and is paid here: somebody already on the list is told to check their inbox. |
| +func (a *app) handleSignup(w http.ResponseWriter, r *http.Request) { |
| + r.Body = http.MaxBytesReader(w, r.Body, maxBody) |
| + |
| + // The honeypot goes first: it is free, needs no seal, and must not sit |
| + // behind anything that writes. It answers success and stores nothing, |
| + // because a crawler that gets an error learns which field to leave alone |
| + // next time, while one that gets a confirmation page learns nothing. |
| + if strings.TrimSpace(r.PostFormValue(honeypotField)) != "" { |
| + Rejected(ReasonHoneypot) |
| + a.renderSent(w) |
| + return |
| + } |
| + |
| + stance := r.PostFormValue("stance") |
| + if !ValidStance(stance) { |
| + Rejected(ReasonBounds) |
| + a.renderSent(w) |
| + return |
| + } |
| + |
| + c := Challenge{ |
| + Nonce: r.PostFormValue("nonce"), |
| + IssuedAt: atoi64(r.PostFormValue("issued_at")), |
| + Difficulty: atoi(r.PostFormValue("difficulty")), |
| + Seal: r.PostFormValue("seal"), |
| + } |
| + |
| + // The seal before the clock: until the signature holds, IssuedAt is a |
| + // number the submitter chose, and checking an unverified timestamp is not |
| + // a check. |
| + if reason, ok := VerifySeal(a.challengeKey, c, time.Now()); !ok { |
| + Rejected(reason) |
| + a.renderSent(w) |
| + return |
| + } |
| + |
| + rawEmail := r.PostFormValue("email") |
| + email, ok := normalizeAddress(rawEmail) |
| + if !ok { |
| + // The one visible error. It leaks nothing: a malformed string is a |
| + // property of what this person just typed, not of any address's state |
| + // on the list. Silently dropping it would strand somebody who simply |
| + // made a typo. |
| + Rejected(ReasonBounds) |
| + a.renderStance(w, Stance(stance), T("form.bademail")) |
| + return |
| + } |
| + |
| + background := clampBackground(r.PostFormValue("background")) |
| + |
| + if !VerifyPow(c.Nonce, email, r.PostFormValue("counter"), c.Difficulty) { |
| + Rejected(ReasonPowShort) |
| + a.renderSent(w) |
| + return |
| + } |
| + |
| + now := time.Now() |
| + var send *pendingMail |
| + |
| + err := a.db.G.Transaction(func(tx *gorm.DB) error { |
| + // Spending the nonce is the first statement, so the transaction opens |
| + // as a writer and nothing needs to upgrade a lock later. Zero rows |
| + // affected means this challenge has already been used: without it, one |
| + // solved challenge is replayable for its whole two-hour window, and |
| + // every replay sends another message to whatever address it names. |
| + res := tx.Clauses(clause.OnConflict{DoNothing: true}). |
| + Create(&SpentNonce{Nonce: c.Nonce, ExpiresAt: c.Expiry()}) |
| + if res.Error != nil { |
| + return res.Error |
| + } |
| + if res.RowsAffected == 0 { |
| + return errReplay |
| + } |
| + |
| + var row Signup |
| + lookup := tx.Where("email = ?", email).First(&row).Error |
| + |
| + switch { |
| + case errors.Is(lookup, gorm.ErrRecordNotFound): |
| + token, hash := mintToken() |
| + row = Signup{ |
| + Email: email, |
| + Stance: stance, |
| + Background: background, |
| + TokenHash: hash, |
| + TokenExpiry: now.Add(TokenLifetime), |
| + } |
| + mailed, err := a.claimMail(tx, &row, now) |
| + if err != nil { |
| + return err |
| + } |
| + if err := tx.Create(&row).Error; err != nil { |
| + return err |
| + } |
| + if mailed { |
| + send = &pendingMail{to: email, token: token} |
| + } |
| + return nil |
| + |
| + case lookup != nil: |
| + return lookup |
| + |
| + case row.Confirmed: |
| + // Already on the list: no write to the row, no message, same |
| + // page. A blanket upsert here would let anybody who knows an |
| + // address knock it off the list, or re-file a named person under |
| + // a stance they never chose — which is the exact signal this |
| + // whole app exists to keep clean. |
| + return nil |
| + |
| + default: |
| + token, hash := mintToken() |
| + row.Stance = stance |
| + row.Background = background |
| + row.TokenHash = hash |
| + row.TokenExpiry = now.Add(TokenLifetime) |
| + |
| + mailed, err := a.claimMail(tx, &row, now) |
| + if err != nil { |
| + return err |
| + } |
| + |
| + // Named columns, and the budget columns are not among the ones a |
| + // resubmission may reset. A counter somebody can clear by filling |
| + // the form again is not a budget. |
| + cols := []string{"Stance", "Background", "TokenHash", "TokenExpiry", "UpdatedAt"} |
| + if mailed { |
| + cols = append(cols, "LastMailedAt", "MailCount") |
| + } |
| + if err := tx.Model(&row).Select(cols).Updates(&row).Error; err != nil { |
| + return err |
| + } |
| + if mailed { |
| + send = &pendingMail{to: email, token: token} |
| + } |
| + return nil |
| + } |
| + }) |
| + |
| + switch { |
| + case errors.Is(err, errReplay): |
| + Rejected(ReasonNonceSpent) |
| + case err != nil: |
| + Rejected(ReasonMailFailed) |
| + case send != nil: |
| + // After commit, never inside the transaction. mail.Send discards the |
| + // context it is given and smtp.SendMail dials with no deadline, so a |
| + // hung connection inside the transaction would hold the single writer |
| + // and stall every other signup and the scheduled tick. |
| + a.sendAfterCommit(*send) |
| + } |
| + |
| + a.renderSent(w) |
| +} |
| + |
| +// pendingMail is a message the transaction decided to send, handed out only |
| +// once the transaction has committed. |
| +type pendingMail struct { |
| + to string |
| + token string |
| +} |
| + |
| +// claimMail marks row as mailed if both budgets allow it. When they do not, |
| +// the row is left with its counters untouched, nothing is sent, and the |
| +// caller still renders the same page — a suppressed message must be |
| +// indistinguishable from a delivered one. |
| +func (a *app) claimMail(tx *gorm.DB, row *Signup, now time.Time) (bool, error) { |
| + if !addressAllowed(row, now) { |
| + Rejected(ReasonMailBudget) |
| + return false, nil |
| + } |
| + ok, err := globalAllowed(tx, now) |
| + if err != nil { |
| + return false, err |
| + } |
| + if !ok { |
| + Rejected(ReasonMailBudget) |
| + return false, nil |
| + } |
| + row.MailCount = nextAddressCount(row, now) |
| + row.LastMailedAt = &now |
| + return true, nil |
| +} |
| + |
| +// normalizeAddress lowercases, bounds and parses. CR and LF are refused |
| +// before anything else, because this string ends up as an SMTP recipient and |
| +// a newline in it is a header injection. |
| +func normalizeAddress(raw string) (string, bool) { |
| + s := strings.TrimSpace(raw) |
| + if s == "" || len(s) > 254 { |
| + return "", false |
| + } |
| + if strings.ContainsAny(s, "\r\n") { |
| + return "", false |
| + } |
| + if _, err := mail.ParseAddress(s); err != nil { |
| + return "", false |
| + } |
| + return NormalizeEmail(s), true |
| +} |
| + |
| +// clampBackground truncates rather than refuses, and strips the control |
| +// characters that would otherwise reach a CSV cell or a log line. |
| +func clampBackground(raw string) string { |
| + var b strings.Builder |
| + runes := 0 |
| + for _, r := range raw { |
| + if r < 0x20 && r != '\n' && r != '\t' { |
| + continue |
| + } |
| + if r == 0x7f { |
| + continue |
| + } |
| + b.WriteRune(r) |
| + if runes++; runes >= maxBackground { |
| + break |
| + } |
| + } |
| + return strings.TrimSpace(b.String()) |
| +} |
| + |
| +func atoi(s string) int { |
| + n, _ := strconv.Atoi(s) |
| + return n |
| +} |
| + |
| +func atoi64(s string) int64 { |
| + n, _ := strconv.ParseInt(s, 10, 64) |
| + return n |
| +} |
diff --git a/internal/movement/templates/sent.html b/internal/movement/templates/sent.html| new file mode 100644 |
| index 0000000..f5f431f |
| --- /dev/null |
| +++ b/internal/movement/templates/sent.html |
| @@ -0,0 +1,9 @@ |
| +{{define "title"}}{{say "sent.title"}}{{end}} |
| +{{define "content"}} |
| +{{/* Every submission ends here: accepted, budget-suppressed, replayed, |
| + already confirmed, or refused outright. One page, one set of bytes. |
| + Anything that varies with the state of an address turns this form into a |
| + way to check who has taken a public position on AI. */}} |
| +<h1 class="rst-page-title">{{say "sent.title"}}</h1> |
| +<p>{{say "sent.body"}}</p> |
| +{{end}} |
diff --git a/internal/movement/templates/stance.html b/internal/movement/templates/stance.html| new file mode 100644 |
| index 0000000..ab65a67 |
| --- /dev/null |
| +++ b/internal/movement/templates/stance.html |
| @@ -0,0 +1,58 @@ |
| +{{define "title"}}{{say (printf "stance.%s.label" .Stance)}}{{end}} |
| +{{define "head"}}<script type="module" src="{{asset "static/pow.js"}}"></script>{{end}} |
| +{{define "content"}} |
| +<h1 class="rst-page-title">{{say (printf "stance.%s.label" .Stance)}}</h1> |
| +<p>{{say (printf "stance.%s.message" .Stance)}}</p> |
| + |
| +{{if .Error}}<p rst-callout="warn" role="alert">{{.Error}}</p>{{end}} |
| + |
| +<form class="rst-form" method="post" action="/signup" |
| + data-pow-form |
| + data-pow-nonce="{{.Challenge.Nonce}}" |
| + data-pow-difficulty="{{.Challenge.Difficulty}}" |
| + data-pow-worker="{{asset "static/pow-worker.js"}}"> |
| + |
| + <input type="hidden" name="stance" value="{{.Stance}}"> |
| + <input type="hidden" name="nonce" value="{{.Challenge.Nonce}}"> |
| + <input type="hidden" name="issued_at" value="{{.Challenge.IssuedAt}}"> |
| + <input type="hidden" name="difficulty" value="{{.Challenge.Difficulty}}"> |
| + <input type="hidden" name="seal" value="{{.Challenge.Seal}}"> |
| + <input type="hidden" name="counter" value="" data-pow-counter> |
| + |
| + {{/* The honeypot. Hidden visually, and kept away from anything that |
| + autofills: a browser or password manager filling this would discard a |
| + real person's signup behind a success page they have no way to see |
| + through. */}} |
| + <div class="hp" aria-hidden="true"> |
| + <label for="{{.Honeypot}}">{{say "form.honeypot"}}</label> |
| + <input type="text" id="{{.Honeypot}}" name="{{.Honeypot}}" |
| + tabindex="-1" autocomplete="off"> |
| + </div> |
| + |
| + <label class="rst-field" for="email"> |
| + <span>{{say "form.email"}}</span> |
| + <input type="email" id="email" name="email" required |
| + autocomplete="email" data-pow-email> |
| + </label> |
| + |
| + <label class="rst-field" for="background"> |
| + <span>{{say "form.background"}}</span> |
| + <textarea id="background" name="background" rows="5" maxlength="2000"></textarea> |
| + </label> |
| + |
| + {{/* Rendered disabled, and the module enables it once loaded. The other |
| + way round is unbuildable — script cannot enable a control inside |
| + <noscript> — and this ordering fails safe: a module that throws leaves |
| + the honest disabled state rather than a form that looks live and does |
| + nothing. */}} |
| + <button type="submit" disabled data-pow-submit |
| + data-label="{{say "form.submit"}}" |
| + data-working-label="{{say "form.working"}}">{{say "form.submit"}}</button> |
| + |
| + <p hidden data-pow-status></p> |
| + |
| + <noscript> |
| + <p rst-callout="warn">{{say "form.nojs"}}</p> |
| + </noscript> |
| +</form> |
| +{{end}} |
diff --git a/internal/movement/token.go b/internal/movement/token.go| new file mode 100644 |
| index 0000000..7625f0b |
| --- /dev/null |
| +++ b/internal/movement/token.go |
| @@ -0,0 +1,40 @@ |
| +package movement |
| + |
| +import ( |
| + "crypto/rand" |
| + "crypto/sha256" |
| + "encoding/hex" |
| + "time" |
| +) |
| + |
| +// TokenLifetime is how long a confirmation link stays good. Long enough for |
| +// somebody to come back to their inbox tomorrow; short enough that a link |
| +// sitting in an archive forever is not a live credential. |
| +const TokenLifetime = 24 * time.Hour |
| + |
| +// mintToken returns the token to mail and the hash to store. |
| +// |
| +// The token is random, not derived from the instance key. Deriving it would |
| +// mean a key rotation silently killed every link already in somebody's inbox, |
| +// and would make the key a second thing that has to survive for the app to |
| +// keep its promises. Only the hash is stored, so a database read does not |
| +// hand somebody the ability to confirm other people's addresses. |
| +func mintToken() (token string, hash []byte) { |
| + var raw [32]byte |
| + if _, err := rand.Read(raw[:]); err != nil { |
| + // Every property of the confirmation flow rests on this being |
| + // unguessable. There is no degraded mode worth having. |
| + panic("movement: crypto/rand unavailable") |
| + } |
| + token = hex.EncodeToString(raw[:]) |
| + return token, hashToken(token) |
| +} |
| + |
| +// hashToken is how a presented token is looked up. Plain SHA-256 is right |
| +// here and would be wrong for a password: the input is 256 bits of entropy we |
| +// generated, so there is nothing to brute-force and nothing a slow hash would |
| +// buy. |
| +func hashToken(token string) []byte { |
| + sum := sha256.Sum256([]byte(token)) |
| + return sum[:] |
| +} |
diff --git a/internal/movementtest/harness_test.go b/internal/movementtest/harness_test.go| index 96c5494..93f4d78 100644 |
| --- a/internal/movementtest/harness_test.go |
| +++ b/internal/movementtest/harness_test.go |
| @@ -15,6 +15,7 @@ import ( |
| "path/filepath" |
| "strings" |
| "testing" |
| + "time" |
| "github.com/carlosframework/rastrillo/db" |
| @@ -27,7 +28,14 @@ const testOrigin = "http://app.test" |
| // newApp builds the whole app per test over a fresh temp database, |
| // exactly as main.go does. |
| -func newApp(t *testing.T) http.Handler { |
| +// 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. |
| +type testApp struct { |
| + http.Handler |
| + DB *db.DB |
| +} |
| + |
| +func newApp(t *testing.T) *testApp { |
| t.Helper() |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| @@ -40,7 +48,51 @@ func newApp(t *testing.T) http.Handler { |
| if err != nil { |
| t.Fatalf("App: %v", err) |
| } |
| - return mux |
| + return &testApp{Handler: mux, DB: d} |
| +} |
| + |
| +func countSignups(t *testing.T, h *testApp, email string) int64 { |
| + t.Helper() |
| + var n int64 |
| + if err := h.DB.G.Model(&movement.Signup{}).Where("email = ?", email).Count(&n).Error; err != nil { |
| + t.Fatalf("counting signups: %v", err) |
| + } |
| + return n |
| +} |
| + |
| +func loadSignup(t *testing.T, h *testApp, email string) movement.Signup { |
| + t.Helper() |
| + var row movement.Signup |
| + if err := h.DB.G.Where("email = ?", email).First(&row).Error; err != nil { |
| + t.Fatalf("loading %s: %v", email, err) |
| + } |
| + return row |
| +} |
| + |
| +func countNonces(t *testing.T, h *testApp) int64 { |
| + t.Helper() |
| + var n int64 |
| + if err := h.DB.G.Model(&movement.SpentNonce{}).Count(&n).Error; err != nil { |
| + t.Fatalf("counting nonces: %v", err) |
| + } |
| + return n |
| +} |
| + |
| +// confirmSignup marks one address confirmed directly. These tests are about |
| +// what /signup does to an already-confirmed row, so they set that state up |
| +// rather than driving the confirmation flow, which has its own tests. |
| +func confirmSignup(t *testing.T, h *testApp, email string) { |
| + t.Helper() |
| + now := time.Now() |
| + res := h.DB.G.Model(&movement.Signup{}). |
| + Where("email = ?", email). |
| + Updates(map[string]any{"confirmed": true, "confirmed_at": now, "token_hash": nil}) |
| + if res.Error != nil { |
| + t.Fatalf("confirming %s: %v", email, res.Error) |
| + } |
| + if res.RowsAffected != 1 { |
| + t.Fatalf("confirming %s: %d rows affected, want 1", email, res.RowsAffected) |
| + } |
| } |
| // testEnv is the configuration a test runs against. It is not the canonical |
diff --git a/internal/movementtest/signup_test.go b/internal/movementtest/signup_test.go| new file mode 100644 |
| index 0000000..c9bb778 |
| --- /dev/null |
| +++ b/internal/movementtest/signup_test.go |
| @@ -0,0 +1,288 @@ |
| +package movementtest |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + movement "amadan.net/carlos/movement/internal/movement" |
| +) |
| + |
| +// powDifficulty is low so the suite stays fast. What these tests are about is |
| +// the handler's decisions, not the cost of a solve. |
| +const powDifficulty = 8 |
| + |
| +// submission builds a valid, solved form post. |
| +// |
| +// The challenge is minted with a timestamp ten seconds in the past rather |
| +// than by scraping a rendered page, because the handler refuses anything |
| +// submitted less than three seconds after its challenge was issued — and a |
| +// suite that slept three seconds per case would be a suite nobody runs. |
| +func submission(t *testing.T, email, stance string) url.Values { |
| + t.Helper() |
| + key := movement.ChallengeKey(testEnv().InstanceKey) |
| + c := movement.NewChallenge(key, time.Now().Add(-10*time.Second), powDifficulty) |
| + return solved(t, c, email, stance) |
| +} |
| + |
| +func solved(t *testing.T, c movement.Challenge, email, stance string) url.Values { |
| + t.Helper() |
| + counter := "" |
| + for i := 0; i < 1<<24; i++ { |
| + s := strconv.Itoa(i) |
| + if movement.VerifyPow(c.Nonce, email, s, c.Difficulty) { |
| + counter = s |
| + break |
| + } |
| + } |
| + if counter == "" { |
| + t.Fatal("no proof-of-work solution found") |
| + } |
| + return url.Values{ |
| + "stance": {stance}, |
| + "email": {email}, |
| + "background": {"why I am interested"}, |
| + "nonce": {c.Nonce}, |
| + "issued_at": {strconv.FormatInt(c.IssuedAt, 10)}, |
| + "difficulty": {strconv.Itoa(c.Difficulty)}, |
| + "seal": {c.Seal}, |
| + "counter": {counter}, |
| + } |
| +} |
| + |
| +func postSignup(t *testing.T, h http.Handler, form url.Values) *httptest.ResponseRecorder { |
| + t.Helper() |
| + req := httptest.NewRequest(http.MethodPost, "/signup", strings.NewReader(form.Encode())) |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + req.Header.Set("Origin", testOrigin) // csrf.Protect checks the origin, not a token |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + return rec |
| +} |
| + |
| +// This is the second half of the one rule, and the test most likely to be |
| +// broken by a well-meaning copy change. If a returning visitor were told |
| +// "you're already signed up", this form would become a way for anybody to |
| +// check who has taken a public position on AI, one address at a time. |
| +func TestSignupRevealsNothingAboutAnAddressState(t *testing.T) { |
| + h := newApp(t) |
| + |
| + unknown := postSignup(t, h, submission(t, "unknown@example.com", "curious")) |
| + |
| + // Same address twice: the second submission meets a pending row. |
| + postSignup(t, h, submission(t, "pending@example.com", "curious")) |
| + pending := postSignup(t, h, submission(t, "pending@example.com", "curious")) |
| + |
| + // And one that is genuinely on the list. Submitting for an address that |
| + // is merely pending is not this case: the interesting path is the one |
| + // where the handler writes nothing and sends nothing, and has to look |
| + // exactly like the path where it does both. |
| + postSignup(t, h, submission(t, "confirmed@example.com", "curious")) |
| + confirmSignup(t, h, "confirmed@example.com") |
| + confirmed := postSignup(t, h, submission(t, "confirmed@example.com", "curious")) |
| + |
| + // A budget-suppressed submission renders the same page too, and it is a |
| + // separate code path from all three above. |
| + for i := 0; i < movement.PerAddressPerDay+1; i++ { |
| + postSignup(t, h, submission(t, "busy@example.com", "curious")) |
| + } |
| + suppressed := postSignup(t, h, submission(t, "busy@example.com", "curious")) |
| + |
| + for name, rec := range map[string]*httptest.ResponseRecorder{ |
| + "pending": pending, "confirmed": confirmed, "budget-suppressed": suppressed, |
| + } { |
| + if rec.Code != unknown.Code { |
| + t.Errorf("%s: status %d, unknown address got %d", name, rec.Code, unknown.Code) |
| + } |
| + if rec.Body.String() != unknown.Body.String() { |
| + t.Errorf("%s: response body differs from an unknown address's", name) |
| + } |
| + } |
| +} |
| + |
| +// Without this, one solved challenge is replayable for its whole two-hour |
| +// window, and every replay sends another message to whatever address it |
| +// names — a mail-bombing primitive aimed at a stranger's inbox, from our own |
| +// verified sending identity. |
| +func TestNonceCannotBeSpentTwice(t *testing.T) { |
| + h := newApp(t) |
| + form := submission(t, "replay@example.com", "curious") |
| + |
| + first := postSignup(t, h, form) |
| + second := postSignup(t, h, form) // byte-identical replay |
| + |
| + if second.Code != first.Code || second.Body.String() != first.Body.String() { |
| + t.Error("a replay must be indistinguishable from a first submission") |
| + } |
| + if got := countSignups(t, h, "replay@example.com"); got != 1 { |
| + t.Errorf("replay wrote %d rows, want 1", got) |
| + } |
| +} |
| + |
| +// A blanket upsert would let anybody who knows an address knock it off the |
| +// list, or re-file a named person under a stance they never chose. |
| +func TestConfirmedRowIsUntouchedBySignup(t *testing.T) { |
| + h := newApp(t) |
| + postSignup(t, h, submission(t, "settled@example.com", "curious")) |
| + confirmSignup(t, h, "settled@example.com") |
| + |
| + before := loadSignup(t, h, "settled@example.com") |
| + if !before.Confirmed { |
| + t.Fatal("setup failed: the row should be confirmed") |
| + } |
| + |
| + postSignup(t, h, submission(t, "settled@example.com", "never")) |
| + |
| + after := loadSignup(t, h, "settled@example.com") |
| + if !after.Confirmed { |
| + t.Error("a stranger's submission un-confirmed a confirmed address") |
| + } |
| + if after.Stance != before.Stance { |
| + t.Errorf("stance rewritten from %q to %q by someone who knew the address", |
| + before.Stance, after.Stance) |
| + } |
| + if after.MailCount != before.MailCount { |
| + t.Error("a confirmed row was mailed again") |
| + } |
| +} |
| + |
| +// A filled honeypot answers success and writes nothing. An error would teach |
| +// a crawler which field to leave alone next time; a confirmation page teaches |
| +// it nothing. |
| +func TestHoneypotLooksLikeSuccessAndStoresNothing(t *testing.T) { |
| + h := newApp(t) |
| + |
| + clean := postSignup(t, h, submission(t, "clean@example.com", "curious")) |
| + |
| + trapped := submission(t, "bot@example.com", "curious") |
| + trapped.Set("hp", "http://spam.example") |
| + rec := postSignup(t, h, trapped) |
| + |
| + if rec.Code != clean.Code || rec.Body.String() != clean.Body.String() { |
| + t.Error("a trapped submission must look exactly like an accepted one") |
| + } |
| + if got := countSignups(t, h, "bot@example.com"); got != 0 { |
| + t.Errorf("the honeypot stored %d rows, want 0", got) |
| + } |
| +} |
| + |
| +// Nothing is written when a challenge is minted. A row per page view would |
| +// turn any crawler into a stream of serialised writes against the single |
| +// SQLite writer that every signup needs. |
| +func TestStancePageWritesNothing(t *testing.T) { |
| + h := newApp(t) |
| + for i := 0; i < 5; i++ { |
| + if rec := get(t, h, "/s/curious"); rec.Code != http.StatusOK { |
| + t.Fatalf("GET /s/curious: %d", rec.Code) |
| + } |
| + } |
| + if got := countNonces(t, h); got != 0 { |
| + t.Errorf("rendering a stance page wrote %d nonce rows, want 0", got) |
| + } |
| +} |
| + |
| +func TestRejectionsAllRenderTheSamePage(t *testing.T) { |
| + h := newApp(t) |
| + baseline := postSignup(t, h, submission(t, "baseline@example.com", "curious")).Body.String() |
| + |
| + cases := map[string]func(url.Values){ |
| + "forged-seal": func(v url.Values) { v.Set("seal", strings.Repeat("0", 64)) }, |
| + "short-proof": func(v url.Values) { v.Set("counter", "0") }, |
| + "unknown-stance": func(v url.Values) { v.Set("stance", "enthusiastic") }, |
| + "tampered-nonce": func(v url.Values) { v.Set("nonce", "deadbeef") }, |
| + "lowered-work": func(v url.Values) { v.Set("difficulty", "1") }, |
| + } |
| + for name, mangle := range cases { |
| + // The address has to be well-formed, or the case takes the one |
| + // deliberately visible path — a malformed address is reported rather |
| + // than swallowed, and that is tested separately. |
| + form := submission(t, name+"@example.com", "curious") |
| + mangle(form) |
| + if got := postSignup(t, h, form).Body.String(); got != baseline { |
| + t.Errorf("%s: response differs from an accepted submission's", name) |
| + } |
| + } |
| +} |
| + |
| +// A submission arriving less than three seconds after its challenge was |
| +// issued is not a person who read three positions and wrote about themselves. |
| +func TestSubmittingTooFastIsRefused(t *testing.T) { |
| + h := newApp(t) |
| + key := movement.ChallengeKey(testEnv().InstanceKey) |
| + fresh := movement.NewChallenge(key, time.Now(), powDifficulty) |
| + |
| + postSignup(t, h, solved(t, fresh, "fast@example.com", "curious")) |
| + |
| + if got := countSignups(t, h, "fast@example.com"); got != 0 { |
| + t.Errorf("a sub-three-second submission wrote %d rows, want 0", got) |
| + } |
| +} |
| + |
| +func TestExpiredChallengeIsRefused(t *testing.T) { |
| + h := newApp(t) |
| + key := movement.ChallengeKey(testEnv().InstanceKey) |
| + stale := movement.NewChallenge(key, time.Now().Add(-3*time.Hour), powDifficulty) |
| + |
| + postSignup(t, h, solved(t, stale, "stale@example.com", "curious")) |
| + |
| + if got := countSignups(t, h, "stale@example.com"); got != 0 { |
| + t.Errorf("a three-hour-old challenge wrote %d rows, want 0", got) |
| + } |
| +} |
| + |
| +// A counter somebody can reset by filling the form in again is not a budget. |
| +func TestResubmittingDoesNotResetTheMailBudget(t *testing.T) { |
| + h := newApp(t) |
| + const addr = "repeat@example.com" |
| + |
| + for i := 0; i < movement.PerAddressPerDay; i++ { |
| + postSignup(t, h, submission(t, addr, "curious")) |
| + } |
| + spent := loadSignup(t, h, addr) |
| + if spent.MailCount != movement.PerAddressPerDay { |
| + t.Fatalf("setup: MailCount = %d, want %d", spent.MailCount, movement.PerAddressPerDay) |
| + } |
| + |
| + // One more, past the allowance. |
| + postSignup(t, h, submission(t, addr, "curious")) |
| + |
| + after := loadSignup(t, h, addr) |
| + if after.MailCount != movement.PerAddressPerDay { |
| + t.Errorf("MailCount = %d after exceeding the budget, want it held at %d", |
| + after.MailCount, movement.PerAddressPerDay) |
| + } |
| +} |
| + |
| +func TestMalformedAddressIsToldSo(t *testing.T) { |
| + h := newApp(t) |
| + sent := postSignup(t, h, submission(t, "fine@example.com", "curious")).Body.String() |
| + |
| + form := submission(t, "fine@example.com", "curious") |
| + form.Set("email", "not-an-address") |
| + rec := postSignup(t, h, form) |
| + |
| + // The one visible error, and it leaks nothing: a malformed string is a |
| + // property of what was just typed, not of any address's state on the list. |
| + if rec.Body.String() == sent { |
| + t.Error("a typo should be reported, not silently swallowed") |
| + } |
| + if !strings.Contains(rec.Body.String(), movement.T("form.bademail")) { |
| + t.Error("the malformed-address page should say what went wrong") |
| + } |
| +} |
| + |
| +func TestHeaderInjectionInAddressIsRefused(t *testing.T) { |
| + h := newApp(t) |
| + form := submission(t, "x@example.com", "curious") |
| + form.Set("email", "x@example.com\r\nBcc: victim@example.com") |
| + |
| + postSignup(t, h, form) |
| + |
| + if got := countSignups(t, h, "x@example.com"); got != 0 { |
| + t.Error("an address containing CRLF must never reach the database or an SMTP header") |
| + } |
| +} |