oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoriatest
2
3import (
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.
32type dialRecorder struct {
33 hits atomic.Int64
34 last atomic.Value // string
35}
36
37func (d *dialRecorder) note(what string) {
38 d.hits.Add(1)
39 d.last.Store(what)
40}
41
42func (d *dialRecorder) RoundTrip(r *http.Request) (*http.Response, error) {
43 d.note("HTTP " + r.URL.String())
44 return nil, errRecorded
45}
46
47func (d *dialRecorder) dialed() bool { return d.hits.Load() > 0 }
48
49func (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.
58var 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.
62type stubMailer struct{ sent atomic.Int64 }
63
64func (m *stubMailer) Send(ctx context.Context, to, subject, body string) error {
65 m.sent.Add(1)
66 return nil
67}
68
69const 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.
74func 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.
83func 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
130func 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.
197func 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