Files
summercms/modules/cabana/partial_render.go
Jakub Zych 7333f450ad 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.
2026-09-29 09:54:35 +02:00

551 lines
16 KiB
Go

package cabana
import (
"bytes"
"context"
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"reflect"
"strconv"
"strings"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/phrasebook"
"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
// truncated tree.
const (
partialMaxBytes = 64 << 10
partialMaxNodes = 2000
partialMaxDepth = 32
)
// compiledPartial is one controller partial template, parsed at boot. The
// pristine template is never executed: html/template refuses to Clone a
// template after Execute, so every render executes a clone.
type compiledPartial struct {
name string
pristine *template.Template
}
// partialFuncs are the functions a partial template may call. trans is bound
// per request; the boot parse only needs the name to exist.
func partialFuncs(trans func(string) string) template.FuncMap {
return template.FuncMap{"trans": trans}
}
// parsePartial parses a partial template with html/template, so contextual
// escaping stays on for every value the view model supplies (D-10).
func parsePartial(name string, src []byte) (*compiledPartial, error) {
tpl, err := template.New(name).Funcs(partialFuncs(func(key string) string { return key })).Parse(string(src))
if err != nil {
return nil, err
}
return &compiledPartial{name: name, pristine: tpl}, nil
}
// render executes the partial against the curated view model and returns the
// allowlisted node tree (D-10, D-17). The template's root is {"Data": data}.
func (p *compiledPartial) render(ctx context.Context, tr *phrasebook.Translator, data any) ([]PartialNode, error) {
tpl, err := p.pristine.Clone()
if err != nil {
return nil, err
}
tpl.Funcs(partialFuncs(func(key string) string { return translateKey(ctx, tr, key) }))
out := &cappedBuffer{limit: partialMaxBytes}
if err := tpl.Execute(out, map[string]any{"Data": data}); err != nil {
return nil, err
}
container := &html.Node{Type: html.ElementNode, Data: "div", DataAtom: atom.Div}
parsed, err := html.ParseFragment(bytes.NewReader(out.buf.Bytes()), container)
if err != nil {
return nil, err
}
budget := &partialBudget{nodes: partialMaxNodes}
return sanitizePartialNodes(parsed, 0, budget)
}
var errPartialTooLarge = errors.New("cabana: partial output exceeds the size cap")
// cappedBuffer fails a write that would grow past limit bytes.
type cappedBuffer struct {
buf bytes.Buffer
limit int
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
if b.buf.Len()+len(p) > b.limit {
return 0, errPartialTooLarge
}
return b.buf.Write(p)
}
type partialBudget struct {
nodes int
}
func (b *partialBudget) take() error {
b.nodes--
if b.nodes < 0 {
return fmt.Errorf("cabana: partial output exceeds %d nodes", partialMaxNodes)
}
return nil
}
// partialTags are the elements a partial may emit (RESEARCH Pattern 4; the
// SPA mirrors the same list).
var partialTags = setOf("div", "span", "p", "strong", "em", "b", "i", "u", "s", "small", "mark",
"code", "pre", "br", "hr", "ul", "ol", "li", "dl", "dt", "dd", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "tfoot", "tr", "th", "td", "caption", "section", "header", "footer",
"figure", "figcaption", "blockquote", "q", "abbr", "time", "data", "meter", "progress", "sup",
"sub", "a", "img")
// partialDroppedTags are removed together with everything inside them.
var partialDroppedTags = setOf("script", "style", "template", "iframe", "object", "embed",
"noscript", "textarea", "title", "xmp", "svg", "math", "form", "input", "button", "select",
"link", "meta", "base")
// partialGlobalAttrs are allowed on every allowlisted element, next to aria-*
// and data-*.
var partialGlobalAttrs = setOf("class", "title", "lang", "dir", "role")
// partialTagAttrs are the per-element attributes. a[href] and img[src] also
// pass safePartialURL.
var partialTagAttrs = map[string]map[string]bool{
"a": setOf("href"),
"img": setOf("src", "alt", "width", "height"),
"td": setOf("colspan", "rowspan", "scope"),
"th": setOf("colspan", "rowspan", "scope"),
"time": setOf("datetime"),
"data": setOf("value"),
"meter": setOf("value", "min", "max", "low", "high", "optimum"),
"progress": setOf("value", "max"),
}
func setOf(items ...string) map[string]bool {
out := make(map[string]bool, len(items))
for _, item := range items {
out[item] = true
}
return out
}
// sanitizePartialNodes walks parsed nodes through the allowlist. Allowed
// elements keep their allowlisted attributes, dropped elements lose their
// whole subtree, any other element is unwrapped (its children kept), and
// comments and doctypes disappear. The result is never nil.
func sanitizePartialNodes(nodes []*html.Node, depth int, budget *partialBudget) ([]PartialNode, error) {
out := []PartialNode{}
for _, node := range nodes {
converted, err := sanitizePartialNode(node, depth, budget)
if err != nil {
return nil, err
}
out = append(out, converted...)
}
return out, nil
}
func sanitizePartialNode(node *html.Node, depth int, budget *partialBudget) ([]PartialNode, error) {
switch node.Type {
case html.TextNode:
if node.Data == "" {
return nil, nil
}
if err := budget.take(); err != nil {
return nil, err
}
return []PartialNode{{Text: node.Data}}, nil
case html.ElementNode:
tag := strings.ToLower(node.Data)
// Foreign (SVG, MathML) content and dropped elements go with their subtree.
if node.Namespace != "" || partialDroppedTags[tag] {
return nil, nil
}
if !partialTags[tag] {
return sanitizePartialNodes(childNodes(node), depth, budget)
}
if depth+1 > partialMaxDepth {
return nil, fmt.Errorf("cabana: partial output exceeds depth %d", partialMaxDepth)
}
if err := budget.take(); err != nil {
return nil, err
}
children, err := sanitizePartialNodes(childNodes(node), depth+1, budget)
if err != nil {
return nil, err
}
out := PartialNode{Tag: tag, Attrs: sanitizePartialAttrs(tag, node.Attr)}
if len(children) > 0 {
out.Children = children
}
return []PartialNode{out}, nil
default:
return nil, nil
}
}
func childNodes(node *html.Node) []*html.Node {
var out []*html.Node
for child := node.FirstChild; child != nil; child = child.NextSibling {
out = append(out, child)
}
return out
}
func sanitizePartialAttrs(tag string, attrs []html.Attribute) map[string]string {
var out map[string]string
for _, attr := range attrs {
if attr.Namespace != "" {
continue
}
key := strings.ToLower(attr.Key)
allowed := partialGlobalAttrs[key] || partialTagAttrs[tag][key] ||
(strings.HasPrefix(key, "aria-") && len(key) > len("aria-")) ||
(strings.HasPrefix(key, "data-") && len(key) > len("data-"))
if !allowed {
continue
}
if (tag == "a" && key == "href") || (tag == "img" && key == "src") {
if !safePartialURL(attr.Val, tag == "a") {
continue
}
}
if out == nil {
out = map[string]string{}
}
out[key] = attr.Val
}
return out
}
// safePartialURL accepts a same-origin path that starts with exactly one
// slash (never "//" or "/\", which browsers resolve to another host) and, for
// links, a fragment. Whitespace and control characters, which browsers strip
// before resolving, are refused outright, and so is every scheme.
func safePartialURL(raw string, allowFragment bool) bool {
if raw == "" {
return false
}
for _, r := range raw {
if r <= 0x20 || r == 0x7f {
return false
}
}
if raw[0] == '#' {
return allowFragment
}
if raw[0] != '/' {
return false
}
return len(raw) == 1 || (raw[1] != '/' && raw[1] != '\\')
}
// trustedTemplateTypes are html/template's pre-escaped content types. A view
// model carrying them could smuggle markup past autoescaping, so they are
// refused like the model itself.
var trustedTemplateTypes = map[reflect.Type]bool{
reflect.TypeOf(template.HTML("")): true,
reflect.TypeOf(template.HTMLAttr("")): true,
reflect.TypeOf(template.JS("")): true,
reflect.TypeOf(template.JSStr("")): true,
reflect.TypeOf(template.CSS("")): true,
reflect.TypeOf(template.URL("")): true,
reflect.TypeOf(template.Srcset("")): true,
}
// 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 {
g.model = baseType(reflect.TypeOf(model))
}
}
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 {
switch t.Kind() {
case reflect.Pointer, reflect.Slice, reflect.Array, reflect.Map:
t = t.Elem()
default:
return t
}
}
return t
}
// 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
// form partial field and the record is loaded through the controller's form
// scope; out of scope is 404. Every render failure, including a cap, is a
// logged 500 with the generic body.
func (s *service) partial(w http.ResponseWriter, r *http.Request) {
s.protect(w, r, func(cc *CompiledController) {
name := r.PathValue("name")
compiled, ok := cc.partials[name]
provider, isProvider := cc.Controller.(pact.AdminPartialData)
if !ok || !isProvider || provider == nil {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
var record any
query := r.URL.Query()
if query.Has("id") {
id, err := strconv.ParseUint(query.Get("id"), 10, 64)
if err != nil || id == 0 || !cc.formPartials[name] {
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
return
}
db, err := s.db()
if err != nil {
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
return
}
record, err = readScopedRecord(r.Context(), db, cc, id)
if err != nil {
writeCRUDError(w, err)
return
}
}
tr := s.translator()
ctx := towel.WithLocale(r.Context(), schemaLocale(r.Context(), tr))
fail := func(reason string, err error) {
slog.Error("cabana: admin partial failed", "controller", controllerID(cc), "partial", name, "reason", reason, "error", err)
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
}
vm, err := provider.PartialData(ctx, name, record)
if err != nil {
fail("view model", err)
return
}
if reason := refusedViewModel(cc, vm); reason != "" {
fail(reason, nil)
return
}
nodes, err := compiled.render(ctx, tr, vm)
if err != nil {
fail("render", err)
return
}
WriteData(w, http.StatusOK, PartialView{Nodes: nodes}, nil)
})
}