| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "html" |
| 6 | "io" |
| 7 | "log/slog" |
| 8 | "mime/multipart" |
| 9 | "net/http" |
| 10 | "net/http/httptest" |
| 11 | "net/url" |
| 12 | "regexp" |
| 13 | "strings" |
| 14 | "testing" |
| 15 | |
| 16 | "amadan.net/rastrillo/rastrillo" |
| 17 | "amadan.net/rastrillo/rastrillo/db" |
| 18 | |
| 19 | memoria "amadan.net/oficina/memoria/internal/memoria" |
| 20 | ) |
| 21 | |
| 22 | // servedApp is the production app behind rastrillo's production stack: |
| 23 | // memoria.App with real sign-in, mounted as main.go mounts it, completed |
| 24 | // by memoria.ServeOptions and wrapped by rastrillo.Handler — so the CSP, |
| 25 | // the framing headers and the framework's own same-origin checks are the |
| 26 | // ones a deployed instance sends. owners is the admission list; mail is |
| 27 | // captured. |
| 28 | func servedApp(t *testing.T, dbPath string, owners []string) (http.Handler, *capturingMailer) { |
| 29 | t.Helper() |
| 30 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 31 | d, err := db.Open(dbPath, logger) |
| 32 | if err != nil { |
| 33 | t.Fatalf("db.Open: %v", err) |
| 34 | } |
| 35 | t.Cleanup(func() { d.Close() }) |
| 36 | mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger) |
| 37 | if err != nil { |
| 38 | t.Fatalf("App: %v", err) |
| 39 | } |
| 40 | mailer := &capturingMailer{} |
| 41 | a.SetOwners(owners) |
| 42 | au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ |
| 43 | Origin: testOrigin, InstanceKey: "admission-gate-instance-key", |
| 44 | Owners: owners, Mailer: mailer, |
| 45 | }, logger, a) |
| 46 | if err != nil { |
| 47 | t.Fatalf("NewAuth: %v", err) |
| 48 | } |
| 49 | a.UseAuth(au) |
| 50 | a.MountAuth(mux, testOrigin) |
| 51 | h := production(t, a.ServeOptions(rastrillo.Options{Logger: logger}, mux)) |
| 52 | offlineResolver(t) |
| 53 | return h, mailer |
| 54 | } |
| 55 | |
| 56 | // production wraps opts in rastrillo.Handler, the stack Serve runs. |
| 57 | func production(t *testing.T, opts rastrillo.Options) http.Handler { |
| 58 | t.Helper() |
| 59 | h, done, err := rastrillo.Handler(opts) |
| 60 | if err != nil { |
| 61 | t.Fatalf("rastrillo.Handler: %v", err) |
| 62 | } |
| 63 | if done != nil { |
| 64 | t.Cleanup(func() { _ = done() }) |
| 65 | } |
| 66 | return h |
| 67 | } |
| 68 | |
| 69 | // defaultCSP is rastrillo's own policy at the pinned version, read off a |
| 70 | // bare handler rather than copied, so a tightening upstream is what the |
| 71 | // tests below expect next time without anybody editing them. |
| 72 | func defaultCSP(t *testing.T) string { |
| 73 | t.Helper() |
| 74 | mux := http.NewServeMux() |
| 75 | mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {}) |
| 76 | rec := httptest.NewRecorder() |
| 77 | production(t, rastrillo.Options{Mux: mux}).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/", nil)) |
| 78 | csp := rec.Header().Get("Content-Security-Policy") |
| 79 | if csp == "" { |
| 80 | t.Fatal("a bare rastrillo handler sends no CSP; this measures nothing") |
| 81 | } |
| 82 | return csp |
| 83 | } |
| 84 | |
| 85 | // sessionSet reports whether rec set a live session cookie. |
| 86 | func sessionSet(rec *httptest.ResponseRecorder) bool { |
| 87 | for _, c := range rec.Result().Cookies() { |
| 88 | if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" && c.MaxAge >= 0 { |
| 89 | return true |
| 90 | } |
| 91 | } |
| 92 | return false |
| 93 | } |
| 94 | |
| 95 | // rastrillo #64, end to end through the production stack: opening an |
| 96 | // emailed link spends nothing; the confirm door's Sign in button does. |
| 97 | // |
| 98 | // begin → mail → the link opened twice (a mail scanner's fetch, then the |
| 99 | // person's: both the door, neither a session) → a cross-site POST and a |
| 100 | // POST with no browser evidence, both refused WITHOUT spending the link → |
| 101 | // the door's own POST signs in and the session opens the index → the same |
| 102 | // token again is ?err=expired. |
| 103 | func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) { |
| 104 | h, mailer := servedApp(t, tempDB(t), []string{chromeOwner}) |
| 105 | c := newClient(t, h) |
| 106 | if rec := c.do(http.MethodPost, testOrigin+"/auth/begin", url.Values{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther { |
| 107 | t.Fatalf("POST /auth/begin = %d", rec.Code) |
| 108 | } |
| 109 | link := mailer.link() |
| 110 | if link == "" { |
| 111 | t.Fatalf("no magic link was sent: %q", mailer.text()) |
| 112 | } |
| 113 | u, err := url.Parse(link) |
| 114 | if err != nil { |
| 115 | t.Fatal(err) |
| 116 | } |
| 117 | |
| 118 | // Twice: a scanner, then the person. A GET mints nothing. |
| 119 | var action, token string |
| 120 | for _, who := range []*client{newClient(t, h), c} { |
| 121 | rec := who.get(testOrigin + u.RequestURI()) |
| 122 | if rec.Code != http.StatusOK { |
| 123 | t.Fatalf("GET the link = %d, want the confirm door", rec.Code) |
| 124 | } |
| 125 | if sessionSet(rec) { |
| 126 | t.Fatal("opening the link minted a session; the GET must spend nothing") |
| 127 | } |
| 128 | action, token = confirmForm(t, rec.Body.String()) |
| 129 | } |
| 130 | if action != "/auth/verify" || token != u.Query().Get("token") { |
| 131 | t.Fatalf("the door posts %q with token %q, want /auth/verify and the link's own token", action, token) |
| 132 | } |
| 133 | |
| 134 | post := func(origin, site string) *httptest.ResponseRecorder { |
| 135 | req := httptest.NewRequest(http.MethodPost, testOrigin+action, strings.NewReader(url.Values{"token": {token}}.Encode())) |
| 136 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 137 | if origin != "" { |
| 138 | req.Header.Set("Origin", origin) |
| 139 | } |
| 140 | if site != "" { |
| 141 | req.Header.Set("Sec-Fetch-Site", site) |
| 142 | } |
| 143 | rec := httptest.NewRecorder() |
| 144 | h.ServeHTTP(rec, req) |
| 145 | return rec |
| 146 | } |
| 147 | for name, rec := range map[string]*httptest.ResponseRecorder{ |
| 148 | "cross-site": post("https://evil.example", "cross-site"), |
| 149 | "no browser proof": post("", ""), |
| 150 | } { |
| 151 | if rec.Code != http.StatusForbidden || sessionSet(rec) { |
| 152 | t.Errorf("%s POST = %d (session %v), want 403 and no session", name, rec.Code, sessionSet(rec)) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | // The link survived both: the door's own POST signs in. |
| 157 | rec := c.postSameOrigin(testOrigin+action, url.Values{"token": {token}}) |
| 158 | if rec.Code != http.StatusSeeOther || !sessionSet(rec) { |
| 159 | t.Fatalf("the door's POST = %d (session %v), want 303 and a session — the refused POSTs above spent the link", rec.Code, sessionSet(rec)) |
| 160 | } |
| 161 | dest := rec.Header().Get("Location") |
| 162 | for i := 0; i < 3 && dest != "/"; i++ { |
| 163 | dest = location(t, c.get(testOrigin+dest)) |
| 164 | } |
| 165 | if dest != "/" { |
| 166 | t.Fatalf("sign-in landed on %q, want /", dest) |
| 167 | } |
| 168 | if got := c.get(testOrigin + "/"); got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "estilo-account") { |
| 169 | t.Fatalf("GET / with the new session = %d, or it is not signed in", got.Code) |
| 170 | } |
| 171 | |
| 172 | // Spent: the same token again is ?err=expired, and no session. |
| 173 | replay := newClient(t, h).postSameOrigin(testOrigin+action, url.Values{"token": {token}}) |
| 174 | if replay.Code != http.StatusSeeOther || replay.Header().Get("Location") != "/signin?err=expired" || sessionSet(replay) { |
| 175 | t.Errorf("a replayed token = %d %q, want 303 /signin?err=expired and no session", replay.Code, replay.Header().Get("Location")) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // The confirm door is drawn by Memoria (renderConfirm), inside its own |
| 180 | // layout — and auth's headers survive the render: no-store, the |
| 181 | // strict-origin Referrer-Policy (the URL is a live credential, and the |
| 182 | // layout's stylesheet and script requests would otherwise carry it as |
| 183 | // their Referer), the framing refusals, and BOTH CSP values: rastrillo's |
| 184 | // policy, then auth's added frame-ancestors 'none'. |
| 185 | // |
| 186 | // MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed per |
| 187 | // AGENTS.md): renderConfirm setting Referrer-Policy to |
| 188 | // strict-origin-when-cross-origin, or Set-ing a CSP of its own, each fail |
| 189 | // here. Reverted. |
| 190 | func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) { |
| 191 | h, _ := servedApp(t, tempDB(t), []string{chromeOwner}) |
| 192 | const evil = `"><script>alert(1)</script>` |
| 193 | rec := httptest.NewRecorder() |
| 194 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/auth/verify?token="+url.QueryEscape(evil), nil)) |
| 195 | if rec.Code != http.StatusOK { |
| 196 | t.Fatalf("GET /auth/verify = %d", rec.Code) |
| 197 | } |
| 198 | hd := rec.Header() |
| 199 | for name, want := range map[string]string{ |
| 200 | "Cache-Control": "no-store", |
| 201 | "Referrer-Policy": "strict-origin", |
| 202 | "X-Frame-Options": "DENY", |
| 203 | "X-Robots-Tag": "noindex, nofollow", |
| 204 | } { |
| 205 | if got := hd.Get(name); got != want { |
| 206 | t.Errorf("%s = %q, want %q", name, got, want) |
| 207 | } |
| 208 | } |
| 209 | csp := hd.Values("Content-Security-Policy") |
| 210 | if want := []string{defaultCSP(t), "frame-ancestors 'none'"}; strings.Join(csp, "\n") != strings.Join(want, "\n") { |
| 211 | t.Errorf("CSP values = %q, want %q", csp, want) |
| 212 | } |
| 213 | body := rec.Body.String() |
| 214 | _, token := confirmForm(t, body) |
| 215 | if token != evil { |
| 216 | t.Errorf("the door carries token %q, want the link's own", token) |
| 217 | } |
| 218 | if strings.Contains(body, "<script>alert(1)") { |
| 219 | t.Error("the token reached the page unescaped") |
| 220 | } |
| 221 | for _, want := range []string{"<header estilo-bar", "estilo-door", "Select Sign in to finish signing in."} { |
| 222 | if !strings.Contains(body, want) { |
| 223 | t.Errorf("the confirm door is not Memoria's door: no %q", want) |
| 224 | } |
| 225 | } |
| 226 | if strings.Contains(body, "<footer") { |
| 227 | t.Error("the confirm door carries the footer; sign-in pages do not") |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | // Standalone, Memoria serves rastrillo's own CSP, unchanged: no page or |
| 232 | // script writes an inline style (memoria.ServeOptions), so there is |
| 233 | // nothing to loosen style-src for. Read through ServeOptions and |
| 234 | // rastrillo.Handler, the stack main runs. |
| 235 | // |
| 236 | // MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed): |
| 237 | // ServeOptions setting opts.CSP to the pre-fb806a2 policy (style-src |
| 238 | // 'self' 'unsafe-inline') fails here. Reverted. |
| 239 | func TestStandaloneServesRastrillosDefaultCSP(t *testing.T) { |
| 240 | h, _ := servedApp(t, tempDB(t), []string{chromeOwner}) |
| 241 | want := defaultCSP(t) |
| 242 | if strings.Contains(want, "'unsafe-inline'") { |
| 243 | t.Fatalf("rastrillo's default allows inline styles again (%q); this test is about the stricter one", want) |
| 244 | } |
| 245 | for _, path := range []string{"/signin", "/signin?err=expired", "/signed-out", "/auth/verify?token=x", "/help/open-source", "/no-such-page"} { |
| 246 | rec := httptest.NewRecorder() |
| 247 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+path, nil)) |
| 248 | if got := rec.Header().Values("Content-Security-Policy"); len(got) == 0 || got[0] != want { |
| 249 | t.Errorf("%s: CSP %q, want rastrillo's default %q", path, got, want) |
| 250 | } |
| 251 | } |
| 252 | } |
| 253 | |
| 254 | // styleAttr is an inline style attribute in markup: the thing the |
| 255 | // default CSP refuses. |
| 256 | var styleAttr = regexp.MustCompile(`(?i)<[a-z][^>]*\sstyle\s*=`) |
| 257 | |
| 258 | // No page Memoria serves standalone carries an inline style attribute, |
| 259 | // signed out or in — the rendered markup, estilo's and rastrillo's |
| 260 | // partials included, not a grep of our own templates. This is what lets |
| 261 | // TestStandaloneServesRastrillosDefaultCSP's policy stand; the browser |
| 262 | // check (TestPagesRenderUnderTheProductionCSP) watches the scripts too. |
| 263 | // |
| 264 | // MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed): |
| 265 | // style="color:red" on layout.html's <main>, on the index's document-count |
| 266 | // heading, and on signin.html's confirm form each fail here. Reverted. |
| 267 | func TestNoPageCarriesAStyleAttribute(t *testing.T) { |
| 268 | check := func(h http.Handler, path string) { |
| 269 | t.Helper() |
| 270 | rec := httptest.NewRecorder() |
| 271 | h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+path, nil)) |
| 272 | // The page itself, not a redirect away from it: a 303 has no |
| 273 | // markup to find a style in, and would pass by measuring nothing. |
| 274 | want := http.StatusOK |
| 275 | if path == "/no-such-page" { |
| 276 | want = http.StatusNotFound |
| 277 | } |
| 278 | if rec.Code != want { |
| 279 | t.Fatalf("GET %s = %d, want %d", path, rec.Code, want) |
| 280 | } |
| 281 | if m := styleAttr.FindString(rec.Body.String()); m != "" { |
| 282 | t.Errorf("%s (%d) carries an inline style, which rastrillo's CSP refuses: %s", path, rec.Code, html.UnescapeString(m)) |
| 283 | } |
| 284 | } |
| 285 | |
| 286 | out, _ := servedApp(t, tempDB(t), []string{chromeOwner}) |
| 287 | for _, path := range []string{"/signin", "/signin?err=expired", "/signin?sent=1", "/signin?err=address", "/signin?reauth=1", "/signed-out", "/auth/verify?token=x", "/help/open-source"} { |
| 288 | check(out, path) |
| 289 | } |
| 290 | |
| 291 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 292 | d, err := db.Open(tempDB(t), logger) |
| 293 | if err != nil { |
| 294 | t.Fatal(err) |
| 295 | } |
| 296 | t.Cleanup(func() { d.Close() }) |
| 297 | mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger) |
| 298 | if err != nil { |
| 299 | t.Fatal(err) |
| 300 | } |
| 301 | in := production(t, a.ServeOptions(rastrillo.Options{Logger: logger}, mux)) |
| 302 | var buf bytes.Buffer |
| 303 | mw := multipart.NewWriter(&buf) |
| 304 | _ = mw.WriteField("title", "Office handbook") |
| 305 | _ = mw.WriteField("body", "The Lisbon office is at Rua Augusta 100.") |
| 306 | mw.Close() |
| 307 | req := httptest.NewRequest(http.MethodPost, testOrigin+"/documents", &buf) |
| 308 | req.Header.Set("Content-Type", mw.FormDataContentType()) |
| 309 | req.Header.Set("Origin", testOrigin) |
| 310 | req.Header.Set("Sec-Fetch-Site", "same-origin") |
| 311 | rec := httptest.NewRecorder() |
| 312 | in.ServeHTTP(rec, req) |
| 313 | if rec.Code != http.StatusSeeOther { |
| 314 | t.Fatalf("store a document = %d", rec.Code) |
| 315 | } |
| 316 | index := httptest.NewRecorder() |
| 317 | in.ServeHTTP(index, httptest.NewRequest(http.MethodGet, testOrigin+"/", nil)) |
| 318 | doc := regexp.MustCompile(`href="(/documents/\d+)"`).FindStringSubmatch(index.Body.String()) |
| 319 | if doc == nil { |
| 320 | t.Fatal("the index links no document; the document pages below would be measured as 404s") |
| 321 | } |
| 322 | for _, path := range []string{"/", "/?q=Lisbon", "/add", "/add?stored=1", doc[1], doc[1] + "/delete", "/search?q=Lisbon", "/connectors", "/connectors/notion", "/help/open-source", "/no-such-page"} { |
| 323 | check(in, path) |
| 324 | } |
| 325 | } |
| 326 | |