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

@@ -17,6 +17,7 @@ import (
"git.golem15.com/golem15/summercms/modules/towel"
"golang.org/x/net/html"
"golang.org/x/net/html/atom"
"gorm.io/gorm"
)
// Partial render caps (T-10.1-12). Exceeding any of them is an error, never a
@@ -261,24 +262,222 @@ var trustedTemplateTypes = map[reflect.Type]bool{
reflect.TypeOf(template.Srcset("")): true,
}
// refusedViewModel reports why a view model may not reach a template (D-10):
// it is (a pointer to, or a collection of) the controller's own model type,
// or its type contains one of html/template's trusted content types.
// viewModelBudget bounds how many values refusedViewModel inspects while it
// resolves interface-typed members. A curated view model is small; one that
// is not is refused rather than walked without limit.
const viewModelBudget = 10000
var (
tablerType = reflect.TypeOf((*interface{ TableName() string })(nil)).Elem()
gormModelType = reflect.TypeOf(gorm.Model{})
gormDeletedAtTyp = reflect.TypeOf(gorm.DeletedAt{})
)
// refusedViewModel reports why a view model may not reach a template (D-10,
// T-10.1-09). It walks the view model's type through pointers, slices,
// arrays, maps, struct fields and the results of its methods (html/template
// calls methods, and emits a trusted result unescaped), and the values held
// in interface-typed members such as map[string]any. It refuses:
// - the controller's own model type anywhere in that structure;
// - any other GORM model: a struct with a TableName method, a gorm struct
// tag, gorm.Model or gorm.DeletedAt;
// - html/template's trusted content types.
//
// A method that returns an interface is not called, so what it returns at
// run time is not checked.
func refusedViewModel(cc *CompiledController, vm any) string {
if vm == nil {
return ""
}
g := &viewModelGuard{types: map[reflect.Type]string{}, holds: map[reflect.Type]bool{}, visited: map[viewModelVisit]bool{}, budget: viewModelBudget}
if src, ok := cc.Controller.(pact.AdminRecordSource); ok && src != nil {
if model := src.NewRecord(); model != nil && baseType(reflect.TypeOf(vm)) == baseType(reflect.TypeOf(model)) {
return "the view model is the controller's model"
if model := src.NewRecord(); model != nil {
g.model = baseType(reflect.TypeOf(model))
}
}
if carriesTrustedContent(reflect.TypeOf(vm), map[reflect.Type]bool{}) {
return "the view model carries pre-escaped html/template content"
return g.value(reflect.ValueOf(vm))
}
type viewModelVisit struct {
ptr uintptr
typ reflect.Type
}
type viewModelGuard struct {
model reflect.Type
types map[reflect.Type]string
holds map[reflect.Type]bool
visited map[viewModelVisit]bool
budget int
}
// value checks a value's static type, then descends into the values only
// where the type holds an interface, whose dynamic content the type cannot
// show.
func (g *viewModelGuard) value(v reflect.Value) string {
if !v.IsValid() {
return ""
}
g.budget--
if g.budget < 0 {
return "the view model is too large to inspect"
}
if v.Kind() == reflect.Interface {
if v.IsNil() {
return ""
}
return g.value(v.Elem())
}
if reason := g.typeReason(v.Type()); reason != "" {
return reason
}
if !g.holdsInterface(v.Type()) {
return ""
}
switch v.Kind() {
case reflect.Pointer, reflect.Map, reflect.Slice:
if v.IsNil() {
return ""
}
key := viewModelVisit{ptr: v.Pointer(), typ: v.Type()}
if g.visited[key] {
return ""
}
g.visited[key] = true
}
switch v.Kind() {
case reflect.Pointer:
return g.value(v.Elem())
case reflect.Struct:
for i := 0; i < v.NumField(); i++ {
if reason := g.value(v.Field(i)); reason != "" {
return reason
}
}
case reflect.Slice, reflect.Array:
for i := 0; i < v.Len(); i++ {
if reason := g.value(v.Index(i)); reason != "" {
return reason
}
}
case reflect.Map:
iter := v.MapRange()
for iter.Next() {
if reason := g.value(iter.Key()); reason != "" {
return reason
}
if reason := g.value(iter.Value()); reason != "" {
return reason
}
}
}
return ""
}
// typeReason is the refusal reason of a type and everything reachable from
// it, or "". A type in progress counts as accepted, which ends cycles; the
// type that closes the cycle is still checked on its own.
func (g *viewModelGuard) typeReason(t reflect.Type) string {
if t == nil {
return ""
}
if reason, ok := g.types[t]; ok {
return reason
}
g.types[t] = ""
reason := g.ownTypeReason(t)
g.types[t] = reason
return reason
}
func (g *viewModelGuard) ownTypeReason(t reflect.Type) string {
if trustedTemplateTypes[t] {
return "the view model carries pre-escaped html/template content"
}
if g.model != nil && t == g.model {
return "the view model carries the controller's model"
}
if gormModel(t) {
return "the view model carries a GORM model (" + t.String() + ")"
}
switch t.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array:
if reason := g.typeReason(t.Elem()); reason != "" {
return reason
}
case reflect.Map:
if reason := g.typeReason(t.Key()); reason != "" {
return reason
}
if reason := g.typeReason(t.Elem()); reason != "" {
return reason
}
case reflect.Struct:
for i := 0; i < t.NumField(); i++ {
if reason := g.typeReason(t.Field(i).Type); reason != "" {
return reason
}
}
}
methodSets := []reflect.Type{t}
if t.Kind() != reflect.Pointer && t.Kind() != reflect.Interface {
methodSets = append(methodSets, reflect.PointerTo(t))
}
for _, mt := range methodSets {
for i := 0; i < mt.NumMethod(); i++ {
method := mt.Method(i)
for o := 0; o < method.Type.NumOut(); o++ {
if reason := g.typeReason(method.Type.Out(o)); reason != "" {
return "method " + method.Name + " of " + t.String() + ": " + reason
}
}
}
}
return ""
}
// holdsInterface reports whether a value of type t can hold an interface
// value, whose dynamic type only the value walk can check.
func (g *viewModelGuard) holdsInterface(t reflect.Type) bool {
if held, ok := g.holds[t]; ok {
return held
}
g.holds[t] = false
held := false
switch t.Kind() {
case reflect.Interface:
held = true
case reflect.Pointer, reflect.Slice, reflect.Array:
held = g.holdsInterface(t.Elem())
case reflect.Map:
held = g.holdsInterface(t.Key()) || g.holdsInterface(t.Elem())
case reflect.Struct:
for i := 0; i < t.NumField() && !held; i++ {
held = g.holdsInterface(t.Field(i).Type)
}
}
g.holds[t] = held
return held
}
// gormModel reports whether t is a GORM model: a struct with a TableName
// method, a field with a gorm struct tag, or gorm.Model or gorm.DeletedAt
// itself (both also reached as embedded fields).
func gormModel(t reflect.Type) bool {
if t.Kind() != reflect.Struct {
return false
}
if t == gormModelType || t == gormDeletedAtTyp || t.Implements(tablerType) || reflect.PointerTo(t).Implements(tablerType) {
return true
}
for i := 0; i < t.NumField(); i++ {
if _, ok := t.Field(i).Tag.Lookup("gorm"); ok {
return true
}
}
return false
}
// baseType strips pointers and the element types of slices, arrays and maps.
func baseType(t reflect.Type) reflect.Type {
for t != nil {
@@ -292,29 +491,6 @@ func baseType(t reflect.Type) reflect.Type {
return t
}
func carriesTrustedContent(t reflect.Type, seen map[reflect.Type]bool) bool {
if t == nil || seen[t] {
return false
}
seen[t] = true
if trustedTemplateTypes[t] {
return true
}
switch t.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array:
return carriesTrustedContent(t.Elem(), seen)
case reflect.Map:
return carriesTrustedContent(t.Key(), seen) || carriesTrustedContent(t.Elem(), seen)
case reflect.Struct:
for i := 0; i < t.NumField(); i++ {
if carriesTrustedContent(t.Field(i).Type, seen) {
return true
}
}
}
return false
}
// partial serves GET .../{controller}/partials/{name} (D-09, D-10, D-11,
// D-17). Without ?id= the view model gets a nil record (a header partial, or
// a form partial on the create form). With ?id= the name must belong to a