| // Copyright 2011 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| package html |
| |
| import ( |
| "bytes" |
| "fmt" |
| "os" |
| "strings" |
| "template" |
| "template/parse" |
| "testing" |
| ) |
| |
| func TestEscape(t *testing.T) { |
| var data = struct { |
| F, T bool |
| C, G, H string |
| A, E []string |
| N int |
| Z *int |
| W HTML |
| }{ |
| F: false, |
| T: true, |
| C: "<Cincinatti>", |
| G: "<Goodbye>", |
| H: "<Hello>", |
| A: []string{"<a>", "<b>"}, |
| E: []string{}, |
| N: 42, |
| Z: nil, |
| W: HTML(`¡<b class="foo">Hello</b>, <textarea>O'World</textarea>!`), |
| } |
| |
| tests := []struct { |
| name string |
| input string |
| output string |
| }{ |
| { |
| "if", |
| "{{if .T}}Hello{{end}}, {{.C}}!", |
| "Hello, <Cincinatti>!", |
| }, |
| { |
| "else", |
| "{{if .F}}{{.H}}{{else}}{{.G}}{{end}}!", |
| "<Goodbye>!", |
| }, |
| { |
| "overescaping", |
| "Hello, {{.C | html}}!", |
| "Hello, <Cincinatti>!", |
| }, |
| { |
| "assignment", |
| "{{if $x := .H}}{{$x}}{{end}}", |
| "<Hello>", |
| }, |
| { |
| "withBody", |
| "{{with .H}}{{.}}{{end}}", |
| "<Hello>", |
| }, |
| { |
| "withElse", |
| "{{with .E}}{{.}}{{else}}{{.H}}{{end}}", |
| "<Hello>", |
| }, |
| { |
| "rangeBody", |
| "{{range .A}}{{.}}{{end}}", |
| "<a><b>", |
| }, |
| { |
| "rangeElse", |
| "{{range .E}}{{.}}{{else}}{{.H}}{{end}}", |
| "<Hello>", |
| }, |
| { |
| "nonStringValue", |
| "{{.T}}", |
| "true", |
| }, |
| { |
| "constant", |
| `<a href="/search?q={{"'a<b'"}}">`, |
| `<a href="/search?q=%27a%3cb%27">`, |
| }, |
| { |
| "multipleAttrs", |
| "<a b=1 c={{.H}}>", |
| "<a b=1 c=<Hello>>", |
| }, |
| { |
| "urlStartRel", |
| `<a href='{{"/foo/bar?a=b&c=d"}}'>`, |
| `<a href='/foo/bar?a=b&c=d'>`, |
| }, |
| { |
| "urlStartAbsOk", |
| `<a href='{{"http://example.com/foo/bar?a=b&c=d"}}'>`, |
| `<a href='http://example.com/foo/bar?a=b&c=d'>`, |
| }, |
| { |
| "protocolRelativeURLStart", |
| `<a href='{{"//example.com:8000/foo/bar?a=b&c=d"}}'>`, |
| `<a href='//example.com:8000/foo/bar?a=b&c=d'>`, |
| }, |
| { |
| "pathRelativeURLStart", |
| `<a href="{{"/javascript:80/foo/bar"}}">`, |
| `<a href="/javascript:80/foo/bar">`, |
| }, |
| { |
| "dangerousURLStart", |
| `<a href='{{"javascript:alert(%22pwned%22)"}}'>`, |
| `<a href='#ZgotmplZ'>`, |
| }, |
| { |
| "dangerousURLStart2", |
| `<a href=' {{"javascript:alert(%22pwned%22)"}}'>`, |
| `<a href=' #ZgotmplZ'>`, |
| }, |
| { |
| "nonHierURL", |
| `<a href={{"mailto:Muhammed \"The Greatest\" Ali <m.ali@example.com>"}}>`, |
| `<a href=mailto:Muhammed "The Greatest" Ali <m.ali@example.com>>`, |
| }, |
| { |
| "urlPath", |
| `<a href='http://{{"javascript:80"}}/foo'>`, |
| `<a href='http://javascript:80/foo'>`, |
| }, |
| { |
| "urlQuery", |
| `<a href='/search?q={{.H}}'>`, |
| `<a href='/search?q=%3cHello%3e'>`, |
| }, |
| { |
| "urlFragment", |
| `<a href='/faq#{{.H}}'>`, |
| `<a href='/faq#%3cHello%3e'>`, |
| }, |
| { |
| "urlBranch", |
| `<a href="{{if .F}}/foo?a=b{{else}}/bar{{end}}">`, |
| `<a href="/bar">`, |
| }, |
| { |
| "urlBranchConflictMoot", |
| `<a href="{{if .T}}/foo?a={{else}}/bar#{{end}}{{.C}}">`, |
| `<a href="/foo?a=%3cCincinatti%3e">`, |
| }, |
| { |
| "jsStrValue", |
| "<button onclick='alert({{.H}})'>", |
| `<button onclick='alert("\u003cHello\u003e")'>`, |
| }, |
| { |
| "jsNumericValue", |
| "<button onclick='alert({{.N}})'>", |
| `<button onclick='alert( 42 )'>`, |
| }, |
| { |
| "jsBoolValue", |
| "<button onclick='alert({{.T}})'>", |
| `<button onclick='alert( true )'>`, |
| }, |
| { |
| "jsNilValue", |
| "<button onclick='alert(typeof{{.Z}})'>", |
| `<button onclick='alert(typeof null )'>`, |
| }, |
| { |
| "jsObjValue", |
| "<button onclick='alert({{.A}})'>", |
| `<button onclick='alert(["\u003ca\u003e","\u003cb\u003e"])'>`, |
| }, |
| { |
| "jsObjValueScript", |
| "<script>alert({{.A}})</script>", |
| `<script>alert(["\u003ca\u003e","\u003cb\u003e"])</script>`, |
| }, |
| { |
| "jsObjValueNotOverEscaped", |
| "<button onclick='alert({{.A | html}})'>", |
| `<button onclick='alert(["\u003ca\u003e","\u003cb\u003e"])'>`, |
| }, |
| { |
| "jsStr", |
| "<button onclick='alert("{{.H}}")'>", |
| `<button onclick='alert("\x3cHello\x3e")'>`, |
| }, |
| { |
| "jsStrNotUnderEscaped", |
| "<button onclick='alert({{.C | urlquery}})'>", |
| // URL escaped, then quoted for JS. |
| `<button onclick='alert("%3CCincinatti%3E")'>`, |
| }, |
| { |
| "jsRe", |
| `<button onclick='alert(/{{"foo+bar"}}/.test(""))'>`, |
| `<button onclick='alert(/foo\x2bbar/.test(""))'>`, |
| }, |
| { |
| "jsReBlank", |
| `<script>alert(/{{""}}/.test(""));</script>`, |
| `<script>alert(/(?:)/.test(""));</script>`, |
| }, |
| { |
| "jsReAmbigOk", |
| `<script>{{if true}}var x = 1{{end}}</script>`, |
| // The {if} ends in an ambiguous jsCtx but there is |
| // no slash following so we shouldn't care. |
| `<script>var x = 1</script>`, |
| }, |
| { |
| "styleBidiKeywordPassed", |
| `<p style="dir: {{"ltr"}}">`, |
| `<p style="dir: ltr">`, |
| }, |
| { |
| "styleBidiPropNamePassed", |
| `<p style="border-{{"left"}}: 0; border-{{"right"}}: 1in">`, |
| `<p style="border-left: 0; border-right: 1in">`, |
| }, |
| { |
| "styleExpressionBlocked", |
| `<p style="width: {{"expression(alert(1337))"}}">`, |
| `<p style="width: ZgotmplZ">`, |
| }, |
| { |
| "styleTagSelectorPassed", |
| `<style>{{"p"}} { color: pink }</style>`, |
| `<style>p { color: pink }</style>`, |
| }, |
| { |
| "styleIDPassed", |
| `<style>p{{"#my-ID"}} { font: Arial }</style>`, |
| `<style>p#my-ID { font: Arial }</style>`, |
| }, |
| { |
| "styleClassPassed", |
| `<style>p{{".my_class"}} { font: Arial }</style>`, |
| `<style>p.my_class { font: Arial }</style>`, |
| }, |
| { |
| "styleQuantityPassed", |
| `<a style="left: {{"2em"}}; top: {{0}}">`, |
| `<a style="left: 2em; top: 0">`, |
| }, |
| { |
| "stylePctPassed", |
| `<table style=width:{{"100%"}}>`, |
| `<table style=width:100%>`, |
| }, |
| { |
| "styleColorPassed", |
| `<p style="color: {{"#8ff"}}; background: {{"#000"}}">`, |
| `<p style="color: #8ff; background: #000">`, |
| }, |
| { |
| "styleObfuscatedExpressionBlocked", |
| `<p style="width: {{" e\78preS\0Sio/**/n(alert(1337))"}}">`, |
| `<p style="width: ZgotmplZ">`, |
| }, |
| { |
| "styleMozBindingBlocked", |
| `<p style="{{"-moz-binding(alert(1337))"}}: ...">`, |
| `<p style="ZgotmplZ: ...">`, |
| }, |
| { |
| "styleObfuscatedMozBindingBlocked", |
| `<p style="{{" -mo\7a-B\0I/**/nding(alert(1337))"}}: ...">`, |
| `<p style="ZgotmplZ: ...">`, |
| }, |
| { |
| "styleFontNameString", |
| `<p style='font-family: "{{"Times New Roman"}}"'>`, |
| `<p style='font-family: "Times New Roman"'>`, |
| }, |
| { |
| "styleFontNameString", |
| `<p style='font-family: "{{"Times New Roman"}}", "{{"sans-serif"}}"'>`, |
| `<p style='font-family: "Times New Roman", "sans-serif"'>`, |
| }, |
| { |
| "styleFontNameUnquoted", |
| `<p style='font-family: {{"Times New Roman"}}'>`, |
| `<p style='font-family: Times New Roman'>`, |
| }, |
| { |
| "styleURLQueryEncoded", |
| `<p style="background: url(/img?name={{"O'Reilly Animal(1)<2>.png"}})">`, |
| `<p style="background: url(/img?name=O%27Reilly%20Animal%281%29%3c2%3e.png)">`, |
| }, |
| { |
| "styleQuotedURLQueryEncoded", |
| `<p style="background: url('/img?name={{"O'Reilly Animal(1)<2>.png"}}')">`, |
| `<p style="background: url('/img?name=O%27Reilly%20Animal%281%29%3c2%3e.png')">`, |
| }, |
| { |
| "styleStrQueryEncoded", |
| `<p style="background: '/img?name={{"O'Reilly Animal(1)<2>.png"}}'">`, |
| `<p style="background: '/img?name=O%27Reilly%20Animal%281%29%3c2%3e.png'">`, |
| }, |
| { |
| "styleURLBadProtocolBlocked", |
| `<a style="background: url('{{"javascript:alert(1337)"}}')">`, |
| `<a style="background: url('#ZgotmplZ')">`, |
| }, |
| { |
| "styleStrBadProtocolBlocked", |
| `<a style="background: '{{"javascript:alert(1337)"}}'">`, |
| `<a style="background: '#ZgotmplZ'">`, |
| }, |
| { |
| "styleURLGoodProtocolPassed", |
| `<a style="background: url('{{"http://oreilly.com/O'Reilly Animals(1)<2>;{}.html"}}')">`, |
| `<a style="background: url('http://oreilly.com/O%27Reilly%20Animals%281%29%3c2%3e;%7b%7d.html')">`, |
| }, |
| { |
| "styleStrGoodProtocolPassed", |
| `<a style="background: '{{"http://oreilly.com/O'Reilly Animals(1)<2>;{}.html"}}'">`, |
| `<a style="background: 'http\3a\2f\2foreilly.com\2fO\27Reilly Animals\28 1\29\3c 2\3e\3b\7b\7d.html'">`, |
| }, |
| { |
| "styleURLEncodedForHTMLInAttr", |
| `<a style="background: url('{{"/search?img=foo&size=icon"}}')">`, |
| `<a style="background: url('/search?img=foo&size=icon')">`, |
| }, |
| { |
| "styleURLNotEncodedForHTMLInCdata", |
| `<style>body { background: url('{{"/search?img=foo&size=icon"}}') }</style>`, |
| `<style>body { background: url('/search?img=foo&size=icon') }</style>`, |
| }, |
| { |
| "styleURLMixedCase", |
| `<p style="background: URL(#{{.H}})">`, |
| `<p style="background: URL(#%3cHello%3e)">`, |
| }, |
| { |
| "stylePropertyPairPassed", |
| `<a style='{{"color: red"}}'>`, |
| `<a style='color: red'>`, |
| }, |
| { |
| "styleStrSpecialsEncoded", |
| `<a style="font-family: '{{"/**/'\";:// \\"}}', "{{"/**/'\";:// \\"}}"">`, |
| `<a style="font-family: '\2f**\2f\27\22\3b\3a\2f\2f \\', "\2f**\2f\27\22\3b\3a\2f\2f \\"">`, |
| }, |
| { |
| "styleURLSpecialsEncoded", |
| // TODO: Find out what IE does with url(/*foo*/bar) |
| // FF, Chrome, and Safari seem to treat it as a URL. |
| `<a style="border-image: url({{"/**/'\";:// \\"}}), url("{{"/**/'\";:// \\"}}"), url('{{"/**/'\";:// \\"}}'), 'http://www.example.com/?q={{"/**/'\";:// \\"}}''">`, |
| `<a style="border-image: url(/**/%27%22;://%20%5c), url("/**/%27%22;://%20%5c"), url('/**/%27%22;://%20%5c'), 'http://www.example.com/?q=%2f%2a%2a%2f%27%22%3b%3a%2f%2f%20%5c''">`, |
| }, |
| { |
| "comment", |
| "<b>Hello, <!-- name of world -->{{.C}}</b>", |
| // TODO: Elide comment. |
| "<b>Hello, <!-- name of world --><Cincinatti></b>", |
| }, |
| { |
| "typed HTML in text", |
| `{{.W}}`, |
| `¡<b class="foo">Hello</b>, <textarea>O'World</textarea>!`, |
| }, |
| { |
| "typed HTML in attribute", |
| `<div title="{{.W}}">`, |
| `<div title="¡Hello, O'World!">`, |
| }, |
| { |
| "typed HTML in script", |
| `<button onclick="alert({{.W}})">`, |
| `<button onclick="alert("&iexcl;\u003cb class=\"foo\"\u003eHello\u003c/b\u003e, \u003ctextarea\u003eO'World\u003c/textarea\u003e!")">`, |
| }, |
| { |
| "typed HTML in RCDATA", |
| `<textarea>{{.W}}</textarea>`, |
| `<textarea>¡<b class="foo">Hello</b>, <textarea>O'World</textarea>!</textarea>`, |
| }, |
| { |
| "range in textarea", |
| "<textarea>{{range .A}}{{.}}{{end}}</textarea>", |
| "<textarea><a><b></textarea>", |
| }, |
| { |
| "auditable exemption from escaping", |
| "{{range .A}}{{. | noescape}}{{end}}", |
| "<a><b>", |
| }, |
| { |
| "No tag injection", |
| `{{"10$"}}<{{"script src,evil.org/pwnd.js"}}...`, |
| `10$<script src,evil.org/pwnd.js...`, |
| }, |
| { |
| "No comment injection", |
| `<{{"!--"}}`, |
| `<!--`, |
| }, |
| { |
| "No RCDATA end tag injection", |
| `<textarea><{{"/textarea "}}...</textarea>`, |
| `<textarea></textarea ...</textarea>`, |
| }, |
| { |
| "optional attrs", |
| `<img class="{{"iconClass"}}"` + |
| `{{if .T}} id="{{"<iconId>"}}"{{end}}` + |
| // Double quotes inside if/else. |
| ` src=` + |
| `{{if .T}}"?{{"<iconPath>"}}"` + |
| `{{else}}"images/cleardot.gif"{{end}}` + |
| // Missing space before title, but it is not a |
| // part of the src attribute. |
| `{{if .T}}title="{{"<title>"}}"{{end}}` + |
| // Quotes outside if/else. |
| ` alt="` + |
| `{{if .T}}{{"<alt>"}}` + |
| `{{else}}{{if .F}}{{"<title>"}}{{end}}` + |
| `{{end}}"` + |
| `>`, |
| `<img class="iconClass" id="<iconId>" src="?%3ciconPath%3e"title="<title>" alt="<alt>">`, |
| }, |
| { |
| "conditional valueless attr name", |
| `<input{{if .T}} checked{{end}} name=n>`, |
| `<input checked name=n>`, |
| }, |
| { |
| "conditional dynamic valueless attr name 1", |
| `<input{{if .T}} {{"checked"}}{{end}} name=n>`, |
| `<input checked name=n>`, |
| }, |
| { |
| "conditional dynamic valueless attr name 2", |
| `<input {{if .T}}{{"checked"}} {{end}}name=n>`, |
| `<input checked name=n>`, |
| }, |
| { |
| "dynamic attribute name", |
| `<img on{{"load"}}="alert({{"loaded"}})">`, |
| // Treated as JS since quotes are inserted. |
| `<img onload="alert("loaded")">`, |
| }, |
| { |
| "dynamic element name", |
| `<h{{3}}><table><t{{"head"}}>...</h{{3}}>`, |
| `<h3><table><thead>...</h3>`, |
| }, |
| } |
| |
| for _, test := range tests { |
| tmpl := template.New(test.name) |
| // TODO: Move noescape into template/func.go |
| tmpl.Funcs(template.FuncMap{ |
| "noescape": func(a ...interface{}) string { |
| return fmt.Sprint(a...) |
| }, |
| }) |
| tmpl = template.Must(Escape(template.Must(tmpl.Parse(test.input)))) |
| b := new(bytes.Buffer) |
| 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 |
| } |
| } |
| } |
| |
| func TestEscapeSet(t *testing.T) { |
| type dataItem struct { |
| Children []*dataItem |
| X string |
| } |
| |
| data := dataItem{ |
| Children: []*dataItem{ |
| &dataItem{X: "foo"}, |
| &dataItem{X: "<bar>"}, |
| &dataItem{ |
| Children: []*dataItem{ |
| &dataItem{X: "baz"}, |
| }, |
| }, |
| }, |
| } |
| |
| tests := []struct { |
| inputs map[string]string |
| want string |
| }{ |
| // The trivial set. |
| { |
| map[string]string{ |
| "main": ``, |
| }, |
| ``, |
| }, |
| // A template called in the start context. |
| { |
| map[string]string{ |
| "main": `Hello, {{template "helper"}}!`, |
| // Not a valid top level HTML template. |
| // "<b" is not a full tag. |
| "helper": `{{"<World>"}}`, |
| }, |
| `Hello, <World>!`, |
| }, |
| // A template called in a context other than the start. |
| { |
| map[string]string{ |
| "main": `<a onclick='a = {{template "helper"}};'>`, |
| // Not a valid top level HTML template. |
| // "<b" is not a full tag. |
| "helper": `{{"<a>"}}<b`, |
| }, |
| `<a onclick='a = "\u003ca\u003e"<b;'>`, |
| }, |
| // A recursive template that ends in its start context. |
| { |
| map[string]string{ |
| "main": `{{range .Children}}{{template "main" .}}{{else}}{{.X}} {{end}}`, |
| }, |
| `foo <bar> baz `, |
| }, |
| // A recursive helper template that ends in its start context. |
| { |
| map[string]string{ |
| "main": `{{template "helper" .}}`, |
| "helper": `{{if .Children}}<ul>{{range .Children}}<li>{{template "main" .}}</li>{{end}}</ul>{{else}}{{.X}}{{end}}`, |
| }, |
| `<ul><li>foo</li><li><bar></li><li><ul><li>baz</li></ul></li></ul>`, |
| }, |
| // Co-recursive templates that end in its start context. |
| { |
| map[string]string{ |
| "main": `<blockquote>{{range .Children}}{{template "helper" .}}{{end}}</blockquote>`, |
| "helper": `{{if .Children}}{{template "main" .}}{{else}}{{.X}}<br>{{end}}`, |
| }, |
| `<blockquote>foo<br><bar><br><blockquote>baz<br></blockquote></blockquote>`, |
| }, |
| // A template that is called in two different contexts. |
| { |
| map[string]string{ |
| "main": `<button onclick="title='{{template "helper"}}'; ...">{{template "helper"}}</button>`, |
| "helper": `{{11}} of {{"<100>"}}`, |
| }, |
| `<button onclick="title='11 of \x3c100\x3e'; ...">11 of <100></button>`, |
| }, |
| // A non-recursive template that ends in a different context. |
| // helper starts in jsCtxRegexp and ends in jsCtxDivOp. |
| { |
| map[string]string{ |
| "main": `<script>var x={{template "helper"}}/{{"42"}};</script>`, |
| "helper": "{{126}}", |
| }, |
| `<script>var x= 126 /"42";</script>`, |
| }, |
| // A recursive template that ends in a similar context. |
| { |
| map[string]string{ |
| "main": `<script>var x=[{{template "countdown" 4}}];</script>`, |
| "countdown": `{{.}}{{if .}},{{template "countdown" . | pred}}{{end}}`, |
| }, |
| `<script>var x=[ 4 , 3 , 2 , 1 , 0 ];</script>`, |
| }, |
| // A recursive template that ends in a different context. |
| /* |
| { |
| map[string]string{ |
| "main": `<a href="/foo{{template "helper" .}}">`, |
| "helper": `{{if .Children}}{{range .Children}}{{template "helper" .}}{{end}}{{else}}?x={{.X}}{{end}}`, |
| }, |
| `<a href="/foo?x=foo?x=%3cbar%3e?x=baz">`, |
| }, |
| */ |
| } |
| |
| // pred is a template function that returns the predecessor of a |
| // natural number for testing recursive templates. |
| fns := template.FuncMap{"pred": func(a ...interface{}) (interface{}, os.Error) { |
| if len(a) == 1 { |
| if i, _ := a[0].(int); i > 0 { |
| return i - 1, nil |
| } |
| } |
| return nil, fmt.Errorf("undefined pred(%v)", a) |
| }} |
| |
| for _, test := range tests { |
| var s template.Set |
| for name, src := range test.inputs { |
| t := template.New(name) |
| t.Funcs(fns) |
| s.Add(template.Must(t.Parse(src))) |
| } |
| s.Funcs(fns) |
| if _, err := EscapeSet(&s, "main"); err != nil { |
| t.Errorf("%s for input:\n%v", err, test.inputs) |
| continue |
| } |
| var b bytes.Buffer |
| |
| if err := s.Execute(&b, "main", data); err != nil { |
| t.Errorf("%q executing %v", err.String(), s.Template("main")) |
| continue |
| } |
| if got := b.String(); test.want != got { |
| t.Errorf("want\n\t%q\ngot\n\t%q", test.want, got) |
| } |
| } |
| |
| } |
| |
| func TestErrors(t *testing.T) { |
| tests := []struct { |
| input string |
| err string |
| }{ |
| // Non-error cases. |
| { |
| "{{if .Cond}}<a>{{else}}<b>{{end}}", |
| "", |
| }, |
| { |
| "{{if .Cond}}<a>{{end}}", |
| "", |
| }, |
| { |
| "{{if .Cond}}{{else}}<b>{{end}}", |
| "", |
| }, |
| { |
| "{{with .Cond}}<div>{{end}}", |
| "", |
| }, |
| { |
| "{{range .Items}}<a>{{end}}", |
| "", |
| }, |
| { |
| "<a href='/foo?{{range .Items}}&{{.K}}={{.V}}{{end}}'>", |
| "", |
| }, |
| // Error cases. |
| { |
| "{{if .Cond}}<a{{end}}", |
| "z:1: {{if}} branches", |
| }, |
| { |
| "{{if .Cond}}\n{{else}}\n<a{{end}}", |
| "z:1: {{if}} branches", |
| }, |
| { |
| // Missing quote in the else branch. |
| `{{if .Cond}}<a href="foo">{{else}}<a href="bar>{{end}}`, |
| "z:1: {{if}} branches", |
| }, |
| { |
| // Different kind of attribute: href implies a URL. |
| "<a {{if .Cond}}href='{{else}}title='{{end}}{{.X}}'>", |
| "z:1: {{if}} branches", |
| }, |
| { |
| "\n{{with .X}}<a{{end}}", |
| "z:2: {{with}} branches", |
| }, |
| { |
| "\n{{with .X}}<a>{{else}}<a{{end}}", |
| "z:2: {{with}} branches", |
| }, |
| { |
| "{{range .Items}}<a{{end}}", |
| `z:1: on range loop re-entry: "<" in attribute name: "<a"`, |
| }, |
| { |
| "\n{{range .Items}} x='<a{{end}}", |
| "z:2: on range loop re-entry: {{range}} branches", |
| }, |
| { |
| "<a b=1 c={{.H}}", |
| "z: ends in a non-text context: {stateAttr delimSpaceOrTagEnd", |
| }, |
| { |
| "<script>foo();", |
| "z: ends in a non-text context: {stateJS", |
| }, |
| { |
| `<a href="{{if .F}}/foo?a={{else}}/bar/{{end}}{{.H}}">`, |
| "z:1: (action: [(command: [F=[H]])]) appears in an ambiguous URL context", |
| }, |
| { |
| `<a onclick="alert('Hello \`, |
| `unfinished escape sequence in JS string: "Hello \\"`, |
| }, |
| { |
| `<a onclick='alert("Hello\, World\`, |
| `unfinished escape sequence in JS string: "Hello\\, World\\"`, |
| }, |
| { |
| `<a onclick='alert(/x+\`, |
| `unfinished escape sequence in JS regexp: "x+\\"`, |
| }, |
| { |
| `<a onclick="/foo[\]/`, |
| `unfinished JS regexp charset: "foo[\\]/"`, |
| }, |
| { |
| `<a onclick="/* alert({{.X}}) */">`, |
| `z:1: (action: [(command: [F=[X]])]) appears inside a comment`, |
| }, |
| { |
| `<a onclick="// alert({{.X}})">`, |
| `z:1: (action: [(command: [F=[X]])]) appears inside a comment`, |
| }, |
| { |
| `<a style="/* color: {{.X}} */">`, |
| `z:1: (action: [(command: [F=[X]])]) appears inside a comment`, |
| }, |
| { |
| `<a style="// color: {{.X}}">`, |
| `z:1: (action: [(command: [F=[X]])]) appears inside a comment`, |
| }, |
| { |
| "<!-- {{.H}} -->", |
| "z:1: (action: [(command: [F=[H]])]) appears inside a comment", |
| }, |
| { |
| // It is ambiguous whether 1.5 should be 1\.5 or 1.5. |
| // Either `var x = 1/- 1.5 /i.test(x)` |
| // where `i.test(x)` is a method call of reference i, |
| // or `/-1\.5/i.test(x)` which is a method call on a |
| // case insensitive regular expression. |
| `<script>{{if false}}var x = 1{{end}}/-{{"1.5"}}/i.test(x)</script>`, |
| `'/' could start div or regexp: "/-"`, |
| }, |
| { |
| `{{template "foo"}}`, |
| "z:1: no such template foo", |
| }, |
| { |
| `{{define "z"}}<div{{template "y"}}>{{end}}` + |
| // Illegal starting in stateTag but not in stateText. |
| `{{define "y"}} foo<b{{end}}`, |
| `"<" in attribute name: " foo<b"`, |
| }, |
| { |
| `{{define "z"}}<script>reverseList = [{{template "t"}}]</script>{{end}}` + |
| // Missing " after recursive call. |
| `{{define "t"}}{{if .Tail}}{{template "t" .Tail}}{{end}}{{.Head}}",{{end}}`, |
| `: cannot compute output context for template t$htmltemplate_stateJS_elementScript`, |
| }, |
| } |
| |
| for _, test := range tests { |
| var err os.Error |
| if strings.HasPrefix(test.input, "{{define") { |
| var s template.Set |
| _, err = s.Parse(test.input) |
| if err != nil { |
| t.Errorf("Failed to parse %q: %s", test.input, err) |
| continue |
| } |
| _, err = EscapeSet(&s, "z") |
| } else { |
| tmpl := template.Must(template.New("z").Parse(test.input)) |
| _, err = Escape(tmpl) |
| } |
| var got string |
| if err != nil { |
| got = err.String() |
| } |
| if test.err == "" { |
| if got != "" { |
| t.Errorf("input=%q: unexpected error %q", test.input, got) |
| } |
| continue |
| } |
| if strings.Index(got, test.err) == -1 { |
| t.Errorf("input=%q: error\n\t%q\ndoes not contain expected string\n\t%q", test.input, got, test.err) |
| continue |
| } |
| } |
| } |
| |
| func TestEscapeText(t *testing.T) { |
| tests := []struct { |
| input string |
| output context |
| }{ |
| { |
| ``, |
| context{}, |
| }, |
| { |
| `Hello, World!`, |
| context{}, |
| }, |
| { |
| // An orphaned "<" is OK. |
| `I <3 Ponies!`, |
| context{}, |
| }, |
| { |
| `<a`, |
| context{state: stateTag}, |
| }, |
| { |
| `<a `, |
| context{state: stateTag}, |
| }, |
| { |
| `<a>`, |
| context{state: stateText}, |
| }, |
| { |
| `<a href`, |
| context{state: stateAttrName, attr: attrURL}, |
| }, |
| { |
| `<a on`, |
| context{state: stateAttrName, attr: attrScript}, |
| }, |
| { |
| `<a href `, |
| context{state: stateAfterName, attr: attrURL}, |
| }, |
| { |
| `<a style = `, |
| context{state: stateBeforeValue, attr: attrStyle}, |
| }, |
| { |
| `<a href=`, |
| context{state: stateBeforeValue, attr: attrURL}, |
| }, |
| { |
| `<a href=x`, |
| context{state: stateURL, delim: delimSpaceOrTagEnd, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a href=x `, |
| context{state: stateTag}, |
| }, |
| { |
| `<a href=>`, |
| context{state: stateText}, |
| }, |
| { |
| `<a href=x>`, |
| context{state: stateText}, |
| }, |
| { |
| `<a href ='`, |
| context{state: stateURL, delim: delimSingleQuote}, |
| }, |
| { |
| `<a href=''`, |
| context{state: stateTag}, |
| }, |
| { |
| `<a href= "`, |
| context{state: stateURL, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a href=""`, |
| context{state: stateTag}, |
| }, |
| { |
| `<a title="`, |
| context{state: stateAttr, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a HREF='http:`, |
| context{state: stateURL, delim: delimSingleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a Href='/`, |
| context{state: stateURL, delim: delimSingleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a href='"`, |
| context{state: stateURL, delim: delimSingleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a href="'`, |
| context{state: stateURL, delim: delimDoubleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a href=''`, |
| context{state: stateURL, delim: delimSingleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a href=""`, |
| context{state: stateURL, delim: delimDoubleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a href=""`, |
| context{state: stateURL, delim: delimDoubleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a href="`, |
| context{state: stateURL, delim: delimSpaceOrTagEnd, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<img alt="1">`, |
| context{state: stateText}, |
| }, |
| { |
| `<img alt="1>"`, |
| context{state: stateTag}, |
| }, |
| { |
| `<img alt="1>">`, |
| context{state: stateText}, |
| }, |
| { |
| `<input checked type="checkbox"`, |
| context{state: stateTag}, |
| }, |
| { |
| `<a onclick="`, |
| context{state: stateJS, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="//foo`, |
| context{state: stateJSLineCmt, delim: delimDoubleQuote}, |
| }, |
| { |
| "<a onclick='//\n", |
| context{state: stateJS, delim: delimSingleQuote}, |
| }, |
| { |
| "<a onclick='//\r\n", |
| context{state: stateJS, delim: delimSingleQuote}, |
| }, |
| { |
| "<a onclick='//\u2028", |
| context{state: stateJS, delim: delimSingleQuote}, |
| }, |
| { |
| `<a onclick="/*`, |
| context{state: stateJSBlockCmt, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="/*/`, |
| context{state: stateJSBlockCmt, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="/**/`, |
| context{state: stateJS, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onkeypress=""`, |
| context{state: stateJSDqStr, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick='"foo"`, |
| context{state: stateJS, delim: delimSingleQuote, jsCtx: jsCtxDivOp}, |
| }, |
| { |
| `<a onclick='foo'`, |
| context{state: stateJS, delim: delimSpaceOrTagEnd, jsCtx: jsCtxDivOp}, |
| }, |
| { |
| `<a onclick='foo`, |
| context{state: stateJSSqStr, delim: delimSpaceOrTagEnd}, |
| }, |
| { |
| `<a onclick=""foo'`, |
| context{state: stateJSDqStr, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="'foo"`, |
| context{state: stateJSSqStr, delim: delimDoubleQuote}, |
| }, |
| { |
| `<A ONCLICK="'`, |
| context{state: stateJSSqStr, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="/`, |
| context{state: stateJSRegexp, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="'foo'`, |
| context{state: stateJS, delim: delimDoubleQuote, jsCtx: jsCtxDivOp}, |
| }, |
| { |
| `<a onclick="'foo\'`, |
| context{state: stateJSSqStr, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="'foo\'`, |
| context{state: stateJSSqStr, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="/foo/`, |
| context{state: stateJS, delim: delimDoubleQuote, jsCtx: jsCtxDivOp}, |
| }, |
| { |
| `<script>/foo/ /=`, |
| context{state: stateJS, element: elementScript}, |
| }, |
| { |
| `<a onclick="1 /foo`, |
| context{state: stateJS, delim: delimDoubleQuote, jsCtx: jsCtxDivOp}, |
| }, |
| { |
| `<a onclick="1 /*c*/ /foo`, |
| context{state: stateJS, delim: delimDoubleQuote, jsCtx: jsCtxDivOp}, |
| }, |
| { |
| `<a onclick="/foo[/]`, |
| context{state: stateJSRegexp, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="/foo\/`, |
| context{state: stateJSRegexp, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a onclick="/foo/`, |
| context{state: stateJS, delim: delimDoubleQuote, jsCtx: jsCtxDivOp}, |
| }, |
| { |
| `<input checked style="`, |
| context{state: stateCSS, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="//`, |
| context{state: stateCSSLineCmt, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="//</script>`, |
| context{state: stateCSSLineCmt, delim: delimDoubleQuote}, |
| }, |
| { |
| "<a style='//\n", |
| context{state: stateCSS, delim: delimSingleQuote}, |
| }, |
| { |
| "<a style='//\r", |
| context{state: stateCSS, delim: delimSingleQuote}, |
| }, |
| { |
| `<a style="/*`, |
| context{state: stateCSSBlockCmt, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="/*/`, |
| context{state: stateCSSBlockCmt, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="/**/`, |
| context{state: stateCSS, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="background: '`, |
| context{state: stateCSSSqStr, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="background: "`, |
| context{state: stateCSSDqStr, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="background: '/foo?img=`, |
| context{state: stateCSSSqStr, delim: delimDoubleQuote, urlPart: urlPartQueryOrFrag}, |
| }, |
| { |
| `<a style="background: '/`, |
| context{state: stateCSSSqStr, delim: delimDoubleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a style="background: url("/`, |
| context{state: stateCSSDqURL, delim: delimDoubleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a style="background: url('/`, |
| context{state: stateCSSSqURL, delim: delimDoubleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a style="background: url('/)`, |
| context{state: stateCSSSqURL, delim: delimDoubleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a style="background: url('/ `, |
| context{state: stateCSSSqURL, delim: delimDoubleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a style="background: url(/`, |
| context{state: stateCSSURL, delim: delimDoubleQuote, urlPart: urlPartPreQuery}, |
| }, |
| { |
| `<a style="background: url( `, |
| context{state: stateCSSURL, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="background: url( /image?name=`, |
| context{state: stateCSSURL, delim: delimDoubleQuote, urlPart: urlPartQueryOrFrag}, |
| }, |
| { |
| `<a style="background: url(x)`, |
| context{state: stateCSS, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="background: url('x'`, |
| context{state: stateCSS, delim: delimDoubleQuote}, |
| }, |
| { |
| `<a style="background: url( x `, |
| context{state: stateCSS, delim: delimDoubleQuote}, |
| }, |
| { |
| `<!-- foo`, |
| context{state: stateComment}, |
| }, |
| { |
| `<!-->`, |
| context{state: stateComment}, |
| }, |
| { |
| `<!--->`, |
| context{state: stateComment}, |
| }, |
| { |
| `<!-- foo -->`, |
| context{state: stateText}, |
| }, |
| { |
| `<script`, |
| context{state: stateTag, element: elementScript}, |
| }, |
| { |
| `<script `, |
| context{state: stateTag, element: elementScript}, |
| }, |
| { |
| `<script src="foo.js" `, |
| context{state: stateTag, element: elementScript}, |
| }, |
| { |
| `<script src='foo.js' `, |
| context{state: stateTag, element: elementScript}, |
| }, |
| { |
| `<script type=text/javascript `, |
| context{state: stateTag, element: elementScript}, |
| }, |
| { |
| `<script>foo`, |
| context{state: stateJS, jsCtx: jsCtxDivOp, element: elementScript}, |
| }, |
| { |
| `<script>foo</script>`, |
| context{state: stateText}, |
| }, |
| { |
| `<script>foo</script><!--`, |
| context{state: stateComment}, |
| }, |
| { |
| `<script>document.write("<p>foo</p>");`, |
| context{state: stateJS, element: elementScript}, |
| }, |
| { |
| `<script>document.write("<p>foo<\/script>");`, |
| context{state: stateJS, element: elementScript}, |
| }, |
| { |
| `<script>document.write("<script>alert(1)</script>");`, |
| context{state: stateText}, |
| }, |
| { |
| `<Script>`, |
| context{state: stateJS, element: elementScript}, |
| }, |
| { |
| `<SCRIPT>foo`, |
| context{state: stateJS, jsCtx: jsCtxDivOp, element: elementScript}, |
| }, |
| { |
| `<textarea>value`, |
| context{state: stateRCDATA, element: elementTextarea}, |
| }, |
| { |
| `<textarea>value</TEXTAREA>`, |
| context{state: stateText}, |
| }, |
| { |
| `<textarea name=html><b`, |
| context{state: stateRCDATA, element: elementTextarea}, |
| }, |
| { |
| `<title>value`, |
| context{state: stateRCDATA, element: elementTitle}, |
| }, |
| { |
| `<style>value`, |
| context{state: stateCSS, element: elementStyle}, |
| }, |
| } |
| |
| for _, test := range tests { |
| b, e := []byte(test.input), newEscaper(nil) |
| c := e.escapeText(context{}, &parse.TextNode{parse.NodeText, b}) |
| if !test.output.eq(c) { |
| t.Errorf("input %q: want context\n\t%v\ngot\n\t%v", test.input, test.output, c) |
| continue |
| } |
| if test.input != string(b) { |
| t.Errorf("input %q: text node was modified: want %q got %q", test.input, test.input, b) |
| continue |
| } |
| } |
| } |
| |
| func TestEnsurePipelineContains(t *testing.T) { |
| tests := []struct { |
| input, output string |
| ids []string |
| }{ |
| { |
| "{{.X}}", |
| "[(command: [F=[X]])]", |
| []string{}, |
| }, |
| { |
| "{{.X | html}}", |
| "[(command: [F=[X]]) (command: [I=html])]", |
| []string{}, |
| }, |
| { |
| "{{.X}}", |
| "[(command: [F=[X]]) (command: [I=html])]", |
| []string{"html"}, |
| }, |
| { |
| "{{.X | html}}", |
| "[(command: [F=[X]]) (command: [I=html]) (command: [I=urlquery])]", |
| []string{"urlquery"}, |
| }, |
| { |
| "{{.X | html | urlquery}}", |
| "[(command: [F=[X]]) (command: [I=html]) (command: [I=urlquery])]", |
| []string{"urlquery"}, |
| }, |
| { |
| "{{.X | html | urlquery}}", |
| "[(command: [F=[X]]) (command: [I=html]) (command: [I=urlquery])]", |
| []string{"html", "urlquery"}, |
| }, |
| { |
| "{{.X | html | urlquery}}", |
| "[(command: [F=[X]]) (command: [I=html]) (command: [I=urlquery])]", |
| []string{"html"}, |
| }, |
| { |
| "{{.X | urlquery}}", |
| "[(command: [F=[X]]) (command: [I=html]) (command: [I=urlquery])]", |
| []string{"html", "urlquery"}, |
| }, |
| { |
| "{{.X | html | print}}", |
| "[(command: [F=[X]]) (command: [I=urlquery]) (command: [I=html]) (command: [I=print])]", |
| []string{"urlquery", "html"}, |
| }, |
| } |
| for _, test := range tests { |
| tmpl := template.Must(template.New("test").Parse(test.input)) |
| action, ok := (tmpl.Tree.Root.Nodes[0].(*parse.ActionNode)) |
| if !ok { |
| t.Errorf("First node is not an action: %s", test.input) |
| continue |
| } |
| pipe := action.Pipe |
| ensurePipelineContains(pipe, test.ids) |
| got := pipe.String() |
| if got != test.output { |
| t.Errorf("%s, %v: want\n\t%s\ngot\n\t%s", test.input, test.ids, test.output, got) |
| } |
| } |
| } |
| |
| func expectExecuteFailure(t *testing.T, b *bytes.Buffer) { |
| if x := recover(); x != nil { |
| if b.Len() != 0 { |
| t.Errorf("output on buffer: %q", b.String()) |
| } |
| } else { |
| t.Errorf("unescaped template executed") |
| } |
| } |
| |
| func TestEscapeErrorsNotIgnorable(t *testing.T) { |
| var b bytes.Buffer |
| tmpl := template.Must(template.New("dangerous").Parse("<a")) |
| Escape(tmpl) |
| defer expectExecuteFailure(t, &b) |
| tmpl.Execute(&b, nil) |
| } |
| |
| func TestEscapeSetErrorsNotIgnorable(t *testing.T) { |
| s, err := (&template.Set{}).Parse(`{{define "t"}}<a{{end}}`) |
| if err != nil { |
| t.Error("failed to parse set: %q", err) |
| } |
| EscapeSet(s, "t") |
| var b bytes.Buffer |
| defer expectExecuteFailure(t, &b) |
| s.Execute(&b, "t", nil) |
| } |