file.type和accept均不可靠:前者由浏览器推测且可伪造,后者仅优化选择器界面;真正校验需前端读取文件头魔数并后端用二进制流重验。

为什么 file.type 和 accept 都拦不住伪装文件
因为 file.type 是浏览器根据扩展名或简单头信息推测的,完全可被绕过;accept 只影响文件选择器界面,用户点“所有文件”就能选任意后缀。比如把 hack.php 改成 safe.jpg,file.type 会返回 "image/jpeg",但实际内容仍是 PHP 代码。
前端读取前 16 字节匹配魔数的实操写法
用 FileReader.readAsArrayBuffer() 读取文件开头字节,再转为十六进制比对标准魔数:
const checkMagicNumber = async (file) => {
const reader = new FileReader();
return new Promise((resolve) => {
reader.onload = () => {
const buffer = reader.result;
const view = new Uint8Array(buffer, 0, 16); // 只取前 16 字节
const hex = Array.from(view, b => b.toString(16).padStart(2, '0')).join('');
if (hex.startsWith('89504e47')) resolve('png');
else if (hex.startsWith('ffd8ff')) resolve('jpg');
else if (hex.startsWith('47494638')) resolve('gif');
else if (hex.startsWith('25504446')) resolve('pdf');
else resolve(null);
};
reader.readAsArrayBuffer(file.slice(0, 16));
});
};
- 必须用
file.slice(0, 16),避免大文件全量读取阻塞主线程 - 不要依赖
file.name或file.type做判断依据 - 魔数比对要区分大小写:
'ffd8ff'≠'FFD8FF',统一转小写再比 - 某些格式(如 WebP、AVIF)魔数更长或有变体,需查证标准定义再扩展
常见魔数对照表与易错点
不同格式魔数位置和长度不一致,硬编码时容易漏判:
- JPG/JPEG:前 3 字节是
ffd8ff,但部分带 EXIF 的文件第 4–6 字节可能是e1?? ??,不能只截 3 字节 - PNG:固定前 8 字节
89504e470d0a1a0a,少一位就可能误判 - GIF:前 6 字节
474946383961或474946383761(GIF89a / GIF87a) - PDF:前 4 字节
25504446(即%PDF),但有些带 BOM 的 PDF 开头是efbbbf25504446 - ZIP 类(DOCX/XLSX/APK):前 4 字节
504b0304,但不能仅靠它区分 DOCX 和恶意 ZIP
魔数校验只是前端辅助,后端必须重做
前端魔数检查能提升体验、过滤明显异常,但无法防篡改:
立即学习“前端免费学习笔记(深入)”;
- 攻击者可在上传前用工具替换文件头+内容,再伪造对应魔数
- 禁用 JavaScript 后,全部前端逻辑失效
- 服务端收到文件后,必须用
finfo_file()(PHP)、file-type(Node.js)、Files.probeContentType()(Java)等真实解析魔数 - 注意:后端也不能只信
$_FILES['file']['type']或req.file.mimetype,它们来自请求头,可被任意修改
最容易被忽略的是:魔数只说明“文件开头像某种类型”,不代表整个文件结构合法——比如一个 PNG 文件末尾拼接了 PHP 代码,魔数校验仍通过,但服务端解析图片时可能崩溃或触发 RCE。真安全得靠完整解析 + 沙箱执行 + 目录权限隔离。



















