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:
parent
051c9f0c22
commit
cd5a6edb7d
2 changed files with 52 additions and 3 deletions
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
views_api.py
48
views_api.py
|
|
@ -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}"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue