fix(security): a production server refuses to start until its keys, allowed origins and test login are right (REL-01, REL-03, SEC-04)

With NODE_ENV=production (always, in the Docker image) the API lists
every problem at once and does not start while any of these holds:
- OTP_MASTER_CODE is set: the fixed test login signs in any customer. A
  test server built from the production image must say so with
  TEST_SERVER_ALLOW_MASTER_CODE=yes.
- JWT_CUSTOMER_SECRET is missing, equal to JWT_SECRET, or under 32
  characters.
- JWT_SECRET is missing, the published dev key, or under 32 characters.
- CORS_ORIGINS is empty, `*`, or not full origins. It no longer falls
  back to allow-all.

Checked live: a misconfigured start lists all four problems; a correct
one starts, answers the allowed origin, and blocks any other.
docs/SERVER-SETUP-CHECKLIST.md is the operator's list. DELIVERY.md,
docker-compose.yml and .env.example are updated to match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
s.esmaeili
2026-10-08 17:00:17 +03:30
co-authored by Claude Opus 5.5
parent d91dcf197b
commit 9d3b5388ba
8 changed files with 256 additions and 42 deletions
+75
View File
@@ -0,0 +1,75 @@
# فهرست تنظیمات سرور اصلی
برای کسی که به سرور اصلی (سرور واقعیِ مشتری‌ها) دسترسی دارد.
از این نسخه به بعد، سرور تا وقتی سه مورد زیر درست نشده باشند **روشن نمی‌شود**.
در گزارش سرور، فهرست کامل ایرادها با هم چاپ می‌شود؛ همه را درست کنید و یک بار
دوباره راه‌اندازی کنید.
این بررسی فقط در حالت اصلی (`NODE_ENV=production`) انجام می‌شود. تصویر داکرِ
سرور همیشه در این حالت است.
---
## ۱. حذف کد ثابت ورود آزمایشی
| متغیر | مقدار درست |
|---|---|
| `OTP_MASTER_CODE` | **خالی** یا کلاً حذف‌شده |
تا وقتی این کد هست، هر کسی که آن را بداند وارد حساب **هر** مشتری می‌شود.
> سرور **آزمایشی**ای که عمداً این کد را لازم دارد، باید کنارش
> `TEST_SERVER_ALLOW_MASTER_CODE=yes` بگذارد. این را **هرگز** روی سرور اصلی
> نگذارید.
## ۲. کلید جدا برای مشتری‌ها
| متغیر | مقدار درست |
|---|---|
| `JWT_SECRET` | یک رشته‌ی تصادفی، دست‌کم ۳۲ نویسه (کلید ورود مدیرها) |
| `JWT_CUSTOMER_SECRET` | یک رشته‌ی تصادفیِ **دیگر**، دست‌کم ۳۲ نویسه (کلید ورود مشتری‌ها و کارشناس‌ها) |
دو مقدار **نباید یکی باشند**. ساختن مقدار تصادفی، روی هر سیستمی که Node دارد:
```
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
دو بار اجرا کنید و هر خروجی را برای یکی از دو متغیر بگذارید.
⚠ **عوض کردن این کلیدها همه را از حساب بیرون می‌کند.** مدیرها و مشتری‌ها
یک بار دوباره وارد می‌شوند. اطلاعات کسی از بین نمی‌رود.
⚠ اگر `ACCOUNTING_SECRET_KEY` خالی باشد، رمز ذخیره‌شده‌ی نرم‌افزار حسابداری از
`JWT_SECRET` ساخته می‌شود. با عوض شدن `JWT_SECRET` باید یک بار دوباره در
«تنظیمات حسابداری» پنل وارد شوید. بهتر است `ACCOUNTING_SECRET_KEY` هم یک مقدار
تصادفیِ جدا بگیرد و دیگر عوض نشود.
## ۳. نشانی‌های مجاز
| متغیر | مقدار درست |
|---|---|
| `CORS_ORIGINS` | نشانی کامل پنل و اپ‌ها، با ویرگول جدا |
نمونه:
```
CORS_ORIGINS=https://adm.henzagold.ir,https://app.henzagold.ir,https://app2.henzagold.ir
```
- هر نشانی با `https://` شروع شود و **مسیر نداشته باشد**؛ مثلاً `/admin` در
آخرش نیاید.
- هر دامنه‌ای که پنل یا یکی از دو اپ از آن باز می‌شود باید در فهرست باشد؛
وگرنه آن صفحه نمی‌تواند با سرور حرف بزند.
- `*` پذیرفته نمی‌شود.
---
## بعد از تنظیم
1. سرور را دوباره راه‌اندازی کنید.
2. در گزارش سرور باید `Nest application successfully started` دیده شود.
اگر سرور بالا نیامد، فهرست ایرادها همان‌جا چاپ شده است.
3. پنل مدیریت و هر دو اپ را باز کنید و یک بار وارد شوید.
4. ورود مشتری با پیامک را امتحان کنید. کد ثابت دیگر نباید کار کند.
+2 -2
View File
@@ -131,7 +131,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **REL-01** | **Critical — before the real release** | TGT deployment env | **`OTP_MASTER_CODE` must be removed from the environment, and the SMS credentials configured.** While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. | `customer-auth.service.ts` `masterOtpCode()`; `main.ts` boot banner; D20 | Unset `OTP_MASTER_CODE`; set `SMS_TREZ_USERNAME` / `SMS_TREZ_PASSWORD` / `SMS_TREZ_SENDER_LINE`; restart and confirm the banner is gone. |
| **REL-01** | **Critical — before the real release** | TGT deployment env | **`OTP_MASTER_CODE` must be removed from the environment, and the SMS credentials configured.** While the variable is set, ANY active customer can sign in with that one fixed code. It is off by default (unset = disabled) and the API prints a boot banner while it is armed, but nothing stops it reaching production except this step. | `customer-auth.service.ts` `masterOtpCode()`; `main.ts` boot banner; D20 | Unset `OTP_MASTER_CODE`; set `SMS_TREZ_USERNAME` / `SMS_TREZ_PASSWORD` / `SMS_TREZ_SENDER_LINE`; restart and confirm the banner is gone. 🔒 **ENFORCED 2026-10-08:** a production API refuses to start while OTP_MASTER_CODE is set, unless TEST_SERVER_ALLOW_MASTER_CODE=yes marks a test server (common/production-checks.ts). Removing it from the live environment is still the operator's step — docs/SERVER-SETUP-CHECKLIST.md. |
| **REL-02** | High | TGT `apps/api/src/modules/sms/sms.service.ts` | D20 removed the rule that a missing SMS credential aborts the boot in production. The server now starts with SMS disabled, so **customers cannot receive a login code** and the failure is only a log line. Deliberate, to keep the delivery unblocked. | D14 (superseded) vs D20 | After the delivery, decide whether to restore a boot check or add a health endpoint that reports SMS readiness. **Eased by D26 (2026-10-05):** the default `relay` driver needs no credentials, so SMS works on a fresh deployment; a gateway refusal is still only a log line. |
| **PERS-01** | Medium | REF `AdminController.php:348` vs TGT `admin-customers.service.ts` `update()` | The reference **discards the submitted mobile on edit** (`$input['mobile'] = $customer->mobile;`), making it immutable once set; the target lets an operator change it. D19 ruled the lock NOT ported, so the target keeps the editable behaviour. Changing a mobile changes the customer's login identity. | `AdminController.php:348`; D19 answer B | If the lock is wanted later, apply it in `update()` and hide/disable the field on the edit form. |
| **PERS-02** | Medium | REF `cities` table vs TGT `City` model | The reference's cities are a **two-level hierarchy** (`parent` + `level`, provinces at level 0) rendered as an `a-tree-select` with provinces **not selectable** (`citiesModel.js:38`). The target flattened this to a nullable `province` **string** and a flat `a-select` on all three Persons forms. Functionally the city is still captured; the province grouping and its tree UI are absent. | `2021_09_18_171700_city_table.php:18-19`; `henza_admin/src/mixins/citiesModel.js:20-48`; `schema.prisma` `model City` | ✅ **FIXED 2026-10-08** (owner: as legacy). `City.parentId` (self relation, Restrict) replaces `province`; migration `20261008170000_city_tree` turns each province label into a parent row («استان …») before dropping the column. Admin pickers are legacy's tree (`CityTreeSelect.vue`: top level with children not selectable); city form/grid have «والد». **Also restored:** legacy `_getP` — a city without its own percent takes its ancestor's (`getCityPercent`); the omde-customer form's city→percent autofill. Import copies legacy parents. Delete refuses a city with children; a parent loop is refused. |
@@ -151,7 +151,7 @@ Severity: `Critical` (money loss / security / data loss) · `High` · `Medium`
| ID | Severity | Where | Description | Evidence | Suggested fix |
|---|---|---|---|---|---|
| **REL-03** | **Critical — before the real release** | TGT deployment env; `customer-auth.service.ts` `customerJwtSecret()` | **`JWT_CUSTOMER_SECRET` must be set to its own value.** Unset, it falls back to `JWT_SECRET`, so customer/sales-expert **app** tokens are signed with the same key as **admin** tokens and the two realms stop being cryptographically separate. The only remaining defence is the `typ` check in `jwt.strategy.ts` — and an expert's app token carries their **AdminUser id** in `sub`, which is exactly what that check exists to stop. Two independent defences collapse to one. | `customerJwtSecret()` `?? process.env.JWT_SECRET`; `jwt.strategy.ts` `APP_TOKEN_TYPES` | Set `JWT_CUSTOMER_SECRET` to a distinct random value. A loud boot banner now fires while it is missing (`main.ts`), and DELIVERY.md lists it first. |
| **REL-03** | **Critical — before the real release** | TGT deployment env; `customer-auth.service.ts` `customerJwtSecret()` | **`JWT_CUSTOMER_SECRET` must be set to its own value.** Unset, it falls back to `JWT_SECRET`, so customer/sales-expert **app** tokens are signed with the same key as **admin** tokens and the two realms stop being cryptographically separate. The only remaining defence is the `typ` check in `jwt.strategy.ts` — and an expert's app token carries their **AdminUser id** in `sub`, which is exactly what that check exists to stop. Two independent defences collapse to one. | `customerJwtSecret()` `?? process.env.JWT_SECRET`; `jwt.strategy.ts` `APP_TOKEN_TYPES` | Set `JWT_CUSTOMER_SECRET` to a distinct random value. A loud boot banner now fires while it is missing (`main.ts`), and DELIVERY.md lists it first. 🔒 **ENFORCED 2026-10-08:** production refuses to start without JWT_CUSTOMER_SECRET, or with it equal to JWT_SECRET or under 32 characters. Setting it is the operator's step — docs/SERVER-SETUP-CHECKLIST.md. |
| **SEC-14** | High (reference bug, NOT ported) | REF `Major/CustomerController.php:87` | `complete()` did `$customer->update($request->all())` — it mass-assigned **the entire request body** onto the customer row. The app could therefore set its own `gram_level` (credit limit), `percent` (making charge), `remain` (gold debt), `active` or even `mobile` by adding fields to the profile-completion POST. A privilege-escalation and money hole reachable from any signed-in customer. | `$input = $request->all(); ... $customer->update($input);` | **Not ported.** The target reads a three-field whitelist (`CompleteProfileDto`) and writes only the three document URLs plus the verification step. Asserted by `test-complete-profile.cjs` §7, which posts credit-limit/debt/mobile overrides and checks the row is unchanged. |
| **SEC-15** | Low (reference bug, NOT ported) | REF `Major/CustomerController.php:110-112` | `sendSmsToAdmin()` hardcoded four real personal mobile numbers in the source. | `$admin_mobile = ['0990…','0913…','0912…','0919…'];` | **Not ported.** The target reads `PROFILE_ADMIN_MOBILES` (comma-separated, blank = notify nobody), so no personal number is committed to this repository. |
| **APP-03** | Low (reference bug, NOT ported) | REF `completeProfile.vue:175-181` | `refresh()` called `window.setInterval(() => window.location.reload(), 1000)` — an **unterminated** timer that reloaded the whole page every second forever, not a one-off refresh. | `completeProfile.vue:178-180` | **Not ported.** The target navigates home once after the success dialog. |