From 28f1d897c52552a19a513de21149fc18e95d66a8 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 7 Aug 2026 15:29:54 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(file):=20HwpxSignatureValidator=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80,=20FileService=EC=97=90=20.hwpx=20=EC=A0=84?= =?UTF-8?q?=EC=9A=A9=20=EA=B2=80=EC=A6=9D=20=EB=B6=84=EA=B8=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/file/application/FileService.java | 16 +++++- .../validation/HwpxSignatureValidator.java | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/fowoco/server/file/application/validation/HwpxSignatureValidator.java 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); + } +} From 96c2cdcc57091969f0693957d72b802f48ad8031 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 7 Aug 2026 15:38:06 +0900 Subject: [PATCH 2/3] =?UTF-8?q?test(file):=20HwpxSignatureValidator=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=206=EA=B0=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HwpxSignatureValidatorTest.java | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/test/java/com/fowoco/server/file/application/validation/HwpxSignatureValidatorTest.java 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(); + } +} From c0b264622850da2640752a9da2a44a5ee09944f1 Mon Sep 17 00:00:00 2001 From: chaelin Date: Fri, 7 Aug 2026 16:04:37 +0900 Subject: [PATCH 3/3] =?UTF-8?q?test(file):=20HWPX=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=20=EA=B2=80=EC=A6=9D=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=203=EA=B0=9C=20(=EC=A0=95=EC=83=81/?= =?UTF-8?q?=EA=B0=80=EC=A7=9CMIME/=EA=B5=AC=EC=A1=B0=EC=97=86=EC=9D=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/FileSecurityIntegrationTest.java | 52 ++++++++++++++++++- 1 file changed, 50 insertions(+), 2 deletions(-) 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));