feat(users): validate usernames before creating or renaming - #610
feat(users): validate usernames before creating or renaming#610jang-hs wants to merge 2 commits into
Conversation
Headscale accepts usernames that can never be referenced from an ACL policy, so a typo like `lm@` silently produces a user that no rule will ever match (tale#502). Add a shared `validateUsername` helper mirroring Headscale's `util.ValidateUsername` (at least two characters, starts with a letter, only letters/numbers/`-`/`.`/`_` and at most one `@`), plus a trailing `@` check since Headscale strips it when resolving usernames in policies. The same pattern is used as a native `pattern`/`minLength` constraint on the create and rename dialogs so the browser blocks the submit inline, and re-checked in the users action since the form is not a trust boundary. This also resolves the existing TODO in the rename dialog.
There was a problem hiding this comment.
ℹ️ No critical issues — one minor note inline about regex fidelity to Headscale's own validator.
Reviewed changes
- Shared username validator — new
validateUsernameand exportedUSERNAME_PATTERN/USERNAME_RULEinapp/utils/user.tsmirroring Headscale'sutil.ValidateUsername, plus a Headplane-specific rule that@may not be the last character. - Create/rename dialogs —
create-user.tsxandrename-user.tsxwirepattern,minLength,title, anddescriptiononto the usernameInputso invalid names are blocked inline. - Server-side gate —
create_userandrename_userinuser-actions.tscallvalidateUsernameandthrow data(..., 400)before touching the API. - Tests — unit coverage for accepted/rejected forms plus a
v-flag compile check confirmingUSERNAME_PATTERNis a valid HTMLpatternattribute.
I verified the two load-bearing claims against Headscale's source: util.ValidateUsername in hscontrol/util/dns.go matches the stated rules, and Username.resolveUser in hscontrol/policy/v2/types.go does strings.TrimSuffix(u.String(), "@") before matching, which confirms the trailing-@ bug from #502 and justifies the extra rule.
DeepSeek Pro (free via Pullfrog for OSS) | 𝕏
…Digit
\p{N} also covers Nl/No (e.g. `ab²`, `ab`), which Headscale's
util.ValidateUsername rejects via unicode.IsDigit. Narrow the pattern so
it is a true mirror instead of being more permissive than the API.
There was a problem hiding this comment.
✅ No new issues found.
Reviewed changes
- Narrowed username digits to
\p{Nd}—USERNAME_PATTERNnow uses\p{Nd}instead of\p{N}, making it a true mirror of Go'sunicode.IsDigit(decimal digits only). Names likeab²andabⅫ(Nl/No number categories) are now rejected up front rather than surfacing as a Headscale API error later. - Updated mirror comment — the comment on
USERNAME_PATTERNnow states\p{L}/\p{Nd}correspond tounicode.IsLetter/IsDigit. - Added rejection test cases —
ab²andabⅫadded to thevalidateUsernamereject set, with a comment noting they are Nl/No.
This directly resolves the prior note about regex fidelity to Headscale's own validator.
DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

Closes #502.
Problem
Headscale will happily create a user whose name can never be referenced from an ACL policy. The report in #502 is
lm@: it passes Headscale's ownutil.ValidateUsername, but policy resolution trims a trailing@from a username token before matching (hscontrol/policy/v2), so no rule will ever resolve to that user. Headplane sends whatever is typed straight to the API, so there is nothing to catch the typo.Change
Adds a shared
validateUsernameinapp/utils/user.tsmirroring Headscale'sutil.ValidateUsername:.,-,_@plus one Headplane-specific rule: the
@may not be the last character, for the reason above.The regex is exported as
USERNAME_PATTERNand reused in three places from one definition:pattern/minLengthon the Create user and Rename user dialog inputs, so the browser blocks the submit inline and the dialog stays open — no round trip, no page-level errorcreate_userandrename_usercases inapp/routes/users/user-actions.ts, since the form is not a trust boundaryUSERNAME_RULEis shown as the field description so the rule is visible before typingThis also resolves the existing
// TODO: Server side validation before submittinginrename-user.tsx.Unicode letters/numbers (
\p{L},\p{N}) are used rather than ASCII ranges so names likejosékeep working, matching Go'sunicode.IsLetter.Notes
@, so this cannot reject a name that currently works.Testing
pnpm run test:unit— 214 passed, including new cases intests/unit/utils/user.test.tscoveringlm@,a@b@c,1abc,@abc, spaces, slashes, and the accepted forms. One test compilesUSERNAME_PATTERNwith thevflag to confirm it is valid as an HTMLpatternattribute.pnpm run typecheck,pnpm run lint,pnpm run formatall clean.