security: switch default hashing to Argon2id, fix tests
- hashPassword now uses Argon2id (memory-hard, GPU-resistant) via hash-wasm - verifyPassword checks both Argon2id and bcrypt - Legacy hashes (bcrypt, argon2, md5, sha1, sha256, sha512, combined, salted) auto-migrate to Argon2id on successful login - Updated all password tests to expect Argon2id format - Register validation: min 12 chars, max 128, upper+lower+digit+special required - Username restricted to [A-Za-z0-9_-], reserved names blocked - Disposable email domains blocked - Fixed parameter names for hash-wasm argon2id API (memorySize, iterations, parallelism, hashLength)
This commit is contained in:
1 parent
ac60a867d9
commit
b13b3a50ff
4 files changed
+117
-63
No files matched your search
@@ -139,9 +139,9 @@ describe("register", () => {
|
||||
expect(state.hashPassword).toHaveBeenCalledWith("Secret1234!@");
|
||||
expect(state.insert).toHaveBeenCalledOnce();
|
||||
expect(state.insert.mock.calls[0][0]).toBe(User);
|
||||
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||
username: "Alice_123",
|
||||
password: "hashed:Secret1234!@",
|
||||
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||
username: "Alice_123",
|
||||
password: "hashed:Secret1234!@",
|
||||
mail: null,
|
||||
accountCreated: expect.any(Number),
|
||||
ipRegister: "203.0.113.9",
|
||||
@@ -214,7 +214,10 @@ expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||
it("rejects passwords without an uppercase letter", async () => {
|
||||
const result = await register(
|
||||
PREV,
|
||||
buildForm({ password: "secret1234!@", password_confirmation: "secret1234!@" }),
|
||||
buildForm({
|
||||
password: "secret1234!@",
|
||||
password_confirmation: "secret1234!@",
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("uppercase");
|
||||
});
|
||||
|
||||
+86
-36
@@ -16,24 +16,63 @@ import { siteSettings } from "@/lib/services/site-settings";
|
||||
|
||||
// Reserved usernames that can never be registered (prevent impersonation/admin confusion).
|
||||
const RESERVED_USERNAMES = new Set([
|
||||
"admin", "root", "system", "moderator", "mod", "staff", "support",
|
||||
"help", "service", "api", "webmaster", "postmaster", "hostmaster",
|
||||
"administrator", "superuser", "sysadmin", "nobody", "anonymous",
|
||||
"guest", "default", "test", "demo", "example", "info", "security",
|
||||
"abuse", "noreply", "donotreply", "bot", "crawler", "indexer",
|
||||
"admin",
|
||||
"root",
|
||||
"system",
|
||||
"moderator",
|
||||
"mod",
|
||||
"staff",
|
||||
"support",
|
||||
"help",
|
||||
"service",
|
||||
"api",
|
||||
"webmaster",
|
||||
"postmaster",
|
||||
"hostmaster",
|
||||
"administrator",
|
||||
"superuser",
|
||||
"sysadmin",
|
||||
"nobody",
|
||||
"anonymous",
|
||||
"guest",
|
||||
"default",
|
||||
"test",
|
||||
"demo",
|
||||
"example",
|
||||
"info",
|
||||
"security",
|
||||
"abuse",
|
||||
"noreply",
|
||||
"donotreply",
|
||||
"bot",
|
||||
"crawler",
|
||||
"indexer",
|
||||
]);
|
||||
|
||||
// Disposable/temporary email domains (subset, expandable via settings).
|
||||
const DISPOSABLE_EMAIL_DOMAINS = new Set([
|
||||
"10minutemail.com", "guerrillamail.com", "mailinator.com",
|
||||
"tempmail.com", "throwawaymail.com", "yopmail.com", "trashmail.com",
|
||||
"fakeinbox.com", "spamgourmet.com", "getnada.com", "maildrop.cc",
|
||||
"10minutemail.com",
|
||||
"guerrillamail.com",
|
||||
"mailinator.com",
|
||||
"tempmail.com",
|
||||
"throwawaymail.com",
|
||||
"yopmail.com",
|
||||
"trashmail.com",
|
||||
"fakeinbox.com",
|
||||
"spamgourmet.com",
|
||||
"getnada.com",
|
||||
"maildrop.cc",
|
||||
]);
|
||||
|
||||
function isReservedUsername(username: string): boolean {
|
||||
const lower = username.toLowerCase();
|
||||
if (RESERVED_USERNAMES.has(lower)) return true;
|
||||
if (lower.startsWith("admin") || lower.startsWith("mod") || lower.startsWith("staff")) return true;
|
||||
if (
|
||||
lower.startsWith("admin") ||
|
||||
lower.startsWith("mod") ||
|
||||
lower.startsWith("staff")
|
||||
)
|
||||
return true;
|
||||
if (/^(x|www|mail|ftp|smtp|pop|imap|dns|ns[0-9]*)$/.test(lower)) return true;
|
||||
return false;
|
||||
}
|
||||
@@ -43,33 +82,44 @@ function hasDisposableEmailDomain(email: string): boolean {
|
||||
return domain ? DISPOSABLE_EMAIL_DOMAINS.has(domain) : false;
|
||||
}
|
||||
|
||||
const registerSchema = z.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(25, "Username must be at most 25 characters")
|
||||
.regex(/^[A-Za-z0-9_-]+$/, "Username may only contain letters, numbers, underscore and hyphen")
|
||||
.refine((u) => !isReservedUsername(u), "This username is reserved"),
|
||||
mail: z
|
||||
.string()
|
||||
.email("Enter a valid email address")
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.refine((e) => !e || !hasDisposableEmailDomain(e), "Temporary email domains are not allowed"),
|
||||
password: z
|
||||
.string()
|
||||
.min(12, "Password must be at least 12 characters") // Increased min length
|
||||
.max(128, "Password is too long") // Added max length
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one digit")
|
||||
.regex(/[^A-Za-z0-9]/, "Password must contain at least one special character"), // Added special character requirement
|
||||
passwordConfirmation: z.string(),
|
||||
look: z.string().optional(),
|
||||
}).refine((data) => data.password === data.passwordConfirmation, {
|
||||
message: "Passwords do not match",
|
||||
path: ["passwordConfirmation"],
|
||||
});
|
||||
const registerSchema = z
|
||||
.object({
|
||||
username: z
|
||||
.string()
|
||||
.min(3, "Username must be at least 3 characters")
|
||||
.max(25, "Username must be at most 25 characters")
|
||||
.regex(
|
||||
/^[A-Za-z0-9_-]+$/,
|
||||
"Username may only contain letters, numbers, underscore and hyphen",
|
||||
)
|
||||
.refine((u) => !isReservedUsername(u), "This username is reserved"),
|
||||
mail: z
|
||||
.string()
|
||||
.email("Enter a valid email address")
|
||||
.optional()
|
||||
.or(z.literal(""))
|
||||
.refine(
|
||||
(e) => !e || !hasDisposableEmailDomain(e),
|
||||
"Temporary email domains are not allowed",
|
||||
),
|
||||
password: z
|
||||
.string()
|
||||
.min(12, "Password must be at least 12 characters") // Increased min length
|
||||
.max(128, "Password is too long") // Added max length
|
||||
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
||||
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
||||
.regex(/[0-9]/, "Password must contain at least one digit")
|
||||
.regex(
|
||||
/[^A-Za-z0-9]/,
|
||||
"Password must contain at least one special character",
|
||||
), // Added special character requirement
|
||||
passwordConfirmation: z.string(),
|
||||
look: z.string().optional(),
|
||||
})
|
||||
.refine((data) => data.password === data.passwordConfirmation, {
|
||||
message: "Passwords do not match",
|
||||
path: ["passwordConfirmation"],
|
||||
});
|
||||
|
||||
// A valid starter Habbo figure so the avatar renders in-client immediately.
|
||||
const DEFAULT_LOOK = "hr-100-.hd-180-1.ch-255-66.lg-280-110.sh-305-62";
|
||||
|
||||
Reference in new issue
Block a user