chore: ran security check for OWASP top 10
Some checks failed
Deploy / deploy (push) Has been cancelled

This commit is contained in:
2026-03-01 20:44:55 -06:00
parent 023587c48c
commit 1645896e54
20 changed files with 1916 additions and 168 deletions

View File

@@ -0,0 +1,83 @@
import { createHash, randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: true, SEED_DEFAULT_BUDGET: false });
await app.ready();
});
afterAll(async () => {
if (app) {
await app.close();
}
await prisma.$disconnect();
});
describe("Account delete access control", () => {
it("rejects confirm-delete when payload email targets another user", async () => {
const attackerEmail = `attacker-${Date.now()}@test.dev`;
const victimEmail = `victim-${Date.now()}@test.dev`;
const victimPassword = "VictimPass123!";
const deleteCode = "654321";
const [attacker, victim] = await Promise.all([
prisma.user.create({
data: {
email: attackerEmail,
passwordHash: await argon2.hash("AttackerPass123!"),
emailVerified: true,
},
}),
prisma.user.create({
data: {
email: victimEmail,
passwordHash: await argon2.hash(victimPassword),
emailVerified: true,
},
}),
]);
try {
await prisma.emailToken.create({
data: {
userId: victim.id,
type: "delete",
tokenHash: createHash("sha256").update(deleteCode).digest("hex"),
expiresAt: new Date(Date.now() + 60_000),
},
});
const csrf = randomUUID().replace(/-/g, "");
const res = await request(app.server)
.post("/account/confirm-delete")
.set("x-user-id", attacker.id)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.send({
email: victim.email,
code: deleteCode,
password: victimPassword,
});
expect(res.status).toBe(403);
const victimStillExists = await prisma.user.findUnique({
where: { id: victim.id },
select: { id: true },
});
expect(victimStillExists?.id).toBe(victim.id);
} finally {
await prisma.user.deleteMany({
where: { id: { in: [attacker.id, victim.id] } },
});
}
});
});

View File

@@ -0,0 +1,113 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let authEnabledApp: FastifyInstance;
let authDisabledApp: FastifyInstance;
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
if (!setCookie) return null;
for (const raw of setCookie) {
const firstPart = raw.split(";")[0] ?? "";
const [name, value] = firstPart.split("=");
if (name?.trim() === cookieName && value) return value.trim();
}
return null;
}
beforeAll(async () => {
authEnabledApp = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false });
await authEnabledApp.ready();
authDisabledApp = await buildApp({
NODE_ENV: "production",
AUTH_DISABLED: true,
ALLOW_INSECURE_AUTH_FOR_DEV: true,
SEED_DEFAULT_BUDGET: false,
CORS_ORIGINS: "https://allowed.example.com",
APP_ORIGIN: "https://allowed.example.com",
});
await authDisabledApp.ready();
});
afterAll(async () => {
if (authEnabledApp) await authEnabledApp.close();
if (authDisabledApp) await authDisabledApp.close();
await prisma.$disconnect();
});
describe("/admin/rollover access control", () => {
it("returns 401 without a valid authenticated session when AUTH_DISABLED=false", async () => {
const res = await request(authEnabledApp.server)
.post("/admin/rollover")
.send({ dryRun: true });
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("returns 403 for authenticated users when AUTH_DISABLED=false", async () => {
const agent = request.agent(authEnabledApp.server);
const email = `rollover-auth-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: {
email,
passwordHash: await argon2.hash(password),
emailVerified: true,
},
});
try {
const login = await agent.post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
expect(csrf).toBeTruthy();
const res = await agent
.post("/admin/rollover")
.set("x-csrf-token", csrf as string)
.send({ dryRun: true });
expect(res.status).toBe(403);
expect(res.body.ok).toBe(false);
} finally {
await prisma.user.deleteMany({ where: { email } });
}
});
it("returns 403 for non-internal client IP when AUTH_DISABLED=true", async () => {
const csrf = randomUUID().replace(/-/g, "");
const res = await request(authDisabledApp.server)
.post("/admin/rollover")
.set("x-user-id", `external-${Date.now()}`)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.set("x-forwarded-for", "8.8.8.8")
.send({ dryRun: true });
expect(res.status).toBe(403);
expect(res.body.ok).toBe(false);
});
it("allows internal client IP when AUTH_DISABLED=true", async () => {
const csrf = randomUUID().replace(/-/g, "");
const res = await request(authDisabledApp.server)
.post("/admin/rollover")
.set("x-user-id", `internal-${Date.now()}`)
.set("x-csrf-token", csrf)
.set("Cookie", `csrf=${csrf}`)
.set("x-forwarded-for", "10.23.45.67")
.send({ dryRun: true });
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
expect(res.body.dryRun).toBe(true);
expect(typeof res.body.processed).toBe("number");
});
});

View File

@@ -2,18 +2,33 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
if (!setCookie) return null;
for (const raw of setCookie) {
const firstPart = raw.split(";")[0] ?? "";
const [name, value] = firstPart.split("=");
if (name?.trim() === cookieName && value) {
return value.trim();
}
}
return null;
}
beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: true });
await app.ready();
});
afterAll(async () => {
await app.close();
if (app) {
await app.close();
}
await prisma.$disconnect();
});
@@ -24,6 +39,26 @@ describe("Auth routes", () => {
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("rejects spoofed x-user-id when auth is enabled", async () => {
const res = await request(app.server)
.get("/dashboard")
.set("x-user-id", "spoofed-user-id");
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("rejects weak passwords on registration", async () => {
const agent = request.agent(app.server);
const email = `weak-${Date.now()}@test.dev`;
const password = "weakpass123";
const register = await agent.post("/auth/register").send({ email, password });
expect(register.status).toBe(400);
expect(register.body.ok).toBe(false);
const user = await prisma.user.findUnique({ where: { email } });
expect(user).toBeNull();
});
it("registers a user and grants access via cookie session", async () => {
const agent = request.agent(app.server);
const email = `reg-${Date.now()}@test.dev`;
@@ -31,9 +66,10 @@ describe("Auth routes", () => {
const register = await agent.post("/auth/register").send({ email, password });
expect(register.status).toBe(200);
expect(register.body.needsVerification).toBe(true);
const dash = await agent.get("/dashboard");
expect(dash.status).toBe(200);
expect(dash.status).toBe(401);
const created = await prisma.user.findUniqueOrThrow({ where: { email } });
const [catCount, planCount] = await Promise.all([
@@ -52,7 +88,10 @@ describe("Auth routes", () => {
const password = "SupersAFE123!";
await agent.post("/auth/register").send({ email, password });
await agent.post("/auth/logout");
await prisma.user.update({
where: { email },
data: { emailVerified: true },
});
const login = await agent.post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
@@ -69,15 +108,54 @@ describe("Auth routes", () => {
const password = "SupersAFE123!";
await agent.post("/auth/register").send({ email, password });
await prisma.user.update({
where: { email },
data: { emailVerified: true },
});
const login = await agent.post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
expect(csrf).toBeTruthy();
const session = await agent.get("/auth/session");
expect(session.status).toBe(200);
expect(session.body.userId).toBeDefined();
await agent.post("/auth/logout");
await agent
.post("/auth/logout")
.set("x-csrf-token", csrf as string);
const afterLogout = await agent.get("/dashboard");
expect(afterLogout.status).toBe(401);
await prisma.user.deleteMany({ where: { email } });
});
it("locks login after repeated failed attempts", async () => {
const agent = request.agent(app.server);
const email = `locked-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: {
email,
passwordHash: await argon2.hash(password),
emailVerified: true,
},
});
for (let attempt = 1; attempt <= 4; attempt++) {
const res = await agent.post("/auth/login").send({ email, password: "WrongPass123!" });
expect(res.status).toBe(401);
}
const locked = await agent.post("/auth/login").send({ email, password: "WrongPass123!" });
expect(locked.status).toBe(429);
expect(locked.body.code).toBe("LOGIN_LOCKED");
expect(locked.headers["retry-after"]).toBeTruthy();
const blockedValid = await agent.post("/auth/login").send({ email, password });
expect(blockedValid.status).toBe(429);
expect(blockedValid.body.code).toBe("LOGIN_LOCKED");
await prisma.user.deleteMany({ where: { email } });
});
});

