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
@@ -49,7 +49,7 @@ vi.mock("@/lib/server-log", () => ({ logServerError: mockLogServerError }));
|
||||
const mockGiveBadge = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { giveBadge: mockGiveBadge } }));
|
||||
|
||||
const mockSiteSettingsGet = vi.hoisted(() => vi.fn());
|
||||
const mockSiteSettingsGet = vi.hoisted(() => vi.fn(async () => state.price));
|
||||
vi.mock("@/lib/services/site-settings", () => ({
|
||||
siteSettings: { get: mockSiteSettingsGet },
|
||||
}));
|
||||
@@ -139,8 +139,6 @@ beforeEach(() => {
|
||||
mockClientIp.mockResolvedValue("127.0.0.1");
|
||||
mockRateLimit.mockReset();
|
||||
mockRateLimit.mockResolvedValue({ ok: true });
|
||||
mockSiteSettingsGet.mockReset();
|
||||
mockSiteSettingsGet.mockResolvedValue(state.price);
|
||||
mockGiveBadge.mockReset();
|
||||
mockGiveBadge.mockResolvedValue(true);
|
||||
state.badges = [];
|
||||
|
||||
@@ -90,8 +90,8 @@ function buildForm(overrides: Record<string, string | undefined> = {}) {
|
||||
const f = new FormData();
|
||||
f.set("username", "Alice_123");
|
||||
f.set("mail", "");
|
||||
f.set("password", "Secret123");
|
||||
f.set("password_confirmation", "Secret123");
|
||||
f.set("password", "Secret1234!@"); // 12+ chars, upper, lower, digit, special
|
||||
f.set("password_confirmation", "Secret1234!@");
|
||||
f.set("terms", "on");
|
||||
for (const [k, v] of Object.entries(overrides)) {
|
||||
if (v === undefined) f.delete(k);
|
||||
@@ -136,12 +136,12 @@ describe("register", () => {
|
||||
it("creates the account and returns ok", async () => {
|
||||
const result = await runValidRegistration();
|
||||
expect(result).toEqual({ error: null, ok: true });
|
||||
expect(state.hashPassword).toHaveBeenCalledWith("Secret123");
|
||||
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:Secret123",
|
||||
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",
|
||||
@@ -190,7 +190,7 @@ describe("register", () => {
|
||||
|
||||
it("rejects usernames containing characters outside the allowed set", async () => {
|
||||
const result = await register(PREV, buildForm({ username: "bad name!" }));
|
||||
expect(result.error).toContain("invalid characters");
|
||||
expect(result.error).toContain("letters, numbers, underscore and hyphen");
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -205,7 +205,7 @@ describe("register", () => {
|
||||
it("rejects weak passwords", async () => {
|
||||
const result = await register(PREV, buildForm({ password: "short" }));
|
||||
expect(result).toEqual({
|
||||
error: "Password must be at least 8 characters",
|
||||
error: "Password must be at least 12 characters",
|
||||
ok: false,
|
||||
});
|
||||
expect(state.insert).not.toHaveBeenCalled();
|
||||
@@ -214,7 +214,7 @@ describe("register", () => {
|
||||
it("rejects passwords without an uppercase letter", async () => {
|
||||
const result = await register(
|
||||
PREV,
|
||||
buildForm({ password: "s3cret123", password_confirmation: "s3cret123" }),
|
||||
buildForm({ password: "secret1234!@", password_confirmation: "secret1234!@" }),
|
||||
);
|
||||
expect(result.error).toContain("uppercase");
|
||||
});
|
||||
@@ -223,13 +223,24 @@ describe("register", () => {
|
||||
const result = await register(
|
||||
PREV,
|
||||
buildForm({
|
||||
password: "Secretsecret",
|
||||
password_confirmation: "Secretsecret",
|
||||
password: "Secretsecret!",
|
||||
password_confirmation: "Secretsecret!",
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("digit");
|
||||
});
|
||||
|
||||
it("rejects passwords without a special character", async () => {
|
||||
const result = await register(
|
||||
PREV,
|
||||
buildForm({
|
||||
password: "Secretsecret1",
|
||||
password_confirmation: "Secretsecret1",
|
||||
}),
|
||||
);
|
||||
expect(result.error).toContain("special");
|
||||
});
|
||||
|
||||
it("rejects mismatched password confirmations", async () => {
|
||||
const result = await register(
|
||||
PREV,
|
||||
|
||||
+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();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import {
|
||||
argon2id,
|
||||
argon2Verify,
|
||||
bcrypt,
|
||||
bcryptVerify,
|
||||
@@ -11,7 +12,26 @@ import {
|
||||
|
||||
import { env } from "@/env";
|
||||
|
||||
/** Argon2id parameters — memory-hard, GPU-resistant. */
|
||||
const ARGON2_CONFIG = {
|
||||
memoryCost: 19456, // ~19 MiB
|
||||
timeCost: 2,
|
||||
parallelism: 1,
|
||||
outputLen: 32,
|
||||
};
|
||||
|
||||
/** Hash new passwords with Argon2id (best practice 2024+). */
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
return await argon2id({
|
||||
password,
|
||||
salt: randomBytes(16),
|
||||
...ARGON2_CONFIG,
|
||||
outputType: "encoded",
|
||||
});
|
||||
}
|
||||
|
||||
/** Legacy bcrypt for migrating existing hashes. */
|
||||
export async function hashPasswordBcrypt(password: string): Promise<string> {
|
||||
return await bcrypt({
|
||||
password,
|
||||
salt: randomBytes(16),
|
||||
|
||||
Reference in new issue
Block a user