- acme fixture plugin under modules/cabana/testdata/extension (gadgets controller, header and form partials, lookup widget, JS and CSS) - TestPhase101FormExtensionSchema, TestPhase101PartialSchema and TestPhase101Toolbar: every widget, partial and toolbar boot rule - TestPhase101PartialSanitizer: tag, attribute and URL allowlist, escaping, per-request trans, size/node/depth caps and the view-model guard - TestPhase101Assets: exact-key asset hits, revalidation, SPA fall-through, boot path checks and ?v= schema URLs - TestPhase101Actions (PostgreSQL): scoping, fill filter, strict body, action permission, error mapping, CSRF header, toolbar and partial routes - TestPhase101BoardwalkExports: ContentType and SetSecurityHeaders
285 lines
11 KiB
Go
285 lines
11 KiB
Go
package cabana
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"html/template"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/towel"
|
|
"golang.org/x/net/html"
|
|
"golang.org/x/net/html/atom"
|
|
)
|
|
|
|
// sanitizeHTML runs raw markup through the same fragment parse and allowlist
|
|
// walk a rendered partial goes through, without html/template in front (which
|
|
// would already strip comments).
|
|
func sanitizeHTML(t *testing.T, src string) []PartialNode {
|
|
t.Helper()
|
|
container := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
|
|
parsed, err := html.ParseFragment(strings.NewReader(src), container)
|
|
if err != nil {
|
|
t.Fatalf("parse %q: %v", src, err)
|
|
}
|
|
nodes, err := sanitizePartialNodes(parsed, 0, &partialBudget{nodes: partialMaxNodes})
|
|
if err != nil {
|
|
t.Fatalf("sanitize %q: %v", src, err)
|
|
}
|
|
return nodes
|
|
}
|
|
|
|
func nodesJSON(t *testing.T, nodes []PartialNode) string {
|
|
t.Helper()
|
|
raw, err := json.Marshal(nodes)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return string(raw)
|
|
}
|
|
|
|
// renderPartial parses src as a partial template and renders it once.
|
|
func renderPartial(t *testing.T, src string, data any) ([]PartialNode, error) {
|
|
t.Helper()
|
|
compiled, err := parsePartial("probe", []byte(src))
|
|
if err != nil {
|
|
t.Fatalf("parse template: %v", err)
|
|
}
|
|
return compiled.render(context.Background(), nil, data)
|
|
}
|
|
|
|
// TestPhase101PartialSanitizer covers the server half of T-10.1-08, T-10.1-09
|
|
// and T-10.1-12: the tag, attribute and URL allowlist, escaping of view-model
|
|
// data, per-request translation, the size, node and depth caps, and the
|
|
// view-model guard.
|
|
func TestPhase101PartialSanitizer(t *testing.T) {
|
|
t.Run("dropped tags go with their subtree", func(t *testing.T) {
|
|
for tag := range partialDroppedTags {
|
|
var src string
|
|
switch tag {
|
|
case "input", "link", "meta", "base", "embed":
|
|
src = `<p>keep</p><` + tag + ` value="gone" href="/gone" content="gone"><p>tail</p>`
|
|
case "svg", "math":
|
|
src = `<p>keep</p><` + tag + `><text>gone</text><mi>gone</mi></` + tag + `><p>tail</p>`
|
|
case "select":
|
|
src = `<p>keep</p><select><option>gone</option></select><p>tail</p>`
|
|
default:
|
|
src = `<p>keep</p><` + tag + `>gone <b>gone</b></` + tag + `><p>tail</p>`
|
|
}
|
|
got := nodesJSON(t, sanitizeHTML(t, src))
|
|
if strings.Contains(got, "gone") || strings.Contains(got, `"`+tag+`"`) {
|
|
t.Fatalf("%s survived: %s", tag, got)
|
|
}
|
|
if !strings.Contains(got, `"text":"keep"`) || !strings.Contains(got, `"text":"tail"`) {
|
|
t.Fatalf("%s took its siblings with it: %s", tag, got)
|
|
}
|
|
}
|
|
if len(partialDroppedTags) != 19 {
|
|
t.Fatalf("dropped tag list has %d entries, the test expects 19", len(partialDroppedTags))
|
|
}
|
|
})
|
|
|
|
t.Run("unknown elements are unwrapped, children kept", func(t *testing.T) {
|
|
nodes := sanitizeHTML(t, `<article><acme-card><span class="x">kept</span></acme-card> text</article>`)
|
|
want := []PartialNode{{Tag: "span", Attrs: map[string]string{"class": "x"}, Children: []PartialNode{{Text: "kept"}}}, {Text: " text"}}
|
|
if !reflect.DeepEqual(nodes, want) {
|
|
t.Fatalf("nodes = %s", nodesJSON(t, nodes))
|
|
}
|
|
})
|
|
|
|
t.Run("attribute allowlist", func(t *testing.T) {
|
|
nodes := sanitizeHTML(t, `<div id="x" style="color:red" onclick="steal()" onmouseover="steal()" class="c" title="t" lang="pl" dir="rtl" role="note" aria-label="a" data-count="3" formaction="/x" tabindex="1">v</div>`)
|
|
want := map[string]string{"class": "c", "title": "t", "lang": "pl", "dir": "rtl", "role": "note", "aria-label": "a", "data-count": "3"}
|
|
if len(nodes) != 1 || !reflect.DeepEqual(nodes[0].Attrs, want) {
|
|
t.Fatalf("attrs = %s", nodesJSON(t, nodes))
|
|
}
|
|
cells := nodesJSON(t, sanitizeHTML(t, `<table><tbody><tr><td colspan="2" rowspan="1" scope="row" width="9">c</td></tr></tbody></table><time datetime="2026-09-29">d</time><meter value="1" min="0" max="2" low="0" high="2" optimum="1" onload="x()">m</meter><progress value="1" max="3">p</progress><data value="7">n</data>`))
|
|
for _, want := range []string{`"colspan":"2"`, `"rowspan":"1"`, `"scope":"row"`, `"datetime":"2026-09-29"`, `"optimum":"1"`, `"low":"0"`, `"high":"2"`, `"max":"3"`, `"value":"7"`} {
|
|
if !strings.Contains(cells, want) {
|
|
t.Fatalf("%s missing from %s", want, cells)
|
|
}
|
|
}
|
|
if strings.Contains(cells, "width") || strings.Contains(cells, "onload") {
|
|
t.Fatalf("per-tag attributes leaked: %s", cells)
|
|
}
|
|
// A per-tag attribute is not global: colspan on a div is dropped.
|
|
if got := sanitizeHTML(t, `<div colspan="2" href="/x" src="/y">v</div>`); got[0].Attrs != nil {
|
|
t.Fatalf("div attrs = %v", got[0].Attrs)
|
|
}
|
|
})
|
|
|
|
t.Run("link and image URLs", func(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
src, attr string
|
|
keep bool
|
|
}{
|
|
{`<a href="/x">l</a>`, "href", true},
|
|
{`<a href="#top">l</a>`, "href", true},
|
|
{`<a href="/">l</a>`, "href", true},
|
|
{`<a href="//evil.test/x">l</a>`, "href", false},
|
|
{`<a href="/\evil.test">l</a>`, "href", false},
|
|
{`<a href="javascript:alert(1)">l</a>`, "href", false},
|
|
{`<a href="JaVaScRiPt:alert(1)">l</a>`, "href", false},
|
|
{`<a href="https://evil.test">l</a>`, "href", false},
|
|
{`<a href="x">l</a>`, "href", false},
|
|
{"<a href=\"/\t/evil.test\">l</a>", "href", false},
|
|
{`<a href="/ /evil.test">l</a>`, "href", false},
|
|
{`<a href="">l</a>`, "href", false},
|
|
{`<img src="/a.png" alt="a" width="10" height="10">`, "src", true},
|
|
{`<img src="data:image/png;base64,AAAA">`, "src", false},
|
|
{`<img src="#frag">`, "src", false},
|
|
{`<img src="//evil.test/a.png">`, "src", false},
|
|
} {
|
|
nodes := sanitizeHTML(t, tc.src)
|
|
if len(nodes) != 1 {
|
|
t.Fatalf("%s: nodes = %s", tc.src, nodesJSON(t, nodes))
|
|
}
|
|
_, kept := nodes[0].Attrs[tc.attr]
|
|
if kept != tc.keep {
|
|
t.Fatalf("%s: %s kept=%v, want %v (%v)", tc.src, tc.attr, kept, tc.keep, nodes[0].Attrs)
|
|
}
|
|
}
|
|
if got := sanitizeHTML(t, `<img src="/a.png" alt="a" width="10" height="10" onerror="x()">`); !reflect.DeepEqual(got[0].Attrs, map[string]string{"src": "/a.png", "alt": "a", "width": "10", "height": "10"}) {
|
|
t.Fatalf("img attrs = %v", got[0].Attrs)
|
|
}
|
|
})
|
|
|
|
t.Run("comments and doctypes are dropped", func(t *testing.T) {
|
|
got := nodesJSON(t, sanitizeHTML(t, `<p>a<!-- secret -->b</p><!DOCTYPE html><!-- also -->`))
|
|
if strings.Contains(got, "secret") || strings.Contains(got, "also") || strings.Contains(got, "html") {
|
|
t.Fatalf("comment leaked: %s", got)
|
|
}
|
|
nodes, err := renderPartial(t, "<p>a<!-- template comment -->b</p>", nil)
|
|
if err != nil || strings.Contains(nodesJSON(t, nodes), "template comment") {
|
|
t.Fatalf("rendered comment: %v %s", err, nodesJSON(t, nodes))
|
|
}
|
|
})
|
|
|
|
t.Run("view-model markup stays text", func(t *testing.T) {
|
|
hostile := `<script>alert(1)</script><b onclick="x()">bold</b>`
|
|
nodes, err := renderPartial(t, `<p class="n">{{ .Data.Name }}</p><span title="{{ .Data.Name }}">t</span>`, struct{ Name string }{hostile})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
want := []PartialNode{
|
|
{Tag: "p", Attrs: map[string]string{"class": "n"}, Children: []PartialNode{{Text: hostile}}},
|
|
{Tag: "span", Attrs: map[string]string{"title": hostile}, Children: []PartialNode{{Text: "t"}}},
|
|
}
|
|
if !reflect.DeepEqual(nodes, want) {
|
|
t.Fatalf("nodes = %s", nodesJSON(t, nodes))
|
|
}
|
|
})
|
|
|
|
t.Run("trans resolves per request on one compiled partial", func(t *testing.T) {
|
|
_, tr := extTranslator(t)
|
|
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
|
|
stats := cc.partials["stats"]
|
|
vm, _ := extPartialView("stats", nil)
|
|
for _, tc := range []struct{ locale, label string }{{"en", "All gadgets"}, {"pl", "Wszystkie gadżety"}, {"en", "All gadgets"}} {
|
|
nodes, err := stats.render(towel.WithLocale(context.Background(), tc.locale), tr, vm)
|
|
if err != nil {
|
|
t.Fatalf("%s render: %v", tc.locale, err)
|
|
}
|
|
got := nodesJSON(t, nodes)
|
|
if !strings.Contains(got, `"text":"`+tc.label+`"`) || !strings.Contains(got, `"text":"3"`) || !strings.Contains(got, `"class":"summer-stats"`) {
|
|
t.Fatalf("%s nodes = %s", tc.locale, got)
|
|
}
|
|
}
|
|
// The pristine template was never executed, so it can still be cloned.
|
|
if _, err := stats.pristine.Clone(); err != nil {
|
|
t.Fatalf("pristine template was executed: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("caps", func(t *testing.T) {
|
|
big := strings.Repeat("x", partialMaxBytes+1)
|
|
if _, err := renderPartial(t, `<p>{{ .Data }}</p>`, big); !errors.Is(err, errPartialTooLarge) {
|
|
t.Fatalf("size cap err = %v", err)
|
|
}
|
|
if _, err := renderPartial(t, `<p>{{ .Data }}</p>`, strings.Repeat("x", partialMaxBytes-len("<p></p>"))); err != nil {
|
|
t.Fatalf("output at the size cap: %v", err)
|
|
}
|
|
// Every <b>x</b> is two nodes: the element and its text.
|
|
atCap := strings.Repeat("<b>x</b>", partialMaxNodes/2)
|
|
if _, err := renderPartial(t, atCap, nil); err != nil {
|
|
t.Fatalf("%d nodes: %v", partialMaxNodes, err)
|
|
}
|
|
if _, err := renderPartial(t, atCap+"y", nil); err == nil || !strings.Contains(err.Error(), "2000 nodes") {
|
|
t.Fatalf("node cap err = %v", err)
|
|
}
|
|
nested := func(depth int) string {
|
|
return strings.Repeat("<div>", depth) + "x" + strings.Repeat("</div>", depth)
|
|
}
|
|
if _, err := renderPartial(t, nested(partialMaxDepth), nil); err != nil {
|
|
t.Fatalf("depth %d: %v", partialMaxDepth, err)
|
|
}
|
|
if _, err := renderPartial(t, nested(partialMaxDepth+1), nil); err == nil || !strings.Contains(err.Error(), "depth 32") {
|
|
t.Fatalf("depth cap err = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("view-model guard", func(t *testing.T) {
|
|
_, cc := mustCompileExt(t, newExtController(), os.DirFS(extDir))
|
|
for name, vm := range map[string]any{
|
|
"model": extGadget{},
|
|
"pointer": &extGadget{},
|
|
"slice": []extGadget{{}},
|
|
"slice of ptr": []*extGadget{{}},
|
|
"map of model": map[string]*extGadget{},
|
|
"template.HTML": struct{ Body template.HTML }{},
|
|
"nested HTMLAttr": struct {
|
|
Items []struct{ A template.HTMLAttr }
|
|
}{},
|
|
"template.URL": map[string]template.URL{},
|
|
} {
|
|
if refusedViewModel(cc, vm) == "" {
|
|
t.Fatalf("%s view model was accepted", name)
|
|
}
|
|
}
|
|
for name, vm := range map[string]any{
|
|
"nil": nil,
|
|
"curated": struct{ Name string }{},
|
|
"items": struct{ Items []extStatItem }{},
|
|
"string": "text",
|
|
} {
|
|
if reason := refusedViewModel(cc, vm); reason != "" {
|
|
t.Fatalf("%s view model refused: %s", name, reason)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("the partial route refuses a model view model", func(t *testing.T) {
|
|
app, _ := extTranslator(t)
|
|
reg, _ := mustCompileExt(t, leakyController{newExtController()}, os.DirFS(extDir))
|
|
svc := &service{app: app, reg: reg}
|
|
// _summary.htm reads .Data.Name, which the model has too: without
|
|
// the guard the model would render.
|
|
req := httptest.NewRequest(http.MethodGet, adminAPI("/acme/demo/gadgets/partials/summary"), nil)
|
|
req.SetPathValue("vendor", "acme")
|
|
req.SetPathValue("plugin", "demo")
|
|
req.SetPathValue("controller", "gadgets")
|
|
req.SetPathValue("name", "summary")
|
|
req = req.WithContext(bouncer.WithUser(req.Context(), &bouncer.Principal{ID: 1, Backend: true, IsSuperuser: true}))
|
|
rec := httptest.NewRecorder()
|
|
svc.partial(rec, req)
|
|
if rec.Code != http.StatusInternalServerError || strings.Contains(rec.Body.String(), "top-secret") {
|
|
t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
assertErrorCode(t, rec.Body.Bytes(), "error")
|
|
})
|
|
}
|
|
|
|
// leakyController hands its GORM model to the template, which the partial
|
|
// handler must refuse.
|
|
type leakyController struct{ extController }
|
|
|
|
func (leakyController) PartialData(context.Context, string, any) (any, error) {
|
|
return &extGadget{Name: "top-secret"}, nil
|
|
}
|