Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
163 lines
4.9 KiB
TypeScript
163 lines
4.9 KiB
TypeScript
import { constants } from 'fs'
|
||
import { lstat, open } from 'fs/promises'
|
||
import path from 'path'
|
||
|
||
import { isPathFreeOfSymbolicLinks } from './path-security'
|
||
import { getUploadRootDir, resolveStoredUploadPath } from './upload-paths'
|
||
|
||
export type CanonicalMaterialSourceValidationCode =
|
||
| 'CANONICAL_SOURCE_PATH_UNTRUSTED'
|
||
| 'CANONICAL_SOURCE_STAT_FAILED'
|
||
| 'CANONICAL_SOURCE_NOT_REGULAR'
|
||
| 'CANONICAL_SOURCE_EMPTY'
|
||
| 'CANONICAL_SOURCE_SIZE_INVALID'
|
||
| 'CANONICAL_SOURCE_SIZE_MISMATCH'
|
||
|
||
export class CanonicalMaterialSourceValidationError extends Error {
|
||
readonly code: CanonicalMaterialSourceValidationCode
|
||
readonly cause?: unknown
|
||
|
||
constructor(
|
||
code: CanonicalMaterialSourceValidationCode,
|
||
message: string,
|
||
cause?: unknown,
|
||
) {
|
||
super(message)
|
||
this.name = 'CanonicalMaterialSourceValidationError'
|
||
this.code = code
|
||
this.cause = cause
|
||
}
|
||
}
|
||
|
||
function parseExpectedSize(value: bigint | number | string): bigint {
|
||
try {
|
||
const parsed = typeof value === 'bigint' ? value : BigInt(value)
|
||
if (parsed <= BigInt(0)) {
|
||
throw new Error('expected size must be positive')
|
||
}
|
||
return parsed
|
||
} catch (error) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_SIZE_INVALID',
|
||
'canonical 源文件的已知大小无效',
|
||
error,
|
||
)
|
||
}
|
||
}
|
||
|
||
function resolveTrustedCanonicalSourcePath(storedPath: string): {
|
||
uploadRoot: string
|
||
sourcePath: string
|
||
} {
|
||
const uploadRoot = path.resolve(getUploadRootDir())
|
||
const sourcePath = path.resolve(resolveStoredUploadPath(String(storedPath || '').trim()))
|
||
const relativePath = path.relative(uploadRoot, sourcePath)
|
||
|
||
if (
|
||
!String(storedPath || '').trim()
|
||
|| !relativePath
|
||
|| relativePath === '..'
|
||
|| relativePath.startsWith(`..${path.sep}`)
|
||
|| path.isAbsolute(relativePath)
|
||
) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_PATH_UNTRUSTED',
|
||
'canonical 源文件不在受信任的 uploads 根目录内',
|
||
)
|
||
}
|
||
|
||
return { uploadRoot, sourcePath }
|
||
}
|
||
|
||
/**
|
||
* 在 Material 提交前及成功响应前验证本地 canonical 原件。
|
||
*
|
||
* 校验同时使用 lstat 与只读文件描述符 fstat:前者拒绝符号链接和越界路径,
|
||
* 后者确认实际打开的对象仍是同一个非空普通文件,降低检查后被替换的风险。
|
||
*/
|
||
export async function validateCanonicalMaterialSource(input: {
|
||
storedPath: string
|
||
expectedSize: bigint | number | string
|
||
}): Promise<{ sourcePath: string; size: bigint }> {
|
||
const expectedSize = parseExpectedSize(input.expectedSize)
|
||
const { uploadRoot, sourcePath } = resolveTrustedCanonicalSourcePath(input.storedPath)
|
||
|
||
let pathStats
|
||
try {
|
||
pathStats = await lstat(sourcePath, { bigint: true })
|
||
} catch (error) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_STAT_FAILED',
|
||
'无法读取 canonical 源文件状态',
|
||
error,
|
||
)
|
||
}
|
||
|
||
if (pathStats.isSymbolicLink() || !pathStats.isFile()) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_NOT_REGULAR',
|
||
'canonical 源文件不是普通非符号链接文件',
|
||
)
|
||
}
|
||
|
||
if (!(await isPathFreeOfSymbolicLinks(uploadRoot, sourcePath))) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_PATH_UNTRUSTED',
|
||
'canonical 源文件路径包含符号链接或已离开受信任根目录',
|
||
)
|
||
}
|
||
|
||
if (pathStats.size <= BigInt(0)) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_EMPTY',
|
||
'canonical 源文件为空',
|
||
)
|
||
}
|
||
if (pathStats.size !== expectedSize) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_SIZE_MISMATCH',
|
||
'canonical 源文件实际大小与已知大小不一致',
|
||
)
|
||
}
|
||
|
||
let handle
|
||
try {
|
||
handle = await open(
|
||
sourcePath,
|
||
constants.O_RDONLY | (constants.O_NOFOLLOW || 0),
|
||
)
|
||
const openedStats = await handle.stat({ bigint: true })
|
||
if (!openedStats.isFile()) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_NOT_REGULAR',
|
||
'实际打开的 canonical 源对象不是普通文件',
|
||
)
|
||
}
|
||
if (openedStats.size <= BigInt(0)) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_EMPTY',
|
||
'实际打开的 canonical 源文件为空',
|
||
)
|
||
}
|
||
if (openedStats.size !== expectedSize || openedStats.size !== pathStats.size) {
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_SIZE_MISMATCH',
|
||
'实际打开的 canonical 源文件大小已发生变化',
|
||
)
|
||
}
|
||
} catch (error) {
|
||
if (error instanceof CanonicalMaterialSourceValidationError) {
|
||
throw error
|
||
}
|
||
throw new CanonicalMaterialSourceValidationError(
|
||
'CANONICAL_SOURCE_STAT_FAILED',
|
||
'无法安全打开 canonical 源文件',
|
||
error,
|
||
)
|
||
} finally {
|
||
await handle?.close().catch(() => undefined)
|
||
}
|
||
|
||
return { sourcePath, size: expectedSize }
|
||
}
|