diff --git a/config/allconfig/load.go b/config/allconfig/load.go
index ad090d60d1a..eca9d06dfa9 100644
--- a/config/allconfig/load.go
+++ b/config/allconfig/load.go
@@ -34,6 +34,7 @@ import (
hglob "github.com/gohugoio/hugo/hugofs/glob"
"github.com/gohugoio/hugo/modules"
"github.com/gohugoio/hugo/parser/metadecoders"
+ "github.com/gohugoio/hugo/tpl"
"github.com/spf13/afero"
)
@@ -89,6 +90,9 @@ func LoadConfig(d ConfigSourceDescriptor) (*Configs, error) {
return nil, fmt.Errorf("failed to init config: %w", err)
}
+ // This is unfortunate, but this is a global setting.
+ tpl.SetSecurityAllowActionJSTmpl(configs.Base.Security.GoTemplates.AllowActionJSTmpl)
+
return configs, nil
}
diff --git a/config/security/securityConfig.go b/config/security/securityConfig.go
index 8bd12af4b77..5d0db2fb940 100644
--- a/config/security/securityConfig.go
+++ b/config/security/securityConfig.go
@@ -68,6 +68,9 @@ type Config struct {
// Allow inline shortcodes
EnableInlineShortcodes bool `json:"enableInlineShortcodes"`
+
+ // Go templates related security config.
+ GoTemplates GoTemplates `json:"goTemplates"`
}
// Exec holds os/exec policies.
@@ -93,6 +96,15 @@ type HTTP struct {
MediaTypes Whitelist `json:"mediaTypes"`
}
+type GoTemplates struct {
+
+ // Enable to allow template actions inside bakcticks in ES6 template literals.
+ // This was blocked in Hugo 0.114.0 for security reasons and you now get errors on the form
+ // "... appears in a JS template literal" if you have this in your templates.
+ // See https://github.com/golang/go/issues/59234
+ AllowActionJSTmpl bool
+}
+
// ToTOML converts c to TOML with [security] as the root.
func (c Config) ToTOML() string {
sec := c.ToSecurityMap()
diff --git a/tpl/internal/go_templates/htmltemplate/context.go b/tpl/internal/go_templates/htmltemplate/context.go
index 146a95d035c..9f592b57f5a 100644
--- a/tpl/internal/go_templates/htmltemplate/context.go
+++ b/tpl/internal/go_templates/htmltemplate/context.go
@@ -121,6 +121,8 @@ const (
stateJSDqStr
// stateJSSqStr occurs inside a JavaScript single quoted string.
stateJSSqStr
+ // stateJSBqStr occurs inside a JavaScript back quoted string.
+ stateJSBqStr
// stateJSRegexp occurs inside a JavaScript regexp literal.
stateJSRegexp
// stateJSBlockCmt occurs inside a JavaScript /* block comment */.
diff --git a/tpl/internal/go_templates/htmltemplate/css.go b/tpl/internal/go_templates/htmltemplate/css.go
index 890a0c6b227..f650d8b3e84 100644
--- a/tpl/internal/go_templates/htmltemplate/css.go
+++ b/tpl/internal/go_templates/htmltemplate/css.go
@@ -238,7 +238,7 @@ func cssValueFilter(args ...any) string {
// inside a string that might embed JavaScript source.
for i, c := range b {
switch c {
- case 0, '"', '\'', '(', ')', '/', ';', '@', '[', '\\', ']', '`', '{', '}':
+ case 0, '"', '\'', '(', ')', '/', ';', '@', '[', '\\', ']', '`', '{', '}', '<', '>':
return filterFailsafe
case '-':
// Disallow .
diff --git a/tpl/internal/go_templates/htmltemplate/css_test.go b/tpl/internal/go_templates/htmltemplate/css_test.go
index 7d8ad8b5989..f44568930dd 100644
--- a/tpl/internal/go_templates/htmltemplate/css_test.go
+++ b/tpl/internal/go_templates/htmltemplate/css_test.go
@@ -234,6 +234,8 @@ func TestCSSValueFilter(t *testing.T) {
{`-exp\000052 ession(alert(1337))`, "ZgotmplZ"},
{`-expre\0000073sion`, "-expre\x073sion"},
{`@import url evil.css`, "ZgotmplZ"},
+ {"<", "ZgotmplZ"},
+ {">", "ZgotmplZ"},
}
for _, test := range tests {
got := cssValueFilter(test.css)
diff --git a/tpl/internal/go_templates/htmltemplate/doc.go b/tpl/internal/go_templates/htmltemplate/doc.go
index 8422b49214b..98b5658f402 100644
--- a/tpl/internal/go_templates/htmltemplate/doc.go
+++ b/tpl/internal/go_templates/htmltemplate/doc.go
@@ -231,5 +231,12 @@ Least Surprise Property:
"A developer (or code reviewer) familiar with HTML, CSS, and JavaScript, who
knows that contextual autoescaping happens should be able to look at a {{.}}
and correctly infer what sanitization happens."
+
+As a consequence of the Least Surprise Property, template actions within an
+ECMAScript 6 template literal are disabled by default.
+Handling string interpolation within these literals is rather complex resulting
+in no clear safe way to support it.
+To re-enable template actions within ECMAScript 6 template literals, use the
+GODEBUG=jstmpllitinterp=1 environment variable.
*/
package template
diff --git a/tpl/internal/go_templates/htmltemplate/error.go b/tpl/internal/go_templates/htmltemplate/error.go
index 916b41a82c5..0a62563cf4f 100644
--- a/tpl/internal/go_templates/htmltemplate/error.go
+++ b/tpl/internal/go_templates/htmltemplate/error.go
@@ -215,6 +215,19 @@ const (
// pipeline occurs in an unquoted attribute value context, "html" is
// disallowed. Avoid using "html" and "urlquery" entirely in new templates.
ErrPredefinedEscaper
+
+ // errJSTmplLit: "... appears in a JS template literal"
+ // Example:
+ //
+ // Discussion:
+ // Package html/template does not support actions inside of JS template
+ // literals.
+ //
+ // TODO(rolandshoemaker): we cannot add this as an exported error in a minor
+ // release, since it is backwards incompatible with the other minor
+ // releases. As such we need to leave it unexported, and then we'll add it
+ // in the next major release.
+ errJSTmplLit
)
func (e *Error) Error() string {
diff --git a/tpl/internal/go_templates/htmltemplate/escape.go b/tpl/internal/go_templates/htmltemplate/escape.go
index 3aac865ef1b..518bc07495c 100644
--- a/tpl/internal/go_templates/htmltemplate/escape.go
+++ b/tpl/internal/go_templates/htmltemplate/escape.go
@@ -161,6 +161,8 @@ func (e *escaper) escape(c context, n parse.Node) context {
panic("escaping " + n.String() + " is unimplemented")
}
+// var debugAllowActionJSTmpl = godebug.New("jstmpllitinterp")
+
// escapeAction escapes an action template node.
func (e *escaper) escapeAction(c context, n *parse.ActionNode) context {
if len(n.Pipe.Decl) != 0 {
@@ -224,6 +226,15 @@ func (e *escaper) escapeAction(c context, n *parse.ActionNode) context {
c.jsCtx = jsCtxDivOp
case stateJSDqStr, stateJSSqStr:
s = append(s, "_html_template_jsstrescaper")
+ case stateJSBqStr:
+ if SecurityAllowActionJSTmpl { // .Value() == "1" {
+ s = append(s, "_html_template_jsstrescaper")
+ } else {
+ return context{
+ state: stateError,
+ err: errorf(errJSTmplLit, n, n.Line, "%s appears in a JS template literal", n),
+ }
+ }
case stateJSRegexp:
s = append(s, "_html_template_jsregexpescaper")
case stateCSS:
@@ -370,9 +381,8 @@ func normalizeEscFn(e string) string {
// for all x.
var redundantFuncs = map[string]map[string]bool{
"_html_template_commentescaper": {
- "_html_template_attrescaper": true,
- "_html_template_nospaceescaper": true,
- "_html_template_htmlescaper": true,
+ "_html_template_attrescaper": true,
+ "_html_template_htmlescaper": true,
},
"_html_template_cssescaper": {
"_html_template_attrescaper": true,
diff --git a/tpl/internal/go_templates/htmltemplate/escape_test.go b/tpl/internal/go_templates/htmltemplate/escape_test.go
index a08ea57ef70..680ba6fa714 100644
--- a/tpl/internal/go_templates/htmltemplate/escape_test.go
+++ b/tpl/internal/go_templates/htmltemplate/escape_test.go
@@ -683,38 +683,49 @@ func TestEscape(t *testing.T) {
``,
`
`,
},
+ {
+ "unquoted empty attribute value (plaintext)",
+ "
", + "
", + }, + { + "unquoted empty attribute value (url)", + "
", + "
", + }, + { + "quoted empty attribute value", + "
", + "
", + }, } for _, test := range tests { - tmpl := New(test.name) - tmpl = Must(tmpl.Parse(test.input)) - // Check for bug 6459: Tree field was not set in Parse. - if tmpl.Tree != tmpl.text.Tree { - t.Errorf("%s: tree not set properly", test.name) - continue - } - b := new(strings.Builder) - if err := tmpl.Execute(b, data); err != nil { - t.Errorf("%s: template execution failed: %s", test.name, err) - continue - } - if w, g := test.output, b.String(); w != g { - t.Errorf("%s: escaped output: want\n\t%q\ngot\n\t%q", test.name, w, g) - continue - } - b.Reset() - if err := tmpl.Execute(b, pdata); err != nil { - t.Errorf("%s: template execution failed for pointer: %s", test.name, err) - continue - } - if w, g := test.output, b.String(); w != g { - t.Errorf("%s: escaped output for pointer: want\n\t%q\ngot\n\t%q", test.name, w, g) - continue - } - if tmpl.Tree != tmpl.text.Tree { - t.Errorf("%s: tree mismatch", test.name) - continue - } + t.Run(test.name, func(t *testing.T) { + tmpl := New(test.name) + tmpl = Must(tmpl.Parse(test.input)) + // Check for bug 6459: Tree field was not set in Parse. + if tmpl.Tree != tmpl.text.Tree { + t.Fatalf("%s: tree not set properly", test.name) + } + b := new(strings.Builder) + if err := tmpl.Execute(b, data); err != nil { + t.Fatalf("%s: template execution failed: %s", test.name, err) + } + if w, g := test.output, b.String(); w != g { + t.Fatalf("%s: escaped output: want\n\t%q\ngot\n\t%q", test.name, w, g) + } + b.Reset() + if err := tmpl.Execute(b, pdata); err != nil { + t.Fatalf("%s: template execution failed for pointer: %s", test.name, err) + } + if w, g := test.output, b.String(); w != g { + t.Fatalf("%s: escaped output for pointer: want\n\t%q\ngot\n\t%q", test.name, w, g) + } + if tmpl.Tree != tmpl.text.Tree { + t.Fatalf("%s: tree mismatch", test.name) + } + }) } } @@ -941,6 +952,10 @@ func TestErrors(t *testing.T) { "{{range .Items}}{{if .X}}{{break}}{{end}}{{end}}", "", }, + { + "`", + "", + }, // Error cases. { "{{if .Cond}}var tmpl = `asd {{.}}`;", + `{{.}} appears in a JS template literal`, + }, } for _, test := range tests { buf := new(bytes.Buffer) @@ -1308,6 +1327,10 @@ func TestEscapeText(t *testing.T) { `= state(len(_state_index)-1) { diff --git a/tpl/internal/go_templates/htmltemplate/transition.go b/tpl/internal/go_templates/htmltemplate/transition.go index 06df679330d..92eb3519063 100644 --- a/tpl/internal/go_templates/htmltemplate/transition.go +++ b/tpl/internal/go_templates/htmltemplate/transition.go @@ -27,6 +27,7 @@ var transitionFunc = [...]func(context, []byte) (context, int){ stateJS: tJS, stateJSDqStr: tJSDelimited, stateJSSqStr: tJSDelimited, + stateJSBqStr: tJSDelimited, stateJSRegexp: tJSDelimited, stateJSBlockCmt: tBlockCmt, stateJSLineCmt: tLineCmt, @@ -262,7 +263,7 @@ func tURL(c context, s []byte) (context, int) { // tJS is the context transition function for the JS state. func tJS(c context, s []byte) (context, int) { - i := bytes.IndexAny(s, `"'/`) + i := bytes.IndexAny(s, "\"`'/") if i == -1 { // Entire input is non string, comment, regexp tokens. c.jsCtx = nextJSCtx(s, c.jsCtx) @@ -274,6 +275,8 @@ func tJS(c context, s []byte) (context, int) { c.state, c.jsCtx = stateJSDqStr, jsCtxRegexp case '\'': c.state, c.jsCtx = stateJSSqStr, jsCtxRegexp + case '`': + c.state, c.jsCtx = stateJSBqStr, jsCtxRegexp case '/': switch { case i+1 < len(s) && s[i+1] == '/': @@ -303,6 +306,8 @@ func tJSDelimited(c context, s []byte) (context, int) { switch c.state { case stateJSSqStr: specials = `\'` + case stateJSBqStr: + specials = "`\\" case stateJSRegexp: specials = `\/[]` } diff --git a/tpl/internal/go_templates/testenv/exec.go b/tpl/internal/go_templates/testenv/exec.go index ca4023647d4..13b4c7102e7 100644 --- a/tpl/internal/go_templates/testenv/exec.go +++ b/tpl/internal/go_templates/testenv/exec.go @@ -9,11 +9,9 @@ import ( "os" "os/exec" "runtime" - "strconv" "strings" "sync" "testing" - "time" ) // HasExec reports whether the current system can start new processes @@ -84,87 +82,7 @@ func CleanCmdEnv(cmd *exec.Cmd) *exec.Cmd { // - fails the test if the command does not complete before the test's deadline, and // - sets a Cleanup function that verifies that the test did not leak a subprocess. func CommandContext(t testing.TB, ctx context.Context, name string, args ...string) *exec.Cmd { - t.Helper() - MustHaveExec(t) - - var ( - cancelCtx context.CancelFunc - gracePeriod time.Duration // unlimited unless the test has a deadline (to allow for interactive debugging) - ) - - if t, ok := t.(interface { - testing.TB - Deadline() (time.Time, bool) - }); ok { - if td, ok := t.Deadline(); ok { - // Start with a minimum grace period, just long enough to consume the - // output of a reasonable program after it terminates. - gracePeriod = 100 * time.Millisecond - if s := os.Getenv("GO_TEST_TIMEOUT_SCALE"); s != "" { - scale, err := strconv.Atoi(s) - if err != nil { - t.Fatalf("invalid GO_TEST_TIMEOUT_SCALE: %v", err) - } - gracePeriod *= time.Duration(scale) - } - - // If time allows, increase the termination grace period to 5% of the - // test's remaining time. - testTimeout := time.Until(td) - if gp := testTimeout / 20; gp > gracePeriod { - gracePeriod = gp - } - - // When we run commands that execute subprocesses, we want to reserve two - // grace periods to clean up: one for the delay between the first - // termination signal being sent (via the Cancel callback when the Context - // expires) and the process being forcibly terminated (via the WaitDelay - // field), and a second one for the delay becween the process being - // terminated and and the test logging its output for debugging. - // - // (We want to ensure that the test process itself has enough time to - // log the output before it is also terminated.) - cmdTimeout := testTimeout - 2*gracePeriod - - if cd, ok := ctx.Deadline(); !ok || time.Until(cd) > cmdTimeout { - // Either ctx doesn't have a deadline, or its deadline would expire - // after (or too close before) the test has already timed out. - // Add a shorter timeout so that the test will produce useful output. - ctx, cancelCtx = context.WithTimeout(ctx, cmdTimeout) - } - } - } - - cmd := exec.CommandContext(ctx, name, args...) - /*cmd.Cancel = func() error { - if cancelCtx != nil && ctx.Err() == context.DeadlineExceeded { - // The command timed out due to running too close to the test's deadline. - // There is no way the test did that intentionally — it's too close to the - // wire! — so mark it as a test failure. That way, if the test expects the - // command to fail for some other reason, it doesn't have to distinguish - // between that reason and a timeout. - t.Errorf("test timed out while running command: %v", cmd) - } else { - // The command is being terminated due to ctx being canceled, but - // apparently not due to an explicit test deadline that we added. - // Log that information in case it is useful for diagnosing a failure, - // but don't actually fail the test because of it. - t.Logf("%v: terminating command: %v", ctx.Err(), cmd) - } - return cmd.Process.Signal(Sigquit) - } - cmd.WaitDelay = gracePeriod*/ - - t.Cleanup(func() { - if cancelCtx != nil { - cancelCtx() - } - if cmd.Process != nil && cmd.ProcessState == nil { - t.Errorf("command was started, but test did not wait for it to complete: %v", cmd) - } - }) - - return cmd + panic("Not implemented, Hugo is not using this") } // Command is like exec.Command, but applies the same changes as diff --git a/tpl/internal/go_templates/testenv/testenv.go b/tpl/internal/go_templates/testenv/testenv.go index 6bfa54a979c..91de6e76cbb 100644 --- a/tpl/internal/go_templates/testenv/testenv.go +++ b/tpl/internal/go_templates/testenv/testenv.go @@ -258,7 +258,7 @@ func MustHaveCGO(t testing.TB) { // CanInternalLink reports whether the current system can link programs with // internal linking. func CanInternalLink() bool { - return false + panic("not implemented, not needed by Hugo") } // MustInternalLink checks that the current system can link programs with internal @@ -349,15 +349,5 @@ func SkipIfOptimizationOff(t testing.TB) { // dstPath containing entries for the packages in std and cmd in addition // to the package to package file mappings in additionalPackageFiles. func WriteImportcfg(t testing.TB, dstPath string, additionalPackageFiles map[string]string) { - /*importcfg, err := goroot.Importcfg() - for k, v := range additionalPackageFiles { - importcfg += fmt.Sprintf("\npackagefile %s=%s", k, v) - } - if err != nil { - t.Fatalf("preparing the importcfg failed: %s", err) - } - err = os.WriteFile(dstPath, []byte(importcfg), 0655) - if err != nil { - t.Fatalf("writing the importcfg failed: %s", err) - }*/ + panic("not implemented, not needed by Hugo") } diff --git a/tpl/internal/go_templates/testenv/testenv_test.go b/tpl/internal/go_templates/testenv/testenv_test.go index 97c92a6e949..2f72080c2b0 100644 --- a/tpl/internal/go_templates/testenv/testenv_test.go +++ b/tpl/internal/go_templates/testenv/testenv_test.go @@ -13,7 +13,8 @@ import ( "github.com/gohugoio/hugo/tpl/internal/go_templates/testenv" ) -func _TestGoToolLocation(t *testing.T) { +func TestGoToolLocation(t *testing.T) { + t.Skip("skipping test that requires go command") testenv.MustHaveGoBuild(t) var exeSuffix string diff --git a/tpl/internal/go_templates/texttemplate/exec.go b/tpl/internal/go_templates/texttemplate/exec.go index 62b2e519ea0..597866c6866 100644 --- a/tpl/internal/go_templates/texttemplate/exec.go +++ b/tpl/internal/go_templates/texttemplate/exec.go @@ -361,19 +361,27 @@ func (s *state) walkRange(dot reflect.Value, r *parse.RangeNode) { // mark top of stack before any variables in the body are pushed. mark := s.mark() oneIteration := func(index, elem reflect.Value) { - // Set top var (lexically the second if there are two) to the element. if len(r.Pipe.Decl) > 0 { if r.Pipe.IsAssign { - s.setVar(r.Pipe.Decl[0].Ident[0], elem) + // With two variables, index comes first. + // With one, we use the element. + if len(r.Pipe.Decl) > 1 { + s.setVar(r.Pipe.Decl[0].Ident[0], index) + } else { + s.setVar(r.Pipe.Decl[0].Ident[0], elem) + } } else { + // Set top var (lexically the second if there + // are two) to the element. s.setTopVar(1, elem) } } - // Set next var (lexically the first if there are two) to the index. if len(r.Pipe.Decl) > 1 { if r.Pipe.IsAssign { - s.setVar(r.Pipe.Decl[1].Ident[0], index) + s.setVar(r.Pipe.Decl[1].Ident[0], elem) } else { + // Set next var (lexically the first if there + // are two) to the index. s.setTopVar(2, index) } } diff --git a/tpl/internal/go_templates/texttemplate/exec_test.go b/tpl/internal/go_templates/texttemplate/exec_test.go index 45edb9e9bb2..b44d61bb116 100644 --- a/tpl/internal/go_templates/texttemplate/exec_test.go +++ b/tpl/internal/go_templates/texttemplate/exec_test.go @@ -697,6 +697,7 @@ var execTests = []execTest{ {"bug18c", "{{eq . 'P'}}", "true", 'P', true}, {"issue56490", "{{$i := 0}}{{$x := 0}}{{range $i = .AI}}{{end}}{{$i}}", "5", tVal, true}, + {"issue60801", "{{$k := 0}}{{$v := 0}}{{range $k, $v = .AI}}{{$k}}={{$v}} {{end}}", "0=3 1=4 2=5 ", tVal, true}, } func zeroArgs() string { diff --git a/tpl/template.go b/tpl/template.go index 7a793101c51..095e900af45 100644 --- a/tpl/template.go +++ b/tpl/template.go @@ -169,6 +169,13 @@ func SetPageInContext(ctx context.Context, p page) context.Context { return context.WithValue(ctx, texttemplate.PageContextKey, p) } +// SetSecurityAllowActionJSTmpl sets the global setting for allowing tempalte actions in JS template literals. +// This was added in Hugo 0.114.0. +// See https://github.com/golang/go/issues/59234 +func SetSecurityAllowActionJSTmpl(b bool) { + htmltemplate.SecurityAllowActionJSTmpl = b +} + type page interface { IsNode() bool } diff --git a/tpl/tplimpl/integration_test.go b/tpl/tplimpl/integration_test.go index 4107a1faa35..fa511fbab35 100644 --- a/tpl/tplimpl/integration_test.go +++ b/tpl/tplimpl/integration_test.go @@ -2,6 +2,7 @@ package tplimpl_test import ( "path/filepath" + "strings" "testing" qt "github.com/frankban/quicktest" @@ -160,3 +161,70 @@ title: "S3P1" b.AssertFileContent("public/s2/p1/index.html", `S2P1`) b.AssertFileContent("public/s3/p1/index.html", `S3P1`) } + +func TestGoTemplateBugs(t *testing.T) { + + t.Run("Issue 11112", func(t *testing.T) { + t.Parallel() + + files := ` +-- config.toml -- +-- layouts/index.html -- +{{ $m := dict "key" "value" }} +{{ $k := "" }} +{{ $v := "" }} +{{ range $k, $v = $m }} +{{ $k }} = {{ $v }} +{{ end }} + ` + + b := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ) + b.Build() + + b.AssertFileContent("public/index.html", `key = value`) + }) + +} + +func TestSecurityAllowActionJSTmpl(t *testing.T) { + + filesTemplate := ` +-- config.toml -- +SECURITYCONFIG +-- layouts/index.html -- + + ` + + files := strings.ReplaceAll(filesTemplate, "SECURITYCONFIG", "") + + b, err := hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).BuildE() + + b.Assert(err, qt.Not(qt.IsNil)) + b.Assert(err.Error(), qt.Contains, "{{.Title}} appears in a JS template literal") + + files = strings.ReplaceAll(filesTemplate, "SECURITYCONFIG", ` +[security] +[security.gotemplates] +allowActionJSTmpl = true +`) + + b = hugolib.NewIntegrationTestBuilder( + hugolib.IntegrationTestConfig{ + T: t, + TxtarString: files, + }, + ).Build() + +}