mirror of https://github.com/halo-dev/halo
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
parent
156a30496c
commit
24f8d7b571
|
@ -10,6 +10,7 @@ import org.apache.tika.metadata.Metadata;
|
||||||
import org.apache.tika.metadata.TikaCoreProperties;
|
import org.apache.tika.metadata.TikaCoreProperties;
|
||||||
import org.apache.tika.mime.MimeTypeException;
|
import org.apache.tika.mime.MimeTypeException;
|
||||||
import org.apache.tika.mime.MimeTypes;
|
import org.apache.tika.mime.MimeTypes;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
|
|
||||||
@UtilityClass
|
@UtilityClass
|
||||||
|
@ -54,4 +55,41 @@ public class FileTypeDetectUtils {
|
||||||
MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes();
|
MimeTypes mimeTypes = MimeTypes.getDefaultMimeTypes();
|
||||||
return mimeTypes.forName(mimeType).getExtension();
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -36,6 +36,7 @@ import org.springframework.web.util.UriUtils;
|
||||||
import reactor.core.Exceptions;
|
import reactor.core.Exceptions;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.publisher.SynchronousSink;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
import reactor.util.retry.Retry;
|
import reactor.util.retry.Retry;
|
||||||
import run.halo.app.core.attachment.AttachmentRootGetter;
|
import run.halo.app.core.attachment.AttachmentRootGetter;
|
||||||
|
@ -159,6 +160,10 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
|
||||||
.next()
|
.next()
|
||||||
.handle((dataBuffer, sink) -> {
|
.handle((dataBuffer, sink) -> {
|
||||||
var mimeType = detectMimeType(dataBuffer.asInputStream(), file.name());
|
var mimeType = detectMimeType(dataBuffer.asInputStream(), file.name());
|
||||||
|
if (!FileTypeDetectUtils.isValidExtensionForMime(mimeType, file.name())) {
|
||||||
|
handleFileTypeError(sink, "fileTypeNotMatch", mimeType);
|
||||||
|
return;
|
||||||
|
}
|
||||||
var isAllow = setting.getAllowedFileTypes()
|
var isAllow = setting.getAllowedFileTypes()
|
||||||
.stream()
|
.stream()
|
||||||
.map(FileCategoryMatcher::of)
|
.map(FileCategoryMatcher::of)
|
||||||
|
@ -167,16 +172,21 @@ class LocalAttachmentUploadHandler implements AttachmentHandler {
|
||||||
sink.next(dataBuffer);
|
sink.next(dataBuffer);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sink.error(new FileTypeNotAllowedException("File type is not allowed",
|
handleFileTypeError(sink, "fileTypeNotSupported", mimeType);
|
||||||
"problemDetail.attachment.upload.fileTypeNotSupported",
|
|
||||||
new Object[] {mimeType})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
validations.add(typeValidator);
|
validations.add(typeValidator);
|
||||||
}
|
}
|
||||||
return Mono.when(validations);
|
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
|
@NonNull
|
||||||
private String detectMimeType(InputStream inputStream, String name) {
|
private String detectMimeType(InputStream inputStream, String name) {
|
||||||
try {
|
try {
|
||||||
|
|
|
@ -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.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.fileSizeExceeded=Make sure the file size is less than {0}.
|
||||||
problemDetail.attachment.upload.fileTypeNotSupported=Unsupported upload of {0} type files.
|
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.
|
problemDetail.comment.waitingForApproval=Comment is awaiting approval.
|
||||||
|
|
||||||
title.visibility.identification.private=(Private)
|
title.visibility.identification.private=(Private)
|
||||||
|
|
|
@ -55,6 +55,7 @@ problemDetail.conflict=检测到冲突,请检查数据后重试。
|
||||||
problemDetail.migration.backup.notFound=备份文件不存在或已删除。
|
problemDetail.migration.backup.notFound=备份文件不存在或已删除。
|
||||||
problemDetail.attachment.upload.fileSizeExceeded=最大支持上传 {0} 大小的文件。
|
problemDetail.attachment.upload.fileSizeExceeded=最大支持上传 {0} 大小的文件。
|
||||||
problemDetail.attachment.upload.fileTypeNotSupported=不支持上传 {0} 类型的文件。
|
problemDetail.attachment.upload.fileTypeNotSupported=不支持上传 {0} 类型的文件。
|
||||||
|
problemDetail.attachment.upload.fileTypeNotMatch=文件类型 {0} 与文件扩展名不匹配,上传被拒绝。
|
||||||
problemDetail.comment.waitingForApproval=评论审核中。
|
problemDetail.comment.waitingForApproval=评论审核中。
|
||||||
|
|
||||||
title.visibility.identification.private=(私有)
|
title.visibility.identification.private=(私有)
|
||||||
|
|
|
@ -96,5 +96,29 @@ class FileTypeDetectUtilsTest {
|
||||||
|
|
||||||
ext = FileTypeDetectUtils.detectFileExtension("application/zip");
|
ext = FileTypeDetectUtils.detectFileExtension("application/zip");
|
||||||
assertThat(ext).isEqualTo(".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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue