fix: sanitizer double-escaped entities ( /&); dot-prefix volumes dir
- chat.SanitizeHTML/SanitizePlain decoded-then-escape text runs, so HTML entities the browser emits (e.g. for the space after a mention chip, or & for a typed "&") no longer render as literal " "/"&". Re-escaping keeps output XSS-safe. Adds regression tests. - Rename ./volumes -> ./.volumes so `go build/vet/test ./...` skip the mongo data dir (700-perm files); Makefile lint now gofmt's tracked files only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -5,4 +5,4 @@ backups/
|
|||||||
coverage.out
|
coverage.out
|
||||||
|
|
||||||
# Docker volume data (bind-mounted under the repo; never committed)
|
# Docker volume data (bind-mounted under the repo; never committed)
|
||||||
/volumes/
|
/.volumes/
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test-contract:
|
|||||||
$(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/
|
$(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/
|
||||||
|
|
||||||
lint:
|
lint:
|
||||||
@unformatted=$$(gofmt -l .); if [ -n "$$unformatted" ]; then \
|
@unformatted=$$(gofmt -l $$(git ls-files '*.go')); if [ -n "$$unformatted" ]; then \
|
||||||
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
|
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; fi
|
||||||
$(GO) vet ./...
|
$(GO) vet ./...
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -13,8 +13,8 @@ services:
|
|||||||
# Mongo is reachable only on the compose network; never published to the
|
# Mongo is reachable only on the compose network; never published to the
|
||||||
# host (§12).
|
# host (§12).
|
||||||
volumes:
|
volumes:
|
||||||
# data lives in-repo (gitignored) so all state is under one tree
|
# data lives in-repo (gitignored, dot-prefixed so go tooling skips it)
|
||||||
- ./volumes/mongo:/data/db
|
- ./.volumes/mongo:/data/db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"]
|
||||||
interval: 10s
|
interval: 10s
|
||||||
@@ -83,7 +83,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ${HOME}/.claude:/home/node/.claude
|
- ${HOME}/.claude:/home/node/.claude
|
||||||
- ${HOME}/.claude.json:/home/node/.claude.json
|
- ${HOME}/.claude.json:/home/node/.claude.json
|
||||||
- ./volumes/work:/work
|
- ./.volumes/work:/work
|
||||||
ports:
|
ports:
|
||||||
- "127.0.0.1:8091:8091"
|
- "127.0.0.1:8091:8091"
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
|
|||||||
@@ -31,10 +31,10 @@ func SanitizeHTML(in string) string {
|
|||||||
if c != '<' {
|
if c != '<' {
|
||||||
j := strings.IndexByte(in[i:], '<')
|
j := strings.IndexByte(in[i:], '<')
|
||||||
if j < 0 {
|
if j < 0 {
|
||||||
out.WriteString(html.EscapeString(in[i:]))
|
out.WriteString(escapeText(in[i:]))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
out.WriteString(html.EscapeString(in[i : i+j]))
|
out.WriteString(escapeText(in[i : i+j]))
|
||||||
i += j
|
i += j
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -118,6 +118,15 @@ func SanitizeHTML(in string) string {
|
|||||||
return out.String()
|
return out.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// escapeText normalizes a text run from (possibly entity-encoded) input HTML:
|
||||||
|
// decode existing entities, then re-escape. This keeps a single round of
|
||||||
|
// escaping so entities the browser emits — e.g. for spaces around inline
|
||||||
|
// elements, or & for a typed "&" — don't get double-escaped into visible
|
||||||
|
// " "/"&" text. Re-escaping keeps the output XSS-safe.
|
||||||
|
func escapeText(s string) string {
|
||||||
|
return html.EscapeString(html.UnescapeString(s))
|
||||||
|
}
|
||||||
|
|
||||||
// splitTag separates the tag name from its raw attribute string.
|
// splitTag separates the tag name from its raw attribute string.
|
||||||
func splitTag(raw string) (string, string) {
|
func splitTag(raw string) (string, string) {
|
||||||
for i := 0; i < len(raw); i++ {
|
for i := 0; i < len(raw); i++ {
|
||||||
@@ -219,10 +228,10 @@ func SanitizePlain(in string) string {
|
|||||||
if in[i] != '<' {
|
if in[i] != '<' {
|
||||||
j := strings.IndexByte(in[i:], '<')
|
j := strings.IndexByte(in[i:], '<')
|
||||||
if j < 0 {
|
if j < 0 {
|
||||||
out.WriteString(html.EscapeString(in[i:]))
|
out.WriteString(escapeText(in[i:]))
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
out.WriteString(html.EscapeString(in[i : i+j]))
|
out.WriteString(escapeText(in[i : i+j]))
|
||||||
i += j
|
i += j
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ func TestSanitizeHTML(t *testing.T) {
|
|||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"plain text escaped", `hello <world> & "friends"`, "hello & "friends""},
|
{"plain text escaped", `hello <world> & "friends"`, "hello & "friends""},
|
||||||
|
{"existing entity not double-escaped", `a & b <ok>`, "a & b <ok>"},
|
||||||
|
{"mention span kept with sanitized uid", `<span class="mention" data-user-card="01ABCdef">@Jane Doe</span> hi`,
|
||||||
|
`<span class="mention" data-user-card="01ABCdef">@Jane Doe</span> hi`},
|
||||||
|
{"non-mention span dropped", `<span style="x">keep text</span>`, "keep text"},
|
||||||
{"allowed formatting kept", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>"},
|
{"allowed formatting kept", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>", "<b>bold</b> <i>it</i> <u>u</u> <s>s</s>"},
|
||||||
{"paragraph and lists", "<p>a</p><ul><li>x</li><li>y</li></ul>", "<p>a</p><ul><li>x</li><li>y</li></ul>"},
|
{"paragraph and lists", "<p>a</p><ul><li>x</li><li>y</li></ul>", "<p>a</p><ul><li>x</li><li>y</li></ul>"},
|
||||||
{"code and pre", "<pre><code>x := 1</code></pre>", "<pre><code>x := 1</code></pre>"},
|
{"code and pre", "<pre><code>x := 1</code></pre>", "<pre><code>x := 1</code></pre>"},
|
||||||
|
|||||||
Reference in New Issue
Block a user