forked from baskbuddy/zhangshun
16 changed files with 778 additions and 66 deletions
@ -0,0 +1,216 @@ |
|||||
|
package com.mydreamjob.profile.storage; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.springframework.web.multipart.MultipartFile; |
||||
|
|
||||
|
import java.io.ByteArrayInputStream; |
||||
|
import java.io.IOException; |
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
import java.util.zip.ZipEntry; |
||||
|
import java.util.zip.ZipException; |
||||
|
import java.util.zip.ZipInputStream; |
||||
|
|
||||
|
/** |
||||
|
* 简历上传白名单校验器。 |
||||
|
* |
||||
|
* <p>扩展名和客户端 Content-Type 只作为第一层校验;随后检查文件实际签名。 |
||||
|
* DOCX 还会验证必要的 Office Open XML 条目,并限制条目数、解压总量和压缩比, |
||||
|
* 避免将任意 ZIP、路径穿越条目或压缩炸弹当作简历保存。</p> |
||||
|
* |
||||
|
* <p>本校验不等同于恶意软件扫描。生产环境如需病毒查杀,应在独立安全组件中完成。</p> |
||||
|
*/ |
||||
|
@Component |
||||
|
public class ResumeFileValidator { |
||||
|
|
||||
|
static final long MAX_FILE_SIZE = 10L * 1024 * 1024; |
||||
|
static final int MAX_DOCX_ENTRIES = 1_024; |
||||
|
static final long MAX_DOCX_UNCOMPRESSED_BYTES = 40L * 1024 * 1024; |
||||
|
static final long MAX_DOCX_COMPRESSION_RATIO = 100L; |
||||
|
private static final int BUFFER_SIZE = 8 * 1024; |
||||
|
|
||||
|
private static final Set<String> ALLOWED_EXTENSIONS = Set.of(".pdf", ".doc", ".docx"); |
||||
|
private static final Map<String, String> EXT_TO_CONTENT_TYPE = Map.of( |
||||
|
".pdf", "application/pdf", |
||||
|
".doc", "application/msword", |
||||
|
".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" |
||||
|
); |
||||
|
|
||||
|
private static final byte[] PDF_SIGNATURE = {'%', 'P', 'D', 'F', '-'}; |
||||
|
private static final byte[] OLE_SIGNATURE = { |
||||
|
(byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, |
||||
|
(byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1 |
||||
|
}; |
||||
|
private static final byte[] WORD_DOCUMENT_UTF16_LE = { |
||||
|
'W', 0, 'o', 0, 'r', 0, 'd', 0, 'D', 0, 'o', 0, 'c', 0, |
||||
|
'u', 0, 'm', 0, 'e', 0, 'n', 0, 't', 0 |
||||
|
}; |
||||
|
|
||||
|
public void validate(MultipartFile file) { |
||||
|
if (file == null || file.isEmpty() || file.getSize() <= 0 || file.getSize() > MAX_FILE_SIZE) { |
||||
|
reject(); |
||||
|
} |
||||
|
|
||||
|
String originalName = file.getOriginalFilename(); |
||||
|
ResumeFilenameSanitizer.validateUploadName(originalName); |
||||
|
String extension = extensionOf(originalName); |
||||
|
if (!ALLOWED_EXTENSIONS.contains(extension)) { |
||||
|
reject(); |
||||
|
} |
||||
|
|
||||
|
String contentType = file.getContentType(); |
||||
|
if (!EXT_TO_CONTENT_TYPE.get(extension).equals(contentType)) { |
||||
|
reject(); |
||||
|
} |
||||
|
|
||||
|
byte[] content; |
||||
|
try { |
||||
|
content = file.getBytes(); |
||||
|
} catch (IOException e) { |
||||
|
reject(); |
||||
|
return; |
||||
|
} |
||||
|
if (content.length == 0 || content.length > MAX_FILE_SIZE) { |
||||
|
reject(); |
||||
|
} |
||||
|
|
||||
|
switch (extension) { |
||||
|
case ".pdf" -> validatePdf(content); |
||||
|
case ".doc" -> validateLegacyDoc(content); |
||||
|
case ".docx" -> validateDocx(content); |
||||
|
default -> reject(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void validatePdf(byte[] content) { |
||||
|
int offset = startsWith(content, new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}) ? 3 : 0; |
||||
|
while (offset < content.length && offset < 16 && isAsciiWhitespace(content[offset])) { |
||||
|
offset++; |
||||
|
} |
||||
|
if (!startsWithAt(content, PDF_SIGNATURE, offset)) { |
||||
|
reject(); |
||||
|
} |
||||
|
int searchStart = Math.max(0, content.length - 2_048); |
||||
|
if (indexOf(content, new byte[]{'%', '%', 'E', 'O', 'F'}, searchStart) < 0) { |
||||
|
reject(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void validateLegacyDoc(byte[] content) { |
||||
|
if (!startsWith(content, OLE_SIGNATURE) || indexOf(content, WORD_DOCUMENT_UTF16_LE, 0) < 0) { |
||||
|
reject(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void validateDocx(byte[] content) { |
||||
|
if (!startsWith(content, new byte[]{'P', 'K'})) { |
||||
|
reject(); |
||||
|
} |
||||
|
|
||||
|
boolean contentTypesFound = false; |
||||
|
boolean documentFound = false; |
||||
|
int entryCount = 0; |
||||
|
long uncompressedBytes = 0; |
||||
|
byte[] buffer = new byte[BUFFER_SIZE]; |
||||
|
|
||||
|
try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(content))) { |
||||
|
ZipEntry entry; |
||||
|
while ((entry = zip.getNextEntry()) != null) { |
||||
|
entryCount++; |
||||
|
if (entryCount > MAX_DOCX_ENTRIES || unsafeEntryName(entry.getName())) { |
||||
|
reject(); |
||||
|
} |
||||
|
if ("[Content_Types].xml".equals(entry.getName())) { |
||||
|
contentTypesFound = true; |
||||
|
} else if ("word/document.xml".equals(entry.getName())) { |
||||
|
documentFound = true; |
||||
|
} |
||||
|
|
||||
|
int read; |
||||
|
while ((read = zip.read(buffer)) != -1) { |
||||
|
uncompressedBytes += read; |
||||
|
if (uncompressedBytes > MAX_DOCX_UNCOMPRESSED_BYTES |
||||
|
|| uncompressedBytes > safeMultiply(content.length, MAX_DOCX_COMPRESSION_RATIO)) { |
||||
|
reject(); |
||||
|
} |
||||
|
} |
||||
|
zip.closeEntry(); |
||||
|
} |
||||
|
} catch (ZipException e) { |
||||
|
reject(); |
||||
|
} catch (IOException e) { |
||||
|
reject(); |
||||
|
} |
||||
|
|
||||
|
if (!contentTypesFound || !documentFound) { |
||||
|
reject(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private boolean unsafeEntryName(String name) { |
||||
|
if (name == null || name.isBlank() || name.startsWith("/") || name.startsWith("\\") |
||||
|
|| name.contains("\\") || name.indexOf('\0') >= 0 || name.contains(":")) { |
||||
|
return true; |
||||
|
} |
||||
|
for (String segment : name.split("/")) { |
||||
|
if ("..".equals(segment)) { |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
private long safeMultiply(long value, long multiplier) { |
||||
|
if (value > Long.MAX_VALUE / multiplier) { |
||||
|
return Long.MAX_VALUE; |
||||
|
} |
||||
|
return value * multiplier; |
||||
|
} |
||||
|
|
||||
|
private String extensionOf(String filename) { |
||||
|
int dot = filename.lastIndexOf('.'); |
||||
|
if (dot <= 0 || dot == filename.length() - 1) { |
||||
|
reject(); |
||||
|
} |
||||
|
return filename.substring(dot).toLowerCase(java.util.Locale.ROOT); |
||||
|
} |
||||
|
|
||||
|
private boolean startsWith(byte[] content, byte[] signature) { |
||||
|
return startsWithAt(content, signature, 0); |
||||
|
} |
||||
|
|
||||
|
private boolean startsWithAt(byte[] content, byte[] signature, int offset) { |
||||
|
if (offset < 0 || content.length - offset < signature.length) { |
||||
|
return false; |
||||
|
} |
||||
|
for (int i = 0; i < signature.length; i++) { |
||||
|
if (content[offset + i] != signature[i]) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
private int indexOf(byte[] content, byte[] target, int start) { |
||||
|
outer: |
||||
|
for (int i = Math.max(0, start); i <= content.length - target.length; i++) { |
||||
|
for (int j = 0; j < target.length; j++) { |
||||
|
if (content[i + j] != target[j]) { |
||||
|
continue outer; |
||||
|
} |
||||
|
} |
||||
|
return i; |
||||
|
} |
||||
|
return -1; |
||||
|
} |
||||
|
|
||||
|
private boolean isAsciiWhitespace(byte value) { |
||||
|
return value == ' ' || value == '\t' || value == '\r' || value == '\n' || value == '\f'; |
||||
|
} |
||||
|
|
||||
|
private void reject() { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,54 @@ |
|||||
|
package com.mydreamjob.profile.storage; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
|
||||
|
/** 文件名白名单处理:阻止路径字符、控制字符和响应头换行进入存储元数据或下载响应。 */ |
||||
|
public final class ResumeFilenameSanitizer { |
||||
|
|
||||
|
private static final int MAX_FILENAME_LENGTH = 255; |
||||
|
|
||||
|
private ResumeFilenameSanitizer() { |
||||
|
} |
||||
|
|
||||
|
public static void validateUploadName(String filename) { |
||||
|
if (filename == null || filename.isBlank() || filename.length() > MAX_FILENAME_LENGTH) { |
||||
|
reject(); |
||||
|
} |
||||
|
for (int i = 0; i < filename.length(); i++) { |
||||
|
char c = filename.charAt(i); |
||||
|
if (Character.isISOControl(c) || c == '/' || c == '\\' || c == ':') { |
||||
|
reject(); |
||||
|
} |
||||
|
} |
||||
|
if (".".equals(filename) || "..".equals(filename)) { |
||||
|
reject(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 兼容历史数据的下载文件名净化。新上传文件已在入口处拒绝危险字符, |
||||
|
* 此方法为旧记录提供防御性兜底,不返回路径片段或换行。 |
||||
|
*/ |
||||
|
public static String forDownload(String filename, String fallback) { |
||||
|
String safeFallback = (fallback == null || fallback.isBlank()) ? "resume" : fallback; |
||||
|
if (filename == null || filename.isBlank()) { |
||||
|
return safeFallback; |
||||
|
} |
||||
|
StringBuilder safe = new StringBuilder(Math.min(filename.length(), MAX_FILENAME_LENGTH)); |
||||
|
for (int i = 0; i < filename.length() && safe.length() < MAX_FILENAME_LENGTH; i++) { |
||||
|
char c = filename.charAt(i); |
||||
|
if (Character.isISOControl(c) || c == '/' || c == '\\' || c == ':') { |
||||
|
safe.append('_'); |
||||
|
} else { |
||||
|
safe.append(c); |
||||
|
} |
||||
|
} |
||||
|
String result = safe.toString().trim(); |
||||
|
return result.isEmpty() || ".".equals(result) || "..".equals(result) ? safeFallback : result; |
||||
|
} |
||||
|
|
||||
|
private static void reject() { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,153 @@ |
|||||
|
package com.mydreamjob.profile; |
||||
|
|
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.profile.storage.ResumeStorageService; |
||||
|
import org.junit.jupiter.api.AfterEach; |
||||
|
import org.junit.jupiter.api.BeforeEach; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.boot.test.context.SpringBootTest; |
||||
|
import org.springframework.data.redis.core.RedisTemplate; |
||||
|
import org.springframework.data.redis.core.ValueOperations; |
||||
|
import org.springframework.mock.web.MockMultipartFile; |
||||
|
import org.springframework.test.context.ActiveProfiles; |
||||
|
import org.springframework.boot.test.mock.mockito.MockBean; |
||||
|
import org.springframework.transaction.PlatformTransactionManager; |
||||
|
import org.springframework.transaction.support.TransactionTemplate; |
||||
|
|
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.HashSet; |
||||
|
import java.util.List; |
||||
|
import java.util.Set; |
||||
|
import java.util.concurrent.atomic.AtomicLong; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals; |
||||
|
import static org.junit.jupiter.api.Assertions.assertEquals; |
||||
|
import static org.junit.jupiter.api.Assertions.assertThrows; |
||||
|
import static org.mockito.ArgumentMatchers.any; |
||||
|
import static org.mockito.Mockito.mock; |
||||
|
import static org.mockito.Mockito.when; |
||||
|
|
||||
|
@SpringBootTest |
||||
|
@ActiveProfiles("integration-test") |
||||
|
class ResumeUploadConsistencyIntegrationTest { |
||||
|
|
||||
|
private static final AtomicLong PHONE_SEQUENCE = new AtomicLong(800_000); |
||||
|
|
||||
|
@Autowired private ProfileService profileService; |
||||
|
@Autowired private StudentProfileRepository profileRepository; |
||||
|
@Autowired private UserRepository userRepository; |
||||
|
@Autowired private ResumeStorageService storageService; |
||||
|
@Autowired private PlatformTransactionManager transactionManager; |
||||
|
|
||||
|
@MockBean private RedisTemplate<String, Object> redisTemplate; |
||||
|
|
||||
|
private final List<String> testStoragePaths = new ArrayList<>(); |
||||
|
private Long userId; |
||||
|
|
||||
|
@BeforeEach |
||||
|
@SuppressWarnings("unchecked") |
||||
|
void setUp() { |
||||
|
ValueOperations<String, Object> operations = mock(ValueOperations.class); |
||||
|
when(redisTemplate.opsForValue()).thenReturn(operations); |
||||
|
when(redisTemplate.hasKey(any())).thenReturn(false); |
||||
|
|
||||
|
User user = User.builder() |
||||
|
.phone("177" + String.format("%08d", PHONE_SEQUENCE.incrementAndGet())) |
||||
|
.nickname("上传一致性测试") |
||||
|
.role(User.Role.STUDENT) |
||||
|
.active(true) |
||||
|
.verified(true) |
||||
|
.build(); |
||||
|
user.setRoles(new HashSet<>(Set.of(User.Role.STUDENT))); |
||||
|
userId = userRepository.saveAndFlush(user).getId(); |
||||
|
} |
||||
|
|
||||
|
@AfterEach |
||||
|
void tearDown() { |
||||
|
for (String path : testStoragePaths) { |
||||
|
storageService.delete(path); |
||||
|
} |
||||
|
profileRepository.findByUserId(userId).ifPresent(profileRepository::delete); |
||||
|
userRepository.findById(userId).ifPresent(userRepository::delete); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void successfulReplacementCommitsNewReferenceBeforeDeletingOldFile() { |
||||
|
String oldPath = store(pdf("old")); |
||||
|
saveProfile(oldPath, "old.pdf"); |
||||
|
|
||||
|
@SuppressWarnings("unchecked") |
||||
|
String newPath = (String) profileService.uploadResume(userId, multipart("new.pdf", pdf("new"))) |
||||
|
.get("resumeUrl"); |
||||
|
testStoragePaths.add(newPath); |
||||
|
|
||||
|
StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); |
||||
|
assertEquals(newPath, persisted.getResumeUrl()); |
||||
|
assertArrayEquals(pdf("new"), storageService.read(newPath)); |
||||
|
assertThrows(BusinessException.class, () -> storageService.read(oldPath)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void outerRollbackRestoresOldReferenceAndDeletesOnlyNewFile() { |
||||
|
String oldPath = store(pdf("old")); |
||||
|
saveProfile(oldPath, "old.pdf"); |
||||
|
TransactionTemplate transaction = new TransactionTemplate(transactionManager); |
||||
|
|
||||
|
String newPath = transaction.execute(status -> { |
||||
|
String stored = (String) profileService.uploadResume(userId, multipart("new.pdf", pdf("new"))) |
||||
|
.get("resumeUrl"); |
||||
|
testStoragePaths.add(stored); |
||||
|
status.setRollbackOnly(); |
||||
|
return stored; |
||||
|
}); |
||||
|
|
||||
|
StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); |
||||
|
assertEquals(oldPath, persisted.getResumeUrl()); |
||||
|
assertArrayEquals(pdf("old"), storageService.read(oldPath)); |
||||
|
assertThrows(BusinessException.class, () -> storageService.read(newPath)); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void deleteRollbackKeepsDatabaseReferenceAndStoredFile() { |
||||
|
String oldPath = store(pdf("old")); |
||||
|
saveProfile(oldPath, "old.pdf"); |
||||
|
TransactionTemplate transaction = new TransactionTemplate(transactionManager); |
||||
|
|
||||
|
transaction.executeWithoutResult(status -> { |
||||
|
profileService.deleteResume(userId); |
||||
|
status.setRollbackOnly(); |
||||
|
}); |
||||
|
|
||||
|
StudentProfile persisted = profileRepository.findByUserId(userId).orElseThrow(); |
||||
|
assertEquals(oldPath, persisted.getResumeUrl()); |
||||
|
assertArrayEquals(pdf("old"), storageService.read(oldPath)); |
||||
|
} |
||||
|
|
||||
|
private String store(byte[] content) { |
||||
|
String path = storageService.store(userId, multipart("resume.pdf", content)); |
||||
|
testStoragePaths.add(path); |
||||
|
return path; |
||||
|
} |
||||
|
|
||||
|
private void saveProfile(String path, String filename) { |
||||
|
profileRepository.saveAndFlush(StudentProfile.builder() |
||||
|
.userId(userId) |
||||
|
.resumeUrl(path) |
||||
|
.resumeOriginalName(filename) |
||||
|
.resumeContentType("application/pdf") |
||||
|
.resumeSize((long) pdf("old").length) |
||||
|
.build()); |
||||
|
} |
||||
|
|
||||
|
private MockMultipartFile multipart(String filename, byte[] content) { |
||||
|
return new MockMultipartFile("file", filename, "application/pdf", content); |
||||
|
} |
||||
|
|
||||
|
private byte[] pdf(String body) { |
||||
|
return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,142 @@ |
|||||
|
package com.mydreamjob.profile.storage; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.springframework.mock.web.MockMultipartFile; |
||||
|
|
||||
|
import java.io.ByteArrayOutputStream; |
||||
|
import java.io.IOException; |
||||
|
import java.util.zip.ZipEntry; |
||||
|
import java.util.zip.ZipOutputStream; |
||||
|
|
||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; |
||||
|
import static org.junit.jupiter.api.Assertions.assertThrows; |
||||
|
|
||||
|
class ResumeFileValidatorTest { |
||||
|
|
||||
|
private final ResumeFileValidator validator = new ResumeFileValidator(); |
||||
|
|
||||
|
@Test |
||||
|
void validPdfDocAndDocxAreAccepted() throws Exception { |
||||
|
assertDoesNotThrow(() -> validator.validate(file("resume.pdf", "application/pdf", pdf("profile")))); |
||||
|
assertDoesNotThrow(() -> validator.validate(file("resume.doc", "application/msword", legacyDoc()))); |
||||
|
assertDoesNotThrow(() -> validator.validate(file("resume.docx", docxType(), docx("docProps/core.xml", "profile")))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void fakePdfAndFakeLegacyDocAreRejected() { |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("resume.pdf", "application/pdf", "not a pdf".getBytes()))); |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("resume.doc", "application/msword", "not a doc".getBytes()))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void extensionContentTypeMismatchIsRejectedEvenWhenSignatureLooksValid() { |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("resume.pdf", "application/msword", pdf("profile")))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void arbitraryZipAndMissingDocumentPartAreRejected() throws Exception { |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("resume.docx", docxType(), |
||||
|
zip(new Entry("payload.txt", "not office"))))); |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("resume.docx", docxType(), |
||||
|
zip(new Entry("[Content_Types].xml", "types"))))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void docxPathTraversalEntryIsRejected() throws Exception { |
||||
|
byte[] malicious = zip( |
||||
|
new Entry("[Content_Types].xml", "types"), |
||||
|
new Entry("word/document.xml", "document"), |
||||
|
new Entry("../outside.txt", "secret")); |
||||
|
|
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("resume.docx", docxType(), malicious))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void highlyCompressedDocxPayloadIsRejected() throws Exception { |
||||
|
byte[] repeated = new byte[1024 * 1024]; |
||||
|
byte[] bomb = zip( |
||||
|
new Entry("[Content_Types].xml", "types"), |
||||
|
new Entry("word/document.xml", "document"), |
||||
|
new Entry("word/media/bomb.bin", repeated)); |
||||
|
|
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("resume.docx", docxType(), bomb))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void unsafeOrOversizedFilenameIsRejected() { |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("..\\resume.pdf", "application/pdf", pdf("profile")))); |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("resume\r\nX-Test.pdf", "application/pdf", pdf("profile")))); |
||||
|
assertThrows(BusinessException.class, |
||||
|
() -> validator.validate(file("a".repeat(252) + ".pdf", "application/pdf", pdf("profile")))); |
||||
|
} |
||||
|
|
||||
|
@Test |
||||
|
void downloadFilenameSanitizerProtectsLegacyMetadata() { |
||||
|
String safe = ResumeFilenameSanitizer.forDownload("..\\old\r\nInjected: value.pdf", "resume.pdf"); |
||||
|
org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\\")); |
||||
|
org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\r")); |
||||
|
org.junit.jupiter.api.Assertions.assertFalse(safe.contains("\n")); |
||||
|
org.junit.jupiter.api.Assertions.assertFalse(safe.contains(":")); |
||||
|
} |
||||
|
|
||||
|
static byte[] pdf(String body) { |
||||
|
return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(java.nio.charset.StandardCharsets.US_ASCII); |
||||
|
} |
||||
|
|
||||
|
static byte[] legacyDoc() { |
||||
|
byte[] marker = "WordDocument".getBytes(java.nio.charset.StandardCharsets.UTF_16LE); |
||||
|
byte[] bytes = new byte[512 + marker.length]; |
||||
|
byte[] signature = { |
||||
|
(byte) 0xD0, (byte) 0xCF, 0x11, (byte) 0xE0, |
||||
|
(byte) 0xA1, (byte) 0xB1, 0x1A, (byte) 0xE1 |
||||
|
}; |
||||
|
System.arraycopy(signature, 0, bytes, 0, signature.length); |
||||
|
System.arraycopy(marker, 0, bytes, 512, marker.length); |
||||
|
return bytes; |
||||
|
} |
||||
|
|
||||
|
static byte[] docx(String extraName, String extraContent) throws IOException { |
||||
|
return zip( |
||||
|
new Entry("[Content_Types].xml", "<Types/>"), |
||||
|
new Entry("word/document.xml", "<w:document/>"), |
||||
|
new Entry(extraName, extraContent)); |
||||
|
} |
||||
|
|
||||
|
private static byte[] zip(Entry... entries) throws IOException { |
||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream(); |
||||
|
try (ZipOutputStream zip = new ZipOutputStream(output)) { |
||||
|
for (Entry entry : entries) { |
||||
|
zip.putNextEntry(new ZipEntry(entry.name())); |
||||
|
if (entry.bytes() != null) { |
||||
|
zip.write(entry.bytes()); |
||||
|
} |
||||
|
zip.closeEntry(); |
||||
|
} |
||||
|
} |
||||
|
return output.toByteArray(); |
||||
|
} |
||||
|
|
||||
|
private static MockMultipartFile file(String name, String contentType, byte[] content) { |
||||
|
return new MockMultipartFile("file", name, contentType, content); |
||||
|
} |
||||
|
|
||||
|
private static String docxType() { |
||||
|
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; |
||||
|
} |
||||
|
|
||||
|
private record Entry(String name, byte[] bytes) { |
||||
|
Entry(String name, String content) { |
||||
|
this(name, content.getBytes(java.nio.charset.StandardCharsets.UTF_8)); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue