Enforce name limits server-side and reject hidden runes in model names

Security review follow-ups. The 200-character registry-name cap the UI
forms promise now holds in domain.Validate for categories, tags,
merchants and instruments, so a non-browser client cannot persist an
unbounded name that every subsequent state response would carry. And a
model-supplied merchant or taxonomy name containing control or format
code points — bidi overrides, zero-width characters — is dropped like
an identifier-shaped one: React escaping already prevented injection,
but such names could visually spoof or reorder the review UI the
operator approves from.
This commit is contained in:
Lars Nolden
2026-09-14 12:30:13 +02:00
parent 676065292e
commit b7e5bf26cc
5 changed files with 28 additions and 12 deletions
+14 -5
View File
@@ -12,6 +12,7 @@ import (
"strings"
"sync/atomic"
"time"
"unicode"
"unicode/utf8"
"finance-duck/internal/domain"
@@ -251,6 +252,13 @@ func (c *Client) Classify(ctx context.Context, facts domain.Facts, data domain.D
return result, nil
}
// hasHiddenRunes reports control or format code points — bidi overrides,
// zero-width characters — that would let model-supplied text spoof or
// reorder review UI. Legitimate payee names never need them.
func hasHiddenRunes(s string) bool {
return strings.ContainsFunc(s, func(r rune) bool { return unicode.IsControl(r) || unicode.Is(unicode.Cf, r) })
}
// resolveAnswer maps one schema-valid provider answer onto enrichment,
// revalidating every id against the local registry. proposed collects newly
// minted merchants by normalized name so several rows resolved against the
@@ -280,11 +288,12 @@ func resolveAnswer(answer answer, facts domain.Facts, data domain.Dataset, candi
}
if answer.NewMerchant != nil {
name := strings.Join(strings.Fields(*answer.NewMerchant), " ")
// An identifier-shaped or oversized name is dropped, never stored, but
// the row keeps its independently enum-validated category and tags: a
// legitimate payee whose spelling trips the redactor (observed in the
// field) must not lose its whole classification.
if !utf8.ValidString(name) || utf8.RuneCountInString(name) > 100 || normalize(name) == "" || normalize(clean(name)) != normalize(name) {
// An identifier-shaped, oversized or hidden-rune name is dropped,
// never stored, but the row keeps its independently enum-validated
// category and tags: a legitimate payee whose spelling trips the
// redactor (observed in the field) must not lose its whole
// classification.
if !utf8.ValidString(name) || utf8.RuneCountInString(name) > 100 || normalize(name) == "" || normalize(clean(name)) != normalize(name) || hasHiddenRunes(name) {
// no merchant
} else if existing := duplicateMerchant(name, data.Merchants); existing != nil {
e.MerchantID = existing.ID