From 51428461a8f35f30642f1c72484440043c8167fb Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Mon, 6 Jul 2026 15:38:59 +0800 Subject: [PATCH] fix: cap multipart backup and upload requests --- src/handlers/backup.ts | 22 ++++++++++++++++++++++ src/services/backup-archive.ts | 2 +- src/utils/direct-upload.ts | 10 ++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index 09da6d0..ee00881 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -2,6 +2,7 @@ import type { Env, User } from '../types'; import { errorResponse, jsonResponse } from '../utils/response'; import { type BackupArchiveBundle, + MAX_BACKUP_ARCHIVE_BYTES, buildBackupArchive, inspectBackupArchiveFileNameChecksum, isSafeBackupAttachmentBlobName, @@ -47,6 +48,7 @@ import { AuthService } from '../services/auth'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { getBlobObject } from '../services/blob-store'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; +import { getMultipartRequestMaxBytes } from '../utils/direct-upload'; import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { unzipSync } from 'fflate'; @@ -54,6 +56,14 @@ function isAdmin(user: User): boolean { return user.role === 'admin' && user.status === 'active'; } +function parseRequestContentLength(request: Request): number | null { + const raw = request.headers.get('content-length'); + if (!raw) return null; + const value = Number(raw); + if (!Number.isFinite(value) || value < 0) return null; + return Math.floor(value); +} + async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise { const normalized = String(masterPasswordHash || '').trim(); if (!normalized) { @@ -1250,6 +1260,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env: export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise { if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); + const contentType = request.headers.get('Content-Type') || ''; + if (!contentType.includes('multipart/form-data')) { + return errorResponse('Content-Type must be multipart/form-data', 400); + } + const declaredSize = parseRequestContentLength(request); + if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(MAX_BACKUP_ARCHIVE_BYTES)) { + return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413); + } + let formData: FormData; try { formData = await request.formData(); @@ -1261,6 +1280,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) { return errorResponse('Backup file is required', 400); } + if ('size' in file && typeof (file as File).size === 'number' && (file as File).size > MAX_BACKUP_ARCHIVE_BYTES) { + return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413); + } const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env); if (verificationError) return verificationError; diff --git a/src/services/backup-archive.ts b/src/services/backup-archive.ts index 1a7e72d..f28e568 100644 --- a/src/services/backup-archive.ts +++ b/src/services/backup-archive.ts @@ -28,7 +28,7 @@ const BACKUP_FILE_HASH_PREFIX_LENGTH = 5; // Prefer store-only ZIP entries over heavier compression to keep exports reliable. const BACKUP_TEXT_COMPRESSION_LEVEL = 0; const BACKUP_JSON_INDENT = 2; -const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024; +export const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024; const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000; const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024; const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024; diff --git a/src/utils/direct-upload.ts b/src/utils/direct-upload.ts index 137e89e..4637610 100644 --- a/src/utils/direct-upload.ts +++ b/src/utils/direct-upload.ts @@ -19,6 +19,8 @@ interface ParseDirectUploadOptions { fileNameMismatchMessage?: string; } +const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024; + export function buildDirectUploadUrl(request: Request, path: string, token: string): string { const version = '2023-11-03'; const expiresAt = '2099-12-31T23:59:59Z'; @@ -34,6 +36,10 @@ export function getSafeJwtSecret(env: Env): string | null { return secret; } +export function getMultipartRequestMaxBytes(maxFileSize: number): number { + return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES; +} + function parseContentLength(request: Request): number | null { const raw = request.headers.get('content-length'); if (!raw) return null; @@ -59,6 +65,10 @@ export async function parseDirectUploadPayload( const contentType = request.headers.get('content-type') || ''; if (contentType.includes('multipart/form-data')) { + const declaredSize = parseContentLength(request); + if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(maxFileSize)) { + return errorResponse(tooLargeMessage, 413); + } const formData = await request.formData(); const file = formData.get('data') as File | null; if (!file) {