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:
co-authored by
Claude Opus 5.5
parent
d91dcf197b
commit
9d3b5388ba
+14
-5
@@ -25,10 +25,17 @@ DATABASE_URL=postgresql://postgres:<the same password>@db:5432/goldbank?schema=p
|
||||
|
||||
# Auth — two DIFFERENT random values. Generate each with:
|
||||
# node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
|
||||
JWT_SECRET=<random value 1>
|
||||
JWT_CUSTOMER_SECRET=<random value 2, NOT the same as above>
|
||||
JWT_SECRET=<random value 1, 32+ characters>
|
||||
JWT_CUSTOMER_SECRET=<random value 2, 32+ characters, NOT the same as above>
|
||||
# Every origin the panel and the two apps are opened from, comma-separated.
|
||||
CORS_ORIGINS=https://adm.example.ir,https://app.example.ir,https://app2.example.ir
|
||||
```
|
||||
|
||||
The API **refuses to start** in production until all three are right, and
|
||||
prints every problem at once (REL-01, REL-03, SEC-04 —
|
||||
docs/SERVER-SETUP-CHECKLIST.md). A random value:
|
||||
`node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`.
|
||||
|
||||
Inside Docker the database host is `db`, not `localhost`.
|
||||
|
||||
Everything else is optional and may be left blank. Two you may want now:
|
||||
@@ -36,8 +43,10 @@ Everything else is optional and may be left blank. Two you may want now:
|
||||
```dotenv
|
||||
# Delivery only — a fixed OTP that signs in ANY active customer.
|
||||
# Exactly 4 digits, or it cannot be typed into the apps. REMOVE before the
|
||||
# real release (see "Before the real release" below).
|
||||
# real release (see "Before the real release" below). The production image
|
||||
# refuses to start with it unless the server is marked as a test server:
|
||||
OTP_MASTER_CODE=1379
|
||||
TEST_SERVER_ALLOW_MASTER_CODE=yes
|
||||
|
||||
# SMS goes through the "relay" gateway with nothing to set (D26).
|
||||
# SMS_PROVIDER=none turns sending off; the server still starts either way.
|
||||
@@ -112,8 +121,8 @@ before it is a production release. The first three are blocking.
|
||||
| # | Do this | Why it matters |
|
||||
|---|---|---|
|
||||
| **1** | **Set `JWT_CUSTOMER_SECRET` to its own random value**, different from `JWT_SECRET`. | Not a cosmetic default. Unset, the API signs customer and sales-expert **app** tokens with `JWT_SECRET` — *the same key admin tokens use* — and the two token realms stop being cryptographically separate. The only remaining barrier is the `typ` check in `jwt.strategy.ts`, and a sales expert's app token carries their **AdminUser id** in `sub`. Two independent defences collapse into one. A loud boot banner fires while it is missing. **REL-03 (Critical).** |
|
||||
| **2** | **Remove `OTP_MASTER_CODE` from the environment.** | While set, **any active customer signs in with that one fixed code**. Unset or empty disables it completely; the API prints a banner at boot for as long as it is armed. **REL-01 (Critical).** |
|
||||
| **3** | **Set real `JWT_SECRET`.** | Without it the API falls back to a well-known development string, so anyone can forge an admin token. |
|
||||
| **2** | **Remove `OTP_MASTER_CODE` and `TEST_SERVER_ALLOW_MASTER_CODE` from the environment.** The API now refuses to start with the code set unless that flag says this is a test server. | While set, **any active customer signs in with that one fixed code**. Unset or empty disables it completely; the API prints a banner at boot for as long as it is armed. **REL-01 (Critical).** |
|
||||
| **3** | **Set real `JWT_SECRET` and `CORS_ORIGINS`.** The API refuses to start without them. | Without the key anyone could forge an admin token; without the origin list any website could call the API from a visitor's browser. |
|
||||
| **4** | **Confirm SMS is going out.** The default `relay` gateway needs no credentials (D26); check `docker compose logs api` for `SMS provider: relay` and send yourself a login code. | If the gateway refuses the `host` value (inside Docker it is the container's IP), set `SMS_RELAY_HOST`. A failed send is only a log line — the server still boots (D20). **REL-02.** |
|
||||
|
||||
| **5** | **Accounting integration (D27), only if it will be switched on:** set `ACCOUNTING_SECRET_KEY`; count products with no image (they disappear from the app — query in ISSUES ACC-05) and photograph them first; for Zarrin, run the trial on temporary documents first (ACC-01). | The integration itself is off until an admin turns it on in «اتصال حسابداری»; nothing changes before that except the no-image rule, which applies to every product. |
|
||||
|
||||
@@ -7,7 +7,9 @@ PORT=3000
|
||||
|
||||
# Customer app sessions (security hotfix stage 2).
|
||||
# Separate from JWT_SECRET so an admin token can never validate as a customer
|
||||
# token. Falls back to JWT_SECRET, then to a dev-only string, when unset.
|
||||
# token. Falls back to JWT_SECRET, then to a dev-only string, when unset —
|
||||
# development only: in production it is REQUIRED, at least 32 characters and
|
||||
# different from JWT_SECRET, or the API refuses to start (REL-03).
|
||||
JWT_CUSTOMER_SECRET=
|
||||
# Customer token lifetime. The reference issued a token that never expired.
|
||||
JWT_CUSTOMER_EXPIRES_IN=30d
|
||||
@@ -53,10 +55,10 @@ ACCOUNTING_SECRET_KEY=
|
||||
ACCOUNTING_WORKER=
|
||||
|
||||
# SEC-04 — browser origins allowed to call the API, comma-separated, exact
|
||||
# (e.g. https://adm.example.ir,https://app.example.ir). Blank = any origin
|
||||
# (development); in production a blank list prints a warning at boot.
|
||||
# In production (NODE_ENV=production) JWT_SECRET is REQUIRED: the API refuses
|
||||
# to start on the built-in development key.
|
||||
# (e.g. https://adm.example.ir,https://app.example.ir). Blank = any origin,
|
||||
# development only. In production (NODE_ENV=production) the API refuses to
|
||||
# start until CORS_ORIGINS lists full origins and JWT_SECRET is a random value
|
||||
# of at least 32 characters (SEC-04). See docs/SERVER-SETUP-CHECKLIST.md.
|
||||
CORS_ORIGINS=
|
||||
|
||||
# SEC-05 — the seed runs once on a fresh database at boot. In production it
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { productionConfigProblems } from './production-checks';
|
||||
|
||||
describe('productionConfigProblems', () => {
|
||||
const A = 'a'.repeat(40);
|
||||
const C = 'c'.repeat(40);
|
||||
const good = {
|
||||
NODE_ENV: 'production',
|
||||
JWT_SECRET: A,
|
||||
JWT_CUSTOMER_SECRET: C,
|
||||
CORS_ORIGINS: 'https://adm.example.ir, https://app.example.ir/',
|
||||
};
|
||||
|
||||
it('a complete production config passes', () => {
|
||||
expect(productionConfigProblems(good)).toEqual([]);
|
||||
});
|
||||
|
||||
it('checks nothing outside production', () => {
|
||||
expect(productionConfigProblems({ NODE_ENV: 'development' })).toEqual([]);
|
||||
});
|
||||
|
||||
it('refuses the fixed test login code (REL-01)', () => {
|
||||
expect(productionConfigProblems({ ...good, OTP_MASTER_CODE: '1234' })).toHaveLength(1);
|
||||
// explicit opt-in for a test server built from the production image
|
||||
expect(
|
||||
productionConfigProblems({ ...good, OTP_MASTER_CODE: '1234', TEST_SERVER_ALLOW_MASTER_CODE: 'yes' }),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('requires a separate customer token key (REL-03)', () => {
|
||||
expect(productionConfigProblems({ ...good, JWT_CUSTOMER_SECRET: '' })).toHaveLength(1);
|
||||
expect(productionConfigProblems({ ...good, JWT_CUSTOMER_SECRET: A })).toHaveLength(1);
|
||||
expect(productionConfigProblems({ ...good, JWT_CUSTOMER_SECRET: 'short' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('requires a strong admin key (SEC-04)', () => {
|
||||
expect(productionConfigProblems({ ...good, JWT_SECRET: undefined })).toHaveLength(1);
|
||||
expect(productionConfigProblems({ ...good, JWT_SECRET: 'dev-only-insecure-secret' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('requires an explicit list of allowed origins (SEC-04)', () => {
|
||||
expect(productionConfigProblems({ ...good, CORS_ORIGINS: '' })).toHaveLength(1);
|
||||
expect(productionConfigProblems({ ...good, CORS_ORIGINS: '*' })).toHaveLength(1);
|
||||
expect(productionConfigProblems({ ...good, CORS_ORIGINS: 'adm.example.ir' })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('lists every problem at once', () => {
|
||||
expect(productionConfigProblems({ NODE_ENV: 'production', OTP_MASTER_CODE: '1234' })).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export const DEV_JWT_SECRET = 'dev-only-insecure-secret';
|
||||
|
||||
/** Lets a TEST server built from the production image keep the fixed OTP. */
|
||||
export const ALLOW_MASTER_CODE_FLAG = 'TEST_SERVER_ALLOW_MASTER_CODE';
|
||||
|
||||
const MIN_SECRET_LENGTH = 32;
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
|
||||
const val = (env: Env, k: string) => {
|
||||
const v = env[k]?.trim();
|
||||
return v ? v : null;
|
||||
};
|
||||
|
||||
/** CORS_ORIGINS as a clean list of origins (no trailing slash). */
|
||||
export function parseCorsOrigins(raw: string | undefined): string[] {
|
||||
return (raw ?? '')
|
||||
.split(',')
|
||||
.map((o) => o.trim().replace(/\/+$/, ''))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function productionConfigProblems(env: Env): string[] {
|
||||
if (env.NODE_ENV !== 'production') return [];
|
||||
const problems: string[] = [];
|
||||
|
||||
// SEC-04 — the admin token key.
|
||||
const admin = val(env, 'JWT_SECRET');
|
||||
if (!admin) {
|
||||
problems.push('JWT_SECRET تنظیم نشده است. / JWT_SECRET is not set.');
|
||||
} else if (admin === DEV_JWT_SECRET || admin.length < MIN_SECRET_LENGTH) {
|
||||
problems.push(
|
||||
`JWT_SECRET باید یک مقدار تصادفی دستکم ${MIN_SECRET_LENGTH} نویسه باشد. / JWT_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
// REL-03 — the customer / sales-expert app token key, separate from the admin one.
|
||||
const customer = val(env, 'JWT_CUSTOMER_SECRET');
|
||||
if (!customer) {
|
||||
problems.push(
|
||||
'JWT_CUSTOMER_SECRET تنظیم نشده است؛ توکن مشتریها با کلید مدیرها امضا میشود. / JWT_CUSTOMER_SECRET is not set: app tokens would be signed with the admin key.',
|
||||
);
|
||||
} else if (customer === admin) {
|
||||
problems.push('JWT_CUSTOMER_SECRET نباید با JWT_SECRET یکی باشد. / JWT_CUSTOMER_SECRET must differ from JWT_SECRET.');
|
||||
} else if (customer === DEV_JWT_SECRET || customer.length < MIN_SECRET_LENGTH) {
|
||||
problems.push(
|
||||
`JWT_CUSTOMER_SECRET باید یک مقدار تصادفی دستکم ${MIN_SECRET_LENGTH} نویسه باشد. / JWT_CUSTOMER_SECRET must be a random value of at least ${MIN_SECRET_LENGTH} characters.`,
|
||||
);
|
||||
}
|
||||
|
||||
// SEC-04 — only the panel and the apps may call the API from a browser.
|
||||
const origins = parseCorsOrigins(env.CORS_ORIGINS);
|
||||
if (!origins.length) {
|
||||
problems.push(
|
||||
'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));
|
||||
if (bad.length) {
|
||||
problems.push(
|
||||
`CORS_ORIGINS فقط نشانی کامل میپذیرد، مثل https://adm.example.ir — نادرست: ${bad.join(', ')} / CORS_ORIGINS takes full origins only; invalid: ${bad.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// REL-01 — the fixed test OTP signs in ANY customer.
|
||||
if (val(env, 'OTP_MASTER_CODE') && val(env, ALLOW_MASTER_CODE_FLAG) !== 'yes') {
|
||||
problems.push(
|
||||
`OTP_MASTER_CODE (کد ثابت ورود آزمایشی) روی سرور اصلی مجاز نیست؛ آن را حذف کنید. فقط سرور آزمایشی با ${ALLOW_MASTER_CODE_FLAG}=yes. / OTP_MASTER_CODE is not allowed in production; remove it (test servers only: ${ALLOW_MASTER_CODE_FLAG}=yes).`,
|
||||
);
|
||||
}
|
||||
|
||||
return problems;
|
||||
}
|
||||
+19
-24
@@ -9,6 +9,7 @@ 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 { masterOtpCode, masterOtpEnabled } from './modules/customer-auth/customer-auth.service';
|
||||
|
||||
// serve-static's bundled `mime` version predates .avif, so it falls back to
|
||||
@@ -19,41 +20,35 @@ const EXTRA_MIME_TYPES: Record<string, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* SEC-04 — production must not run on the published development key. Both JWT
|
||||
* modules fall back to 'dev-only-insecure-secret' (a string anyone can read
|
||||
* in this repository), with which anyone could mint an admin token. Outside
|
||||
* production that fallback is kept, so a fresh checkout still runs.
|
||||
* 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.
|
||||
* The rules live in common/production-checks.ts.
|
||||
*/
|
||||
function assertProductionSecrets() {
|
||||
if (process.env.NODE_ENV !== 'production') return;
|
||||
if (!envOrNull('JWT_SECRET')) {
|
||||
throw new Error(
|
||||
'JWT_SECRET تنظیم نشده است — سرور در حالت production بدون آن اجرا نمیشود.\n' +
|
||||
'JWT_SECRET is not set: refusing to start in production on the public development key.',
|
||||
);
|
||||
}
|
||||
function assertProductionConfig() {
|
||||
const problems = productionConfigProblems(process.env);
|
||||
if (!problems.length) return;
|
||||
throw new Error(
|
||||
'\nسرور در حالت production اجرا نمیشود تا این موارد درست شوند:\n' +
|
||||
'Refusing to start in production until these are fixed:\n' +
|
||||
problems.map((p) => ' • ' + p).join('\n') +
|
||||
'\n(see docs/SERVER-SETUP-CHECKLIST.md)\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).
|
||||
* Unset keeps the old allow-all, with a warning in production, so an existing
|
||||
* deployment keeps working until the list is filled in.
|
||||
* Required in production (assertProductionConfig); unset elsewhere = allow-all.
|
||||
*/
|
||||
function corsOrigins(): string[] | true {
|
||||
const list = (envOrNull('CORS_ORIGINS') ?? '')
|
||||
.split(',')
|
||||
.map((o) => o.trim().replace(/\/+$/, ''))
|
||||
.filter(Boolean);
|
||||
if (list.length) return list;
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.warn('⚠ CORS_ORIGINS تنظیم نشده — هر سایتی میتواند به API درخواست بفرستد. / CORS_ORIGINS is not set: any website may call this API.');
|
||||
}
|
||||
return true;
|
||||
const list = parseCorsOrigins(envOrNull('CORS_ORIGINS') ?? '');
|
||||
return list.length ? list : true;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
assertProductionSecrets();
|
||||
assertProductionConfig();
|
||||
|
||||
// D20 — reports the SMS configuration and never throws. A missing credential
|
||||
// no longer stops the boot; messages are logged instead of sent.
|
||||
|
||||
+10
-6
@@ -31,21 +31,25 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=${DATABASE_URL:-postgresql://postgres:postgres@db:5432/goldbank?schema=public}
|
||||
- PORT=${PORT:-3000}
|
||||
# Auth. Without these the API falls back to a well-known development
|
||||
# secret, so set them in the deployment environment.
|
||||
# Auth. REQUIRED: the API refuses to start without both, each a random
|
||||
# value of 32+ characters, and the two must differ (SEC-04, REL-03).
|
||||
# See docs/SERVER-SETUP-CHECKLIST.md.
|
||||
- JWT_SECRET=${JWT_SECRET:-}
|
||||
- JWT_EXPIRES_IN=${JWT_EXPIRES_IN:-}
|
||||
- JWT_CUSTOMER_SECRET=${JWT_CUSTOMER_SECRET:-}
|
||||
- JWT_CUSTOMER_EXPIRES_IN=${JWT_CUSTOMER_EXPIRES_IN:-}
|
||||
# SEC-04: comma-separated browser origins allowed to call the API
|
||||
# (e.g. https://adm.example.ir,https://app.example.ir). Blank = any.
|
||||
# (e.g. https://adm.example.ir,https://app.example.ir). REQUIRED: the API
|
||||
# refuses to start with it blank.
|
||||
- CORS_ORIGINS=${CORS_ORIGINS:-}
|
||||
# SEC-05: demo seed data is never created in production unless "true".
|
||||
- SEED_DEMO_DATA=${SEED_DEMO_DATA:-}
|
||||
# ⚠ DELIVERY ONLY — a fixed OTP that signs in ANY active customer.
|
||||
# Empty/unset disables it, which is the correct state for production.
|
||||
# See ISSUES REL-01. Must be exactly 4 digits to be typable in the apps.
|
||||
# ⚠ TEST SERVERS ONLY — a fixed OTP that signs in ANY active customer.
|
||||
# Leave empty in production: the API refuses to start with it set unless
|
||||
# TEST_SERVER_ALLOW_MASTER_CODE=yes, which only a test server may carry
|
||||
# (REL-01). Must be exactly 4 digits to be typable in the apps.
|
||||
- OTP_MASTER_CODE=${OTP_MASTER_CODE:-}
|
||||
- TEST_SERVER_ALLOW_MASTER_CODE=${TEST_SERVER_ALLOW_MASTER_CODE:-}
|
||||
# SMS. All blank = the "relay" gateway with its built-in defaults (D26).
|
||||
# A failed SMS never blocks the boot or an operation (D20).
|
||||
- SMS_PROVIDER=${SMS_PROVIDER:-}
|
||||
|
||||
@@ -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. ورود مشتری با پیامک را امتحان کنید. کد ثابت دیگر نباید کار کند.
|
||||
@@ -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. |
|
||||
|
||||
Reference in New Issue
Block a user