Browse Source

fix: harden resume upload safety

fix/resume-upload-safety
Zhang Shun 1 month ago
parent
commit
96a920b698
  1. 2
      docs/frontend-integration.md
  2. 8
      docs/resume-api.md
  3. 3
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java
  4. 108
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java
  5. 16
      mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java
  6. 216
      mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFileValidator.java
  7. 54
      mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java
  8. 3
      mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java
  9. 6
      mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java
  10. 16
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java
  11. 66
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java
  12. 153
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeUploadConsistencyIntegrationTest.java
  13. 29
      mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java
  14. 142
      mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFileValidatorTest.java
  15. 3
      mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java
  16. 19
      mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java

2
docs/frontend-integration.md

@ -447,6 +447,8 @@ Authorization: Bearer <accessToken>
- 值可能是**相对存储路径**或文件存储地址,**不保证是完整的 `http/https` URL**。
- 前端**下载简历应调用 `GET /api/v1/profile/me/resume`**,不要直接拼接 `resumeUrl` 做请求地址。
- 上传仍仅接受 PDF/DOC/DOCX 且不超过 10MB;服务端会同时校验扩展名、Content-Type 和实际文件结构。伪装扩展名、损坏文件、危险 DOCX 压缩包或含路径/控制字符的文件名返回 400,前端不要仅依据浏览器 MIME 判断文件有效。
- 上传替换失败时原简历保持可用;成功提交后旧文件才会清理。该流程不改变现有请求字段或响应结构。
### 12.12 graduationYear 特别说明

8
docs/resume-api.md

@ -48,7 +48,7 @@ POST /profile/me/resume-versions (multipart/form-data)
| 字段 | 必填 | 约束 |
|---|---|---|
| `file` | 是 | PDF/DOC/DOCX,≤10MB,扩展名与 Content-Type 一致 |
| `file` | 是 | PDF/DOC/DOCX,≤10MB;扩展名、Content-Type 与实际文件结构必须一致 |
| `name` | 否 | ≤120 字符,可空 |
默认状态 `ACTIVE`、**非默认**;版本号自动分配(`max+1`)。并发创建由 DB 唯一约束兜底,冲突自动重试(独立事务)。
@ -157,6 +157,10 @@ GET /resume-export-tasks/{taskId}/download
- 身份一律来自 JWT(`auth.getName()` = userId),**不信任请求体/路径中的 userId**;
- 不能读取其他用户的简历(owner 校验);
- 不返回内部堆栈;错误响应不泄露数据库结构。
- 上传文件名拒绝路径分隔符、控制字符和响应头换行;下载历史文件时再次净化文件名。
- PDF 校验文件头和结尾标记;DOC 校验 OLE/Word 文档结构标记;DOCX 校验 OOXML 必需条目,并限制 ZIP 条目数、解压总量和压缩比,拒绝路径穿越及压缩炸弹。
- 文件替换采用“新文件落盘 → 数据库更新 → 事务提交后删除旧文件”;回滚时清理新文件。底层本地存储先写 `.uploading` 临时文件,再原子移动到最终随机存储名,失败不暴露半文件。
- 上述措施属于格式与资源安全校验,**不等同于病毒查杀或沙箱检测**;生产部署仍应在存储接入层配置恶意文件扫描。
## 5. 异步导出设计
@ -199,6 +203,7 @@ GET /resume-export-tasks/{taskId}/download
4. **默认简历仅作用于版本体系**:不联动旧 `student_profiles.resume_url`(按确认保持独立)。
5. **被导出任务引用的旧文件可能延迟清理**:替换版本文件时,若旧文件正被 PENDING/RUNNING 导出任务引用则保留(保导出一致性);任务结束后这些文件不会自动回收,需后续清理策略。
6. **OpenAPI 快照**:`docs/openapi-rc.json` 由 H2 集成测试上下文导出(**49 路径**,server URL 为测试默认值),接入现网后可用 `GET /api/v1/v3/api-docs` 重新导出。
7. **不包含恶意软件扫描**:当前只执行文件格式、OOXML 结构、路径和资源消耗防护;不对宏、嵌入对象或恶意载荷作杀毒结论。
## 10. 错误码(新增 82xx)
@ -210,4 +215,3 @@ GET /resume-export-tasks/{taskId}/download
| 8204 | `RESUME_EXPORT_IN_PROGRESS` | 409 | 导出任务进行中,不可删除版本 |
| 8205 | `DUPLICATE_DEFAULT_RESUME` | 409 | 并发设置默认冲突 |
| 8206 | `RESUME_EXPORT_NOT_READY` | 409 | 导出任务未完成,不可下载 |

