]> Cypherpunks repositories - gostls13.git/commitdiff
[release-branch.go1.26] html/template: properly escape URLs in meta content attributes
authorRoland Shoemaker <bracewell@google.com>
Fri, 9 Jan 2026 19:12:01 +0000 (11:12 -0800)
committerGopher Robot <gobot@golang.org>
Fri, 6 Mar 2026 00:12:49 +0000 (16:12 -0800)
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 #77972
Fixes CVE-2026-27142

Change-Id: I9bbca263be9894688e6ef1e9a8f8d2f4304f5873
Reviewed-on: https://go-internal-review.googlesource.com/c/go/+/3360
Reviewed-by: Neal Patel <nealpatel@google.com>
Reviewed-by: Nicholas Husin <husin@google.com>
Reviewed-on: https://go-internal-review.googlesource.com/c/go/+/3643
Reviewed-by: Damien Neil <dneil@google.com>
Reviewed-on: https://go-review.googlesource.com/c/go/+/752081
Auto-Submit: Gopher Robot <gobot@golang.org>
Reviewed-by: Cherry Mui <cherryyz@google.com>
TryBot-Bypass: Gopher Robot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
doc/godebug.md
src/html/template/attr_string.go
src/html/template/context.go
src/html/template/element_string.go
src/html/template/escape.go
src/html/template/escape_test.go
src/html/template/state_string.go
src/html/template/transition.go
src/internal/godebugs/table.go
src/runtime/metrics/doc.go

index 90ed63a01ad4588e668ce27a8e235477835e036e..dbec880c56c7ac4383d853bdf923a783a7e7be43 100644 (file)
@@ -155,6 +155,11 @@ and the [go command documentation](/cmd/go#hdr-Build_and_test_caching).
 
 ### Go 1.26
 
+Go 1.26.1 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.26 added a new `httpcookiemaxnum` setting that controls the maximum number
 of cookies that net/http will accept when parsing HTTP headers. If the number of
 cookie in a header exceeds the number set in `httpcookiemaxnum`, cookie parsing
index 51c3f262084c04592e18c6c563070c706eac9ba2..7159fa9cbaa15a0b78c6c98324ad0133d180b05b 100644 (file)
@@ -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) {
index b78f0f7325ed66545278250aabd421bd82e78920..8b3af2feabd8aa9199c1599cfff19dc455d4f431 100644 (file)
@@ -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 <title> 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
 )
index db286655aa30aaa908519eee37c82f04b484ef31..bdf9da7b9d53e665e4f4fc684db8117e59ef57dd 100644 (file)
@@ -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) {
index 1f963e61b4cedef2008f05110d3dc5f0bab4f727..d8e1b8cb547db2d0bd6e7bfb4afc20fc3135428b 100644 (file)
@@ -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.
index 003060e90fb620a9274b94af5a0e4e759caeb222..49710c38b7265bb7f22e7d341026da5b01738112 100644 (file)
@@ -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)
+       }
+}
index eed1e8bcc018c22bb99abb70f2265d956e9fdcdf..f5a70b2231c2da6065827496114523eb97c7612d 100644 (file)
@@ -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) {
index c430389a345f69b256ede5488a4e2b2940ffe9a2..7fbab1df7b06ee02a40cc6b7c126864642830c71 100644 (file)
@@ -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.
index 87b499385a7cdc9054caab71303db43b216ec33a..014229399d04ba022ac8db2de3447d821b1990b1 100644 (file)
@@ -40,6 +40,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"},
index 6b774c36f35df5de7141c0da82470f24510c0284..d0ca6f0dcf5b7c3d7961ce76e0a2136f1eef4386 100644 (file)
@@ -306,6 +306,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.