From: Roland Shoemaker Date: Fri, 9 Jan 2026 19:12:01 +0000 (-0800) Subject: [release-branch.go1.25] html/template: properly escape URLs in meta content attributes X-Git-Tag: go1.25.8~1 X-Git-Url: http://www.git.cypherpunks.su/?a=commitdiff_plain;h=a9db31e6d9f280418ce441067f3f9dc0a036e770;p=gostls13.git [release-branch.go1.25] html/template: properly escape URLs in meta content attributes The meta tag can include a content attribute that contains URLs, which we currently don't escape if they are inserted via a template action. This can plausibly lead to XSS vulnerabilities if untrusted data is inserted there, the http-equiv attribute is set to "refresh", and the content attribute contains an action like `url={{.}}`. Track whether we are inside of a meta element, if we are inside of a content attribute, _and_ if the content attribute contains "url=". If all of those are true, then we will apply the same URL escaping that we use elsewhere. Also add a new GODEBUG, htmlmetacontenturlescape, to allow disabling this escaping for cases where this behavior is considered safe. The behavior can be disabled by setting htmlmetacontenturlescape=0. Updates #77954 Fixes #77971 Fixes CVE-2026-27142 Change-Id: I9bbca263be9894688e6ef1e9a8f8d2f4304f5873 Reviewed-on: https://go-internal-review.googlesource.com/c/go/+/3360 Reviewed-by: Neal Patel Reviewed-by: Nicholas Husin Reviewed-on: https://go-internal-review.googlesource.com/c/go/+/3644 Reviewed-by: Damien Neil Commit-Queue: Roland Shoemaker Reviewed-on: https://go-review.googlesource.com/c/go/+/752101 Auto-Submit: Gopher Robot TryBot-Bypass: Gopher Robot Reviewed-by: Dmitri Shuralyov Reviewed-by: Cherry Mui --- diff --git a/doc/godebug.md b/doc/godebug.md index a6e64d16ef..473057e18d 100644 --- a/doc/godebug.md +++ b/doc/godebug.md @@ -172,6 +172,11 @@ this setting and default was backported to Go 1.25.4 and Go 1.24.10. ### Go 1.25 +Go 1.25.8 added a new `htmlmetacontenturlescape` setting that controls whether +html/template will escape URLs in the `url=` portion of the content attribute of +HTML meta tags. The default `htmlmetacontentescape=1` will cause URLs to be +escaped. Setting `htmlmetacontentescape=0` disables this behavior. + Go 1.25 added a new `decoratemappings` setting that controls whether the Go runtime annotates OS anonymous memory mappings with context about their purpose. These annotations appear in /proc/self/maps and /proc/self/smaps as diff --git a/src/html/template/attr_string.go b/src/html/template/attr_string.go index 51c3f26208..7159fa9cba 100644 --- a/src/html/template/attr_string.go +++ b/src/html/template/attr_string.go @@ -14,11 +14,12 @@ func _() { _ = x[attrStyle-3] _ = x[attrURL-4] _ = x[attrSrcset-5] + _ = x[attrMetaContent-6] } -const _attr_name = "attrNoneattrScriptattrScriptTypeattrStyleattrURLattrSrcset" +const _attr_name = "attrNoneattrScriptattrScriptTypeattrStyleattrURLattrSrcsetattrMetaContent" -var _attr_index = [...]uint8{0, 8, 18, 32, 41, 48, 58} +var _attr_index = [...]uint8{0, 8, 18, 32, 41, 48, 58, 73} func (i attr) String() string { if i >= attr(len(_attr_index)-1) { diff --git a/src/html/template/context.go b/src/html/template/context.go index b78f0f7325..8b3af2feab 100644 --- a/src/html/template/context.go +++ b/src/html/template/context.go @@ -156,6 +156,10 @@ const ( // stateError is an infectious error state outside any valid // HTML/CSS/JS construct. stateError + // stateMetaContent occurs inside a HTML meta element content attribute. + stateMetaContent + // stateMetaContentURL occurs inside a "url=" tag in a HTML meta element content attribute. + stateMetaContentURL // stateDead marks unreachable code after a {{break}} or {{continue}}. stateDead ) @@ -267,6 +271,8 @@ const ( elementTextarea // elementTitle corresponds to the RCDATA element. elementTitle + // elementMeta corresponds to the HTML <meta> element. + elementMeta ) //go:generate stringer -type attr @@ -288,4 +294,6 @@ const ( attrURL // attrSrcset corresponds to a srcset attribute. attrSrcset + // attrMetaContent corresponds to the content attribute in meta HTML element. + attrMetaContent ) diff --git a/src/html/template/element_string.go b/src/html/template/element_string.go index db286655aa..bdf9da7b9d 100644 --- a/src/html/template/element_string.go +++ b/src/html/template/element_string.go @@ -13,11 +13,12 @@ func _() { _ = x[elementStyle-2] _ = x[elementTextarea-3] _ = x[elementTitle-4] + _ = x[elementMeta-5] } -const _element_name = "elementNoneelementScriptelementStyleelementTextareaelementTitle" +const _element_name = "elementNoneelementScriptelementStyleelementTextareaelementTitleelementMeta" -var _element_index = [...]uint8{0, 11, 24, 36, 51, 63} +var _element_index = [...]uint8{0, 11, 24, 36, 51, 63, 74} func (i element) String() string { if i >= element(len(_element_index)-1) { diff --git a/src/html/template/escape.go b/src/html/template/escape.go index 1f963e61b4..d8e1b8cb54 100644 --- a/src/html/template/escape.go +++ b/src/html/template/escape.go @@ -166,6 +166,8 @@ func (e *escaper) escape(c context, n parse.Node) context { var debugAllowActionJSTmpl = godebug.New("jstmpllitinterp") +var htmlmetacontenturlescape = godebug.New("htmlmetacontenturlescape") + // escapeAction escapes an action template node. func (e *escaper) escapeAction(c context, n *parse.ActionNode) context { if len(n.Pipe.Decl) != 0 { @@ -223,6 +225,18 @@ func (e *escaper) escapeAction(c context, n *parse.ActionNode) context { default: panic(c.urlPart.String()) } + case stateMetaContent: + // Handled below in delim check. + case stateMetaContentURL: + if htmlmetacontenturlescape.Value() != "0" { + s = append(s, "_html_template_urlfilter") + } else { + // We don't have a great place to increment this, since it's hard to + // know if we actually escape any urls in _html_template_urlfilter, + // since it has no information about what context it is being + // executed in etc. This is probably the best we can do. + htmlmetacontenturlescape.IncNonDefault() + } case stateJS: s = append(s, "_html_template_jsvalescaper") // A slash after a value starts a div operator. diff --git a/src/html/template/escape_test.go b/src/html/template/escape_test.go index 003060e90f..49710c38b7 100644 --- a/src/html/template/escape_test.go +++ b/src/html/template/escape_test.go @@ -734,6 +734,16 @@ func TestEscape(t *testing.T) { "<script>var a = `${ var a = \"{{\"a \\\" d\"}}\" }`</script>", "<script>var a = `${ var a = \"a \\u0022 d\" }`</script>", }, + { + "meta content attribute url", + `<meta http-equiv="refresh" content="asd; url={{"javascript:alert(1)"}}; asd; url={{"vbscript:alert(1)"}}; asd">`, + `<meta http-equiv="refresh" content="asd; url=#ZgotmplZ; asd; url=#ZgotmplZ; asd">`, + }, + { + "meta content string", + `<meta http-equiv="refresh" content="{{"asd: 123"}}">`, + `<meta http-equiv="refresh" content="asd: 123">`, + }, } for _, test := range tests { @@ -1016,6 +1026,14 @@ func TestErrors(t *testing.T) { "<script>var tmpl = `asd ${return \"{\"}`;</script>", ``, }, + { + `{{if eq "" ""}}<meta>{{end}}`, + ``, + }, + { + `{{if eq "" ""}}<meta content="url={{"asd"}}">{{end}}`, + ``, + }, // Error cases. { @@ -2198,3 +2216,19 @@ func TestAliasedParseTreeDoesNotOverescape(t *testing.T) { t.Fatalf(`Template "foo" and "bar" rendered %q and %q respectively, expected equal values`, got1, got2) } } + +func TestMetaContentEscapeGODEBUG(t *testing.T) { + savedGODEBUG := os.Getenv("GODEBUG") + os.Setenv("GODEBUG", savedGODEBUG+",htmlmetacontenturlescape=0") + defer func() { os.Setenv("GODEBUG", savedGODEBUG) }() + + tmpl := Must(New("").Parse(`<meta http-equiv="refresh" content="asd; url={{"javascript:alert(1)"}}; asd; url={{"vbscript:alert(1)"}}; asd">`)) + var b strings.Builder + if err := tmpl.Execute(&b, nil); err != nil { + t.Fatalf("unexpected error: %s", err) + } + want := `<meta http-equiv="refresh" content="asd; url=javascript:alert(1); asd; url=vbscript:alert(1); asd">` + if got := b.String(); got != want { + t.Fatalf("got %q, want %q", got, want) + } +} diff --git a/src/html/template/state_string.go b/src/html/template/state_string.go index eed1e8bcc0..f5a70b2231 100644 --- a/src/html/template/state_string.go +++ b/src/html/template/state_string.go @@ -36,12 +36,14 @@ func _() { _ = x[stateCSSBlockCmt-25] _ = x[stateCSSLineCmt-26] _ = x[stateError-27] - _ = x[stateDead-28] + _ = x[stateMetaContent-28] + _ = x[stateMetaContentURL-29] + _ = x[stateDead-30] } -const _state_name = "stateTextstateTagstateAttrNamestateAfterNamestateBeforeValuestateHTMLCmtstateRCDATAstateAttrstateURLstateSrcsetstateJSstateJSDqStrstateJSSqStrstateJSTmplLitstateJSRegexpstateJSBlockCmtstateJSLineCmtstateJSHTMLOpenCmtstateJSHTMLCloseCmtstateCSSstateCSSDqStrstateCSSSqStrstateCSSDqURLstateCSSSqURLstateCSSURLstateCSSBlockCmtstateCSSLineCmtstateErrorstateDead" +const _state_name = "stateTextstateTagstateAttrNamestateAfterNamestateBeforeValuestateHTMLCmtstateRCDATAstateAttrstateURLstateSrcsetstateJSstateJSDqStrstateJSSqStrstateJSTmplLitstateJSRegexpstateJSBlockCmtstateJSLineCmtstateJSHTMLOpenCmtstateJSHTMLCloseCmtstateCSSstateCSSDqStrstateCSSSqStrstateCSSDqURLstateCSSSqURLstateCSSURLstateCSSBlockCmtstateCSSLineCmtstateErrorstateMetaContentstateMetaContentURLstateDead" -var _state_index = [...]uint16{0, 9, 17, 30, 44, 60, 72, 83, 92, 100, 111, 118, 130, 142, 156, 169, 184, 198, 216, 235, 243, 256, 269, 282, 295, 306, 322, 337, 347, 356} +var _state_index = [...]uint16{0, 9, 17, 30, 44, 60, 72, 83, 92, 100, 111, 118, 130, 142, 156, 169, 184, 198, 216, 235, 243, 256, 269, 282, 295, 306, 322, 337, 347, 363, 382, 391} func (i state) String() string { if i >= state(len(_state_index)-1) { diff --git a/src/html/template/transition.go b/src/html/template/transition.go index c430389a34..7fbab1df7b 100644 --- a/src/html/template/transition.go +++ b/src/html/template/transition.go @@ -23,6 +23,8 @@ var transitionFunc = [...]func(context, []byte) (context, int){ stateRCDATA: tSpecialTagEnd, stateAttr: tAttr, stateURL: tURL, + stateMetaContent: tMetaContent, + stateMetaContentURL: tMetaContentURL, stateSrcset: tURL, stateJS: tJS, stateJSDqStr: tJSDelimited, @@ -83,6 +85,7 @@ var elementContentType = [...]state{ elementStyle: stateCSS, elementTextarea: stateRCDATA, elementTitle: stateRCDATA, + elementMeta: stateText, } // tTag is the context transition function for the tag state. @@ -93,6 +96,11 @@ func tTag(c context, s []byte) (context, int) { return c, len(s) } if s[i] == '>' { + // Treat <meta> specially, because it doesn't have an end tag, and we + // want to transition into the correct state/element for it. + if c.element == elementMeta { + return context{state: stateText, element: elementNone}, i + 1 + } return context{ state: elementContentType[c.element], element: c.element, @@ -113,6 +121,8 @@ func tTag(c context, s []byte) (context, int) { attrName := strings.ToLower(string(s[i:j])) if c.element == elementScript && attrName == "type" { attr = attrScriptType + } else if c.element == elementMeta && attrName == "content" { + attr = attrMetaContent } else { switch attrType(attrName) { case contentTypeURL: @@ -162,12 +172,13 @@ func tAfterName(c context, s []byte) (context, int) { } var attrStartStates = [...]state{ - attrNone: stateAttr, - attrScript: stateJS, - attrScriptType: stateAttr, - attrStyle: stateCSS, - attrURL: stateURL, - attrSrcset: stateSrcset, + attrNone: stateAttr, + attrScript: stateJS, + attrScriptType: stateAttr, + attrStyle: stateCSS, + attrURL: stateURL, + attrSrcset: stateSrcset, + attrMetaContent: stateMetaContent, } // tBeforeValue is the context transition function for stateBeforeValue. @@ -203,6 +214,7 @@ var specialTagEndMarkers = [...][]byte{ elementStyle: []byte("style"), elementTextarea: []byte("textarea"), elementTitle: []byte("title"), + elementMeta: []byte(""), } var ( @@ -612,6 +624,28 @@ func tError(c context, s []byte) (context, int) { return c, len(s) } +// tMetaContent is the context transition function for the meta content attribute state. +func tMetaContent(c context, s []byte) (context, int) { + for i := 0; i < len(s); i++ { + if i+3 <= len(s)-1 && bytes.Equal(bytes.ToLower(s[i:i+4]), []byte("url=")) { + c.state = stateMetaContentURL + return c, i + 4 + } + } + return c, len(s) +} + +// tMetaContentURL is the context transition function for the "url=" part of a meta content attribute state. +func tMetaContentURL(c context, s []byte) (context, int) { + for i := 0; i < len(s); i++ { + if s[i] == ';' { + c.state = stateMetaContent + return c, i + 1 + } + } + return c, len(s) +} + // eatAttrName returns the largest j such that s[i:j] is an attribute name. // It returns an error if s[i:] does not look like it begins with an // attribute name, such as encountering a quote mark without a preceding @@ -638,6 +672,7 @@ var elementNameMap = map[string]element{ "style": elementStyle, "textarea": elementTextarea, "title": elementTitle, + "meta": elementMeta, } // asciiAlpha reports whether c is an ASCII letter. diff --git a/src/internal/godebugs/table.go b/src/internal/godebugs/table.go index 1d4a329fef..af93a8b0be 100644 --- a/src/internal/godebugs/table.go +++ b/src/internal/godebugs/table.go @@ -39,6 +39,7 @@ var All = []Info{ {Name: "gocacheverify", Package: "cmd/go"}, {Name: "gotestjsonbuildtext", Package: "cmd/go", Changed: 24, Old: "1"}, {Name: "gotypesalias", Package: "go/types", Changed: 23, Old: "0"}, + {Name: "htmlmetacontenturlescape", Package: "html/template"}, {Name: "http2client", Package: "net/http"}, {Name: "http2debug", Package: "net/http", Opaque: true}, {Name: "http2server", Package: "net/http"}, diff --git a/src/runtime/metrics/doc.go b/src/runtime/metrics/doc.go index bd90b5f894..e9af2c8eaf 100644 --- a/src/runtime/metrics/doc.go +++ b/src/runtime/metrics/doc.go @@ -274,6 +274,11 @@ Below is the full list of supported metrics, ordered lexicographically. The number of non-default behaviors executed by the go/types package due to a non-default GODEBUG=gotypesalias=... setting. + /godebug/non-default-behavior/htmlmetacontenturlescape:events + The number of non-default behaviors executed by + the html/template package due to a non-default + GODEBUG=htmlmetacontenturlescape=... setting. + /godebug/non-default-behavior/http2client:events The number of non-default behaviors executed by the net/http package due to a non-default GODEBUG=http2client=... setting.