Skip to content

Commit 9ffd933

Browse files
Samuel Tanrsc
Samuel Tan
authored andcommitted
html/template: panic if predefined escapers are found in pipelines during rewriting
Report an error if a predefined escaper (i.e. "html", "urlquery", or "js") is found in a pipeline that will be rewritten by the contextual auto-escaper, instead of trying to merge the escaper-inserted escaping directives with these predefined escapers. This merging behavior is a source of several security and correctness bugs (eee #19336, #19345, #19352, and #19353.) This merging logic was originally intended to ease migration of text/template templates with user-defined escapers to html/template. Now that migration is no longer an issue, this logic can be safely removed. NOTE: this is a backward-incompatible change that fixes known security bugs (see linked issues for more details). It will explicitly break users that attempt to execute templates with pipelines containing predefined escapers. Fixes #19336, #19345, #19352, #19353 Change-Id: I46b0ca8a2809d179c13c0d4f42b63126ed1c3b49 Reviewed-on: https://go-review.googlesource.com/37880 Run-TryBot: Russ Cox <[email protected]> TryBot-Result: Gobot Gobot <[email protected]> Reviewed-by: Russ Cox <[email protected]>
1 parent 221541e commit 9ffd933

File tree

3 files changed

+81
-148
lines changed

3 files changed

+81
-148
lines changed

src/html/template/error.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,27 @@ const (
183183
// Look for missing semicolons inside branches, and maybe add
184184
// parentheses to make it clear which interpretation you intend.
185185
ErrSlashAmbig
186+
187+
// ErrPredefinedEscaper: "predefined escaper ... disallowed in template"
188+
// Example:
189+
// <a href="{{.X | urlquery}}">
190+
// Discussion:
191+
// Package html/template already contextually escapes all pipelines to
192+
// produce HTML output safe against code injection. Manually escaping
193+
// pipeline output using the predefined escapers "html", "urlquery", or "js"
194+
// is unnecessary, and might affect the correctness or safety of the escaped
195+
// pipeline output. In the above example, "urlquery" should simply be
196+
// removed from the pipeline so that escaping is performed solely by the
197+
// contextual autoescaper.
198+
// If the predefined escaper occurs in the middle of a pipeline where
199+
// subsequent commands expect escaped input, e.g.
200+
// {{.X | html | makeALink}}
201+
// where makeALink does
202+
// return "<a href='+input+'>link</a>"
203+
// consider refactoring the surrounding template to make use of the
204+
// contextual autoescaper, i.e.
205+
// <a href='{{.X}}'>link</a>
206+
ErrPredefinedEscaper
186207
)
187208

188209
func (e *Error) Error() string {

src/html/template/escape.go

Lines changed: 34 additions & 76 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,11 @@ var funcMap = template.FuncMap{
6262
"_html_template_urlnormalizer": urlNormalizer,
6363
}
6464

65-
// equivEscapers matches contextual escapers to equivalent template builtins.
66-
var equivEscapers = map[string]string{
67-
"_html_template_attrescaper": "html",
68-
"_html_template_htmlescaper": "html",
69-
"_html_template_nospaceescaper": "html",
70-
"_html_template_rcdataescaper": "html",
71-
"_html_template_urlescaper": "urlquery",
72-
"_html_template_urlnormalizer": "urlquery",
65+
// predefinedEscapers contains template predefined escapers.
66+
var predefinedEscapers = map[string]bool{
67+
"html" : true,
68+
"urlquery": true,
69+
"js": true,
7370
}
7471

7572
// escaper collects type inferences about templates and changes needed to make
@@ -133,12 +130,37 @@ func (e *escaper) escape(c context, n parse.Node) context {
133130
panic("escaping " + n.String() + " is unimplemented")
134131
}
135132

133+
// allIdents returns the names of the identifiers under the Ident field of the node,
134+
// which might be a singleton (Identifier) or a slice (Field or Chain).
135+
func allIdents(node parse.Node) []string {
136+
switch node := node.(type) {
137+
case *parse.IdentifierNode:
138+
return []string{node.Ident}
139+
case *parse.FieldNode:
140+
return node.Ident
141+
case *parse.ChainNode:
142+
return node.Field
143+
}
144+
return nil
145+
}
146+
136147
// escapeAction escapes an action template node.
137148
func (e *escaper) escapeAction(c context, n *parse.ActionNode) context {
138149
if len(n.Pipe.Decl) != 0 {
139150
// A local variable assignment, not an interpolation.
140151
return c
141152
}
153+
// Disallow the use of predefined escapers in pipelines.
154+
for _, idNode := range n.Pipe.Cmds {
155+
for _, ident := range allIdents(idNode.Args[0]) {
156+
if _, ok := predefinedEscapers[ident]; ok {
157+
return context{
158+
state: stateError,
159+
err: errorf(ErrPredefinedEscaper, n, n.Line, "predefined escaper %q disallowed in template", ident),
160+
}
161+
}
162+
}
163+
}
142164
c = nudge(c)
143165
s := make([]string, 0, 3)
144166
switch c.state {
@@ -204,69 +226,16 @@ func (e *escaper) escapeAction(c context, n *parse.ActionNode) context {
204226
return c
205227
}
206228

207-
// allIdents returns the names of the identifiers under the Ident field of the node,
208-
// which might be a singleton (Identifier) or a slice (Field or Chain).
209-
func allIdents(node parse.Node) []string {
210-
switch node := node.(type) {
211-
case *parse.IdentifierNode:
212-
return []string{node.Ident}
213-
case *parse.FieldNode:
214-
return node.Ident
215-
case *parse.ChainNode:
216-
return node.Field
217-
}
218-
return nil
219-
}
220-
221-
// ensurePipelineContains ensures that the pipeline has commands with
229+
// ensurePipelineContains ensures that the pipeline ends with the commands with
222230
// the identifiers in s in order.
223-
// If the pipeline already has some of the sanitizers, do not interfere.
224-
// For example, if p is (.X | html) and s is ["escapeJSVal", "html"] then it
225-
// has one matching, "html", and one to insert, "escapeJSVal", to produce
226-
// (.X | escapeJSVal | html).
227231
func ensurePipelineContains(p *parse.PipeNode, s []string) {
228232
if len(s) == 0 {
233+
// Do not rewrite pipeline if we have no escapers to insert.
229234
return
230235
}
231-
n := len(p.Cmds)
232-
// Find the identifiers at the end of the command chain.
233-
idents := p.Cmds
234-
for i := n - 1; i >= 0; i-- {
235-
if cmd := p.Cmds[i]; len(cmd.Args) != 0 {
236-
if _, ok := cmd.Args[0].(*parse.IdentifierNode); ok {
237-
continue
238-
}
239-
}
240-
idents = p.Cmds[i+1:]
241-
}
242-
dups := 0
243-
for _, idNode := range idents {
244-
for _, ident := range allIdents(idNode.Args[0]) {
245-
if escFnsEq(s[dups], ident) {
246-
dups++
247-
if dups == len(s) {
248-
return
249-
}
250-
}
251-
}
252-
}
253-
newCmds := make([]*parse.CommandNode, n-len(idents), n+len(s)-dups)
236+
// Rewrite the pipeline, creating the escapers in s at the end of the pipeline.
237+
newCmds := make([]*parse.CommandNode, len(p.Cmds), len(p.Cmds)+len(s))
254238
copy(newCmds, p.Cmds)
255-
// Merge existing identifier commands with the sanitizers needed.
256-
for _, idNode := range idents {
257-
pos := idNode.Args[0].Position()
258-
for _, ident := range allIdents(idNode.Args[0]) {
259-
i := indexOfStr(ident, s, escFnsEq)
260-
if i != -1 {
261-
for _, name := range s[:i] {
262-
newCmds = appendCmd(newCmds, newIdentCmd(name, pos))
263-
}
264-
s = s[i+1:]
265-
}
266-
}
267-
newCmds = appendCmd(newCmds, idNode)
268-
}
269-
// Create any remaining sanitizers.
270239
for _, name := range s {
271240
newCmds = appendCmd(newCmds, newIdentCmd(name, p.Position()))
272241
}
@@ -318,17 +287,6 @@ func indexOfStr(s string, strs []string, eq func(a, b string) bool) int {
318287
return -1
319288
}
320289

321-
// escFnsEq reports whether the two escaping functions are equivalent.
322-
func escFnsEq(a, b string) bool {
323-
if e := equivEscapers[a]; e != "" {
324-
a = e
325-
}
326-
if e := equivEscapers[b]; e != "" {
327-
b = e
328-
}
329-
return a == b
330-
}
331-
332290
// newIdentCmd produces a command containing a single identifier node.
333291
func newIdentCmd(identifier string, pos parse.Pos) *parse.CommandNode {
334292
return &parse.CommandNode{

src/html/template/escape_test.go

Lines changed: 26 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -69,17 +69,7 @@ func TestEscape(t *testing.T) {
6969
"&lt;Goodbye&gt;!",
7070
},
7171
{
72-
"overescaping1",
73-
"Hello, {{.C | html}}!",
74-
"Hello, &lt;Cincinatti&gt;!",
75-
},
76-
{
77-
"overescaping2",
78-
"Hello, {{html .C}}!",
79-
"Hello, &lt;Cincinatti&gt;!",
80-
},
81-
{
82-
"overescaping3",
72+
"overescaping",
8373
"{{with .C}}{{$msg := .}}Hello, {{$msg}}!{{end}}",
8474
"Hello, &lt;Cincinatti&gt;!",
8575
},
@@ -213,11 +203,6 @@ func TestEscape(t *testing.T) {
213203
"<script>alert({{.A}})</script>",
214204
`<script>alert(["\u003ca\u003e","\u003cb\u003e"])</script>`,
215205
},
216-
{
217-
"jsObjValueNotOverEscaped",
218-
"<button onclick='alert({{.A | html}})'>",
219-
`<button onclick='alert([&#34;\u003ca\u003e&#34;,&#34;\u003cb\u003e&#34;])'>`,
220-
},
221206
{
222207
"jsStr",
223208
"<button onclick='alert(&quot;{{.H}}&quot;)'>",
@@ -233,12 +218,6 @@ func TestEscape(t *testing.T) {
233218
`<button onclick='alert({{.M}})'>`,
234219
`<button onclick='alert({&#34;\u003cfoo\u003e&#34;:&#34;O&#39;Reilly&#34;})'>`,
235220
},
236-
{
237-
"jsStrNotUnderEscaped",
238-
"<button onclick='alert({{.C | urlquery}})'>",
239-
// URL escaped, then quoted for JS.
240-
`<button onclick='alert(&#34;%3CCincinatti%3E&#34;)'>`,
241-
},
242221
{
243222
"jsRe",
244223
`<button onclick='alert(/{{"foo+bar"}}/.test(""))'>`,
@@ -970,8 +949,32 @@ func TestErrors(t *testing.T) {
970949
`<a=foo>`,
971950
`: expected space, attr name, or end of tag, but got "=foo>"`,
972951
},
952+
{
953+
`Hello, {{. | html}}!`,
954+
// Piping to html is disallowed.
955+
`predefined escaper "html" disallowed in template`,
956+
},
957+
{
958+
`Hello, {{. | html | print}}!`,
959+
// html is disallowed, even if it is not the last command in the pipeline.
960+
`predefined escaper "html" disallowed in template`,
961+
},
962+
{
963+
`Hello, {{html .}}!`,
964+
// Calling html is disallowed.
965+
`predefined escaper "html" disallowed in template`,
966+
},
967+
{
968+
`Hello, {{. | urlquery | html}}!`,
969+
// urlquery is disallowed; first disallowed escaper in the pipeline is reported in error.
970+
`predefined escaper "urlquery" disallowed in template`,
971+
},
972+
{
973+
`<script>function do{{. | js}}() { return 1 }</script>`,
974+
// js is disallowed.
975+
`predefined escaper "js" disallowed in template`,
976+
},
973977
}
974-
975978
for _, test := range tests {
976979
buf := new(bytes.Buffer)
977980
tmpl, err := New("z").Parse(test.input)
@@ -1518,61 +1521,16 @@ func TestEnsurePipelineContains(t *testing.T) {
15181521
".X",
15191522
[]string{},
15201523
},
1521-
{
1522-
"{{.X | html}}",
1523-
".X | html",
1524-
[]string{},
1525-
},
15261524
{
15271525
"{{.X}}",
15281526
".X | html",
15291527
[]string{"html"},
15301528
},
1531-
{
1532-
"{{.X | html}}",
1533-
".X | html | urlquery",
1534-
[]string{"urlquery"},
1535-
},
1536-
{
1537-
"{{.X | html | urlquery}}",
1538-
".X | html | urlquery",
1539-
[]string{"urlquery"},
1540-
},
1541-
{
1542-
"{{.X | html | urlquery}}",
1543-
".X | html | urlquery",
1544-
[]string{"html", "urlquery"},
1545-
},
1546-
{
1547-
"{{.X | html | urlquery}}",
1548-
".X | html | urlquery",
1549-
[]string{"html"},
1550-
},
1551-
{
1552-
"{{.X | urlquery}}",
1553-
".X | html | urlquery",
1554-
[]string{"html", "urlquery"},
1555-
},
1556-
{
1557-
"{{.X | html | print}}",
1558-
".X | urlquery | html | print",
1559-
[]string{"urlquery", "html"},
1560-
},
1561-
{
1562-
"{{($).X | html | print}}",
1563-
"($).X | urlquery | html | print",
1564-
[]string{"urlquery", "html"},
1565-
},
15661529
{
15671530
"{{.X | print 2 | .f 3}}",
15681531
".X | print 2 | .f 3 | urlquery | html",
15691532
[]string{"urlquery", "html"},
15701533
},
1571-
{
1572-
"{{.X | html | print 2 | .f 3}}",
1573-
".X | urlquery | html | print 2 | .f 3",
1574-
[]string{"urlquery", "html"},
1575-
},
15761534
{
15771535
// covering issue 10801
15781536
"{{.X | js.x }}",
@@ -1605,11 +1563,7 @@ func TestEnsurePipelineContains(t *testing.T) {
16051563
func TestEscapeMalformedPipelines(t *testing.T) {
16061564
tests := []string{
16071565
"{{ 0 | $ }}",
1608-
"{{ 0 | $ | urlquery }}",
1609-
"{{ 0 | $ | urlquery | html }}",
16101566
"{{ 0 | (nil) }}",
1611-
"{{ 0 | (nil) | html }}",
1612-
"{{ 0 | (nil) | html | urlquery }}",
16131567
}
16141568
for _, test := range tests {
16151569
var b bytes.Buffer

0 commit comments

Comments
 (0)