oficina / memoria Public

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>
Paul Campbell pushed by paul@keymail.dev 5d3bd2867cd2ab96bfbbcecbf01a5c7b9c78cb22 parent 651f370
7 files changed, +252 −5
  • AGENTS.md +4 −0
  • internal/memoria/app.go +4 −2
  • internal/memoria/home_confirm_test.go +41 −0
  • internal/memoria/native.go +54 −0
  • internal/memoria/serving.go +5 −0
  • internal/memoriatest/csp_browser_test.go +43 −3
  • internal/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
+}