fix: XSS vulnerability due to polyglot file type upload (#7149)

#### What type of PR is this?
/kind bug
/area core
/milestone 2.20.x

#### What this PR does / why we need it:
修复文件类型限制能通过混合文件类型绕过检测的问题

参考:https://github.com/halo-dev/halo/security/advisories/GHSA-99mc-ch53-pqh9

#### Does this PR introduce a user-facing change?

```release-note
修复文件类型限制能通过混合文件类型绕过检测的问题
```
pull/7199/head
guqing 2025-01-03 17:32:13 +08:00 committed by GitHub
parent 156a30496c
commit 24f8d7b571
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 78 additions and 4 deletions

View File

@ -10,6 +10,7 @@ import org.apache.tika.metadata.Metadata;
import org.apache.tika.metadata.TikaCoreProperties;
import org.apache.tika.mime.MimeTypeException;
import org.apache.tika.mime.MimeTypes;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
@UtilityClass
@ -54,4 +55,41 @@ public class FileTypeDetectUtils {
MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes();
return mimeTypes.forName(mimeType).getExtension();
}
/**
* <p>Get file extension from file name.</p>
* <p>The obtained file extension is in lowercase and includes the dot, such as ".jpg".</p>
*/
@NonNull
public static String getFileExtension(String fileName) {
Assert.notNull(fileName, "The fileName must not be null");
int lastDot = fileName.lastIndexOf(".");
if (lastDot > 0) {
return fileName.substring(lastDot).toLowerCase();
}
return "";
}
/**
* <p>Recommend to use this method to verify whether the file extension matches the file type
* after matching the file type to avoid XSS attacks such as bypassing detection by polyglot
* file</p>
*
* @param mimeType file mime type,such as "image/png"
* @param fileName file name,such as "test.png"
* @see
* <a href="https://github.com/halo-dev/halo/security/advisories/GHSA-99mc-ch53-pqh9">CVE Stored XSS</a>
* @see <a href="https://github.com/halo-dev/halo/pull/7149">gh-7149</a>
*/
public boolean isValidExtensionForMime(String mimeType, String fileName) {
Assert.notNull(mimeType, "The mimeType must not be null");
Assert.notNull(fileName, "The fileName must not be null");
String fileExtension = getFileExtension(fileName);
try {
String detectedExtByMime = detectFileExtension(mimeType);
return detectedExtByMime.equalsIgnoreCase(fileExtension);
} catch (MimeTypeException e) {
return false;
}
}
}

View File

@ -36,6 +36,7 @@ import org.springframework.web.util.UriUtils;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.publisher.SynchronousSink;
import reactor.core.scheduler.Schedulers;
import reactor.util.retry.Retry;
import run.halo.app.core.attachment.AttachmentRootGetter;
@ -159,6 +160,10 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
.next()
.handle((dataBuffer, sink) -> {
var mimeType = detectMimeType(dataBuffer.asInputStream(), file.name());
if (!FileTypeDetectUtils.isValidExtensionForMime(mimeType, file.name())) {
handleFileTypeError(sink, "fileTypeNotMatch", mimeType);
return;
}
var isAllow = setting.getAllowedFileTypes()
.stream()
.map(FileCategoryMatcher::of)
@ -167,16 +172,21 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
sink.next(dataBuffer);
return;
}
sink.error(new FileTypeNotAllowedException("File type is not allowed",
"problemDetail.attachment.upload.fileTypeNotSupported",
new Object[] {mimeType})
);
handleFileTypeError(sink, "fileTypeNotSupported", mimeType);
});
validations.add(typeValidator);
}
return Mono.when(validations);
}
private static void handleFileTypeError(SynchronousSink<Object> sink, String detailCode,
String mimeType) {
sink.error(new FileTypeNotAllowedException("File type is not allowed",
"problemDetail.attachment.upload." + detailCode,
new Object[] {mimeType})
);
}
@NonNull
private String detectMimeType(InputStream inputStream, String name) {
try {

View File

@ -82,6 +82,7 @@ problemDetail.conflict=Conflict detected, please check the data and retry.
problemDetail.migration.backup.notFound=The backup file does not exist or has been deleted.
problemDetail.attachment.upload.fileSizeExceeded=Make sure the file size is less than {0}.
problemDetail.attachment.upload.fileTypeNotSupported=Unsupported upload of {0} type files.
problemDetail.attachment.upload.fileTypeNotMatch=The file type {0} does not match the file extension, and the upload is rejected.
problemDetail.comment.waitingForApproval=Comment is awaiting approval.
title.visibility.identification.private=(Private)

View File

@ -55,6 +55,7 @@ problemDetail.conflict=检测到冲突,请检查数据后重试。
problemDetail.migration.backup.notFound=备份文件不存在或已删除。
problemDetail.attachment.upload.fileSizeExceeded=最大支持上传 {0} 大小的文件。
problemDetail.attachment.upload.fileTypeNotSupported=不支持上传 {0} 类型的文件。
problemDetail.attachment.upload.fileTypeNotMatch=文件类型 {0} 与文件扩展名不匹配,上传被拒绝。
problemDetail.comment.waitingForApproval=评论审核中。
title.visibility.identification.private=(私有)

View File

@ -96,5 +96,29 @@ class FileTypeDetectUtilsTest {
ext = FileTypeDetectUtils.detectFileExtension("application/zip");
assertThat(ext).isEqualTo(".zip");
ext = FileTypeDetectUtils.detectFileExtension("image/bmp");
assertThat(ext).isEqualTo(".bmp");
}
@Test
void getFileExtensionTest() {
var ext = FileTypeDetectUtils.getFileExtension("BMP+HTML+JAR.html");
assertThat(ext).isEqualTo(".html");
ext = FileTypeDetectUtils.getFileExtension("test.jpg");
assertThat(ext).isEqualTo(".jpg");
ext = FileTypeDetectUtils.getFileExtension("hello");
assertThat(ext).isEqualTo("");
}
@Test
void isValidExtensionForMimeTest() {
assertThat(FileTypeDetectUtils.isValidExtensionForMime("image/bmp", "hello.html"))
.isFalse();
assertThat(FileTypeDetectUtils.isValidExtensionForMime("image/bmp", "hello.bmp"))
.isTrue();
}
}