forked from wangziqi/gongxue-base
feat: enforce api production safety limits
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<JsonObject> {
|
||||
export interface ReadJsonBodyOptions {
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export async function readJsonBody(ctx: RequestContext, options: ReadJsonBodyOptions = {}): Promise<JsonObject> {
|
||||
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();
|
||||
|
||||
@@ -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<T>(
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user