diff --git a/.env.example b/.env.example index 2e56da0a..014ed192 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,8 @@ PORT=3000 DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres DEFAULT_TENANT_SLUG=master CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173 +MAX_JSON_BODY_BYTES=1048576 +MAX_IMPORT_JSON_BODY_BYTES=10485760 # 认证迁移期配置:生产环境必须替换为高强度随机值 AUTH_SMS_PROVIDER=mock diff --git a/apps/api/.env.example b/apps/api/.env.example index b6c0b6e9..07422316 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -2,3 +2,5 @@ PORT=8787 DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/postgres DEFAULT_TENANT_SLUG=master CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173 +MAX_JSON_BODY_BYTES=1048576 +MAX_IMPORT_JSON_BODY_BYTES=10485760 diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts index f55447b5..895ff7eb 100644 --- a/apps/api/src/core/config.ts +++ b/apps/api/src/core/config.ts @@ -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; diff --git a/apps/api/src/core/request.ts b/apps/api/src/core/request.ts index ee8d2dec..d4816e25 100644 --- a/apps/api/src/core/request.ts +++ b/apps/api/src/core/request.ts @@ -33,11 +33,28 @@ export function stringParam(ctx: RequestContext, name: string) { return ctx.url.searchParams.get(name)?.trim() || ''; } -export async function readJsonBody(ctx: RequestContext): Promise { +export interface ReadJsonBodyOptions { + maxBytes?: number; +} + +export async function readJsonBody(ctx: RequestContext, options: ReadJsonBodyOptions = {}): Promise { + const maxBytes = options.maxBytes ?? config.maxJsonBodyBytes; + const contentLength = Number(getHeader(ctx.req, 'content-length') || 0); + if (Number.isFinite(contentLength) && contentLength > maxBytes) { + throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE'); + } + const chunks: Buffer[] = []; + let totalBytes = 0; for await (const chunk of ctx.req) { - chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + totalBytes += buffer.length; + if (totalBytes > maxBytes) { + ctx.req.destroy(); + throw new HttpError(413, `JSON body is too large. Max ${maxBytes} bytes.`, 'JSON_BODY_TOO_LARGE'); + } + chunks.push(buffer); } const raw = Buffer.concat(chunks).toString('utf8').trim(); diff --git a/apps/api/src/features/tenant-content/imports.ts b/apps/api/src/features/tenant-content/imports.ts index 550a469d..fb1980b1 100644 --- a/apps/api/src/features/tenant-content/imports.ts +++ b/apps/api/src/features/tenant-content/imports.ts @@ -1,5 +1,6 @@ import { createHash, randomUUID } from 'node:crypto'; import type pg from 'pg'; +import { config } from '../../core/config.js'; import { HttpError, type RequestContext } from '../../core/http.js'; import { intParam, readJsonBody, requiredString, stringParam } from '../../core/request.js'; import { query, queryOne, transaction } from '../../core/db.js'; @@ -2308,25 +2309,25 @@ async function runGenericImport( export async function previewQuestionsImportRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); - const body = await readJsonBody(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); return createQuestionPreviewJob(auth, body); } export async function previewVocabularyImportRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); - const body = await readJsonBody(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); return createGenericPreviewJob(auth, body, 'vocabulary', 'vocabulary_unit', createVocabularyNormalizedItems(body)); } export async function previewHandbookImportRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); - const body = await readJsonBody(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); return createGenericPreviewJob(auth, body, 'handbook', 'handbook_subject', createHandbookNormalizedItems(body)); } export async function importQuestionsRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); - const body = await readJsonBody(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); const allowPartial = boolValue(body.allowPartial, false); const jobId = nullableString(body.previewJobId) || nullableString(body.jobId); @@ -2441,7 +2442,7 @@ export async function importQuestionsRoute(ctx: RequestContext) { export async function importVocabularyRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); - const body = await readJsonBody(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); return runGenericImport( auth, body, @@ -2453,7 +2454,7 @@ export async function importVocabularyRoute(ctx: RequestContext) { export async function importHandbookRoute(ctx: RequestContext) { const auth = await requireTenantContentEditor(ctx); - const body = await readJsonBody(ctx); + const body = await readJsonBody(ctx, { maxBytes: config.maxImportJsonBodyBytes }); return runGenericImport( auth, body, diff --git a/docs/refactor/multitenant-auth-security-contract.md b/docs/refactor/multitenant-auth-security-contract.md index b2c4401a..671a2860 100644 --- a/docs/refactor/multitenant-auth-security-contract.md +++ b/docs/refactor/multitenant-auth-security-contract.md @@ -57,6 +57,7 @@ Supabase 官方允许前端用 Data API 访问数据,但前提是 RLS、最小 - 普通 JSON API 必须有默认上限。 - 导入接口可以有更大上限,但必须可配置且有最大值。 - 超限返回 413。 + - 当前默认:`MAX_JSON_BODY_BYTES=1048576`,`MAX_IMPORT_JSON_BODY_BYTES=10485760`,硬上限 50MB。 6. 租户密钥保护 - 商户密钥、短信 secret、OAuth secret 不允许明文长期存储。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 67da30d3..51ef217a 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -29,6 +29,7 @@ - 用 Supabase Auth/JWT 或服务端 session 替换迁移期 `x-tenant-id`、`x-user-id`、`x-platform-admin-key`。 - 校验平台管理员、租户管理员、运营、教师、销售、代理、学生的访问边界。 - 做一轮真实 JWT + RLS 回归测试。 + - 已补生产配置 fail-fast 和 JSON body size limit;后续继续补正式身份上下文。 2. 对象存储 - 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。 diff --git a/scripts/api-integration-test.js b/scripts/api-integration-test.js index 387a5552..8ea5ed5a 100644 --- a/scripts/api-integration-test.js +++ b/scripts/api-integration-test.js @@ -109,6 +109,8 @@ async function startServerIfNeeded() { ...process.env, PORT: String(port), DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, + MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '8192', + MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '65536', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, @@ -124,6 +126,56 @@ async function startServerIfNeeded() { await waitForHealth(); } +async function waitForProcessExit(child, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill(); + reject(new Error('process did not exit before timeout')); + }, timeoutMs); + + child.on('exit', (code, signal) => { + clearTimeout(timer); + resolve({ code, signal }); + }); + child.on('error', error => { + clearTimeout(timer); + reject(error); + }); + }); +} + +async function testProductionConfigFailFast() { + const port = await getFreePort(); + const child = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { + cwd: process.cwd(), + env: { + ...process.env, + NODE_ENV: 'production', + PORT: String(port), + DATABASE_URL: process.env.DATABASE_URL || DEFAULT_DATABASE_URL, + CORS_ORIGIN: '*', + AUTH_SMS_PROVIDER: 'mock', + AUTH_CODE_PEPPER: 'development-code-pepper-change-me', + AUTH_SESSION_SECRET: 'development-session-secret-change-me', + PLATFORM_ADMIN_API_KEY: 'local-platform-admin-key', + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + let logs = ''; + child.stdout.on('data', chunk => { + logs += chunk.toString(); + }); + child.stderr.on('data', chunk => { + logs += chunk.toString(); + }); + + const result = await waitForProcessExit(child); + assert.notEqual(result.code, 0, 'production server with unsafe defaults should fail to start'); + assert.match(logs, /Invalid production API configuration/, 'production fail-fast should explain unsafe config'); +} + function stopServer() { if (serverProcess && !serverProcess.killed) { serverProcess.kill(); @@ -818,6 +870,38 @@ async function testTenantContentAssetsAndImports() { }); assert.equal(deniedImport.code, 'TENANT_CONTENT_EDITOR_REQUIRED', 'student should not preview content import'); + const oversizedNormalJson = await request('/api/auth/sms/send', { + method: 'POST', + body: { + phone: '13800000009', + purpose: 'login', + padding: 'x'.repeat(9000), + }, + expectStatus: 413, + }); + assert.equal(oversizedNormalJson.code, 'JSON_BODY_TOO_LARGE', 'ordinary JSON endpoints should enforce body size limit'); + + const largeImportPreview = await request('/api/tenant-content/imports/preview/questions', { + userId: TENANT_ADMIN_USER_ID, + method: 'POST', + body: { + sourceName: 'large-but-allowed-question-import.json', + subjectId: ids.subject, + categoryId: ids.category, + regionId: ids.region, + items: [ + { + legacyId: 'integration-large-preview-001', + type: 'choice', + content: `大体积导入预览:${'企业级导入需要受控上限。'.repeat(500)}`, + options: ['正确', '错误'], + correctOptionIndices: [0], + }, + ], + }, + }); + assert.equal(largeImportPreview.job?.errorCount, 0, 'import preview should use larger bounded body limit'); + const invalidPreview = await request('/api/tenant-content/imports/preview/questions', { userId: TENANT_ADMIN_USER_ID, method: 'POST', @@ -1680,6 +1764,7 @@ async function testReferralAndCrmGrowth() { async function main() { try { + await check('production config fail-fast', testProductionConfigFailFast); await startServerIfNeeded(); console.log(`[INFO] API integration target: ${apiBase}`);