Require uppercase and numeric setup passwords
This commit is contained in:
+20
-9
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user