From 9bc1fa7c69df9aa0c1a00ce51f10401660c799bc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 17 Feb 2026 00:21:24 +0000 Subject: [PATCH] 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 --- Makefile | 2 +- cmd/wasm/main.go | 4 ++++ node_test.go | 8 ++++++++ pipeline.go | 26 +++++++++++++++++--------- responsive.go | 2 +- 5 files changed, 31 insertions(+), 11 deletions(-) diff --git a/Makefile b/Makefile index cc4ce61..ad68e50 100644 --- a/Makefile +++ b/Makefile @@ -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)"); \ diff --git a/cmd/wasm/main.go b/cmd/wasm/main.go index c695292..254f08d 100644 --- a/cmd/wasm/main.go +++ b/cmd/wasm/main.go @@ -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 "" diff --git a/node_test.go b/node_test.go index c1df25b..77fd26d 100644 --- a/node_test.go +++ b/node_test.go @@ -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{ diff --git a/pipeline.go b/pipeline.go index 04e6a6d..8fde6eb 100644 --- a/pipeline.go +++ b/pipeline.go @@ -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 { - b.WriteRune(r) + 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, diff --git a/responsive.go b/responsive.go index 25374f4..7332e08 100644 --- a/responsive.go +++ b/responsive.go @@ -30,7 +30,7 @@ func (r *Responsive) Render(ctx *Context) string { var b strings.Builder for _, v := range r.variants { b.WriteString(`
`) b.WriteString(v.layout.Render(ctx)) b.WriteString(`
`)