oficina / home Public

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

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "bytes"
5 "io/fs"
6 "net/http"
7 "net/http/httptest"
8 "os"
9 "path"
10 "path/filepath"
11 "regexp"
12 "slices"
13 "sort"
14 "strings"
15 "testing"
16
17 "amadan.net/oficina/estilo.git/theme"
18 "amadan.net/rastrillo/rastrillo"
19 "amadan.net/rastrillo/rastrillo/ui"
20 "github.com/go-chi/chi/v5"
21)
22
23// homeScripts is every script Home serves, by stable path. Paul,
24// 2026-09-29: no script by default, a script only to enhance a page or
25// provide interaction, and always prefer none. So any page may load one of
26// these — they are all progressive enhancements over a page that works
27// without them — but nothing else, and never inline.
28//
29// Paths, not fingerprints: a template writes {{v "/static/x.js"}} (a
30// content-versioned URL), which the sweep reads back to the bare path.
31var homeScripts = []string{
32 "/static/assertion.js", // the handoff's auto-submit; Continue stays for script-off
33 "/static/passkey.js", // the passkey ceremonies: webauthn cannot be done without script
34 "/static/busy.js", // estilo's busy rule: spinner and double-submit guard
35}
36
37// templateSrc unwraps {{v "PATH"}} to PATH, so the sweep checks the file,
38// not the helper call that versions it.
39var templateSrc = regexp.MustCompile(`^\{\{\s*v\s+"([^"]+)"\s*\}\}$`)
40
41// scriptTag matches a complete <script ...>...</script>.
42//
43// The closing pattern tolerates whitespace — `</script >` is valid HTML
44// that every browser accepts, and the first version of this regex
45// required the literal `</script>` and so walked straight past a page
46// loading an arbitrary script. The justification for hand-rolling was
47// "the shapes it has to survive are the ones we write", which is exactly
48// the assumption that failed: nobody writes `</script >` on purpose,
49// which is why nobody would notice.
50var scriptTag = regexp.MustCompile(`(?is)<script\b([^>]*)>(.*?)</\s*script\s*>`)
51
52// scriptOpen matches just the opening tag, so a <script> with no
53// matching close can be counted and refused rather than silently
54// skipped.
55var scriptOpen = regexp.MustCompile(`(?is)<script\b`)
56
57// srcAttr accepts double quotes, single quotes and an unquoted value.
58// Only the double-quoted form was matched at first, so a
59// single-quoted src fell through to the "inline" branch — which still
60// FAILED the guard, since fail-safe was the right default, but with a
61// diagnosis naming the wrong problem. A guard that reports the wrong
62// reason gets the wrong fix.
63// It also requires a whitespace boundary before src, not \b: \b matches
64// inside data-src=, so a tag carrying both reported the wrong one.
65var srcAttr = regexp.MustCompile(`(?i)(?:^|\s)src\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`)
66
67// scriptSrcs returns one entry per <script> in b: its src, or the empty
68// string for an inline one. An inline body counts even when a src is
69// also present, because the empty string is what the guard refuses.
70func scriptSrcs(b []byte) []string {
71 var out []string
72 for _, m := range scriptTag.FindAllSubmatch(b, -1) {
73 attrs, body := m[1], m[2]
74 if len(bytes.TrimSpace(body)) > 0 {
75 out = append(out, "")
76 continue
77 }
78 if m := srcAttr.FindSubmatch(attrs); m != nil {
79 // One of the three alternatives matched; the others are empty.
80 for _, g := range m[1:] {
81 if len(g) > 0 {
82 out = append(out, string(g))
83 break
84 }
85 }
86 continue
87 }
88 out = append(out, "")
89 }
90 return out
91}
92
93// templateNames is every template file this app ships, including the
94// layout — the sweep reads SOURCE, so the layout is a page like any
95// other and can carry a script too.
96func templateNames(t *testing.T) []string {
97 t.Helper()
98 files, err := fs.Glob(templatesFS, "templates/*.html")
99 if err != nil {
100 t.Fatal(err)
101 }
102 names := make([]string, 0, len(files))
103 for _, f := range files {
104 names = append(names, path.Base(f))
105 }
106 sort.Strings(names)
107 if len(names) == 0 {
108 t.Fatal("no templates found: the sweep would pass by not looking")
109 }
110 return names
111}
112
113// templateSource is one template file's raw text.
114func templateSource(t *testing.T, name string) []byte {
115 t.Helper()
116 b, err := templatesFS.ReadFile("templates/" + name)
117 if err != nil {
118 t.Fatal(err)
119 }
120 return b
121}
122
123// sampleData is enough data to render each page for the tests that need
124// a rendered page.
125//
126// It is deliberately NOT what the script sweep uses. A fixed key set
127// cannot reach a branch keyed on something it does not carry — on a map,
128// {{if .Debug}} resolves to nil with no error — so a sweep built on it
129// passes by not looking. The sweep reads template source instead. This
130// stays for the recovery-form test, which needs real output.
131func sampleData(name string) []any {
132 common := map[string]any{
133 "RPID": "id.oficina.works", "LegacyRPID": "",
134 "UserID": "cHJfdGVzdA", "UserName": "pr_test",
135 "Token": "sample-token", "Remaining": 7,
136 }
137 withError := map[string]any{}
138 for k, v := range common {
139 withError[k] = v
140 }
141 withError["Error"] = "something went wrong"
142 withError["RecoveryFailed"] = true
143 withError["Codes"] = []string{"AAAA-BBBB", "CCCC-DDDD"}
144 return []any{common, withError}
145}
146
147// TestNoScriptTagOutsideTheAllowlist is the script rule, made
148// mechanical (it once also named the only screens allowed any script; the
149// rule is now no script by default and prefer none, so it names the
150// files instead). Historically: every
151// screen works with scripts off, and ruling 21 once carved out only the
152// passkey screens.
153//
154// Two stated limits, so nobody reads more into a green run than is
155// there. It does not catch inline event handlers — onclick= and its
156// relatives are script and are not <script>; rastrillo's default CSP
157// (default-src 'self', no unsafe-inline) is what makes them inert, and
158// TestServedCSPAllowsNoInlineScript is what guards that. And it reads
159// only this app's templates/*.html, while roughly thirty rastrillo ui
160// partials are parsed into every page tree. Those are clean at the
161// pinned version — no <script> anywhere in partials/, and ui.Templates()
162// excludes the layouts/ files that do have one — but an upgrade could
163// change that without this sweep noticing.
164//
165// It reads template SOURCE, not rendered output, and that is the
166// correction. The first version rendered each page with a hand-written
167// fixture and scanned the result, with a comment claiming the fixture
168// reached "every branch". It could not: on a map, {{if .Debug}}
169// resolves to nil with no error, so a <script> inside a branch the
170// fixture never satisfies rendered as nothing and the sweep passed by
171// not looking. Any key a future template introduced was invisible to it
172// by construction. Source has no branches to miss.
173func TestNoScriptTagOutsideTheAllowlist(t *testing.T) {
174 for _, name := range templateNames(t) {
175 src := templateSource(t, name)
176
177 // A <script> with no matching close is refused rather than
178 // skipped: an unbalanced tag is either a typo or an evasion, and
179 // neither should pass silently.
180 if opens, full := len(scriptOpen.FindAll(src, -1)), len(scriptTag.FindAll(src, -1)); opens != full {
181 t.Errorf("%s has %d <script opening tags but %d complete elements; "+
182 "an unbalanced tag cannot be swept", name, opens, full)
183 continue
184 }
185
186 srcs := scriptSrcs(src)
187 if len(srcs) == 0 {
188 continue // no script at all: the common case
189 }
190 for _, srcAttr := range srcs {
191 if m := templateSrc.FindStringSubmatch(srcAttr); m != nil {
192 srcAttr = m[1]
193 }
194 switch {
195 case srcAttr == "":
196 t.Errorf("%s has an INLINE <script>; scripts are static same-origin "+
197 "files, and the default CSP forbids inline anyway", name)
198 case !slices.Contains(homeScripts, srcAttr):
199 t.Errorf("%s loads %q, which is not one of Home's scripts (homeScripts)", name, srcAttr)
200 }
201 }
202 }
203}
204
205// scriptSrcs must actually see the shapes it is meant to refuse, or the
206// sweep above is a sweep over nothing.
207func TestScriptSrcsSeesInlineAndSourced(t *testing.T) {
208 got := scriptSrcs([]byte(`
209 <script src="/static/passkey.js"></script>
210 <script>alert(1)</script>
211 <script type="module" src="/static/other.js"></script>
212 <script src="/static/x.js">alert(2)</script>
213 <script src="/static/spaced.js"></script >
214 <SCRIPT SRC="/static/shouty.js"></SCRIPT>
215 <script src='/static/singly.js'></script>
216 <script src=/static/bare.js></script>
217 `))
218 want := []string{"/static/passkey.js", "", "/static/other.js", "",
219 "/static/spaced.js", "/static/shouty.js",
220 "/static/singly.js", "/static/bare.js"}
221 if !slices.Equal(got, want) {
222 t.Errorf("scriptSrcs = %q, want %q", got, want)
223 }
224}
225
226// The carve-out must not widen into unsafe-inline behind our backs.
227//
228// Asserted against the SERVED header rather than against rastrillo's
229// defaultCSP, which is an unexported const: the point is what a browser
230// receives, not what a library declares.
231func TestServedCSPAllowsNoInlineScript(t *testing.T) {
232 app := newTestApp(t)
233 mux := http.NewServeMux()
234 mux.Handle("/", app.Router())
235
236 h, cleanup, err := rastrillo.Handler(rastrillo.Options{Mux: mux, Logger: quietLogger(), CSP: app.SecurityPolicy()})
237 if err != nil {
238 t.Fatalf("rastrillo.Handler: %v", err)
239 }
240 if cleanup != nil {
241 t.Cleanup(func() { cleanup() })
242 }
243
244 // EVERY route, not just one. rastrillo's securityHeaders sets the
245 // default and a handler's own header REPLACES it, so an
246 // unsafe-inline carve-out added to the one page that actually loads
247 // script — the tempting place to put it — would never be seen by a
248 // check that only requests /signin.
249 routes, ok := app.Router().(chi.Routes)
250 if !ok {
251 t.Fatalf("Router() is %T, which does not implement chi.Routes", app.Router())
252 }
253 // Method AND route, AND a session. It took three goes, and the
254 // history is the useful part because each version's comment claimed
255 // more coverage than it had.
256 //
257 // It first issued GET on every path, so all ten POST handlers were
258 // skipped and an unsafe-inline header in handleSigninBegin survived
259 // the whole suite. Using chi.Walk's method fixed that, and the
260 // comment then called it "the whole fix" — wrong again: five
261 // handlers still never ran, all five stopped by RequireFresh
262 // (GET and POST /passkey/codes, GET /passkey/enrol, and both
263 // register endpoints). None was stopped by csrf; the Origin header
264 // below sees to that. So the sweep reached eighteen of
265 // twenty-three, and one of the five it missed renders the enrolment
266 // screen, which is one of the pages that loads a script to enhance it.
267 //
268 // So: a fresh session cookie as well. With it, all twenty-three
269 // targets reach and run their handler — probe-verified rather than
270 // inferred from status codes, which is how an earlier version of
271 // this comment got it wrong in the other direction. The three
272 // ceremony endpoints answer 403, but that 403 is written by
273 // passkey.Handlers.refuse from INSIDE the handler after the pending
274 // half-session lookup misses, so a header set at the top of one
275 // would be caught here.
276 type target struct{ method, path string }
277 var targets []target
278 if err := chi.Walk(routes, func(method string, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
279 targets = append(targets, target{method, strings.ReplaceAll(route, "/*", "")})
280 return nil
281 }); err != nil {
282 t.Fatalf("walk routes: %v", err)
283 }
284 if len(targets) < 5 {
285 t.Fatalf("walked %d routes; the sweep would pass by not looking", len(targets))
286 }
287 var posts int
288 for _, tg := range targets {
289 if tg.method == http.MethodPost {
290 posts++
291 }
292 }
293 if posts == 0 {
294 t.Fatal("walked no POST routes; the sweep would miss every state-changing handler")
295 }
296
297 // A real, fresh session: the passkey routes want one, and the codes
298 // and enrol pages want a recent one. Without it this sweep silently
299 // covered eighteen routes while claiming twenty-three.
300 cookie := signInFully(t, app, "sweeper@example.invalid")
301
302 for _, tg := range targets {
303 p := tg.path
304 req := httptest.NewRequest(tg.method, p, strings.NewReader("address=someone@example.invalid"))
305 req.Host = credentialHost
306 if req.URL.Path == "/" {
307 req.Host = directoryHost
308 }
309 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
310 // The POSTs are behind csrf.Protect, which short-circuits before
311 // the handler runs — and a handler that never runs sets no
312 // header to inspect.
313 req.Header.Set("Origin", Origin)
314 req.AddCookie(cookie)
315 rec := httptest.NewRecorder()
316 h.ServeHTTP(rec, req)
317
318 csp := rec.Header().Get("Content-Security-Policy")
319 if csp == "" {
320 t.Errorf("%s served no Content-Security-Policy header", p)
321 continue
322 }
323 for _, directive := range strings.Split(csp, ";") {
324 d := strings.TrimSpace(directive)
325 name, _, _ := strings.Cut(d, " ")
326 // Inline STYLES are allowed on purpose: ui's partials set
327 // style attributes. Inline SCRIPT is what §11 and the script rule
328 // are about.
329 if name == "style-src" {
330 continue
331 }
332 if strings.Contains(d, "'unsafe-inline'") || strings.Contains(d, "'unsafe-eval'") {
333 t.Errorf("%s: CSP directive %q permits inline or eval'd script", p, d)
334 }
335 }
336 if !strings.Contains(csp, "default-src 'self'") {
337 t.Errorf("%s: CSP %q does not restrict script to same-origin", p, csp)
338 }
339 }
340}
341
342// The binary uses the policy whose only framework change is tested separately.
343func TestAppUsesReviewedCSP(t *testing.T) {
344 b, err := os.ReadFile("../../cmd/home/main.go")
345 if err != nil {
346 t.Fatal(err)
347 }
348 assignment := []byte("opts.CSP = app.SecurityPolicy()")
349 if bytes.Count(b, assignment) != 1 || bytes.Contains(bytes.ReplaceAll(b, assignment, nil), []byte("CSP")) {
350 t.Error("binary must wire exactly the reviewed policy")
351 }
352}
353
354// The one screen that carries script must still work without it.
355// passkey.SignInRecovery is a plain form POST, because recovery is
356// exactly the moment WebAuthn is not working — so stripping every
357// <script> must leave the code form intact and pointed at its endpoint.
358func TestConfirmPageRecoveryFormSurvivesScriptStripping(t *testing.T) {
359 app := newTestApp(t)
360 var buf bytes.Buffer
361 if err := app.renderer.pages["passkey_confirm.html"].
362 ExecuteTemplate(&buf, "layout", sampleData("passkey_confirm.html")[0]); err != nil {
363 t.Fatal(err)
364 }
365
366 stripped := scriptTag.ReplaceAll(buf.Bytes(), nil)
367 page := string(stripped)
368
369 if strings.Contains(page, "<script") {
370 t.Fatal("stripping left a script tag behind; the rest of this test would be meaningless")
371 }
372 for _, want := range []string{
373 `action="/passkey/signin/recovery"`,
374 `method="post"`,
375 `name="code"`,
376 `type="submit"`,
377 } {
378 if !strings.Contains(page, want) {
379 t.Errorf("the recovery form lost %s when script was stripped", want)
380 }
381 }
382}
383
384func TestVendoredAssetsMatchTheLibrary(t *testing.T) {
385 tokens, err := os.ReadFile("static/tokens.css")
386 if err != nil {
387 t.Fatal(err)
388 }
389 if !bytes.Equal(tokens, ui.TokensCSS()) {
390 t.Error("static/tokens.css differs from ui.TokensCSS(); run `rastrillo doctor --fix`")
391 }
392
393 // The theme is estilo's own now — Calma, not rastrillo's day (estilo
394 // AGENTS.md, decisions table, row 8) — so the check is that Home serves
395 // exactly what its estilo pin ships, below, and that the theme still
396 // declares every token tokens.css uses.
397 day, ok := ui.ThemeCSS("day")
398 if !ok {
399 t.Fatal("ui has no day theme")
400 }
401 names := regexp.MustCompile(`(?m)^\s*(--rst-[a-z0-9-]+)\s*:`)
402 have := map[string]bool{}
403 for _, m := range names.FindAllSubmatch(theme.CSS(), -1) {
404 have[string(m[1])] = true
405 }
406 for _, m := range names.FindAllSubmatch(day, -1) {
407 if !have[string(m[1])] {
408 t.Errorf("estilo's theme does not declare %s, which tokens.css expects", m[1])
409 }
410 }
411 app := newTestApp(t)
412 for _, host := range []string{directoryHost, credentialHost} {
413 req := httptest.NewRequest(http.MethodGet, "/static/theme.css", nil)
414 req.Host = host
415 rec := httptest.NewRecorder()
416 app.Router().ServeHTTP(rec, req)
417 if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/css; charset=utf-8" || !bytes.Equal(rec.Body.Bytes(), theme.CSS()) {
418 t.Fatalf("%s: shared theme response mismatch (status %d)", host, rec.Code)
419 }
420 }
421}
422
423// rastrilloScripts are the scripts `rastrillo doctor --fix` writes into
424// static/ beside tokens.css. Home uses none of them. rastrillo.js at this
425// pin is the combined shim with rastrillo's old busy rule inside, and
426// Home serves estilo's busy.js; the two rules on one page fight over the
427// same submit (each holds it, then re-submits). So after a doctor run they
428// are deleted, and this fails if one is left on disk or served.
429var rastrilloScripts = []string{"rastrillo.js", "select.js", "datetime.js", "calendar.js"}
430
431func TestNoRastrilloShimBesideBusy(t *testing.T) {
432 for _, name := range rastrilloScripts {
433 if _, err := os.Stat(filepath.Join("static", name)); err == nil {
434 t.Errorf("static/%s is on disk: `rastrillo doctor --fix` adds it, and Home serves estilo's busy.js instead; delete it", name)
435 }
436 }
437 app := newTestApp(t)
438 for _, host := range []string{directoryHost, credentialHost} {
439 for _, name := range rastrilloScripts {
440 req := httptest.NewRequest(http.MethodGet, "/static/"+name, nil)
441 req.Host = host
442 rec := httptest.NewRecorder()
443 app.Router().ServeHTTP(rec, req)
444 if rec.Code != http.StatusNotFound {
445 t.Errorf("%s /static/%s: %d, want 404 (Home serves no rastrillo script)", host, name, rec.Code)
446 }
447 }
448 }
449}
450
451// No handler in this package may set its own Content-Security-Policy.
452//
453// rastrillo's securityHeaders sets the default on every response, and a
454// handler's own header REPLACES it — the one move that turns ruling
455// 21's carve-out into a blanket exemption, and it would show up in no
456// rendered page.
457//
458// What this adds over TestServedCSPAllowsNoInlineScript, which does now
459// reach all twenty-three routes: a route added later and not yet swept,
460// and a code path inside a handler that the sweep's one canned request
461// does not take — a branch behind a form value, an error path.
462//
463// What it does NOT add, contrary to an earlier version of this comment:
464// coverage of the three passkey ceremony endpoints. Those handlers live
465// in rastrillo's passkey package, and this globs *.go in THIS package,
466// so it never reads them. The runtime sweep is what covers those.
467//
468// It is also evadable by anyone trying: a header key built by
469// concatenation does not contain the literal string. That is acceptable
470// — this is the belt and the sweep is the braces, and the braces reach
471// every route.
472func TestNoHandlerSetsItsOwnCSP(t *testing.T) {
473 files, err := filepath.Glob("*.go")
474 if err != nil {
475 t.Fatal(err)
476 }
477 var checked int
478 for _, f := range files {
479 if strings.HasSuffix(f, "_test.go") {
480 continue
481 }
482 checked++
483 b, err := os.ReadFile(f)
484 if err != nil {
485 t.Fatal(err)
486 }
487 if bytes.Contains(bytes.ToLower(b), []byte("content-security-policy")) {
488 t.Errorf("%s mentions Content-Security-Policy; a handler's own header replaces "+
489 "rastrillo's default, which is how the passkey carve-out would quietly "+
490 "become a blanket one", f)
491 }
492 }
493 if checked == 0 {
494 t.Fatal("scanned no files; the guard would pass by not looking")
495 }
496}
497
498// busyScript is the one script every non-hop page carries: estilo's busy
499// rule, a progressive enhancement over forms that work without it.
500var busyScript = regexp.MustCompile(`<script defer src=["']/static/busy\.js\?v=[0-9a-f]{16}["']></script>`)
501
502// hasOtherScript reports whether a rendered page carries any script
503// beyond the busy rule — what "no script" means for a page since the
504// busy rule arrived.
505func hasOtherScript(body string) bool {
506 return strings.Contains(busyScript.ReplaceAllString(body, ""), "<script")
507}
508