security: harden authentication (register/login)
CI / check (push) Failing after 30s
CI / preflight (push) Skipped
CI / deploy (push) Skipped

- 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:
openhands committed 2026-09-21 19:33:13 +02:00
1 parent 6dc80b0b05
commit ac60a867d9
4 files changed
+83 -22

No files matched your search

+40 -8
View File
@@ -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();