Skip to content

feat(users): validate usernames before creating or renaming - #610

Open
jang-hs wants to merge 2 commits into
tale:mainfrom
jang-hs:feat/validate-usernames
Open

feat(users): validate usernames before creating or renaming#610
jang-hs wants to merge 2 commits into
tale:mainfrom
jang-hs:feat/validate-usernames

Conversation

@jang-hs

@jang-hs jang-hs commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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 own util.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 validateUsername in app/utils/user.ts mirroring Headscale's util.ValidateUsername:

  • at least 2 characters
  • starts with a letter
  • only letters, numbers, ., -, _
  • at most one @

plus one Headplane-specific rule: the @ may not be the last character, for the reason above.

The regex is exported as USERNAME_PATTERN and reused in three places from one definition:

  • native pattern / minLength on 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 error
  • the create_user and rename_user cases in app/routes/users/user-actions.ts, since the form is not a trust boundary
  • USERNAME_RULE is shown as the field description so the rule is visible before typing

This also resolves the existing // TODO: Server side validation before submitting in rename-user.tsx.

Unicode letters/numbers (\p{L}, \p{N}) are used rather than ASCII ranges so names like josé keep working, matching Go's unicode.IsLetter.

Notes

  • Deliberately not stricter than Headscale anywhere except the trailing @, so this cannot reject a name that currently works.
  • Duplicate-name detection is left to the Headscale API.

Testing

  • pnpm run test:unit — 214 passed, including new cases in tests/unit/utils/user.test.ts covering lm@, a@b@c, 1abc, @abc, spaces, slashes, and the accepted forms. One test compiles USERNAME_PATTERN with the v flag to confirm it is valid as an HTML pattern attribute.
  • pnpm run typecheck, pnpm run lint, pnpm run format all clean.

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.
@jang-hs
jang-hs requested a review from tale as a code owner August 19, 2026 12:41

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No critical issues — one minor note inline about regex fidelity to Headscale's own validator.

Reviewed changes

  • Shared username validator — new validateUsername and exported USERNAME_PATTERN / USERNAME_RULE in app/utils/user.ts mirroring Headscale's util.ValidateUsername, plus a Headplane-specific rule that @ may not be the last character.
  • Create/rename dialogscreate-user.tsx and rename-user.tsx wire pattern, minLength, title, and description onto the username Input so invalid names are blocked inline.
  • Server-side gatecreate_user and rename_user in user-actions.ts call validateUsername and throw data(..., 400) before touching the API.
  • Tests — unit coverage for accepted/rejected forms plus a v-flag compile check confirming USERNAME_PATTERN is a valid HTML pattern attribute.

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.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

Comment thread app/utils/user.ts Outdated
…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.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found.

Reviewed changes

  • Narrowed username digits to \p{Nd}USERNAME_PATTERN now uses \p{Nd} instead of \p{N}, making it a true mirror of Go's unicode.IsDigit (decimal digits only). Names like ab² and abⅫ (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_PATTERN now states \p{L}/\p{Nd} correspond to unicode.IsLetter/IsDigit.
  • Added rejection test casesab² and abⅫ added to the validateUsername reject set, with a comment noting they are Nl/No.

This directly resolves the prior note about regex fidelity to Headscale's own validator.

Pullfrog  | View workflow run | Using DeepSeek Pro (free via Pullfrog for OSS) | 𝕏

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Support for Validating Usernames

1 participant