3
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java

@ -2,6 +2,7 @@ package com.mydreamjob.profile;
import com.mydreamjob.profile.dto.StudentProfileResponse;
import com.mydreamjob.profile.dto.StudentProfileUpdateRequest;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.common.api.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
@ -115,7 +116,7 @@ public class ProfileController {
ProfileService.ResumeFile rf = profileService.getResume(userId);
MediaType mediaType = resolveContentType(rf.getContentType());
ContentDisposition cd = ContentDisposition.inline()
.filename(rf.getFilename(), StandardCharsets.UTF_8)
.filename(ResumeFilenameSanitizer.forDownload(rf.getFilename(), "resume"), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(mediaType)

108
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java

@ -6,6 +6,8 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.profile.dto.StudentProfileResponse;
import com.mydreamjob.profile.dto.StudentProfileUpdateRequest;
import com.mydreamjob.profile.storage.ResumeFileValidator;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
@ -14,12 +16,13 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
@Slf4j
@Service
@ -29,22 +32,10 @@ public class ProfileService {
private final StudentProfileRepository profileRepository;
private final ResumeStorageService resumeStorageService;
private final UserRepository userRepository;
private final ResumeFileValidator resumeFileValidator;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final Set<String> ALLOWED_CONTENT_TYPES = Set.of(
"application/pdf",
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"
);
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 long MAX_RESUME_SIZE = 10 * 1024 * 1024L;
// ── 角色校验 ──────────────────────────────────────────────
public void assertStudentRole(Long userId) {
@ -94,32 +85,7 @@ public class ProfileService {
// ── 简历校验 ──────────────────────────────────────────────
public void validateResumeFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
if (file.getSize() > MAX_RESUME_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String originalName = file.getOriginalFilename();
if (originalName == null || !originalName.contains(".")) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String ext = originalName.substring(originalName.lastIndexOf('.')).toLowerCase();
if (!ALLOWED_EXTENSIONS.contains(ext)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String contentType = file.getContentType();
if (contentType == null || !ALLOWED_CONTENT_TYPES.contains(contentType)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
// 验证扩展名与 Content-Type 一致
String expectedContentType = EXT_TO_CONTENT_TYPE.get(ext);
if (!contentType.equals(expectedContentType)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
resumeFileValidator.validate(file);
}
// ── 简历上传 ──────────────────────────────────────────────
@ -132,17 +98,27 @@ public class ProfileService {
StudentProfile profile = profileRepository.findByUserId(userId)
.orElse(StudentProfile.builder().userId(userId).build());
// 删除旧文件
if (profile.getResumeUrl() != null) {
resumeStorageService.delete(profile.getResumeUrl());
}
String oldPath = profile.getResumeUrl();
String newPath = resumeStorageService.store(userId, file);
if (newPath == null || newPath.isBlank()) {
throw new IllegalStateException("resume storage returned no path");
}
boolean synchronizationRegistered = registerReplacementLifecycle(oldPath, newPath);
profile.setResumeUrl(newPath);
profile.setResumeOriginalName(file.getOriginalFilename());
profile.setResumeOriginalName(ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume"));
profile.setResumeContentType(file.getContentType());
profile.setResumeSize(file.getSize());
profileRepository.save(profile);
try {
profileRepository.save(profile);
} catch (RuntimeException e) {
if (!synchronizationRegistered && !newPath.equals(oldPath)) {
resumeStorageService.delete(newPath);
}
throw e;
}
if (!synchronizationRegistered && oldPath != null && !oldPath.equals(newPath)) {
resumeStorageService.delete(oldPath);
}
return toResumeView(profile);
}
@ -170,12 +146,13 @@ public class ProfileService {
StudentProfile profile = profileRepository.findByUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND));
if (profile.getResumeUrl() != null) {
resumeStorageService.delete(profile.getResumeUrl());
String storagePath = profile.getResumeUrl();
profile.setResumeUrl(null);
profile.setResumeOriginalName(null);
profile.setResumeContentType(null);
profile.setResumeSize(null);
profileRepository.save(profile);
deleteFileAfterCommit(storagePath);
}
}
@ -242,4 +219,39 @@ public class ProfileService {
this.content = content;
}
}
private boolean registerReplacementLifecycle(String oldPath, String newPath) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
return false;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
if (oldPath != null && !oldPath.equals(newPath)) {
resumeStorageService.delete(oldPath);
}
}
@Override
public void afterCompletion(int status) {
if (status != TransactionSynchronization.STATUS_COMMITTED && !newPath.equals(oldPath)) {
resumeStorageService.delete(newPath);
}
}
});
return true;
}
private void deleteFileAfterCommit(String storagePath) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
resumeStorageService.delete(storagePath);
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
resumeStorageService.delete(storagePath);
}
});
}
}

