fix(10.1): WR-03 walk the whole partial view model before rendering

refusedViewModel compared only the top-level type with the controller's
model. It now walks the type through pointers, slices, arrays, maps,
struct fields and the results of exported methods, and the values held
in interface-typed members, refusing the controller's model, any other
GORM model (TableName, a gorm tag, gorm.Model, gorm.DeletedAt) and
html/template's trusted content types anywhere in that structure.
This commit is contained in:
Jakub Zych
2026-09-29 09:54:35 +02:00
parent 5bbb0ada05
commit 7333f450ad
4 changed files with 277 additions and 33 deletions

View File

@@ -11,11 +11,13 @@ import (
"reflect"
"strings"
"testing"
"time"
"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"
"gorm.io/gorm"
)
// sanitizeHTML runs raw markup through the same fragment parse and allowlist
@@ -237,16 +239,49 @@ func TestPhase101PartialSanitizer(t *testing.T) {
Items []struct{ A template.HTMLAttr }
}{},
"template.URL": map[string]template.URL{},
// WR-03: the model nested in a wrapper, behind an interface, as
// another GORM model, or reached through a method.
"wrapped model": struct{ Gadget *extGadget }{},
"wrapped model slice": struct{ Rows []struct{ G extGadget } }{},
"model in map[string]any": map[string]any{"gadget": &extGadget{Name: "top-secret"}},
"model in []any": []any{1, extGadget{}},
"model in an any field": struct{ Row any }{Row: []*extGadget{{}}},
"HTML in map[string]any": map[string]any{"banner": template.HTML("<b>x</b>")},
"another tabler model": struct{ User *BackendUser }{},
"gorm-tagged struct": struct{ Row vmTagged }{},
"embedded gorm.Model": struct{ vmGormModel }{},
"gorm.DeletedAt": struct{ Deleted gorm.DeletedAt }{},
"method returns HTML": vmHTMLMethod{},
"pointer method HTML": struct{ Inner vmPtrHTMLMethod }{},
"method with an argument": vmArgHTMLMethod{},
"method returns the model": vmModelMethod{},
} {
if refusedViewModel(cc, vm) == "" {
t.Fatalf("%s view model was accepted", name)
}
}
selfRef := &vmNode{Label: "a"}
selfRef.Next = selfRef
loop := map[string]any{"n": 1}
loop["self"] = loop
for name, vm := range map[string]any{
"nil": nil,
"curated": struct{ Name string }{},
"items": struct{ Items []extStatItem }{},
"string": "text",
// The shape of a curated statistics view model: labels and
// integers, including behind interfaces.
"stats view": struct {
Total int
Formats []extStatItem
NoShelf int
}{Total: 3, Formats: []extStatItem{{Label: "x", Count: 3}}},
"curated map": map[string]any{"total": 3, "items": []extStatItem{{}}, "label": "x", "none": nil},
"time field": struct{ At time.Time }{At: time.Now()},
"self reference": selfRef,
"cyclic map": loop,
"plain method": vmPlainMethod{},
"nil pointer field": struct{ Next *vmNode }{},
} {
if reason := refusedViewModel(cc, vm); reason != "" {
t.Fatalf("%s view model refused: %s", name, reason)
@@ -282,3 +317,35 @@ type leakyController struct{ extController }
func (leakyController) PartialData(context.Context, string, any) (any, error) {
return &extGadget{Name: "top-secret"}, nil
}
// View-model fixtures of the guard (WR-03).
type vmTagged struct {
Secret string `gorm:"column:secret"`
}
type vmGormModel struct{ gorm.Model }
type vmHTMLMethod struct{ Body string }
func (v vmHTMLMethod) Banner() template.HTML { return template.HTML(v.Body) }
type vmPtrHTMLMethod struct{ Body string }
func (v *vmPtrHTMLMethod) Banner() template.HTML { return template.HTML(v.Body) }
type vmArgHTMLMethod struct{}
func (vmArgHTMLMethod) Wrap(s string) template.HTML { return template.HTML(s) }
type vmModelMethod struct{}
func (vmModelMethod) Gadget() *extGadget { return &extGadget{} }
type vmPlainMethod struct{ N int }
func (v vmPlainMethod) Double() int { return v.N * 2 }
type vmNode struct {
Label string
Next *vmNode
}