feat: enforce api production safety limits

This commit is contained in:
Codex
2026-06-28 21:20:38 +08:00
parent a8e0ac78be
commit 1d873b2e50
8 changed files with 177 additions and 14 deletions

View File

@@ -6,6 +6,8 @@ export interface ApiConfig {
databaseUrl: string;
defaultTenantSlug: string;
corsOrigins: string[];
maxJsonBodyBytes: number;
maxImportJsonBodyBytes: number;
authCodePepper: string;
authSessionSecret: string;
authSmsProvider: string;
@@ -38,21 +40,69 @@ export interface ApiConfig {
loadDotenv();
const isProduction = envString('NODE_ENV', 'development') === 'production';
const DEFAULT_AUTH_CODE_PEPPER = 'development-code-pepper-change-me';
const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me';
const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
const DEFAULT_MAX_IMPORT_JSON_BODY_BYTES = 10 * 1024 * 1024;
const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024;
export const config: ApiConfig = {
nodeEnv: envString('NODE_ENV', 'development'),
function boundedBytes(key: string, fallback: number, hardMax = HARD_MAX_JSON_BODY_BYTES) {
const value = envNumber(key, fallback);
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.min(Math.trunc(value), hardMax);
}
function isUnsafeSecret(value: string, defaultValue: string) {
const normalized = value.trim().toLowerCase();
return (
value === defaultValue ||
normalized.length < 32 ||
normalized.includes('replace_with') ||
normalized.includes('change-me') ||
normalized.includes('changeme')
);
}
function validateProductionConfig(nextConfig: ApiConfig) {
if (!nextConfig.isProduction) return;
const failures: string[] = [];
if (nextConfig.corsOrigins.includes('*')) failures.push('CORS_ORIGIN must not include * in production');
if (nextConfig.authSmsProvider === 'mock') failures.push('AUTH_SMS_PROVIDER=mock is not allowed in production');
if (isUnsafeSecret(nextConfig.authCodePepper, DEFAULT_AUTH_CODE_PEPPER)) {
failures.push('AUTH_CODE_PEPPER must be a strong production secret');
}
if (isUnsafeSecret(nextConfig.authSessionSecret, DEFAULT_AUTH_SESSION_SECRET)) {
failures.push('AUTH_SESSION_SECRET must be a strong production secret');
}
if (isUnsafeSecret(nextConfig.platformAdminApiKey, DEFAULT_PLATFORM_ADMIN_API_KEY)) {
failures.push('PLATFORM_ADMIN_API_KEY must be a strong production secret until platform JWT is implemented');
}
if (failures.length > 0) {
throw new Error(`Invalid production API configuration: ${failures.join('; ')}`);
}
}
const nodeEnv = envString('NODE_ENV', 'development');
const isProduction = nodeEnv === 'production';
const loadedConfig: ApiConfig = {
nodeEnv,
port: envNumber('PORT', 8787),
databaseUrl: envString('DATABASE_URL', DEFAULT_DATABASE_URL),
defaultTenantSlug: envString('DEFAULT_TENANT_SLUG', DEFAULT_TENANT_SLUG),
corsOrigins: envList('CORS_ORIGIN', '*'),
authCodePepper: envString('AUTH_CODE_PEPPER', 'development-code-pepper-change-me'),
authSessionSecret: envString('AUTH_SESSION_SECRET', 'development-session-secret-change-me'),
maxJsonBodyBytes: boundedBytes('MAX_JSON_BODY_BYTES', DEFAULT_MAX_JSON_BODY_BYTES),
maxImportJsonBodyBytes: boundedBytes('MAX_IMPORT_JSON_BODY_BYTES', DEFAULT_MAX_IMPORT_JSON_BODY_BYTES),
authCodePepper: envString('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER),
authSessionSecret: envString('AUTH_SESSION_SECRET', DEFAULT_AUTH_SESSION_SECRET),
authSmsProvider: envString('AUTH_SMS_PROVIDER', 'mock'),
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', 'local-platform-admin-key'),
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY),
storageDefaultProvider: envString('STORAGE_DEFAULT_PROVIDER', 'local_dev'),
storageDefaultBucket: envString('STORAGE_DEFAULT_BUCKET', 'tenant-assets'),
storagePublicBaseUrl: envString('STORAGE_PUBLIC_BASE_URL', ''),
@@ -93,3 +143,7 @@ export const config: ApiConfig = {
supabaseStorageServiceKey: envString('SUPABASE_STORAGE_SERVICE_KEY', ''),
isProduction,
};
validateProductionConfig(loadedConfig);
export const config = loadedConfig;