diff --git a/src/main/java/com/fowoco/server/file/application/FileService.java b/src/main/java/com/fowoco/server/file/application/FileService.java index 09ab2b52..a8f13d86 100644 --- a/src/main/java/com/fowoco/server/file/application/FileService.java +++ b/src/main/java/com/fowoco/server/file/application/FileService.java @@ -15,6 +15,7 @@ import com.fowoco.server.file.application.port.FileStorage; import com.fowoco.server.file.application.port.StoredFileRepository; import com.fowoco.server.file.application.validation.HwpSignatureValidator; +import com.fowoco.server.file.application.validation.HwpxSignatureValidator; import com.fowoco.server.file.domain.StoredFile; import com.fowoco.server.task.application.error.TaskErrorCode; import com.fowoco.server.task.application.port.TaskRepository; @@ -42,13 +43,14 @@ public class FileService { "image/jpeg", "image/png", "image/webp", - "application/pdf", - "application/hwp+zip" + "application/pdf" ); private static final String HWP_EXTENSION = ".hwp"; + private static final String HWPX_EXTENSION = ".hwpx"; private final StoredFileRepository storedFileRepository; private final HwpSignatureValidator hwpSignatureValidator; + private final HwpxSignatureValidator hwpxSignatureValidator; private final FileStorage fileStorage; private final TaskRepository taskRepository; private final WorkerRepository workerRepository; @@ -60,6 +62,7 @@ public class FileService { public FileService( StoredFileRepository storedFileRepository, HwpSignatureValidator hwpSignatureValidator, + HwpxSignatureValidator hwpxSignatureValidator, FileStorage fileStorage, TaskRepository taskRepository, WorkerRepository workerRepository, @@ -70,6 +73,7 @@ public FileService( ) { this.storedFileRepository = storedFileRepository; this.hwpSignatureValidator = hwpSignatureValidator; + this.hwpxSignatureValidator = hwpxSignatureValidator; this.fileStorage = fileStorage; this.taskRepository = taskRepository; this.workerRepository = workerRepository; @@ -91,6 +95,10 @@ public StoredFile upload(FileCreateCommand command, ActorContext actor, RequestM if (!hwpSignatureValidator.isValidHwp(contentBytes)) { throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); } + } else if (isHwpxExtension(command.name())) { + if (!hwpxSignatureValidator.isValidHwpx(contentBytes)) { + throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); + } } else if (!ALLOWED_MIME_TYPES.contains(command.mimeType())) { throw new ApiException(FileErrorCode.UNSUPPORTED_FILE_TYPE); } @@ -200,6 +208,10 @@ private boolean isHwpExtension(String name) { return name != null && name.toLowerCase(java.util.Locale.ROOT).endsWith(HWP_EXTENSION); } + private boolean isHwpxExtension(String name) { + return name != null && name.toLowerCase(java.util.Locale.ROOT).endsWith(HWPX_EXTENSION); + } + private byte[] readAllBytes(java.io.InputStream content) { try { return content.readAllBytes(); diff --git a/src/main/java/com/fowoco/server/file/application/validation/HwpxSignatureValidator.java b/src/main/java/com/fowoco/server/file/application/validation/HwpxSignatureValidator.java new file mode 100644 index 00000000..3f5edf70 --- /dev/null +++ b/src/main/java/com/fowoco/server/file/application/validation/HwpxSignatureValidator.java @@ -0,0 +1,55 @@ +package com.fowoco.server.file.application.validation; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import org.springframework.stereotype.Component; + +/** + * HWPX는 ZIP(OWPML) 구조이며, 정식 MIME 타입은 있지만(application/hwp+zip) + * 클라이언트가 보내는 값을 신뢰하지 않는다. 실제 압축 내부에 + * 최상위 "mimetype" 항목의 값이 "application/hwp+zip"인지, 그리고 + * 본문 콘텐츠("Contents/section0.xml" 또는 호환 경로)가 있는지 확인한다. + */ +@Component +public class HwpxSignatureValidator { + + private static final String MIMETYPE_ENTRY_NAME = "mimetype"; + private static final String EXPECTED_MIMETYPE = "application/hwp+zip"; + private static final String SECTION_ENTRY_PREFIX = "Contents/section"; + private static final String SECTION_ENTRY_SUFFIX = ".xml"; + + public boolean isValidHwpx(byte[] content) { + boolean mimetypeMatched = false; + boolean sectionFound = false; + + try (ZipInputStream zipInputStream = new ZipInputStream(new ByteArrayInputStream(content))) { + ZipEntry entry; + while ((entry = zipInputStream.getNextEntry()) != null) { + String entryName = entry.getName(); + if (MIMETYPE_ENTRY_NAME.equals(entryName)) { + mimetypeMatched = EXPECTED_MIMETYPE.equals(readEntryAsString(zipInputStream).strip()); + } else if (isSectionEntry(entryName)) { + sectionFound = true; + } + } + } catch (IOException | IllegalArgumentException exception) { + return false; + } + + return mimetypeMatched && sectionFound; + } + + private boolean isSectionEntry(String entryName) { + return entryName != null + && entryName.startsWith(SECTION_ENTRY_PREFIX) + && entryName.endsWith(SECTION_ENTRY_SUFFIX); + } + + private String readEntryAsString(InputStream stream) throws IOException { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } +} diff --git a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java index f2831419..84ba04bc 100644 --- a/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java +++ b/src/test/java/com/fowoco/server/file/FileSecurityIntegrationTest.java @@ -148,17 +148,65 @@ void uploadRejectsUnsupportedMimeType() throws Exception { } @Test - void uploadAcceptsHwpxMimeType() throws Exception { + void uploadAcceptsValidHwpxStructure() throws Exception { String token = accessToken(login(HR_A_EMAIL)); + byte[] hwpxContent = buildValidHwpxZip(); HttpResponse response = uploadFile( - token, "contract.hwpx", "application/hwp+zip", "hwpx content".getBytes(StandardCharsets.UTF_8), "GENERAL" + token, "contract.hwpx", "application/octet-stream", hwpxContent, "GENERAL" ); assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(201); assertThat(JsonPath.read(response.body(), "$.name")).isEqualTo("contract.hwpx"); } + @Test + void uploadRejectsHwpxExtensionWithFakeContent() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + + HttpResponse response = uploadFile( + token, "fake.hwpx", "application/hwp+zip", + "hwpx content".getBytes(StandardCharsets.UTF_8), "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(415); + } + + @Test + void uploadRejectsZipWithoutHwpxContents() throws Exception { + String token = accessToken(login(HR_A_EMAIL)); + byte[] plainZip = buildZipWithoutHwpxContents(); + + HttpResponse response = uploadFile( + token, "notreally.hwpx", "application/octet-stream", plainZip, "GENERAL" + ); + + assertThat(response.statusCode()).as("body: %s", response.body()).isEqualTo(415); + } + + private byte[] buildValidHwpxZip() throws Exception { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try (java.util.zip.ZipOutputStream zip = new java.util.zip.ZipOutputStream(out)) { + zip.putNextEntry(new java.util.zip.ZipEntry("mimetype")); + zip.write("application/hwp+zip".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + zip.putNextEntry(new java.util.zip.ZipEntry("Contents/section0.xml")); + zip.write("placeholder".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return out.toByteArray(); + } + + private byte[] buildZipWithoutHwpxContents() throws Exception { + java.io.ByteArrayOutputStream out = new java.io.ByteArrayOutputStream(); + try (java.util.zip.ZipOutputStream zip = new java.util.zip.ZipOutputStream(out)) { + zip.putNextEntry(new java.util.zip.ZipEntry("readme.txt")); + zip.write("just a plain zip file".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + return out.toByteArray(); + } + @Test void uploadAcceptsValidHwpFileBySignature() throws Exception { String token = accessToken(login(HR_A_EMAIL)); diff --git a/src/test/java/com/fowoco/server/file/application/validation/HwpxSignatureValidatorTest.java b/src/test/java/com/fowoco/server/file/application/validation/HwpxSignatureValidatorTest.java new file mode 100644 index 00000000..fa11761b --- /dev/null +++ b/src/test/java/com/fowoco/server/file/application/validation/HwpxSignatureValidatorTest.java @@ -0,0 +1,70 @@ +package com.fowoco.server.file.application.validation; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import org.junit.jupiter.api.Test; + +class HwpxSignatureValidatorTest { + + private final HwpxSignatureValidator validator = new HwpxSignatureValidator(); + + @Test + void acceptsValidHwpxStructure() throws Exception { + byte[] content = buildZip("application/hwp+zip", "Contents/section0.xml"); + + assertThat(validator.isValidHwpx(content)).isTrue(); + } + + @Test + void rejectsWrongMimetypeEntry() throws Exception { + byte[] content = buildZip("application/zip", "Contents/section0.xml"); + + assertThat(validator.isValidHwpx(content)).isFalse(); + } + + @Test + void rejectsMissingSectionXml() throws Exception { + byte[] content = buildZip("application/hwp+zip", null); + + assertThat(validator.isValidHwpx(content)).isFalse(); + } + + @Test + void rejectsNonZipContent() { + byte[] content = "not a zip file at all".getBytes(StandardCharsets.UTF_8); + + assertThat(validator.isValidHwpx(content)).isFalse(); + } + + @Test + void rejectsEmptyContent() { + assertThat(validator.isValidHwpx(new byte[0])).isFalse(); + } + + @Test + void acceptsAlternateSectionNumber() throws Exception { + byte[] content = buildZip("application/hwp+zip", "Contents/section1.xml"); + + assertThat(validator.isValidHwpx(content)).isTrue(); + } + + private byte[] buildZip(String mimetypeValue, String sectionEntryName) throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ZipOutputStream zip = new ZipOutputStream(out)) { + zip.putNextEntry(new ZipEntry("mimetype")); + zip.write(mimetypeValue.getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + + if (sectionEntryName != null) { + zip.putNextEntry(new ZipEntry(sectionEntryName)); + zip.write("placeholder".getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + } + } + return out.toByteArray(); + } +}