feat(accounts): validate account-name characters server-side

The endpoint only checked the root prefix, so a direct API call (bypassing
the UI) could write a malformed Open directive into the ledger source.
Add _validate_account_name mirroring Beancount's core/account.py grammar
(root [\p{Lu}][\p{L}\p{Nd}-]*, sub [\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*, >=1
sub-account) — verified to match beancount.core.account.is_valid across
20 cases incl. Unicode, digit-start subs, hyphens. Align the client
segment regex to the same rule (was ASCII-only, rejected valid names).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Padreug 2026-06-16 22:55:45 +02:00
commit cd5a6edb7d
2 changed files with 52 additions and 3 deletions

View file

@ -596,14 +596,15 @@ window.app = Vue.createApp({
return return
} }
// Each segment under the root must be a valid Beancount account // Each segment under the root must be a valid Beancount account
// component: start with an uppercase letter, then letters/digits/hyphens. // component (core/account.py ACC_COMP_NAME_RE): starts with an uppercase
// letter or digit, then letters/digits/hyphens (Unicode letters allowed).
const badSegment = name.split(':').slice(1).find( const badSegment = name.split(':').slice(1).find(
seg => !/^[A-Z][A-Za-z0-9-]*$/.test(seg) seg => !/^[\p{Lu}\p{Nd}][\p{L}\p{Nd}-]*$/u.test(seg)
) )
if (badSegment !== undefined) { if (badSegment !== undefined) {
this.$q.notify({ this.$q.notify({
type: 'warning', type: 'warning',
message: `Invalid segment "${badSegment}" — each part must start with a capital letter (letters, digits, hyphens only)` message: `Invalid segment "${badSegment}" — letters, digits and hyphens only, starting with a capital letter or digit`
}) })
return return
} }

View file

@ -3661,6 +3661,52 @@ async def api_get_account_hierarchy(
_VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:") _VALID_ACCOUNT_PREFIXES = ("Assets:", "Liabilities:", "Equity:", "Income:", "Expenses:")
def _is_valid_account_component(component: str, *, is_root: bool) -> bool:
"""Validate one ':'-separated account component against Beancount's grammar.
Mirrors core/account.py: a root component matches ``[\\p{Lu}][\\p{L}\\p{Nd}-]*``
(must start with an uppercase letter); a sub component matches
``[\\p{Lu}\\p{Nd}][\\p{L}\\p{Nd}-]*`` (may also start with a digit). Body
chars are letters, decimal digits, or hyphen. Implemented with Unicode-aware
str methods (libra's runtime has no beancount — Fava is a separate service),
so non-ASCII letters are accepted exactly as Beancount accepts them.
"""
if not component:
return False
first, rest = component[0], component[1:]
first_ok = (first.isalpha() and first.isupper()) or (
not is_root and first.isdecimal()
)
if not first_ok:
return False
return all(ch == "-" or ch.isalpha() or ch.isdecimal() for ch in rest)
def _validate_account_name(name: str) -> None:
"""Raise HTTP 400 if ``name`` is not a syntactically valid Beancount account.
The UI guards this client-side, but the endpoint is reachable directly via
API, so this is the load-bearing check before the name is written into the
ledger source. Requires a root plus at least one sub-component.
"""
parts = name.split(":")
valid = (
len(parts) >= 2
and _is_valid_account_component(parts[0], is_root=True)
and all(_is_valid_account_component(p, is_root=False) for p in parts[1:])
)
if not valid:
raise HTTPException(
status_code=HTTPStatus.BAD_REQUEST,
detail=(
f"Invalid account name {name!r}: each ':'-separated part must be "
"letters/digits/hyphens, the root starting with an uppercase "
"letter (sub-accounts may start with a digit), with at least one "
"sub-account (e.g. Expenses:Food)."
),
)
@libra_api_router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED) @libra_api_router.post("/api/v1/admin/accounts", status_code=HTTPStatus.CREATED)
async def api_admin_add_chart_account( async def api_admin_add_chart_account(
payload: CreateChartAccount, payload: CreateChartAccount,
@ -3685,6 +3731,8 @@ async def api_admin_add_chart_account(
), ),
) )
_validate_account_name(payload.name)
logger.info( logger.info(
f"Admin {auth.user_id[:8]} adding chart account {payload.name} " f"Admin {auth.user_id[:8]} adding chart account {payload.name} "
f"with currencies {payload.currencies}" f"with currencies {payload.currencies}"