gongxiangp2/lib/material-canonical-source.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

163 lines
4.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 }
}