151 lines
5.0 KiB
TypeScript
151 lines
5.0 KiB
TypeScript
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"');
|
|
});
|
|
});
|