16
mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/LocalResumeStorageService.java

@ -49,15 +49,27 @@ public class LocalResumeStorageService implements ResumeStorageService {
String ext = getExtension(originalName);
String storedName = userId + "_" + UUID.randomUUID().toString().substring(0, 8) + ext;
Path target = basePath.resolve(storedName).normalize();
Path temporary = basePath.resolve(storedName + ".uploading").normalize();
// 禁止路径穿越
if (!target.startsWith(basePath)) {
if (!target.startsWith(basePath) || !temporary.startsWith(basePath)) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
try {
Files.copy(file.getInputStream(), target, StandardCopyOption.REPLACE_EXISTING);
// Never expose a partially copied file under its final storage key.
Files.copy(file.getInputStream(), temporary, StandardCopyOption.REPLACE_EXISTING);
try {
Files.move(temporary, target, StandardCopyOption.ATOMIC_MOVE);
} catch (java.nio.file.AtomicMoveNotSupportedException ignored) {
Files.move(temporary, target, StandardCopyOption.REPLACE_EXISTING);
}
} catch (IOException e) {
try {
Files.deleteIfExists(temporary);
} catch (IOException cleanupFailure) {
e.addSuppressed(cleanupFailure);
}
throw new RuntimeException("Failed to store resume", e);
}
log.info("Resume stored: userId={}, path={}", userId, storedName);

216
mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFileValidator.java

@ -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);
}
}

54
mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java

@ -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);
}
}

3
mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionController.java

