gongxiangp2/lib/multipart-request-guard.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

495 lines
15 KiB
TypeScript

/**
* 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<Headers, 'get'>
| Record<string, string | null | undefined>
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<Record<string, MultipartFieldRuleInput>>
| readonly string[]
| ReadonlySet<string>
/**
* 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<Record<string, number>>
/** 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<Record<string, unknown>>
constructor(
message: string,
status: number,
code: MultipartRequestErrorCode,
details?: Readonly<Record<string, unknown>>,
) {
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<string, File[]>
}
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<string, string | null | undefined>)[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<T>(...values: Array<T | undefined>): 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<string, MultipartFieldRule> {
const result = new Map<string, MultipartFieldRule>()
// 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<string, File[]>): Record<string, File[]> {
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<string, File[]>()
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),
}
}