feat(checks): add check 28 — public page handlers inject admin toolbar

New AST check scans handler render functions that build site settings
(getSiteSettings + doc["site_settings"]) and fails if they don't also set
siteSettings["toolbar"], so admin-toolbar injection can't silently regress
when a new public page type is added.

- toolbar.go: AST scanner; exempts renderListingPage (auto SEO pages) and
  renderVersionPreview (carries its own preview banner).
- check_toolbar.go: registration at Seq 280 (ID "28").
- registry_test.go: +1 expected check; golden fixtures regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Dunmow 2026-06-17 22:37:59 +08:00
parent 7599fff971
commit c048766075
6 changed files with 141 additions and 0 deletions

37
check_toolbar.go Normal file
View File

@ -0,0 +1,37 @@
package main
import "fmt"
func init() {
register(Check{
Seq: 280,
ID: "28",
Title: "Public page handlers inject admin toolbar data",
Run: func(ctx *ScanContext, rep *Reporter) {
fmt.Println("=== Check 28: Public page handlers inject admin toolbar data ===")
var violations []toolbarViolation
for _, target := range ctx.backendTargets {
for _, v := range checkToolbarInjection(target.root) {
v.file = prefixDisplayPath(target.display, v.file)
violations = append(violations, v)
}
}
if len(violations) > 0 {
fmt.Printf(" FAIL: %d public page handler(s) missing toolbar injection:\n", len(violations))
for _, v := range violations {
fmt.Printf(" %s:%d func %s — missing siteSettings[\"toolbar\"] for admin toolbar\n", v.file, v.line, v.funcName)
}
fmt.Println("\n Fix: Add auth check + toolbar injection between getSiteSettings() and doc[\"site_settings\"]:")
fmt.Println(" if claims, ok := auth.GetUserFromContext(ctx); ok && claims != nil {")
fmt.Println(" toolbarData := h.buildToolbarData(ctx, r, page, systemTemplate, pageTemplateKey)")
fmt.Println(" siteSettings[\"toolbar\"] = toolbarData")
fmt.Println(" }")
rep.Fail()
} else {
fmt.Println(" OK: All public page handlers inject admin toolbar data")
}
fmt.Println()
},
})
}

View File

@ -33,6 +33,7 @@
// 25. sqlc compile and buf generate must succeed for scanned roots that define them
// 26. Orchestrator backend tests must pass when checking the core BlockNinja repo
// 27. No hand-rolled HTML sanitization — use bluemonday
// 28. Public page handlers inject admin toolbar data
package main
import (

View File

@ -18,6 +18,7 @@ func TestRegistryOrder(t *testing.T) {
"1", "2", "2b", "2c", "2d", "2e", "2f", "3", "3b", "4",
"5", "6", "7", "8", "9", "10", "10b", "10disc", "11", "12",
"13", "14", "15", "16", "17", "18", "19", "20", "21", "22",
"28",
}
ordered := make([]Check, len(registry))

View File

@ -87,3 +87,6 @@
=== Check 22: No hand-rolled HTML sanitization (use bluemonday) ===
OK: No hand-rolled HTML sanitization detected
=== Check 28: Public page handlers inject admin toolbar data ===
OK: All public page handlers inject admin toolbar data

View File

@ -108,3 +108,6 @@
=== Check 22: No hand-rolled HTML sanitization (use bluemonday) ===
OK: No hand-rolled HTML sanitization detected
=== Check 28: Public page handlers inject admin toolbar data ===
OK: All public page handlers inject admin toolbar data

96
toolbar.go Normal file
View File

@ -0,0 +1,96 @@
package main
import (
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
)
type toolbarViolation struct {
file string
line int
funcName string
}
// Functions that render full public pages but legitimately skip toolbar injection.
var toolbarExemptFuncs = map[string]bool{
// Auto-generated SEO listing pages — not block-editable.
"renderListingPage": true,
// Historical version preview — has its own preview banner; toolbar edit/publish
// buttons would be misleading in that context.
"renderVersionPreview": true,
}
// checkToolbarInjection scans handler Go files for public page render functions
// (identified by containing both getSiteSettings and doc["site_settings"]) and
// verifies each one also injects siteSettings["toolbar"] for the admin toolbar.
func checkToolbarInjection(root string) []toolbarViolation {
var violations []toolbarViolation
handlersDir := filepath.Join(root, "internal", "handlers")
if _, err := os.Stat(handlersDir); err != nil {
return nil
}
_ = filepath.Walk(handlersDir, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || !strings.HasSuffix(path, ".go") {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil
}
srcText := string(src)
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, path, src, 0)
if err != nil {
return nil
}
relPath, _ := filepath.Rel(root, path)
for _, decl := range f.Decls {
fn, ok := decl.(*ast.FuncDecl)
if !ok || fn.Body == nil {
continue
}
if toolbarExemptFuncs[fn.Name.Name] {
continue
}
bodyStart := fset.Position(fn.Body.Pos()).Offset
bodyEnd := fset.Position(fn.Body.End()).Offset
if bodyEnd > len(srcText) {
bodyEnd = len(srcText)
}
body := srcText[bodyStart:bodyEnd]
hasSiteSettings := strings.Contains(body, "getSiteSettings")
hasDocAssign := strings.Contains(body, `doc["site_settings"]`)
if hasSiteSettings && hasDocAssign {
hasToolbar := strings.Contains(body, `siteSettings["toolbar"]`)
if !hasToolbar {
violations = append(violations, toolbarViolation{
file: relPath,
line: fset.Position(fn.Pos()).Line,
funcName: fn.Name.Name,
})
}
}
}
return nil
})
return violations
}