181 lines
6.1 KiB
JavaScript
181 lines
6.1 KiB
JavaScript
|
|
/**
|
|||
|
|
* FileValidator.js
|
|||
|
|
* 封装好的文件安全校验类 (ES Module)
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
// ==========================================
|
|||
|
|
// 1. 预定义:已知文件类型的魔数 (Signature Database)
|
|||
|
|
// ==========================================
|
|||
|
|
const KNOWN_SIGNATURES = {
|
|||
|
|
// === 图片 ===
|
|||
|
|
png: '89504E470D0A1A0A',
|
|||
|
|
jpg: 'FFD8FF',
|
|||
|
|
jpeg: 'FFD8FF',
|
|||
|
|
gif: '47494638',
|
|||
|
|
webp: '52494646', // RIFF Header
|
|||
|
|
|
|||
|
|
// === 文档 (Office 新版 - ZIP 格式) ===
|
|||
|
|
docx: '504B0304',
|
|||
|
|
xlsx: '504B0304',
|
|||
|
|
pptx: '504B0304',
|
|||
|
|
|
|||
|
|
// === 文档 (Office 旧版 - OLECF 格式) ===
|
|||
|
|
doc: 'D0CF11E0',
|
|||
|
|
xls: 'D0CF11E0',
|
|||
|
|
ppt: 'D0CF11E0',
|
|||
|
|
|
|||
|
|
// === 其他 ===
|
|||
|
|
pdf: '25504446',
|
|||
|
|
|
|||
|
|
// === 纯文本 (无固定魔数,需特殊算法检测) ===
|
|||
|
|
txt: 'TYPE_TEXT',
|
|||
|
|
csv: 'TYPE_TEXT',
|
|||
|
|
md: 'TYPE_TEXT',
|
|||
|
|
json: 'TYPE_TEXT',
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// ==========================================
|
|||
|
|
// 2. 核心类定义
|
|||
|
|
// ==========================================
|
|||
|
|
export class FileValidator {
|
|||
|
|
/**
|
|||
|
|
* 构造函数
|
|||
|
|
* @param {Object} options 配置项
|
|||
|
|
* @param {number} [options.maxSizeMB=10] 最大文件大小 (MB)
|
|||
|
|
* @param {string[]} [options.allowedExtensions] 允许的扩展名列表 (如 ['jpg', 'png']),默认允许全部已知类型
|
|||
|
|
*/
|
|||
|
|
version = '1.0.0';
|
|||
|
|
constructor(options = {}) {
|
|||
|
|
// 配置大小 (默认 10MB)
|
|||
|
|
this.maxSizeMB = options.maxSizeMB || 10;
|
|||
|
|
|
|||
|
|
// 配置允许的类型
|
|||
|
|
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
|||
|
|
if (options.allowedExtensions && Array.isArray(options.allowedExtensions)) {
|
|||
|
|
this.allowedConfig = {};
|
|||
|
|
options.allowedExtensions.forEach((ext) => {
|
|||
|
|
const key = ext.toLowerCase();
|
|||
|
|
if (KNOWN_SIGNATURES[key]) {
|
|||
|
|
this.allowedConfig[key] = KNOWN_SIGNATURES[key];
|
|||
|
|
} else {
|
|||
|
|
console.warn(`[FileValidator] 未知的文件类型: .${key},已忽略`);
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
} else {
|
|||
|
|
this.allowedConfig = {
|
|||
|
|
...KNOWN_SIGNATURES
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 辅助:ArrayBuffer 转 Hex 字符串
|
|||
|
|
*/
|
|||
|
|
_bufferToHex(buffer) {
|
|||
|
|
return Array.prototype.map
|
|||
|
|
.call(new Uint8Array(buffer), (x) => ('00' + x.toString(16)).slice(-2))
|
|||
|
|
.join('')
|
|||
|
|
.toUpperCase();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 辅助:纯文本抽样检测
|
|||
|
|
*/
|
|||
|
|
_isCleanText(buffer) {
|
|||
|
|
const bytes = new Uint8Array(buffer);
|
|||
|
|
const checkLen = Math.min(bytes.length, 1000);
|
|||
|
|
let suspiciousCount = 0;
|
|||
|
|
|
|||
|
|
for (let i = 0; i < checkLen; i++) {
|
|||
|
|
const byte = bytes[i];
|
|||
|
|
// 允许常见控制符: 9(Tab), 10(LF), 13(CR)
|
|||
|
|
// 0-31 范围内其他的通常是二进制控制符
|
|||
|
|
if (byte < 32 && ![9, 10, 13].includes(byte)) {
|
|||
|
|
suspiciousCount++;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
// 如果可疑字符占比 < 5%,认为是纯文本
|
|||
|
|
return suspiciousCount / checkLen < 0.05;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 执行校验
|
|||
|
|
* @param {File} file 文件对象
|
|||
|
|
* @returns {Promise<boolean>}
|
|||
|
|
*/
|
|||
|
|
validate(file) {
|
|||
|
|
return new Promise((resolve, reject) => {
|
|||
|
|
// 1. 基础对象检查
|
|||
|
|
if (!file || !file.name) return reject('无效的文件对象');
|
|||
|
|
|
|||
|
|
// 2. 大小检查
|
|||
|
|
if (file.size > this.maxSizeMB * 1024 * 1024) {
|
|||
|
|
return reject(`文件大小超出限制 (最大 ${this.maxSizeMB}MB)`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 3. 后缀名检查
|
|||
|
|
const fileName = file.name.toLowerCase();
|
|||
|
|
const extension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
|||
|
|
|
|||
|
|
// 检查是否在配置的白名单中
|
|||
|
|
const expectedMagic = this.allowedConfig[extension];
|
|||
|
|
if (!expectedMagic) {
|
|||
|
|
return reject(`不支持的文件格式: .${extension}`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 4. 读取二进制头进行魔数校验
|
|||
|
|
const reader = new FileReader();
|
|||
|
|
|
|||
|
|
reader.onload = (e) => {
|
|||
|
|
const buffer = e.target.result;
|
|||
|
|
let isSafe = false;
|
|||
|
|
|
|||
|
|
// 分支处理:纯文本 vs 二进制
|
|||
|
|
if (expectedMagic === 'TYPE_TEXT') {
|
|||
|
|
if (this._isCleanText(buffer)) {
|
|||
|
|
isSafe = true;
|
|||
|
|
} else {
|
|||
|
|
return reject(`文件异常:.${extension} 文件包含非法二进制内容`);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 获取文件头 Hex (读取足够长的字节以覆盖最长的魔数,PNG需8字节)
|
|||
|
|
const fileHeader = this._bufferToHex(buffer.slice(0, 8));
|
|||
|
|
|
|||
|
|
// 使用 startsWith 匹配 (兼容 4字节或8字节魔数)
|
|||
|
|
if (fileHeader.startsWith(expectedMagic)) {
|
|||
|
|
isSafe = true;
|
|||
|
|
} else {
|
|||
|
|
return reject(`文件可能已被篡改 (真实类型与 .${extension} 不符)`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (isSafe) resolve(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
reader.onerror = () => reject('文件读取失败,无法校验');
|
|||
|
|
|
|||
|
|
// 读取前 1KB 足够进行判断
|
|||
|
|
reader.readAsArrayBuffer(file.slice(0, 1024));
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 【demo】
|
|||
|
|
// 如果传入了 allowedExtensions,则只使用传入的;否则使用全部 KNOWN_SIGNATURES
|
|||
|
|
// const imageValidator = new FileValidator({
|
|||
|
|
// maxSizeMB: 5,
|
|||
|
|
// allowedExtensions: ['png', 'jpg', 'jpeg'],
|
|||
|
|
// });
|
|||
|
|
|
|||
|
|
// imageValidator
|
|||
|
|
// .validate(file)
|
|||
|
|
// .then(() => {
|
|||
|
|
// statusDiv.textContent = `✅ 检测通过: ${file.name}`;
|
|||
|
|
// statusDiv.style.color = 'green';
|
|||
|
|
// console.log('图片校验通过,开始上传...');
|
|||
|
|
// // upload(file)...
|
|||
|
|
// })
|
|||
|
|
// .catch((err) => {
|
|||
|
|
// statusDiv.textContent = `❌ 检测失败: ${err}`;
|
|||
|
|
// statusDiv.style.color = 'red';
|
|||
|
|
// });
|