修复认证挑战与可信代理安全边界(07-17-auth-challenge-trusted-proxy-hardening)
这个提交包含在:
@@ -0,0 +1,173 @@
|
||||
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);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户