]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
tpl: Detect and fail on infinite template recursion
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sun, 20 Apr 2025 16:20:17 +0000 (18:20 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 21 Apr 2025 13:17:20 +0000 (15:17 +0200)
Fixes #13627

config/allconfig/load.go
hugolib/page__content.go
tpl/partials/partials.go
tpl/partials/partials_integration_test.go
tpl/template.go
tpl/tplimpl/templatestore.go
tpl/tplimpl/templatestore_integration_test.go

index f224009ac8b1ce3454a1d932218b099a22611561..9289cf294f293af0775c0644129942e9bea83f33 100644 (file)
@@ -213,7 +213,7 @@ func (l configLoader) applyDefaultConfig() error {
                "disableAliases":                       false,
                "debug":                                false,
                "disableFastRender":                    false,
-               "timeout":                              "30s",
+               "timeout":                              "60s",
                "timeZone":                             "",
                "enableInlineShortcodes":               false,
        }
index 5f7d6f930489aeaec3a3e3e469bdf1d3f358c7d2..20abb788418b38ef3d03aa87c9ba3eeedcaf2549 100644 (file)
@@ -850,7 +850,7 @@ func (c *cachedContentScope) contentPlain(ctx context.Context) (contentPlainPlai
        })
        if err != nil {
                if herrors.IsTimeoutError(err) {
-                       err = fmt.Errorf("timed out rendering the page content. You may have a circular loop in a shortcode, or your site may have resources that take longer to build than the `timeout` limit in your Hugo config file: %w", err)
+                       err = fmt.Errorf("timed out rendering the page content. Extend the `timeout` limit in your Hugo config file: %w", err)
                }
                return contentPlainPlainWords{}, err
        }
