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