fix: cap multipart backup and upload requests

This commit is contained in:
shuaiplus
2026-07-06 15:38:59 +08:00
parent 23c53bd1af
commit 51428461a8
3 changed files with 33 additions and 1 deletions
+22
View File
@@ -2,6 +2,7 @@ import type { Env, User } from '../types';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { import {
type BackupArchiveBundle, type BackupArchiveBundle,
MAX_BACKUP_ARCHIVE_BYTES,
buildBackupArchive, buildBackupArchive,
inspectBackupArchiveFileNameChecksum, inspectBackupArchiveFileNameChecksum,
isSafeBackupAttachmentBlobName, isSafeBackupAttachmentBlobName,
@@ -47,6 +48,7 @@ import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store'; import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { getMultipartRequestMaxBytes } from '../utils/direct-upload';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate'; import { unzipSync } from 'fflate';
@@ -54,6 +56,14 @@ function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; 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<Response | null> { async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim(); const normalized = String(masterPasswordHash || '').trim();
if (!normalized) { if (!normalized) {
@@ -1250,6 +1260,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); 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; let formData: FormData;
try { try {
formData = await request.formData(); 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)) { if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
return errorResponse('Backup file is required', 400); 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); const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError; if (verificationError) return verificationError;
+1 -1
View File
@@ -28,7 +28,7 @@ const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// Prefer store-only ZIP entries over heavier compression to keep exports reliable. // Prefer store-only ZIP entries over heavier compression to keep exports reliable.
const BACKUP_TEXT_COMPRESSION_LEVEL = 0; const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
const BACKUP_JSON_INDENT = 2; 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_ARCHIVE_ENTRY_COUNT = 10_000;
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024; const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024; const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
+10
View File
@@ -19,6 +19,8 @@ interface ParseDirectUploadOptions {
fileNameMismatchMessage?: string; fileNameMismatchMessage?: string;
} }
const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024;
export function buildDirectUploadUrl(request: Request, path: string, token: string): string { export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
const version = '2023-11-03'; const version = '2023-11-03';
const expiresAt = '2099-12-31T23:59:59Z'; const expiresAt = '2099-12-31T23:59:59Z';
@@ -34,6 +36,10 @@ export function getSafeJwtSecret(env: Env): string | null {
return secret; return secret;
} }
export function getMultipartRequestMaxBytes(maxFileSize: number): number {
return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES;
}
function parseContentLength(request: Request): number | null { function parseContentLength(request: Request): number | null {
const raw = request.headers.get('content-length'); const raw = request.headers.get('content-length');
if (!raw) return null; if (!raw) return null;
@@ -59,6 +65,10 @@ export async function parseDirectUploadPayload(
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) { 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 formData = await request.formData();
const file = formData.get('data') as File | null; const file = formData.get('data') as File | null;
if (!file) { if (!file) {