diff --git a/server/src/app.ts b/server/src/app.ts index f948af4..80f3912 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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 { "/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 { "/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") diff --git a/server/tests/app.test.ts b/server/tests/app.test.ts index f26b53e..d000e63 100644 --- a/server/tests/app.test.ts +++ b/server/tests/app.test.ts @@ -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); diff --git a/web/src/App.tsx b/web/src/App.tsx index dab5a44..9a1d38e 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -126,8 +126,18 @@ function AccountScreen({ {error &&
{error}
}