index b9ef4b2447644eb20d22490fa632721424ebf246..19882e36a523c35e43fd17ccf03bfa31e0dd7368 100644 (file)
@@ -24,13 +24,13 @@ import (
        "time"
 
        "github.com/bep/lazycache"
-
        "github.com/gohugoio/hugo/common/constants"
        "github.com/gohugoio/hugo/common/hashing"
        "github.com/gohugoio/hugo/identity"
+       texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate"
 
        "github.com/gohugoio/hugo/tpl"
-       texttemplate "github.com/gohugoio/hugo/tpl/internal/go_templates/texttemplate"
+       "github.com/gohugoio/hugo/tpl/tplimpl"
 
        bp "github.com/gohugoio/hugo/bufferpool"
        "github.com/gohugoio/hugo/deps"
@@ -109,7 +109,7 @@ func (c *contextWrapper) Set(in any) string {
 // A string if the partial is a text/template, or template.HTML when html/template.
 // Note that ctx is provided by Hugo, not the end user.
 func (ns *Namespace) Include(ctx context.Context, name string, contextList ...any) (any, error) {
-       res := ns.includWithTimeout(ctx, name, contextList...)
+       res := ns.include(ctx, name, contextList...)
        if res.err != nil {
                return nil, res.err
        }
@@ -121,49 +121,36 @@ func (ns *Namespace) Include(ctx context.Context, name string, contextList ...an
        return res.result, nil
 }
 
-func (ns *Namespace) includWithTimeout(ctx context.Context, name string, dataList ...any) includeResult {
+func (ns *Namespace) include(ctx context.Context, name string, dataList ...any) includeResult {
+       v, err := ns.lookup(name)
+       if err != nil {
+               return includeResult{err: err}
+       }
+       return ns.doInclude(ctx, v, dataList...)
+}
+
+func (ns *Namespace) lookup(name string) (*tplimpl.TemplInfo, error) {
        if strings.HasPrefix(name, "partials/") {
                // This is most likely not what the user intended.
                // This worked before Hugo 0.146.0.
                ns.deps.Log.Warnidf(constants.WarnPartialSuperfluousPrefix, "Partial name %q starting with 'partials/' (as in {{ partial \"%s\"}}) is most likely not what you want. Before 0.146.0 we did a double lookup in this situation.", name, name)
        }
-       // Create a new context with a timeout not connected to the incoming context.
-       timeoutCtx, cancel := context.WithTimeout(context.Background(), ns.deps.Conf.Timeout())
-       defer cancel()
-
-       res := make(chan includeResult, 1)
-
-       go func() {
-               res <- ns.include(ctx, name, dataList...)
-       }()
-
-       select {
-       case r := <-res:
-               return r
-       case <-timeoutCtx.Done():
-               err := timeoutCtx.Err()
-               if err == context.DeadlineExceeded {
-                       //lint:ignore ST1005 end user message.
-                       err = fmt.Errorf("partial %q timed out after %s. This is most likely due to infinite recursion. If this is just a slow template, you can try to increase the 'timeout' config setting.", name, ns.deps.Conf.Timeout())
-               }
-               return includeResult{err: err}
+       v := ns.deps.TemplateStore.LookupPartial(name)
+       if v == nil {
+               return nil, fmt.Errorf("partial %q not found", name)
        }
+       return v, nil
 }
 
 // include is a helper function that lookups and executes the named partial.
 // Returns the final template name and the rendered output.
-func (ns *Namespace) include(ctx context.Context, name string, dataList ...any) includeResult {
+func (ns *Namespace) doInclude(ctx context.Context, templ *tplimpl.TemplInfo, dataList ...any) includeResult {
        var data any
        if len(dataList) > 0 {
                data = dataList[0]
        }
-       v := ns.deps.TemplateStore.LookupPartial(name)
-       if v == nil {
-               return includeResult{err: fmt.Errorf("partial %q not found", name)}
-       }
 
-       templ := v
-       info := v.ParseInfo
+       info := templ.ParseInfo
 
        var w io.Writer
 
@@ -212,6 +199,20 @@ func (ns *Namespace) IncludeCached(ctx context.Context, name string, context any
                Variants: variants,
        }
        depsManagerIn := tpl.Context.GetDependencyManagerInCurrentScope(ctx)
+       ti, err := ns.lookup(name)
+       if err != nil {
+               return nil, err
+       }
+
+       if parent := tpl.Context.CurrentTemplate.Get(ctx); parent != nil {
+               for parent != nil {
+                       if parent.CurrentTemplateInfoOps == ti {
+                               // This will deadlock if we continue.
+                               return nil, fmt.Errorf("circular call stack detected in partial %q", ti.Filename())
+                       }
+                       parent = parent.Parent
+               }
+       }
 
        r, found, err := ns.cachedPartials.cache.GetOrCreate(key.Key(), func(string) (includeResult, error) {
                var depsManagerShared identity.Manager
@@ -221,7 +222,7 @@ func (ns *Namespace) IncludeCached(ctx context.Context, name string, context any
                        depsManagerShared = identity.NewManager("partials")
                        ctx = tpl.Context.DependencyManagerScopedProvider.Set(ctx, depsManagerShared.(identity.DependencyManagerScopedProvider))
                }
-               r := ns.includWithTimeout(ctx, key.Name, context)
+               r := ns.doInclude(ctx, ti, context)
                if ns.deps.Conf.Watching() {
                        r.mangager = depsManagerShared
                }
index 6fab3abd82772215b1d2cb032b4bed5a98ec3153..0fa47104d5211a3c915e03620ea1a8469fb8709a 100644 (file)
@@ -256,7 +256,6 @@ func TestIncludeTimeout(t *testing.T) {
        files := `
 -- config.toml --
 baseURL = 'http://example.com/'
-timeout = '200ms'
 -- layouts/index.html --
 {{ partials.Include "foo.html" . }}
 -- layouts/partials/foo.html --
@@ -271,7 +270,7 @@ timeout = '200ms'
        ).BuildE()
 
        b.Assert(err, qt.Not(qt.IsNil))
-       b.Assert(err.Error(), qt.Contains, "timed out")
+       b.Assert(err.Error(), qt.Contains, "maximum template call stack size exceeded")
 }
 
 func TestIncludeCachedTimeout(t *testing.T) {
@@ -284,6 +283,8 @@ timeout = '200ms'
 -- layouts/index.html --
 {{ partials.IncludeCached "foo.html" . }}
 -- layouts/partials/foo.html --
+{{ partialCached "bar.html" . }}
+-- layouts/partials/bar.html --
 {{ partialCached "foo.html" . }}
   `
 
@@ -295,7 +296,7 @@ timeout = '200ms'
        ).BuildE()
 
        b.Assert(err, qt.Not(qt.IsNil))
-       b.Assert(err.Error(), qt.Contains, "timed out")
+       b.Assert(err.Error(), qt.Contains, `error calling partialCached: circular call stack detected in partial`)
 }
 
 // See Issue #10789
index f69ae2210dd291c9e19b96d359d68ab7c1db807d..877422123f3c2a2562c9c8fa81eadcf7d7747b64 100644 (file)
@@ -160,6 +160,7 @@ type CurrentTemplateInfoCommonOps interface {
 // CurrentTemplateInfo as returned in templates.Current.
 type CurrentTemplateInfo struct {
        Parent *CurrentTemplateInfo
+       Level  int
        CurrentTemplateInfoOps
 }
 
index 4266e274aba30fe4cdd759dc2b86f363f7020660..7770d053bf2ae515bd2c9bd9c58473f3d940cc0e 100644 (file)
@@ -484,13 +484,24 @@ func (t *TemplateStore) ExecuteWithContext(ctx context.Context, ti *TemplInfo, w
 
        templ := ti.Template
 
+       parent := tpl.Context.CurrentTemplate.Get(ctx)
+       var level int
+       if parent != nil {
+               level = parent.Level + 1
+       }
        currentTi := &tpl.CurrentTemplateInfo{
-               Parent:                 tpl.Context.CurrentTemplate.Get(ctx),
+               Parent:                 parent,
+               Level:                  level,
                CurrentTemplateInfoOps: ti,
        }
 
        ctx = tpl.Context.CurrentTemplate.Set(ctx, currentTi)
 
+       const levelThreshold = 999
+       if level > levelThreshold {
+               return fmt.Errorf("maximum template call stack size exceeded in %q", ti.Filename())
+       }
+
        if t.opts.Metrics != nil {
                defer t.opts.Metrics.MeasureSince(templ.Name(), time.Now())
        }
index e59dad33a52adbdb91ba67c2ce0ceca058366847..375813c31e839e1c2a380729ade36cb99860f4fc 100644 (file)
@@ -1229,3 +1229,19 @@ layouts/list.html
                b.AssertFileContent("public/p1/index.html", "layouts/single.html")
        }
 }
+
+func TestTemplateLoop(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+-- layouts/_partials/p.html --
+p: {{ partial "p.html" . }}
+-- layouts/all.html --
+{{ partial "p.html" . }}
+
+`
+       b, err := hugolib.TestE(t, files)
+       b.Assert(err, qt.IsNotNil)
+       b.Assert(err.Error(), qt.Contains, "error calling partial: maximum template call stack size exceeded")
+}