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) }) }