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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user