Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 27 additions & 2 deletions runtime/html/html.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package html

import (
stdhtml "html"
htmltemplate "html/template"
"strings"
)

Expand All @@ -12,10 +13,34 @@ func Escape(value string) string {

// Attr renders an escaped HTML attribute when value is non-empty.
func Attr(name, value string) string {
if value == "" {
if value == "" || !validAttrName(name) {
return ""
}
return " " + name + `="` + stdhtml.EscapeString(value) + `"`

var out strings.Builder
out.WriteByte(' ')
out.WriteString(name)
out.WriteString(`="`)
htmltemplate.HTMLEscape(&out, []byte(value))
out.WriteByte('"')
return out.String()
}

func validAttrName(name string) bool {
if name == "" {
return false
}
for _, char := range name {
switch {
case char >= 'a' && char <= 'z':
case char >= 'A' && char <= 'Z':
case char >= '0' && char <= '9':
case char == '-' || char == '_' || char == ':' || char == '.':
default:
return false
}
}
return true
}

// Classes joins generated class tokens.
Expand Down
38 changes: 37 additions & 1 deletion runtime/html/html_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package html

import "testing"
import (
"strings"
"testing"
)

func TestEscapeEscapesHTMLText(t *testing.T) {
if got := Escape(`<script>alert("x")</script>`); got != `&lt;script&gt;alert(&#34;x&#34;)&lt;/script&gt;` {
Expand All @@ -17,6 +20,39 @@ func TestAttrOmitsEmptyValuesAndEscapesNonEmptyValues(t *testing.T) {
}
}

func TestAttrEscapesJSONAttributeValues(t *testing.T) {
got := Attr("data-gowdk-state", `{"name":"hero","quote":"\"","html":"</gowdk-island>","amp":"&"}`)
const prefix = ` data-gowdk-state="`
if !strings.HasPrefix(got, prefix) || !strings.HasSuffix(got, `"`) {
t.Fatalf("unexpected attr shape: %q", got)
}
inner := strings.TrimSuffix(strings.TrimPrefix(got, prefix), `"`)
if strings.Contains(inner, `"`) {
t.Fatalf("attribute value contains an unescaped double quote: %q", got)
}
for _, escaped := range []string{`&#34;name&#34;`, `&lt;/gowdk-island&gt;`, `&amp;`} {
if !strings.Contains(inner, escaped) {
t.Fatalf("attribute value is missing escaped fragment %q: %q", escaped, got)
}
}
}

func TestAttrRejectsUnsafeAttributeNames(t *testing.T) {
for _, name := range []string{
``,
`href" onclick="alert(1)`,
`data-gowdk-state onmouseover`,
`data-gowdk-state=bad`,
} {
if got := Attr(name, "value"); got != "" {
t.Fatalf("expected unsafe attr name %q to be omitted, got %q", name, got)
}
}
if got := Attr("data-gowdk-parent-on-submit", "Save()"); got == "" {
t.Fatal("expected generated data attribute name to be accepted")
}
}

func TestClassesJoinsTrimmedNonEmptyTokens(t *testing.T) {
if got := Classes(" btn ", "", "primary", " "); got != "btn primary" {
t.Fatalf("unexpected classes: %q", got)
Expand Down