Skip to content

Commit 7131c85

Browse files
committed
fix: add file upload validation to prevent arbitrary file upload vulnerability
Add FileUploadValidator with extension whitelist, MIME type consistency check, and filename sanitization to the attachment upload endpoint. 🤖 Generated with [Qoder][https://qoder.com]
1 parent a8a6680 commit 7131c85

File tree

2 files changed

+220
-2
lines changed

2 files changed

+220
-2
lines changed
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
20+
package com.alibaba.himarket.core.utils;
21+
22+
import java.util.Locale;
23+
import java.util.Map;
24+
import java.util.Set;
25+
26+
public final class FileUploadValidator {
27+
28+
private FileUploadValidator() {}
29+
30+
private static final Set<String> ALLOWED_EXTENSIONS =
31+
Set.of(
32+
// Images
33+
"jpg",
34+
"jpeg",
35+
"png",
36+
"gif",
37+
"bmp",
38+
"webp",
39+
"svg",
40+
// Documents
41+
"txt",
42+
"md",
43+
"pdf",
44+
"doc",
45+
"docx",
46+
"xls",
47+
"xlsx",
48+
"ppt",
49+
"pptx",
50+
"csv",
51+
// Audio
52+
"mp3",
53+
"wav",
54+
"ogg",
55+
"aac",
56+
"flac",
57+
// Video
58+
"mp4",
59+
"avi",
60+
"mov",
61+
"wmv",
62+
"webm",
63+
// Archives
64+
"zip");
65+
66+
private static final Map<String, Set<String>> MIME_TO_EXTENSIONS =
67+
Map.ofEntries(
68+
// Images
69+
Map.entry("image/jpeg", Set.of("jpg", "jpeg")),
70+
Map.entry("image/png", Set.of("png")),
71+
Map.entry("image/gif", Set.of("gif")),
72+
Map.entry("image/bmp", Set.of("bmp")),
73+
Map.entry("image/webp", Set.of("webp")),
74+
Map.entry("image/svg+xml", Set.of("svg")),
75+
// Documents
76+
Map.entry("text/plain", Set.of("txt", "md", "csv")),
77+
Map.entry("text/markdown", Set.of("md")),
78+
Map.entry("text/csv", Set.of("csv")),
79+
Map.entry("application/pdf", Set.of("pdf")),
80+
Map.entry("application/msword", Set.of("doc")),
81+
Map.entry(
82+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
83+
Set.of("docx")),
84+
Map.entry("application/vnd.ms-excel", Set.of("xls")),
85+
Map.entry(
86+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
87+
Set.of("xlsx")),
88+
Map.entry("application/vnd.ms-powerpoint", Set.of("ppt")),
89+
Map.entry(
90+
"application/vnd.openxmlformats-officedocument.presentationml"
91+
+ ".presentation",
92+
Set.of("pptx")),
93+
// Audio
94+
Map.entry("audio/mpeg", Set.of("mp3")),
95+
Map.entry("audio/wav", Set.of("wav")),
96+
Map.entry("audio/ogg", Set.of("ogg")),
97+
Map.entry("audio/aac", Set.of("aac")),
98+
Map.entry("audio/flac", Set.of("flac")),
99+
// Video
100+
Map.entry("video/mp4", Set.of("mp4")),
101+
Map.entry("video/x-msvideo", Set.of("avi")),
102+
Map.entry("video/quicktime", Set.of("mov")),
103+
Map.entry("video/x-ms-wmv", Set.of("wmv")),
104+
Map.entry("video/webm", Set.of("webm")),
105+
// Archives
106+
Map.entry("application/zip", Set.of("zip")),
107+
// Common fallback MIME types
108+
Map.entry("application/octet-stream", ALLOWED_EXTENSIONS));
109+
110+
/**
111+
* Extract and validate the file extension against the whitelist.
112+
*
113+
* @return the lowercase extension, or {@code null} if the filename has no extension
114+
* @throws IllegalArgumentException if the extension is not in the whitelist
115+
*/
116+
public static String validateExtension(String filename) {
117+
String ext = extractExtension(filename);
118+
if (ext == null) {
119+
throw new IllegalArgumentException(
120+
"File has no extension. Allowed extensions: " + ALLOWED_EXTENSIONS);
121+
}
122+
if (!ALLOWED_EXTENSIONS.contains(ext)) {
123+
throw new IllegalArgumentException(
124+
"File extension '" + ext + "' is not allowed. Allowed: " + ALLOWED_EXTENSIONS);
125+
}
126+
return ext;
127+
}
128+
129+
/**
130+
* Validate that the MIME type is consistent with the file extension. When the MIME type is
131+
* {@code null} or {@code application/octet-stream}, the check is skipped (extension whitelist
132+
* alone is sufficient).
133+
*
134+
* @throws IllegalArgumentException if the MIME type does not match the extension
135+
*/
136+
public static void validateMimeType(String mimeType, String extension) {
137+
if (mimeType == null || "application/octet-stream".equals(mimeType)) {
138+
return;
139+
}
140+
Set<String> expected = MIME_TO_EXTENSIONS.get(mimeType);
141+
if (expected != null && !expected.contains(extension)) {
142+
throw new IllegalArgumentException(
143+
"MIME type '"
144+
+ mimeType
145+
+ "' does not match file extension '."
146+
+ extension
147+
+ "'");
148+
}
149+
}
150+
151+
/**
152+
* Sanitize a filename for safe storage. Removes path traversal sequences, null bytes, and
153+
* characters that are problematic in file systems or URLs.
154+
*/
155+
public static String sanitizeFilename(String filename) {
156+
if (filename == null || filename.isBlank()) {
157+
return "unnamed";
158+
}
159+
160+
// Strip path components (both Unix and Windows separators)
161+
int lastSep = Math.max(filename.lastIndexOf('/'), filename.lastIndexOf('\\'));
162+
if (lastSep >= 0) {
163+
filename = filename.substring(lastSep + 1);
164+
}
165+
166+
// Remove null bytes and control characters
167+
filename = filename.replaceAll("[\\x00-\\x1f]", "");
168+
169+
// Remove path traversal sequences
170+
filename = filename.replace("..", "");
171+
172+
// Keep only safe characters: letters, digits, dots, hyphens, underscores, spaces, CJK
173+
filename = filename.replaceAll("[^\\w.\\-\\s\\u4e00-\\u9fff\\u3400-\\u4dbf]", "_");
174+
175+
// Collapse multiple dots or underscores
176+
filename = filename.replaceAll("[_.]{2,}", "_");
177+
178+
// Trim leading/trailing dots and spaces
179+
filename = filename.replaceAll("^[.\\s]+|[.\\s]+$", "").trim();
180+
181+
if (filename.isEmpty()) {
182+
return "unnamed";
183+
}
184+
185+
return filename;
186+
}
187+
188+
private static String extractExtension(String filename) {
189+
if (filename == null) {
190+
return null;
191+
}
192+
int dot = filename.lastIndexOf('.');
193+
if (dot < 0 || dot == filename.length() - 1) {
194+
return null;
195+
}
196+
return filename.substring(dot + 1).toLowerCase(Locale.ROOT);
197+
}
198+
}

himarket-server/src/main/java/com/alibaba/himarket/service/impl/ChatAttachmentServiceImpl.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import com.alibaba.himarket.core.exception.BusinessException;
2424
import com.alibaba.himarket.core.exception.ErrorCode;
2525
import com.alibaba.himarket.core.security.ContextHolder;
26+
import com.alibaba.himarket.core.utils.FileUploadValidator;
2627
import com.alibaba.himarket.core.utils.IdGenerator;
2728
import com.alibaba.himarket.dto.result.chat.ChatAttachmentDetailResult;
2829
import com.alibaba.himarket.dto.result.chat.ChatAttachmentResult;
@@ -50,8 +51,27 @@ public ChatAttachmentResult uploadAttachment(MultipartFile file) {
5051
throw new BusinessException(ErrorCode.INVALID_REQUEST, "File cannot be empty");
5152
}
5253

53-
// Determine attachment type from MIME type
54+
// Validate file extension against whitelist
55+
String originalFilename = file.getOriginalFilename();
56+
String extension;
57+
try {
58+
extension = FileUploadValidator.validateExtension(originalFilename);
59+
} catch (IllegalArgumentException e) {
60+
throw new BusinessException(ErrorCode.INVALID_REQUEST, e.getMessage());
61+
}
62+
63+
// Validate MIME type consistency with extension
5464
String mimeType = file.getContentType();
65+
try {
66+
FileUploadValidator.validateMimeType(mimeType, extension);
67+
} catch (IllegalArgumentException e) {
68+
throw new BusinessException(ErrorCode.INVALID_REQUEST, e.getMessage());
69+
}
70+
71+
// Sanitize filename for safe storage
72+
String safeName = FileUploadValidator.sanitizeFilename(originalFilename);
73+
74+
// Determine attachment type from MIME type
5575
ChatAttachmentType type = determineAttachmentType(mimeType);
5676

5777
try {
@@ -60,7 +80,7 @@ public ChatAttachmentResult uploadAttachment(MultipartFile file) {
6080
ChatAttachment.builder()
6181
.attachmentId(IdGenerator.genChatAttachmentId())
6282
.userId(contextHolder.getUser())
63-
.name(file.getOriginalFilename())
83+
.name(safeName)
6484
.type(type)
6585
.mimeType(mimeType)
6686
.size(file.getSize())

0 commit comments

Comments
 (0)