View File

@@ -0,0 +1,145 @@
import { createHmac } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { buildApp } from "../src/server";
const JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
const COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
let app: FastifyInstance;
function base64UrlEncode(value: string): string {
return Buffer.from(value)
.toString("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
}
function signHs256Token(payload: Record<string, unknown>, secret: string): string {
const header = { alg: "HS256", typ: "JWT" };
const encodedHeader = base64UrlEncode(JSON.stringify(header));
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
const signingInput = `${encodedHeader}.${encodedPayload}`;
const signature = createHmac("sha256", secret)
.update(signingInput)
.digest("base64")
.replace(/=/g, "")
.replace(/\+/g, "-")
.replace(/\//g, "_");
return `${signingInput}.${signature}`;
}
beforeAll(async () => {
app = await buildApp({
NODE_ENV: "test",
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
JWT_SECRET,
COOKIE_SECRET,
JWT_ISSUER: "skymoney-api",
JWT_AUDIENCE: "skymoney-web",
APP_ORIGIN: "http://localhost:5173",
});
await app.ready();
// Keep JWT verification behavior under test without requiring DB connectivity.
(app as any).ensureUser = async () => undefined;
});
afterAll(async () => {
if (app) await app.close();
});
describe("A04 Cryptographic Failures (runtime adversarial checks)", () => {
const csrf = "runtime-test-csrf-token";
it("rejects token with wrong issuer", async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const token = signHs256Token(
{
sub: `wrong-iss-${Date.now()}`,
iss: "attacker-issuer",
aud: "skymoney-web",
iat: nowSeconds,
exp: nowSeconds + 600,
},
JWT_SECRET
);
const res = await request(app.server)
.post("/auth/refresh")
.set("x-csrf-token", csrf)
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("rejects token with wrong audience", async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const token = signHs256Token(
{
sub: `wrong-aud-${Date.now()}`,
iss: "skymoney-api",
aud: "attacker-app",
iat: nowSeconds,
exp: nowSeconds + 600,
},
JWT_SECRET
);
const res = await request(app.server)
.post("/auth/refresh")
.set("x-csrf-token", csrf)
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("rejects token with alg=none (unsigned token)", async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const encodedHeader = base64UrlEncode(JSON.stringify({ alg: "none", typ: "JWT" }));
const encodedPayload = base64UrlEncode(
JSON.stringify({
sub: `none-alg-${Date.now()}`,
iss: "skymoney-api",
aud: "skymoney-web",
iat: nowSeconds,
exp: nowSeconds + 600,
})
);
const token = `${encodedHeader}.${encodedPayload}.`;
const res = await request(app.server)
.post("/auth/refresh")
.set("x-csrf-token", csrf)
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
expect(res.status).toBe(401);
expect(res.body.code).toBe("UNAUTHENTICATED");
});
it("accepts token with correct signature, issuer, and audience", async () => {
const nowSeconds = Math.floor(Date.now() / 1000);
const token = signHs256Token(
{
sub: `valid-${Date.now()}`,
iss: "skymoney-api",
aud: "skymoney-web",
iat: nowSeconds,
exp: nowSeconds + 600,
},
JWT_SECRET
);
const res = await request(app.server)
.post("/auth/refresh")
.set("x-csrf-token", csrf)
.set("Cookie", [`csrf=${csrf}`, `session=${token}`]);
expect(res.status).toBe(200);
expect(res.body.ok).toBe(true);
});
});

View File

@@ -0,0 +1,83 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
process.env = { ...ORIGINAL_ENV };
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
function setRequiredBaseEnv() {
process.env.DATABASE_URL = "postgres://app:app@127.0.0.1:5432/skymoney";
process.env.JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
process.env.COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
process.env.CORS_ORIGINS = "https://allowed.example.com";
process.env.AUTH_DISABLED = "0";
process.env.SEED_DEFAULT_BUDGET = "0";
}
describe("A04 Cryptographic Failures", () => {
it("rejects non-https APP_ORIGIN in production", async () => {
setRequiredBaseEnv();
process.env.NODE_ENV = "production";
process.env.APP_ORIGIN = "http://allowed.example.com";
await expect(import("../src/env")).rejects.toThrow(
"APP_ORIGIN must use https:// in production."
);
});
it("applies secure JWT defaults for issuer and audience", async () => {
setRequiredBaseEnv();
process.env.NODE_ENV = "production";
process.env.APP_ORIGIN = "https://allowed.example.com";
delete process.env.JWT_ISSUER;
delete process.env.JWT_AUDIENCE;
const envModule = await import("../src/env");
expect(envModule.env.JWT_ISSUER).toBe("skymoney-api");
expect(envModule.env.JWT_AUDIENCE).toBe("skymoney-web");
});
it("registers fastify-jwt with explicit algorithm and claim validation", async () => {
const jwtPlugin = vi.fn(async () => undefined);
vi.doMock("@fastify/jwt", () => ({
default: jwtPlugin,
}));
const { buildApp } = await import("../src/server");
const app = await buildApp({
NODE_ENV: "test",
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
JWT_SECRET: "test-jwt-secret-32-chars-min-abcdef",
COOKIE_SECRET: "test-cookie-secret-32-chars-abcdef",
AUTH_DISABLED: true,
SEED_DEFAULT_BUDGET: false,
APP_ORIGIN: "http://localhost:5173",
});
try {
await app.ready();
expect(jwtPlugin.mock.calls.length).toBeGreaterThan(0);
const jwtCall = jwtPlugin.mock.calls.find((call) => {
const opts = call[1] as Record<string, any> | undefined;
return !!opts?.sign && !!opts?.verify;
});
expect(jwtCall).toBeTruthy();
const jwtOptions = jwtCall?.[1] as Record<string, any>;
expect(jwtOptions.sign.algorithm).toBe("HS256");
expect(jwtOptions.sign.iss).toBe("skymoney-api");
expect(jwtOptions.sign.aud).toBe("skymoney-web");
expect(jwtOptions.verify.algorithms).toEqual(["HS256"]);
expect(jwtOptions.verify.allowedIss).toBe("skymoney-api");
expect(jwtOptions.verify.allowedAud).toBe("skymoney-web");
} finally {
await app.close();
}
});
});

View File

@@ -0,0 +1,94 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
if (!setCookie) return null;
for (const raw of setCookie) {
const firstPart = raw.split(";")[0] ?? "";
const [name, value] = firstPart.split("=");
if (name?.trim() === cookieName && value) return value.trim();
}
return null;
}
beforeAll(async () => {
app = await buildApp({
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
AUTH_MAX_FAILED_ATTEMPTS: 3,
AUTH_LOCKOUT_WINDOW_MS: 120_000,
});
await app.ready();
});
afterAll(async () => {
if (app) await app.close();
await prisma.$disconnect();
});
describe("A07 Identification and Authentication Failures", () => {
it("rejects weak passwords on registration and password updates", async () => {
const regEmail = `weak-reg-${Date.now()}@test.dev`;
const weakRegister = await request(app.server)
.post("/auth/register")
.send({ email: regEmail, password: "weakpass123" });
expect(weakRegister.status).toBe(400);
const email = `pw-update-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: { email, passwordHash: await argon2.hash(password), emailVerified: true },
});
const login = await request(app.server).post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c) => c.startsWith("session="));
expect(csrf).toBeTruthy();
expect(sessionCookie).toBeTruthy();
const weakUpdate = await request(app.server)
.patch("/me/password")
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
.set("x-csrf-token", csrf as string)
.send({ currentPassword: password, newPassword: "weakpass123" });
expect(weakUpdate.status).toBe(400);
await prisma.user.deleteMany({ where: { email } });
});
it("locks login according to configured threshold/window", async () => {
const email = `lockout-threshold-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: { email, passwordHash: await argon2.hash(password), emailVerified: true },
});
for (let i = 0; i < 2; i++) {
const fail = await request(app.server).post("/auth/login").send({
email,
password: "WrongPass123!",
});
expect(fail.status).toBe(401);
}
const locked = await request(app.server).post("/auth/login").send({
email,
password: "WrongPass123!",
});
expect(locked.status).toBe(429);
expect(locked.body.code).toBe("LOGIN_LOCKED");
expect(locked.headers["retry-after"]).toBeTruthy();
const blockedValid = await request(app.server).post("/auth/login").send({ email, password });
expect(blockedValid.status).toBe(429);
await prisma.user.deleteMany({ where: { email } });
});
});

