security: use html.EscapeString for XSS protection in rendering
All checks were successful
Security Scan / security (push) Successful in 9s
Test / test (push) Successful in 46s

Replace manual string-replace escaping with stdlib html.EscapeString.
Escape tag names and attribute keys in elNode and Layout rendering.
Improve Attr() to traverse wrapper nodes (If, Unless, Entitled).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-03-09 08:29:54 +00:00
parent 77dd329b01
commit 473fda5894
4 changed files with 21 additions and 23 deletions

View file

@ -447,7 +447,7 @@ func TestEscapeAttr_AllSpecialChars(t *testing.T) {
if strings.Contains(got, `"&<>"'"`) {
t.Error("attribute value with special chars must be fully escaped")
}
if !strings.Contains(got, "&amp;&lt;&gt;&quot;&#39;") {
if !strings.Contains(got, "&amp;&lt;&gt;&#34;&#39;") {
t.Errorf("expected all special chars escaped in attribute, got: %s", got)
}
}

View file

@ -89,11 +89,11 @@ func (l *Layout) Render(ctx *Context) string {
bid := l.blockID(slot)
b.WriteByte('<')
b.WriteString(meta.tag)
b.WriteString(escapeHTML(meta.tag))
b.WriteString(` role="`)
b.WriteString(meta.role)
b.WriteString(escapeAttr(meta.role))
b.WriteString(`" data-block="`)
b.WriteString(bid)
b.WriteString(escapeAttr(bid))
b.WriteString(`">`)
for _, child := range children {

34
node.go
View file

@ -1,6 +1,7 @@
package html
import (
"html"
"iter"
"maps"
"slices"
@ -33,12 +34,7 @@ var voidElements = map[string]bool{
// escapeAttr escapes a string for use in an HTML attribute value.
func escapeAttr(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
return s
return html.EscapeString(s)
}
// --- rawNode ---
@ -74,10 +70,17 @@ func El(tag string, children ...Node) Node {
}
// Attr sets an attribute on an El node. Returns the node for chaining.
// If the node is not an *elNode, returns it unchanged.
// It recursively traverses through wrappers like If, Unless, and Entitled.
func Attr(n Node, key, value string) Node {
if el, ok := n.(*elNode); ok {
el.attrs[key] = value
switch t := n.(type) {
case *elNode:
t.attrs[key] = value
case *ifNode:
Attr(t.node, key, value)
case *unlessNode:
Attr(t.node, key, value)
case *entitledNode:
Attr(t.node, key, value)
}
return n
}
@ -86,14 +89,14 @@ func (n *elNode) Render(ctx *Context) string {
var b strings.Builder
b.WriteByte('<')
b.WriteString(n.tag)
b.WriteString(escapeHTML(n.tag))
// Sort attribute keys for deterministic output.
keys := slices.Collect(maps.Keys(n.attrs))
slices.Sort(keys)
for _, key := range keys {
b.WriteByte(' ')
b.WriteString(key)
b.WriteString(escapeHTML(key))
b.WriteString(`="`)
b.WriteString(escapeAttr(n.attrs[key]))
b.WriteByte('"')
@ -110,7 +113,7 @@ func (n *elNode) Render(ctx *Context) string {
}
b.WriteString("</")
b.WriteString(n.tag)
b.WriteString(escapeHTML(n.tag))
b.WriteByte('>')
return b.String()
@ -120,12 +123,7 @@ func (n *elNode) Render(ctx *Context) string {
// escapeHTML escapes a string for safe inclusion in HTML text content.
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
s = strings.ReplaceAll(s, "'", "&#39;")
return s
return html.EscapeString(s)
}
// --- textNode ---

View file

@ -169,7 +169,7 @@ func TestElNode_AttrEscaping(t *testing.T) {
ctx := NewContext()
node := Attr(El("img"), "alt", `he said "hello"`)
got := node.Render(ctx)
if !strings.Contains(got, `alt="he said &quot;hello&quot;"`) {
if !strings.Contains(got, `alt="he said &#34;hello&#34;"`) {
t.Errorf("Attr should escape attribute values, got %q", got)
}
}