修复认证挑战与可信代理安全边界(07-17-auth-challenge-trusted-proxy-hardening)

这个提交包含在:
2026-07-17 14:24:58 +08:00
父节点 bbb5c89825
当前提交 6ddedf1e0e
共修改 31 个文件,包含 3801 行新增和 419 行删除
+115 -10
查看文件
@@ -16,11 +16,110 @@ function normalizeBuildSha(value) {
return /^[0-9a-f]{7,64}$/.test(sha) ? sha.slice(0, 12) : "unknown";
}
function parseBoundedInteger(name, value, { min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
const normalized = typeof value === "number" ? String(value) : String(value ?? "").trim();
if (!/^\d+$/.test(normalized)) {
throw new Error(`Invalid ${name}`);
}
const parsed = Number(normalized);
if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
throw new Error(`Invalid ${name}`);
}
return parsed;
}
function assertProductionAuthCodePepper(value) {
const isPlaceholder = /^<[^>]+>$/.test(value);
if (isPlaceholder || Buffer.byteLength(value, "utf8") < 32) {
throw new Error("AUTH_CODE_PEPPER must be a non-placeholder secret of at least 32 bytes");
}
}
function getConfig() {
const nodeEnv = process.env.NODE_ENV ?? "development";
const serverRoot = path.resolve(__dirname, "..");
const storageDir = process.env.STORAGE_DIR ?? "generated/storage";
const clientDistDir = process.env.CLIENT_DIST_DIR ?? "../client/dist";
const jwtSecret = requiredEnv("JWT_SECRET");
const devShowPasswordResetCode = process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true";
if (nodeEnv === "production" && devShowPasswordResetCode) {
throw new Error("DEV_SHOW_PASSWORD_RESET_CODE is forbidden in production");
}
const authCodePepper = String(process.env.AUTH_CODE_PEPPER ?? "").trim();
if (nodeEnv === "production" && !authCodePepper) {
throw new Error("Missing required env: AUTH_CODE_PEPPER");
}
if (nodeEnv === "production") {
assertProductionAuthCodePepper(authCodePepper);
if (authCodePepper === jwtSecret) {
throw new Error("AUTH_CODE_PEPPER must be independent from JWT_SECRET");
}
}
const trustProxyHopsValue =
process.env.TRUST_PROXY_HOPS ?? (nodeEnv === "production" ? requiredEnv("TRUST_PROXY_HOPS") : "0");
const trustProxyHops = parseBoundedInteger("TRUST_PROXY_HOPS", trustProxyHopsValue, {
min: nodeEnv === "production" ? 1 : 0,
max: nodeEnv === "production" ? 1 : 8,
});
const passwordResetCodeTtlSeconds = parseBoundedInteger(
"PASSWORD_RESET_CODE_TTL_SECONDS",
process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: ONE_DAY_SECONDS },
);
const passwordResetFailWindowSeconds = parseBoundedInteger(
"PASSWORD_RESET_FAIL_WINDOW_SECONDS",
process.env.PASSWORD_RESET_FAIL_WINDOW_SECONDS ?? 30 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
const passwordResetMaxAttempts = parseBoundedInteger(
"PASSWORD_RESET_MAX_ATTEMPTS",
process.env.PASSWORD_RESET_MAX_ATTEMPTS ?? 5,
{ min: 1, max: 20 },
);
const passwordResetLockSeconds = parseBoundedInteger(
"PASSWORD_RESET_LOCK_SECONDS",
process.env.PASSWORD_RESET_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
const passwordResetRequestWindowSeconds = parseBoundedInteger(
"PASSWORD_RESET_REQUEST_WINDOW_SECONDS",
process.env.PASSWORD_RESET_REQUEST_WINDOW_SECONDS ?? 60 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
const passwordResetRequestMax = parseBoundedInteger(
"PASSWORD_RESET_REQUEST_MAX",
process.env.PASSWORD_RESET_REQUEST_MAX ?? 3,
{ min: 1, max: 20 },
);
const authCodeRequestCooldownSeconds = parseBoundedInteger(
"AUTH_CODE_REQUEST_COOLDOWN_SECONDS",
process.env.AUTH_CODE_REQUEST_COOLDOWN_SECONDS ?? ONE_MINUTE_SECONDS,
{ min: 1, max: ONE_DAY_SECONDS },
);
const emailLoginCodeTtlSeconds = parseBoundedInteger(
"EMAIL_LOGIN_CODE_TTL_SECONDS",
process.env.EMAIL_LOGIN_CODE_TTL_SECONDS ?? 10 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: ONE_DAY_SECONDS },
);
const emailLoginCodeMaxAttempts = parseBoundedInteger(
"EMAIL_LOGIN_CODE_MAX_ATTEMPTS",
process.env.EMAIL_LOGIN_CODE_MAX_ATTEMPTS ?? 5,
{ min: 1, max: 20 },
);
const emailLoginCodeFailWindowSeconds = parseBoundedInteger(
"EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS",
process.env.EMAIL_LOGIN_CODE_FAIL_WINDOW_SECONDS ?? 30 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
const emailLoginCodeLockSeconds = parseBoundedInteger(
"EMAIL_LOGIN_CODE_LOCK_SECONDS",
process.env.EMAIL_LOGIN_CODE_LOCK_SECONDS ?? 30 * ONE_MINUTE_SECONDS,
{ min: ONE_MINUTE_SECONDS, max: 7 * ONE_DAY_SECONDS },
);
return {
nodeEnv,
@@ -29,10 +128,12 @@ function getConfig() {
buildSha: normalizeBuildSha(process.env.BUILD_SHA),
metricsEnabled: process.env.METRICS_ENABLED !== "false",
trustProxy: trustProxyHops > 0 ? trustProxyHops : false,
appOrigin: process.env.APP_ORIGIN ?? "",
jwtSecret: requiredEnv("JWT_SECRET"),
jwtSecret,
fieldEncryptionKey: process.env.FIELD_ENCRYPTION_KEY ?? "",
authCodePepper: authCodePepper || jwtSecret,
accessTokenTtl: "15m",
refreshTtlSeconds: 7 * ONE_DAY_SECONDS,
@@ -43,15 +144,19 @@ function getConfig() {
? process.env.COOKIE_SECURE === "true"
: nodeEnv === "production",
passwordResetCodeTtlSeconds: Number(
process.env.PASSWORD_RESET_CODE_TTL_SECONDS ?? 10 * 60,
),
emailLoginCodeTtlSeconds: Number(
process.env.EMAIL_LOGIN_CODE_TTL_SECONDS ?? 10 * 60,
),
emailLoginCodeMaxAttempts: Number(process.env.EMAIL_LOGIN_CODE_MAX_ATTEMPTS ?? 5),
passwordResetCodeTtlSeconds,
passwordResetFailWindowSeconds,
passwordResetMaxAttempts,
passwordResetLockSeconds,
passwordResetRequestWindowSeconds,
passwordResetRequestMax,
authCodeRequestCooldownSeconds,
emailLoginCodeTtlSeconds,
emailLoginCodeMaxAttempts,
emailLoginCodeFailWindowSeconds,
emailLoginCodeLockSeconds,
devShowPasswordResetCode: process.env.DEV_SHOW_PASSWORD_RESET_CODE === "true",
devShowPasswordResetCode,
loginFailWindowSeconds: Number(
process.env.AUTH_LOGIN_FAIL_WINDOW_SECONDS ?? 15 * ONE_MINUTE_SECONDS,
@@ -209,4 +314,4 @@ function getConfig() {
};
}
module.exports = { getConfig };
module.exports = { getConfig, parseBoundedInteger };