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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user