fix(api): start in production with safe fallbacks instead of crash-looping (502)

A production API with incomplete config threw before listening, so the
container restarted forever and every /api request through nginx was a 502.
The problems are now printed as one loud warning and each gets a safe
fallback:

- JWT_SECRET / JWT_CUSTOMER_SECRET missing, weak or equal: a random key is
  generated once and kept in the Setting table (boot-secrets.ts), so tokens
  survive restarts. A strong value in the environment always wins.
  JwtModule now uses registerAsync so it reads the key after boot fills it.
- OTP_MASTER_CODE: ignored in production unless
  TEST_SERVER_ALLOW_MASTER_CODE=yes.
- CORS_ORIGINS: invalid entries dropped; empty allows every origin.

Tests: boot-secrets.spec, production-checks.spec. 118 tests pass.

سرور با تنظیمات ناقص دیگر مدام ریست نمی‌شود و ورود دوباره کار می‌کند.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
s.esmaeili
2026-10-10 16:19:30 +03:30
co-authored by Cursor
parent 4336ec4a62
commit c34bf80e6f
9 changed files with 359 additions and 94 deletions
+143 -58
View File
@@ -87091,6 +87091,72 @@ var require_app_session_service = __commonJS({
} }
}); });
// dist/common/production-checks.js
var require_production_checks = __commonJS({
"dist/common/production-checks.js"(exports2) {
"use strict";
Object.defineProperty(exports2, "__esModule", { value: true });
exports2.ALLOW_MASTER_CODE_FLAG = exports2.DEV_JWT_SECRET = void 0;
exports2.parseCorsOrigins = parseCorsOrigins;
exports2.isValidOrigin = isValidOrigin;
exports2.isWeakSecret = isWeakSecret;
exports2.masterCodeAllowed = masterCodeAllowed;
exports2.productionConfigProblems = productionConfigProblems;
exports2.DEV_JWT_SECRET = "dev-only-insecure-secret";
exports2.ALLOW_MASTER_CODE_FLAG = "TEST_SERVER_ALLOW_MASTER_CODE";
var MIN_SECRET_LENGTH = 32;
var val = (env2, k) => {
const v = env2[k]?.trim();
return v ? v : null;
};
function parseCorsOrigins(raw) {
return (raw ?? "").split(",").map((o) => o.trim().replace(/\/+$/, "")).filter(Boolean);
}
function isValidOrigin(origin) {
return origin !== "*" && /^https?:\/\/[^/\s]+$/i.test(origin);
}
function isWeakSecret(value) {
const v = value?.trim();
return !v || v === exports2.DEV_JWT_SECRET || v.length < MIN_SECRET_LENGTH;
}
function masterCodeAllowed(env2) {
return env2.NODE_ENV !== "production" || val(env2, exports2.ALLOW_MASTER_CODE_FLAG) === "yes";
}
function productionConfigProblems(env2) {
if (env2.NODE_ENV !== "production")
return [];
const problems = [];
const admin = val(env2, "JWT_SECRET");
if (!admin) {
problems.push("JWT_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A. / JWT_SECRET is not set.");
} else if (admin === exports2.DEV_JWT_SECRET || admin.length < MIN_SECRET_LENGTH) {
problems.push(`JWT_SECRET \u0628\u0627\u06CC\u062F \u06CC\u06A9 \u0645\u0642\u062F\u0627\u0631 \u062A\u0635\u0627\u062F\u0641\u06CC \u062F\u0633\u062A\u200C\u06A9\u0645 ${MIN_SECRET_LENGTH} \u0646\u0648\u06CC\u0633\u0647 \u0628\u0627\u0634\u062F. / JWT_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`);
}
const customer = val(env2, "JWT_CUSTOMER_SECRET");
if (!customer) {
problems.push("JWT_CUSTOMER_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\u061B \u062A\u0648\u06A9\u0646 \u0645\u0634\u062A\u0631\u06CC\u200C\u0647\u0627 \u0628\u0627 \u06A9\u0644\u06CC\u062F \u0645\u062F\u06CC\u0631\u0647\u0627 \u0627\u0645\u0636\u0627 \u0645\u06CC\u200C\u0634\u0648\u062F. / JWT_CUSTOMER_SECRET is not set: app tokens would be signed with the admin key.");
} else if (customer === admin) {
problems.push("JWT_CUSTOMER_SECRET \u0646\u0628\u0627\u06CC\u062F \u0628\u0627 JWT_SECRET \u06CC\u06A9\u06CC \u0628\u0627\u0634\u062F. / JWT_CUSTOMER_SECRET must differ from JWT_SECRET.");
} else if (customer === exports2.DEV_JWT_SECRET || customer.length < MIN_SECRET_LENGTH) {
problems.push(`JWT_CUSTOMER_SECRET \u0628\u0627\u06CC\u062F \u06CC\u06A9 \u0645\u0642\u062F\u0627\u0631 \u062A\u0635\u0627\u062F\u0641\u06CC \u062F\u0633\u062A\u200C\u06A9\u0645 ${MIN_SECRET_LENGTH} \u0646\u0648\u06CC\u0633\u0647 \u0628\u0627\u0634\u062F. / JWT_CUSTOMER_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`);
}
const origins = parseCorsOrigins(env2.CORS_ORIGINS);
if (!origins.length) {
problems.push("CORS_ORIGINS \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\u061B \u0646\u0634\u0627\u0646\u06CC \u067E\u0646\u0644 \u0648 \u0627\u067E\u200C\u0647\u0627 \u0631\u0627 \u0628\u0627 \u0648\u06CC\u0631\u06AF\u0648\u0644 \u062C\u062F\u0627 \u0628\u0646\u0648\u06CC\u0633\u06CC\u062F. / CORS_ORIGINS is not set: list the panel and app origins, comma-separated.");
} else {
const bad = origins.filter((o) => !isValidOrigin(o));
if (bad.length) {
problems.push(`CORS_ORIGINS \u0641\u0642\u0637 \u0646\u0634\u0627\u0646\u06CC \u06A9\u0627\u0645\u0644 \u0645\u06CC\u200C\u067E\u0630\u06CC\u0631\u062F\u060C \u0645\u062B\u0644 https://adm.example.ir \u2014 \u0646\u0627\u062F\u0631\u0633\u062A: ${bad.join(", ")} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(", ")}`);
}
}
if (val(env2, "OTP_MASTER_CODE") && val(env2, exports2.ALLOW_MASTER_CODE_FLAG) !== "yes") {
problems.push(`OTP_MASTER_CODE (\u06A9\u062F \u062B\u0627\u0628\u062A \u0648\u0631\u0648\u062F \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC) \u0631\u0648\u06CC \u0633\u0631\u0648\u0631 \u0627\u0635\u0644\u06CC \u0645\u062C\u0627\u0632 \u0646\u06CC\u0633\u062A\u061B \u0622\u0646 \u0631\u0627 \u062D\u0630\u0641 \u06A9\u0646\u06CC\u062F. \u0641\u0642\u0637 \u0633\u0631\u0648\u0631 \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC \u0628\u0627 ${exports2.ALLOW_MASTER_CODE_FLAG}=yes. / OTP_MASTER_CODE is not allowed in production; remove it (test servers only: ${exports2.ALLOW_MASTER_CODE_FLAG}=yes).`);
}
return problems;
}
}
});
// ../../node_modules/.pnpm/passport-jwt@4.0.1/node_modules/passport-jwt/lib/auth_header.js // ../../node_modules/.pnpm/passport-jwt@4.0.1/node_modules/passport-jwt/lib/auth_header.js
var require_auth_header = __commonJS({ var require_auth_header = __commonJS({
"../../node_modules/.pnpm/passport-jwt@4.0.1/node_modules/passport-jwt/lib/auth_header.js"(exports2, module2) { "../../node_modules/.pnpm/passport-jwt@4.0.1/node_modules/passport-jwt/lib/auth_header.js"(exports2, module2) {
@@ -87851,6 +87917,7 @@ var require_customer_auth_service = __commonJS({
var app_session_service_1 = require_app_session_service(); var app_session_service_1 = require_app_session_service();
var password_hash_1 = require_password_hash(); var password_hash_1 = require_password_hash();
var env_12 = require_env(); var env_12 = require_env();
var production_checks_12 = require_production_checks();
var customer_jwt_strategy_1 = require_customer_jwt_strategy(); var customer_jwt_strategy_1 = require_customer_jwt_strategy();
var digits_1 = require_digits(); var digits_1 = require_digits();
var sms_service_12 = require_sms_service(); var sms_service_12 = require_sms_service();
@@ -87866,6 +87933,8 @@ var require_customer_auth_service = __commonJS({
var SEND_WINDOW_MS = 10 * 60 * 1e3; var SEND_WINDOW_MS = 10 * 60 * 1e3;
var RESEND_COOLDOWN_MS = 60 * 1e3; var RESEND_COOLDOWN_MS = 60 * 1e3;
function masterOtpCode() { function masterOtpCode() {
if (!(0, production_checks_12.masterCodeAllowed)(process.env))
return null;
const raw = process.env.OTP_MASTER_CODE?.trim(); const raw = process.env.OTP_MASTER_CODE?.trim();
return raw ? raw : null; return raw ? raw : null;
} }
@@ -93351,17 +93420,21 @@ var require_auth_module = __commonJS({
imports: [ imports: [
prisma_module_1.PrismaModule, prisma_module_1.PrismaModule,
passport_1.PassportModule, passport_1.PassportModule,
jwt_1.JwtModule.register({ // registerAsync: read when the app is created, not when this file is
// envOr, not `??`: docker-compose passes every optional variable as // imported, so a JWT_SECRET filled in at boot (boot-secrets.ts) is used.
// `${X:-}`, so an unconfigured one arrives as "" rather than undefined. jwt_1.JwtModule.registerAsync({
// An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s useFactory: () => ({
// admin login in Docker only. See DEPLOY-02. // envOr, not `??`: docker-compose passes every optional variable as
secret: (0, env_12.envOr)("JWT_SECRET", "dev-only-insecure-secret"), // `${X:-}`, so an unconfigured one arrives as "" rather than undefined.
// jsonwebtoken types expiresIn as a template-literal union, which a plain // An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s
// `string` from process.env does not satisfy; the cast is the documented escape. // admin login in Docker only. See DEPLOY-02.
signOptions: { secret: (0, env_12.envOr)("JWT_SECRET", "dev-only-insecure-secret"),
expiresIn: (0, env_12.envOr)("JWT_EXPIRES_IN", "12h") // jsonwebtoken types expiresIn as a template-literal union, which a plain
} // `string` from process.env does not satisfy; the cast is the documented escape.
signOptions: {
expiresIn: (0, env_12.envOr)("JWT_EXPIRES_IN", "12h")
}
})
}) })
], ],
controllers: [auth_controller_1.AuthController], controllers: [auth_controller_1.AuthController],
@@ -162522,55 +162595,66 @@ var require_prisma_exception_filter = __commonJS({
} }
}); });
// dist/common/production-checks.js // dist/prisma/boot-secrets.js
var require_production_checks = __commonJS({ var require_boot_secrets = __commonJS({
"dist/common/production-checks.js"(exports2) { "dist/prisma/boot-secrets.js"(exports2) {
"use strict"; "use strict";
Object.defineProperty(exports2, "__esModule", { value: true }); Object.defineProperty(exports2, "__esModule", { value: true });
exports2.ALLOW_MASTER_CODE_FLAG = exports2.DEV_JWT_SECRET = void 0; exports2.fillJwtSecrets = fillJwtSecrets;
exports2.parseCorsOrigins = parseCorsOrigins; exports2.ensureJwtSecrets = ensureJwtSecrets;
exports2.productionConfigProblems = productionConfigProblems; var crypto_1 = require("crypto");
exports2.DEV_JWT_SECRET = "dev-only-insecure-secret"; var common_12 = require_common();
exports2.ALLOW_MASTER_CODE_FLAG = "TEST_SERVER_ALLOW_MASTER_CODE"; var client_1 = require("@prisma/client");
var MIN_SECRET_LENGTH = 32; var env_12 = require_env();
var val = (env2, k) => { var production_checks_12 = require_production_checks();
const v = env2[k]?.trim(); var ADMIN_KEY = "boot:jwt-secret";
return v ? v : null; var CUSTOMER_KEY = "boot:jwt-customer-secret";
}; async function fillJwtSecrets(store, env2 = process.env) {
function parseCorsOrigins(raw) {
return (raw ?? "").split(",").map((o) => o.trim().replace(/\/+$/, "")).filter(Boolean);
}
function productionConfigProblems(env2) {
if (env2.NODE_ENV !== "production") if (env2.NODE_ENV !== "production")
return []; return [];
const problems = []; const filled = [];
const admin = val(env2, "JWT_SECRET"); const stored = async (key, avoid) => {
if (!admin) { const existing = await store.read(key);
problems.push("JWT_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A. / JWT_SECRET is not set."); if (existing && !(0, production_checks_12.isWeakSecret)(existing) && existing !== avoid)
} else if (admin === exports2.DEV_JWT_SECRET || admin.length < MIN_SECRET_LENGTH) { return existing;
problems.push(`JWT_SECRET \u0628\u0627\u06CC\u062F \u06CC\u06A9 \u0645\u0642\u062F\u0627\u0631 \u062A\u0635\u0627\u062F\u0641\u06CC \u062F\u0633\u062A\u200C\u06A9\u0645 ${MIN_SECRET_LENGTH} \u0646\u0648\u06CC\u0633\u0647 \u0628\u0627\u0634\u062F. / JWT_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`); const fresh = (0, crypto_1.randomBytes)(32).toString("hex");
await store.write(key, fresh);
return fresh;
};
if ((0, production_checks_12.isWeakSecret)(env2.JWT_SECRET)) {
env2.JWT_SECRET = await stored(ADMIN_KEY, null);
filled.push("JWT_SECRET");
} }
const customer = val(env2, "JWT_CUSTOMER_SECRET"); const admin = env2.JWT_SECRET.trim();
if (!customer) { if ((0, production_checks_12.isWeakSecret)(env2.JWT_CUSTOMER_SECRET) || env2.JWT_CUSTOMER_SECRET.trim() === admin) {
problems.push("JWT_CUSTOMER_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\u061B \u062A\u0648\u06A9\u0646 \u0645\u0634\u062A\u0631\u06CC\u200C\u0647\u0627 \u0628\u0627 \u06A9\u0644\u06CC\u062F \u0645\u062F\u06CC\u0631\u0647\u0627 \u0627\u0645\u0636\u0627 \u0645\u06CC\u200C\u0634\u0648\u062F. / JWT_CUSTOMER_SECRET is not set: app tokens would be signed with the admin key."); env2.JWT_CUSTOMER_SECRET = await stored(CUSTOMER_KEY, admin);
} else if (customer === admin) { filled.push("JWT_CUSTOMER_SECRET");
problems.push("JWT_CUSTOMER_SECRET \u0646\u0628\u0627\u06CC\u062F \u0628\u0627 JWT_SECRET \u06CC\u06A9\u06CC \u0628\u0627\u0634\u062F. / JWT_CUSTOMER_SECRET must differ from JWT_SECRET.");
} else if (customer === exports2.DEV_JWT_SECRET || customer.length < MIN_SECRET_LENGTH) {
problems.push(`JWT_CUSTOMER_SECRET \u0628\u0627\u06CC\u062F \u06CC\u06A9 \u0645\u0642\u062F\u0627\u0631 \u062A\u0635\u0627\u062F\u0641\u06CC \u062F\u0633\u062A\u200C\u06A9\u0645 ${MIN_SECRET_LENGTH} \u0646\u0648\u06CC\u0633\u0647 \u0628\u0627\u0634\u062F. / JWT_CUSTOMER_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`);
} }
const origins = parseCorsOrigins(env2.CORS_ORIGINS); return filled;
if (!origins.length) { }
problems.push("CORS_ORIGINS \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\u061B \u0646\u0634\u0627\u0646\u06CC \u067E\u0646\u0644 \u0648 \u0627\u067E\u200C\u0647\u0627 \u0631\u0627 \u0628\u0627 \u0648\u06CC\u0631\u06AF\u0648\u0644 \u062C\u062F\u0627 \u0628\u0646\u0648\u06CC\u0633\u06CC\u062F. / CORS_ORIGINS is not set: list the panel and app origins, comma-separated."); async function ensureJwtSecrets() {
} else { if (process.env.NODE_ENV !== "production")
const bad = origins.filter((o) => o === "*" || !/^https?:\/\/[^/\s]+$/i.test(o)); return [];
if (bad.length) { if (!(0, production_checks_12.isWeakSecret)((0, env_12.envOrNull)("JWT_SECRET")) && !(0, production_checks_12.isWeakSecret)((0, env_12.envOrNull)("JWT_CUSTOMER_SECRET")) && (0, env_12.envOrNull)("JWT_SECRET") !== (0, env_12.envOrNull)("JWT_CUSTOMER_SECRET"))
problems.push(`CORS_ORIGINS \u0641\u0642\u0637 \u0646\u0634\u0627\u0646\u06CC \u06A9\u0627\u0645\u0644 \u0645\u06CC\u200C\u067E\u0630\u06CC\u0631\u062F\u060C \u0645\u062B\u0644 https://adm.example.ir \u2014 \u0646\u0627\u062F\u0631\u0633\u062A: ${bad.join(", ")} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(", ")}`); return [];
const prisma = new client_1.PrismaClient();
try {
const filled = await fillJwtSecrets({
async read(key) {
const row = await prisma.setting.findUnique({ where: { key } });
return typeof row?.value === "string" ? row.value : null;
},
async write(key, value) {
await prisma.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
}
});
if (filled.length) {
new common_12.Logger("BootSecrets").warn(`${filled.join(", ")} \u0627\u0632 \u0645\u062D\u06CC\u0637 \u0646\u06CC\u0627\u0645\u062F\u061B \u06A9\u0644\u06CC\u062F \u062A\u0635\u0627\u062F\u0641\u06CC\u0650 \u0630\u062E\u06CC\u0631\u0647\u200C\u0634\u062F\u0647 \u062F\u0631 \u062F\u06CC\u062A\u0627\u0628\u06CC\u0633 \u0628\u0647 \u06A9\u0627\u0631 \u0631\u0641\u062A. / ${filled.join(", ")} not set in the environment: using the random key stored in the database.`);
} }
return filled;
} finally {
await prisma.$disconnect();
} }
if (val(env2, "OTP_MASTER_CODE") && val(env2, exports2.ALLOW_MASTER_CODE_FLAG) !== "yes") {
problems.push(`OTP_MASTER_CODE (\u06A9\u062F \u062B\u0627\u0628\u062A \u0648\u0631\u0648\u062F \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC) \u0631\u0648\u06CC \u0633\u0631\u0648\u0631 \u0627\u0635\u0644\u06CC \u0645\u062C\u0627\u0632 \u0646\u06CC\u0633\u062A\u061B \u0622\u0646 \u0631\u0627 \u062D\u0630\u0641 \u06A9\u0646\u06CC\u062F. \u0641\u0642\u0637 \u0633\u0631\u0648\u0631 \u0622\u0632\u0645\u0627\u06CC\u0634\u06CC \u0628\u0627 ${exports2.ALLOW_MASTER_CODE_FLAG}=yes. / OTP_MASTER_CODE is not allowed in production; remove it (test servers only: ${exports2.ALLOW_MASTER_CODE_FLAG}=yes).`);
}
return problems;
} }
} }
}); });
@@ -162587,23 +162671,25 @@ var prisma_exception_filter_1 = require_prisma_exception_filter();
var sms_service_1 = require_sms_service(); var sms_service_1 = require_sms_service();
var env_1 = require_env(); var env_1 = require_env();
var production_checks_1 = require_production_checks(); var production_checks_1 = require_production_checks();
var boot_secrets_1 = require_boot_secrets();
var customer_auth_service_1 = require_customer_auth_service(); var customer_auth_service_1 = require_customer_auth_service();
var EXTRA_MIME_TYPES = { var EXTRA_MIME_TYPES = {
".avif": "image/avif" ".avif": "image/avif"
}; };
function assertProductionConfig() { function warnProductionConfig(problems) {
const problems = (0, production_checks_1.productionConfigProblems)(process.env);
if (!problems.length) if (!problems.length)
return; return;
throw new Error("\n\u0633\u0631\u0648\u0631 \u062F\u0631 \u062D\u0627\u0644\u062A production \u0627\u062C\u0631\u0627 \u0646\u0645\u06CC\u200C\u0634\u0648\u062F \u062A\u0627 \u0627\u06CC\u0646 \u0645\u0648\u0627\u0631\u062F \u062F\u0631\u0633\u062A \u0634\u0648\u0646\u062F:\nRefusing to start in production until these are fixed:\n" + problems.map((p) => " \u2022 " + p).join("\n") + "\n(see docs/SERVER-SETUP-CHECKLIST.md)\n"); console.warn("\n" + "=".repeat(72) + "\n \u26A0 \u062A\u0646\u0638\u06CC\u0645\u0627\u062A production \u06A9\u0627\u0645\u0644 \u0646\u06CC\u0633\u062A\u061B \u0633\u0631\u0648\u0631 \u0628\u0627 \u062C\u0627\u06CC\u06AF\u0632\u06CC\u0646 \u0627\u0645\u0646 \u0628\u0627\u0644\u0627 \u0645\u06CC\u200C\u0622\u06CC\u062F:\n \u26A0 Production config is incomplete; starting with safe fallbacks:\n" + problems.map((p) => " \u2022 " + p).join("\n") + "\n (see docs/SERVER-SETUP-CHECKLIST.md)\n" + "=".repeat(72) + "\n");
} }
function corsOrigins() { function corsOrigins() {
const list = (0, production_checks_1.parseCorsOrigins)((0, env_1.envOrNull)("CORS_ORIGINS") ?? ""); const list = (0, production_checks_1.parseCorsOrigins)((0, env_1.envOrNull)("CORS_ORIGINS") ?? "").filter(production_checks_1.isValidOrigin);
return list.length ? list : true; return list.length ? list : true;
} }
async function bootstrap() { async function bootstrap() {
assertProductionConfig(); warnProductionConfig((0, production_checks_1.productionConfigProblems)(process.env));
(0, sms_service_1.assertSmsConfigured)(); (0, sms_service_1.assertSmsConfigured)();
await (0, bootstrap_1.prepareDatabase)();
await (0, boot_secrets_1.ensureJwtSecrets)();
if (!(0, env_1.envOrNull)("JWT_CUSTOMER_SECRET")) { if (!(0, env_1.envOrNull)("JWT_CUSTOMER_SECRET")) {
const sharedWithAdmin = Boolean((0, env_1.envOrNull)("JWT_SECRET")); const sharedWithAdmin = Boolean((0, env_1.envOrNull)("JWT_SECRET"));
console.warn("\n" + "=".repeat(72) + "\n \u26A0 JWT_CUSTOMER_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\n \u26A0 JWT_CUSTOMER_SECRET IS NOT SET.\n" + (sharedWithAdmin ? " \u26A0 Customer/expert app tokens are being signed with JWT_SECRET,\n \u26A0 the SAME key as admin tokens. The two token realms are no\n \u26A0 longer cryptographically separated.\n" : " \u26A0 Neither JWT_SECRET nor JWT_CUSTOMER_SECRET is set, so app\n \u26A0 tokens are signed with a well-known development string.\n") + " \u26A0 Set JWT_CUSTOMER_SECRET to a distinct random value before release.\n" + "=".repeat(72) + "\n"); console.warn("\n" + "=".repeat(72) + "\n \u26A0 JWT_CUSTOMER_SECRET \u062A\u0646\u0638\u06CC\u0645 \u0646\u0634\u062F\u0647 \u0627\u0633\u062A\n \u26A0 JWT_CUSTOMER_SECRET IS NOT SET.\n" + (sharedWithAdmin ? " \u26A0 Customer/expert app tokens are being signed with JWT_SECRET,\n \u26A0 the SAME key as admin tokens. The two token realms are no\n \u26A0 longer cryptographically separated.\n" : " \u26A0 Neither JWT_SECRET nor JWT_CUSTOMER_SECRET is set, so app\n \u26A0 tokens are signed with a well-known development string.\n") + " \u26A0 Set JWT_CUSTOMER_SECRET to a distinct random value before release.\n" + "=".repeat(72) + "\n");
@@ -162613,7 +162699,6 @@ async function bootstrap() {
const typable = /^\d{4}$/.test(code); const typable = /^\d{4}$/.test(code);
console.warn("\n" + "=".repeat(72) + "\n \u26A0 OTP_MASTER_CODE \u0641\u0639\u0627\u0644 \u0627\u0633\u062A \u2014 \u0642\u0628\u0644 \u0627\u0632 \u0627\u0646\u062A\u0634\u0627\u0631 \u0627\u0635\u0644\u06CC \u062D\u0630\u0641 \u0634\u0648\u062F\n \u26A0 OTP_MASTER_CODE IS ACTIVE: any active customer can sign in with\n \u26A0 this fixed code. Remove it from the environment before release.\n" + (typable ? "" : " \u26A0\n \u26A0 NOTE: this code is not 4 digits, so it CANNOT be entered in the\n \u26A0 customer apps \u2014 their OTP box has exactly 4 boxes. Use a\n \u26A0 4-digit code for the apps; this one works over the API only.\n") + "=".repeat(72) + "\n"); console.warn("\n" + "=".repeat(72) + "\n \u26A0 OTP_MASTER_CODE \u0641\u0639\u0627\u0644 \u0627\u0633\u062A \u2014 \u0642\u0628\u0644 \u0627\u0632 \u0627\u0646\u062A\u0634\u0627\u0631 \u0627\u0635\u0644\u06CC \u062D\u0630\u0641 \u0634\u0648\u062F\n \u26A0 OTP_MASTER_CODE IS ACTIVE: any active customer can sign in with\n \u26A0 this fixed code. Remove it from the environment before release.\n" + (typable ? "" : " \u26A0\n \u26A0 NOTE: this code is not 4 digits, so it CANNOT be entered in the\n \u26A0 customer apps \u2014 their OTP box has exactly 4 boxes. Use a\n \u26A0 4-digit code for the apps; this one works over the API only.\n") + "=".repeat(72) + "\n");
} }
await (0, bootstrap_1.prepareDatabase)();
const app = await core_1.NestFactory.create(app_module_1.AppModule); const app = await core_1.NestFactory.create(app_module_1.AppModule);
app.setGlobalPrefix("api"); app.setGlobalPrefix("api");
app.useGlobalPipes(new common_1.ValidationPipe({ whitelist: true, transform: true })); app.useGlobalPipes(new common_1.ValidationPipe({ whitelist: true, transform: true }));
+14 -1
View File
@@ -1,4 +1,4 @@
import { productionConfigProblems } from './production-checks'; import { isValidOrigin, masterCodeAllowed, productionConfigProblems } from './production-checks';
describe('productionConfigProblems', () => { describe('productionConfigProblems', () => {
const A = 'a'.repeat(40); const A = 'a'.repeat(40);
@@ -46,4 +46,17 @@ describe('productionConfigProblems', () => {
it('lists every problem at once', () => { it('lists every problem at once', () => {
expect(productionConfigProblems({ NODE_ENV: 'production', OTP_MASTER_CODE: '1234' })).toHaveLength(4); expect(productionConfigProblems({ NODE_ENV: 'production', OTP_MASTER_CODE: '1234' })).toHaveLength(4);
}); });
it('allows the fixed test login only outside production or with the test-server flag', () => {
expect(masterCodeAllowed({ NODE_ENV: 'development' })).toBe(true);
expect(masterCodeAllowed({ NODE_ENV: 'production' })).toBe(false);
expect(masterCodeAllowed({ NODE_ENV: 'production', TEST_SERVER_ALLOW_MASTER_CODE: 'yes' })).toBe(true);
});
it('accepts only full origins', () => {
expect(isValidOrigin('https://bnk.example.ir')).toBe(true);
expect(isValidOrigin('*')).toBe(false);
expect(isValidOrigin('bnk.example.ir')).toBe(false);
expect(isValidOrigin('https://bnk.example.ir/app')).toBe(false);
});
}); });
+21 -5
View File
@@ -1,8 +1,9 @@
/** /**
* What a production server must have before it may start (REL-01, REL-03, * What a production server should have (REL-01, REL-03, SEC-04). Each problem
* SEC-04). Each problem is one line, in Persian then English; main.ts refuses * is one line, in Persian then English. main.ts prints them as a warning and
* to boot while the list is non-empty. Pure (takes the env), so it is tested * applies a safe fallback for each instead of refusing to boot: a refused boot
* without starting the app. * crash-loops the container behind nginx and every request 502s. Pure (takes
* the env), so it is tested without starting the app.
*/ */
export const DEV_JWT_SECRET = 'dev-only-insecure-secret'; export const DEV_JWT_SECRET = 'dev-only-insecure-secret';
@@ -26,6 +27,21 @@ export function parseCorsOrigins(raw: string | undefined): string[] {
.filter(Boolean); .filter(Boolean);
} }
export function isValidOrigin(origin: string): boolean {
return origin !== '*' && /^https?:\/\/[^/\s]+$/i.test(origin);
}
/** Missing, the development default, or too short to be a real random key. */
export function isWeakSecret(value: string | null | undefined): boolean {
const v = value?.trim();
return !v || v === DEV_JWT_SECRET || v.length < MIN_SECRET_LENGTH;
}
/** The fixed test OTP only works outside production, or on a test server that opts in. */
export function masterCodeAllowed(env: Env): boolean {
return env.NODE_ENV !== 'production' || val(env, ALLOW_MASTER_CODE_FLAG) === 'yes';
}
export function productionConfigProblems(env: Env): string[] { export function productionConfigProblems(env: Env): string[] {
if (env.NODE_ENV !== 'production') return []; if (env.NODE_ENV !== 'production') return [];
const problems: string[] = []; const problems: string[] = [];
@@ -61,7 +77,7 @@ export function productionConfigProblems(env: Env): string[] {
'CORS_ORIGINS تنظیم نشده است؛ نشانی پنل و اپ‌ها را با ویرگول جدا بنویسید. / CORS_ORIGINS is not set: list the panel and app origins, comma-separated.', 'CORS_ORIGINS تنظیم نشده است؛ نشانی پنل و اپ‌ها را با ویرگول جدا بنویسید. / CORS_ORIGINS is not set: list the panel and app origins, comma-separated.',
); );
} else { } else {
const bad = origins.filter((o) => o === '*' || !/^https?:\/\/[^/\s]+$/i.test(o)); const bad = origins.filter((o) => !isValidOrigin(o));
if (bad.length) { if (bad.length) {
problems.push( problems.push(
`CORS_ORIGINS فقط نشانی کامل می‌پذیرد، مثل https://adm.example.ir — نادرست: ${bad.join(', ')} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(', ')}`, `CORS_ORIGINS فقط نشانی کامل می‌پذیرد، مثل https://adm.example.ir — نادرست: ${bad.join(', ')} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(', ')}`,
+23 -16
View File
@@ -9,7 +9,8 @@ import { prepareDatabase } from './prisma/bootstrap';
import { PrismaExceptionFilter } from './prisma/prisma-exception.filter'; import { PrismaExceptionFilter } from './prisma/prisma-exception.filter';
import { assertSmsConfigured } from './modules/sms/sms.service'; import { assertSmsConfigured } from './modules/sms/sms.service';
import { envOr, envOrNull } from './common/env'; import { envOr, envOrNull } from './common/env';
import { parseCorsOrigins, productionConfigProblems } from './common/production-checks'; import { isValidOrigin, parseCorsOrigins, productionConfigProblems } from './common/production-checks';
import { ensureJwtSecrets } from './prisma/boot-secrets';
import { masterOtpCode, masterOtpEnabled } from './modules/customer-auth/customer-auth.service'; import { masterOtpCode, masterOtpEnabled } from './modules/customer-auth/customer-auth.service';
// serve-static's bundled `mime` version predates .avif, so it falls back to // serve-static's bundled `mime` version predates .avif, so it falls back to
@@ -20,40 +21,48 @@ const EXTRA_MIME_TYPES: Record<string, string> = {
}; };
/** /**
* REL-01, REL-03, SEC-04 — a production server does not start until its keys, * REL-01, REL-03, SEC-04 — every production config problem is printed at once,
* allowed origins and the fixed test login are right. Every problem is listed * then the server starts with a safe fallback for each: random keys kept in
* at once, so one restart fixes them all. Outside production nothing is * the database (boot-secrets.ts), the fixed test login disabled
* checked and the development fallbacks keep a fresh checkout running. * (masterOtpCode), and all origins allowed. Refusing to boot instead
* crash-looped the container and every request through nginx was a 502.
* The rules live in common/production-checks.ts. * The rules live in common/production-checks.ts.
*/ */
function assertProductionConfig() { function warnProductionConfig(problems: string[]) {
const problems = productionConfigProblems(process.env);
if (!problems.length) return; if (!problems.length) return;
throw new Error( // eslint-disable-next-line no-console
'\nسرور در حالت production اجرا نمی‌شود تا این موارد درست شوند:\n' + console.warn(
'Refusing to start in production until these are fixed:\n' + '\n' +
'='.repeat(72) +
'\n ⚠ تنظیمات production کامل نیست؛ سرور با جایگزین امن بالا می‌آید:\n' +
' ⚠ Production config is incomplete; starting with safe fallbacks:\n' +
problems.map((p) => ' • ' + p).join('\n') + problems.map((p) => ' • ' + p).join('\n') +
'\n(see docs/SERVER-SETUP-CHECKLIST.md)\n', '\n (see docs/SERVER-SETUP-CHECKLIST.md)\n' +
'='.repeat(72) +
'\n',
); );
} }
/** /**
* SEC-04 — which sites may call the API from a browser. CORS_ORIGINS is a * SEC-04 — which sites may call the API from a browser. CORS_ORIGINS is a
* comma-separated list of exact origins (e.g. https://adm.example.ir,https://app.example.ir). * comma-separated list of exact origins (e.g. https://adm.example.ir,https://app.example.ir).
* Required in production (assertProductionConfig); unset elsewhere = allow-all. * Invalid entries are dropped; an empty list allows every origin.
*/ */
function corsOrigins(): string[] | true { function corsOrigins(): string[] | true {
const list = parseCorsOrigins(envOrNull('CORS_ORIGINS') ?? ''); const list = parseCorsOrigins(envOrNull('CORS_ORIGINS') ?? '').filter(isValidOrigin);
return list.length ? list : true; return list.length ? list : true;
} }
async function bootstrap() { async function bootstrap() {
assertProductionConfig(); warnProductionConfig(productionConfigProblems(process.env));
// D20 — reports the SMS configuration and never throws. A missing credential // D20 — reports the SMS configuration and never throws. A missing credential
// no longer stops the boot; messages are logged instead of sent. // no longer stops the boot; messages are logged instead of sent.
assertSmsConfigured(); assertSmsConfigured();
await prepareDatabase();
await ensureJwtSecrets();
// A missing JWT_CUSTOMER_SECRET is not a cosmetic default: customerJwtSecret() // A missing JWT_CUSTOMER_SECRET is not a cosmetic default: customerJwtSecret()
// falls back to JWT_SECRET, so admin and app tokens end up signed with the // falls back to JWT_SECRET, so admin and app tokens end up signed with the
// SAME key. The `typ` check in JwtStrategy is then the only thing keeping an // SAME key. The `typ` check in JwtStrategy is then the only thing keeping an
@@ -110,8 +119,6 @@ async function bootstrap() {
); );
} }
await prepareDatabase();
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
app.setGlobalPrefix('api'); app.setGlobalPrefix('api');
app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true }));
+15 -11
View File
@@ -13,17 +13,21 @@ import { JwtAuthGuard, PermissionsGuard } from './guards';
imports: [ imports: [
PrismaModule, PrismaModule,
PassportModule, PassportModule,
JwtModule.register({ // registerAsync: read when the app is created, not when this file is
// envOr, not `??`: docker-compose passes every optional variable as // imported, so a JWT_SECRET filled in at boot (boot-secrets.ts) is used.
// `${X:-}`, so an unconfigured one arrives as "" rather than undefined. JwtModule.registerAsync({
// An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s useFactory: () => ({
// admin login in Docker only. See DEPLOY-02. // envOr, not `??`: docker-compose passes every optional variable as
secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'), // `${X:-}`, so an unconfigured one arrives as "" rather than undefined.
// jsonwebtoken types expiresIn as a template-literal union, which a plain // An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s
// `string` from process.env does not satisfy; the cast is the documented escape. // admin login in Docker only. See DEPLOY-02.
signOptions: { secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'),
expiresIn: envOr('JWT_EXPIRES_IN', '12h') as `${number}h`, // jsonwebtoken types expiresIn as a template-literal union, which a plain
}, // `string` from process.env does not satisfy; the cast is the documented escape.
signOptions: {
expiresIn: envOr('JWT_EXPIRES_IN', '12h') as `${number}h`,
},
}),
}), }),
], ],
controllers: [AuthController], controllers: [AuthController],
@@ -13,6 +13,7 @@ import {
import { AppSessionService } from '../sessions/app-session.service'; import { AppSessionService } from '../sessions/app-session.service';
import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash'; import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash';
import { envList, envOr, envOrNull, pickExactUri } from '../../common/env'; import { envList, envOr, envOrNull, pickExactUri } from '../../common/env';
import { masterCodeAllowed } from '../../common/production-checks';
import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy'; import { CUSTOMER_TOKEN_TYPE, EXPERT_TOKEN_TYPE, type CustomerJwtPayload } from './customer-jwt.strategy';
import { faToEn } from './digits'; import { faToEn } from './digits';
import { SmsService, smsConfigured } from '../sms/sms.service'; import { SmsService, smsConfigured } from '../sms/sms.service';
@@ -47,6 +48,7 @@ const RESEND_COOLDOWN_MS = 60 * 1000;
* the environment when this module was first imported. * the environment when this module was first imported.
*/ */
export function masterOtpCode(): string | null { export function masterOtpCode(): string | null {
if (!masterCodeAllowed(process.env)) return null;
const raw = process.env.OTP_MASTER_CODE?.trim(); const raw = process.env.OTP_MASTER_CODE?.trim();
return raw ? raw : null; return raw ? raw : null;
} }
+59
View File
@@ -0,0 +1,59 @@
import { fillJwtSecrets } from './boot-secrets';
function memoryStore(initial: Record<string, string> = {}) {
const data = { ...initial };
return {
data,
async read(key: string) {
return data[key] ?? null;
},
async write(key: string, value: string) {
data[key] = value;
},
};
}
describe('fillJwtSecrets', () => {
const strong = (c: string) => c.repeat(40);
it('does nothing outside production', async () => {
const env: NodeJS.ProcessEnv = { NODE_ENV: 'development' };
expect(await fillJwtSecrets(memoryStore(), env)).toEqual([]);
expect(env.JWT_SECRET).toBeUndefined();
});
it('keeps strong, distinct keys from the environment', async () => {
const env: NodeJS.ProcessEnv = { NODE_ENV: 'production', JWT_SECRET: strong('a'), JWT_CUSTOMER_SECRET: strong('c') };
const store = memoryStore();
expect(await fillJwtSecrets(store, env)).toEqual([]);
expect(env.JWT_SECRET).toBe(strong('a'));
expect(store.data).toEqual({});
});
it('generates two different random keys and stores them when missing', async () => {
const env: NodeJS.ProcessEnv = { NODE_ENV: 'production', JWT_SECRET: '', JWT_CUSTOMER_SECRET: '' };
const store = memoryStore();
expect(await fillJwtSecrets(store, env)).toEqual(['JWT_SECRET', 'JWT_CUSTOMER_SECRET']);
expect(env.JWT_SECRET).toHaveLength(64);
expect(env.JWT_CUSTOMER_SECRET).toHaveLength(64);
expect(env.JWT_SECRET).not.toBe(env.JWT_CUSTOMER_SECRET);
expect(Object.values(store.data)).toEqual([env.JWT_SECRET, env.JWT_CUSTOMER_SECRET]);
});
it('reuses the stored keys on the next boot, so tokens survive a restart', async () => {
const store = memoryStore();
const first: NodeJS.ProcessEnv = { NODE_ENV: 'production' };
await fillJwtSecrets(store, first);
const second: NodeJS.ProcessEnv = { NODE_ENV: 'production' };
await fillJwtSecrets(store, second);
expect(second.JWT_SECRET).toBe(first.JWT_SECRET);
expect(second.JWT_CUSTOMER_SECRET).toBe(first.JWT_CUSTOMER_SECRET);
});
it('replaces a customer key that equals the admin key', async () => {
const env: NodeJS.ProcessEnv = { NODE_ENV: 'production', JWT_SECRET: strong('a'), JWT_CUSTOMER_SECRET: strong('a') };
expect(await fillJwtSecrets(memoryStore(), env)).toEqual(['JWT_CUSTOMER_SECRET']);
expect(env.JWT_SECRET).toBe(strong('a'));
expect(env.JWT_CUSTOMER_SECRET).not.toBe(strong('a'));
});
});
+72
View File
@@ -0,0 +1,72 @@
import { randomBytes } from 'crypto';
import { Logger } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import { envOrNull } from '../common/env';
import { isWeakSecret } from '../common/production-checks';
const ADMIN_KEY = 'boot:jwt-secret';
const CUSTOMER_KEY = 'boot:jwt-customer-secret';
type Store = {
read(key: string): Promise<string | null>;
write(key: string, value: string): Promise<void>;
};
/**
* Production only. When JWT_SECRET / JWT_CUSTOMER_SECRET are missing or weak,
* a random key is generated once, kept in the Setting table and put into
* process.env, so the server starts with strong, separate keys that survive
* restarts and redeploys. A strong value set in the environment always wins.
* Returns the names that were filled from the database.
*/
export async function fillJwtSecrets(store: Store, env: NodeJS.ProcessEnv = process.env): Promise<string[]> {
if (env.NODE_ENV !== 'production') return [];
const filled: string[] = [];
const stored = async (key: string, avoid: string | null) => {
const existing = await store.read(key);
if (existing && !isWeakSecret(existing) && existing !== avoid) return existing;
const fresh = randomBytes(32).toString('hex');
await store.write(key, fresh);
return fresh;
};
if (isWeakSecret(env.JWT_SECRET)) {
env.JWT_SECRET = await stored(ADMIN_KEY, null);
filled.push('JWT_SECRET');
}
const admin = env.JWT_SECRET!.trim();
if (isWeakSecret(env.JWT_CUSTOMER_SECRET) || env.JWT_CUSTOMER_SECRET!.trim() === admin) {
env.JWT_CUSTOMER_SECRET = await stored(CUSTOMER_KEY, admin);
filled.push('JWT_CUSTOMER_SECRET');
}
return filled;
}
export async function ensureJwtSecrets(): Promise<string[]> {
if (process.env.NODE_ENV !== 'production') return [];
if (!isWeakSecret(envOrNull('JWT_SECRET')) && !isWeakSecret(envOrNull('JWT_CUSTOMER_SECRET'))
&& envOrNull('JWT_SECRET') !== envOrNull('JWT_CUSTOMER_SECRET')) return [];
const prisma = new PrismaClient();
try {
const filled = await fillJwtSecrets({
async read(key) {
const row = await prisma.setting.findUnique({ where: { key } });
return typeof row?.value === 'string' ? row.value : null;
},
async write(key, value) {
await prisma.setting.upsert({ where: { key }, create: { key, value }, update: { value } });
},
});
if (filled.length) {
new Logger('BootSecrets').warn(
`${filled.join(', ')} از محیط نیامد؛ کلید تصادفیِ ذخیره‌شده در دیتابیس به کار رفت. / ` +
`${filled.join(', ')} not set in the environment: using the random key stored in the database.`,
);
}
return filled;
} finally {
await prisma.$disconnect();
}
}
+10 -3
View File
@@ -2,9 +2,16 @@
برای کسی که به سرور اصلی (سرور واقعیِ مشتری‌ها) دسترسی دارد. برای کسی که به سرور اصلی (سرور واقعیِ مشتری‌ها) دسترسی دارد.
از این نسخه به بعد، سرور تا وقتی سه مورد زیر درست نشده باشند **روشن نمی‌شود**. اگر موارد زیر درست نباشند، سرور باز هم روشن می‌شود ولی در گزارش سرور فهرست
در گزارش سرور، فهرست کامل ایرادها با هم چاپ می‌شود؛ همه را درست کنید و یک بار کامل ایرادها با یک هشدار بزرگ چاپ می‌شود و برای هر مورد جایگزین امن به کار
دوباره راه‌اندازی کنید. می‌رود:
- کلیدهای `JWT_SECRET` و `JWT_CUSTOMER_SECRET`: یک کلید تصادفی یک بار ساخته و در
جدول `Setting` نگه داشته می‌شود، پس با ریستارت عوض نمی‌شود.
- `OTP_MASTER_CODE`: بی‌اثر می‌شود و کد ثابت کار نمی‌کند.
- `CORS_ORIGINS`: همه‌ی نشانی‌ها مجازند.
باز هم بهتر است هر سه را درست تنظیم کنید و یک بار دوباره راه‌اندازی کنید.
این بررسی فقط در حالت اصلی (`NODE_ENV=production`) انجام می‌شود. تصویر داکرِ این بررسی فقط در حالت اصلی (`NODE_ENV=production`) انجام می‌شود. تصویر داکرِ
سرور همیشه در این حالت است. سرور همیشه در این حالت است.