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 { readUpdateStatus, triggerSystemUpdate } from "./update.js";
import "./types.js"; import "./types.js";
const credentialsSchema = z.object({ const usernameSchema = z
username: 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() .string()
.trim() .min(8, "Password must contain at least 8 characters")
.min(3, "Username must contain at least 3 characters") .max(256)
.max(48, "Username must contain at most 48 characters") .regex(/[A-Z]/, "Password must contain at least one uppercase letter")
.regex(/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/, "Use letters, numbers, dots, hyphens, or underscores"), .regex(/[0-9]/, "Password must contain at least one number")
password: z.string().min(12, "Password must contain at least 12 characters").max(256)
}); });
type UserRow = { id: number; username: string; role: "admin"; password_hash: string }; 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", "/api/auth/setup",
{ config: { rateLimit: { max: 5, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 5, timeWindow: "1 minute" } } },
async (request, reply) => { async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body); const parsed = setupCredentialsSchema.safeParse(request.body);
if (!parsed.success) { if (!parsed.success) {
return reply.code(400).send({ return reply.code(400).send({
error: { code: "INVALID_CREDENTIALS", message: parsed.error.issues[0]?.message ?? "Invalid account details" } 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", "/api/auth/login",
{ config: { rateLimit: { max: 8, timeWindow: "1 minute" } } }, { config: { rateLimit: { max: 8, timeWindow: "1 minute" } } },
async (request, reply) => { async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body); const parsed = loginCredentialsSchema.safeParse(request.body);
const user = parsed.success const user = parsed.success
? (database ? (database
.prepare("SELECT id, username, role, password_hash FROM users WHERE username = ? COLLATE NOCASE") .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", method: "POST",
url: "/api/auth/setup", url: "/api/auth/setup",
headers: mutationHeaders(token), 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", method: "POST",
url: "/api/auth/setup", url: "/api/auth/setup",
headers: mutationHeaders(token), headers: mutationHeaders(token),
payload: { username: "second", password: "another-secure-password" } payload: { username: "second", password: "Another-pass2" }
}); });
expect(second.statusCode).toBe(409); expect(second.statusCode).toBe(409);
expect(second.json()).toMatchObject({ error: { code: "SETUP_COMPLETE" } }); 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 () => { it("rejects state changes without a matching CSRF token", async () => {
const app = await createApp(); const app = await createApp();
const response = await app.inject({ const response = await app.inject({
method: "POST", method: "POST",
url: "/api/auth/setup", url: "/api/auth/setup",
payload: { username: "owner", password: "correct-horse-battery-staple" } payload: { username: "owner", password: "Correct-horse1" }
}); });
expect(response.statusCode).toBe(403); expect(response.statusCode).toBe(403);
@@ -89,7 +110,7 @@ describe("authentication boundary", () => {
method: "POST", method: "POST",
url: "/api/auth/setup", url: "/api/auth/setup",
headers: { ...mutationHeaders(token), origin: "https://attacker.example" }, 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); expect(response.statusCode).toBe(403);
@@ -107,7 +128,7 @@ describe("authentication boundary", () => {
host: "192.168.4.20:8787", host: "192.168.4.20:8787",
origin: "http://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); expect(response.statusCode).toBe(201);
@@ -123,7 +144,7 @@ describe("authentication boundary", () => {
method: "POST", method: "POST",
url: "/api/auth/login", url: "/api/auth/login",
headers: mutationHeaders(token), headers: mutationHeaders(token),
payload: { username: "owner", password: "correct-horse-battery-staple" } payload: { username: "owner", password: "Correct-horse1" }
}); });
expect(login.statusCode).toBe(200); expect(login.statusCode).toBe(200);
const sessionCookie = login.cookies.find((cookie) => cookie.name === SESSION_COOKIE); const sessionCookie = login.cookies.find((cookie) => cookie.name === SESSION_COOKIE);
+12 -2
View File
@@ -126,8 +126,18 @@ function AccountScreen({
</label> </label>
<label> <label>
<span>Password</span> <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 /> <input
{setup && <small>Use at least 12 characters.</small>} 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> </label>
{error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>} {error && <div className="form-error" role="alert"><WarningCircle size={20} />{error}</div>}
<button className="primary-button" type="submit" disabled={submitting}> <button className="primary-button" type="submit" disabled={submitting}>