๐ค feat: refuse the sign-in dial before it happens, and gate 27
POST /auth/begin now runs the admission predicate before rastrillo's classifier resolves anything, so an unadmitted address produces no outbound traffic at all. ยง3.2 has recorded since the code review that an anonymous visitor can make this instance dial any domain they type, leaking its existence, its egress IP and a timing channel โ and that this is why the footer stopped claiming "nothing leaves this instance". The guard renders the SAME outcome as a successful magic-link send โ same 303, same /signin?sent=1 โ because a distinct rejection would turn it into an owners-list oracle, which is worse than the dial it removes: the dial confirms an instance exists, an oracle confirms who owns it. Fable's point and it is the right one. Two things found by building it rather than by reasoning about it. The classifier resolves a TXT record BEFORE it fetches .well-known, and only fetches if the TXT lookup finds a 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. The first draft of the gate watched HTTP only, and called a reserved-TLD address "no dial" while the process had already asked the world about it. The gate now installs both an http.DefaultTransport recorder and a PreferGo net.DefaultResolver Dial hook. And the gate is two-sided. 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 no recorder being installed โ four ways to be green while measuring nothing. The owner case asserts the dial DOES happen, and it caught the DNS mistake above within a minute. Five mutations, each verified red and reverted: no guard, guard admits everyone, guard refuses everyone, guard returns 403 instead of ?sent=1, neither recorder installed. Author-chosen, which ยง1.7 rates as the weak kind, so it goes to a reviewer to pick its own. UseAuth now takes the admission predicate alongside auth, so a caller cannot wire one without the other โ beginGuard fails closed on a nil predicate, and a two-call version would compile with the second missing. What is NOT fixed, and is not memoria's: the classifier still resolves and fetches before Authorize for every rastrillo app, and the fetcher's own hardening โ port pinning, redirect refusal, private-range refusal โ is unread. One residual here too: an owner on a keymail-delegated domain is redirected to keymail's host rather than ?sent=1, so those two paths still differ. Narrower than what it replaces; closes when upstream does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H3VKtXnq6fSmM2F11Bjcsy
6 files changed,
+316
−4
cmd/memoria/main.go+1 −1docs/superpowers/specs/2026-09-03-memoria-design.md+31 −1internal/memoria/app.go+3 −1internal/memoria/auth.go+65 −0internal/memoria/handlers.go+18 −1internal/memoriatest/signindial_test.go+198 −0
diff --git a/cmd/memoria/main.go b/cmd/memoria/main.go| index 5001c06..90dc3ae 100644 |
| --- a/cmd/memoria/main.go |
| +++ b/cmd/memoria/main.go |
| @@ -69,7 +69,7 @@ func main() { |
| logger.Error("wire sign-in", "err", err) |
| os.Exit(1) |
| } |
| - app.UseAuth(a) |
| + app.UseAuth(a, memoria.Admits(cfg.Owners)) |
| app.MountAuth(mux, cfg.Origin) |
| opts.Mux = mux |
diff --git a/docs/superpowers/specs/2026-09-03-memoria-design.md b/docs/superpowers/specs/2026-09-03-memoria-design.md| index 3ad36a6..01c931a 100644 |
| --- a/docs/superpowers/specs/2026-09-03-memoria-design.md |
| +++ b/docs/superpowers/specs/2026-09-03-memoria-design.md |
| @@ -1948,6 +1948,22 @@ renders on its own origin. The home never sees `q`. |
| previous request's result set, expect red. Slice 2. |
| --- |
| +27. **An anonymous visitor cannot make this instance dial a domain of |
| + their choosing.** Built 2026-09-05 as `TestSigninDoesNotDialForANonOwner`. |
| + `POST /auth/begin` runs the admission predicate **before** rastrillo's |
| + classifier resolves anything, so an unadmitted address produces no |
| + outbound traffic at all โ and the gate watches **DNS as well as HTTP**, |
| + because the classifier resolves a TXT record first and only fetches |
| + `.well-known/keymail` if that finds a delegation. A recorder watching |
| + HTTP alone called a reserved-TLD address "no dial" while the process |
| + had already asked the world about it. |
| + **Two-sided on purpose:** the non-owner case asserts no dial *and* the |
| + exact `?sent=1` destination, because a distinct rejection would make |
| + the guard an owners-list oracle; the owner case asserts the dial does |
| + happen, without which a guard that refuses everybody, an unwired auth, |
| + a missing route and an uninstalled recorder are all green. Five |
| + mutations, each verified red โ author-chosen, which AGENTS.md rates as |
| + the weak kind, so it is going to a reviewer for its own. |
| ## 11. Sequencing |
| @@ -2079,7 +2095,21 @@ rows. |
| because memoria depends on those specs being current, and because |
| "nobody has built the gate yet" is exactly the sentence that lets a |
| currency rule rot. |
| -- **The sign-in dial (ยง3.2) needs a decision.** The fetch lives in |
| +- **The sign-in dial: memoria's half is closed, the upstream half is |
| + not.** `POST /auth/begin` now runs the admission predicate before the |
| + classifier resolves anything (gate 27), so an unadmitted address causes |
| + no DNS query and no fetch. **What remains is not memoria's:** the |
| + classifier in `rastrillo/auth` and `keymaildev/signin` still resolves |
| + and fetches before the app's `Authorize` callback for every app that |
| + uses it, and the fetcher's own hardening โ port pinning, redirect |
| + refusal, private-range refusal โ is unread. Every rastrillo app has this |
| + until it is fixed there. One residual here too, stated in the guard's |
| + own comment: an *owner* whose domain delegates to keymail is redirected |
| + to keymail's host rather than to `?sent=1`, so the two paths still |
| + differ for keymail-delegated domains. Narrower than the status quo it |
| + replaced, and it closes when the upstream fix lands. |
| +- ~~**The sign-in dial (ยง3.2) needs a decision.**~~ *Superseded by the |
| + item above; the original text follows.* The fetch lives in |
| `rastrillo/auth` and `keymaildev/signin`, not here. memoria could gate |
| `/auth/begin` on the owners list before rastrillo sees the address, |
| which is small but changes guest sign-in that slice 1 will want; or it |
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index e4e8ec6..f14dd70 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -76,7 +76,9 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| func (a *app) MountAuth(mux *http.ServeMux, origin string) { |
| r := chi.NewRouter() |
| r.Use(csrf.Protect(origin)) |
| - r.Post("/auth/begin", a.auth.Begin) |
| + // Guarded, not bare: the classifier inside Begin dials the submitted |
| + // address's domain before Authorize runs. beginGuard says why. |
| + r.Post("/auth/begin", a.beginGuard(a.auth.Begin)) |
| r.Get("/auth/callback", a.auth.Callback) |
| r.Get("/auth/verify", a.auth.Verify) |
| r.Post("/signout", a.auth.Signout) |
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go| index 54c359c..1225506 100644 |
| --- a/internal/memoria/auth.go |
| +++ b/internal/memoria/auth.go |
| @@ -4,12 +4,77 @@ import ( |
| "database/sql" |
| "errors" |
| "log/slog" |
| + "net/http" |
| "strings" |
| "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/mail" |
| ) |
| +// signinPath is where every sign-in outcome lands. It must match the |
| +// SigninPath given to auth.New below, because beginGuard redirects here |
| +// itself: if the two disagree, the guard becomes distinguishable from the |
| +// real thing by its destination alone. |
| +const signinPath = "/signin" |
| + |
| +// beginGuard stops an anonymous visitor making this instance dial a |
| +// domain of their choosing. |
| +// |
| +// rastrillo's sign-in classifier fetches https://<the submitted address's |
| +// domain>/.well-known/keymail inside auth.Begin, BEFORE the Authorize |
| +// callback runs. So POST /auth/begin with any address makes memoria issue |
| +// an outbound request to any host โ leaking the instance's existence, its |
| +// egress IP and a timing channel to anyone who can reach the sign-in |
| +// page. Not content, but ยง3.2 records it, and it is why the footer no |
| +// longer claims "nothing leaves this instance". |
| +// |
| +// The real fix is upstream, in rastrillo/auth and keymaildev/signin: the |
| +// classifier should not fetch before the app's admission predicate has |
| +// run, and the fetcher should refuse redirects and private address ranges |
| +// regardless. This guard is memoria's half. It is cheap here precisely |
| +// because slice 0 has no guests โ admission IS the owners list, so |
| +// refusing a non-owner before the dial changes no behaviour that exists |
| +// today. When slice 1 brings guests, the predicate becomes the roster |
| +// (ยง4.3) and this still runs before any fetch. |
| +// |
| +// It renders the same outcome as a successful magic-link send, same |
| +// status and same destination, because a distinct rejection would turn |
| +// this guard into an owners-list oracle โ worse than the dial it removes, |
| +// since the dial only confirms the instance exists while an oracle |
| +// confirms who owns it. |
| +// |
| +// Residual, stated rather than left to be discovered: an owner whose |
| +// domain delegates to keymail is redirected to keymail's host rather than |
| +// to ?sent=1, so for keymail-delegated domains the two paths still |
| +// differ. That is strictly narrower than the status quo, where the dial |
| +// happens for every address at every domain, and it closes when the |
| +// upstream fix lands. |
| +func (a *app) beginGuard(next http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + // Fail closed. A missing predicate is a wiring bug, and the |
| + // version of this that defaults to allowing is the version that |
| + // dials for everyone while looking configured. |
| + if a.admits == nil { |
| + a.logger.Error("sign-in guard has no admission predicate; refusing") |
| + http.Error(w, "sign-in is not configured", http.StatusInternalServerError) |
| + return |
| + } |
| + if err := r.ParseForm(); err != nil { |
| + // Let rastrillo produce its own error for a malformed body |
| + // rather than inventing a second shape for one. |
| + next(w, r) |
| + return |
| + } |
| + if !a.admits(r.PostFormValue("address")) { |
| + // No address in the log line, ever: ยง3.2, gate 4. |
| + a.logger.Info("sign-in refused before classifier dial") |
| + http.Redirect(w, r, signinPath+"?sent=1", http.StatusSeeOther) |
| + return |
| + } |
| + next(w, r) |
| + } |
| +} |
| + |
| // AuthEnv is the sign-in configuration read from platform config. |
| type AuthEnv struct { |
| Origin string |
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go| index 54758ea..da4f33c 100644 |
| --- a/internal/memoria/handlers.go |
| +++ b/internal/memoria/handlers.go |
| @@ -27,6 +27,14 @@ type app struct { |
| store *Store |
| auth *auth.Auth |
| + // admits is the same predicate auth.Config.Authorize gets, held here |
| + // as well because beginGuard has to consult it BEFORE rastrillo does |
| + // โ the whole point is to run it earlier than auth.Begin would. |
| + // Duplicating a reference is the cost of that; the two must be the |
| + // same function value, which UseAuth's signature enforces by taking |
| + // them together. |
| + admits func(string) bool |
| + |
| // allowUnauthenticated is set only by AppForTest. Production cannot |
| // set it: cmd/memoria never calls that constructor, and |
| // TestProductionDoesNotUseTheTestConstructor fails if it starts to. |
| @@ -36,7 +44,16 @@ type app struct { |
| // UseAuth attaches auth after construction. App builds the router, and |
| // the router needs auth's handlers, but auth needs the admission gate |
| // that only App can supply โ so the knot is untied by attaching late. |
| -func (a *app) UseAuth(x *auth.Auth) { a.auth = x } |
| +// |
| +// It takes the admission predicate alongside, rather than letting a |
| +// caller set it separately, because beginGuard fails closed on a nil one: |
| +// a two-call version would compile with the second call missing and the |
| +// sign-in route would then refuse everybody, loudly but pointlessly. One |
| +// call means the compiler asks for both. |
| +func (a *app) UseAuth(x *auth.Auth, admits func(string) bool) { |
| + a.auth = x |
| + a.admits = admits |
| +} |
| // page is the data every template gets. Chunks and Signed are on it |
| // because the layout states them on every screen: a corpus count is how |
diff --git a/internal/memoriatest/signindial_test.go b/internal/memoriatest/signindial_test.go| new file mode 100644 |
| index 0000000..ea96cfa |
| --- /dev/null |
| +++ b/internal/memoriatest/signindial_test.go |
| @@ -0,0 +1,198 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "io" |
| + "log/slog" |
| + "net" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| + "sync/atomic" |
| + "testing" |
| + |
| + "github.com/carlosframework/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://<domain>/.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() |
| + 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) |
| + } |
| + owners := []string{dialTestOwner} |
| + au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ |
| + Origin: testOrigin, |
| + InstanceKey: "signin-dial-gate-instance-key", |
| + Owners: owners, |
| + Mailer: &stubMailer{}, |
| + }, logger, memoria.Admits(owners)) |
| + if err != nil { |
| + t.Fatalf("NewAuth: %v", err) |
| + } |
| + a.UseAuth(au, memoria.Admits(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 27 (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, five, each verified red and reverted: |
| +// |
| +// 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 |
| +// |
| +// M4 is the oracle case and the reason the non-owner half asserts a |
| +// Location and not merely a status. M5 is the check on the gate itself: |
| +// with no recorder there is nothing to observe, and only the owner half |
| +// notices โ a one-sided version of this test would have passed M3 and M5 |
| +// both, while measuring nothing at all. |
| +// |
| +// These five were chosen by the author, which AGENTS.md says is the weak |
| +// kind: they are the cases already defended against while writing it. The |
| +// gate has been handed to a reviewer to pick its own. |
| +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") |
| + } |
| + }) |
| +} |