| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "io" |
| 7 | "log/slog" |
| 8 | "net" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "net/url" |
| 12 | "path/filepath" |
| 13 | "strings" |
| 14 | "sync/atomic" |
| 15 | "testing" |
| 16 | |
| 17 | "amadan.net/rastrillo/rastrillo/db" |
| 18 | |
| 19 | memoria "amadan.net/oficina/memoria/internal/memoria" |
| 20 | ) |
| 21 | |
| 22 | // dialRecorder stands in for the network — BOTH halves of it. |
| 23 | // |
| 24 | // The classifier resolves a TXT record for the submitted domain first and |
| 25 | // only fetches https://<domain>/.well-known/keymail if that finds a |
| 26 | // keymail delegation. So the DNS query is the outbound step that happens |
| 27 | // for every address, and the HTTP fetch is the one that happens for some. |
| 28 | // A recorder watching only HTTP would have called a reserved-TLD address |
| 29 | // "no dial" while the process had already asked the world about it — |
| 30 | // which is what the first draft of this gate did, and what its two-sided |
| 31 | // half caught within a minute of being written. |
| 32 | type dialRecorder struct { |
| 33 | hits atomic.Int64 |
| 34 | last atomic.Value // string |
| 35 | } |
| 36 | |
| 37 | func (d *dialRecorder) note(what string) { |
| 38 | d.hits.Add(1) |
| 39 | d.last.Store(what) |
| 40 | } |
| 41 | |
| 42 | func (d *dialRecorder) RoundTrip(r *http.Request) (*http.Response, error) { |
| 43 | d.note("HTTP " + r.URL.String()) |
| 44 | return nil, errRecorded |
| 45 | } |
| 46 | |
| 47 | func (d *dialRecorder) dialed() bool { return d.hits.Load() > 0 } |
| 48 | |
| 49 | func (d *dialRecorder) where() string { |
| 50 | if s, ok := d.last.Load().(string); ok { |
| 51 | return s |
| 52 | } |
| 53 | return "(none)" |
| 54 | } |
| 55 | |
| 56 | // errRecorded is returned instead of performing any request. The value is |
| 57 | // irrelevant; being called at all is the finding. |
| 58 | var errRecorded = errors.New("memoriatest: outbound request recorded, not sent") |
| 59 | |
| 60 | // stubMailer accepts a magic link and discards it. Never a real sender: |
| 61 | // the link is a live credential and this test does not need one sent. |
| 62 | type stubMailer struct{ sent atomic.Int64 } |
| 63 | |
| 64 | func (m *stubMailer) Send(ctx context.Context, to, subject, body string) error { |
| 65 | m.sent.Add(1) |
| 66 | return nil |
| 67 | } |
| 68 | |
| 69 | const dialTestOwner = "owner@example.test" |
| 70 | |
| 71 | // signinApp builds the app the way main.go does — production constructor, |
| 72 | // real auth, real owners list — and points the whole process's HTTP |
| 73 | // transport at a recorder. |
| 74 | func signinApp(t *testing.T) (http.Handler, *dialRecorder) { |
| 75 | t.Helper() |
| 76 | return signinAppWithOwners(t, []string{dialTestOwner}) |
| 77 | } |
| 78 | |
| 79 | // signinAppWithOwners is signinApp with the owners list under the test's |
| 80 | // control, so the fail-closed branch — an app wired with no owners at all |
| 81 | // — can be reached. NewAuth still gets a real predicate; it refuses to |
| 82 | // construct without one. |
| 83 | func signinAppWithOwners(t *testing.T, owners []string) (http.Handler, *dialRecorder) { |
| 84 | t.Helper() |
| 85 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 86 | d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger) |
| 87 | if err != nil { |
| 88 | t.Fatalf("db.Open: %v", err) |
| 89 | } |
| 90 | t.Cleanup(func() { d.Close() }) |
| 91 | |
| 92 | mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger) |
| 93 | if err != nil { |
| 94 | t.Fatalf("App: %v", err) |
| 95 | } |
| 96 | au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ |
| 97 | Origin: testOrigin, |
| 98 | InstanceKey: "signin-dial-gate-instance-key", |
| 99 | Owners: []string{dialTestOwner}, |
| 100 | Mailer: &stubMailer{}, |
| 101 | }, logger, a) |
| 102 | if err != nil { |
| 103 | t.Fatalf("NewAuth: %v", err) |
| 104 | } |
| 105 | a.UseAuth(au) |
| 106 | a.SetOwners(owners) |
| 107 | a.MountAuth(mux, testOrigin) |
| 108 | |
| 109 | rec := &dialRecorder{} |
| 110 | |
| 111 | prevTransport := http.DefaultTransport |
| 112 | http.DefaultTransport = rec |
| 113 | t.Cleanup(func() { http.DefaultTransport = prevTransport }) |
| 114 | |
| 115 | // PreferGo routes resolution through Go's own resolver, which calls |
| 116 | // Dial — the only hook that sees a DNS query from inside the process. |
| 117 | prevResolver := net.DefaultResolver |
| 118 | net.DefaultResolver = &net.Resolver{ |
| 119 | PreferGo: true, |
| 120 | Dial: func(ctx context.Context, network, address string) (net.Conn, error) { |
| 121 | rec.note("DNS " + address) |
| 122 | return nil, errRecorded |
| 123 | }, |
| 124 | } |
| 125 | t.Cleanup(func() { net.DefaultResolver = prevResolver }) |
| 126 | |
| 127 | return mux, rec |
| 128 | } |
| 129 | |
| 130 | func beginSignin(t *testing.T, h http.Handler, address string) *httptest.ResponseRecorder { |
| 131 | t.Helper() |
| 132 | form := url.Values{"address": {address}} |
| 133 | req := httptest.NewRequest(http.MethodPost, "/auth/begin", strings.NewReader(form.Encode())) |
| 134 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 135 | req.Header.Set("Origin", testOrigin) |
| 136 | rr := httptest.NewRecorder() |
| 137 | h.ServeHTTP(rr, req) |
| 138 | return rr |
| 139 | } |
| 140 | |
| 141 | // Gate 33 (spec §3.2, §4.3): an anonymous visitor cannot make this |
| 142 | // instance dial a domain of their choosing. |
| 143 | // |
| 144 | // rastrillo's sign-in classifier fetches the submitted address's domain |
| 145 | // inside auth.Begin, before the Authorize callback runs. memoria's |
| 146 | // beginGuard runs the owners predicate first, so a non-owner's address |
| 147 | // never reaches the classifier. |
| 148 | // |
| 149 | // The gate is deliberately TWO-SIDED, and that is the part worth reading. |
| 150 | // Asserting only "a non-owner causes no dial" is satisfied by a guard |
| 151 | // that refuses everybody, by auth being unwired, by the route being |
| 152 | // absent, and by the transport never being consulted at all — four ways |
| 153 | // to be green while the app is broken or the test is measuring nothing. |
| 154 | // So the owner case asserts the dial DOES happen, which proves the |
| 155 | // recorder is installed, the route is reachable, the classifier still |
| 156 | // runs for an admitted address, and the guard discriminates rather than |
| 157 | // blocks. |
| 158 | // |
| 159 | // MUTATION-TESTED 2026-09-05, in two passes, and the second is why this |
| 160 | // file is worth reading. |
| 161 | // |
| 162 | // The author's five, each verified red: |
| 163 | // |
| 164 | // M1 register a.auth.Begin bare, no guard -> non-owner case red |
| 165 | // M2 guard admits everyone -> non-owner case red |
| 166 | // M3 guard refuses everyone -> owner case red |
| 167 | // M4 guard returns 403 instead of ?sent=1 -> non-owner case red |
| 168 | // M5 neither recorder installed -> owner case red |
| 169 | // |
| 170 | // Then an adversarial pass by someone who did not write the gate, per |
| 171 | // AGENTS.md. It found the five above were the cases already defended |
| 172 | // against, and that **the gate was green over two live bypasses in the |
| 173 | // shipped guard** — not mutations, the actual code: |
| 174 | // |
| 175 | // - `POST /auth/begin?a;b=1` with an ordinary body reached the |
| 176 | // classifier. The guard called ParseForm and passed the request |
| 177 | // through on error; ParseForm reports a query-string error even when |
| 178 | // the body parsed fine, and rastrillo's FormValue ignores it and uses |
| 179 | // what parsed. One character defeated the check. |
| 180 | // - An address supplied only in the query string was invisible to the |
| 181 | // guard's PostFormValue and visible to rastrillo's FormValue. |
| 182 | // |
| 183 | // Both are one mistake — a guard reading the request differently from the |
| 184 | // thing it guards — and the subtests below are the four payloads that |
| 185 | // proved it, each confirmed to go red against the old code with the |
| 186 | // resolver being asked for the attacker's domain. |
| 187 | // |
| 188 | // The same pass found three more green mutations, now closed: SigninPath |
| 189 | // drifting from the guard's own constant (they share it), dropping |
| 190 | // csrf.Protect from MountAuth (cross-origin now answers identically), and |
| 191 | // a nil predicate falling open (asserted). |
| 192 | // |
| 193 | // STILL OPEN, and stated rather than quietly carried: rate limiting makes |
| 194 | // owner and non-owner distinguishable after five submissions, because the |
| 195 | // guard sits in front of signin's per-address budget. See beginGuard's |
| 196 | // residual 2. Not closable from here. |
| 197 | func TestSigninDoesNotDialForANonOwner(t *testing.T) { |
| 198 | t.Run("a non-owner never reaches the classifier", func(t *testing.T) { |
| 199 | h, rec := signinApp(t) |
| 200 | rr := beginSignin(t, h, "stranger@attacker.example") |
| 201 | |
| 202 | if rec.dialed() { |
| 203 | t.Errorf("an unadmitted address made the instance dial %s", rec.where()) |
| 204 | } |
| 205 | if rr.Code != http.StatusSeeOther { |
| 206 | t.Errorf("status = %d, want %d — a non-owner must get the same "+ |
| 207 | "outcome as a sent link, not a distinguishable one", |
| 208 | rr.Code, http.StatusSeeOther) |
| 209 | } |
| 210 | if got := rr.Header().Get("Location"); got != "/signin?sent=1" { |
| 211 | t.Errorf("Location = %q, want %q: a distinct destination turns "+ |
| 212 | "this guard into an owners-list oracle, which is a worse "+ |
| 213 | "leak than the dial it removes", got, "/signin?sent=1") |
| 214 | } |
| 215 | }) |
| 216 | |
| 217 | t.Run("an owner still reaches it, or this gate proves nothing", func(t *testing.T) { |
| 218 | h, rec := signinApp(t) |
| 219 | beginSignin(t, h, dialTestOwner) |
| 220 | |
| 221 | if !rec.dialed() { |
| 222 | t.Error("the classifier was never reached for an admitted address: " + |
| 223 | "either the guard refuses everybody, or the recorder is not " + |
| 224 | "installed, or the route is gone — in all three the other " + |
| 225 | "half of this gate is measuring nothing") |
| 226 | } |
| 227 | }) |
| 228 | |
| 229 | // A guard that reads the request differently from the thing it guards |
| 230 | // is not guarding it. Every case below defeated the first version, |
| 231 | // which called ParseForm and passed the request through on error — |
| 232 | // and ParseForm reports a query-string error even when the body is |
| 233 | // fine, while rastrillo's FormValue ignores it and uses what parsed. |
| 234 | // |
| 235 | // Found by an adversarial mutation pass, 2026-09-05. These are not |
| 236 | // mutations: they were live bypasses of shipped code. |
| 237 | t.Run("a malformed request does not skip the check", func(t *testing.T) { |
| 238 | const stranger = "stranger@attacker.example" |
| 239 | cases := []struct { |
| 240 | name, target, body, contentType string |
| 241 | }{ |
| 242 | {"semicolon in the query string", "/auth/begin?a;b=1", |
| 243 | "address=" + stranger, "application/x-www-form-urlencoded"}, |
| 244 | {"semicolon in the body", "/auth/begin", |
| 245 | "x;y=1&address=" + stranger, "application/x-www-form-urlencoded"}, |
| 246 | {"bad percent-escape in the body", "/auth/begin", |
| 247 | "x=%zz&address=" + stranger, "application/x-www-form-urlencoded"}, |
| 248 | {"malformed Content-Type, address in the query", |
| 249 | "/auth/begin?address=" + url.QueryEscape(stranger), |
| 250 | "", "application/x-www-form-urlencoded; charset"}, |
| 251 | {"address only in the query string, empty body", |
| 252 | "/auth/begin?address=" + url.QueryEscape(stranger), |
| 253 | "", "application/x-www-form-urlencoded"}, |
| 254 | } |
| 255 | for _, c := range cases { |
| 256 | t.Run(c.name, func(t *testing.T) { |
| 257 | h, rec := signinApp(t) |
| 258 | req := httptest.NewRequest(http.MethodPost, c.target, strings.NewReader(c.body)) |
| 259 | req.Header.Set("Content-Type", c.contentType) |
| 260 | req.Header.Set("Origin", testOrigin) |
| 261 | h.ServeHTTP(httptest.NewRecorder(), req) |
| 262 | if rec.dialed() { |
| 263 | t.Errorf("an unadmitted address reached the classifier via %s: dialed %s", |
| 264 | c.name, rec.where()) |
| 265 | } |
| 266 | }) |
| 267 | } |
| 268 | }) |
| 269 | |
| 270 | // A cross-site page can read the difference between an opaque redirect |
| 271 | // and an opaque response, so owner and stranger must be identical |
| 272 | // cross-origin too. Without this, removing csrf.Protect from MountAuth |
| 273 | // — plausible, since Begin re-checks the origin itself — turns the |
| 274 | // guard into an oracle: 403 for an owner, 303 for a stranger. |
| 275 | t.Run("cross-origin answers the same for both", func(t *testing.T) { |
| 276 | submit := func(address string) (int, string) { |
| 277 | h, _ := signinApp(t) |
| 278 | form := url.Values{"address": {address}} |
| 279 | req := httptest.NewRequest(http.MethodPost, "/auth/begin", strings.NewReader(form.Encode())) |
| 280 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 281 | req.Header.Set("Origin", "https://evil.example") |
| 282 | rr := httptest.NewRecorder() |
| 283 | h.ServeHTTP(rr, req) |
| 284 | return rr.Code, rr.Header().Get("Location") |
| 285 | } |
| 286 | ownerCode, ownerLoc := submit(dialTestOwner) |
| 287 | strangerCode, strangerLoc := submit("stranger@attacker.example") |
| 288 | if ownerCode != strangerCode || ownerLoc != strangerLoc { |
| 289 | t.Errorf("cross-origin is distinguishable: owner %d %q, stranger %d %q", |
| 290 | ownerCode, ownerLoc, strangerCode, strangerLoc) |
| 291 | } |
| 292 | }) |
| 293 | |
| 294 | // The fail-closed branch was untested prose until this existed: the |
| 295 | // gate always installed an owners list, so making the empty case fall |
| 296 | // through to rastrillo stayed green while dialing for everyone. |
| 297 | t.Run("no owners refuses rather than falling open", func(t *testing.T) { |
| 298 | h, rec := signinAppWithOwners(t, nil) |
| 299 | rr := beginSignin(t, h, "stranger@attacker.example") |
| 300 | if rec.dialed() { |
| 301 | t.Errorf("an app with no owners list dialed %s", rec.where()) |
| 302 | } |
| 303 | if rr.Code != http.StatusInternalServerError { |
| 304 | t.Errorf("status = %d, want %d: an empty owners list is a wiring "+ |
| 305 | "bug — LoadConfig refuses to boot without one — and must say "+ |
| 306 | "so rather than refuse quietly", rr.Code, http.StatusInternalServerError) |
| 307 | } |
| 308 | }) |
| 309 | } |
| 310 | |