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
+10 -5
View File
@@ -250,6 +250,11 @@ func validHint(s string) bool {
return utf8.ValidString(s) && utf8.RuneCountInString(s) <= 200
}
// validName bounds registry display names at the 200 runes every UI form
// already enforces, so no client can persist an unbounded name that every
// later state response would carry.
func validName(s string) bool { return nonblank(s) && utf8.RuneCountInString(s) <= 200 }
// ValidISIN reports a syntactically valid ISIN: two country letters, nine
// alphanumerics and a check digit.
func ValidISIN(s string) bool { return isinPattern.MatchString(s) }
@@ -292,7 +297,7 @@ func Validate(d Dataset) error {
if err := register(c.ID, "category"); err != nil {
return err
}
if !nonblank(c.Name) || !validHint(c.Hint) || (c.Kind != "expense" && c.Kind != "income") {
if !validName(c.Name) || !validHint(c.Hint) || (c.Kind != "expense" && c.Kind != "income") {
return fmt.Errorf("category %q: invalid name, hint or kind", c.ID)
}
categories[c.ID] = c
@@ -330,7 +335,7 @@ func Validate(d Dataset) error {
if err := register(t.ID, "tag"); err != nil {
return err
}
if !nonblank(t.Name) || !validHint(t.Hint) {
if !validName(t.Name) || !validHint(t.Hint) {
return fmt.Errorf("tag %q: name or hint invalid", t.ID)
}
tags[t.ID] = true
@@ -339,8 +344,8 @@ func Validate(d Dataset) error {
if err := register(m.ID, "merchant"); err != nil {
return err
}
if !nonblank(m.Name) {
return fmt.Errorf("merchant %q: name required", m.ID)
if !validName(m.Name) {
return fmt.Errorf("merchant %q: valid name of at most 200 characters required", m.ID)
}
if m.DefaultCategoryID != "" {
if _, ok := categories[m.DefaultCategoryID]; !ok || children[m.DefaultCategoryID] {
@@ -375,7 +380,7 @@ func Validate(d Dataset) error {
if other, ok := isins[v.ISIN]; ok {
return fmt.Errorf("instrument %q: ISIN %s already held by %q", v.ID, v.ISIN, other)
}
if !nonblank(v.Name) || !currencyPattern.MatchString(v.Currency) || !validText(v.Symbol) {
if !validName(v.Name) || !currencyPattern.MatchString(v.Currency) || !validText(v.Symbol) {
return fmt.Errorf("instrument %q: valid UTF-8 name and symbol and three-letter uppercase currency required", v.ID)
}
// A quote without its day cannot be judged stale, and a day without a
+2
View File
@@ -75,6 +75,8 @@ func TestDomainRejectsBrokenReferencesAndTaxonomy(t *testing.T) {
{"duplicate identity", func(d *Dataset) { d.Tags[0].ID = "acc_main" }},
{"invalid provenance date", func(d *Dataset) { d.Transactions[0].Enrichment.Classification.Timestamp = "yesterday" }},
{"nonleaf merchant default", func(d *Dataset) { d.Merchants[0].DefaultCategoryID = "cat_food" }},
{"oversized tag name", func(d *Dataset) { d.Tags[0].Name = strings.Repeat("x", 201) }},
{"oversized category name", func(d *Dataset) { d.Categories[2].Name = strings.Repeat("x", 201) }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {