- placement: tabs before/after tab anchors, anchor cursor for plugin order, own-field anchors sorted with cycle detection, Requires() rule, anchor tab inheritance, Hide with reference checks, zone refusal (D-101) - boot errors name plugin, controller and offending name; fileupload is refused (D-100); localized tab label collisions checked in Activate - extension widgets dispatch to AdminFormExtension.Actions, partials render from the contributor's Dir through PartialData, plugin field types decode for Save and load after Load, translatable values reach Save as locale maps and never the owner's translations - Permissions and Visible apply to schema, show, save, widgets and partials; AdminFormErrors answer 422, Save notices join meta.notices
566 lines
17 KiB
Go
566 lines
17 KiB
Go
package cabana
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"net/http"
|
|
"reflect"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"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]
|
|
var data func(ctx context.Context, name string, record any) (any, error)
|
|
if provider, isProvider := cc.Controller.(pact.AdminPartialData); isProvider && provider != nil {
|
|
data = provider.PartialData
|
|
}
|
|
formPartial := cc.formPartials[name]
|
|
// D-91: a form extension's partial renders from the contributing
|
|
// plugin's template through its PartialData, only for an
|
|
// administrator who sees the extension.
|
|
if ext := cc.extensionPartials[name]; ext != nil {
|
|
principal, _ := bouncer.User(r.Context())
|
|
if !extensionVisible(r.Context(), principal, ext) {
|
|
WriteError(w, http.StatusNotFound, "not_found", msgNotFound)
|
|
return
|
|
}
|
|
compiled, ok, data, formPartial = ext.partials[name], true, ext.def.PartialData, true
|
|
}
|
|
if !ok || data == 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 || !formPartial {
|
|
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) {
|
|
moduleLog.Error("cabana: admin partial failed", "controller", controllerID(cc), "partial", name, "reason", reason, "error", err)
|
|
WriteError(w, http.StatusInternalServerError, "error", msgServerError)
|
|
}
|
|
vm, err := data(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)
|
|
})
|
|
}
|