View File

@@ -0,0 +1,99 @@
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { describe, expect, it } from "vitest";
describe("A05 Injection Safety", () => {
function runNormalizedScript(scriptPath: string, env: Record<string, string>) {
const tmpScriptDir = mkdtempSync(join(tmpdir(), "skymoney-a05-script-"));
const normalizedScript = join(tmpScriptDir, "script.sh");
writeFileSync(normalizedScript, readFileSync(scriptPath, "utf8").replace(/\r/g, ""));
chmodSync(normalizedScript, 0o755);
const res = spawnSync("bash", [normalizedScript], {
env: { ...process.env, ...env },
encoding: "utf8",
});
rmSync(tmpScriptDir, { recursive: true, force: true });
return res;
}
it("contains no unsafe Prisma raw SQL APIs across API source", () => {
const apiRoot = resolve(__dirname, "..");
const badPatterns = ["$queryRawUnsafe(", "$executeRawUnsafe("];
const queue = [resolve(apiRoot, "src")];
const offenders: string[] = [];
while (queue.length > 0) {
const current = queue.pop() as string;
for (const name of readdirSync(current)) {
const full = join(current, name);
if (statSync(full).isDirectory()) {
queue.push(full);
continue;
}
if (!full.endsWith(".ts")) continue;
const content = readFileSync(full, "utf8");
if (badPatterns.some((p) => content.includes(p))) offenders.push(full);
}
}
expect(offenders).toEqual([]);
});
it("rejects malicious restore DB identifiers before DB command execution", () => {
const repoRoot = resolve(__dirname, "..", "..");
const restoreScriptPath = resolve(repoRoot, "scripts/restore.sh");
const tmpRoot = mkdtempSync(join(tmpdir(), "skymoney-a05-restore-"));
const fakeBin = join(tmpRoot, "bin");
const backupFile = join(tmpRoot, "sample.dump");
const checksumFile = `${backupFile}.sha256`;
const markerFile = join(tmpRoot, "db_called.marker");
try {
mkdirSync(fakeBin, { recursive: true });
writeFileSync(backupFile, "valid-content");
const backupBytes = readFileSync(backupFile);
const hash = createHash("sha256").update(backupBytes).digest("hex");
writeFileSync(checksumFile, `${hash} sample.dump\n`);
const fakePsql = join(fakeBin, "psql");
const fakePgRestore = join(fakeBin, "pg_restore");
const fakeSha256sum = join(fakeBin, "sha256sum");
writeFileSync(fakePsql, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
writeFileSync(fakePgRestore, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
writeFileSync(
fakeSha256sum,
`#!/usr/bin/env bash\nset -euo pipefail\necho "${hash} $1"\n`
);
chmodSync(fakePsql, 0o755);
chmodSync(fakePgRestore, 0o755);
chmodSync(fakeSha256sum, 0o755);
const res = runNormalizedScript(restoreScriptPath, {
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
BACKUP_FILE: backupFile,
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
RESTORE_DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney_restore_test",
RESTORE_DB: 'bad-name"; DROP DATABASE skymoney; --',
});
expect(res.status).not.toBe(0);
expect(`${res.stdout}${res.stderr}`).toContain("RESTORE_DB must match");
expect(existsSync(markerFile)).toBe(false);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,104 @@
import { randomUUID } from "node:crypto";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { PrismaClient } from "@prisma/client";
import argon2 from "argon2";
import { buildApp } from "../src/server";
const prisma = new PrismaClient();
let app: FastifyInstance;
function readCookieValue(setCookie: string[] | undefined, cookieName: string): string | null {
if (!setCookie) return null;
for (const raw of setCookie) {
const firstPart = raw.split(";")[0] ?? "";
const [name, value] = firstPart.split("=");
if (name?.trim() === cookieName && value) return value.trim();
}
return null;
}
beforeAll(async () => {
app = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false });
await app.ready();
});
afterAll(async () => {
if (app) await app.close();
await prisma.$disconnect();
});
describe("A06 Insecure Design", () => {
it("enforces resend-code cooldown with 429 and Retry-After", async () => {
const email = `cooldown-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await request(app.server).post("/auth/register").send({ email, password });
// Registration issues a signup token; immediate resend should be cooldown-blocked.
const resend = await request(app.server).post("/auth/verify/resend").send({ email });
expect(resend.status).toBe(429);
expect(resend.body.code).toBe("EMAIL_TOKEN_COOLDOWN");
expect(resend.headers["retry-after"]).toBeTruthy();
await prisma.user.deleteMany({ where: { email } });
});
it("enforces delete-code cooldown with 429 and Retry-After", async () => {
const email = `delete-cooldown-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await prisma.user.create({
data: {
email,
passwordHash: await argon2.hash(password),
emailVerified: true,
},
});
const login = await request(app.server).post("/auth/login").send({ email, password });
expect(login.status).toBe(200);
const csrf = readCookieValue(login.headers["set-cookie"], "csrf");
const sessionCookie = (login.headers["set-cookie"] ?? []).find((c) => c.startsWith("session="));
expect(csrf).toBeTruthy();
expect(sessionCookie).toBeTruthy();
const first = await request(app.server)
.post("/account/delete-request")
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
.set("x-csrf-token", csrf as string)
.send({ password });
expect(first.status).toBe(200);
const second = await request(app.server)
.post("/account/delete-request")
.set("Cookie", [sessionCookie as string, `csrf=${csrf}`])
.set("x-csrf-token", csrf as string)
.send({ password });
expect(second.status).toBe(429);
expect(second.body.code).toBe("EMAIL_TOKEN_COOLDOWN");
expect(second.headers["retry-after"]).toBeTruthy();
await prisma.user.deleteMany({ where: { email } });
});
it("rate-limits repeated invalid verification attempts", async () => {
const email = `verify-rate-${Date.now()}@test.dev`;
const password = "SupersAFE123!";
await request(app.server).post("/auth/register").send({ email, password });
let limited = false;
for (let i = 0; i < 12; i++) {
const res = await request(app.server)
.post("/auth/verify")
.send({ email, code: randomUUID().slice(0, 6) });
if (res.status === 429) {
limited = true;
break;
}
}
expect(limited).toBe(true);
await prisma.user.deleteMany({ where: { email } });
});
});

View File

@@ -0,0 +1,77 @@
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import request from "supertest";
import type { FastifyInstance } from "fastify";
import { buildApp } from "../src/server";
let authApp: FastifyInstance;
let csrfApp: FastifyInstance;
const capturedEvents: Array<Record<string, unknown>> = [];
function attachSecurityEventCapture(app: FastifyInstance) {
const logger = app.log as any;
const originalChild = logger.child.bind(logger);
logger.child = (...args: any[]) => {
const child = originalChild(...args);
const originalWarn = child.warn.bind(child);
child.warn = (obj: unknown, ...rest: unknown[]) => {
if (obj && typeof obj === "object") {
const payload = obj as Record<string, unknown>;
if (typeof payload.securityEvent === "string") capturedEvents.push(payload);
}
return originalWarn(obj, ...rest);
};
return child;
};
}
beforeAll(async () => {
authApp = await buildApp({ AUTH_DISABLED: false, SEED_DEFAULT_BUDGET: false });
await authApp.ready();
(authApp as any).ensureUser = async () => undefined;
attachSecurityEventCapture(authApp);
csrfApp = await buildApp({ AUTH_DISABLED: true, SEED_DEFAULT_BUDGET: false });
await csrfApp.ready();
(csrfApp as any).ensureUser = async () => undefined;
attachSecurityEventCapture(csrfApp);
});
afterAll(async () => {
if (authApp) await authApp.close();
if (csrfApp) await csrfApp.close();
});
describe("A09 Security Logging and Monitoring Failures", () => {
it("emits structured security log for unauthenticated protected-route access", async () => {
capturedEvents.length = 0;
const res = await request(authApp.server).get("/dashboard");
expect(res.status).toBe(401);
const event = capturedEvents.find((payload) => {
return payload.securityEvent === "auth.unauthenticated_request";
});
expect(event).toBeTruthy();
expect(event?.outcome).toBe("failure");
expect(typeof event?.requestId).toBe("string");
expect(typeof event?.ip).toBe("string");
});
it("emits structured security log for csrf validation failures", async () => {
capturedEvents.length = 0;
const res = await request(csrfApp.server)
.post("/me")
.set("x-user-id", `csrf-user-${Date.now()}`)
.send({ displayName: "NoCsrf" });
expect(res.status).toBe(403);
expect(res.body.code).toBe("CSRF");
const event = capturedEvents.find((payload) => payload.securityEvent === "csrf.validation");
expect(event).toBeTruthy();
expect(event?.outcome).toBe("failure");
expect(typeof event?.requestId).toBe("string");
expect(typeof event?.ip).toBe("string");
});
});

View File

@@ -0,0 +1,150 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
afterEach(async () => {
vi.resetAllMocks();
vi.resetModules();
vi.doUnmock("nodemailer");
});
describe("A02 Security Misconfiguration", () => {
it("enforces CORS allowlist in production", async () => {
const { buildApp } = await import("../src/server");
const app = await buildApp({
NODE_ENV: "production",
CORS_ORIGINS: "https://allowed.example.com",
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
APP_ORIGIN: "https://allowed.example.com",
});
await app.ready();
try {
const allowedOrigin = "https://allowed.example.com";
const deniedOrigin = "https://denied.example.com";
const allowed = await app.inject({
method: "OPTIONS",
url: "/health",
headers: {
origin: allowedOrigin,
"access-control-request-method": "GET",
},
});
expect(allowed.statusCode).toBe(204);
expect(allowed.headers["access-control-allow-origin"]).toBe(allowedOrigin);
expect(allowed.headers["access-control-allow-credentials"]).toBe("true");
const denied = await app.inject({
method: "OPTIONS",
url: "/health",
headers: {
origin: deniedOrigin,
"access-control-request-method": "GET",
},
});
expect([204, 404]).toContain(denied.statusCode);
expect(denied.headers["access-control-allow-origin"]).toBeUndefined();
expect(denied.headers["access-control-allow-credentials"]).toBeUndefined();
} finally {
await app.close();
}
});
it("applies expected CORS response headers at runtime for allowed origins", async () => {
const { buildApp } = await import("../src/server");
const app = await buildApp({
NODE_ENV: "production",
CORS_ORIGINS: "https://allowed.example.com",
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
APP_ORIGIN: "https://allowed.example.com",
});
await app.ready();
try {
const response = await app.inject({
method: "OPTIONS",
url: "/health",
headers: {
origin: "https://allowed.example.com",
"access-control-request-method": "GET",
},
});
expect(response.statusCode).toBe(204);
expect(response.headers["access-control-allow-origin"]).toBe(
"https://allowed.example.com"
);
expect(response.headers["access-control-allow-credentials"]).toBe("true");
expect(response.headers.vary).toContain("Origin");
} finally {
await app.close();
}
});
it("disables SMTP debug logger in production mailer config", async () => {
const verify = vi.fn(async () => undefined);
const sendMail = vi.fn(async () => ({ messageId: "mock-message-id" }));
const createTransport = vi.fn(() => ({ verify, sendMail }));
vi.doMock("nodemailer", () => ({
default: { createTransport },
}));
const { buildApp } = await import("../src/server");
const app = await buildApp({
NODE_ENV: "production",
SMTP_HOST: "smtp.example.com",
SMTP_PORT: 587,
SMTP_REQUIRE_TLS: true,
SMTP_TLS_REJECT_UNAUTHORIZED: true,
SMTP_USER: "smtp-user",
SMTP_PASS: "smtp-pass",
CORS_ORIGINS: "https://allowed.example.com",
APP_ORIGIN: "https://allowed.example.com",
AUTH_DISABLED: false,
SEED_DEFAULT_BUDGET: false,
});
try {
await app.ready();
expect(createTransport).toHaveBeenCalledTimes(1);
const transportOptions = createTransport.mock.calls[0]?.[0] as Record<string, unknown>;
expect(transportOptions.requireTLS).toBe(true);
expect(transportOptions.logger).toBe(false);
expect(transportOptions.debug).toBe(false);
} finally {
await app.close();
}
});
it("defines anti-framing headers in edge configs", () => {
const root = resolve(__dirname, "..", "..");
const caddyProd = readFileSync(resolve(root, "Caddyfile.prod"), "utf8");
const caddyDev = readFileSync(resolve(root, "Caddyfile.dev"), "utf8");
const nginxProd = readFileSync(
resolve(root, "deploy/nginx/skymoneybudget.com.conf"),
"utf8"
);
expect(caddyProd).toContain('X-Frame-Options "DENY"');
expect(caddyProd).toContain(`Content-Security-Policy "frame-ancestors 'none'"`);
expect(caddyDev).toContain('X-Frame-Options "DENY"');
expect(caddyDev).toContain(`Content-Security-Policy "frame-ancestors 'none'"`);
expect(nginxProd).toContain('add_header X-Frame-Options "DENY" always;');
expect(nginxProd).toContain(
`add_header Content-Security-Policy "frame-ancestors 'none'" always;`
);
});
it("keeps docker-compose service ports bound to localhost only", () => {
const root = resolve(__dirname, "..", "..");
const compose = readFileSync(resolve(root, "docker-compose.yml"), "utf8");
expect(compose).toContain('- "127.0.0.1:5432:5432"');
expect(compose).toContain('- "127.0.0.1:8081:8080"');
});
});

View File

@@ -0,0 +1,82 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { join, resolve } from "node:path";
const ORIGINAL_ENV = { ...process.env };
beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
process.env = { ...ORIGINAL_ENV };
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
function setRequiredBaseEnv() {
process.env.DATABASE_URL = "postgres://app:app@127.0.0.1:5432/skymoney";
process.env.JWT_SECRET = "test-jwt-secret-32-chars-min-abcdef";
process.env.COOKIE_SECRET = "test-cookie-secret-32-chars-abcdef";
process.env.CORS_ORIGINS = "https://allowed.example.com";
process.env.AUTH_DISABLED = "0";
process.env.SEED_DEFAULT_BUDGET = "0";
}
describe("A10 Server-Side Request Forgery", () => {
it("rejects localhost/private APP_ORIGIN values in production", async () => {
const blockedOrigins = [
"https://127.0.0.1:5173",
"https://10.0.0.5:5173",
"https://192.168.1.10:5173",
"https://172.16.5.20:5173",
"https://localhost:5173",
"https://app.local:5173",
"https://[::1]:5173",
];
for (const origin of blockedOrigins) {
setRequiredBaseEnv();
process.env.NODE_ENV = "production";
process.env.APP_ORIGIN = origin;
await expect(import("../src/env")).rejects.toThrow(
"APP_ORIGIN must not point to localhost or private network hosts in production."
);
vi.resetModules();
}
});
it("accepts public APP_ORIGIN in production", async () => {
setRequiredBaseEnv();
process.env.NODE_ENV = "production";
process.env.APP_ORIGIN = "https://app.example.com";
const envModule = await import("../src/env");
expect(envModule.env.APP_ORIGIN).toBe("https://app.example.com");
});
it("keeps API source free of generic outbound HTTP request clients", () => {
const apiRoot = resolve(__dirname, "..");
const srcRoot = resolve(apiRoot, "src");
const queue = [srcRoot];
const offenders: string[] = [];
const patterns = ["fetch(", "axios", "http.request(", "https.request("];
while (queue.length > 0) {
const current = queue.pop() as string;
for (const name of readdirSync(current)) {
const full = join(current, name);
if (statSync(full).isDirectory()) {
if (full.includes(`${join("src", "scripts")}`)) continue;
queue.push(full);
continue;
}
if (!full.endsWith(".ts")) continue;
const content = readFileSync(full, "utf8");
if (patterns.some((p) => content.includes(p))) offenders.push(full);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -1,16 +1,49 @@
import { execSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, afterAll } from "vitest";
import { PrismaClient } from "@prisma/client";
function readEnvValue(filePath: string, key: string): string | undefined {
if (!existsSync(filePath)) return undefined;
const content = readFileSync(filePath, "utf8");
const line = content
.split(/\r?\n/)
.find((raw) => raw.trim().startsWith(`${key}=`));
if (!line) return undefined;
const value = line.slice(line.indexOf("=") + 1).trim();
return value.length > 0 ? value : undefined;
}
function resolveDatabaseUrl(): string {
if (process.env.TEST_DATABASE_URL?.trim()) return process.env.TEST_DATABASE_URL.trim();
if (process.env.BACKUP_DATABASE_URL?.trim()) return process.env.BACKUP_DATABASE_URL.trim();
if (process.env.DATABASE_URL?.trim()) return process.env.DATABASE_URL.trim();
const envPaths = [resolve(process.cwd(), ".env"), resolve(process.cwd(), "..", ".env")];
for (const envPath of envPaths) {
const testUrl = readEnvValue(envPath, "TEST_DATABASE_URL");
if (testUrl) return testUrl;
const backupUrl = readEnvValue(envPath, "BACKUP_DATABASE_URL");
if (backupUrl) return backupUrl;
const dbUrl = readEnvValue(envPath, "DATABASE_URL");
if (dbUrl) return dbUrl.replace("@postgres:", "@127.0.0.1:");
}
return "postgres://app:app@127.0.0.1:5432/skymoney";
}
process.env.NODE_ENV = process.env.NODE_ENV || "test";
process.env.DATABASE_URL =
process.env.DATABASE_URL ||
"postgres://app:app@localhost:5432/skymoney";
process.env.PORT = process.env.PORT || "0"; // fastify can bind an ephemeral port
process.env.DATABASE_URL = resolveDatabaseUrl();
process.env.PORT = process.env.PORT || "8081";
process.env.HOST ??= "127.0.0.1";
process.env.CORS_ORIGIN = process.env.CORS_ORIGIN || "";
process.env.AUTH_DISABLED = process.env.AUTH_DISABLED || "1";
process.env.SEED_DEFAULT_BUDGET = process.env.SEED_DEFAULT_BUDGET || "1";
process.env.JWT_SECRET =
process.env.JWT_SECRET || "test-jwt-secret-32-chars-min-abcdef";
process.env.COOKIE_SECRET =
process.env.COOKIE_SECRET || "test-cookie-secret-32-chars-abcdef";
export const prisma = new PrismaClient();
@@ -25,8 +58,14 @@ export async function resetUser(userId: string) {
}
beforeAll(async () => {
// make sure the schema is applied before running tests
execSync("npx prisma migrate deploy", { stdio: "inherit" });
// Optional schema bootstrap for CI/local environments that can run Prisma CLI.
if (process.env.TEST_APPLY_SCHEMA === "1") {
try {
execSync("npx prisma migrate deploy", { stdio: "inherit" });
} catch {
execSync("npx prisma db push --skip-generate --accept-data-loss", { stdio: "inherit" });
}
}
// Ensure a clean slate: wipe all tables to avoid cross-file leakage
await prisma.$transaction([

View File

@@ -0,0 +1,95 @@
import {
chmodSync,
mkdtempSync,
rmSync,
writeFileSync,
existsSync,
readdirSync,
readFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { spawnSync } from "node:child_process";
import { describe, expect, it } from "vitest";
function runScript(scriptPath: string, env: Record<string, string>) {
const tmpScriptDir = mkdtempSync(join(tmpdir(), "skymoney-a08-script-"));
const normalizedScript = join(tmpScriptDir, "script.sh");
writeFileSync(normalizedScript, readFileSync(scriptPath, "utf8").replace(/\r/g, ""));
chmodSync(normalizedScript, 0o755);
const result = spawnSync("bash", [normalizedScript], {
env: { ...process.env, ...env },
encoding: "utf8",
});
rmSync(tmpScriptDir, { recursive: true, force: true });
return result;
}
describe("A08 Software and Data Integrity Failures", () => {
it("creates checksum artifacts during backup execution", () => {
const repoRoot = resolve(__dirname, "..", "..");
const backupScriptPath = resolve(repoRoot, "scripts/backup.sh");
const tmpRoot = mkdtempSync(join(tmpdir(), "skymoney-a08-backup-"));
const fakeBin = join(tmpRoot, "bin");
const outDir = join(tmpRoot, "backups");
const fakePgDump = join(fakeBin, "pg_dump");
try {
spawnSync("mkdir", ["-p", fakeBin], { encoding: "utf8" });
writeFileSync(
fakePgDump,
"#!/usr/bin/env bash\nset -euo pipefail\nout=''\nwhile [[ $# -gt 0 ]]; do\n if [[ \"$1\" == '-f' ]]; then out=\"$2\"; shift 2; continue; fi\n shift\ndone\nprintf 'fake-dump-data' > \"$out\"\n"
);
chmodSync(fakePgDump, 0o755);
const res = runScript(backupScriptPath, {
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
BACKUP_DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
BACKUP_DIR: outDir,
});
expect(res.status).toBe(0);
const names = readdirSync(outDir);
expect(names.some((n) => n.endsWith(".dump"))).toBe(true);
expect(names.some((n) => n.endsWith(".sha256"))).toBe(true);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
it("blocks restore on checksum mismatch before DB commands execute", () => {
const repoRoot = resolve(__dirname, "..", "..");
const restoreScriptPath = resolve(repoRoot, "scripts/restore.sh");
const tmpRoot = mkdtempSync(join(tmpdir(), "skymoney-a08-restore-"));
const fakeBin = join(tmpRoot, "bin");
const backupFile = join(tmpRoot, "sample.dump");
const checksumFile = `${backupFile}.sha256`;
const markerFile = join(tmpRoot, "db_called.marker");
try {
spawnSync("mkdir", ["-p", fakeBin], { encoding: "utf8" });
writeFileSync(backupFile, "tampered-content");
writeFileSync(checksumFile, `${"0".repeat(64)} sample.dump\n`);
const fakePsql = join(fakeBin, "psql");
const fakePgRestore = join(fakeBin, "pg_restore");
writeFileSync(fakePsql, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
writeFileSync(fakePgRestore, `#!/usr/bin/env bash\nset -euo pipefail\ntouch "${markerFile}"\n`);
chmodSync(fakePsql, 0o755);
chmodSync(fakePgRestore, 0o755);
const res = runScript(restoreScriptPath, {
PATH: `${fakeBin}:${process.env.PATH ?? ""}`,
BACKUP_FILE: backupFile,
DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney",
RESTORE_DATABASE_URL: "postgres://app:app@127.0.0.1:5432/skymoney_restore_test",
});
expect(res.status).not.toBe(0);
expect(`${res.stdout}${res.stderr}`).toContain("checksum verification failed");
expect(existsSync(markerFile)).toBe(false);
} finally {
rmSync(tmpRoot, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
describe("A03 Software Supply Chain Failures", () => {
it("enforces deploy workflow dependency-audit gate for api and web", () => {
const repoRoot = resolve(__dirname, "..", "..");
const deployWorkflow = readFileSync(
resolve(repoRoot, ".gitea/workflows/deploy.yml"),
"utf8"
);
expect(deployWorkflow).toContain("name: Supply chain checks (production dependencies)");
expect(deployWorkflow).toContain("cd api");
expect(deployWorkflow).toContain("cd ../web");
const npmCiMatches = deployWorkflow.match(/\bnpm ci\b/g) ?? [];
expect(npmCiMatches.length).toBeGreaterThanOrEqual(2);
const auditMatches =
deployWorkflow.match(/npm audit --omit=dev --audit-level=high/g) ?? [];
expect(auditMatches.length).toBeGreaterThanOrEqual(2);
});
it("pins checkout action to an explicit version tag", () => {
const repoRoot = resolve(__dirname, "..", "..");
const deployWorkflow = readFileSync(
resolve(repoRoot, ".gitea/workflows/deploy.yml"),
"utf8"
);
expect(deployWorkflow).toMatch(/uses:\s*actions\/checkout@v\d+\.\d+\.\d+/);
});
});