174 行
5.2 KiB
JavaScript
174 行
5.2 KiB
JavaScript
const crypto = require("node:crypto");
|
|
const fp = require("fastify-plugin");
|
|
|
|
const { buildApp } = require("../../src/app");
|
|
const { getConfig } = require("../../src/config");
|
|
const {
|
|
generateSixDigitCode,
|
|
hashAuthCode,
|
|
timingSafeEqualHex,
|
|
} = require("../../src/utils/crypto");
|
|
|
|
const STRONG_PEPPER = "test-auth-code-pepper-with-at-least-32-bytes";
|
|
|
|
function stubBaseEnv(nodeEnv = "test") {
|
|
vi.stubEnv("NODE_ENV", nodeEnv);
|
|
vi.stubEnv("JWT_SECRET", "test-jwt-secret");
|
|
vi.stubEnv("DATABASE_URL", "");
|
|
vi.stubEnv("AUTH_CODE_PEPPER", nodeEnv === "production" ? STRONG_PEPPER : "");
|
|
vi.stubEnv("TRUST_PROXY_HOPS", nodeEnv === "production" ? "1" : undefined);
|
|
vi.stubEnv("JOBS_ENABLED", "false");
|
|
vi.stubEnv("METRICS_ENABLED", "false");
|
|
vi.stubEnv("SERVE_CLIENT_DIST", "false");
|
|
vi.stubEnv("OPENAPI_DOCS_ENABLED", "false");
|
|
}
|
|
|
|
function createPrismaPlugin() {
|
|
return fp(async (app) => {
|
|
app.decorate("prisma", {
|
|
$queryRaw: vi.fn().mockResolvedValue([{ ok: 1 }]),
|
|
employee: {
|
|
findUnique: vi.fn(),
|
|
},
|
|
});
|
|
}, { name: "test-prisma" });
|
|
}
|
|
|
|
async function buildIpProbeApp() {
|
|
const app = await buildApp({
|
|
prismaPlugin: createPrismaPlugin(),
|
|
startJobs: vi.fn(async () => ({ driver: "disabled" })),
|
|
});
|
|
app.get("/__test/ip", async (request) => ({
|
|
ip: request.ip,
|
|
ips: request.ips,
|
|
}));
|
|
return app;
|
|
}
|
|
|
|
describe("auth security configuration", () => {
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
it("uses crypto.randomInt for padded six-digit codes", () => {
|
|
const randomSpy = vi.spyOn(crypto, "randomInt").mockReturnValue(42);
|
|
|
|
expect(generateSixDigitCode()).toBe("000042");
|
|
expect(randomSpy).toHaveBeenCalledWith(0, 1_000_000);
|
|
});
|
|
|
|
it("separates auth code hashes by purpose and compares them safely", () => {
|
|
const common = {
|
|
employeeId: "11111111-1111-4111-8111-111111111111",
|
|
code: "123456",
|
|
pepper: STRONG_PEPPER,
|
|
};
|
|
const emailHash = hashAuthCode({ ...common, purpose: "email_login" });
|
|
const resetHash = hashAuthCode({ ...common, purpose: "password_reset" });
|
|
|
|
expect(emailHash).not.toBe(resetHash);
|
|
expect(timingSafeEqualHex(emailHash, emailHash)).toBe(true);
|
|
expect(timingSafeEqualHex(emailHash, resetHash)).toBe(false);
|
|
expect(timingSafeEqualHex(emailHash, "not-hex")).toBe(false);
|
|
});
|
|
|
|
it("fails closed when production has no auth code pepper", () => {
|
|
stubBaseEnv("production");
|
|
vi.stubEnv("AUTH_CODE_PEPPER", "");
|
|
|
|
expect(() => getConfig()).toThrow("Missing required env: AUTH_CODE_PEPPER");
|
|
});
|
|
|
|
it.each([
|
|
"<managed-auth-code-pepper>",
|
|
"short-secret",
|
|
])("rejects a weak production auth code pepper", (pepper) => {
|
|
stubBaseEnv("production");
|
|
vi.stubEnv("AUTH_CODE_PEPPER", pepper);
|
|
|
|
expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be a non-placeholder secret");
|
|
});
|
|
|
|
it("rejects a production auth code pepper reused as the JWT secret", () => {
|
|
stubBaseEnv("production");
|
|
vi.stubEnv("JWT_SECRET", STRONG_PEPPER);
|
|
vi.stubEnv("AUTH_CODE_PEPPER", STRONG_PEPPER);
|
|
|
|
expect(() => getConfig()).toThrow("AUTH_CODE_PEPPER must be independent from JWT_SECRET");
|
|
});
|
|
|
|
it("forbids development password reset code logging in production", () => {
|
|
stubBaseEnv("production");
|
|
vi.stubEnv("DEV_SHOW_PASSWORD_RESET_CODE", "true");
|
|
|
|
expect(() => getConfig()).toThrow("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production");
|
|
});
|
|
|
|
it.each([
|
|
[undefined, "Missing required env: TRUST_PROXY_HOPS"],
|
|
["", "Invalid TRUST_PROXY_HOPS"],
|
|
["0", "Invalid TRUST_PROXY_HOPS"],
|
|
["2", "Invalid TRUST_PROXY_HOPS"],
|
|
["true", "Invalid TRUST_PROXY_HOPS"],
|
|
])("requires exactly one trusted proxy hop in production", (value, error) => {
|
|
stubBaseEnv("production");
|
|
vi.stubEnv("TRUST_PROXY_HOPS", value);
|
|
|
|
expect(() => getConfig()).toThrow(error);
|
|
});
|
|
|
|
it.each([
|
|
["PASSWORD_RESET_MAX_ATTEMPTS", "0"],
|
|
["PASSWORD_RESET_LOCK_SECONDS", "-1"],
|
|
["EMAIL_LOGIN_CODE_TTL_SECONDS", "NaN"],
|
|
["EMAIL_LOGIN_CODE_MAX_ATTEMPTS", "21"],
|
|
])("rejects unsafe numeric auth configuration", (name, value) => {
|
|
stubBaseEnv();
|
|
vi.stubEnv(name, value);
|
|
|
|
expect(() => getConfig()).toThrow(`Invalid ${name}`);
|
|
});
|
|
|
|
it("ignores a forged X-Forwarded-For header by default", async () => {
|
|
stubBaseEnv();
|
|
const app = await buildIpProbeApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/__test/ip",
|
|
headers: { "x-forwarded-for": "10.10.10.10" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({ ip: "127.0.0.1" });
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
}, 20_000);
|
|
|
|
it("trusts only the nearest address supplied by one controlled proxy", async () => {
|
|
stubBaseEnv();
|
|
vi.stubEnv("TRUST_PROXY_HOPS", "1");
|
|
const app = await buildIpProbeApp();
|
|
|
|
try {
|
|
const response = await app.inject({
|
|
method: "GET",
|
|
url: "/__test/ip",
|
|
headers: { "x-forwarded-for": "10.10.10.10, 192.168.1.20" },
|
|
});
|
|
|
|
expect(response.statusCode).toBe(200);
|
|
expect(response.json()).toEqual({
|
|
ip: "192.168.1.20",
|
|
ips: ["127.0.0.1", "192.168.1.20"],
|
|
});
|
|
} finally {
|
|
await app.close();
|
|
}
|
|
}, 20_000);
|
|
});
|