Skip to content

Commit 3cbcdfb

Browse files
committed
fix: add file type validation for uploads
- Add file validation utility with MIME type, extension, and content validation - Validate file types in upload, presign, and finalize endpoints - Support validation for JPEG, PNG, WebP, GIF, HEIC, HEIF, AVIF, and SVG - Use sharp for content validation where supported, magic bytes for others - Prevent malicious file uploads while maintaining user experience
1 parent baa7508 commit 3cbcdfb

2 files changed

Lines changed: 155 additions & 1 deletion

File tree

server/route/v2/attachment.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
updateAttachmentsSortOrder,
1515
upsertAttachmentsByOriginalKey,
1616
} from '../../utils/dbMethods';
17+
import { validateContentType, validateFile } from '../../utils/fileValidation';
1718
import { asyncHandler } from '../../utils/handlers';
1819
import { createResponse, isValidUUID } from '../../utils/main';
1920
import { presignPutUrl, r2uploadhandler } from '../../utils/r2';
@@ -47,6 +48,11 @@ attachmentsRouter.post(
4748

4849
const imageFiles = Array.isArray(files.images) ? files.images : [files.images];
4950

51+
// 验证每个文件的类型和内容
52+
for (const file of imageFiles) {
53+
await validateFile(file);
54+
}
55+
5056
// 并发控制,避免单次请求时间过长导致中断
5157
const CONCURRENCY = 3; // 保留并发配置,因为这是性能调优必需的
5258
const queue: Promise<void>[] = [];
@@ -145,7 +151,6 @@ attachmentsRouter.put(
145151
})
146152
);
147153

