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
This commit is contained in:
374
modules/cabana/partial_render.go
Normal file
374
modules/cabana/partial_render.go
Normal file
@@ -0,0 +1,374 @@
|
||||
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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user