chore: ran security check for OWASP top 10
Some checks failed
Deploy / deploy (push) Has been cancelled
Some checks failed
Deploy / deploy (push) Has been cancelled
This commit is contained in:
83
api/tests/access-control.account-delete.test.ts
Normal file
83
api/tests/access-control.account-delete.test.ts
Normal 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] } },
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
113
api/tests/access-control.admin-rollover.test.ts
Normal file
113
api/tests/access-control.admin-rollover.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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 } });
|
||||
});
|
||||
});
|
||||
|
||||
145
api/tests/cryptographic-failures.runtime.test.ts
Normal file
145
api/tests/cryptographic-failures.runtime.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
83
api/tests/cryptographic-failures.test.ts
Normal file
83
api/tests/cryptographic-failures.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
94
api/tests/identification-auth-failures.test.ts
Normal file
94
api/tests/identification-auth-failures.test.ts
Normal 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 } });
|
||||
});
|
||||
});
|
||||
99
api/tests/injection-safety.test.ts
Normal file
99
api/tests/injection-safety.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
104
api/tests/insecure-design.test.ts
Normal file
104
api/tests/insecure-design.test.ts
Normal 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 } });
|
||||
});
|
||||
});
|
||||
77
api/tests/security-logging-monitoring-failures.test.ts
Normal file
77
api/tests/security-logging-monitoring-failures.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
150
api/tests/security-misconfiguration.test.ts
Normal file
150
api/tests/security-misconfiguration.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
82
api/tests/server-side-request-forgery.test.ts
Normal file
82
api/tests/server-side-request-forgery.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([
|
||||
|
||||
95
api/tests/software-data-integrity-failures.test.ts
Normal file
95
api/tests/software-data-integrity-failures.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
34
api/tests/software-supply-chain-failures.test.ts
Normal file
34
api/tests/software-supply-chain-failures.test.ts
Normal 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+/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user