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

+22 -11
View File
@@ -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,