Files
summercms/modules/cabana/partial_render.go
Jakub Zych 771d2ccce0 feat(10.1-01): render header and form partials into an allowlisted node tree
- fields.yaml type: partial with a bare path name and config_list.yaml
  headerPartial resolve to {ConfigDir}/_{name}.htm, parsed at boot; the
  controller must implement pact.AdminPartialData
- html/template render against a curated view model, then x/net/html
  ParseFragment and a tag, attribute and URL allowlist with 64 KiB, 2000-node
  and depth-32 caps; the model type and trusted template types are refused
- GET .../partials/{name} with optional ?id= loaded through the form scope
- golang.org/x/net becomes a direct requirement (D-18), no new module
2026-09-28 23:52:50 +02:00

375 lines
12 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"
)
// 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,
}
// 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.
func refusedViewModel(cc *CompiledController, vm any) string {
if vm == nil {
return ""
}
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 carriesTrustedContent(reflect.TypeOf(vm), map[reflect.Type]bool{}) {
return "the view model carries pre-escaped html/template content"
}
return ""
}
// 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
}
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
// 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)
})
}