feat(10-01): harden the admin cookie session and prefix boot guards

- refresh and logout read the Bearer header first, then the summer_admin
  cookie; a cookie refresh rotates the cookie without a token in the body and
  logout always expires the cookie
- backend.cookie_secure (default true) may drop Secure outside production only
- activation rejects controller vendor segments api, assets, login, settings
- BuildRouter rejects non-cabana routes at or under the admin prefix
- SPA single-flights refresh on 401, replays once, and refreshes proactively
  at 80 percent of expires_in; dist rebuilt
- scripts/check-admin-dist.sh rebuilds the SPA and fails on dist drift
- tests: TestPhase10CookieAuth, TestPhase10CSRF, TestPhase10Prefix,
  TestPhase10AdminPrefixCollision, boardwalk serving and header tests
This commit is contained in:
Jakub Zych
2026-09-27 15:34:19 +02:00
parent 5f9353841b
commit dafdb18234
15 changed files with 1283 additions and 18 deletions

317
boardwalk/boardwalk_test.go Normal file
View File

@@ -0,0 +1,317 @@
package boardwalk
import (
"io/fs"
"net/http"
"net/http/httptest"
"path"
"regexp"
"strings"
"testing"
"testing/fstest"
)
const testPrefix = "/acme-admin"
var (
assetRef = regexp.MustCompile(`(?:src|href)="([^"]+)"`)
scriptTag = regexp.MustCompile(`<script\b[^>]*>`)
)
type apiSpy struct{ calls int }
func (s *apiSpy) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
s.calls++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusNotFound)
_, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"Not found","details":{}}}`))
}
func newTestHandler(t *testing.T) (http.Handler, *apiSpy) {
t.Helper()
spy := &apiSpy{}
h, err := Handler(testPrefix, spy)
if err != nil {
t.Fatal(err)
}
return h, spy
}
func get(h http.Handler, target string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
return rec
}
func TestIndexRewrite(t *testing.T) {
h, _ := newTestHandler(t)
for _, target := range []string{testPrefix, testPrefix + "/", testPrefix + "/index.html"} {
rec := get(h, target)
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("%s status=%d", target, rec.Code)
}
if !strings.Contains(body, `<meta name="summer-admin-base" content="`+testPrefix+`"`) {
t.Fatalf("%s index has no injected base: %s", target, body)
}
if strings.Contains(body, BaseToken) || strings.Contains(body, `="./`) {
t.Fatalf("%s index was not rewritten: %s", target, body)
}
if !strings.Contains(body, `src="`+testPrefix+`/assets/`) {
t.Fatalf("%s index script is not under the prefix: %s", target, body)
}
}
}
func TestRewriteIndexRequiresToken(t *testing.T) {
if _, err := RewriteIndex([]byte(`<html><script src="./assets/a.js"></script></html>`), testPrefix); err == nil {
t.Fatal("index without the base token was accepted")
}
root := fstest.MapFS{"index.html": &fstest.MapFile{Data: []byte("<html></html>")}}
if _, err := newHandler(root, testPrefix, &apiSpy{}); err == nil || !strings.Contains(err.Error(), BaseToken) {
t.Fatalf("stale dist accepted: %v", err)
}
if _, err := newHandler(fstest.MapFS{}, testPrefix, &apiSpy{}); err == nil {
t.Fatal("dist without index.html accepted")
}
if _, err := Handler(testPrefix, nil); err == nil {
t.Fatal("nil API not-found handler accepted")
}
if _, err := Handler("acme-admin", &apiSpy{}); err == nil {
t.Fatal("prefix without a leading slash accepted")
}
}
func TestRewriteIndexEscapesPrefix(t *testing.T) {
out, err := RewriteIndex([]byte(`<meta content="`+BaseToken+`"><script src="./a.js"></script>`), `/a"b`)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(out), `"/a"b`) || !strings.Contains(string(out), `/a&#34;b`) {
t.Fatalf("prefix not escaped: %s", out)
}
}
func TestEveryReferencedAssetIsEmbedded(t *testing.T) {
root, err := Dist()
if err != nil {
t.Fatal(err)
}
raw, err := fs.ReadFile(root, "index.html")
if err != nil {
t.Fatal(err)
}
index, err := RewriteIndex(raw, testPrefix)
if err != nil {
t.Fatal(err)
}
refs := assetRef.FindAllStringSubmatch(string(index), -1)
if len(refs) == 0 {
t.Fatal("index references no assets")
}
h, _ := newTestHandler(t)
for _, ref := range refs {
target := ref[1]
if !strings.HasPrefix(target, testPrefix+"/") {
t.Fatalf("reference %q is not under the prefix", target)
}
name := strings.TrimPrefix(target, testPrefix+"/")
if _, err := fs.Stat(root, name); err != nil {
t.Fatalf("index references %s, missing from the embedded dist: %v", name, err)
}
if rec := get(h, target); rec.Code != http.StatusOK {
t.Fatalf("GET %s status=%d", target, rec.Code)
}
}
}
func TestNoInlineScript(t *testing.T) {
root, err := Dist()
if err != nil {
t.Fatal(err)
}
raw, err := fs.ReadFile(root, "index.html")
if err != nil {
t.Fatal(err)
}
tags := scriptTag.FindAllString(string(raw), -1)
if len(tags) == 0 {
t.Fatal("index has no module script")
}
for _, tag := range tags {
if !strings.Contains(tag, " src=") {
t.Fatalf("inline script in index.html: %s", tag)
}
}
if strings.Contains(strings.ToLower(string(raw)), "javascript:") {
t.Fatal("index.html contains a javascript: URL")
}
}
func TestAPIPathsAreDelegated(t *testing.T) {
h, spy := newTestHandler(t)
for _, target := range []string{testPrefix + "/api", testPrefix + "/api/", testPrefix + "/api/v1/nope", testPrefix + "/api/v1/auth/login"} {
before := spy.calls
rec := get(h, target)
if spy.calls != before+1 || rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "<html") {
t.Fatalf("%s was not delegated: status=%d body=%s", target, rec.Code, rec.Body.String())
}
}
before := spy.calls
if rec := get(h, testPrefix+"/apiary"); rec.Code != http.StatusOK || spy.calls != before {
t.Fatalf("/apiary is a client route, status=%d", rec.Code)
}
}
func TestMissingFileWithExtensionIs404(t *testing.T) {
h, _ := newTestHandler(t)
for _, target := range []string{testPrefix + "/assets/missing.js", testPrefix + "/favicon.ico", testPrefix + "/golem/missing.css"} {
rec := get(h, target)
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "summer-admin-base") {
t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String())
}
}
for _, target := range []string{testPrefix + "/acme/demo/widgets", testPrefix + "/acme/demo/widgets/12", testPrefix + "/login"} {
rec := get(h, target)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "summer-admin-base") {
t.Fatalf("client route %s status=%d", target, rec.Code)
}
}
}
func TestTraversalIsCleaned(t *testing.T) {
root := fstest.MapFS{
"index.html": &fstest.MapFile{Data: []byte(`<meta content="` + BaseToken + `">`)},
"assets/app.js": &fstest.MapFile{Data: []byte("console.log(1)")},
}
h, err := newHandler(root, testPrefix, &apiSpy{})
if err != nil {
t.Fatal(err)
}
for _, target := range []string{
testPrefix + "/../../../go.mod",
testPrefix + "/assets/../../boardwalk.go",
testPrefix + "/%2e%2e/%2e%2e/etc/passwd.txt",
} {
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.URL.Path = target
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound || strings.Contains(rec.Body.String(), "module ") || strings.Contains(rec.Body.String(), "package ") {
t.Fatalf("%s status=%d body=%s", target, rec.Code, rec.Body.String())
}
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.URL.Path = testPrefix + "/assets/../index.html"
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), BaseToken) {
t.Fatalf("cleaned index path served the raw index: %d %s", rec.Code, rec.Body.String())
}
}
func TestDirectoryIsNeverListed(t *testing.T) {
h, _ := newTestHandler(t)
root, err := Dist()
if err != nil {
t.Fatal(err)
}
entries, err := fs.ReadDir(root, "assets")
if err != nil || len(entries) == 0 {
t.Fatalf("embedded assets: %v", err)
}
for _, target := range []string{testPrefix + "/assets", testPrefix + "/assets/"} {
rec := get(h, target)
body := rec.Body.String()
if strings.Contains(body, entries[0].Name()) || !strings.Contains(body, "summer-admin-base") {
t.Fatalf("%s listed the directory or skipped the shell: %s", target, body)
}
}
}
func TestContentTypesAndCaching(t *testing.T) {
h, _ := newTestHandler(t)
root, err := Dist()
if err != nil {
t.Fatal(err)
}
want := map[string]string{
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".woff2": "font/woff2",
".woff": "font/woff",
}
seen := map[string]bool{}
err = fs.WalkDir(root, "assets", func(name string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() {
return err
}
ext := path.Ext(name)
ct, ok := want[ext]
if !ok || seen[ext] {
return nil
}
seen[ext] = true
rec := get(h, testPrefix+"/"+name)
if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != ct {
t.Fatalf("%s status=%d type=%q, want %q", name, rec.Code, rec.Header().Get("Content-Type"), ct)
}
if cc := rec.Header().Get("Cache-Control"); cc != "public, max-age=31536000, immutable" {
t.Fatalf("%s Cache-Control=%q", name, cc)
}
return nil
})
if err != nil {
t.Fatal(err)
}
for ext := range want {
if !seen[ext] {
t.Fatalf("embedded dist has no %s asset", ext)
}
}
index := get(h, testPrefix)
if index.Header().Get("Cache-Control") != "no-store" || !strings.HasPrefix(index.Header().Get("Content-Type"), "text/html") {
t.Fatalf("index headers = %v", index.Header())
}
if got := contentType("x.svg"); got != "image/svg+xml" {
t.Fatalf("svg type %q", got)
}
if got := contentType("x.json"); got != "application/json" {
t.Fatalf("json type %q", got)
}
if got := contentType("x.unknown-ext"); got != "application/octet-stream" {
t.Fatalf("unknown type %q", got)
}
}
func TestSecurityHeadersOnEveryResponse(t *testing.T) {
h, _ := newTestHandler(t)
root, err := Dist()
if err != nil {
t.Fatal(err)
}
entries, err := fs.ReadDir(root, "assets")
if err != nil || len(entries) == 0 {
t.Fatalf("embedded assets: %v", err)
}
for _, target := range []string{
testPrefix,
testPrefix + "/acme/demo/widgets",
testPrefix + "/assets/" + entries[0].Name(),
testPrefix + "/missing.js",
testPrefix + "/api/v1/nope",
} {
rec := get(h, target)
for header, want := range map[string]string{
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "same-origin",
"X-Frame-Options": "DENY",
"Content-Security-Policy": "frame-ancestors 'none'; base-uri 'none'; object-src 'none'; script-src 'self'",
"X-Robots-Tag": "noindex, nofollow",
} {
if got := rec.Header().Get(header); got != want {
t.Fatalf("%s %s=%q, want %q", target, header, got, want)
}
}
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,7 +6,7 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-ZNCn30hM.js"></script>
<script type="module" crossorigin src="./assets/index-BD2QIjGC.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-UTAit0wB.css">
</head>
<body>