mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
fix: cap multipart backup and upload requests
This commit is contained in:
@@ -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<Response | null> {
|
||||
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<Response> {
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user