From 38b876676620534af5fbcae7fedef757ca8c55bd Mon Sep 17 00:00:00 2001 From: Joe Mooring Date: Fri, 17 Jan 2025 09:44:30 -0800 Subject: [PATCH] tpl/tplimpl: Prevent overloading of embedded comment shortcode Creates a mechanism to prevent loading of user space templates that match a list of reserved paths. The primary intent is to prevent users from overloading specific embedded templates. For example, with the string "shortcodes/comment" in the list of reserved paths, we do not load any of the following from user space: - layouts/shortcodes/comment.html - layouts/shortcodes/comment.html.html - layouts/shortcodes/comment.en.html.html - layouts/shortcodes/comment.json" - layouts/shortcodes/comment.json.json" - layouts/shortcodes/comment.en.json.json" --- common/paths/path.go | 13 ++++++ common/paths/path_test.go | 39 ++++++++++++++++ .../templates/shortcodes/comment.html | 2 + tpl/tplimpl/template.go | 22 ++++++++++ tpl/tplimpl/tplimpl_integration_test.go | 44 +++++++++++++++++++ 5 files changed, 120 insertions(+) diff --git a/common/paths/path.go b/common/paths/path.go index de91d6a2ff2..712469ca6a7 100644 --- a/common/paths/path.go +++ b/common/paths/path.go @@ -428,3 +428,16 @@ func ToSlashPreserveLeading(s string) string { func IsSameFilePath(s1, s2 string) bool { return path.Clean(ToSlashTrim(s1)) == path.Clean(ToSlashTrim(s2)) } + +// ToSlashNoExtensions returns the result of replacing each separator character +// in path s with a slash ('/') character, then removing all file extensions. +// For example, "/a/b/c.d/e.f.g" becomes "/a/b/c.d/e". +func ToSlashNoExtensions(s string) string { + s = filepath.ToSlash(s) + d, f := path.Split(s) + i := strings.Index(f, ".") + if i >= 0 { + f = f[:i] + } + return path.Join(d, f) +} diff --git a/common/paths/path_test.go b/common/paths/path_test.go index bc27df6c6c8..305fa3da500 100644 --- a/common/paths/path_test.go +++ b/common/paths/path_test.go @@ -15,6 +15,7 @@ package paths import ( "path/filepath" + "strconv" "testing" qt "github.com/frankban/quicktest" @@ -311,3 +312,41 @@ func TestIsSameFilePath(t *testing.T) { c.Assert(IsSameFilePath(filepath.FromSlash(this.a), filepath.FromSlash(this.b)), qt.Equals, this.expected, qt.Commentf("a: %s b: %s", this.a, this.b)) } } + +func TestToSlashNoExtensions(t *testing.T) { + tests := []struct { + path string + want string + }{ + {"a", "a"}, + {"a/", "a"}, + {"/a", "/a"}, + {"/a/", "/a"}, + {"a.b", "a"}, + {"a.b/", "a.b"}, + {"/a.b", "/a"}, + {"/a.b/", "/a.b"}, + {"a/b", "a/b"}, + {"a/b/", "a/b"}, + {"a/b", "a/b"}, + {"/a/b/", "/a/b"}, + {"a/b/c.d", "a/b/c"}, + {"a/b/c.d", "a/b/c"}, + {"a/b/c.d.e", "a/b/c"}, + {"a/b/c.d/e", "a/b/c.d/e"}, + {"a/b/c.d/e.f", "a/b/c.d/e"}, + {"a/b/c.d/e.f.g", "a/b/c.d/e"}, + {"a.", "a"}, + {".a", ""}, + {"/", "/"}, + {".", ""}, + {"", ""}, + } + for k, tt := range tests { + t.Run(strconv.Itoa(k), func(t *testing.T) { + if got := ToSlashNoExtensions(tt.path); got != tt.want { + t.Errorf("ToSlashNoExtensions() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/tpl/tplimpl/embedded/templates/shortcodes/comment.html b/tpl/tplimpl/embedded/templates/shortcodes/comment.html index cb32934018c..e6cb53a1558 100644 --- a/tpl/tplimpl/embedded/templates/shortcodes/comment.html +++ b/tpl/tplimpl/embedded/templates/shortcodes/comment.html @@ -1 +1,3 @@ +{{- /* DO NOT REMOVE THIS SHORTCODE FROM THE CODE BASE. */ -}} +{{- /* DOING SO WOULD EXPOSE HIDDEN INFORMATION. */ -}} {{- $noop := .Inner -}} diff --git a/tpl/tplimpl/template.go b/tpl/tplimpl/template.go index 0ea7117a3ac..640fca8dbe5 100644 --- a/tpl/tplimpl/template.go +++ b/tpl/tplimpl/template.go @@ -23,6 +23,7 @@ import ( "path/filepath" "reflect" "regexp" + "slices" "sort" "strings" "sync" @@ -31,6 +32,7 @@ import ( "unicode/utf8" "github.com/gohugoio/hugo/common/maps" + "github.com/gohugoio/hugo/common/paths" "github.com/gohugoio/hugo/common/types" "github.com/gohugoio/hugo/output/layouts" @@ -72,6 +74,13 @@ var embeddedTemplatesAliases = map[string][]string{ "shortcodes/twitter.html": {"shortcodes/tweet.html"}, } +// These paths are reserved for embedded templates. Reserved paths follow the +// format "directory/name" (no extensions) relative to the layouts directory. +// Templates with these paths are not loaded from user space. +var reservedTemplatePaths = []string{ + "shortcodes/comment", +} + var ( _ tpl.TemplateManager = (*templateExec)(nil) _ tpl.TemplateHandler = (*templateExec)(nil) @@ -825,6 +834,12 @@ func (t *templateHandler) loadTemplates() error { name := strings.TrimPrefix(filepath.ToSlash(path), "/") filename := filepath.Base(path) + + if t.isReservedTemplatePath(path) { + t.Log.Infof("template not loaded: the path %q is reserved for embedded templates", name) + return nil + } + outputFormats := t.Conf.GetConfigSection("outputFormats").(output.Formats) outputFormat, found := outputFormats.FromFilename(filename) @@ -849,6 +864,13 @@ func (t *templateHandler) loadTemplates() error { return nil } +// isReservedTemplatePath reports whether the given template path is reserved +// for embedded templates. Reserved paths follow the format "directory/name" +// (no extensions) relative to the layouts directory. +func (t *templateHandler) isReservedTemplatePath(path string) bool { + return slices.Contains(reservedTemplatePaths, paths.ToSlashNoExtensions(path)) +} + func (t *templateHandler) nameIsText(name string) (string, bool) { isText := strings.HasPrefix(name, textTmplNamePrefix) if isText { diff --git a/tpl/tplimpl/tplimpl_integration_test.go b/tpl/tplimpl/tplimpl_integration_test.go index d1e214ce26f..53ee8439007 100644 --- a/tpl/tplimpl/tplimpl_integration_test.go +++ b/tpl/tplimpl/tplimpl_integration_test.go @@ -858,3 +858,47 @@ title: p5 } b.Assert(htmlFiles, hqt.IsAllElementsEqual) } + +func TestReservedTemplatePaths(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +disableKinds = ['page','rss','section','sitemap','taxonomy','term'] +-- layouts/LAYOUT -- +-- layouts/home.html -- +{{- .Content -}} +-- layouts/shortcodes/a.html -- +shortcode a +-- content/_index.md -- +--- +title: home +--- +a {{< comment >}} b {{< /comment >}} c|{{< a >}} +` + + filesOriginal := files + + layouts := []string{ + "shortcodes/comment.html", + "shortcodes/comment.html.html", + "shortcodes/comment.en.html.html", + "shortcodes/comment.de.html.html", + "shortcodes/comment.json", + "shortcodes/comment.json.json", + "shortcodes/comment.en.json.json", + "shortcodes/comment.de.json.json", + } + + for _, layout := range layouts { + files = strings.ReplaceAll(filesOriginal, "LAYOUT", layout) + b := hugolib.Test(t, files, hugolib.TestOptInfo()) + b.AssertLogContains("INFO template not loaded: the path", layout, "is reserved for embedded templates") + b.AssertFileContent("public/index.html", "

a c|shortcode a

") + } + + files = strings.ReplaceAll(filesOriginal, "LAYOUT", "shortcodes/foo.html") + b := hugolib.Test(t, files, hugolib.TestOptInfo()) + b.AssertLogContains("! INFO template not loaded: the path", "! is reserved for embedded templates") + b.AssertFileContent("public/index.html", "

a c|shortcode a

") +}