feat: bound template execution in tmplfuncs by refusing call, capping printf/print/println/html/js/urlquery/join output, metering range steps, limiting variables to 64 and function arguments to 32, adding a context-escaping CompileHTML, and make the integration fallback a single non-rescanning pass under the same output cap

This commit is contained in:
Matthew Meszaros
2026-10-23 16:07:43 +02:00
parent 19c7eb13dc
commit 1a77dbbd2b
7 changed files with 429 additions and 43 deletions
+1 -1
View File
@@ -140,7 +140,7 @@ New reply from {{.contact_email}} on campaign {{.campaign_id}} ({{.intent}})
| Warmup health changed | `email`, `new_state`, `previous_state`, `reason` |
| Campaign action | `contact_email`, `contact_id`, `campaign_id`, `campaign_name`, `first_name`, `last_name`, `company`, `phone` |
Action text is a **full template, not token substitution**: conditionals, pipelines, helpers, `{{range}}`, and nested fields (`{{.lead.company}}`) all work, and values keep their type. Because numbers stay numbers, `{{if gt .confidence 0.8}}` works directly in a message body. A malformed template never blocks the action; it falls back to plain `{{.key}}` substitution. Unknown keys render empty. `{{range}}` loops over a field of the data (`{{range .lead.tags}}`), never a number or a function's result, and nests at most two deep; `{{template}}` and `{{block}}` are not supported, and output is capped at 1 MB. A template past any of these limits falls back the same way.
Action text is a **full template, not token substitution**: conditionals, pipelines, helpers, `{{range}}`, and nested fields (`{{.lead.company}}`) all work, and values keep their type. Because numbers stay numbers, `{{if gt .confidence 0.8}}` works directly in a message body. A malformed template never blocks the action; it falls back to plain `{{.key}}` substitution. Unknown keys render empty. `{{range}}` loops over a field of the data (`{{range .lead.tags}}`), never a number or a function's result, and nests at most two deep; `{{template}}`, `{{block}}` and `call` are not supported, a template declares at most 64 variables and a function takes at most 32 arguments, and output (fallback included) is capped at 1 MB. A template past any of these limits falls back the same way.
</Fold>
+18 -8
View File
@@ -144,22 +144,32 @@ func compileTemplate(tmpl string) *template.Template {
// naiveRenderTemplate is a literal {{.key}} substitution (a leading dot is
// optional here), used as a safe fallback when a template can't compile/execute
// so a single malformed block never blanks the whole value.
// so a single malformed block never blanks the whole value. One left-to-right
// pass: a substituted value is never rescanned, and values share MaxOutput.
func naiveRenderTemplate(tmpl string, data map[string]any) string {
out := tmpl
var b strings.Builder
budget := tmplfuncs.MaxOutput
rest := tmpl
for {
start := strings.Index(out, "{{")
start := strings.Index(rest, "{{")
if start < 0 {
break
}
end := strings.Index(out[start:], "}}")
end := strings.Index(rest[start:], "}}")
if end < 0 {
break
}
end += start
key := strings.TrimSpace(out[start+2 : end])
key = strings.TrimPrefix(key, ".")
out = out[:start] + stringFromMap(data, key) + out[end+2:]
key := strings.TrimPrefix(strings.TrimSpace(rest[start+2:end]), ".")
value := stringFromMap(data, key)
if len(value) > budget {
value = ""
}
budget -= len(value)
b.WriteString(rest[:start])
b.WriteString(value)
rest = rest[end+2:]
}
return strings.TrimSpace(out)
b.WriteString(rest)
return strings.TrimSpace(b.String())
}
@@ -21,3 +21,10 @@ func TestRenderTemplateRefusesRangeOverNumber(t *testing.T) {
t.Fatal("ValidExpression accepted a range over a number")
}
}
func TestNaiveRenderNeverExpandsSubstitutedValues(t *testing.T) {
data := map[string]any{"a": "{{.a}}{{.a}}", "b": "B"}
if got := naiveRenderTemplate(`{{if}} {{.a}}|{{b}}`, data); got != "{{.a}}{{.a}}|B" {
t.Fatalf("got %q", got)
}
}
+133
View File
@@ -0,0 +1,133 @@
package tmplfuncs
import (
"errors"
"fmt"
"reflect"
"strings"
"text/template"
)
var (
errCallRefused = errors.New("call is not supported")
errPrintfForm = errors.New("printf widths and argument indexes must be literal")
)
// argSize is the printed length of a value, read without printing a string.
func argSize(v any) int {
if v == nil {
return 0
}
if s, ok := v.([]string); ok {
n := 2
for _, x := range s {
n += len(x) + 1
}
return n
}
if rv := reflect.ValueOf(v); rv.Kind() == reflect.String {
return rv.Len()
}
return len(fmt.Sprint(v))
}
func argsSize(args []any) int {
n := 0
for _, a := range args {
n += argSize(a) + 1
}
return n
}
// bounded refuses a result that would pass MaxOutput, before building it.
func bounded(size int, build func() string) (string, error) {
if size > MaxOutput {
return "", ErrOutputLimit
}
out := build()
if len(out) > MaxOutput {
return "", ErrOutputLimit
}
return out, nil
}
// literalNumber reads the digits at format[i:], capped past MaxOutput.
func literalNumber(format string, i int) (int, int) {
n := 0
for i < len(format) && format[i] >= '0' && format[i] <= '9' {
n = min(n*10+int(format[i]-'0'), MaxOutput+1)
i++
}
return n, i
}
// printfSize is an upper bound on what fmt.Sprintf(format, args...) prints. It
// refuses * widths and explicit argument indexes, whose cost it cannot see.
func printfSize(format string, args []any) (int, error) {
widest := 0
for _, a := range args {
widest = max(widest, argSize(a))
}
total := len(format) + argsSize(args)
for i := 0; i < len(format); i++ {
if format[i] != '%' {
continue
}
i++
for i < len(format) && strings.IndexByte("+-# 0", format[i]) >= 0 {
i++
}
width, precision := 0, 0
width, i = literalNumber(format, i)
if i < len(format) && format[i] == '.' {
precision, i = literalNumber(format, i+1)
}
if i < len(format) && (format[i] == '*' || format[i] == '[') {
return 0, errPrintfForm
}
total += width + precision + widest + 32
if total > MaxOutput {
return 0, ErrOutputLimit
}
}
return total, nil
}
// boundedBuiltins replace the text/template builtins that can call into the
// data or build a string of any size; each refuses a result past MaxOutput.
var boundedBuiltins = template.FuncMap{
"call": func(...any) (string, error) { return "", errCallRefused },
"printf": func(format string, args ...any) (string, error) {
size, err := printfSize(format, args)
if err != nil {
return "", err
}
return bounded(size, func() string { return fmt.Sprintf(format, args...) })
},
"print": func(args ...any) (string, error) {
return bounded(argsSize(args), func() string { return fmt.Sprint(args...) })
},
"println": func(args ...any) (string, error) {
return bounded(argsSize(args)+1, func() string { return fmt.Sprintln(args...) })
},
"html": func(args ...any) (string, error) {
return bounded(argsSize(args), func() string { return template.HTMLEscaper(args...) })
},
"js": func(args ...any) (string, error) {
return bounded(argsSize(args), func() string { return template.JSEscaper(args...) })
},
"urlquery": func(args ...any) (string, error) {
return bounded(argsSize(args), func() string { return template.URLQueryEscaper(args...) })
},
}
// boundedJoin is strings.Join over a list, refused past MaxOutput.
func boundedJoin(list, sep any) (string, error) {
items, _ := ListStrings(list)
s := valueString(sep)
size := len(s) * len(items)
for _, x := range items {
size += len(x)
}
return bounded(size, func() string { return strings.Join(items, s) })
}
+105
View File
@@ -0,0 +1,105 @@
package tmplfuncs
import (
"errors"
"strings"
"testing"
)
func TestBuiltinsAreBounded(t *testing.T) {
big := strings.Repeat("x", MaxOutput/2+1)
data := map[string]any{
"name": "Ann", "big": big, "list": []string{big, big, big},
"fn": func() string { return "called" },
}
for _, src := range []string{
`{{call .fn}}`,
`{{printf "%2000000s" .name}}`,
`{{printf "%.2000000f" 1.5}}`,
`{{printf "%*s" 2000000 .name}}`,
`{{printf "%[1]s%[1]s%[1]s" .big}}`,
`{{printf "%s%s" .big .big}}`,
`{{print .big .big}}`,
`{{println .big .big}}`,
`{{html .big .big}}`,
`{{js .big .big}}`,
`{{urlquery .big .big}}`,
`{{join .list ","}}`,
} {
for kind, compile := range map[string]func(string, string) (Executor, error){
"text": func(n, s string) (Executor, error) { return Compile(n, s) },
"html": func(n, s string) (Executor, error) { return CompileHTML(n, s) },
} {
tmpl, err := compile("t", src)
if err != nil {
continue
}
if out, err := Execute(tmpl, data); err == nil {
t.Errorf("%s %s rendered %d bytes, want a refusal", kind, src, len(out))
}
}
}
}
func TestBuiltinsStillRender(t *testing.T) {
data := map[string]any{"name": "Ann & Co", "n": 3, "list": []string{"a", "b"}}
for src, want := range map[string]string{
`{{printf "%s-%03d" .name .n}}`: "Ann & Co-003",
`{{print .name .n}}`: "Ann & Co3",
`{{html .name}}`: "Ann &amp; Co",
`{{urlquery .name}}`: "Ann+%26+Co",
`{{join .list ", "}}`: "a, b",
`{{.name | printf "%q"}}`: `"Ann & Co"`,
`{{range .list}}{{.}}{{end}}`: "ab",
`{{range $i, $v := .list}}{{$i}}{{end}}`: "01",
} {
tmpl, err := Compile("t", src)
if err != nil {
t.Fatalf("Compile(%q): %v", src, err)
}
if out, err := Execute(tmpl, data); err != nil || out != want {
t.Errorf("%s = %q, %v; want %q", src, out, err, want)
}
}
}
func TestExecuteBoundsRangeSteps(t *testing.T) {
const src = `{{range .n}}{{range $.n}}{{end}}{{end}}`
text, err := Compile("t", src)
if err != nil {
t.Fatal(err)
}
html, err := CompileHTML("t", src)
if err != nil {
t.Fatal(err)
}
for _, tmpl := range []Executor{text, html} {
if _, err := Execute(tmpl, map[string]any{"n": 100000}); !errors.Is(err, ErrStepLimit) {
t.Fatalf("err = %v, want ErrStepLimit", err)
}
}
}
func TestCompileRefusesTooManyVariablesAndArgs(t *testing.T) {
vars := strings.Repeat(`{{$a := 1}}`, MaxVariables+1)
args := `{{print` + strings.Repeat(" 1", MaxArgs+1) + `}}`
for _, src := range []string{vars, args} {
if _, err := Compile("t", src); err == nil {
t.Errorf("Compile accepted %.40s...", src)
}
if _, err := CompileHTML("t", src); err == nil {
t.Errorf("CompileHTML accepted %.40s...", src)
}
}
}
func TestCompileHTMLEscapesInContext(t *testing.T) {
tmpl, err := CompileHTML("t", `<a href="{{.u}}" title="{{.n}}">{{.n}}</a>`)
if err != nil {
t.Fatal(err)
}
out, err := Execute(tmpl, map[string]any{"u": "javascript:alert(1)", "n": `<b>"x"</b>`})
if err != nil || out != `<a href="#ZgotmplZ" title="&lt;b&gt;&#34;x&#34;&lt;/b&gt;">&lt;b&gt;&#34;x&#34;&lt;/b&gt;</a>` {
t.Fatalf("got %q, %v", out, err)
}
}
+157 -29
View File
@@ -3,6 +3,8 @@ package tmplfuncs
import (
"errors"
"fmt"
htmltemplate "html/template"
"io"
"strings"
"sync"
"sync/atomic"
@@ -13,17 +15,31 @@ import (
// MaxOutput bounds what one render of a user-authored template may write.
const MaxOutput = 1 << 20
// MaxSteps bounds how many writes one render may make, which bounds range iterations.
const MaxSteps = 1 << 18
// MaxRangeDepth bounds how deeply {{range}} blocks may nest.
const MaxRangeDepth = 2
// MaxVariables bounds how many variables one template may declare.
const MaxVariables = 64
// MaxArgs bounds how many arguments one command may pass.
const MaxArgs = 32
// ErrOutputLimit is returned when a render passes MaxOutput.
var ErrOutputLimit = errors.New("template output exceeds the size limit")
// ErrStepLimit is returned when a render passes MaxSteps.
var ErrStepLimit = errors.New("template render exceeds the step limit")
var (
errRangeSource = errors.New("range only iterates over a field, like {{range .items}}")
errRangeDepth = fmt.Errorf("range blocks may nest at most %d deep", MaxRangeDepth)
errTemplateUse = errors.New("{{template}} and {{block}} are not supported")
errRootAssign = errors.New("$ cannot be reassigned")
errVariables = fmt.Errorf("a template may declare at most %d variables", MaxVariables)
errArgs = fmt.Errorf("a function may take at most %d arguments", MaxArgs)
)
// Compile parses a user-authored template with the shared funcs and absent keys
@@ -36,18 +52,51 @@ func Compile(name, src string) (*template.Template, error) {
if err := CheckLimits(t); err != nil {
return nil, err
}
for _, tt := range t.Templates() {
meterRanges(tt.Tree)
}
return t, nil
}
// CompileHTML is Compile for an HTML document: html/template escapes every
// value for the context it lands in, under the same funcs and limits.
func CompileHTML(name, src string) (*htmltemplate.Template, error) {
t, err := htmltemplate.New(name).Funcs(funcs).Option("missingkey=zero").Parse(src)
if err != nil {
return nil, err
}
trees := make([]*parse.Tree, 0, 1)
for _, tt := range t.Templates() {
trees = append(trees, tt.Tree)
}
if err := checkTrees(trees); err != nil {
return nil, err
}
for _, tree := range trees {
meterRanges(tree)
}
return t, nil
}
// CheckLimits refuses constructs whose cost is not bounded by the data: a
// {{range}} over anything but a field of the data, {{range}} nested past
// MaxRangeDepth, {{template}} calls, and reassigning $.
// MaxRangeDepth, {{template}} calls, reassigning $, more than MaxVariables
// declarations and commands with more than MaxArgs arguments.
func CheckLimits(t *template.Template) error {
trees := make([]*parse.Tree, 0, 1)
for _, tt := range t.Templates() {
if tt.Tree == nil || tt.Root == nil {
trees = append(trees, tt.Tree)
}
return checkTrees(trees)
}
func checkTrees(trees []*parse.Tree) error {
vars := 0
for _, tree := range trees {
if tree == nil || tree.Root == nil {
continue
}
if err := checkNode(tt.Root, 0, true); err != nil {
if err := checkNode(tree.Root, 0, true, &vars); err != nil {
return err
}
}
@@ -55,21 +104,21 @@ func CheckLimits(t *template.Template) error {
}
// checkNode walks n; dotIsData is whether dot can only hold a value from the data.
func checkNode(n parse.Node, depth int, dotIsData bool) error {
func checkNode(n parse.Node, depth int, dotIsData bool, vars *int) error {
switch x := n.(type) {
case *parse.ListNode:
if x == nil {
return nil
}
for _, c := range x.Nodes {
if err := checkNode(c, depth, dotIsData); err != nil {
if err := checkNode(c, depth, dotIsData, vars); err != nil {
return err
}
}
case *parse.ActionNode:
return checkPipe(x.Pipe)
return checkPipe(x.Pipe, vars)
case *parse.RangeNode:
if err := checkPipe(x.Pipe); err != nil {
if err := checkPipe(x.Pipe, vars); err != nil {
return err
}
if depth+1 > MaxRangeDepth {
@@ -78,34 +127,35 @@ func checkNode(n parse.Node, depth int, dotIsData bool) error {
if !isDataPath(x.Pipe, dotIsData) {
return errRangeSource
}
if err := checkNode(x.List, depth+1, true); err != nil {
if err := checkNode(x.List, depth+1, true, vars); err != nil {
return err
}
return checkNode(x.ElseList, depth, dotIsData)
return checkNode(x.ElseList, depth, dotIsData, vars)
case *parse.IfNode:
if err := checkPipe(x.Pipe); err != nil {
if err := checkPipe(x.Pipe, vars); err != nil {
return err
}
if err := checkNode(x.List, depth, dotIsData); err != nil {
if err := checkNode(x.List, depth, dotIsData, vars); err != nil {
return err
}
return checkNode(x.ElseList, depth, dotIsData)
return checkNode(x.ElseList, depth, dotIsData, vars)
case *parse.WithNode:
if err := checkPipe(x.Pipe); err != nil {
if err := checkPipe(x.Pipe, vars); err != nil {
return err
}
if err := checkNode(x.List, depth, isDataPath(x.Pipe, dotIsData)); err != nil {
if err := checkNode(x.List, depth, isDataPath(x.Pipe, dotIsData), vars); err != nil {
return err
}
return checkNode(x.ElseList, depth, dotIsData)
return checkNode(x.ElseList, depth, dotIsData, vars)
case *parse.TemplateNode:
return errTemplateUse
}
return nil
}
// checkPipe refuses a pipeline that declares or assigns $, the data root.
func checkPipe(p *parse.PipeNode) error {
// checkPipe refuses a pipeline that declares or assigns $, declares past
// MaxVariables, or passes a command more than MaxArgs arguments.
func checkPipe(p *parse.PipeNode, vars *int) error {
if p == nil {
return nil
}
@@ -113,11 +163,20 @@ func checkPipe(p *parse.PipeNode) error {
if len(v.Ident) > 0 && v.Ident[0] == "$" {
return errRootAssign
}
if !p.IsAssign {
*vars++
}
}
if *vars > MaxVariables {
return errVariables
}
for _, c := range p.Cmds {
if len(c.Args) > MaxArgs+1 {
return errArgs
}
for _, a := range c.Args {
if sub, ok := a.(*parse.PipeNode); ok {
if err := checkPipe(sub); err != nil {
if err := checkPipe(sub, vars); err != nil {
return err
}
}
@@ -148,9 +207,64 @@ func isPathNode(n parse.Node, dotIsData bool) bool {
return false
}
// Execute renders t into a string, failing once the output passes MaxOutput.
func Execute(t *template.Template, data any) (string, error) {
w := &cappedWriter{max: MaxOutput}
// meterRanges opens every range body with an empty write, so each iteration
// counts against MaxSteps even when the body writes nothing.
func meterRanges(tree *parse.Tree) {
if tree == nil {
return
}
var walk func(n parse.Node)
walk = func(n parse.Node) {
switch x := n.(type) {
case *parse.ListNode:
if x == nil {
return
}
for _, c := range x.Nodes {
walk(c)
}
case *parse.RangeNode:
walk(x.List)
walk(x.ElseList)
if x.List != nil {
x.List.Nodes = append([]parse.Node{emptyText()}, x.List.Nodes...)
}
case *parse.IfNode:
walk(x.List)
walk(x.ElseList)
case *parse.WithNode:
walk(x.List)
walk(x.ElseList)
}
}
walk(tree.Root)
}
// emptyText returns a text node owned by a parsed tree, so copying it is safe.
func emptyText() parse.Node {
trees, err := parse.Parse("step", " ", "", "")
if err != nil {
panic(err)
}
n := trees["step"].Root.Nodes[0].(*parse.TextNode)
n.Text = n.Text[:0]
return n
}
// Executor is a compiled text or HTML template.
type Executor interface {
Execute(w io.Writer, data any) error
}
// Execute renders t into a string, failing once the output passes MaxOutput
// or the render passes MaxSteps.
func Execute(t Executor, data any) (string, error) {
return ExecuteLimit(t, data, MaxOutput)
}
// ExecuteLimit is Execute with a smaller output budget, for callers sharing one.
func ExecuteLimit(t Executor, data any, limit int) (string, error) {
w := &cappedWriter{max: min(limit, MaxOutput)}
if err := t.Execute(w, data); err != nil {
return "", err
}
@@ -158,39 +272,53 @@ func Execute(t *template.Template, data any) (string, error) {
}
type cappedWriter struct {
b strings.Builder
max int
b strings.Builder
max int
steps int
}
func (w *cappedWriter) Write(p []byte) (int, error) {
w.steps++
if w.steps > MaxSteps {
return 0, ErrStepLimit
}
if w.b.Len()+len(p) > w.max {
return 0, ErrOutputLimit
}
return w.b.Write(p)
}
// IsLimit reports whether err is a render passing one of the limits.
func IsLimit(err error) bool {
return errors.Is(err, ErrOutputLimit) || errors.Is(err, ErrStepLimit)
}
// CacheCap bounds how many compiled templates a Cache keeps.
const CacheCap = 4096
// Cache maps a template source to its compiled form, nil marking a source
// Cache maps a template source to its compiled text template.
type Cache = TypedCache[*template.Template]
// TypedCache maps a template source to its compiled form, nil marking a source
// that failed to compile. Past CacheCap a miss is recompiled, not stored.
type Cache struct {
type TypedCache[T any] struct {
m sync.Map
n atomic.Int64
}
// Load returns the cached template for src and whether src was cached.
func (c *Cache) Load(src string) (*template.Template, bool) {
func (c *TypedCache[T]) Load(src string) (T, bool) {
v, ok := c.m.Load(src)
if !ok {
return nil, false
var zero T
return zero, false
}
t, _ := v.(*template.Template)
t, _ := v.(T)
return t, true
}
// Store caches t (nil for a known-bad source) unless the cache is full.
func (c *Cache) Store(src string, t *template.Template) {
func (c *TypedCache[T]) Store(src string, t T) {
if c.n.Load() >= CacheCap {
return
}
+8 -5
View File
@@ -110,11 +110,8 @@ var funcs = template.FuncMap{
},
// List helpers: {{has .ai_labels "Meeting"}} is whole-item membership, so
// "Interested" does not match "Not interested"; {{join .ai_labels ", "}}.
"has": ListHas,
"join": func(list, sep any) string {
items, _ := ListStrings(list)
return strings.Join(items, valueString(sep))
},
"has": ListHas,
"join": boundedJoin,
"hasPrefix": func(s, p any) bool { return strings.HasPrefix(valueString(s), valueString(p)) },
"hasSuffix": func(s, p any) bool { return strings.HasSuffix(valueString(s), valueString(p)) },
// Fallback: {{.FirstName | default "there"}} -> "there" when the field is blank.
@@ -126,6 +123,12 @@ var funcs = template.FuncMap{
},
}
func init() {
for name, fn := range boundedBuiltins {
funcs[name] = fn
}
}
// FuncMap returns the shared template function set. The returned map is shared
// (the funcs are stateless), so callers must not mutate it.
func FuncMap() template.FuncMap {