- 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
375 lines
12 KiB
Go
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)
|
|
})
|
|
}
|