diff --git a/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java b/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java
index 7166260d4f..63663b0700 100644
--- a/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java
+++ b/api/src/main/java/run/halo/app/infra/utils/FileTypeDetectUtils.java
@@ -10,6 +10,7 @@
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 static String detectFileExtension(String mimeType) throws MimeTypeExcepti
MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes();
return mimeTypes.forName(mimeType).getExtension();
}
+
+ /**
+ *
Get file extension from file name.
+ * The obtained file extension is in lowercase and includes the dot, such as ".jpg".
+ */
+ @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 "";
+ }
+
+ /**
+ * 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
+ *
+ * @param mimeType file mime type,such as "image/png"
+ * @param fileName file name,such as "test.png"
+ * @see
+ * CVE Stored XSS
+ * @see gh-7149
+ */
+ 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;
+ }
+ }
}
diff --git a/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java b/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java
index 2c4351112f..3a24fb342d 100644
--- a/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java
+++ b/application/src/main/java/run/halo/app/core/attachment/endpoint/LocalAttachmentUploadHandler.java
@@ -36,6 +36,7 @@
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 @@ private Mono validateFile(FilePart file, PolicySetting setting) {
.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 @@ private Mono validateFile(FilePart file, PolicySetting setting) {
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 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 {
diff --git a/application/src/main/resources/config/i18n/messages.properties b/application/src/main/resources/config/i18n/messages.properties
index 565d813987..81e36aa05b 100644
--- a/application/src/main/resources/config/i18n/messages.properties
+++ b/application/src/main/resources/config/i18n/messages.properties
@@ -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)
diff --git a/application/src/main/resources/config/i18n/messages_zh.properties b/application/src/main/resources/config/i18n/messages_zh.properties
index 767d79b77e..bdab92ac74 100644
--- a/application/src/main/resources/config/i18n/messages_zh.properties
+++ b/application/src/main/resources/config/i18n/messages_zh.properties
@@ -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=(私有)
diff --git a/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java b/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java
index 1c3e407d08..8cf0b4fe0e 100644
--- a/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java
+++ b/application/src/test/java/run/halo/app/infra/utils/FileTypeDetectUtilsTest.java
@@ -96,5 +96,29 @@ void detectFileExtensionTest() throws MimeTypeException {
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();
}
}