diff --git a/.gitignore b/.gitignore index c1a4ed7..2e76203 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ backups/ coverage.out # Docker volume data (bind-mounted under the repo; never committed) -/volumes/ +/.volumes/ diff --git a/Makefile b/Makefile index 793f92e..efcc387 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ test-contract: $(GO) test -tags=contract -count=1 -timeout=8m ./internal/contract/ 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 $(GO) vet ./... diff --git a/docker-compose.yml b/docker-compose.yml index 28c0557..40ed582 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,8 +13,8 @@ services: # Mongo is reachable only on the compose network; never published to the # host (§12). volumes: - # data lives in-repo (gitignored) so all state is under one tree - - ./volumes/mongo:/data/db + # data lives in-repo (gitignored, dot-prefixed so go tooling skips it) + - ./.volumes/mongo:/data/db healthcheck: test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] interval: 10s @@ -83,7 +83,7 @@ services: volumes: - ${HOME}/.claude:/home/node/.claude - ${HOME}/.claude.json:/home/node/.claude.json - - ./volumes/work:/work + - ./.volumes/work:/work ports: - "127.0.0.1:8091:8091" extra_hosts: diff --git a/internal/chat/sanitize.go b/internal/chat/sanitize.go index 7826e74..7bb1cc7 100644 --- a/internal/chat/sanitize.go +++ b/internal/chat/sanitize.go @@ -31,10 +31,10 @@ func SanitizeHTML(in string) string { if c != '<' { j := strings.IndexByte(in[i:], '<') if j < 0 { - out.WriteString(html.EscapeString(in[i:])) + out.WriteString(escapeText(in[i:])) break } - out.WriteString(html.EscapeString(in[i : i+j])) + out.WriteString(escapeText(in[i : i+j])) i += j continue } @@ -118,6 +118,15 @@ func SanitizeHTML(in string) 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. func splitTag(raw string) (string, string) { for i := 0; i < len(raw); i++ { @@ -219,10 +228,10 @@ func SanitizePlain(in string) string { if in[i] != '<' { j := strings.IndexByte(in[i:], '<') if j < 0 { - out.WriteString(html.EscapeString(in[i:])) + out.WriteString(escapeText(in[i:])) break } - out.WriteString(html.EscapeString(in[i : i+j])) + out.WriteString(escapeText(in[i : i+j])) i += j continue } diff --git a/internal/chat/sanitize_test.go b/internal/chat/sanitize_test.go index c4d157d..53a415e 100644 --- a/internal/chat/sanitize_test.go +++ b/internal/chat/sanitize_test.go @@ -12,6 +12,10 @@ func TestSanitizeHTML(t *testing.T) { want string }{ {"plain text escaped", `hello & "friends"`, "hello & "friends""}, + {"existing entity not double-escaped", `a & b <ok>`, "a & b <ok>"}, + {"mention span kept with sanitized uid", `@Jane Doe hi`, + `@Jane Doe hi`}, + {"non-mention span dropped", `keep text`, "keep text"}, {"allowed formatting kept", "bold it u s", "bold it u s"}, {"paragraph and lists", "

a

", "

a

"}, {"code and pre", "
x := 1
", "
x := 1
"},