| 1 | package memoria |
| 2 | |
| 3 | import ( |
| 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. |
| 21 | const 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. |
| 77 | func (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. |
| 139 | type 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. |
| 154 | func 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. |
| 173 | func 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. |
| 190 | func (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. |
| 204 | func 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. |
| 274 | func (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. |
| 285 | func (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 | |
| 298 | type 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. |
| 307 | func 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 | |