From e01074926e106f4d63ead85b285e2215f2e0adad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sat, 18 Apr 2026 14:02:43 +0200 Subject: [PATCH] security: reject common/weak passwords on every set-password path (#31) Adds a synchronous policy check that blocks (1) the curated >=12-char common-password list (rockyou top, predictable seasonal, admin defaults), (2) trivial patterns (single-char repeat, short-pattern repeat, keyboard or numeric sequences), and (3) passwords containing the user's email local-part or any name component. Wired into all five password-mutation sites: first-admin setup, admin createUser/setUserPassword, invite acceptance, and password-reset. Co-Authored-By: Claude Opus 4.7 --- apps/web/src/app/setup/actions.ts | 8 + packages/api/src/router/auth.ts | 12 + packages/api/src/router/invite.ts | 6 + .../api/src/router/user-procedure-support.ts | 11 + .../src/__tests__/password-policy.test.ts | 114 ++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/security/index.ts | 1 + .../shared/src/security/password-policy.ts | 206 ++++++++++++++++++ 8 files changed, 359 insertions(+) create mode 100644 packages/shared/src/__tests__/password-policy.test.ts create mode 100644 packages/shared/src/security/index.ts create mode 100644 packages/shared/src/security/password-policy.ts diff --git a/apps/web/src/app/setup/actions.ts b/apps/web/src/app/setup/actions.ts index ffaf230..b099b55 100644 --- a/apps/web/src/app/setup/actions.ts +++ b/apps/web/src/app/setup/actions.ts @@ -5,6 +5,7 @@ import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE, + checkPasswordPolicy, } from "@capakraken/shared"; export type SetupResult = @@ -26,6 +27,13 @@ export async function createFirstAdmin(formData: { ) { return { error: "validation", message: PASSWORD_POLICY_MESSAGE }; } + const policy = checkPasswordPolicy(formData.password, { + email: formData.email, + name: formData.name, + }); + if (!policy.ok) { + return { error: "validation", message: policy.reason }; + } // TOCTOU guard — check again inside the action const count = await prisma.user.count(); diff --git a/packages/api/src/router/auth.ts b/packages/api/src/router/auth.ts index 9406f12..ace4a60 100644 --- a/packages/api/src/router/auth.ts +++ b/packages/api/src/router/auth.ts @@ -3,6 +3,7 @@ import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE, + checkPasswordPolicy, } from "@capakraken/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; @@ -133,6 +134,17 @@ export const authRouter = createTRPCRouter({ }); } + // Reject weak/common/identity-related passwords *after* the token is + // validated so attackers can't probe the policy without a valid link. + const userForPolicy = await ctx.db.user.findUnique({ + where: { email: record.email }, + select: { email: true, name: true }, + }); + const policy = checkPasswordPolicy(input.password, userForPolicy ?? undefined); + if (!policy.ok) { + throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason }); + } + const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); diff --git a/packages/api/src/router/invite.ts b/packages/api/src/router/invite.ts index 3e6b6a5..20ce6ce 100644 --- a/packages/api/src/router/invite.ts +++ b/packages/api/src/router/invite.ts @@ -6,6 +6,7 @@ import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE, + checkPasswordPolicy, } from "@capakraken/shared"; import { createTRPCRouter, adminProcedure, publicProcedure } from "../trpc.js"; import { getAppBaseUrl } from "../lib/app-base-url.js"; @@ -155,6 +156,11 @@ export const inviteRouter = createTRPCRouter({ }); } + const policy = checkPasswordPolicy(input.password, { email: invite.email }); + if (!policy.ok) { + throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason }); + } + const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); diff --git a/packages/api/src/router/user-procedure-support.ts b/packages/api/src/router/user-procedure-support.ts index 2974e2d..18229e9 100644 --- a/packages/api/src/router/user-procedure-support.ts +++ b/packages/api/src/router/user-procedure-support.ts @@ -3,6 +3,7 @@ import { PASSWORD_MAX_LENGTH, PASSWORD_MIN_LENGTH, PASSWORD_POLICY_MESSAGE, + checkPasswordPolicy, } from "@capakraken/shared"; import { PermissionOverrides, SystemRole, resolvePermissions } from "@capakraken/shared/types"; import { TRPCError } from "@trpc/server"; @@ -121,6 +122,11 @@ export async function createUser( throw new TRPCError({ code: "CONFLICT", message: "User with this email already exists" }); } + const policy = checkPasswordPolicy(input.password, { email: input.email, name: input.name }); + if (!policy.ok) { + throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason }); + } + const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); @@ -169,6 +175,11 @@ export async function setUserPassword( "User", ); + const policy = checkPasswordPolicy(input.password, { email: user.email, name: user.name }); + if (!policy.ok) { + throw new TRPCError({ code: "BAD_REQUEST", message: policy.reason }); + } + const { hash } = await import("@node-rs/argon2"); const passwordHash = await hash(input.password); diff --git a/packages/shared/src/__tests__/password-policy.test.ts b/packages/shared/src/__tests__/password-policy.test.ts new file mode 100644 index 0000000..eba7f86 --- /dev/null +++ b/packages/shared/src/__tests__/password-policy.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from "vitest"; +import { checkPasswordPolicy } from "../security/password-policy.js"; + +describe("checkPasswordPolicy", () => { + describe("length bounds", () => { + it("rejects passwords shorter than 12 chars", () => { + const result = checkPasswordPolicy("short1!"); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/at least 12/i); + }); + + it("rejects passwords longer than 128 chars", () => { + const result = checkPasswordPolicy("A".repeat(129)); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/no more than 128/i); + }); + + it("accepts passwords at the lower bound that pass other checks", () => { + const result = checkPasswordPolicy("Tr0ub4dor&3!"); // 12 chars, varied + expect(result.ok).toBe(true); + }); + }); + + describe("trivial patterns", () => { + it("rejects single char repeated", () => { + const result = checkPasswordPolicy("aaaaaaaaaaaa"); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/single character/i); + }); + + it("rejects short patterns repeated", () => { + const result = checkPasswordPolicy("abcabcabcabc"); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/short pattern/i); + }); + + it("rejects '1212121212121212' (2-char pattern repeated)", () => { + const result = checkPasswordPolicy("1212121212121212"); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/short pattern/i); + }); + + it("rejects keyboard sequences like 'abcdefghijkl'", () => { + const result = checkPasswordPolicy("abcdefghijkl"); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/sequence/i); + }); + + it("rejects numeric runs like '1234567890ab'", () => { + const result = checkPasswordPolicy("1234567890ab"); + // Either matches blacklist or sequence detector — both rejections OK. + expect(result.ok).toBe(false); + }); + }); + + describe("common-password blacklist", () => { + it("rejects 'PasswordPassword' (case-insensitive)", () => { + const result = checkPasswordPolicy("PasswordPassword"); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/commonly used/i); + }); + + it("rejects 'Welcome2026!' seasonal password", () => { + const result = checkPasswordPolicy("Welcome2026!"); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/commonly used/i); + }); + + it("rejects 'Summer2025!' regardless of case", () => { + const result = checkPasswordPolicy("SUMMER2025!"); + expect(result.ok).toBe(false); + }); + }); + + describe("identity inclusion", () => { + it("rejects passwords containing the email local-part", () => { + const result = checkPasswordPolicy("hartmutSomePass1", { + email: "hartmut@example.com", + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/email or name/i); + }); + + it("rejects passwords containing the user name", () => { + const result = checkPasswordPolicy("MyHartmutPass1!", { + name: "Hartmut Noerenberg", + }); + expect(result.ok).toBe(false); + expect(result.reason).toMatch(/email or name/i); + }); + + it("ignores short email locals to avoid false positives", () => { + const result = checkPasswordPolicy("XyZmagic12345!", { email: "x@example.com" }); + expect(result.ok).toBe(true); + }); + + it("ignores short names (<4 chars)", () => { + const result = checkPasswordPolicy("XyZmagic12345!", { name: "Ed" }); + expect(result.ok).toBe(true); + }); + }); + + describe("strong passwords", () => { + it("accepts a 16-char random-looking passphrase", () => { + const result = checkPasswordPolicy("Tr0ub4d0r&3-x9Q!"); + expect(result.ok).toBe(true); + }); + + it("accepts diceware-style passphrase", () => { + const result = checkPasswordPolicy("correct-horse-battery-staple-7!"); + expect(result.ok).toBe(true); + }); + }); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index b12b03b..6b5f90c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ export * from "./types/index.js"; export * from "./schemas/index.js"; export * from "./constants/index.js"; +export * from "./security/index.js"; diff --git a/packages/shared/src/security/index.ts b/packages/shared/src/security/index.ts new file mode 100644 index 0000000..7c109c2 --- /dev/null +++ b/packages/shared/src/security/index.ts @@ -0,0 +1 @@ +export * from "./password-policy.js"; diff --git a/packages/shared/src/security/password-policy.ts b/packages/shared/src/security/password-policy.ts new file mode 100644 index 0000000..fa1ddfe --- /dev/null +++ b/packages/shared/src/security/password-policy.ts @@ -0,0 +1,206 @@ +import { + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, + PASSWORD_POLICY_MESSAGE, +} from "../constants/index.js"; + +export interface PasswordContext { + email?: string | null; + name?: string | null; +} + +export type PasswordCheckResult = { ok: true } | { ok: false; reason: string }; + +// Curated list of >=12-char passwords that pass the length gate but are +// known weak (rockyou top entries, predictable seasonal patterns, common +// admin defaults). Stored lower-cased for case-insensitive matching. The +// vast majority of weak passwords are <12 chars and already rejected by +// length, so this list focuses on what would otherwise *pass* min-length. +const COMMON_PASSWORDS: ReadonlySet = new Set([ + "passwordpassword", + "password1234", + "password12345", + "password123456", + "password1234567", + "passwordpassword1", + "passw0rdpassw0rd", + "1234567890ab", + "1234567890abc", + "12345678901", + "123456789012", + "1234567891234", + "qwertyuiop12", + "qwertyuiop123", + "qwertyuiop1234", + "qwertyuiopas", + "asdfghjkl123", + "asdfghjkl1234", + "iloveyou1234", + "iloveyou12345", + "iloveyouabc1", + "iloveyouforever", + "ilovemybabies", + "letmein123456", + "letmein12345", + "welcome12345", + "welcome123456", + "welcome1234567", + "admin1234567", + "admin12345678", + "administrator1", + "administrator123", + "changeme1234", + "changeme12345", + "default1234567", + "football1234", + "baseball1234", + "trustno112345", + "summer2023!", + "summer2024!", + "summer2025!", + "summer2026!", + "winter2023!", + "winter2024!", + "winter2025!", + "winter2026!", + "spring2023!", + "spring2024!", + "spring2025!", + "spring2026!", + "autumn2023!", + "autumn2024!", + "autumn2025!", + "autumn2026!", + "welcome2023!", + "welcome2024!", + "welcome2025!", + "welcome2026!", + "password2023!", + "password2024!", + "password2025!", + "password2026!", + "p@ssw0rd1234", + "p@ssword1234", +]); + +function isSingleCharRepeated(pw: string): boolean { + if (pw.length === 0) return false; + const first = pw[0]!; + for (let i = 1; i < pw.length; i++) { + if (pw[i] !== first) return false; + } + return true; +} + +// Detect monotonically +/-1 character runs. A password where >=60% of +// characters belong to a run of length >=5 (e.g. "abcdefgh1234") is +// rejected as a keyboard / numeric sequence. The 60% threshold lets +// passwords with a sequential prefix and a long random suffix pass. +function isSequentialPassword(pw: string): boolean { + if (pw.length < 8) return false; + const lower = pw.toLowerCase(); + let runChars = 0; + let i = 0; + while (i < lower.length) { + let runLen = 1; + while (i + runLen < lower.length) { + const delta = lower.charCodeAt(i + runLen) - lower.charCodeAt(i + runLen - 1); + if (delta === 1 || delta === -1) { + runLen++; + } else { + break; + } + } + if (runLen >= 5) runChars += runLen; + i += runLen; + } + return runChars * 100 >= lower.length * 60; +} + +// Reject passwords that are a 2-6 char pattern repeated >=3 times +// (e.g. "abcabcabcabc", "12121212"). Length divisibility check first +// keeps the constant-factor work tiny. +function isShortPatternRepeated(pw: string): boolean { + for (let patLen = 2; patLen <= 6; patLen++) { + if (pw.length % patLen !== 0) continue; + const repeats = pw.length / patLen; + if (repeats < 3) continue; + const pattern = pw.slice(0, patLen); + let allMatch = true; + for (let r = 1; r < repeats; r++) { + if (pw.slice(r * patLen, (r + 1) * patLen) !== pattern) { + allMatch = false; + break; + } + } + if (allMatch) return true; + } + return false; +} + +// Substrings shorter than 4 chars are ignored: short names ("ed") or +// generic email locals ("ab") would otherwise reject too many legitimate +// passwords by coincidence. +function containsIdentity(pw: string, ctx: PasswordContext | undefined): boolean { + if (!ctx) return false; + const lower = pw.toLowerCase(); + if (ctx.email) { + const local = ctx.email.split("@")[0]?.toLowerCase().trim() ?? ""; + if (local.length >= 4 && lower.includes(local)) return true; + } + if (ctx.name) { + const lowered = ctx.name.toLowerCase().trim(); + const fullNoSpaces = lowered.replace(/\s+/g, ""); + if (fullNoSpaces.length >= 4 && lower.includes(fullNoSpaces)) return true; + // Also reject if the password embeds an individual name component + // (e.g. given-name or surname). "Hartmut Noerenberg" → check both + // "hartmut" and "noerenberg" so a password like "MyHartmutPass1!" + // is rejected, not only the unrealistic "MyHartmutNoerenbergPass1!". + for (const part of lowered.split(/\s+/)) { + if (part.length >= 4 && lower.includes(part)) return true; + } + } + return false; +} + +/** + * Validate a password against the policy. Pure synchronous function so it + * can be called from server (tRPC mutations, server actions) and client + * (pre-submit validation) with identical results. + */ +export function checkPasswordPolicy(password: string, ctx?: PasswordContext): PasswordCheckResult { + if (password.length < PASSWORD_MIN_LENGTH) { + return { ok: false, reason: PASSWORD_POLICY_MESSAGE }; + } + if (password.length > PASSWORD_MAX_LENGTH) { + return { + ok: false, + reason: `Password must be no more than ${PASSWORD_MAX_LENGTH} characters.`, + }; + } + if (isSingleCharRepeated(password)) { + return { ok: false, reason: "Password must not be a single character repeated." }; + } + if (isShortPatternRepeated(password)) { + return { ok: false, reason: "Password must not be a short pattern repeated." }; + } + if (isSequentialPassword(password)) { + return { + ok: false, + reason: "Password must not be a keyboard or numeric sequence.", + }; + } + if (COMMON_PASSWORDS.has(password.toLowerCase())) { + return { + ok: false, + reason: "Password is in the list of commonly used or breached passwords.", + }; + } + if (containsIdentity(password, ctx)) { + return { + ok: false, + reason: "Password must not contain your email or name.", + }; + } + return { ok: true }; +}