Require uppercase and numeric setup passwords

This commit is contained in:
2026-07-31 18:47:32 +02:00
parent 6d360ec558
commit e48b034678
3 changed files with 59 additions and 17 deletions
+20 -9
View File
@@ -20,14 +20,25 @@ import { collectSystemStatus } from "./status.js";
import { readUpdateStatus, triggerSystemUpdate } from "./update.js";
import "./types.js";
const credentialsSchema = z.object({
username: z
const usernameSchema = z
.string()
.trim()
.min(3, "Username must contain at least 3 characters")
.max(48, "Username must contain at most 48 characters")
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, "Use letters, numbers, dots, hyphens, or underscores");
const loginCredentialsSchema = z.object({
username: usernameSchema,
password: z.string().min(1).max(256)
});
const setupCredentialsSchema = loginCredentialsSchema.extend({
password: z
.string()
.trim()
.min(3, "Username must contain at least 3 characters")
.max(48, "Username must contain at most 48 characters")
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, "Use letters, numbers, dots, hyphens, or underscores"),
password: z.string().min(12, "Password must contain at least 12 characters").max(256)
.min(8, "Password must contain at least 8 characters")
.max(256)
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/[0-9]/, "Password must contain at least one number")
});
type UserRow = { id: number; username: string; role: "admin"; password_hash: string };
@@ -162,7 +173,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
"/api/auth/setup",
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body);
const parsed = setupCredentialsSchema.safeParse(request.body);
if (!parsed.success) {
return reply.code(400).send({
error: { code: "INVALID_CREDENTIALS", message: parsed.error.issues[0]?.message ?? "Invalid account details" }
@@ -203,7 +214,7 @@ export async function buildApp(config: AppConfig): Promise<FastifyInstance> {
"/api/auth/login",
{ config: { rateLimit: { max: 8, timeWindow: "1 minute" } } },
async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body);
const parsed = loginCredentialsSchema.safeParse(request.body);
const user = parsed.success
? (database
.prepare("SELECT id, username, role, password_hash FROM users WHERE username = ? COLLATE NOCASE")
+27 -6
View File
@@ -45,7 +45,7 @@ async function setup(app: FastifyInstance, token: string) {
method: "POST",
url: "/api/auth/setup",
headers: mutationHeaders(token),
payload: { username: "owner", password: "correct-horse-battery-staple" }
payload: { username: "owner", password: "Correct-horse1" }
});
}
@@ -63,19 +63,40 @@ describe("authentication boundary", () => {
method: "POST",
url: "/api/auth/setup",
headers: mutationHeaders(token),
payload: { username: "second", password: "another-secure-password" }
payload: { username: "second", password: "Another-pass2" }
});
expect(second.statusCode).toBe(409);
expect(second.json()).toMatchObject({ error: { code: "SETUP_COMPLETE" } });
});
it("requires 8 characters, an uppercase letter, and a number for setup", async () => {
const app = await createApp();
const token = await csrf(app);
const cases = [
{ password: "Short1", expectedMessage: "at least 8 characters" },
{ password: "lowercase1", expectedMessage: "uppercase letter" },
{ password: "NoNumberHere", expectedMessage: "one number" }
];
for (const testCase of cases) {
const response = await app.inject({
method: "POST",
url: "/api/auth/setup",
headers: mutationHeaders(token),
payload: { username: "owner", password: testCase.password }
});
expect(response.statusCode).toBe(400);
expect(response.json<{ error: { message: string } }>().error.message).toContain(testCase.expectedMessage);
}
});
it("rejects state changes without a matching CSRF token", async () => {
const app = await createApp();
const response = await app.inject({
method: "POST",
url: "/api/auth/setup",
payload: { username: "owner", password: "correct-horse-battery-staple" }
payload: { username: "owner", password: "Correct-horse1" }
});
expect(response.statusCode).toBe(403);
@@ -89,7 +110,7 @@ describe("authentication boundary", () => {
method: "POST",
url: "/api/auth/setup",
headers: { ...mutationHeaders(token), origin: "https://attacker.example" },
payload: { username: "owner", password: "correct-horse-battery-staple" }
payload: { username: "owner", password: "Correct-horse1" }
});
expect(response.statusCode).toBe(403);
@@ -107,7 +128,7 @@ describe("authentication boundary", () => {
host: "192.168.4.20:8787",
origin: "http://192.168.4.20:8787"
},
payload: { username: "owner", password: "correct-horse-battery-staple" }
payload: { username: "owner", password: "Correct-horse1" }
});
expect(response.statusCode).toBe(201);
@@ -123,7 +144,7 @@ describe("authentication boundary", () => {
method: "POST",
url: "/api/auth/login",
headers: mutationHeaders(token),
payload: { username: "owner", password: "correct-horse-battery-staple" }
payload: { username: "owner", password: "Correct-horse1" }
});
expect(login.statusCode).toBe(200);
const sessionCookie = login.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
+12 -2
View File
@@ -126,8 +126,18 @@ function AccountScreen({
</label>
<label>
<span>Password</span>
<input type="password" autoComplete={setup ? "new-password" : "current-password"} value={password} onChange={(event) => setPassword(event.target.value)} minLength={12} maxLength={256} required />
{setup && <small>Use at least 12 characters.</small>}
<input
type="password"
autoComplete={setup ? "new-password" : "current-password"}
value={password}
onChange={(event) => setPassword(event.target.value)}
minLength={setup ? 8 : 1}
maxLength={256}
pattern={setup ? "(?=.*[A-Z])(?=.*[0-9]).{8,}" : undefined}
title={setup ? "Use at least 8 characters, one uppercase letter, and one number." : undefined}
required
/>
{setup && <small>Use at least 8 characters, one uppercase letter, and one number.</small>}
</label>
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
<button className="primary-button" type="submit" disabled={submitting}>