diff --git a/runtime/html/html.go b/runtime/html/html.go index f3e0938..e5089fd 100644 --- a/runtime/html/html.go +++ b/runtime/html/html.go @@ -2,6 +2,7 @@ package html import ( stdhtml "html" + htmltemplate "html/template" "strings" ) @@ -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. diff --git a/runtime/html/html_test.go b/runtime/html/html_test.go index c3d732a..9b4d681 100644 --- a/runtime/html/html_test.go +++ b/runtime/html/html_test.go @@ -1,6 +1,9 @@ package html -import "testing" +import ( + "strings" + "testing" +) func TestEscapeEscapesHTMLText(t *testing.T) { if got := Escape(``); got != `<script>alert("x")</script>` { @@ -17,6 +20,39 @@ func TestAttrOmitsEmptyValuesAndEscapesNonEmptyValues(t *testing.T) { } } +func TestAttrEscapesJSONAttributeValues(t *testing.T) { + got := Attr("data-gowdk-state", `{"name":"hero","quote":"\"","html":"","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{`"name"`, `</gowdk-island>`, `&`} { + 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)