security: harden authentication (register/login)
- Username: restrict to [A-Za-z0-9_-], block reserved names (admin, mod, root, etc.), normalize NFC - Password: min 12, max 128, require upper+lower+digit+special char - Email: block disposable/temporary domains (mailinator, yopmail, etc.) - Hashing: switch to Argon2id (memory-hard) via hash-wasm argon2id API - Legacy hash migration: argon2/bcrypt/md5/sha1/sha256/sha512/salted/combined auto-upgrade to Argon2id on successful login - Rate limits: 5/10min register, 10/5min login precheck per IP - VPN/proxy block (configurable via /admin/vpn) - Timing attack mitigation: dummy bcrypt hash for non-existent users - Fixed typo in error message (R3 -> 3) - Updated register.test.ts to match new validation rules
This commit is contained in:
1 parent
6dc80b0b05
commit
ac60a867d9
4 files changed
+83
-22
No files matched your search
+40
-8
@@ -14,25 +14,61 @@ import { checkVpn } from "@/lib/services/ip-lookup";
|
||||
import { recordReferral } from "@/lib/services/referrals";
|
||||
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",
|
||||
]);
|
||||
|
||||
// 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",
|
||||
]);
|
||||
|
||||
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 (/^(x|www|mail|ftp|smtp|pop|imap|dns|ns[0-9]*)$/.test(lower)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasDisposableEmailDomain(email: string): boolean {
|
||||
const domain = email.split("@")[1]?.toLowerCase();
|
||||
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 contains invalid 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("")),
|
||||
.or(z.literal(""))
|
||||
.refine((e) => !e || !hasDisposableEmailDomain(e), "Temporary email domains are not allowed"),
|
||||
password: z
|
||||
.string()
|
||||
.min(8, "Password must be at least 8 characters")
|
||||
.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(/[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.
|
||||
@@ -72,10 +108,6 @@ export async function register(
|
||||
return fail(parsed.error.issues[0]?.message ?? "Invalid input");
|
||||
}
|
||||
|
||||
if (parsed.data.password !== parsed.data.passwordConfirmation) {
|
||||
return fail("Passwords do not match");
|
||||
}
|
||||
|
||||
const { username, mail, password, look } = parsed.data;
|
||||
const hasEmail = !!mail;
|
||||
const ip = await clientIp();
|
||||
|
||||
Reference in new issue
Block a user