/** * Shared guards for multipart requests. * * These checks intentionally happen before `request.formData()` is called for * the header guard. A multipart parser may allocate memory for the complete * body, so accepting an unbounded request before parsing defeats the limit. */ export type MultipartHeaderSource = | Headers | Pick | Record export type MultipartFieldRule = { /** Maximum number of files for this field. Omit for no per-field count cap. */ maxCount?: number /** Maximum bytes of each file for this field. */ maxFileBytes?: number /** Alias for maxFileBytes, retained for concise route policies. */ maxBytes?: number /** Maximum combined bytes for this field. */ maxTotalBytes?: number /** Exact MIME types (or `type/*` wildcards) accepted for this field. */ allowedMimeTypes?: readonly string[] /** Aliases accepted when a route uses a shorter policy name. */ allowedMimes?: readonly string[] mimeTypes?: readonly string[] [key: string]: unknown } export type MultipartFieldRuleInput = MultipartFieldRule | number | null | undefined export type MultipartFieldRules = | Readonly> | readonly string[] | ReadonlySet /** * Policy accepted by `assertMultipartFiles`. * * `fields` is the canonical shape. The other field-map names are kept as * aliases so a route can adopt the helper without a large policy rewrite. */ export interface MultipartFilePolicy { fields?: MultipartFieldRules allowedFields?: MultipartFieldRules allowedFileFields?: MultipartFieldRules fieldPolicies?: MultipartFieldRules rules?: MultipartFieldRules fileFields?: MultipartFieldRules filePolicies?: MultipartFieldRules fieldLimits?: MultipartFieldRules perField?: MultipartFieldRules allowedFileFieldPolicies?: MultipartFieldRules maxFilesPerField?: Readonly> /** Maximum number of files across all fields. */ maxFiles?: number maxTotalFiles?: number maxFileCount?: number maxTotalFileCount?: number /** Maximum combined bytes across all files. */ maxTotalBytes?: number maxTotalFileBytes?: number totalBytes?: number /** Optional global per-file cap and MIME allow-list. */ maxFileBytes?: number maxSingleFileBytes?: number allowedMimeTypes?: readonly string[] allowedMimes?: readonly string[] mimeTypes?: readonly string[] /** Explicit escape hatch for a route that really accepts any field. */ allowAnyFileField?: boolean [key: string]: unknown } export type MultipartRequestErrorCode = | 'MULTIPART_REQUIRED' | 'MULTIPART_BOUNDARY_REQUIRED' | 'CONTENT_ENCODING_UNSUPPORTED' | 'LENGTH_REQUIRED' | 'REQUEST_TOO_LARGE' | 'INVALID_FILE_FIELD' | 'TOO_MANY_FILES' | 'EMPTY_FILE' | 'FILE_TOO_LARGE' | 'FILES_TOO_LARGE' | 'MIME_TYPE_UNSUPPORTED' export class MultipartRequestError extends Error { readonly status: number readonly code: MultipartRequestErrorCode readonly details?: Readonly> constructor( message: string, status: number, code: MultipartRequestErrorCode, details?: Readonly>, ) { super(message) this.name = 'MultipartRequestError' this.status = status this.code = code this.details = details } } export type MultipartFilesSummary = { totalFiles: number totalBytes: number files: File[] filesByField: Record } function readHeader(headers: MultipartHeaderSource, name: string): string | null { if (typeof headers === 'object' && headers !== null && typeof headers.get === 'function') { return headers.get(name) } if (typeof headers !== 'object' || headers === null) { return null } const wanted = name.toLowerCase() const key = Object.keys(headers).find((candidate) => candidate.toLowerCase() === wanted) if (!key) { return null } const value = (headers as Record)[key] return value == null ? null : String(value) } function hasNonEmptyBoundary(contentType: string): boolean { const mediaType = contentType.split(';', 1)[0]?.trim().toLowerCase() if (mediaType !== 'multipart/form-data') { return false } // RFC 2046 permits a token or a quoted-string. Reject an empty value and // values containing whitespace when unquoted; this prevents a parser from // interpreting a different delimiter than the guard checked. const parameterText = contentType.slice(contentType.indexOf(';') + 1) if (!contentType.includes(';')) { return false } const boundaryMatch = /(?:^|;)\s*boundary\s*=\s*(?:"([^"]*)"|([^;]*?))\s*(?:;|$)/i.exec( parameterText, ) if (!boundaryMatch) { return false } const quoted = boundaryMatch[1] const token = boundaryMatch[2] const boundary = (quoted ?? token ?? '').trim() return boundary.length > 0 && (quoted !== undefined || !/[\s"']/.test(boundary)) } function isSafePositiveInteger(value: number): boolean { return Number.isSafeInteger(value) && value > 0 } /** * Validate multipart framing before a body parser is entered. * * @returns the trusted Content-Length in bytes. */ export function assertBoundedMultipartHeaders( headers: MultipartHeaderSource, maxRequestBytes: number, ): number { if (!isSafePositiveInteger(maxRequestBytes)) { throw new TypeError('maxRequestBytes 必须是正的安全整数') } const contentType = readHeader(headers, 'content-type') || '' const mediaType = contentType.split(';', 1)[0]?.trim().toLowerCase() if (mediaType !== 'multipart/form-data') { throw new MultipartRequestError( '请求必须使用 multipart/form-data', 415, 'MULTIPART_REQUIRED', ) } if (!hasNonEmptyBoundary(contentType)) { throw new MultipartRequestError( 'multipart/form-data 请求缺少有效 boundary', 415, 'MULTIPART_BOUNDARY_REQUIRED', ) } const contentEncoding = (readHeader(headers, 'content-encoding') || '').trim().toLowerCase() if (contentEncoding !== '' && contentEncoding !== 'identity') { throw new MultipartRequestError( '上传请求不支持压缩传输编码', 415, 'CONTENT_ENCODING_UNSUPPORTED', ) } const rawLength = readHeader(headers, 'content-length') // Do not use parseInt: values such as "10px", "+10", and decimal notation // must never become a trusted body bound. if (!rawLength || !/^\d+$/.test(rawLength)) { throw new MultipartRequestError( '上传请求缺少有效的 Content-Length', 411, 'LENGTH_REQUIRED', ) } const contentLength = Number(rawLength) if (!Number.isSafeInteger(contentLength) || contentLength <= 0) { throw new MultipartRequestError( '上传请求的 Content-Length 无效', 411, 'LENGTH_REQUIRED', ) } if (contentLength > maxRequestBytes) { throw new MultipartRequestError( '上传请求内容超过大小限制', 413, 'REQUEST_TOO_LARGE', { contentLength, maxRequestBytes }, ) } return contentLength } function isFilePart(value: FormDataEntryValue): value is File { return typeof value === 'object' && value !== null && typeof (value as File).name === 'string' && typeof (value as File).size === 'number' && typeof (value as File).arrayBuffer === 'function' } function firstDefined(...values: Array): T | undefined { return values.find((value) => value !== undefined) } function asFieldRules(value: unknown): MultipartFieldRules | undefined { if (Array.isArray(value)) { return value.every((item) => typeof item === 'string') ? value : undefined } if (value instanceof Set) { return Array.from(value).every((item) => typeof item === 'string') ? value : undefined } if (typeof value === 'object' && value !== null) { return value as MultipartFieldRules } return undefined } function normalizeFieldRules( policy: MultipartFilePolicy, ): Map { const result = new Map() // Apply aliases first and the canonical `fields` map last. This permits a // route to declare `allowedFields: ['file']` plus `maxFilesPerField` while // still letting an explicit `fields.file` rule take precedence. const sources: unknown[] = [ policy.allowedFields, policy.allowedFileFields, policy.fieldPolicies, policy.rules, policy.fileFields, policy.filePolicies, policy.fieldLimits, policy.perField, policy.maxFilesPerField, policy.allowedFileFieldPolicies, policy.fields, ] for (const source of sources) { const configured = asFieldRules(source) if (configured === undefined) { continue } const entries = Array.isArray(configured) ? configured.map((field) => [field, {} as MultipartFieldRule] as const) : configured instanceof Set ? Array.from(configured).map((field) => [field, {} as MultipartFieldRule] as const) : Object.entries(configured) for (const [field, rawRule] of entries) { const nextRule = typeof rawRule === 'number' ? { maxCount: rawRule } : rawRule === null || rawRule === undefined ? {} : rawRule result.set(field, { ...(result.get(field) || {}), ...nextRule }) } } return result } function readNonNegativeLimit(value: unknown, label: string): number | undefined { if (value === undefined) { return undefined } const numberValue = Number(value) if (!Number.isSafeInteger(numberValue) || numberValue < 0) { throw new TypeError(`${label} 必须是非负的安全整数`) } return numberValue } function readRuleLimit(rule: MultipartFieldRule, ...keys: string[]): number | undefined { for (const key of keys) { if (rule[key] !== undefined) { return readNonNegativeLimit(rule[key], `字段策略 ${key}`) } } return undefined } function readMimeTypes(value: unknown): readonly string[] | undefined { if (value === undefined) { return undefined } if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) { throw new TypeError('allowedMimeTypes 必须是字符串数组') } return value.map((item) => item.trim().toLowerCase()).filter(Boolean) } function mimeAllowed(fileType: string, allowed: readonly string[]): boolean { const normalized = fileType.trim().toLowerCase() return allowed.some((candidate) => { if (candidate.endsWith('/*')) { return normalized.startsWith(candidate.slice(0, -1)) } return normalized === candidate }) } function filesByFieldRecord(filesByField: Map): Record { return Object.fromEntries(Array.from(filesByField.entries()).map(([field, files]) => [ field, files.slice(), ])) } /** * Validate every file entry in parsed FormData. Text entries are deliberately * ignored, including text fields whose names are not in the file policy. */ export function assertMultipartFiles( formData: FormData, policy: MultipartFilePolicy, ): MultipartFilesSummary { const rules = normalizeFieldRules(policy) const allowAnyField = policy.allowAnyFileField === true const maxFiles = readNonNegativeLimit( firstDefined( policy.maxFiles, policy.maxTotalFiles, policy.maxFileCount, policy.maxTotalFileCount, ), 'maxFiles', ) const maxTotalBytes = readNonNegativeLimit( firstDefined(policy.maxTotalBytes, policy.maxTotalFileBytes, policy.totalBytes), 'maxTotalBytes', ) const globalMaxFileBytes = readNonNegativeLimit( firstDefined(policy.maxFileBytes, policy.maxSingleFileBytes), 'maxFileBytes', ) const globalMimeTypes = readMimeTypes( firstDefined(policy.allowedMimeTypes, policy.allowedMimes, policy.mimeTypes), ) const files: File[] = [] const filesByField = new Map() let totalBytes = 0 for (const [field, value] of Array.from(formData.entries())) { if (!isFilePart(value)) { continue } const rule = rules.get(field) if (!rule && !allowAnyField) { throw new MultipartRequestError( `不允许上传文件字段:${field}`, 400, 'INVALID_FILE_FIELD', { field }, ) } const effectiveRule = rule || {} const fileSize = value.size if (!Number.isSafeInteger(fileSize) || fileSize <= 0) { throw new MultipartRequestError( `文件「${value.name || field}」为空或大小无效`, 400, 'EMPTY_FILE', { field, fileName: value.name, size: fileSize }, ) } const fieldFiles = filesByField.get(field) || [] const maxCount = readRuleLimit(effectiveRule, 'maxCount', 'maxFiles', 'maxFileCount') if (maxCount !== undefined && fieldFiles.length >= maxCount) { throw new MultipartRequestError( `文件字段「${field}」最多上传 ${maxCount} 个文件`, 413, 'TOO_MANY_FILES', { field, maxCount }, ) } const maxFileBytes = firstDefined( readRuleLimit(effectiveRule, 'maxFileBytes', 'maxBytes', 'maxSize', 'maxFileSizeBytes'), globalMaxFileBytes, ) if (maxFileBytes !== undefined && fileSize > maxFileBytes) { throw new MultipartRequestError( `文件「${value.name || field}」超过单文件大小限制`, 413, 'FILE_TOO_LARGE', { field, fileName: value.name, fileSize, maxFileBytes }, ) } const allowedMimeTypes = readMimeTypes( firstDefined( effectiveRule.allowedMimeTypes, effectiveRule.allowedMimes, effectiveRule.mimeTypes, globalMimeTypes, ), ) if (allowedMimeTypes !== undefined && !mimeAllowed(value.type || '', allowedMimeTypes)) { throw new MultipartRequestError( `文件「${value.name || field}」的 MIME 类型不被允许`, 415, 'MIME_TYPE_UNSUPPORTED', { field, fileName: value.name, mimeType: value.type || '' }, ) } const nextFieldBytes = fieldFiles.reduce((sum, file) => sum + file.size, 0) + fileSize const maxFieldBytes = readRuleLimit(effectiveRule, 'maxTotalBytes', 'totalBytes') if (maxFieldBytes !== undefined && nextFieldBytes > maxFieldBytes) { throw new MultipartRequestError( `文件字段「${field}」的总大小超过限制`, 413, 'FILES_TOO_LARGE', { field, totalBytes: nextFieldBytes, maxTotalBytes: maxFieldBytes }, ) } if (maxFiles !== undefined && files.length >= maxFiles) { throw new MultipartRequestError( `一次最多上传 ${maxFiles} 个文件`, 413, 'TOO_MANY_FILES', { maxFiles }, ) } if (maxTotalBytes !== undefined && totalBytes > maxTotalBytes - fileSize) { throw new MultipartRequestError( '上传文件总大小超过限制', 413, 'FILES_TOO_LARGE', { totalBytes: totalBytes + fileSize, maxTotalBytes }, ) } fieldFiles.push(value) filesByField.set(field, fieldFiles) files.push(value) totalBytes += fileSize } return { totalFiles: files.length, totalBytes, files, filesByField: filesByFieldRecord(filesByField), } }