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());
|
const mockGiveBadge = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("@/lib/services/rcon", () => ({ rcon: { giveBadge: mockGiveBadge } }));
|
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", () => ({
|
vi.mock("@/lib/services/site-settings", () => ({
|
||||||
siteSettings: { get: mockSiteSettingsGet },
|
siteSettings: { get: mockSiteSettingsGet },
|
||||||
}));
|
}));
|
||||||
@@ -139,8 +139,6 @@ beforeEach(() => {
|
|||||||
mockClientIp.mockResolvedValue("127.0.0.1");
|
mockClientIp.mockResolvedValue("127.0.0.1");
|
||||||
mockRateLimit.mockReset();
|
mockRateLimit.mockReset();
|
||||||
mockRateLimit.mockResolvedValue({ ok: true });
|
mockRateLimit.mockResolvedValue({ ok: true });
|
||||||
mockSiteSettingsGet.mockReset();
|
|
||||||
mockSiteSettingsGet.mockResolvedValue(state.price);
|
|
||||||
mockGiveBadge.mockReset();
|
mockGiveBadge.mockReset();
|
||||||
mockGiveBadge.mockResolvedValue(true);
|
mockGiveBadge.mockResolvedValue(true);
|
||||||
state.badges = [];
|
state.badges = [];
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ function buildForm(overrides: Record<string, string | undefined> = {}) {
|
|||||||
const f = new FormData();
|
const f = new FormData();
|
||||||
f.set("username", "Alice_123");
|
f.set("username", "Alice_123");
|
||||||
f.set("mail", "");
|
f.set("mail", "");
|
||||||
f.set("password", "Secret123");
|
f.set("password", "Secret1234!@"); // 12+ chars, upper, lower, digit, special
|
||||||
f.set("password_confirmation", "Secret123");
|
f.set("password_confirmation", "Secret1234!@");
|
||||||
f.set("terms", "on");
|
f.set("terms", "on");
|
||||||
for (const [k, v] of Object.entries(overrides)) {
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
if (v === undefined) f.delete(k);
|
if (v === undefined) f.delete(k);
|
||||||
@@ -136,12 +136,12 @@ describe("register", () => {
|
|||||||
it("creates the account and returns ok", async () => {
|
it("creates the account and returns ok", async () => {
|
||||||
const result = await runValidRegistration();
|
const result = await runValidRegistration();
|
||||||
expect(result).toEqual({ error: null, ok: true });
|
expect(result).toEqual({ error: null, ok: true });
|
||||||
expect(state.hashPassword).toHaveBeenCalledWith("Secret123");
|
expect(state.hashPassword).toHaveBeenCalledWith("Secret1234!@");
|
||||||
expect(state.insert).toHaveBeenCalledOnce();
|
expect(state.insert).toHaveBeenCalledOnce();
|
||||||
expect(state.insert.mock.calls[0][0]).toBe(User);
|
expect(state.insert.mock.calls[0][0]).toBe(User);
|
||||||
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
expect(state.insert.mock.calls[0][1]).toMatchObject({
|
||||||
username: "Alice_123",
|
username: "Alice_123",
|
||||||
password: "hashed:Secret123",
|
password: "hashed:Secret1234!@",
|
||||||
mail: null,
|
mail: null,
|
||||||
accountCreated: expect.any(Number),
|
accountCreated: expect.any(Number),
|
||||||
ipRegister: "203.0.113.9",
|
ipRegister: "203.0.113.9",
|
||||||
@@ -190,7 +190,7 @@ describe("register", () => {
|
|||||||
|
|
||||||
it("rejects usernames containing characters outside the allowed set", async () => {
|
it("rejects usernames containing characters outside the allowed set", async () => {
|
||||||
const result = await register(PREV, buildForm({ username: "bad name!" }));
|
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();
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -205,7 +205,7 @@ describe("register", () => {
|
|||||||
it("rejects weak passwords", async () => {
|
it("rejects weak passwords", async () => {
|
||||||
const result = await register(PREV, buildForm({ password: "short" }));
|
const result = await register(PREV, buildForm({ password: "short" }));
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
error: "Password must be at least 8 characters",
|
error: "Password must be at least 12 characters",
|
||||||
ok: false,
|
ok: false,
|
||||||
});
|
});
|
||||||
expect(state.insert).not.toHaveBeenCalled();
|
expect(state.insert).not.toHaveBeenCalled();
|
||||||
@@ -214,7 +214,7 @@ describe("register", () => {
|
|||||||
it("rejects passwords without an uppercase letter", async () => {
|
it("rejects passwords without an uppercase letter", async () => {
|
||||||
const result = await register(
|
const result = await register(
|
||||||
PREV,
|
PREV,
|
||||||
buildForm({ password: "s3cret123", password_confirmation: "s3cret123" }),
|
buildForm({ password: "secret1234!@", password_confirmation: "secret1234!@" }),
|
||||||
);
|
);
|
||||||
expect(result.error).toContain("uppercase");
|
expect(result.error).toContain("uppercase");
|
||||||
});
|
});
|
||||||
@@ -223,13 +223,24 @@ describe("register", () => {
|
|||||||
const result = await register(
|
const result = await register(
|
||||||
PREV,
|
PREV,
|
||||||
buildForm({
|
buildForm({
|
||||||
password: "Secretsecret",
|
password: "Secretsecret!",
|
||||||
password_confirmation: "Secretsecret",
|
password_confirmation: "Secretsecret!",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
expect(result.error).toContain("digit");
|
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 () => {
|
it("rejects mismatched password confirmations", async () => {
|
||||||
const result = await register(
|
const result = await register(
|
||||||
PREV,
|
PREV,
|
||||||
|
|||||||
+40
-8
@@ -14,25 +14,61 @@ import { checkVpn } from "@/lib/services/ip-lookup";
|
|||||||
import { recordReferral } from "@/lib/services/referrals";
|
import { recordReferral } from "@/lib/services/referrals";
|
||||||
import { siteSettings } from "@/lib/services/site-settings";
|
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({
|
const registerSchema = z.object({
|
||||||
username: z
|
username: z
|
||||||
.string()
|
.string()
|
||||||
.min(3, "Username must be at least 3 characters")
|
.min(3, "Username must be at least 3 characters")
|
||||||
.max(25, "Username must be at most 25 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
|
mail: z
|
||||||
.string()
|
.string()
|
||||||
.email("Enter a valid email address")
|
.email("Enter a valid email address")
|
||||||
.optional()
|
.optional()
|
||||||
.or(z.literal("")),
|
.or(z.literal(""))
|
||||||
|
.refine((e) => !e || !hasDisposableEmailDomain(e), "Temporary email domains are not allowed"),
|
||||||
password: z
|
password: z
|
||||||
.string()
|
.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 uppercase letter")
|
||||||
.regex(/[a-z]/, "Password must contain at least one lowercase 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(),
|
passwordConfirmation: z.string(),
|
||||||
look: z.string().optional(),
|
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.
|
// 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");
|
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 { username, mail, password, look } = parsed.data;
|
||||||
const hasEmail = !!mail;
|
const hasEmail = !!mail;
|
||||||
const ip = await clientIp();
|
const ip = await clientIp();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import {
|
import {
|
||||||
|
argon2id,
|
||||||
argon2Verify,
|
argon2Verify,
|
||||||
bcrypt,
|
bcrypt,
|
||||||
bcryptVerify,
|
bcryptVerify,
|
||||||
@@ -11,7 +12,26 @@ import {
|
|||||||
|
|
||||||
import { env } from "@/env";
|
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> {
|
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({
|
return await bcrypt({
|
||||||
password,
|
password,
|
||||||
salt: randomBytes(16),
|
salt: randomBytes(16),
|
||||||
|
|||||||
Reference in new issue
Block a user