oficina / memoria Public

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

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3import (
4 "database/sql"
5 "errors"
6 "log/slog"
7 "net/http"
8 "strings"
9
10 "amadan.net/oficina/estilo.git/chrome"
11 "amadan.net/oficina/estilo.git/sky"
12 "amadan.net/oficina/home/connect"
13 "amadan.net/rastrillo/rastrillo/auth"
14 "amadan.net/rastrillo/rastrillo/mail"
15)
16
17// signinPath is where every sign-in outcome lands. It must match the
18// SigninPath given to auth.New below, because beginGuard redirects here
19// itself: if the two disagree, the guard becomes distinguishable from the
20// real thing by its destination alone.
21const signinPath = "/signin"
22
23// beginGuard stops an anonymous visitor making this instance dial a
24// domain of their choosing.
25//
26// rastrillo's sign-in classifier fetches https://<the submitted address's
27// domain>/.well-known/keymail inside auth.Begin, BEFORE the Authorize
28// callback runs. So POST /auth/begin with any address makes memoria issue
29// an outbound request to any host — leaking the instance's existence, its
30// egress IP and a timing channel to anyone who can reach the sign-in
31// page. Not content, but §3.2 records it, and it is why the footer no
32// longer claims "nothing leaves this instance".
33//
34// The real fix is upstream, in rastrillo/auth and keymaildev/signin: the
35// classifier should not fetch before the app's admission predicate has
36// run, and the fetcher should refuse redirects and private address ranges
37// regardless. This guard is memoria's half. It is cheap here precisely
38// because slice 0 has no guests — admission IS the owners list, so
39// refusing a non-owner before the dial changes no behaviour that exists
40// today. When slice 1 brings guests, the predicate becomes the roster
41// (§4.3) and this still runs before any fetch.
42//
43// It renders the same outcome as a successful magic-link send, same
44// status and same destination, because a distinct rejection would turn
45// this guard into an owners-list oracle — worse than the dial it removes,
46// since the dial only confirms the instance exists while an oracle
47// confirms who owns it.
48//
49// THREE RESIDUALS, and the second one means the sentence above is an aim
50// rather than an achievement. Stated here because an earlier version of
51// this comment claimed the guard was not an oracle, and it is one.
52//
53// 1. An owner whose domain delegates to keymail is redirected to
54// keymail's host rather than to ?sent=1, so those two paths differ.
55//
56// 2. **Rate limiting distinguishes them after five submissions.** This
57// guard sits in front of rastrillo, so a refused address never
58// reaches signin's per-address budget while an admitted one does:
59// six tries at an owner's address yields ?err=rate, six at a
60// stranger's yields ?sent=1 every time. Enumeration is also
61// unthrottled, because a refused address consumes no per-IP budget
62// either. The budget lives inside signin's flow and is not reachable
63// from here, so closing this means either duplicating upstream's
64// window and count — coupling to a constant we do not own — or
65// fixing the ordering upstream, which is the same fix the SSRF needs.
66// Found by an adversarial mutation pass, 2026-09-05.
67//
68// 3. A `force=1` submission skips the classifier, so no dial happens —
69// but rastrillo still mails a magic link to whatever address was
70// given. The guard refuses those before Begin, so memoria is covered;
71// it is listed because "no dial" and "no outbound anything" are not
72// the same claim.
73//
74// All three are narrower than the status quo, where the dial happens for
75// every address at every domain, and all three close when the upstream
76// fix lands.
77func (a *app) beginGuard(origin string, next http.HandlerFunc) http.HandlerFunc {
78 return func(w http.ResponseWriter, r *http.Request) {
79 // Fail closed. An empty owners list is a wiring bug — LoadConfig
80 // refuses to boot without one — and the version of this that
81 // defaults to allowing is the version that dials for everyone
82 // while looking configured.
83 if len(a.owners) == 0 {
84 a.logger.Error("sign-in guard has no owners list; refusing")
85 http.Error(w, "sign-in is not configured", http.StatusInternalServerError)
86 return
87 }
88
89 // Cross-origin goes straight to rastrillo, which refuses it
90 // uniformly with a 403 before it dials anything. Deciding it here
91 // would answer differently for an owner and a stranger — a
92 // cross-site page can read that difference through an opaque
93 // redirect — and this guard exists to remove distinguishable
94 // outcomes, not to add one on another axis.
95 if o := r.Header.Get("Origin"); o != "" && o != origin {
96 next(w, r)
97 return
98 }
99
100 // Read the address EXACTLY as rastrillo does. This is the whole
101 // correctness argument of the guard, and the first version got it
102 // wrong twice in one line.
103 //
104 // It called r.ParseForm() and passed the request through on error.
105 // url.ParseQuery keeps every well-formed pair AND returns the
106 // first error, and ParseForm reports a query-string error even
107 // when the body parsed perfectly — while rastrillo's FormValue
108 // ignores the error and uses what parsed. So `POST /auth/begin?a;b=1`
109 // with an ordinary body skipped the check entirely and dialled.
110 // One character.
111 //
112 // It also read PostFormValue where rastrillo reads FormValue, so
113 // an address supplied only in the query string was invisible here
114 // and visible there.
115 //
116 // Both are the same mistake: a guard that reads a request
117 // differently from the thing it guards is not guarding it. There
118 // is no error branch now, because FormValue has no error to
119 // branch on — the two calls cannot disagree.
120 // A shape check first, answered the same for everyone: the door's
121 // form leaves validation to the server, and "paul@" or an empty
122 // field should say so rather than claim a link was sent. It looks
123 // only at the text, never at who owns what, so it tells nothing.
124 if !plausibleAddress(r.FormValue("address")) {
125 http.Redirect(w, r, signinPath+"?err=address", http.StatusSeeOther)
126 return
127 }
128 if !a.isOwner(normaliseAddress(r.FormValue("address"))) {
129 // No address in the log line, ever: §3.2, gate 4.
130 a.logger.Info("sign-in refused before classifier dial")
131 http.Redirect(w, r, signinPath+"?sent=1", http.StatusSeeOther)
132 return
133 }
134 next(w, r)
135 }
136}
137
138// AuthEnv is the sign-in configuration read from platform config.
139type AuthEnv struct {
140 Origin string
141 // InstanceKey seals auth's pending blob. auth.New refuses an empty
142 // one, and rightly: an empty input hashes to a fixed, publicly
143 // computable value identical across every deployment.
144 InstanceKey string
145 Mailer mail.Sender
146 Owners []string
147 HomeEnabled bool
148}
149
150// ParseOwners splits a comma-separated allowlist, lowercasing and
151// trimming. Slice 0's membership table is this list; slice 1 replaces it
152// with rows, because §4.3's admission gate has to admit whoever the
153// team's grants admit, and only a database knows that.
154func ParseOwners(raw string) []string {
155 var out []string
156 for _, part := range strings.Split(raw, ",") {
157 if addr := strings.ToLower(strings.TrimSpace(part)); addr != "" {
158 out = append(out, addr)
159 }
160 }
161 return out
162}
163
164// normaliseAddress is the one spelling rule. identity() lowercases and
165// trims what auth verified; the guard must do the same to what a form
166// submitted, or the two disagree about whether an address is an owner —
167// and a guard that disagrees with the thing it guards is the bug this
168// branch already shipped once, in a different clause.
169//
170// Addresses are case-sensitive before the @ per RFC 5321 and no mainstream
171// provider honours that, so this matches what an operator expects when
172// they type a list.
173func normaliseAddress(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
174
175// Admit is THE admission predicate: the one rastrillo runs at
176// verification, the one beginGuard runs before the classifier dials, and
177// the one requireOwner runs on the connector routes.
178//
179// A method on the app rather than a constructor over a list, because a
180// constructor gives a caller something to build twice — and it was built
181// twice. main.go passed `Admits(cfg.Owners)` to NewAuth and separately
182// called `SetOwners(cfg.Owners)`: two closures over two copies guarding
183// one rule, and a review's mutation (`Admits(nil)` in main) stayed green
184// because every test built its own. One list, one predicate, nothing to
185// pass a second version of.
186//
187// It normalises through normaliseAddress. The old constructor re-spelled
188// that inline, which is how a guard and the thing it guards start to
189// disagree.
190func (a *app) Admit(address string) bool {
191 return a.isOwner(normaliseAddress(address))
192}
193
194// NewAuth wires the sign-in flow. Exactly one per process: auth carries
195// rate-limiter state on the value, so one per request would get a fresh
196// empty budget every time and rate-limit nothing, silently.
197//
198// a supplies the three things auth asks of the app at redemption: the
199// admission gate (app.Admit), the door a verified but refused address is
200// shown (app.refused), and the door an emailed link opens on
201// (app.renderConfirm). The app rather than a predicate, for the reason
202// Admit gives: there is one of each, so there is nothing to pass a second
203// version of.
204func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, a *app) (*auth.Auth, error) {
205 if a == nil {
206 // A nil Authorize admits every verified address, which on a
207 // public hostname means anyone who can receive mail. Refusing
208 // beats defaulting, because a caller that forgot to pass one has
209 // a bug either way and this is the version that says so.
210 return nil, errors.New("sign-in: refusing to wire auth with no admission gate")
211 }
212 // Standalone, a verified link lands on /auth/landed, which goes on to
213 // the page the person was heading for (return.go).
214 landing := landedPath
215 if env.HomeEnabled {
216 landing = "/auth/local/complete"
217 }
218 au, err := auth.New(auth.Config{
219 DB: sqlDB,
220 Origin: env.Origin,
221 InstanceKey: env.InstanceKey,
222 Mailer: env.Mailer,
223 Authorize: a.Admit,
224 // A verified address Admit refuses gets the door, with the
225 // refusal on it, in auth's own 403 (app.refused) — not
226 // rastrillo's bare-text "verified but not admitted".
227 Refused: a.refused,
228 // GET /auth/verify spends nothing: it opens the door with one
229 // Sign in button that POSTs the link back, so a mail scanner's
230 // fetch cannot spend it (app.renderConfirm).
231 RenderConfirm: a.renderConfirm,
232 // No proof-of-work on the sign-in form. Memoria never had one:
233 // beginGuard refuses every non-owner before Begin (gate 33), and
234 // rastrillo's rate budgets stand behind it. The door is estilo's
235 // form, which renders no pow challenge. ProofOff keeps today's
236 // behaviour; wiring a pow.Guard is a decision of its own
237 // (schema, assets, a form that needs JavaScript).
238 ProofOff: true,
239 SigninPath: signinPath,
240 SignedInPath: landing,
241 Subject: chrome.EmailSubject("Memoria"),
242 Logger: logger,
243 })
244 if err != nil {
245 return nil, err
246 }
247 return au, nil
248}
249
250// signinBegin is POST /auth/begin's whole handler chain, in one place with
251// its own tests rather than composed inline in MountAuth.
252//
253// **The guard is what keeps a non-admitted address from being dialled**
254// (gate 33), and it runs before Begin either way. It is placed outermost
255// by preference, so a refused address never reaches the continuation code
256// at all — but the two orders are behaviourally identical, and a review of
257// the mutation that swaps them found nothing to tell them apart: the
258// continuation rewrites only an EXTERNAL 303, and the guard only ever
259// answers with a same-origin one. The order is a preference, not a guard.
260//
261// **Inside it, Begin is wrapped in Home's OAuthContinuation.** For a
262// Keymail address Begin answers 303 to the Keymail server's
263// /oauth/authorize — and CSP's form-action 'self' is enforced across a
264// form submission's WHOLE redirect chain, so the browser refused it and
265// sign-in with a Keymail address could not leave the sign-in page (seen
266// in Safari, 2026-09-29; the rastrillo docs describe the same refusal).
267// OAuthContinuation replaces exactly that 303 — after checking it is a
268// well-formed authorize URL for THIS origin and callback, with PKCE S256 —
269// with a no-store, no-referrer continuation page whose navigation is not a
270// form submission. Every other response, including the magic-link path's
271// same-origin redirect, passes through untouched. The CSP is not widened:
272// that would have to name every Keymail server, and the framework notes
273// that a federated address on an unlisted host would still be refused.
274func (a *app) signinBegin(origin string, begin http.HandlerFunc) http.HandlerFunc {
275 return a.beginGuard(origin, connect.OAuthContinuation(origin, begin, a.renderOAuth))
276}
277
278// renderOAuth renders the continuation page inside memoria's layout, footer
279// and AI state included — it is a page like any other.
280//
281// It wears the sign-in door, so the moment between the door and Keymail
282// is the same card on the same sky rather than a bare page. Home's
283// words and destination (connect.OAuthPage) and Home's meta refresh
284// (home-oauth-head) are unchanged; only the body is the door's notice.
285func (a *app) renderOAuth(w http.ResponseWriter, r *http.Request, destination string) {
286 data := connect.OAuthPage(destination)
287 render(w, "signin_oauth", oauthPage{
288 page: page{Frame: a.frameAs(r, viewer{}, frame{parts: []string{data.Title}, signinPage: true}), ModelOn: a.modelOn()},
289 Data: data,
290 Door: chrome.SignInNotice{
291 Icon: assets.Path("static/brand/icon.svg"), App: "Memoria", Sky: sky.For(r.Host),
292 Heading: data.Title, Body: "Taking you to Keymail to finish signing in.",
293 Link: &chrome.Link{Href: data.URL, Label: data.ActionLabel},
294 },
295 })
296}
297
298type oauthPage struct {
299 page
300 Data connect.OAuthData
301 Door chrome.SignInNotice
302}
303
304// plausibleAddress is the shape of an email address and nothing more:
305// something, an @, something, no spaces. The last @ splits it (a quoted
306// local part may hold one), and a dotless domain is still an address.
307func plausibleAddress(v string) bool {
308 v = strings.TrimSpace(v)
309 at := strings.LastIndexByte(v, '@')
310 return at > 0 && at < len(v)-1 && !strings.ContainsAny(v, " \t\r\n")
311}
312