Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
495 lines
15 KiB
TypeScript
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),
|
|
}
|
|
}
|