forked from wangziqi/gongxue-base
chore: harden production readiness gates
This commit is contained in:
@@ -62,6 +62,50 @@ assert.notEqual(unsafeApi.status, 0, 'production API config should reject local_
|
||||
assert.match(unsafeApi.output, /Invalid production API configuration/, 'API config should explain production config failure');
|
||||
assert.match(unsafeApi.output, /STORAGE_DEFAULT_PROVIDER=local_dev/, 'API config should name unsafe storage provider');
|
||||
|
||||
const unsafeApiSmsProvider = runImport(apiConfigUrl, {
|
||||
...safeApiEnv,
|
||||
AUTH_SMS_PROVIDER: 'mocked-production',
|
||||
});
|
||||
assert.notEqual(unsafeApiSmsProvider.status, 0, 'production API config should reject unsupported SMS provider');
|
||||
assert.match(
|
||||
unsafeApiSmsProvider.output,
|
||||
/AUTH_SMS_PROVIDER must be aliyun\/aliyun-sms or tencent\/tencent-sms/,
|
||||
'API config should name supported production SMS providers',
|
||||
);
|
||||
|
||||
const unsafeApiStoragePublicBaseUrl = runImport(apiConfigUrl, {
|
||||
...safeApiEnv,
|
||||
STORAGE_PUBLIC_BASE_URL: 'http://127.0.0.1:9000/assets',
|
||||
});
|
||||
assert.notEqual(unsafeApiStoragePublicBaseUrl.status, 0, 'production API config should reject unsafe storage public base URL');
|
||||
assert.match(
|
||||
unsafeApiStoragePublicBaseUrl.output,
|
||||
/STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL/,
|
||||
'API config should require HTTPS storage public base URL',
|
||||
);
|
||||
|
||||
const unsafeApiAliyunInternal = runImport(apiConfigUrl, {
|
||||
...safeApiEnv,
|
||||
ALIYUN_OSS_INTERNAL: 'true',
|
||||
});
|
||||
assert.notEqual(unsafeApiAliyunInternal.status, 0, 'production API config should reject internal OSS signed URL mode');
|
||||
assert.match(
|
||||
unsafeApiAliyunInternal.output,
|
||||
/ALIYUN_OSS_INTERNAL=true is not allowed/,
|
||||
'API config should name unsafe internal OSS mode',
|
||||
);
|
||||
|
||||
const unsafeApiAliyunEndpoint = runImport(apiConfigUrl, {
|
||||
...safeApiEnv,
|
||||
ALIYUN_OSS_ENDPOINT: 'https://oss-cn-hangzhou.example.com',
|
||||
});
|
||||
assert.notEqual(unsafeApiAliyunEndpoint.status, 0, 'production API config should reject non-Aliyun OSS endpoints');
|
||||
assert.match(
|
||||
unsafeApiAliyunEndpoint.output,
|
||||
/ALIYUN_OSS_ENDPOINT must be a production HTTPS aliyuncs\.com endpoint/,
|
||||
'API config should require official Aliyun OSS endpoint host',
|
||||
);
|
||||
|
||||
const safeApi = runImport(apiConfigUrl, safeApiEnv);
|
||||
assert.equal(safeApi.status, 0, `safe production API config should load: ${safeApi.output}`);
|
||||
|
||||
@@ -80,6 +124,28 @@ const unsafeWorkerScanner = runImport(workerConfigUrl, {
|
||||
assert.notEqual(unsafeWorkerScanner.status, 0, 'production worker config should require external scanner');
|
||||
assert.match(unsafeWorkerScanner.output, /WORKER_ASSET_SECURITY_SCANNER must include http/, 'worker config should require http scanner');
|
||||
|
||||
const unsafeWorkerStorageBaseUrl = runImport(workerConfigUrl, {
|
||||
...safeBaseEnv,
|
||||
STORAGE_PUBLIC_BASE_URL: 'http://localhost:9000/assets',
|
||||
});
|
||||
assert.notEqual(unsafeWorkerStorageBaseUrl.status, 0, 'production worker config should reject unsafe storage public base URL');
|
||||
assert.match(
|
||||
unsafeWorkerStorageBaseUrl.output,
|
||||
/STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL/,
|
||||
'worker config should require HTTPS storage public base URL',
|
||||
);
|
||||
|
||||
const unsafeWorkerAliyunInternal = runImport(workerConfigUrl, {
|
||||
...safeBaseEnv,
|
||||
ALIYUN_OSS_INTERNAL: 'true',
|
||||
});
|
||||
assert.notEqual(unsafeWorkerAliyunInternal.status, 0, 'production worker config should reject internal OSS signed URL mode');
|
||||
assert.match(
|
||||
unsafeWorkerAliyunInternal.output,
|
||||
/ALIYUN_OSS_INTERNAL=true is not allowed/,
|
||||
'worker config should name unsafe internal OSS mode',
|
||||
);
|
||||
|
||||
const unsafeWorkerPlatformAuditNotification = runImport(workerConfigUrl, {
|
||||
...safeBaseEnv,
|
||||
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',
|
||||
|
||||
@@ -7,12 +7,18 @@ import { spawnSync } from 'node:child_process';
|
||||
const repoRoot = process.cwd();
|
||||
const scriptPath = path.join(repoRoot, 'scripts', 'production-readiness-check.js');
|
||||
|
||||
function runReadiness(envContent) {
|
||||
function runReadiness(envContent, options = {}) {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-readiness-'));
|
||||
const envFile = path.join(tempDir, '.env');
|
||||
fs.writeFileSync(envFile, envContent, 'utf8');
|
||||
const args = [scriptPath, '--env-file', envFile, '--skip-db', '--json'];
|
||||
if (options.providerRows) {
|
||||
const fixtureFile = path.join(tempDir, 'provider-fixture.json');
|
||||
fs.writeFileSync(fixtureFile, JSON.stringify({ rows: options.providerRows }, null, 2), 'utf8');
|
||||
args.push('--provider-config-fixture', fixtureFile);
|
||||
}
|
||||
|
||||
const result = spawnSync(process.execPath, [scriptPath, '--env-file', envFile, '--skip-db', '--json'], {
|
||||
const result = spawnSync(process.execPath, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
@@ -47,6 +53,37 @@ assert.ok(
|
||||
'unsafe readiness should block mock SMS provider',
|
||||
);
|
||||
|
||||
const unsupportedSmsProvider = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com
|
||||
AUTH_SMS_PROVIDER=aliyun-production
|
||||
AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef
|
||||
AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl
|
||||
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
|
||||
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
|
||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||
PLATFORM_ADMIN_API_KEY=s3cure-platform-admin-key-2026-06-29-mnopqr
|
||||
STORAGE_DEFAULT_PROVIDER=aliyun_oss
|
||||
STORAGE_DEFAULT_BUCKET=tiku-assets
|
||||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||||
ALIYUN_OSS_REGION=cn-hangzhou
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
|
||||
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
`);
|
||||
|
||||
assert.notEqual(unsupportedSmsProvider.status, 0, 'unsupported production SMS provider readiness should fail');
|
||||
assert.ok(
|
||||
unsupportedSmsProvider.payload.checks?.some(item => item.id === 'env.auth_sms_provider' && item.status === 'blocker'),
|
||||
'readiness should block unsupported AUTH_SMS_PROVIDER values',
|
||||
);
|
||||
|
||||
const strongSecretA = 's3cure-prod-code-pepper-2026-06-29-abcdef';
|
||||
const strongSecretB = 's3cure-prod-session-secret-2026-06-29-ghijkl';
|
||||
const strongSecretC = 's3cure-platform-admin-key-2026-06-29-mnopqr';
|
||||
@@ -95,6 +132,84 @@ assert.ok(
|
||||
'env-only readiness should explicitly warn that DB checks are skipped',
|
||||
);
|
||||
|
||||
const unsafeProviderFixture = runReadiness(
|
||||
`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
|
||||
AUTH_SMS_PROVIDER=aliyun
|
||||
AUTH_CODE_PEPPER=${strongSecretA}
|
||||
AUTH_SESSION_SECRET=${strongSecretB}
|
||||
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
|
||||
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
|
||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||
PLATFORM_ADMIN_API_KEY=${strongSecretC}
|
||||
STORAGE_DEFAULT_PROVIDER=aliyun_oss
|
||||
STORAGE_DEFAULT_BUCKET=tiku-assets
|
||||
STORAGE_REQUIRE_TENANT_PREFIX=true
|
||||
ALIYUN_OSS_REGION=cn-hangzhou
|
||||
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
|
||||
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
|
||||
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
|
||||
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
|
||||
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
|
||||
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
|
||||
`,
|
||||
{
|
||||
providerRows: [
|
||||
{
|
||||
source: 'auth',
|
||||
tenantId: 'tenant-a',
|
||||
provider: 'qq-oauth',
|
||||
configPublic: {
|
||||
appId: '101000000',
|
||||
redirectUri: 'http://localhost:5173/auth/qq/callback',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: 'payment',
|
||||
tenantId: 'tenant-a',
|
||||
provider: 'wechat_pay',
|
||||
configPublic: {
|
||||
appId: 'wx123',
|
||||
merchantId: '1900000001',
|
||||
merchantSerialNo: 'serial123',
|
||||
},
|
||||
},
|
||||
{
|
||||
source: 'payment',
|
||||
tenantId: 'tenant-b',
|
||||
provider: 'alipay',
|
||||
configPublic: {
|
||||
appId: '2021000000000000',
|
||||
notifyUrl: 'http://pay.example.com/notify',
|
||||
privateKey: 'should-not-be-public',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
|
||||
assert.notEqual(unsafeProviderFixture.status, 0, 'unsafe provider fixture readiness should fail');
|
||||
assert.ok(
|
||||
unsafeProviderFixture.payload.checks?.some(item => item.id === 'db.auth.qq-oauth.redirect_uri.unsafe' && item.status === 'blocker'),
|
||||
'readiness should block unsafe QQ redirectUri',
|
||||
);
|
||||
assert.ok(
|
||||
unsafeProviderFixture.payload.checks?.some(item => item.id === 'db.payment.wechat_pay.public_required' && item.status === 'blocker'),
|
||||
'readiness should block missing WeChat Pay notifyUrl',
|
||||
);
|
||||
assert.ok(
|
||||
unsafeProviderFixture.payload.checks?.some(item => item.id === 'db.payment.alipay.notify_url.unsafe' && item.status === 'blocker'),
|
||||
'readiness should block unsafe Alipay notifyUrl',
|
||||
);
|
||||
assert.ok(
|
||||
unsafeProviderFixture.payload.checks?.some(item => item.id === 'db.payment.alipay.public_secret' && item.status === 'blocker'),
|
||||
'readiness should block secret-like public payment config keys',
|
||||
);
|
||||
|
||||
const missingJwksIssuer = runReadiness(`
|
||||
NODE_ENV=production
|
||||
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
|
||||
|
||||
@@ -10,6 +10,21 @@ const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me';
|
||||
const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
|
||||
const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
|
||||
const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024;
|
||||
const PRODUCTION_SMS_PROVIDERS = new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms']);
|
||||
const PRODUCTION_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
|
||||
const AUTH_PROVIDER_ALIASES = {
|
||||
sms: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms']),
|
||||
aliyun: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms']),
|
||||
tencent: new Set(['tencent', 'tencent-sms', 'tencent_sms']),
|
||||
wechatMiniapp: new Set(['wechat-miniapp', 'wechat_miniapp', 'wechat-mini', 'wx-miniapp', 'wx_miniapp']),
|
||||
wechatWeb: new Set(['wechat-web', 'wechat_web', 'wechat', 'wechat-oauth', 'wechat_oauth']),
|
||||
qq: new Set(['qq', 'qq-oauth', 'qq_oauth']),
|
||||
};
|
||||
const PAYMENT_PROVIDER_ALIASES = {
|
||||
wechatPay: new Set(['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay', 'wx_pay']),
|
||||
alipay: new Set(['alipay', 'ali_pay']),
|
||||
manual: new Set(['manual']),
|
||||
};
|
||||
|
||||
const args = new Set(process.argv.slice(2));
|
||||
const argValues = new Map();
|
||||
@@ -25,6 +40,7 @@ const jsonOutput = args.has('--json');
|
||||
const checkDb = args.has('--check-db');
|
||||
const skipDb = args.has('--skip-db') || !checkDb;
|
||||
const envFile = argValues.get('--env-file') || path.resolve(process.cwd(), '.env');
|
||||
const providerConfigFixture = argValues.get('--provider-config-fixture') || '';
|
||||
const checks = [];
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
@@ -101,10 +117,322 @@ function hostFromUrl(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function urlFromValue(value) {
|
||||
try {
|
||||
return new URL(String(value || ''));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isLocalHost(hostname) {
|
||||
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
|
||||
}
|
||||
|
||||
function isProductionHttpsUrl(value) {
|
||||
const url = urlFromValue(value);
|
||||
return Boolean(url && url.protocol === 'https:' && !isLocalHost(url.hostname.toLowerCase()));
|
||||
}
|
||||
|
||||
function isAllowedHost(hostname, allowedHosts) {
|
||||
const normalized = String(hostname || '').toLowerCase();
|
||||
return allowedHosts.some(host => normalized === host || normalized.endsWith(`.${host}`));
|
||||
}
|
||||
|
||||
function publicString(configPublic, keys) {
|
||||
for (const key of keys) {
|
||||
const value = configPublic?.[key];
|
||||
if (typeof value === 'string' && value.trim()) return value.trim();
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function missingPublicKeys(configPublic, keys) {
|
||||
return keys.filter(key => !publicString(configPublic, [key]));
|
||||
}
|
||||
|
||||
function normalizeProvider(value) {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function providerIn(provider, aliases) {
|
||||
return aliases.has(normalizeProvider(provider));
|
||||
}
|
||||
|
||||
function validateProviderUrl({
|
||||
id,
|
||||
value,
|
||||
message,
|
||||
allowedHosts = [],
|
||||
required = false,
|
||||
details = {},
|
||||
}) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
if (required) block(`${id}.missing`, message || 'Production URL is required', details);
|
||||
return;
|
||||
}
|
||||
const url = urlFromValue(raw);
|
||||
if (!url || url.protocol !== 'https:' || isLocalHost(url.hostname.toLowerCase())) {
|
||||
block(`${id}.unsafe`, message || 'Production URL must use HTTPS and must not point to localhost', details);
|
||||
return;
|
||||
}
|
||||
if (allowedHosts.length > 0 && !isAllowedHost(url.hostname, allowedHosts)) {
|
||||
block(`${id}.host`, 'Provider endpoint host is not allowed for production', {
|
||||
...details,
|
||||
host: url.hostname,
|
||||
allowedHosts,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function missingPublicKeyGroups(configPublic, groups) {
|
||||
return groups
|
||||
.filter(group => !publicString(configPublic, group.keys))
|
||||
.map(group => group.label);
|
||||
}
|
||||
|
||||
function blockMissingPublicConfig(row, missing) {
|
||||
if (missing.length === 0) return;
|
||||
block(
|
||||
`db.${row.source}.${safeProviderName(row.provider)}.public_required`,
|
||||
'Provider public config is missing production-required fields',
|
||||
{
|
||||
tenantId: row.tenant_id,
|
||||
provider: row.provider,
|
||||
missing,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function validateAuthProviderPublicConfig(row) {
|
||||
const provider = normalizeProvider(row.provider);
|
||||
const configPublic = row.config_public || {};
|
||||
const details = { tenantId: row.tenant_id, provider: row.provider };
|
||||
|
||||
if (provider === 'mock') {
|
||||
block('db.auth.mock_provider', 'Active/testing mock auth provider is not allowed in production', details);
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerIn(provider, AUTH_PROVIDER_ALIASES.aliyun)) {
|
||||
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
||||
{ label: 'signName', keys: ['signName'] },
|
||||
{ label: 'templateCode', keys: ['templateCode'] },
|
||||
]));
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
|
||||
value: publicString(configPublic, ['endpoint']),
|
||||
allowedHosts: ['aliyuncs.com'],
|
||||
details,
|
||||
});
|
||||
if (!publicString(configPublic, ['regionId'])) {
|
||||
warn(`db.auth.${safeProviderName(row.provider)}.region`, 'Aliyun SMS regionId is not set; default cn-hangzhou will be used', details);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerIn(provider, AUTH_PROVIDER_ALIASES.tencent)) {
|
||||
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
||||
{ label: 'smsSdkAppId/appId', keys: ['smsSdkAppId', 'appId'] },
|
||||
{ label: 'signName', keys: ['signName'] },
|
||||
{ label: 'templateId', keys: ['templateId'] },
|
||||
]));
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
|
||||
value: publicString(configPublic, ['endpoint']),
|
||||
allowedHosts: ['tencentcloudapi.com'],
|
||||
details,
|
||||
});
|
||||
if (!publicString(configPublic, ['region'])) {
|
||||
warn(`db.auth.${safeProviderName(row.provider)}.region`, 'Tencent SMS region is not set; default ap-guangzhou will be used', details);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerIn(provider, AUTH_PROVIDER_ALIASES.wechatMiniapp)) {
|
||||
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
||||
{ label: 'appId', keys: ['appId'] },
|
||||
]));
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
|
||||
value: publicString(configPublic, ['endpoint']),
|
||||
allowedHosts: ['weixin.qq.com'],
|
||||
details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerIn(provider, AUTH_PROVIDER_ALIASES.wechatWeb)) {
|
||||
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
||||
{ label: 'appId', keys: ['appId'] },
|
||||
]));
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.token_endpoint`,
|
||||
value: publicString(configPublic, ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint']),
|
||||
allowedHosts: ['weixin.qq.com'],
|
||||
details,
|
||||
});
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.userinfo_endpoint`,
|
||||
value: publicString(configPublic, ['userInfoEndpoint', 'userinfoEndpoint']),
|
||||
allowedHosts: ['weixin.qq.com'],
|
||||
details,
|
||||
});
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.redirect_uri`,
|
||||
value: publicString(configPublic, ['redirectUri', 'callbackUrl']),
|
||||
details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerIn(provider, AUTH_PROVIDER_ALIASES.qq)) {
|
||||
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
||||
{ label: 'appId/clientId', keys: ['appId', 'clientId'] },
|
||||
{ label: 'redirectUri/callbackUrl', keys: ['redirectUri', 'callbackUrl'] },
|
||||
]));
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.redirect_uri`,
|
||||
value: publicString(configPublic, ['redirectUri', 'callbackUrl']),
|
||||
required: true,
|
||||
details,
|
||||
});
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.token_endpoint`,
|
||||
value: publicString(configPublic, ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint']),
|
||||
allowedHosts: ['graph.qq.com'],
|
||||
details,
|
||||
});
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.openid_endpoint`,
|
||||
value: publicString(configPublic, ['openIdEndpoint', 'openidEndpoint']),
|
||||
allowedHosts: ['graph.qq.com'],
|
||||
details,
|
||||
});
|
||||
validateProviderUrl({
|
||||
id: `db.auth.${safeProviderName(row.provider)}.userinfo_endpoint`,
|
||||
value: publicString(configPublic, ['userInfoEndpoint', 'userinfoEndpoint']),
|
||||
allowedHosts: ['graph.qq.com'],
|
||||
details,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
block('db.auth.unsupported_provider', 'Active/testing auth provider is not in the supported production provider list', details);
|
||||
}
|
||||
|
||||
function validatePaymentProviderPublicConfig(row) {
|
||||
const provider = normalizeProvider(row.provider);
|
||||
const configPublic = row.config_public || {};
|
||||
const details = { tenantId: row.tenant_id, provider: row.provider };
|
||||
|
||||
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.manual)) {
|
||||
warn('db.payment.manual_provider', 'Manual payment account is active; use only for offline collection or migration operations', details);
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.wechatPay)) {
|
||||
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
||||
{ label: 'appId', keys: ['appId'] },
|
||||
{ label: 'merchantId/mchId', keys: ['merchantId', 'mchId'] },
|
||||
{ label: 'merchantSerialNo', keys: ['merchantSerialNo'] },
|
||||
{ label: 'notifyUrl', keys: ['notifyUrl'] },
|
||||
]));
|
||||
validateProviderUrl({
|
||||
id: `db.payment.${safeProviderName(row.provider)}.notify_url`,
|
||||
value: publicString(configPublic, ['notifyUrl']),
|
||||
required: true,
|
||||
details,
|
||||
});
|
||||
validateProviderUrl({
|
||||
id: `db.payment.${safeProviderName(row.provider)}.refund_notify_url`,
|
||||
value: publicString(configPublic, ['refundNotifyUrl']),
|
||||
details,
|
||||
});
|
||||
for (const [key, allowedHosts] of [
|
||||
['endpoint', ['api.mch.weixin.qq.com']],
|
||||
['refundEndpoint', ['api.mch.weixin.qq.com']],
|
||||
['refundQueryEndpoint', ['api.mch.weixin.qq.com']],
|
||||
]) {
|
||||
validateProviderUrl({
|
||||
id: `db.payment.${safeProviderName(row.provider)}.${key}`,
|
||||
value: publicString(configPublic, [key]),
|
||||
allowedHosts,
|
||||
details,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.alipay)) {
|
||||
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
|
||||
{ label: 'appId', keys: ['appId'] },
|
||||
{ label: 'notifyUrl', keys: ['notifyUrl'] },
|
||||
]));
|
||||
validateProviderUrl({
|
||||
id: `db.payment.${safeProviderName(row.provider)}.notify_url`,
|
||||
value: publicString(configPublic, ['notifyUrl']),
|
||||
required: true,
|
||||
details,
|
||||
});
|
||||
validateProviderUrl({
|
||||
id: `db.payment.${safeProviderName(row.provider)}.return_url`,
|
||||
value: publicString(configPublic, ['returnUrl']),
|
||||
details,
|
||||
});
|
||||
validateProviderUrl({
|
||||
id: `db.payment.${safeProviderName(row.provider)}.quit_url`,
|
||||
value: publicString(configPublic, ['quitUrl']),
|
||||
details,
|
||||
});
|
||||
for (const [key, allowedHosts] of [
|
||||
['endpoint', ['openapi.alipay.com']],
|
||||
['refundEndpoint', ['openapi.alipay.com']],
|
||||
['refundQueryEndpoint', ['openapi.alipay.com']],
|
||||
]) {
|
||||
validateProviderUrl({
|
||||
id: `db.payment.${safeProviderName(row.provider)}.${key}`,
|
||||
value: publicString(configPublic, [key]),
|
||||
allowedHosts,
|
||||
details,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
block('db.payment.unsupported_provider', 'Active payment account is not in the supported production provider list', details);
|
||||
}
|
||||
|
||||
function validateProviderConfigRows(inputRows) {
|
||||
const rows = inputRows.map(row => ({
|
||||
source: row.source,
|
||||
tenant_id: row.tenant_id || row.tenantId || 'fixture-tenant',
|
||||
provider: row.provider,
|
||||
config_public: row.config_public || row.configPublic || {},
|
||||
}));
|
||||
if (rows.length === 0) {
|
||||
warn('db.provider_fixture.empty', 'Provider config fixture did not contain any rows');
|
||||
return;
|
||||
}
|
||||
for (const row of rows) {
|
||||
const secretPaths = findSecretLikePaths(row.config_public || {});
|
||||
if (secretPaths.length > 0) {
|
||||
block(`db.${row.source}.${safeProviderName(row.provider)}.public_secret`, 'Provider public config contains secret-like keys', {
|
||||
tenantId: row.tenant_id,
|
||||
provider: row.provider,
|
||||
secretPathCount: secretPaths.length,
|
||||
});
|
||||
}
|
||||
if (row.source === 'auth') validateAuthProviderPublicConfig(row);
|
||||
else if (row.source === 'payment') validatePaymentProviderPublicConfig(row);
|
||||
else block('db.provider_fixture.source', 'Provider config fixture row source must be auth or payment', { source: row.source });
|
||||
}
|
||||
if (rows.every(row => findSecretLikePaths(row.config_public || {}).length === 0)) {
|
||||
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
|
||||
}
|
||||
}
|
||||
|
||||
function isSecretLikeKey(key) {
|
||||
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
|
||||
const allowedSecretRef = normalized === 'secretref' || normalized.endsWith('secretref');
|
||||
@@ -177,9 +505,14 @@ function validateEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
const authSmsProvider = env('AUTH_SMS_PROVIDER', 'mock');
|
||||
if (authSmsProvider === 'mock') block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER=mock is not allowed in production');
|
||||
else pass('env.auth_sms_provider', 'AUTH_SMS_PROVIDER is not mock');
|
||||
const authSmsProvider = env('AUTH_SMS_PROVIDER', 'mock').trim().toLowerCase();
|
||||
if (!PRODUCTION_SMS_PROVIDERS.has(authSmsProvider)) {
|
||||
block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER must be aliyun/aliyun-sms or tencent/tencent-sms in production', {
|
||||
provider: authSmsProvider || '(empty)',
|
||||
});
|
||||
} else {
|
||||
pass('env.auth_sms_provider', 'AUTH_SMS_PROVIDER is a supported production SMS provider', { provider: authSmsProvider });
|
||||
}
|
||||
|
||||
if (isUnsafeSecret(env('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER), DEFAULT_AUTH_CODE_PEPPER)) {
|
||||
block('env.auth_code_pepper', 'AUTH_CODE_PEPPER must be a strong production secret');
|
||||
@@ -239,9 +572,13 @@ function validateEnv() {
|
||||
pass('env.body_size', 'JSON body limits are bounded');
|
||||
}
|
||||
|
||||
const storageProvider = env('STORAGE_DEFAULT_PROVIDER', 'local_dev');
|
||||
const storageProvider = env('STORAGE_DEFAULT_PROVIDER', 'local_dev').trim();
|
||||
if (storageProvider === 'local_dev') {
|
||||
block('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER=local_dev is not allowed for production assets');
|
||||
} else if (!PRODUCTION_STORAGE_PROVIDERS.has(storageProvider)) {
|
||||
block('env.storage_provider.unsupported', 'STORAGE_DEFAULT_PROVIDER must be aliyun_oss, tencent_cos or supabase_storage in production', {
|
||||
provider: storageProvider || '(empty)',
|
||||
});
|
||||
} else {
|
||||
pass('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER is production-capable', { provider: storageProvider });
|
||||
}
|
||||
@@ -255,11 +592,24 @@ function validateEnv() {
|
||||
} else {
|
||||
pass('env.storage_tenant_prefix', 'tenant-prefixed object keys are required');
|
||||
}
|
||||
if (env('STORAGE_PUBLIC_BASE_URL', '') && !isProductionHttpsUrl(env('STORAGE_PUBLIC_BASE_URL', ''))) {
|
||||
block('env.storage_public_base_url', 'STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL when configured');
|
||||
}
|
||||
|
||||
if (storageProvider === 'aliyun_oss') {
|
||||
for (const key of ['ALIYUN_OSS_REGION', 'ALIYUN_OSS_ENDPOINT', 'ALIYUN_OSS_ACCESS_KEY_ID', 'ALIYUN_OSS_ACCESS_KEY_SECRET']) {
|
||||
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for aliyun_oss`);
|
||||
}
|
||||
validateProviderUrl({
|
||||
id: 'env.aliyun_oss_endpoint',
|
||||
value: env('ALIYUN_OSS_ENDPOINT', ''),
|
||||
message: 'ALIYUN_OSS_ENDPOINT must be a production HTTPS endpoint',
|
||||
allowedHosts: ['aliyuncs.com'],
|
||||
required: true,
|
||||
});
|
||||
if (envBool('ALIYUN_OSS_INTERNAL', false)) {
|
||||
block('env.aliyun_oss_internal', 'ALIYUN_OSS_INTERNAL=true is not allowed for user-facing production signed URLs');
|
||||
}
|
||||
}
|
||||
if (storageProvider === 'tencent_cos') {
|
||||
for (const key of ['TENCENT_COS_REGION', 'TENCENT_COS_APP_ID', 'TENCENT_COS_SECRET_ID', 'TENCENT_COS_SECRET_KEY']) {
|
||||
@@ -270,6 +620,12 @@ function validateEnv() {
|
||||
for (const key of ['SUPABASE_STORAGE_URL', 'SUPABASE_STORAGE_SERVICE_KEY']) {
|
||||
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for supabase_storage`);
|
||||
}
|
||||
validateProviderUrl({
|
||||
id: 'env.supabase_storage_url',
|
||||
value: env('SUPABASE_STORAGE_URL', ''),
|
||||
message: 'SUPABASE_STORAGE_URL must be a production HTTPS URL',
|
||||
required: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (envList('STORAGE_ALLOWED_MIME_TYPES').includes('application/octet-stream')) {
|
||||
@@ -389,6 +745,8 @@ async function validateDatabase() {
|
||||
secretPathCount: secretPaths.length,
|
||||
});
|
||||
}
|
||||
if (row.source === 'auth') validateAuthProviderPublicConfig(row);
|
||||
if (row.source === 'payment') validatePaymentProviderPublicConfig(row);
|
||||
}
|
||||
if (publicSecretRows.rows.every(row => findSecretLikePaths(row.config_public || {}).length === 0)) {
|
||||
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
|
||||
@@ -721,6 +1079,12 @@ function printSummary() {
|
||||
async function main() {
|
||||
loadEnvFile(envFile);
|
||||
validateEnv();
|
||||
if (providerConfigFixture) {
|
||||
const fixturePath = path.resolve(process.cwd(), providerConfigFixture);
|
||||
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
|
||||
const rows = Array.isArray(fixture) ? fixture : Array.isArray(fixture.rows) ? fixture.rows : [];
|
||||
validateProviderConfigRows(rows);
|
||||
}
|
||||
await validateDatabase();
|
||||
printSummary();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user