Commit c0cda71d authored by Mike Samuel's avatar Mike Samuel Committed by Russ Cox

html/template: add srcset content type

Srcset is largely the same as a URL, but is escaped in URL contexts.
Inside a srcset attribute, URLs have their commas percent-escaped to
avoid having the URL be interpreted as multiple URLs.  Srcset is placed
in a srcset attribute literally.

Fixes #17441

Change-Id: I676b544784c7e54954ddb91eeff242cab25d02c4
Reviewed-on: https://go-review.googlesource.com/38324Reviewed-by: 's avatarKunpei Sakai <namusyaka@gmail.com>
Reviewed-by: 's avatarMike Samuel <mikesamuel@gmail.com>
Reviewed-by: 's avatarRuss Cox <rsc@golang.org>
Run-TryBot: Russ Cox <rsc@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
parent c7b7c433
......@@ -120,6 +120,7 @@ var attrTypeMap = map[string]contentType{
"src": contentTypeURL,
"srcdoc": contentTypeHTML,
"srclang": contentTypePlain,
"srcset": contentTypeSrcset,
"start": contentTypePlain,
"step": contentTypePlain,
"style": contentTypeCSS,
......
......@@ -83,6 +83,14 @@ type (
// the encapsulated content should come from a trusted source,
// as it will be included verbatim in the template output.
URL string
// Srcset encapsulates a known safe srcset attribute
// (see http://w3c.github.io/html/semantics-embedded-content.html#element-attrdef-img-srcset).
//
// Use of this type presents a security risk:
// the encapsulated content should come from a trusted source,
// as it will be included verbatim in the template output.
Srcset string
)
type contentType uint8
......@@ -95,6 +103,7 @@ const (
contentTypeJS
contentTypeJSStr
contentTypeURL
contentTypeSrcset
// contentTypeUnsafe is used in attr.go for values that affect how
// embedded content and network messages are formed, vetted,
// or interpreted; or which credentials network messages carry.
......@@ -156,6 +165,8 @@ func stringify(args ...interface{}) (string, contentType) {
return string(s), contentTypeJSStr
case URL:
return string(s), contentTypeURL
case Srcset:
return string(s), contentTypeSrcset
}
}
for i, arg := range args {
......
......@@ -19,7 +19,9 @@ func TestTypedContent(t *testing.T) {
HTMLAttr(` dir="ltr"`),
JS(`c && alert("Hello, World!");`),
JSStr(`Hello, World & O'Reilly\x21`),
URL(`greeting=H%69&addressee=(World)`),
URL(`greeting=H%69,&addressee=(World)`),
Srcset(`greeting=H%69,&addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`),
URL(`,foo/,`),
}
// For each content sensitive escaper, see how it does on
......@@ -40,6 +42,8 @@ func TestTypedContent(t *testing.T) {
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
},
},
{
......@@ -53,6 +57,8 @@ func TestTypedContent(t *testing.T) {
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
},
},
{
......@@ -65,7 +71,9 @@ func TestTypedContent(t *testing.T) {
` dir=&#34;ltr&#34;`,
`c &amp;&amp; alert(&#34;Hello, World!&#34;);`,
`Hello, World &amp; O&#39;Reilly\x21`,
`greeting=H%69&amp;addressee=(World)`,
`greeting=H%69,&amp;addressee=(World)`,
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`,foo/,`,
},
},
{
......@@ -79,6 +87,8 @@ func TestTypedContent(t *testing.T) {
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
},
},
{
......@@ -91,7 +101,9 @@ func TestTypedContent(t *testing.T) {
`&#32;dir&#61;&#34;ltr&#34;`,
`c&#32;&amp;&amp;&#32;alert(&#34;Hello,&#32;World!&#34;);`,
`Hello,&#32;World&#32;&amp;&#32;O&#39;Reilly\x21`,
`greeting&#61;H%69&amp;addressee&#61;(World)`,
`greeting&#61;H%69,&amp;addressee&#61;(World)`,
`greeting&#61;H%69,&amp;addressee&#61;(World)&#32;2x,&#32;https://golang.org/favicon.ico&#32;500.5w`,
`,foo/,`,
},
},
{
......@@ -104,7 +116,9 @@ func TestTypedContent(t *testing.T) {
` dir=&#34;ltr&#34;`,
`c &amp;&amp; alert(&#34;Hello, World!&#34;);`,
`Hello, World &amp; O&#39;Reilly\x21`,
`greeting=H%69&amp;addressee=(World)`,
`greeting=H%69,&amp;addressee=(World)`,
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`,foo/,`,
},
},
{
......@@ -117,7 +131,9 @@ func TestTypedContent(t *testing.T) {
` dir=&#34;ltr&#34;`,
`c &amp;&amp; alert(&#34;Hello, World!&#34;);`,
`Hello, World &amp; O&#39;Reilly\x21`,
`greeting=H%69&amp;addressee=(World)`,
`greeting=H%69,&amp;addressee=(World)`,
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`,foo/,`,
},
},
{
......@@ -131,7 +147,9 @@ func TestTypedContent(t *testing.T) {
`c && alert("Hello, World!");`,
// Escape sequence not over-escaped.
`"Hello, World & O'Reilly\x21"`,
`"greeting=H%69\u0026addressee=(World)"`,
`"greeting=H%69,\u0026addressee=(World)"`,
`"greeting=H%69,\u0026addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"`,
`",foo/,"`,
},
},
{
......@@ -145,7 +163,9 @@ func TestTypedContent(t *testing.T) {
`c &amp;&amp; alert(&#34;Hello, World!&#34;);`,
// Escape sequence not over-escaped.
`&#34;Hello, World &amp; O&#39;Reilly\x21&#34;`,
`&#34;greeting=H%69\u0026addressee=(World)&#34;`,
`&#34;greeting=H%69,\u0026addressee=(World)&#34;`,
`&#34;greeting=H%69,\u0026addressee=(World) 2x, https://golang.org/favicon.ico 500.5w&#34;`,
`&#34;,foo/,&#34;`,
},
},
{
......@@ -158,7 +178,9 @@ func TestTypedContent(t *testing.T) {
`c \x26\x26 alert(\x22Hello, World!\x22);`,
// Escape sequence not over-escaped.
`Hello, World \x26 O\x27Reilly\x21`,
`greeting=H%69\x26addressee=(World)`,
`greeting=H%69,\x26addressee=(World)`,
`greeting=H%69,\x26addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w`,
`,foo\/,`,
},
},
{
......@@ -171,7 +193,9 @@ func TestTypedContent(t *testing.T) {
`c \x26\x26 alert(\x22Hello, World!\x22);`,
// Escape sequence not over-escaped.
`Hello, World \x26 O\x27Reilly\x21`,
`greeting=H%69\x26addressee=(World)`,
`greeting=H%69,\x26addressee=(World)`,
`greeting=H%69,\x26addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w`,
`,foo\/,`,
},
},
{
......@@ -185,7 +209,9 @@ func TestTypedContent(t *testing.T) {
`c && alert("Hello, World!");`,
// Escape sequence not over-escaped.
`"Hello, World & O'Reilly\x21"`,
`"greeting=H%69\u0026addressee=(World)"`,
`"greeting=H%69,\u0026addressee=(World)"`,
`"greeting=H%69,\u0026addressee=(World) 2x, https://golang.org/favicon.ico 500.5w"`,
`",foo/,"`,
},
},
{
......@@ -199,7 +225,9 @@ func TestTypedContent(t *testing.T) {
` dir=&#34;ltr&#34;`,
`c &amp;&amp; alert(&#34;Hello, World!&#34;);`,
`Hello, World &amp; O&#39;Reilly\x21`,
`greeting=H%69&amp;addressee=(World)`,
`greeting=H%69,&amp;addressee=(World)`,
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`,foo/,`,
},
},
{
......@@ -212,7 +240,9 @@ func TestTypedContent(t *testing.T) {
`c \x26\x26 alert(\x22Hello, World!\x22);`,
// Escape sequence not over-escaped.
`Hello, World \x26 O\x27Reilly\x21`,
`greeting=H%69\x26addressee=(World)`,
`greeting=H%69,\x26addressee=(World)`,
`greeting=H%69,\x26addressee=(World) 2x, https:\/\/golang.org\/favicon.ico 500.5w`,
`,foo\/,`,
},
},
{
......@@ -225,7 +255,9 @@ func TestTypedContent(t *testing.T) {
`c%20%26%26%20alert%28%22Hello%2c%20World%21%22%29%3b`,
`Hello%2c%20World%20%26%20O%27Reilly%5cx21`,
// Quotes and parens are escaped but %69 is not over-escaped. HTML escaping is done.
`greeting=H%69&amp;addressee=%28World%29`,
`greeting=H%69,&amp;addressee=%28World%29`,
`greeting%3dH%2569%2c%26addressee%3d%28World%29%202x%2c%20https%3a%2f%2fgolang.org%2ffavicon.ico%20500.5w`,
`,foo/,`,
},
},
{
......@@ -238,7 +270,113 @@ func TestTypedContent(t *testing.T) {
`c%20%26%26%20alert%28%22Hello%2c%20World%21%22%29%3b`,
`Hello%2c%20World%20%26%20O%27Reilly%5cx21`,
// Quotes and parens are escaped but %69 is not over-escaped. HTML escaping is not done.
`greeting=H%69&addressee=%28World%29`,
`greeting=H%69,&addressee=%28World%29`,
`greeting%3dH%2569%2c%26addressee%3d%28World%29%202x%2c%20https%3a%2f%2fgolang.org%2ffavicon.ico%20500.5w`,
`,foo/,`,
},
},
{
`<img srcset="{{.}}">`,
[]string{
`#ZgotmplZ`,
`#ZgotmplZ`,
// Commas are not esacped
`Hello,#ZgotmplZ`,
// Leading spaces are not percent escapes.
` dir=%22ltr%22`,
// Spaces after commas are not percent escaped.
`#ZgotmplZ, World!%22%29;`,
`Hello,#ZgotmplZ`,
`greeting=H%69%2c&amp;addressee=%28World%29`,
// Metadata is not escaped.
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`%2cfoo/%2c`,
},
},
{
`<img srcset={{.}}>`,
[]string{
`#ZgotmplZ`,
`#ZgotmplZ`,
`Hello,#ZgotmplZ`,
// Spaces are HTML escaped not %-escaped
`&#32;dir&#61;%22ltr%22`,
`#ZgotmplZ,&#32;World!%22%29;`,
`Hello,#ZgotmplZ`,
`greeting&#61;H%69%2c&amp;addressee&#61;%28World%29`,
`greeting&#61;H%69,&amp;addressee&#61;(World)&#32;2x,&#32;https://golang.org/favicon.ico&#32;500.5w`,
// Commas are escaped.
`%2cfoo/%2c`,
},
},
{
`<img srcset="{{.}} 2x, https://golang.org/ 500.5w">`,
[]string{
`#ZgotmplZ`,
`#ZgotmplZ`,
`Hello,#ZgotmplZ`,
` dir=%22ltr%22`,
`#ZgotmplZ, World!%22%29;`,
`Hello,#ZgotmplZ`,
`greeting=H%69%2c&amp;addressee=%28World%29`,
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`%2cfoo/%2c`,
},
},
{
`<img srcset="http://godoc.org/ {{.}}, https://golang.org/ 500.5w">`,
[]string{
`#ZgotmplZ`,
`#ZgotmplZ`,
`Hello,#ZgotmplZ`,
` dir=%22ltr%22`,
`#ZgotmplZ, World!%22%29;`,
`Hello,#ZgotmplZ`,
`greeting=H%69%2c&amp;addressee=%28World%29`,
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`%2cfoo/%2c`,
},
},
{
`<img srcset="http://godoc.org/?q={{.}} 2x, https://golang.org/ 500.5w">`,
[]string{
`#ZgotmplZ`,
`#ZgotmplZ`,
`Hello,#ZgotmplZ`,
` dir=%22ltr%22`,
`#ZgotmplZ, World!%22%29;`,
`Hello,#ZgotmplZ`,
`greeting=H%69%2c&amp;addressee=%28World%29`,
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`%2cfoo/%2c`,
},
},
{
`<img srcset="http://godoc.org/ 2x, {{.}} 500.5w">`,
[]string{
`#ZgotmplZ`,
`#ZgotmplZ`,
`Hello,#ZgotmplZ`,
` dir=%22ltr%22`,
`#ZgotmplZ, World!%22%29;`,
`Hello,#ZgotmplZ`,
`greeting=H%69%2c&amp;addressee=%28World%29`,
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`%2cfoo/%2c`,
},
},
{
`<img srcset="http://godoc.org/ 2x, https://golang.org/ {{.}}">`,
[]string{
`#ZgotmplZ`,
`#ZgotmplZ`,
`Hello,#ZgotmplZ`,
` dir=%22ltr%22`,
`#ZgotmplZ, World!%22%29;`,
`Hello,#ZgotmplZ`,
`greeting=H%69%2c&amp;addressee=%28World%29`,
`greeting=H%69,&amp;addressee=(World) 2x, https://golang.org/favicon.ico 500.5w`,
`%2cfoo/%2c`,
},
},
}
......
......@@ -102,6 +102,8 @@ const (
stateAttr
// stateURL occurs inside an HTML attribute whose content is a URL.
stateURL
// stateSrcset occurs inside an HTML srcset attribute.
stateSrcset
// stateJS occurs inside an event handler or script element.
stateJS
// stateJSDqStr occurs inside a JavaScript double quoted string.
......@@ -145,6 +147,7 @@ var stateNames = [...]string{
stateRCDATA: "stateRCDATA",
stateAttr: "stateAttr",
stateURL: "stateURL",
stateSrcset: "stateSrcset",
stateJS: "stateJS",
stateJSDqStr: "stateJSDqStr",
stateJSSqStr: "stateJSSqStr",
......@@ -326,6 +329,8 @@ const (
attrStyle
// attrURL corresponds to an attribute whose value is a URL.
attrURL
// attrSrcset corresponds to a srcset attribute.
attrSrcset
)
var attrNames = [...]string{
......@@ -334,6 +339,7 @@ var attrNames = [...]string{
attrScriptType: "attrScriptType",
attrStyle: "attrStyle",
attrURL: "attrURL",
attrSrcset: "attrSrcset",
}
func (a attr) String() string {
......
......@@ -71,6 +71,7 @@ var funcMap = template.FuncMap{
"_html_template_jsvalescaper": jsValEscaper,
"_html_template_nospaceescaper": htmlNospaceEscaper,
"_html_template_rcdataescaper": rcdataEscaper,
"_html_template_srcsetescaper": srcsetFilterAndEscaper,
"_html_template_urlescaper": urlEscaper,
"_html_template_urlfilter": urlFilter,
"_html_template_urlnormalizer": urlNormalizer,
......@@ -215,6 +216,8 @@ func (e *escaper) escapeAction(c context, n *parse.ActionNode) context {
case stateAttrName, stateTag:
c.state = stateAttrName
s = append(s, "_html_template_htmlnamefilter")
case stateSrcset:
s = append(s, "_html_template_srcsetescaper")
default:
if isComment(c.state) {
s = append(s, "_html_template_commentescaper")
......
......@@ -650,6 +650,12 @@ func TestEscape(t *testing.T) {
`<{{"script"}}>{{"doEvil()"}}</{{"script"}}>`,
`&lt;script>doEvil()&lt;/script>`,
},
{
"srcset bad URL in second position",
`<img srcset="{{"/not-an-image#,javascript:alert(1)"}}">`,
// The second URL is also filtered.
`<img srcset="/not-an-image#,#ZgotmplZ">`,
},
}
for _, test := range tests {
......
......@@ -23,6 +23,7 @@ var transitionFunc = [...]func(context, []byte) (context, int){
stateRCDATA: tSpecialTagEnd,
stateAttr: tAttr,
stateURL: tURL,
stateSrcset: tURL,
stateJS: tJS,
stateJSDqStr: tJSDelimited,
stateJSSqStr: tJSDelimited,
......@@ -117,6 +118,8 @@ func tTag(c context, s []byte) (context, int) {
attr = attrStyle
case contentTypeJS:
attr = attrScript
case contentTypeSrcset:
attr = attrSrcset
}
}
......@@ -161,6 +164,7 @@ var attrStartStates = [...]state{
attrScriptType: stateAttr,
attrStyle: stateCSS,
attrURL: stateURL,
attrSrcset: stateSrcset,
}
// tBeforeValue is the context transition function for stateBeforeValue.
......
......@@ -37,13 +37,23 @@ func urlFilter(args ...interface{}) string {
if t == contentTypeURL {
return s
}
if !isSafeUrl(s) {
return "#" + filterFailsafe
}
return s
}
// isSafeUrl is true if s is a relative URL or if URL has a protocol in
// (http, https, mailto).
func isSafeUrl(s string) bool {
if i := strings.IndexRune(s, ':'); i >= 0 && !strings.ContainsRune(s[:i], '/') {
protocol := strings.ToLower(s[:i])
if protocol != "http" && protocol != "https" && protocol != "mailto" {
return "#" + filterFailsafe
protocol := s[:i]
if !strings.EqualFold(protocol, "http") && !strings.EqualFold(protocol, "https") && !strings.EqualFold(protocol, "mailto") {
return false
}
}
return s
return true
}
// urlEscaper produces an output that can be embedded in a URL query.
......@@ -69,6 +79,16 @@ func urlProcessor(norm bool, args ...interface{}) string {
norm = true
}
var b bytes.Buffer
if processUrlOnto(s, norm, &b) {
return b.String()
}
return s
}
// processUrlOnto appends a normalized URL corresponding to its input to b
// and returns true if the appended content differs from s.
func processUrlOnto(s string, norm bool, b *bytes.Buffer) bool {
b.Grow(b.Cap() + len(s) + 16)
written := 0
// The byte loop below assumes that all URLs use UTF-8 as the
// content-encoding. This is similar to the URI to IRI encoding scheme
......@@ -114,12 +134,86 @@ func urlProcessor(norm bool, args ...interface{}) string {
}
}
b.WriteString(s[written:i])
fmt.Fprintf(&b, "%%%02x", c)
fmt.Fprintf(b, "%%%02x", c)
written = i + 1
}
if written == 0 {
b.WriteString(s[written:])
return written != 0
}
// Filters and normalizes srcset values which are comma separated
// URLs followed by metadata.
func srcsetFilterAndEscaper(args ...interface{}) string {
s, t := stringify(args...)
switch t {
case contentTypeSrcset:
return s
case contentTypeURL:
// Normalizing gets rid of all HTML whitespace
// which separate the image URL from its metadata.
var b bytes.Buffer
if processUrlOnto(s, true, &b) {
s = b.String()
}
// Additionally, commas separate one source from another.
return strings.Replace(s, ",", "%2c", -1)
}
b.WriteString(s[written:])
var b bytes.Buffer
written := 0
for i := 0; i < len(s); i++ {
if s[i] == ',' {
filterSrcsetElement(s, written, i, &b)
b.WriteString(",")
written = i + 1
}
}
filterSrcsetElement(s, written, len(s), &b)
return b.String()
}
// Derived from https://play.golang.org/p/Dhmj7FORT5
const htmlSpaceAndAsciiAlnumBytes = "\x00\x36\x00\x00\x01\x00\xff\x03\xfe\xff\xff\x07\xfe\xff\xff\x07"
// isHtmlSpace is true iff c is a whitespace character per
// https://infra.spec.whatwg.org/#ascii-whitespace
func isHtmlSpace(c byte) bool {
return (c <= 0x20) && 0 != (htmlSpaceAndAsciiAlnumBytes[c>>3]&(1<<uint(c&0x7)))
}
func isHtmlSpaceOrAsciiAlnum(c byte) bool {
return (c < 0x80) && 0 != (htmlSpaceAndAsciiAlnumBytes[c>>3]&(1<<uint(c&0x7)))
}
func filterSrcsetElement(s string, left int, right int, b *bytes.Buffer) {
start := left
for start < right && isHtmlSpace(s[start]) {
start += 1
}
end := right
for i := start; i < right; i++ {
if isHtmlSpace(s[i]) {
end = i
break
}
}
if url := s[start:end]; isSafeUrl(url) {
// If image metadata is only spaces or alnums then
// we don't need to URL normalize it.
metadataOk := true
for i := end; i < right; i++ {
if !isHtmlSpaceOrAsciiAlnum(s[i]) {
metadataOk = false
break
}
}
if metadataOk {
b.WriteString(s[left:start])
processUrlOnto(url, true, b)
b.WriteString(s[end:right])
return
}
}
b.WriteString("#")
b.WriteString(filterFailsafe)
}
......@@ -87,6 +87,51 @@ func TestURLFilters(t *testing.T) {
}
}
func TestSrcsetFilter(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
"one ok",
"http://example.com/img.png",
"http://example.com/img.png",
},
{
"one ok with metadata",
" /img.png 200w",
" /img.png 200w",
},
{
"one bad",
"javascript:alert(1) 200w",
"#ZgotmplZ",
},
{
"two ok",
"foo.png, bar.png",
"foo.png, bar.png",
},
{
"left bad",
"javascript:alert(1), /foo.png",
"#ZgotmplZ, /foo.png",
},
{
"right bad",
"/bogus#, javascript:alert(1)",
"/bogus#,#ZgotmplZ",
},
}
for _, test := range tests {
if got := srcsetFilterAndEscaper(test.input); got != test.want {
t.Errorf("%s: srcsetFilterAndEscaper(%q) want %q != %q", test.name, test.input, test.want, got)
}
}
}
func BenchmarkURLEscaper(b *testing.B) {
for i := 0; i < b.N; i++ {
urlEscaper("http://example.com:80/foo?q=bar%20&baz=x+y#frag")
......@@ -110,3 +155,15 @@ func BenchmarkURLNormalizerNoSpecials(b *testing.B) {
urlNormalizer("http://example.com:80/foo?q=bar%20&baz=x+y#frag")
}
}
func BenchmarkSrcsetFilter(b *testing.B) {
for i := 0; i < b.N; i++ {
srcsetFilterAndEscaper(" /foo/bar.png 200w, /baz/boo(1).png")
}
}
func BenchmarkSrcsetFilterNoSpecials(b *testing.B) {
for i := 0; i < b.N; i++ {
srcsetFilterAndEscaper("http://example.com:80/foo?q=bar%20&baz=x+y#frag")
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment