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

This commit is contained in:
Jade
2026-08-25 14:04:49 +09:00
committed by GitHub
parent a5b6ecf28e
commit 03ce112457
5 changed files with 78 additions and 3 deletions
+12 -1
View File
@@ -3,6 +3,7 @@ import Dialog, { DialogPanel } from "~/components/dialog";
import Input from "~/components/input"; import Input from "~/components/input";
import Text from "~/components/text"; import Text from "~/components/text";
import Title from "~/components/title"; import Title from "~/components/title";
import { USERNAME_PATTERN, USERNAME_RULE } from "~/utils/user";
interface CreateUserProps { interface CreateUserProps {
isOidc?: boolean; isOidc?: boolean;
@@ -23,7 +24,17 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
</Text> </Text>
<input name="action_id" type="hidden" value="create_user" /> <input name="action_id" type="hidden" value="create_user" />
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Input required label="Username" name="username" placeholder="my-new-user" type="text" /> <Input
description={USERNAME_RULE}
minLength={2}
pattern={USERNAME_PATTERN}
required
title={USERNAME_RULE}
label="Username"
name="username"
placeholder="my-new-user"
type="text"
/>
<Input label="Display Name" name="display_name" placeholder="John Doe" type="text" /> <Input label="Display Name" name="display_name" placeholder="John Doe" type="text" />
<Input label="Email" name="email" placeholder="name@example.com" type="email" /> <Input label="Email" name="email" placeholder="name@example.com" type="email" />
</div> </div>
+5 -1
View File
@@ -3,6 +3,7 @@ import Input from "~/components/input";
import Text from "~/components/text"; import Text from "~/components/text";
import Title from "~/components/title"; import Title from "~/components/title";
import { User } from "~/types"; import { User } from "~/types";
import { USERNAME_PATTERN, USERNAME_RULE } from "~/utils/user";
interface RenameProps { interface RenameProps {
user: User; user: User;
@@ -10,7 +11,6 @@ interface RenameProps {
setIsOpen: (isOpen: boolean) => void; setIsOpen: (isOpen: boolean) => void;
} }
// TODO: Server side validation before submitting
export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) { export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
return ( return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
@@ -24,7 +24,11 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
<input name="headscale_user_id" type="hidden" value={user.id} /> <input name="headscale_user_id" type="hidden" value={user.id} />
<Input <Input
defaultValue={user.name} defaultValue={user.name}
description={USERNAME_RULE}
minLength={2}
pattern={USERNAME_PATTERN}
required required
title={USERNAME_RULE}
label="Username" label="Username"
name="new_name" name="new_name"
placeholder="my-new-name" placeholder="my-new-name"
+11
View File
@@ -5,6 +5,7 @@ import { usersResource } from "~/server/headscale/live-store";
import { isUserPrincipal } from "~/server/web/auth"; import { isUserPrincipal } from "~/server/web/auth";
import { Capabilities } from "~/server/web/roles"; import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles"; import type { Role } from "~/server/web/roles";
import { validateUsername } from "~/utils/user";
import type { Route } from "./+types/overview"; import type { Route } from "./+types/overview";
@@ -42,6 +43,11 @@ export async function userAction({ request, context }: Route.ActionArgs) {
}); });
} }
const nameError = validateUsername(name);
if (nameError) {
throw data(nameError, { status: 400 });
}
await api.users.create({ name, email, displayName }); await api.users.create({ name, email, displayName });
await headscaleLiveStore.refresh(usersResource, api); await headscaleLiveStore.refresh(usersResource, api);
return { message: "User created successfully" }; return { message: "User created successfully" };
@@ -65,6 +71,11 @@ export async function userAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 400); return data({ success: false }, 400);
} }
const newNameError = validateUsername(newName);
if (newNameError) {
throw data(newNameError, { status: 400 });
}
const users = await api.users.list({ id: headscaleUserId }); const users = await api.users.list({ id: headscaleUserId });
const user = users.find((user) => user.id === headscaleUserId); const user = users.find((user) => user.id === headscaleUserId);
if (!user) { if (!user) {
+22
View File
@@ -7,3 +7,25 @@ export function getUserDisplayName(user: User): string {
return user.name || user.displayName || user.email || user.id; return user.name || user.displayName || user.email || user.id;
} }
// Mirrors Headscale's `util.ValidateUsername` (hscontrol/util/dns.go): a name has
// to start with a letter and may only contain letters, numbers, `-`, `.`, `_` and
// at most one `@` (`\p{L}`/`\p{Nd}` match Go's `unicode.IsLetter`/`IsDigit`).
// The trailing `@` is ours: Headscale strips it when resolving a
// username in an ACL policy, so a user whose name ends in `@` is created fine but
// can never be matched by a rule.
export const USERNAME_PATTERN = String.raw`\p{L}[\p{L}\p{Nd}._\-]*(@[\p{L}\p{Nd}._\-]+)?`;
export const USERNAME_RULE =
"Usernames must be at least 2 characters, start with a letter, and contain only " +
"letters, numbers, dots, dashes and underscores, with at most one @ that cannot " +
"be the last character.";
const usernameRegex = new RegExp(`^${USERNAME_PATTERN}$`, "u");
// Returns an error message when the username is not usable in Headscale.
export function validateUsername(name: string): string | undefined {
if (name.length < 2 || !usernameRegex.test(name)) {
return USERNAME_RULE;
}
}
+28 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { User } from "~/types/User"; import type { User } from "~/types/User";
import { getUserDisplayName } from "~/utils/user"; import { getUserDisplayName, USERNAME_PATTERN, validateUsername } from "~/utils/user";
const makeUser = (overrides: Partial<User>): User => ({ const makeUser = (overrides: Partial<User>): User => ({
id: "default-id", id: "default-id",
@@ -56,3 +56,30 @@ describe("getUserDisplayName", () => {
expect(getUserDisplayName(user)).toBe("John Doe"); expect(getUserDisplayName(user)).toBe("John Doe");
}); });
}); });
describe("validateUsername", () => {
test.each(["ab", "john_doe", "user-1.name", "alice@example.com", "josé"])(
"accepts %s",
(name) => {
expect(validateUsername(name)).toBeUndefined();
},
);
// "lm@" is the case from #502: Headscale creates it, but policies strip the
// trailing "@" so no rule can ever match the user.
// `ab²`/`abⅫ` are Nl/No, which Go's unicode.IsDigit rejects.
test.each(["", "a", "1abc", "@abc", "lm@", "a@b@c", "bad name", "bad/name", "ab²", "abⅫ"])(
"rejects %s",
(name) => {
expect(validateUsername(name)).toBeTypeOf("string");
},
);
});
describe("USERNAME_PATTERN", () => {
test("is a valid HTML pattern (unicode sets mode)", () => {
const regex = new RegExp(`^${USERNAME_PATTERN}$`, "v");
expect(regex.test("alice@example.com")).toBe(true);
expect(regex.test("lm@")).toBe(false);
});
});