Files
cms-website/apps/api/src/modules/import/magic-bytes.ts
T

50 lines
2.9 KiB
TypeScript
Raw Normal View History

2026-10-21 18:38:10 +08:00
// 手写魔数签名表(Plan 3 Task 4,spec §11.1、计划"Tech Stack:零新增依赖"):
// 常见六类媒体格式判别 + SVG 文本特征检出。刻意不引 file-type 依赖——导入管道的
// 安全决策只须回答两问:"是什么格式"与"是否 SVG(隔离)",一张可审计的小签名表
// 足够,且避免为一次头部读取引入整个 MIME 数据库。
//
// 判别语义(detectMediaType):
// - 命中二进制签名 → {type: MIME, svg: false}(PNG/JPEG/GIF87a/GIF89a/WEBP/PDF);
// - 无二进制命中但前 1KB 文本窗口含 "<svg"(大小写不敏感)→ {type: 'image/svg+xml',
// svg: true}——调用方据此 status='quarantined'(spec §11.1:SVG 可携带脚本与外部
// 引用,不进产物语义);
// - 均未命中 → null(未知签名——调用方 status='unsupported' + issue,不猜格式)。
//
// 顺序纪律:二进制签名先于 SVG 文本扫描——PNG/JPEG 正例即便尾部字节碰巧含 "<svg"
// 片段也按二进制格式判(文本窗口只对非二进制文件有意义)。窗口 1KB 是钉死的折中:
// 覆盖常规 "<?xml …?><svg" 前导(声明/注释/doctype),不给深藏脚本的巨型前缀放行。
//
// Buffer 读取口径:latin1 单字节解码后小写化做包含检测——不做 UTF-8 多字节解码,
// 避免截断的字节序列被解码成意外字符。
/** 二进制签名表(前缀匹配;WEBP 的 RIFF+WEBP@8 双段结构在表外单查) */
const SIGNATURES: ReadonlyArray<{ mime: string; bytes: Buffer }> = Object.freeze([
{ mime: 'image/png', bytes: Buffer.from([0x89, 0x50, 0x4e, 0x47]) },
{ mime: 'image/jpeg', bytes: Buffer.from([0xff, 0xd8, 0xff]) },
{ mime: 'image/gif', bytes: Buffer.from('GIF87a', 'latin1') },
{ mime: 'image/gif', bytes: Buffer.from('GIF89a', 'latin1') },
{ mime: 'application/pdf', bytes: Buffer.from('%PDF', 'latin1') },
])
const RIFF = Buffer.from('RIFF', 'latin1')
const WEBP = Buffer.from('WEBP', 'latin1')
const SVG_WINDOW_BYTES = 1024
/**
* 魔数检测:读文件头部字节判别媒体类型。返回 null = 未知签名(调用方按
* unsupported 处置);svg=true 仅出现在 type='image/svg+xml'(文本特征命中)。
*/
export function detectMediaType(buf: Buffer): { type: string; svg: boolean } | null {
for (const sig of SIGNATURES) {
if (buf.length >= sig.bytes.length && buf.subarray(0, sig.bytes.length).equals(sig.bytes)) {
return { type: sig.mime, svg: false }
}
}
// WEBP:RIFF 容器头(4B 长度)+ 'WEBP' Magic(@8)——RIFF 家族其他成员(WAV/AVI)不误报
if (buf.length >= 12 && buf.subarray(0, 4).equals(RIFF) && buf.subarray(8, 12).equals(WEBP)) {
return { type: 'image/webp', svg: false }
}
const head = buf.subarray(0, Math.min(buf.length, SVG_WINDOW_BYTES)).toString('latin1').toLowerCase()
if (head.includes('<svg')) return { type: 'image/svg+xml', svg: true }
return null
}