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)