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