148-
export default attachmentsRouter;
149154
// 预签名直传(前端直接 PUT 到 R2)
150155
attachmentsRouter.post(
151156
'/presign',
@@ -161,6 +166,22 @@ attachmentsRouter.post(
161166
throw new Error('No files to presign');
162167
}
163168

169+
// 验证文件数量和大小
170+
const MAX_FILE_SIZE = 20 * 1024 * 1024; // 20MB
171+
const MAX_FILES = 9;
172+
173+
if (files.length > MAX_FILES) {
174+
throw new Error(`最多允许 ${MAX_FILES} 个文件`);
175+
}
176+
177+
// 验证每个文件的内容类型和大小
178+
for (const f of files) {
179+
validateContentType(f.contentType);
180+
if (f.size && f.size > MAX_FILE_SIZE) {
181+
throw new Error(`文件大小超过限制: ${MAX_FILE_SIZE} 字节`);
182+
}
183+
}
184+
164185
const getExt = (filename?: string, contentType?: string) => {
165186
if (filename && filename.includes('.')) return `.${filename.split('.').pop()}`;
166187
if (!contentType) return '';
@@ -243,6 +264,13 @@ attachmentsRouter.post(
243264
throw new Error('Invalid object key');
244265
}
245266

267+
// 验证 mimetype(如果提供)
268+
for (const a of attachments) {
269+
if (a.mimetype) {
270+
validateContentType(a.mimetype);
271+
}
272+
}
273+
246274
const uploads: UploadResult[] = attachments.map((a) => {
247275
const storageConfig = getGlobalConfig<StorageConfig>('storage');
248276
const urlPrefix = storageConfig?.urlPrefix;
@@ -273,3 +301,5 @@ attachmentsRouter.post(
273301
res.status(201).json(createResponse(data));
274302
})
275303
);
304+
305+
export default attachmentsRouter;

server/utils/fileValidation.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import formidable from 'formidable';
2+
import fs from 'fs/promises';
3+
import path from 'path';
4+
import sharp from 'sharp';
5+
6+
// 允许的图片 MIME 类型
7+
export const ALLOWED_MIME_TYPES = [
8+
'image/jpeg',
9+
'image/jpg',
10+
'image/png',
11+
'image/webp',
12+
'image/gif',
13+
'image/heic',
14+
'image/heif',
15+
'image/avif',
16+
'image/svg+xml',
17+
];
18+
19+
// 允许的文件扩展名
20+
export const ALLOWED_EXTENSIONS = [
21+
'.jpg',
22+
'.jpeg',
23+
'.png',
24+
'.webp',
25+
'.gif',
26+
'.heic',
27+
'.heif',
28+
'.avif',
29+
'.svg',
30+
];
31+
32+
/**
33+
* 验证文件类型(MIME type 和扩展名)
34+
* @param file formidable 文件对象
35+
* @throws Error 如果文件类型无效
36+
*/
37+
export function validateFileType(file: formidable.File): void {
38+
// 验证 MIME type
39+
if (!file.mimetype || !ALLOWED_MIME_TYPES.includes(file.mimetype)) {
40+
throw new Error(`不允许的文件类型: ${file.mimetype || '未知'}`);
41+
}
42+
43+
// 验证扩展名(如果提供了文件名)
44+
// 注意:某些情况下可能没有 originalFilename,此时只验证 MIME type
45+
if (file.originalFilename && file.originalFilename.includes('.')) {
46+
const ext = path.extname(file.originalFilename).toLowerCase();
47+
if (!ALLOWED_EXTENSIONS.includes(ext)) {
48+
throw new Error(`不允许的文件扩展名: ${ext}`);
49+
}
50+
}
51+
}
52+
53+
/**
54+
* 验证文件内容(使用 magic bytes 或 sharp)
55+
* 通过验证文件头或使用 sharp 读取文件来验证文件是否为有效的图片
56+
* @param file formidable 文件对象
57+
* @throws Error 如果文件内容无效
58+
*/
59+
export async function validateFileContent(file: formidable.File): Promise<void> {
60+
const buffer = await fs.readFile(file.filepath);
61+
62+
// SVG 文件:sharp 不支持,使用简单的结构验证
63+
if (file.mimetype === 'image/svg+xml') {
64+
const content = buffer.toString('utf-8');
65+
if (!content.includes('<svg') && !content.includes('<?xml')) {
66+
throw new Error('无效的 SVG 文件');
67+
}
68+
return;
69+
}
70+
71+
// HEIC/HEIF 文件:sharp 默认不支持,使用 magic bytes 验证
72+
if (file.mimetype === 'image/heic' || file.mimetype === 'image/heif') {
73+
if (buffer.length < 12) {
74+
throw new Error('无效的 HEIC/HEIF 文件');
75+
}
76+
const magicBytes = buffer.slice(4, 8).toString('ascii');
77+
if (magicBytes !== 'ftyp') {
78+
throw new Error('无效的 HEIC/HEIF 文件');
79+
}
80+
const brand = buffer.slice(8, 12).toString('ascii');
81+
if (!brand.includes('heic') && !brand.includes('heif') && !brand.includes('mif1')) {
82+
throw new Error('无效的 HEIC/HEIF 文件');
83+
}
84+
return;
85+
}
86+
87+
// 其他格式(JPEG, PNG, GIF, WebP, AVIF):使用 sharp 验证
88+
// sharp 会验证文件是否为有效的图片格式
89+
try {
90+
await sharp(buffer).metadata();
91+
} catch (error) {
92+
throw new Error(
93+
`文件内容验证失败: ${error instanceof Error ? error.message : '无效的图片文件'}`
94+
);
95+
}
96+
}
97+
98+
/**
99+
* 完整验证文件(类型和内容)
100+
* @param file formidable 文件对象
101+
* @throws Error 如果文件验证失败
102+
*/
103+
export async function validateFile(file: formidable.File): Promise<void> {
104+
// 先验证文件类型(MIME type 和扩展名)
105+
validateFileType(file);
106+
107+
// 再验证文件内容(magic bytes)
108+
await validateFileContent(file);
109+
}
110+
111+
/**
112+
* 验证内容类型(用于 presign 接口)
113+
* @param contentType 内容类型字符串
114+
* @throws Error 如果内容类型无效
115+
*/
116+
export function validateContentType(contentType?: string): void {
117+
if (!contentType) {
118+
throw new Error('缺少内容类型');
119+
}
120+
121+
if (!ALLOWED_MIME_TYPES.includes(contentType)) {
122+
throw new Error(`不允许的内容类型: ${contentType}`);
123+
}
124+
}

0 commit comments

Comments
 (0)