e01074926e
CI / Architecture Guardrails (pull_request) Successful in 6m31s
CI / Typecheck (pull_request) Failing after 6m9s
CI / Build (pull_request) Has been skipped
CI / E2E Tests (pull_request) Has been skipped
CI / Fresh-Linux Docker Deploy (pull_request) Has been skipped
CI / Assistant Split Regression (pull_request) Successful in 7m23s
CI / Lint (pull_request) Successful in 6m54s
CI / Unit Tests (pull_request) Successful in 9m28s
CI / Release Images (pull_request) Has been skipped
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 <noreply@anthropic.com>
207 lines
5.9 KiB
TypeScript
207 lines
5.9 KiB
TypeScript
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<string> = 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 };
|
|
}
|