@ -3,6 +3,7 @@ package com.mydreamjob.resume;
import com.mydreamjob.common.api.ApiResponse;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
@ -127,7 +128,7 @@ public class ResumeVersionController {
ResumeExportService.DownloadFile df = resumeExportService.download(userId, taskId);
MediaType mediaType = resolveContentType(df.getContentType());
ContentDisposition cd = ContentDisposition.attachment()
.filename(df.getFilename(), StandardCharsets.UTF_8)
.filename(ResumeFilenameSanitizer.forDownload(df.getFilename(), "resume"), StandardCharsets.UTF_8)
.build();
return ResponseEntity.ok()
.contentType(mediaType)

6
mydreamjob-server/src/main/java/com/mydreamjob/resume/ResumeVersionService.java

@ -3,6 +3,7 @@ package com.mydreamjob.resume;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.profile.ProfileService;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.profile.storage.ResumeStorageService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -61,7 +62,8 @@ public class ResumeVersionService {
for (int attempt = 0; attempt < MAX_VERSION_NO_RETRY; attempt++) {
try {
return versionWriter.saveNewVersion(userId, normalizeName(name), storagePath,
file.getOriginalFilename(), file.getContentType(), file.getSize());
ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume"),
file.getContentType(), file.getSize());
} catch (DataIntegrityViolationException e) {
// uk_resume_versions_user_no 并发冲突 → 短退避后重试
log.info("Resume version_no conflict for user {}, retry {}/{}", userId, attempt + 1, MAX_VERSION_NO_RETRY);
@ -129,7 +131,7 @@ public class ResumeVersionService {
// 1) 先保存新文件(绝不在保存前删除旧文件)
newStoragePath = resumeStorageService.store(userId, file);
version.setFileStoragePath(newStoragePath);
version.setFileName(file.getOriginalFilename());
version.setFileName(ResumeFilenameSanitizer.forDownload(file.getOriginalFilename(), "resume"));
version.setContentType(file.getContentType());
version.setFileSize(file.getSize());
// 2) 注册事务回调:提交后删旧文件;回滚时清理新文件

16
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeControllerTest.java

@ -221,4 +221,20 @@ class ResumeControllerTest {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"),
resp.getHeaders().getContentType());
}
@Test
void downloadResume_legacyUnsafeFilenameCannotInjectResponseHeader() {
mockAuth(STUDENT_ID);
ProfileService.ResumeFile rf = new ProfileService.ResumeFile(
"..\\resume\r\nX-Injected: secret.pdf", "application/pdf", "data".getBytes());
when(profileService.getResume(STUDENT_ID)).thenReturn(rf);
var resp = controller.downloadResume(request);
String contentDisposition = resp.getHeaders().getFirst(HttpHeaders.CONTENT_DISPOSITION);
assertNotNull(contentDisposition);
assertFalse(contentDisposition.contains("\r"));
assertFalse(contentDisposition.contains("\n"));
assertFalse(contentDisposition.contains("X-Injected:"));
}
}

66
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java

@ -3,6 +3,7 @@ package com.mydreamjob.profile;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.profile.storage.ResumeFileValidator;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import org.junit.jupiter.api.BeforeEach;
@ -10,6 +11,7 @@ import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Spy;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import org.springframework.web.multipart.MultipartFile;
@ -32,6 +34,9 @@ class ResumeServiceTest {
@Mock
private UserRepository userRepository;
@Spy
private ResumeFileValidator resumeFileValidator = new ResumeFileValidator();
@InjectMocks
private ProfileService profileService;
@ -70,7 +75,7 @@ class ResumeServiceTest {
void uploadResume_asStudent_shouldSucceed() {
mockStudentRole();
MultipartFile file = new MockMultipartFile("file", "resume.pdf",
"application/pdf", "content".getBytes());
"application/pdf", validPdf("content"));
when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile));
when(storageService.store(eq(STUDENT_ID), any())).thenReturn("new_resume.pdf");
@ -88,7 +93,7 @@ class ResumeServiceTest {
void uploadResume_noExistingProfile_shouldCreateProfile() {
mockStudentRole();
MultipartFile file = new MockMultipartFile("file", "r.pdf",
"application/pdf", "content".getBytes());
"application/pdf", validPdf("content"));
when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.empty());
when(storageService.store(eq(STUDENT_ID), any())).thenReturn("r.pdf");
@ -159,6 +164,58 @@ class ResumeServiceTest {
() -> profileService.uploadResume(STUDENT_ID, file));
}
@Test
void uploadResume_fakePdfContent_shouldReturn400AndNotStore() {
mockStudentRole();
MultipartFile file = new MockMultipartFile("file", "resume.pdf",
"application/pdf", "<script>not pdf</script>".getBytes());
assertThrows(BusinessException.class,
() -> profileService.uploadResume(STUDENT_ID, file));
verify(storageService, never()).store(anyLong(), any());
}
@Test
void uploadResume_storageFailure_keepsOldFile() {
mockStudentRole();
MultipartFile file = new MockMultipartFile("file", "resume.pdf",
"application/pdf", validPdf("content"));
when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile));
when(storageService.store(eq(STUDENT_ID), any())).thenThrow(new RuntimeException("disk full"));
assertThrows(RuntimeException.class, () -> profileService.uploadResume(STUDENT_ID, file));
verify(storageService, never()).delete("old_resume.pdf");
verify(profileRepository, never()).save(any());
}
@Test
void uploadResume_databaseFailure_cleansNewFileAndKeepsOldFile() {
mockStudentRole();
MultipartFile file = new MockMultipartFile("file", "resume.pdf",
"application/pdf", validPdf("content"));
when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile));
when(storageService.store(eq(STUDENT_ID), any())).thenReturn("new_resume.pdf");
when(profileRepository.save(any())).thenThrow(new RuntimeException("database unavailable"));
assertThrows(RuntimeException.class, () -> profileService.uploadResume(STUDENT_ID, file));
verify(storageService).delete("new_resume.pdf");
verify(storageService, never()).delete("old_resume.pdf");
}
@Test
void deleteResume_databaseFailure_keepsStoredFile() {
mockStudentRole();
when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile));
when(profileRepository.save(any())).thenThrow(new RuntimeException("database unavailable"));
assertThrows(RuntimeException.class, () -> profileService.deleteResume(STUDENT_ID));
verify(storageService, never()).delete("old_resume.pdf");
}
// ── 获取 / 删除 ──────────────────────────────────────────
@Test
@ -187,4 +244,9 @@ class ResumeServiceTest {
assertNull(profile.getResumeContentType());
assertNull(profile.getResumeSize());
}
private byte[] validPdf(String content) {
return ("%PDF-1.7\n" + content + "\n%%EOF\n")
.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
}
}

153
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeUploadConsistencyIntegrationTest.java

