diff --git a/apps/api/release/app.js b/apps/api/release/app.js index a0e5531..b35e332 100644 --- a/apps/api/release/app.js +++ b/apps/api/release/app.js @@ -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 var require_auth_header = __commonJS({ "../../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 password_hash_1 = require_password_hash(); var env_12 = require_env(); + var production_checks_12 = require_production_checks(); var customer_jwt_strategy_1 = require_customer_jwt_strategy(); var digits_1 = require_digits(); 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 RESEND_COOLDOWN_MS = 60 * 1e3; function masterOtpCode() { + if (!(0, production_checks_12.masterCodeAllowed)(process.env)) + return null; const raw = process.env.OTP_MASTER_CODE?.trim(); return raw ? raw : null; } @@ -93351,17 +93420,21 @@ var require_auth_module = __commonJS({ imports: [ prisma_module_1.PrismaModule, passport_1.PassportModule, - jwt_1.JwtModule.register({ - // envOr, not `??`: docker-compose passes every optional variable as - // `${X:-}`, so an unconfigured one arrives as "" rather than undefined. - // An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s - // admin login in Docker only. See DEPLOY-02. - secret: (0, env_12.envOr)("JWT_SECRET", "dev-only-insecure-secret"), - // 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") - } + // registerAsync: read when the app is created, not when this file is + // imported, so a JWT_SECRET filled in at boot (boot-secrets.ts) is used. + jwt_1.JwtModule.registerAsync({ + useFactory: () => ({ + // envOr, not `??`: docker-compose passes every optional variable as + // `${X:-}`, so an unconfigured one arrives as "" rather than undefined. + // An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s + // admin login in Docker only. See DEPLOY-02. + secret: (0, env_12.envOr)("JWT_SECRET", "dev-only-insecure-secret"), + // 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], @@ -162522,55 +162595,66 @@ var require_prisma_exception_filter = __commonJS({ } }); -// dist/common/production-checks.js -var require_production_checks = __commonJS({ - "dist/common/production-checks.js"(exports2) { +// dist/prisma/boot-secrets.js +var require_boot_secrets = __commonJS({ + "dist/prisma/boot-secrets.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.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 productionConfigProblems(env2) { + exports2.fillJwtSecrets = fillJwtSecrets; + exports2.ensureJwtSecrets = ensureJwtSecrets; + var crypto_1 = require("crypto"); + var common_12 = require_common(); + var client_1 = require("@prisma/client"); + var env_12 = require_env(); + var production_checks_12 = require_production_checks(); + var ADMIN_KEY = "boot:jwt-secret"; + var CUSTOMER_KEY = "boot:jwt-customer-secret"; + async function fillJwtSecrets(store, env2 = process.env) { 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 filled = []; + const stored = async (key, avoid) => { + const existing = await store.read(key); + if (existing && !(0, production_checks_12.isWeakSecret)(existing) && existing !== avoid) + return existing; + 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"); - 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 admin = env2.JWT_SECRET.trim(); + if ((0, production_checks_12.isWeakSecret)(env2.JWT_CUSTOMER_SECRET) || env2.JWT_CUSTOMER_SECRET.trim() === admin) { + env2.JWT_CUSTOMER_SECRET = await stored(CUSTOMER_KEY, admin); + filled.push("JWT_CUSTOMER_SECRET"); } - 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) => o === "*" || !/^https?:\/\/[^/\s]+$/i.test(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(", ")}`); + return filled; + } + async function ensureJwtSecrets() { + if (process.env.NODE_ENV !== "production") + return []; + 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")) + 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 env_1 = require_env(); var production_checks_1 = require_production_checks(); +var boot_secrets_1 = require_boot_secrets(); var customer_auth_service_1 = require_customer_auth_service(); var EXTRA_MIME_TYPES = { ".avif": "image/avif" }; -function assertProductionConfig() { - const problems = (0, production_checks_1.productionConfigProblems)(process.env); +function warnProductionConfig(problems) { if (!problems.length) 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() { - 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; } async function bootstrap() { - assertProductionConfig(); + warnProductionConfig((0, production_checks_1.productionConfigProblems)(process.env)); (0, sms_service_1.assertSmsConfigured)(); + await (0, bootstrap_1.prepareDatabase)(); + await (0, boot_secrets_1.ensureJwtSecrets)(); if (!(0, env_1.envOrNull)("JWT_CUSTOMER_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"); @@ -162613,7 +162699,6 @@ async function bootstrap() { 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"); } - await (0, bootstrap_1.prepareDatabase)(); const app = await core_1.NestFactory.create(app_module_1.AppModule); app.setGlobalPrefix("api"); app.useGlobalPipes(new common_1.ValidationPipe({ whitelist: true, transform: true })); diff --git a/apps/api/src/common/production-checks.spec.ts b/apps/api/src/common/production-checks.spec.ts index 3ec49a4..8209e69 100644 --- a/apps/api/src/common/production-checks.spec.ts +++ b/apps/api/src/common/production-checks.spec.ts @@ -1,4 +1,4 @@ -import { productionConfigProblems } from './production-checks'; +import { isValidOrigin, masterCodeAllowed, productionConfigProblems } from './production-checks'; describe('productionConfigProblems', () => { const A = 'a'.repeat(40); @@ -46,4 +46,17 @@ describe('productionConfigProblems', () => { it('lists every problem at once', () => { 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); + }); }); diff --git a/apps/api/src/common/production-checks.ts b/apps/api/src/common/production-checks.ts index 226519d..cbb8d72 100644 --- a/apps/api/src/common/production-checks.ts +++ b/apps/api/src/common/production-checks.ts @@ -1,8 +1,9 @@ /** - * What a production server must have before it may start (REL-01, REL-03, - * SEC-04). Each problem is one line, in Persian then English; main.ts refuses - * to boot while the list is non-empty. Pure (takes the env), so it is tested - * without starting the app. + * What a production server should have (REL-01, REL-03, SEC-04). Each problem + * is one line, in Persian then English. main.ts prints them as a warning and + * applies a safe fallback for each instead of refusing to boot: a refused boot + * 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'; @@ -26,6 +27,21 @@ export function parseCorsOrigins(raw: string | undefined): string[] { .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[] { if (env.NODE_ENV !== 'production') return []; 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.', ); } else { - const bad = origins.filter((o) => o === '*' || !/^https?:\/\/[^/\s]+$/i.test(o)); + const bad = origins.filter((o) => !isValidOrigin(o)); if (bad.length) { problems.push( `CORS_ORIGINS فقط نشانی کامل می‌پذیرد، مثل https://adm.example.ir — نادرست: ${bad.join(', ')} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(', ')}`, diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 3cb1767..ce9b959 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -9,7 +9,8 @@ import { prepareDatabase } from './prisma/bootstrap'; import { PrismaExceptionFilter } from './prisma/prisma-exception.filter'; import { assertSmsConfigured } from './modules/sms/sms.service'; 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'; // serve-static's bundled `mime` version predates .avif, so it falls back to @@ -20,40 +21,48 @@ const EXTRA_MIME_TYPES: Record = { }; /** - * REL-01, REL-03, SEC-04 — a production server does not start until its keys, - * allowed origins and the fixed test login are right. Every problem is listed - * at once, so one restart fixes them all. Outside production nothing is - * checked and the development fallbacks keep a fresh checkout running. + * REL-01, REL-03, SEC-04 — every production config problem is printed at once, + * then the server starts with a safe fallback for each: random keys kept in + * the database (boot-secrets.ts), the fixed test login disabled + * (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. */ -function assertProductionConfig() { - const problems = productionConfigProblems(process.env); +function warnProductionConfig(problems: string[]) { if (!problems.length) return; - throw new Error( - '\nسرور در حالت production اجرا نمی‌شود تا این موارد درست شوند:\n' + - 'Refusing to start in production until these are fixed:\n' + + // eslint-disable-next-line no-console + console.warn( + '\n' + + '='.repeat(72) + + '\n ⚠ تنظیمات production کامل نیست؛ سرور با جایگزین امن بالا می‌آید:\n' + + ' ⚠ Production config is incomplete; starting with safe fallbacks:\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 * 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 { - const list = parseCorsOrigins(envOrNull('CORS_ORIGINS') ?? ''); + const list = parseCorsOrigins(envOrNull('CORS_ORIGINS') ?? '').filter(isValidOrigin); return list.length ? list : true; } async function bootstrap() { - assertProductionConfig(); + warnProductionConfig(productionConfigProblems(process.env)); // D20 — reports the SMS configuration and never throws. A missing credential // no longer stops the boot; messages are logged instead of sent. assertSmsConfigured(); + await prepareDatabase(); + await ensureJwtSecrets(); + // 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 // 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(AppModule); app.setGlobalPrefix('api'); app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); diff --git a/apps/api/src/modules/auth/auth.module.ts b/apps/api/src/modules/auth/auth.module.ts index 1d2dde8..eceb328 100644 --- a/apps/api/src/modules/auth/auth.module.ts +++ b/apps/api/src/modules/auth/auth.module.ts @@ -13,17 +13,21 @@ import { JwtAuthGuard, PermissionsGuard } from './guards'; imports: [ PrismaModule, PassportModule, - JwtModule.register({ - // envOr, not `??`: docker-compose passes every optional variable as - // `${X:-}`, so an unconfigured one arrives as "" rather than undefined. - // An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s - // admin login in Docker only. See DEPLOY-02. - secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'), - // 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`, - }, + // registerAsync: read when the app is created, not when this file is + // imported, so a JWT_SECRET filled in at boot (boot-secrets.ts) is used. + JwtModule.registerAsync({ + useFactory: () => ({ + // envOr, not `??`: docker-compose passes every optional variable as + // `${X:-}`, so an unconfigured one arrives as "" rather than undefined. + // An empty expiresIn makes jsonwebtoken throw on EVERY sign, which 500s + // admin login in Docker only. See DEPLOY-02. + secret: envOr('JWT_SECRET', 'dev-only-insecure-secret'), + // 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], diff --git a/apps/api/src/modules/customer-auth/customer-auth.service.ts b/apps/api/src/modules/customer-auth/customer-auth.service.ts index 1ee566f..4270bcf 100644 --- a/apps/api/src/modules/customer-auth/customer-auth.service.ts +++ b/apps/api/src/modules/customer-auth/customer-auth.service.ts @@ -13,6 +13,7 @@ import { import { AppSessionService } from '../sessions/app-session.service'; import { OTP_ONLY_PASSWORD_HASH } from '../auth/password-hash'; 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 { faToEn } from './digits'; 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. */ export function masterOtpCode(): string | null { + if (!masterCodeAllowed(process.env)) return null; const raw = process.env.OTP_MASTER_CODE?.trim(); return raw ? raw : null; } diff --git a/apps/api/src/prisma/boot-secrets.spec.ts b/apps/api/src/prisma/boot-secrets.spec.ts new file mode 100644 index 0000000..3883eb1 --- /dev/null +++ b/apps/api/src/prisma/boot-secrets.spec.ts @@ -0,0 +1,59 @@ +import { fillJwtSecrets } from './boot-secrets'; + +function memoryStore(initial: Record = {}) { + 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')); + }); +}); diff --git a/apps/api/src/prisma/boot-secrets.ts b/apps/api/src/prisma/boot-secrets.ts new file mode 100644 index 0000000..c4da008 --- /dev/null +++ b/apps/api/src/prisma/boot-secrets.ts @@ -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; + write(key: string, value: string): Promise; +}; + +/** + * 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 { + 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 { + 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(); + } +} diff --git a/docs/SERVER-SETUP-CHECKLIST.md b/docs/SERVER-SETUP-CHECKLIST.md index 1b36b6c..2e1ef0a 100644 --- a/docs/SERVER-SETUP-CHECKLIST.md +++ b/docs/SERVER-SETUP-CHECKLIST.md @@ -2,9 +2,16 @@ برای کسی که به سرور اصلی (سرور واقعیِ مشتری‌ها) دسترسی دارد. -از این نسخه به بعد، سرور تا وقتی سه مورد زیر درست نشده باشند **روشن نمی‌شود**. -در گزارش سرور، فهرست کامل ایرادها با هم چاپ می‌شود؛ همه را درست کنید و یک بار -دوباره راه‌اندازی کنید. +اگر موارد زیر درست نباشند، سرور باز هم روشن می‌شود ولی در گزارش سرور فهرست +کامل ایرادها با یک هشدار بزرگ چاپ می‌شود و برای هر مورد جایگزین امن به کار +می‌رود: + +- کلیدهای `JWT_SECRET` و `JWT_CUSTOMER_SECRET`: یک کلید تصادفی یک بار ساخته و در + جدول `Setting` نگه داشته می‌شود، پس با ریستارت عوض نمی‌شود. +- `OTP_MASTER_CODE`: بی‌اثر می‌شود و کد ثابت کار نمی‌کند. +- `CORS_ORIGINS`: همه‌ی نشانی‌ها مجازند. + +باز هم بهتر است هر سه را درست تنظیم کنید و یک بار دوباره راه‌اندازی کنید. این بررسی فقط در حالت اصلی (`NODE_ENV=production`) انجام می‌شود. تصویر داکرِ سرور همیشه در این حالت است.