fix: escape variant names, single-pass StripTags, WASM security contract

- Escape variant name in Responsive.Render HTML attribute (XSS fix)
- Rewrite StripTags to single-pass O(n) space collapsing
- Document Raw() security contract in WASM entry point
- Add TestAttr_NonElement coverage
- Fix Makefile WASM target to rebuild on source changes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude 2026-02-17 00:21:24 +00:00
parent 18d2933315
commit 9bc1fa7c69
No known key found for this signature in database
GPG key ID: AF404715446AEB41
5 changed files with 31 additions and 11 deletions

View file

@ -11,7 +11,7 @@ test:
wasm: $(WASM_OUT)
$(WASM_OUT):
$(WASM_OUT): $(shell find . -name '*.go' -not -path './dist/*')
@mkdir -p dist
GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o $(WASM_OUT) ./cmd/wasm/
@RAW=$$(stat -c%s "$(WASM_OUT)" 2>/dev/null || stat -f%z "$(WASM_OUT)"); \

View file

@ -8,6 +8,10 @@ import (
html "forge.lthn.ai/core/go-html"
)
// renderToString builds an HLCRF layout from JS arguments and returns HTML.
// Slot content is injected via Raw() — the caller is responsible for sanitisation.
// This is intentional: the WASM module is a rendering engine for trusted content
// produced server-side or by the application's own templates.
func renderToString(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return ""

View file

@ -183,6 +183,14 @@ func TestElNode_MultipleAttrs(t *testing.T) {
}
}
func TestAttr_NonElement(t *testing.T) {
node := Attr(Raw("text"), "class", "x")
got := node.Render(NewContext())
if got != "text" {
t.Errorf("Attr on non-element should return unchanged, got %q", got)
}
}
func TestSwitchNode(t *testing.T) {
ctx := NewContext()
cases := map[string]Node{

View file

@ -7,30 +7,38 @@ import (
)
// StripTags removes HTML tags from rendered output, returning plain text.
// Tag boundaries are replaced with a single space; result is trimmed.
// Tag boundaries are collapsed into single spaces; result is trimmed.
// Does not handle script/style element content (go-html does not generate these).
func StripTags(html string) string {
var b strings.Builder
inTag := false
prevSpace := true // starts true to trim leading space
for _, r := range html {
if r == '<' {
inTag = true
b.WriteByte(' ')
continue
}
if r == '>' {
inTag = false
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
continue
}
if !inTag {
if r == ' ' || r == '\t' || r == '\n' {
if !prevSpace {
b.WriteByte(' ')
prevSpace = true
}
} else {
b.WriteRune(r)
prevSpace = false
}
}
// Collapse multiple spaces into one.
result := b.String()
for strings.Contains(result, " ") {
result = strings.ReplaceAll(result, " ", " ")
}
return strings.TrimSpace(result)
return strings.TrimSpace(b.String())
}
// Imprint renders a node tree to HTML, strips tags, tokenises the text,

View file

@ -30,7 +30,7 @@ func (r *Responsive) Render(ctx *Context) string {
var b strings.Builder
for _, v := range r.variants {
b.WriteString(`<div data-variant="`)
b.WriteString(v.name)
b.WriteString(escapeAttr(v.name))
b.WriteString(`">`)
b.WriteString(v.layout.Render(ctx))
b.WriteString(`</div>`)