Style the native approval page under the strict CSP, by hash on its route
rastrillo/native's approval page (/native/authorize) is styled by one inline <style> element, and fb806a2's default style-src refused it, so the card and button went unstyled (Astra on #27, P2). That stylesheet is now allowed by its sha256 on that route alone, added to the policy rastrillo already set; every other route keeps the default exactly. TestNativeApprovalStyleIsTheHashedOne re-hashes the served stylesheet, and the browser CSP check now opens the approval page and checks the button is styled. Both fail on 651f370. Also from the review: the confirm door's headers are now checked in Home mode too, and the browser check rejects any [style] attribute in the live DOM, which also catches scripts that assign element.style.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+252
−5
AGENTS.md+4 −0internal/memoria/app.go+4 −2internal/memoria/home_confirm_test.go+41 −0internal/memoria/native.go+54 −0internal/memoria/serving.go+5 −0internal/memoriatest/csp_browser_test.go+43 −3internal/memoriatest/native_csp_test.go+101 −0
CI passed — run details
CI log
=== vet === go vet ./... go vet -tags browser ./internal/memoriatest/ === fmt === === test === go test ./... ? amadan.net/oficina/memoria/cmd/memoria [no test files] ok amadan.net/oficina/memoria/internal/memoria 3.868s ok amadan.net/oficina/memoria/internal/memoria/chunk (cached) ok amadan.net/oficina/memoria/internal/memoria/fact (cached) ? amadan.net/oficina/memoria/internal/memoria/icons [no test files] ok amadan.net/oficina/memoria/internal/memoria/index (cached) ok amadan.net/oficina/memoria/internal/memoria/notion (cached) ok amadan.net/oficina/memoria/internal/memoria/provider (cached) ok amadan.net/oficina/memoria/internal/memoria/query (cached) ok amadan.net/oficina/memoria/internal/memoria/seal (cached) ok amadan.net/oficina/memoria/internal/memoria/sibling (cached) ok amadan.net/oficina/memoria/internal/memoriatest 2.139s ? amadan.net/oficina/memoria/scripts/writeestilo [no test files] ? amadan.net/oficina/memoria/tools/bench [no test files] ? amadan.net/oficina/memoria/tools/drive [no test files] ? amadan.net/oficina/memoria/tools/seed [no test files] ? amadan.net/oficina/memoria/tools/vendorassets [no test files] === migration-check === go tool rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/AGENTS.md b/AGENTS.md| index ef0dc0f..4cd8267 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -198,6 +198,10 @@ so; it never renders as a confident zero. |
| browser`'s `TestPagesRenderUnderTheProductionCSP` watches for |
| violations in Chromium. Draw with attributes and memoria.css, never |
| `style=`; `memoria.ServeOptions` says why there is no policy of our own. |
| + The one exception is the native kit's approval page, whose inline |
| + `<style>` is allowed by hash on `/native/authorize` alone; |
| + `TestNativeApprovalStyleIsTheHashedOne` re-hashes it, so a native bump |
| + that changes its CSS fails CI rather than shipping it unstyled. |
| - **A field styled with `font: inherit` under a selector heavier than a |
| bare `input`** loses tokens.css's 16px-on-touch rule, and iOS zooms on |
| tap. Restate `font-size: max(1rem, 1em)` for it under `(pointer: |
diff --git a/internal/memoria/app.go b/internal/memoria/app.go| index b4b518c..370732f 100644 |
| --- a/internal/memoria/app.go |
| +++ b/internal/memoria/app.go |
| @@ -95,8 +95,10 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht |
| r.Post("/native/request", a.native.ServeHTTP) |
| r.Post("/native/exchange", a.native.ServeHTTP) |
| r.Get("/native/start", a.native.ServeHTTP) |
| - r.Get("/native/authorize", a.native.ServeHTTP) |
| - r.Post("/native/authorize", a.native.ServeHTTP) |
| + // The approval page is the native kit's, styled by one inline |
| + // <style>: allowed by its hash on this route alone (native.go). |
| + r.Get("/native/authorize", nativeApprovalCSP(a.native.ServeHTTP)) |
| + r.Post("/native/authorize", nativeApprovalCSP(a.native.ServeHTTP)) |
| // Appearance (estilo chrome ยง8.3): inside the app-wide CSRF check |
| // (homeMiddleware) and OUTSIDE the session gate, so a session that |
diff --git a/internal/memoria/home_confirm_test.go b/internal/memoria/home_confirm_test.go| index c8aa457..8963df0 100644 |
| --- a/internal/memoria/home_confirm_test.go |
| +++ b/internal/memoria/home_confirm_test.go |
| @@ -7,6 +7,9 @@ import ( |
| "regexp" |
| "strings" |
| "testing" |
| + |
| + "amadan.net/oficina/home/connect" |
| + "amadan.net/rastrillo/rastrillo" |
| ) |
| // rastrillo #64 in Home mode: the local form's emailed link lands on the |
| @@ -85,3 +88,41 @@ func TestHomeLocalLinkOpensTheConfirmDoor(t *testing.T) { |
| t.Errorf("a replayed token = %d %q, want 303 /signin?err=expired", replay.Code, replay.Header().Get("Location")) |
| } |
| } |
| + |
| +// The confirm door's headers in Home mode, through the production stack: |
| +// auth's no-store, strict-origin Referrer-Policy and framing refusals |
| +// survive, and the CSP is connect's FormPolicy followed by auth's added |
| +// frame-ancestors 'none'. The standalone half is memoriatest's |
| +// TestTheConfirmDoorKeepsEveryPolicy; this one was missing (Astra on #27: |
| +// overwriting Referrer-Policy in renderConfirm only when Home is |
| +// configured passed both suites). Run that mutation: it fails here. |
| +func TestHomeConfirmDoorKeepsEveryPolicy(t *testing.T) { |
| + f := newHomeRouterFixture(t) |
| + h, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.handler.(*http.ServeMux)}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if done != nil { |
| + defer done() |
| + } |
| + w := httptest.NewRecorder() |
| + h.ServeHTTP(w, httptest.NewRequest("GET", homeTestOrigin+"/auth/verify?token=x", nil)) |
| + if w.Code != http.StatusOK { |
| + t.Fatalf("GET /auth/verify = %d", w.Code) |
| + } |
| + confirmForm(t, w.Body.String()) |
| + for name, want := range map[string]string{ |
| + "Cache-Control": "no-store", |
| + "Referrer-Policy": "strict-origin", |
| + "X-Frame-Options": "DENY", |
| + "X-Robots-Tag": "noindex, nofollow", |
| + } { |
| + if got := w.Header().Get(name); got != want { |
| + t.Errorf("%s = %q, want %q", name, got, want) |
| + } |
| + } |
| + got := w.Header().Values("Content-Security-Policy") |
| + if want := []string{connect.FormPolicy(homeTestIssuer), "frame-ancestors 'none'"}; strings.Join(got, "\n") != strings.Join(want, "\n") { |
| + t.Errorf("CSP values = %q, want %q", got, want) |
| + } |
| +} |
diff --git a/internal/memoria/native.go b/internal/memoria/native.go| index 04ff662..bc14b23 100644 |
| --- a/internal/memoria/native.go |
| +++ b/internal/memoria/native.go |
| @@ -146,3 +146,57 @@ func (a *app) nativeNote(w http.ResponseWriter, r *http.Request) { |
| } |
| nativeJSON(w, map[string]any{"id": id, "indexed": err == nil}) |
| } |
| + |
| +// nativeApprovalStyle is the CSP source for the one <style> element on |
| +// rastrillo/native's approval page (/native/authorize, server auth.go's |
| +// approval template, at the pin in go.mod). That page is the native |
| +// kit's, not ours: its whole stylesheet is inline, and rastrillo |
| +// fb806a2's default style-src refuses it, which left the card, spacing |
| +// and button unstyled (Astra on #27, P2). Its stylesheet holds no |
| +// template action, so it is one fixed string and one hash. |
| +// |
| +// Allowed by hash, on that route only, rather than 'unsafe-inline' |
| +// anywhere: the hash admits exactly that stylesheet, and every other page |
| +// keeps rastrillo's strict policy. TestNativeApprovalStyleIsTheHashedOne |
| +// re-hashes the served <style> on every run, so a native bump that |
| +// changes the CSS fails in CI instead of shipping an unstyled page. The |
| +// lasting fix is upstream: an external stylesheet in native/server. |
| +const nativeApprovalStyle = "'sha256-zliDvO3z4TJ7/8VeNbSfTcTmu729BtmpmXEY89IdRBI='" |
| + |
| +// nativeApprovalCSP adds nativeApprovalStyle to the style-src of the |
| +// policy already on the response. rastrillo's security headers run first |
| +// and set it, so this edits the policy production really sends, and a |
| +// tightening of rastrillo's default reaches this route too. A policy that |
| +// already allows inline styles is left alone: adding a hash to it would |
| +// void its 'unsafe-inline' (Home mode's connect.FormPolicy). No policy at |
| +// all (the bare router in tests) is left alone too. |
| +func nativeApprovalCSP(next http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + h := w.Header() |
| + if p := h.Get("Content-Security-Policy"); p != "" { |
| + h.Set("Content-Security-Policy", withStyleSource(p, nativeApprovalStyle)) |
| + } |
| + next(w, r) |
| + } |
| +} |
| + |
| +// withStyleSource appends source to policy's style-src, or adds a |
| +// style-src of 'self' and source when it has none. A style-src that |
| +// allows 'unsafe-inline' is returned unchanged. |
| +func withStyleSource(policy, source string) string { |
| + parts := strings.Split(policy, ";") |
| + for i, d := range parts { |
| + f := strings.Fields(d) |
| + if len(f) == 0 || f[0] != "style-src" { |
| + continue |
| + } |
| + for _, s := range f[1:] { |
| + if s == "'unsafe-inline'" { |
| + return policy |
| + } |
| + } |
| + parts[i] = strings.TrimRight(d, " ") + " " + source |
| + return strings.Join(parts, ";") |
| + } |
| + return policy + "; style-src 'self' " + source |
| +} |
diff --git a/internal/memoria/serving.go b/internal/memoria/serving.go| index 25a16e0..0c095b3 100644 |
| --- a/internal/memoria/serving.go |
| +++ b/internal/memoria/serving.go |
| @@ -23,6 +23,11 @@ import ( |
| // browser check TestPagesRenderUnderTheProductionCSP watches for |
| // violations in Chromium. |
| // |
| +// One page is not ours: rastrillo/native's approval page |
| +// (/native/authorize) draws itself with one inline <style>, admitted by |
| +// its hash on that route alone (nativeApprovalCSP, native.go). Every other |
| +// route serves the default exactly. |
| +// |
| // With Home configured, connect's middleware replaces the policy with |
| // connect.FormPolicy, whose style-src keeps 'unsafe-inline': estilo's |
| // account-link page (homeconnect home_link.html) draws the address with |
diff --git a/internal/memoriatest/csp_browser_test.go b/internal/memoriatest/csp_browser_test.go| index b92e77e..bb2b28d 100644 |
| --- a/internal/memoriatest/csp_browser_test.go |
| +++ b/internal/memoriatest/csp_browser_test.go |
| @@ -5,6 +5,9 @@ package memoriatest |
| import ( |
| "bytes" |
| "context" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| "io" |
| "log/slog" |
| "mime/multipart" |
| @@ -66,8 +69,10 @@ const cspProbe = `new Promise(done => { |
| // |
| // MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed per |
| // AGENTS.md): style="color:red" on the confirm form in signin.html fails |
| -// here; and ServeOptions setting the pre-fb806a2 policy (style-src 'self' |
| -// 'unsafe-inline') fails the probe. Reverted. |
| +// here; ServeOptions setting the pre-fb806a2 policy (style-src 'self' |
| +// 'unsafe-inline') fails the probe; the native approval route without its |
| +// hash (651f370) fails on style-src-elem; search.js assigning |
| +// main.style.outline fails the [style] check. Reverted. |
| func TestPagesRenderUnderTheProductionCSP(t *testing.T) { |
| logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| served := func(t *testing.T, test bool) func(origin string) http.Handler { |
| @@ -112,7 +117,7 @@ func TestPagesRenderUnderTheProductionCSP(t *testing.T) { |
| t.Helper() |
| ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second) |
| defer cancel() |
| - var violations []string |
| + var violations, styled []string |
| var refused bool |
| actions := []chromedp.Action{ |
| chromedp.Navigate(rig.Origin + path), |
| @@ -122,6 +127,10 @@ func TestPagesRenderUnderTheProductionCSP(t *testing.T) { |
| actions = append(actions, |
| chromedp.Sleep(150*time.Millisecond), |
| chromedp.Evaluate(`window.__csp`, &violations), |
| + // A script assigning element.style.x raises no violation (the |
| + // CSSOM is not policed), so look at the DOM itself too (Astra |
| + // on #27): no element may carry a style attribute. |
| + chromedp.Evaluate(`[...document.querySelectorAll("[style]")].map(e => e.tagName.toLowerCase() + '[style="' + e.getAttribute("style") + '"]')`, &styled), |
| chromedp.Evaluate(cspProbe, &refused, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }), |
| ) |
| if err := chromedp.Run(ctx, actions...); err != nil { |
| @@ -130,6 +139,9 @@ func TestPagesRenderUnderTheProductionCSP(t *testing.T) { |
| if len(violations) > 0 { |
| t.Errorf("%s: the production CSP refused: %s", path, strings.Join(violations, "; ")) |
| } |
| + if len(styled) > 0 { |
| + t.Errorf("%s: elements carry inline styles after load: %s", path, strings.Join(styled, "; ")) |
| + } |
| if !refused { |
| t.Errorf("%s: an inline style attribute was NOT refused: this page is not under rastrillo's strict policy, or the watch never attached", path) |
| } |
| @@ -190,5 +202,33 @@ func TestPagesRenderUnderTheProductionCSP(t *testing.T) { |
| visit(t, rig, doc+"/delete", `main`) |
| visit(t, rig, "/connectors", `main`) |
| visit(t, rig, "/connectors/notion", `input[name="token"]`) |
| + |
| + // The native kit's approval page (Astra on #27, P2): its whole |
| + // stylesheet is one inline <style>, admitted by hash on that route |
| + // alone (memoria native.go). On 651f370 this was refused |
| + // (style-src-elem), and the card and button went unstyled โ so |
| + // also check the stylesheet applied: the button is the kit's |
| + // purple, not the browser's grey. |
| + sum := sha256.Sum256([]byte(strings.Repeat("v", 43))) |
| + nreq, _ := http.NewRequest(http.MethodPost, rig.Origin+"/native/request", |
| + strings.NewReader(`{"challenge":"`+base64.RawURLEncoding.EncodeToString(sum[:])+`"}`)) |
| + nreq.Header.Set("Content-Type", "application/json") |
| + nreq.Header.Set("Origin", rig.Origin) |
| + nres, err := http.DefaultClient.Do(nreq) |
| + if err != nil { |
| + t.Fatalf("POST /native/request: %v", err) |
| + } |
| + var native struct{ ID string } |
| + _ = json.NewDecoder(nres.Body).Decode(&native) |
| + nres.Body.Close() |
| + if native.ID == "" { |
| + t.Fatalf("POST /native/request = %d with no id", nres.StatusCode) |
| + } |
| + var bg string |
| + visit(t, rig, "/native/authorize?id="+native.ID, `main form button`, |
| + chromedp.Evaluate(`getComputedStyle(document.querySelector("main form button")).backgroundColor`, &bg)) |
| + if bg != "rgb(112, 82, 162)" { |
| + t.Errorf("/native/authorize: the approval button's background is %q, want the kit's #7052a2 โ its stylesheet did not apply", bg) |
| + } |
| }) |
| } |
diff --git a/internal/memoriatest/native_csp_test.go b/internal/memoriatest/native_csp_test.go| new file mode 100644 |
| index 0000000..34423c8 |
| --- /dev/null |
| +++ b/internal/memoriatest/native_csp_test.go |
| @@ -0,0 +1,101 @@ |
| +package memoriatest |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "regexp" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// nativeApprovalPage starts a native sign-in on h and opens its approval |
| +// page as c, the signed-in browser, returning the response. |
| +func nativeApprovalPage(t *testing.T, h http.Handler, c *client) *httptest.ResponseRecorder { |
| + t.Helper() |
| + sum := sha256.Sum256([]byte(strings.Repeat("v", 43))) |
| + req := httptest.NewRequest(http.MethodPost, testOrigin+"/native/request", |
| + strings.NewReader(`{"challenge":"`+base64.RawURLEncoding.EncodeToString(sum[:])+`"}`)) |
| + req.Header.Set("Content-Type", "application/json") |
| + req.Header.Set("Origin", testOrigin) |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, req) |
| + var out struct{ ID string } |
| + if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.ID == "" { |
| + t.Fatalf("POST /native/request = %d %q", rec.Code, rec.Body.String()) |
| + } |
| + page := c.get(testOrigin + "/native/authorize?id=" + out.ID) |
| + if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "Sign in to Memoria app") { |
| + t.Fatalf("GET /native/authorize = %d, not the approval page:\n%s", page.Code, page.Body.String()) |
| + } |
| + return page |
| +} |
| + |
| +var styleElement = regexp.MustCompile(`(?s)<style>(.*?)</style>`) |
| + |
| +// Astra on #27, P2: the native kit's approval page (/native/authorize) is |
| +// styled by one inline <style> element, and rastrillo fb806a2's strict |
| +// default refused it, so the page lost its card, spacing and button. The |
| +// fix allows that stylesheet by its hash, on that route only. |
| +// |
| +// Read through the production stack: the served stylesheet's own hash |
| +// must be in the route's style-src (so a native bump that changes the CSS |
| +// fails here, not on a phone), and with that one source taken out the |
| +// policy is rastrillo's default exactly, so nothing else is relaxed. No |
| +// other page carries the hash. |
| +// |
| +// Fails on 651f370 (no hash on the route). MUTATIONS (author-chosen, |
| +// 2026-10-06): dropping the wrapper from the GET route; 'unsafe-inline' |
| +// in place of the hash; the wrapper on every route. Each fails here. |
| +// Reverted. |
| +func TestNativeApprovalStyleIsTheHashedOne(t *testing.T) { |
| + c, h := signedInServedClient(t) |
| + page := nativeApprovalPage(t, h, c) |
| + m := styleElement.FindStringSubmatch(page.Body.String()) |
| + if m == nil { |
| + t.Fatal("the approval page has no <style> element; if native/server moved its CSS out, drop the hash (memoria native.go)") |
| + } |
| + sum := sha256.Sum256([]byte(m[1])) |
| + source := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'" |
| + |
| + policies := page.Header().Values("Content-Security-Policy") |
| + if len(policies) != 1 { |
| + t.Fatalf("CSP values = %q, want one", policies) |
| + } |
| + if !strings.Contains(policies[0], "style-src ") || !strings.Contains(policies[0], " "+source) { |
| + t.Fatalf("the approval page's CSP %q does not admit its own stylesheet %s", policies[0], source) |
| + } |
| + if strings.Contains(policies[0], "'unsafe-inline'") { |
| + t.Errorf("the approval page allows every inline style: %q", policies[0]) |
| + } |
| + if got, want := strings.Replace(policies[0], " "+source, "", 1), defaultCSP(t); got != want { |
| + t.Errorf("with the native hash taken out, the policy is %q, want rastrillo's default %q", got, want) |
| + } |
| + |
| + for _, path := range []string{"/", "/signin", "/auth/verify?token=x"} { |
| + if got := strings.Join(c.get(testOrigin+path).Header().Values("Content-Security-Policy"), "\n"); strings.Contains(got, source) { |
| + t.Errorf("%s carries the native approval page's hash: %q", path, got) |
| + } |
| + } |
| +} |
| + |
| +// signedInServedClient is a browser signed in through the real magic link |
| +// on servedApp, the production stack. |
| +func signedInServedClient(t *testing.T) (*client, http.Handler) { |
| + t.Helper() |
| + h, mailer := servedApp(t, tempDB(t), []string{chromeOwner}) |
| + c := newClient(t, h) |
| + if rec := c.postSameOrigin(testOrigin+"/auth/begin", map[string][]string{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("POST /auth/begin = %d", rec.Code) |
| + } |
| + link := regexp.MustCompile(`/auth/verify\?[^\s<>"]+`).FindString(mailer.text()) |
| + if link == "" { |
| + t.Fatalf("no magic link: %q", mailer.text()) |
| + } |
| + if rec := c.openLink(testOrigin + link); rec.Code != http.StatusSeeOther { |
| + t.Fatalf("redeem = %d", rec.Code) |
| + } |
| + return c, h |
| +} |