html/template: double-encode dynamic values in srcdoc attributes
The srcdoc attribute is mapped to contentTypeHTML in attrTypeMap, but
tTag did not handle contentTypeHTML in its switch. Dynamic values
therefore fell through to attr=attrNone/stateAttr and received only
single-level HTML attribute encoding (_html_template_attrescaper).
The HTML specification requires browsers to HTML-decode srcdoc attribute
values before using them as the iframe document content. Single-level
encoding is therefore insufficient: the browser's decode step reverses
it, exposing the original HTML to the parser and allowing arbitrary
script execution in the context of the parent page's origin.
For example:
t.Execute(w, "<script>alert(1)</script>")
// before: <iframe srcdoc="<script>alert(1)</script>">
// browser decodes → <script>alert(1)</script> (executes)
// after: <iframe srcdoc="&lt;script&gt;alert(1)&lt;/script&gt;">
// browser decodes → <script>alert(1)</script> (text, safe)
This change introduces attrSrcdoc, adds it to tTag's contentType switch
and to attrStartStates, and registers srcdocEscaper. The new escaper
applies one level of HTML encoding; attrEscaper (applied by the
existing delimiter check) adds the second level. For template.HTML
values, srcdocEscaper strips tags (consistent with attrEscaper) and
returns plain text for attrEscaper to attribute-encode.
Fixes #80154
diff --git a/src/html/template/attr_string.go b/src/html/template/attr_string.go
index 009458f..4365caa 100644
--- a/src/html/template/attr_string.go
+++ b/src/html/template/attr_string.go
@@ -15,16 +15,16 @@
_ = x[attrURL-4]
_ = x[attrSrcset-5]
_ = x[attrMetaContent-6]
+ _ = x[attrSrcdoc-7]
}
-const _attr_name = "attrNoneattrScriptattrScriptTypeattrStyleattrURLattrSrcsetattrMetaContent"
+const _attr_name = "attrNoneattrScriptattrScriptTypeattrStyleattrURLattrSrcsetattrMetaContentattrSrcdoc"
-var _attr_index = [...]uint8{0, 8, 18, 32, 41, 48, 58, 73}
+var _attr_index = [...]uint8{0, 8, 18, 32, 41, 48, 58, 73, 83}
func (i attr) String() string {
- idx := int(i) - 0
- if i < 0 || idx >= len(_attr_index)-1 {
+ if i >= attr(len(_attr_index)-1) {
return "attr(" + strconv.FormatInt(int64(i), 10) + ")"
}
- return _attr_name[_attr_index[idx]:_attr_index[idx+1]]
+ return _attr_name[_attr_index[i]:_attr_index[i+1]]
}
diff --git a/src/html/template/context.go b/src/html/template/context.go
index 132ae2d..b1a8f82 100644
--- a/src/html/template/context.go
+++ b/src/html/template/context.go
@@ -308,4 +308,5 @@
attrSrcset
// attrMetaContent corresponds to the content attribute in meta HTML element.
attrMetaContent
+ attrSrcdoc
)
diff --git a/src/html/template/escape.go b/src/html/template/escape.go
index e18fa3a..e192c4d 100644
--- a/src/html/template/escape.go
+++ b/src/html/template/escape.go
@@ -76,6 +76,7 @@
"_html_template_nospaceescaper": htmlNospaceEscaper,
"_html_template_rcdataescaper": rcdataEscaper,
"_html_template_srcsetescaper": srcsetFilterAndEscaper,
+ "_html_template_srcdocescaper": srcdocEscaper,
"_html_template_urlescaper": urlEscaper,
"_html_template_urlfilter": urlFilter,
"_html_template_urlnormalizer": urlNormalizer,
@@ -254,7 +255,9 @@
case stateRCDATA:
s = append(s, "_html_template_rcdataescaper")
case stateAttr:
- // Handled below in delim check.
+ if c.attr == attrSrcdoc {
+ s = append(s, "_html_template_srcdocescaper")
+ }
case stateAttrName, stateTag:
c.state = stateAttrName
s = append(s, "_html_template_htmlnamefilter")
diff --git a/src/html/template/html.go b/src/html/template/html.go
index a181699..087758e 100644
--- a/src/html/template/html.go
+++ b/src/html/template/html.go
@@ -50,6 +50,14 @@
return htmlReplacer(s, htmlReplacementTable, true)
}
+func srcdocEscaper(args ...any) string {
+ s, t := stringify(args...)
+ if t == contentTypeHTML {
+ return stripTags(s)
+ }
+ return htmlReplacer(s, htmlReplacementTable, true)
+}
+
// htmlReplacementTable contains the runes that need to be escaped
// inside a quoted attribute value or in a text node.
var htmlReplacementTable = []string{
diff --git a/src/html/template/srcdoc_test.go b/src/html/template/srcdoc_test.go
new file mode 100644
index 0000000..96d2e5a
--- /dev/null
+++ b/src/html/template/srcdoc_test.go
@@ -0,0 +1,108 @@
+package template
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestSrcdocEscaper(t *testing.T) {
+ tests := []struct {
+ name string
+ input any
+ wantAttr string
+ }{
+ {
+ "script tag injection",
+ `<script>alert(document.domain)</script>`,
+ `&lt;script&gt;alert(document.domain)&lt;/script&gt;`,
+ },
+ {
+ "img onerror injection",
+ `<img src=x onerror=alert(1)>`,
+ `&lt;img src=x onerror=alert(1)&gt;`,
+ },
+ {
+ "svg onload injection",
+ `<svg onload=alert(1)>`,
+ `&lt;svg onload=alert(1)&gt;`,
+ },
+ {
+ "double quote",
+ `say "hello"`,
+ `say &#34;hello&#34;`,
+ },
+ {
+ "plain text",
+ `Hello, World!`,
+ `Hello, World!`,
+ },
+ {
+ "ampersand",
+ `a & b`,
+ `a &amp; b`,
+ },
+ {
+ "trusted HTML strips tags",
+ HTML(`<b>hello</b>`),
+ `hello`,
+ },
+ {
+ "trusted HTML plain text",
+ HTML(`safe text`),
+ `safe text`,
+ },
+ {
+ "empty string",
+ ``,
+ ``,
+ },
+ }
+
+ tmpl := Must(New("").Parse(`<iframe srcdoc="{{.}}"></iframe>`))
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var buf strings.Builder
+ if err := tmpl.Execute(&buf, tt.input); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ got := buf.String()
+ want := `<iframe srcdoc="` + tt.wantAttr + `"></iframe>`
+ if got != want {
+ t.Errorf("\ngot: %s\nwant: %s", got, want)
+ }
+ })
+ }
+}
+
+func TestSrcdocEscaperSingleQuote(t *testing.T) {
+ tmpl := Must(New("").Parse(`<iframe srcdoc='{{.}}'></iframe>`))
+ var buf strings.Builder
+ if err := tmpl.Execute(&buf, `<script>x</script>`); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ got := buf.String()
+ want := `<iframe srcdoc='&lt;script&gt;x&lt;/script&gt;'></iframe>`
+ if got != want {
+ t.Errorf("\ngot: %s\nwant: %s", got, want)
+ }
+}
+
+func TestSrcdocNotExecutable(t *testing.T) {
+ tmpl := Must(New("").Parse(`<iframe srcdoc="{{.}}"></iframe>`))
+ var buf strings.Builder
+ if err := tmpl.Execute(&buf, `<script>alert(1)</script>`); err != nil {
+ t.Fatalf("Execute: %v", err)
+ }
+ out := buf.String()
+
+ const dangerous = `srcdoc="<script>`
+ if strings.Contains(out, dangerous) {
+ t.Errorf("output contains single-encoded script tag that a browser would execute:\n%s", out)
+ }
+
+ const safe = `srcdoc="&lt;script&gt;`
+ if !strings.Contains(out, safe) {
+ t.Errorf("output does not contain double-encoded script tag:\n%s", out)
+ }
+}
diff --git a/src/html/template/transition.go b/src/html/template/transition.go
index 05b6abd..9c272189 100644
--- a/src/html/template/transition.go
+++ b/src/html/template/transition.go
@@ -133,6 +133,8 @@
attr = attrScript
case contentTypeSrcset:
attr = attrSrcset
+ case contentTypeHTML:
+ attr = attrSrcdoc
}
}
@@ -179,6 +181,7 @@
attrURL: stateURL,
attrSrcset: stateSrcset,
attrMetaContent: stateMetaContent,
+ attrSrcdoc: stateAttr,
}
// tBeforeValue is the context transition function for stateBeforeValue.
@@ -426,7 +429,7 @@
// If "</script" appears in a regex literal, the '/' should not
// close the regex literal, and it will later be escaped to
// "\x3C/script" in escapeText.
- if i > 0 && i+7 <= len(s) && bytes.EqualFold(s[i-1:i+7], []byte("</script")) {
+ if i > 0 && i+7 <= len(s) && bytes.Equal(bytes.ToLower(s[i-1:i+7]), []byte("</script")) {
i++
} else if !inCharset {
c.state, c.jsCtx = stateJS, jsCtxDivOp
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
Congratulations on opening your first change. Thank you for your contribution!
Next steps:
A maintainer will review your change and provide feedback. See
https://go.dev/doc/contribute#review for more info and tips to get your
patch through code review.
Most changes in the Go project go through a few rounds of revision. This can be
surprising to people new to the project. The careful, iterative review process
is our way of helping mentor contributors and ensuring that their contributions
have a lasting impact.
During May-July and Nov-Jan the Go project is in a code freeze, during which
little code gets reviewed or merged. If a reviewer responds with a comment like
R=go1.11 or adds a tag like "wait-release", it means that this CL will be
reviewed as part of the next development cycle. See https://go.dev/s/release
for more details.
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |
Hi Roland,
I've submitted CL 794481 implementing the srcdoc double-encoding fix for #80154. Since then, CL 794660 appeared covering the same issue with an overlapping approach.
I'd appreciate a review of 794481 when you have a chance. Happy to address any feedback quickly.
Thanks,
Pixel_DefaultBR
| Inspect html for hidden footers to help with email filtering. To unsubscribe visit settings. |