diff --git a/src/pkg/exp/template/html/content.go b/src/pkg/exp/template/html/content.go
index 4f792004050..8b9809b982e 100644
--- a/src/pkg/exp/template/html/content.go
+++ b/src/pkg/exp/template/html/content.go
@@ -19,11 +19,15 @@ type (
CSS string
// HTML encapsulates a known safe HTML document fragment.
- // Should not be used for HTML from a third-party, or HTML with
+ // It should not be used for HTML from a third-party, or HTML with
// unclosed tags or comments. The outputs of a sound HTML sanitizer
// and a template escaped by this package are fine for use with HTML.
HTML string
+ // HTMLAttr encapsulates an HTML attribute from a trusted source,
+ // for example: ` dir="ltr"`.
+ HTMLAttr string
+
// JS encapsulates a known safe EcmaScript5 Expression, or example,
// `(x + y * z())`.
// Template authors are responsible for ensuring that typed expressions
@@ -56,6 +60,7 @@ const (
contentTypePlain contentType = iota
contentTypeCSS
contentTypeHTML
+ contentTypeHTMLAttr
contentTypeJS
contentTypeJSStr
contentTypeURL
@@ -71,6 +76,8 @@ func stringify(args ...interface{}) (string, contentType) {
return string(s), contentTypeCSS
case HTML:
return string(s), contentTypeHTML
+ case HTMLAttr:
+ return string(s), contentTypeHTMLAttr
case JS:
return string(s), contentTypeJS
case JSStr:
diff --git a/src/pkg/exp/template/html/content_test.go b/src/pkg/exp/template/html/content_test.go
index caef5ade8e9..033dee1747c 100644
--- a/src/pkg/exp/template/html/content_test.go
+++ b/src/pkg/exp/template/html/content_test.go
@@ -16,6 +16,7 @@ func TestTypedContent(t *testing.T) {
` "foo%" O'Reilly &bar;`,
CSS(`a[href =~ "//example.com"]#foo`),
HTML(`Hello, World &tc!`),
+ HTMLAttr(` dir="ltr"`),
JS(`c && alert("Hello, World!");`),
JSStr(`Hello, World & O'Reilly\x21`),
URL(`greeting=H%69&addressee=(World)`),
@@ -38,6 +39,7 @@ func TestTypedContent(t *testing.T) {
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
+ `ZgotmplZ`,
},
},
{
@@ -50,6 +52,7 @@ func TestTypedContent(t *testing.T) {
`ZgotmplZ`,
`ZgotmplZ`,
`ZgotmplZ`,
+ `ZgotmplZ`,
},
},
{
@@ -59,11 +62,25 @@ func TestTypedContent(t *testing.T) {
`a[href =~ "//example.com"]#foo`,
// Not escaped.
`Hello, World &tc!`,
+ ` dir="ltr"`,
`c && alert("Hello, World!");`,
`Hello, World & O'Reilly\x21`,
`greeting=H%69&addressee=(World)`,
},
},
+ {
+ ``,
+ []string{
+ `ZgotmplZ`,
+ `ZgotmplZ`,
+ `ZgotmplZ`,
+ // Allowed and HTML escaped.
+ ` dir="ltr"`,
+ `ZgotmplZ`,
+ `ZgotmplZ`,
+ `ZgotmplZ`,
+ },
+ },
{
``,
[]string{
@@ -71,6 +88,7 @@ func TestTypedContent(t *testing.T) {
`a[href =~ "//example.com"]#foo`,
// Tags stripped, spaces escaped, entity not re-escaped.
`Hello, World &tc!`,
+ ` dir="ltr"`,
`c && alert("Hello, World!");`,
`Hello, World & O'Reilly\x21`,
`greeting=H%69&addressee=(World)`,
@@ -83,6 +101,7 @@ func TestTypedContent(t *testing.T) {
`a[href =~ "//example.com"]#foo`,
// Tags stripped, entity not re-escaped.
`Hello, World &tc!`,
+ ` dir="ltr"`,
`c && alert("Hello, World!");`,
`Hello, World & O'Reilly\x21`,
`greeting=H%69&addressee=(World)`,
@@ -95,6 +114,7 @@ func TestTypedContent(t *testing.T) {
`a[href =~ "//example.com"]#foo`,
// Angle brackets escaped to prevent injection of close tags, entity not re-escaped.
`Hello, <b>World</b> &tc!`,
+ ` dir="ltr"`,
`c && alert("Hello, World!");`,
`Hello, World & O'Reilly\x21`,
`greeting=H%69&addressee=(World)`,
@@ -106,6 +126,7 @@ func TestTypedContent(t *testing.T) {
`"\u003cb\u003e \"foo%\" O'Reilly &bar;"`,
`"a[href =~ \"//example.com\"]#foo"`,
`"Hello, \u003cb\u003eWorld\u003c/b\u003e &tc!"`,
+ `" dir=\"ltr\""`,
// Not escaped.
`c && alert("Hello, World!");`,
// Escape sequence not over-escaped.
@@ -119,6 +140,7 @@ func TestTypedContent(t *testing.T) {
`"\u003cb\u003e \"foo%\" O'Reilly &bar;"`,
`"a[href =~ \"//example.com\"]#foo"`,
`"Hello, \u003cb\u003eWorld\u003c/b\u003e &tc!"`,
+ `" dir=\"ltr\""`,
// Not JS escaped but HTML escaped.
`c && alert("Hello, World!");`,
// Escape sequence not over-escaped.
@@ -132,6 +154,7 @@ func TestTypedContent(t *testing.T) {
`\x3cb\x3e \x22foo%\x22 O\x27Reilly \x26bar;`,
`a[href =~ \x22\/\/example.com\x22]#foo`,
`Hello, \x3cb\x3eWorld\x3c\/b\x3e \x26amp;tc!`,
+ ` dir=\x22ltr\x22`,
`c \x26\x26 alert(\x22Hello, World!\x22);`,
// Escape sequence not over-escaped.
`Hello, World \x26 O\x27Reilly\x21`,
@@ -144,6 +167,7 @@ func TestTypedContent(t *testing.T) {
`\x3cb\x3e \x22foo%\x22 O\x27Reilly \x26bar;`,
`a[href =~ \x22\/\/example.com\x22]#foo`,
`Hello, \x3cb\x3eWorld\x3c\/b\x3e \x26amp;tc!`,
+ ` dir=\x22ltr\x22`,
`c \x26\x26 alert(\x22Hello, World!\x22);`,
// Escape sequence not over-escaped.
`Hello, World \x26 O\x27Reilly\x21`,
@@ -156,6 +180,7 @@ func TestTypedContent(t *testing.T) {
`%3cb%3e%20%22foo%25%22%20O%27Reilly%20%26bar%3b`,
`a%5bhref%20%3d~%20%22%2f%2fexample.com%22%5d%23foo`,
`Hello%2c%20%3cb%3eWorld%3c%2fb%3e%20%26amp%3btc%21`,
+ `%20dir%3d%22ltr%22`,
`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.
@@ -168,6 +193,7 @@ func TestTypedContent(t *testing.T) {
`%3cb%3e%20%22foo%25%22%20O%27Reilly%20%26bar%3b`,
`a%5bhref%20%3d~%20%22%2f%2fexample.com%22%5d%23foo`,
`Hello%2c%20%3cb%3eWorld%3c%2fb%3e%20%26amp%3btc%21`,
+ `%20dir%3d%22ltr%22`,
`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.
diff --git a/src/pkg/exp/template/html/context.go b/src/pkg/exp/template/html/context.go
index e8812cf8657..f7802d04b31 100644
--- a/src/pkg/exp/template/html/context.go
+++ b/src/pkg/exp/template/html/context.go
@@ -20,6 +20,7 @@ type context struct {
delim delim
urlPart urlPart
jsCtx jsCtx
+ attr attr
element element
err *Error
}
@@ -30,6 +31,7 @@ func (c context) eq(d context) bool {
c.delim == d.delim &&
c.urlPart == d.urlPart &&
c.jsCtx == d.jsCtx &&
+ c.attr == d.attr &&
c.element == d.element &&
c.err == d.err
}
@@ -51,6 +53,9 @@ func (c context) mangle(templateName string) string {
if c.jsCtx != 0 {
s += "_" + c.jsCtx.String()
}
+ if c.attr != 0 {
+ s += "_" + c.attr.String()
+ }
if c.element != 0 {
s += "_" + c.element.String()
}
@@ -75,6 +80,15 @@ const (
stateText state = iota
// stateTag occurs before an HTML attribute or the end of a tag.
stateTag
+ // stateAttrName occurs inside an attribute name.
+ // It occurs between the ^'s in ` ^name^ = value`.
+ stateAttrName
+ // stateAfterName occurs after an attr name has ended but before any
+ // equals sign. It occurs between the ^'s in ` name^ ^= value`.
+ stateAfterName
+ // stateBeforeValue occurs after the equals sign but before the value.
+ // It occurs between the ^'s in ` name =^ ^value`.
+ stateBeforeValue
// stateComment occurs inside an .
stateComment
// stateRCDATA occurs inside an RCDATA element (`,
``,
},
+ {
+ "optional attrs",
+ `"}}"{{end}}` +
+ // Double quotes inside if/else.
+ ` src=` +
+ `{{if .T}}"?{{""}}"` +
+ `{{else}}"images/cleardot.gif"{{end}}` +
+ // Missing space before title, but it is not a
+ // part of the src attribute.
+ `{{if .T}}title="{{""}}"{{end}}` +
+ // Quotes outside if/else.
+ ` alt="` +
+ `{{if .T}}{{""}}` +
+ `{{else}}{{if .F}}{{""}}{{end}}` +
+ `{{end}}"` +
+ `>`,
+ ``,
+ },
+ {
+ "conditional valueless attr name",
+ ``,
+ ``,
+ },
+ {
+ "conditional dynamic valueless attr name 1",
+ ``,
+ ``,
+ },
+ {
+ "conditional dynamic valueless attr name 2",
+ ``,
+ ``,
+ },
+ {
+ "dynamic attribute name",
+ ``,
+ // Treated as JS since quotes are inserted.
+ ``,
+ },
+ {
+ "dynamic element name",
+ `...`,
+ `...`,
+ },
}
for _, test := range tests {
@@ -780,9 +825,25 @@ func TestEscapeText(t *testing.T) {
``,
context{state: stateText},
},
+ {
+ `' {
- state = elementContentType[c.element]
- return context{state: state, element: c.element}, s[i+1:]
- } else if s[i] != '=' {
- // Possible due to a valueless attribute or '/' in "".
- return c, s[i:]
+ return context{
+ state: elementContentType[c.element],
+ element: c.element,
+ }, s[i+1:]
}
- // Consume the "=".
- i = eatWhiteSpace(s, i+1)
-
- // Find the attribute delimiter.
- delim := delimSpaceOrTagEnd
- if i < len(s) {
- switch s[i] {
- case '\'':
- delim, i = delimSingleQuote, i+1
- case '"':
- delim, i = delimDoubleQuote, i+1
+ j, err := eatAttrName(s, i)
+ if err != nil {
+ return context{state: stateError, err: err}, nil
+ }
+ state, attr := stateTag, attrNone
+ if i != j {
+ canonAttrName := strings.ToLower(string(s[i:j]))
+ if urlAttr[canonAttrName] {
+ attr = attrURL
+ } else if strings.HasPrefix(canonAttrName, "on") {
+ attr = attrScript
+ } else if canonAttrName == "style" {
+ attr = attrStyle
+ }
+ if j == len(s) {
+ state = stateAttrName
+ } else {
+ state = stateAfterName
}
}
+ return context{state: state, element: c.element, attr: attr}, s[j:]
+}
- return context{state: state, delim: delim, element: c.element}, s[i:]
+// tAttrName is the context transition function for stateAttrName.
+func tAttrName(c context, s []byte) (context, []byte) {
+ i, err := eatAttrName(s, 0)
+ if err != nil {
+ return context{state: stateError, err: err}, nil
+ } else if i == len(s) {
+ return c, nil
+ }
+ c.state = stateAfterName
+ return c, s[i:]
+}
+
+// tAfterName is the context transition function for stateAfterName.
+func tAfterName(c context, s []byte) (context, []byte) {
+ // Look for the start of the value.
+ i := eatWhiteSpace(s, 0)
+ if i == len(s) {
+ return c, nil
+ } else if s[i] != '=' {
+ // Occurs due to tag ending '>', and valueless attribute.
+ c.state = stateTag
+ return c, s[i:]
+ }
+ c.state = stateBeforeValue
+ // Consume the "=".
+ return c, s[i+1:]
+}
+
+var attrStartStates = [...]state{
+ attrNone: stateAttr,
+ attrScript: stateJS,
+ attrStyle: stateCSS,
+ attrURL: stateURL,
+}
+
+// tBeforeValue is the context transition function for stateBeforeValue.
+func tBeforeValue(c context, s []byte) (context, []byte) {
+ i := eatWhiteSpace(s, 0)
+ if i == len(s) {
+ return c, nil
+ }
+ // Find the attribute delimiter.
+ delim := delimSpaceOrTagEnd
+ switch s[i] {
+ case '\'':
+ delim, i = delimSingleQuote, i+1
+ case '"':
+ delim, i = delimDoubleQuote, i+1
+ }
+ c.state, c.delim, c.attr = attrStartStates[c.attr], delim, attrNone
+ return c, s[i:]
}
// tComment is the context transition function for stateComment.