@ -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);
}
}

29
mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/LocalResumeStorageServiceTest.java

@ -9,7 +9,8 @@ import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.io.IOException;
import java.io.InputStream;
import static org.junit.jupiter.api.Assertions.*;
@ -105,6 +106,32 @@ class LocalResumeStorageServiceTest {
assertNotNull(path);
}
@Test
void store_interruptedCopy_removesTemporaryAndFinalFiles() throws Exception {
MultipartFile file = new MockMultipartFile("file", "resume.pdf", "application/pdf", "ignored".getBytes()) {
@Override
public InputStream getInputStream() {
return new InputStream() {
private final byte[] content = "partial-content".getBytes();
private int position;
@Override
public int read() throws IOException {
if (position >= 4) {
throw new IOException("simulated interrupted upload");
}
return content[position++];
}
};
}
};
assertThrows(RuntimeException.class, () -> service.store(6L, file));
try (var files = Files.list(tempDir)) {
assertEquals(0, files.count(), "failed upload must leave neither final nor .uploading files");
}
}
// ── 文件不存在 ────────────────────────────────────────────
@Test

142
mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFileValidatorTest.java

@ -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));
}
}
}

3
mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPermissionControllerTest.java

@ -57,7 +57,8 @@ class ResumeVersionPermissionControllerTest {
ownerUserId = createStudent();
otherUserId = createStudent();
MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf",
"%PDF-1.7\npermission-fixture\n%%EOF\n".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
ResumeVersion version = resumeVersionService.createVersion(ownerUserId, "秋招版", file);
versionId = version.getId();
}

19
mydreamjob-server/src/test/java/com/mydreamjob/resume/ResumeVersionPersistenceIntegrationTest.java

@ -162,7 +162,7 @@ class ResumeVersionPersistenceIntegrationTest {
ExecutorService pool = Executors.newFixedThreadPool(2);
CountDownLatch start = new CountDownLatch(1);
AtomicReference<Throwable> failure = new AtomicReference<>();
MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", "data".getBytes());
MockMultipartFile file = new MockMultipartFile("file", "r.pdf", "application/pdf", pdf("data"));
pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "a", file), failure));
pool.submit(() -> runWithLatch(start, () -> resumeVersionService.createVersion(userId, "b", file), failure));
start.countDown();
@ -183,8 +183,10 @@ class ResumeVersionPersistenceIntegrationTest {
*/
@Test
void export_afterVersionModified_shouldUseOriginalFile() throws Exception {
MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", "AAA".getBytes());
MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", "BBB".getBytes());
byte[] contentA = pdf("AAA");
byte[] contentB = pdf("BBB");
MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", contentA);
MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", contentB);
ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA);
ResumeExportTask task = resumeExportService.createExportTask(userId, v1.getId()); // 快照 = A 的路径
@ -194,7 +196,7 @@ class ResumeVersionPersistenceIntegrationTest {
ResumeExportTask done = awaitTerminal(task.getId());
assertEquals(ResumeExportTask.STATUS_SUCCESS, done.getStatus(), "导出应成功: " + done.getErrorMessage());
assertArrayEquals("AAA".getBytes(), resumeExportStorageService.read(done.getOutputPath()));
assertArrayEquals(contentA, resumeExportStorageService.read(done.getOutputPath()));
assertEquals("r.pdf", done.getOutputName());
}
@ -216,8 +218,8 @@ class ResumeVersionPersistenceIntegrationTest {
*/
@Test
void updateVersion_outerRollback_shouldCleanNewFileAndKeepOldFileAndDb() throws Exception {
MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", "AAA".getBytes());
MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", "BBB".getBytes());
MockMultipartFile fileA = new MockMultipartFile("file", "r.pdf", "application/pdf", pdf("AAA"));
MockMultipartFile fileB = new MockMultipartFile("file", "r2.pdf", "application/pdf", pdf("BBB"));
ResumeVersion v1 = resumeVersionService.createVersion(userId, "v1", fileA);
String pathA = v1.getFileStoragePath();
List<String> before = listMyResumeFiles();
@ -260,4 +262,9 @@ class ResumeVersionPersistenceIntegrationTest {
failure.compareAndSet(null, t);
}
}
private byte[] pdf(String content) {
return ("%PDF-1.7\n" + content + "\n%%EOF\n")
.getBytes(java.nio.charset.StandardCharsets.US_ASCII);
}
}

Loading…
Cancel
Save