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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user