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
+7 -5
View File
@@ -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);
});
});
+80
View File
@@ -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
View File
@@ -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.