package memoriatest import ( "context" "errors" "io" "log/slog" "net" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "sync/atomic" "testing" "amadan.net/rastrillo/rastrillo/db" memoria "amadan.net/oficina/memoria/internal/memoria" ) // dialRecorder stands in for the network — BOTH halves of it. // // The classifier resolves a TXT record for the submitted domain first and // only fetches https:///.well-known/keymail if that finds a // keymail delegation. So the DNS query is the outbound step that happens // for every address, and the HTTP fetch is the one that happens for some. // A recorder watching only HTTP would have called a reserved-TLD address // "no dial" while the process had already asked the world about it — // which is what the first draft of this gate did, and what its two-sided // half caught within a minute of being written. type dialRecorder struct { hits atomic.Int64 last atomic.Value // string } func (d *dialRecorder) note(what string) { d.hits.Add(1) d.last.Store(what) } func (d *dialRecorder) RoundTrip(r *http.Request) (*http.Response, error) { d.note("HTTP " + r.URL.String()) return nil, errRecorded } func (d *dialRecorder) dialed() bool { return d.hits.Load() > 0 } func (d *dialRecorder) where() string { if s, ok := d.last.Load().(string); ok { return s } return "(none)" } // errRecorded is returned instead of performing any request. The value is // irrelevant; being called at all is the finding. var errRecorded = errors.New("memoriatest: outbound request recorded, not sent") // stubMailer accepts a magic link and discards it. Never a real sender: // the link is a live credential and this test does not need one sent. type stubMailer struct{ sent atomic.Int64 } func (m *stubMailer) Send(ctx context.Context, to, subject, body string) error { m.sent.Add(1) return nil } const dialTestOwner = "owner@example.test" // signinApp builds the app the way main.go does — production constructor, // real auth, real owners list — and points the whole process's HTTP // transport at a recorder. func signinApp(t *testing.T) (http.Handler, *dialRecorder) { t.Helper() return signinAppWithOwners(t, []string{dialTestOwner}) } // signinAppWithOwners is signinApp with the owners list under the test's // control, so the fail-closed branch — an app wired with no owners at all // — can be reached. NewAuth still gets a real predicate; it refuses to // construct without one. func signinAppWithOwners(t *testing.T, owners []string) (http.Handler, *dialRecorder) { t.Helper() logger := slog.New(slog.NewTextHandler(io.Discard, nil)) d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger) if err != nil { t.Fatalf("App: %v", err) } au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ Origin: testOrigin, InstanceKey: "signin-dial-gate-instance-key", Owners: []string{dialTestOwner}, Mailer: &stubMailer{}, }, logger, a) if err != nil { t.Fatalf("NewAuth: %v", err) } a.UseAuth(au) a.SetOwners(owners) a.MountAuth(mux, testOrigin) rec := &dialRecorder{} prevTransport := http.DefaultTransport http.DefaultTransport = rec t.Cleanup(func() { http.DefaultTransport = prevTransport }) // PreferGo routes resolution through Go's own resolver, which calls // Dial — the only hook that sees a DNS query from inside the process. prevResolver := net.DefaultResolver net.DefaultResolver = &net.Resolver{ PreferGo: true, Dial: func(ctx context.Context, network, address string) (net.Conn, error) { rec.note("DNS " + address) return nil, errRecorded }, } t.Cleanup(func() { net.DefaultResolver = prevResolver }) return mux, rec } func beginSignin(t *testing.T, h http.Handler, address string) *httptest.ResponseRecorder { t.Helper() form := url.Values{"address": {address}} req := httptest.NewRequest(http.MethodPost, "/auth/begin", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", testOrigin) rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr } // Gate 33 (spec §3.2, §4.3): an anonymous visitor cannot make this // instance dial a domain of their choosing. // // rastrillo's sign-in classifier fetches the submitted address's domain // inside auth.Begin, before the Authorize callback runs. memoria's // beginGuard runs the owners predicate first, so a non-owner's address // never reaches the classifier. // // The gate is deliberately TWO-SIDED, and that is the part worth reading. // Asserting only "a non-owner causes no dial" is satisfied by a guard // that refuses everybody, by auth being unwired, by the route being // absent, and by the transport never being consulted at all — four ways // to be green while the app is broken or the test is measuring nothing. // So the owner case asserts the dial DOES happen, which proves the // recorder is installed, the route is reachable, the classifier still // runs for an admitted address, and the guard discriminates rather than // blocks. // // MUTATION-TESTED 2026-09-05, in two passes, and the second is why this // file is worth reading. // // The author's five, each verified red: // // M1 register a.auth.Begin bare, no guard -> non-owner case red // M2 guard admits everyone -> non-owner case red // M3 guard refuses everyone -> owner case red // M4 guard returns 403 instead of ?sent=1 -> non-owner case red // M5 neither recorder installed -> owner case red // // Then an adversarial pass by someone who did not write the gate, per // AGENTS.md. It found the five above were the cases already defended // against, and that **the gate was green over two live bypasses in the // shipped guard** — not mutations, the actual code: // // - `POST /auth/begin?a;b=1` with an ordinary body reached the // classifier. The guard called ParseForm and passed the request // through on error; ParseForm reports a query-string error even when // the body parsed fine, and rastrillo's FormValue ignores it and uses // what parsed. One character defeated the check. // - An address supplied only in the query string was invisible to the // guard's PostFormValue and visible to rastrillo's FormValue. // // Both are one mistake — a guard reading the request differently from the // thing it guards — and the subtests below are the four payloads that // proved it, each confirmed to go red against the old code with the // resolver being asked for the attacker's domain. // // The same pass found three more green mutations, now closed: SigninPath // drifting from the guard's own constant (they share it), dropping // csrf.Protect from MountAuth (cross-origin now answers identically), and // a nil predicate falling open (asserted). // // STILL OPEN, and stated rather than quietly carried: rate limiting makes // owner and non-owner distinguishable after five submissions, because the // guard sits in front of signin's per-address budget. See beginGuard's // residual 2. Not closable from here. func TestSigninDoesNotDialForANonOwner(t *testing.T) { t.Run("a non-owner never reaches the classifier", func(t *testing.T) { h, rec := signinApp(t) rr := beginSignin(t, h, "stranger@attacker.example") if rec.dialed() { t.Errorf("an unadmitted address made the instance dial %s", rec.where()) } if rr.Code != http.StatusSeeOther { t.Errorf("status = %d, want %d — a non-owner must get the same "+ "outcome as a sent link, not a distinguishable one", rr.Code, http.StatusSeeOther) } if got := rr.Header().Get("Location"); got != "/signin?sent=1" { t.Errorf("Location = %q, want %q: a distinct destination turns "+ "this guard into an owners-list oracle, which is a worse "+ "leak than the dial it removes", got, "/signin?sent=1") } }) t.Run("an owner still reaches it, or this gate proves nothing", func(t *testing.T) { h, rec := signinApp(t) beginSignin(t, h, dialTestOwner) if !rec.dialed() { t.Error("the classifier was never reached for an admitted address: " + "either the guard refuses everybody, or the recorder is not " + "installed, or the route is gone — in all three the other " + "half of this gate is measuring nothing") } }) // A guard that reads the request differently from the thing it guards // is not guarding it. Every case below defeated the first version, // which called ParseForm and passed the request through on error — // and ParseForm reports a query-string error even when the body is // fine, while rastrillo's FormValue ignores it and uses what parsed. // // Found by an adversarial mutation pass, 2026-09-05. These are not // mutations: they were live bypasses of shipped code. t.Run("a malformed request does not skip the check", func(t *testing.T) { const stranger = "stranger@attacker.example" cases := []struct { name, target, body, contentType string }{ {"semicolon in the query string", "/auth/begin?a;b=1", "address=" + stranger, "application/x-www-form-urlencoded"}, {"semicolon in the body", "/auth/begin", "x;y=1&address=" + stranger, "application/x-www-form-urlencoded"}, {"bad percent-escape in the body", "/auth/begin", "x=%zz&address=" + stranger, "application/x-www-form-urlencoded"}, {"malformed Content-Type, address in the query", "/auth/begin?address=" + url.QueryEscape(stranger), "", "application/x-www-form-urlencoded; charset"}, {"address only in the query string, empty body", "/auth/begin?address=" + url.QueryEscape(stranger), "", "application/x-www-form-urlencoded"}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { h, rec := signinApp(t) req := httptest.NewRequest(http.MethodPost, c.target, strings.NewReader(c.body)) req.Header.Set("Content-Type", c.contentType) req.Header.Set("Origin", testOrigin) h.ServeHTTP(httptest.NewRecorder(), req) if rec.dialed() { t.Errorf("an unadmitted address reached the classifier via %s: dialed %s", c.name, rec.where()) } }) } }) // A cross-site page can read the difference between an opaque redirect // and an opaque response, so owner and stranger must be identical // cross-origin too. Without this, removing csrf.Protect from MountAuth // — plausible, since Begin re-checks the origin itself — turns the // guard into an oracle: 403 for an owner, 303 for a stranger. t.Run("cross-origin answers the same for both", func(t *testing.T) { submit := func(address string) (int, string) { h, _ := signinApp(t) form := url.Values{"address": {address}} req := httptest.NewRequest(http.MethodPost, "/auth/begin", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Origin", "https://evil.example") rr := httptest.NewRecorder() h.ServeHTTP(rr, req) return rr.Code, rr.Header().Get("Location") } ownerCode, ownerLoc := submit(dialTestOwner) strangerCode, strangerLoc := submit("stranger@attacker.example") if ownerCode != strangerCode || ownerLoc != strangerLoc { t.Errorf("cross-origin is distinguishable: owner %d %q, stranger %d %q", ownerCode, ownerLoc, strangerCode, strangerLoc) } }) // The fail-closed branch was untested prose until this existed: the // gate always installed an owners list, so making the empty case fall // through to rastrillo stayed green while dialing for everyone. t.Run("no owners refuses rather than falling open", func(t *testing.T) { h, rec := signinAppWithOwners(t, nil) rr := beginSignin(t, h, "stranger@attacker.example") if rec.dialed() { t.Errorf("an app with no owners list dialed %s", rec.where()) } if rr.Code != http.StatusInternalServerError { t.Errorf("status = %d, want %d: an empty owners list is a wiring "+ "bug — LoadConfig refuses to boot without one — and must say "+ "so rather than refuse quietly", rr.Code, http.StatusInternalServerError) } }) }