diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e8c980..6ab083fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ - KPI 스냅샷 증거를 다시 불러오는 `refreshKpiEvidence` 동작 중에 "Refresh evidence" 버튼을 비활성화하고 "Refreshing..." 이라는 피드백을 제공하여 사용자의 중복 클릭을 방지했습니다. - 버튼 상태 변경 시 내부 DOM 구조를 보존하기 위해 `Array.from(button.childNodes)`로 원래 노드를 저장하고, 성공 및 실패 후 `finally` 블록에서 `replaceChildren(...)`으로 안전하게 복원하도록 구현했습니다. +### Fixed +- `DefaultDocumentValidationService`의 `java.nio.file.Path.of` 기반 파일명 추출 로직을 `StringUtils.cleanPath()`로 변경하여 경로 탐색(Path Traversal) 공격 방지 강화 +- `tokenFingerprint` 메서드에서 인자가 null일 때 발생하는 `NullPointerException` 방지 로직 추가 ## [0.1.0] - 2026-06-25 diff --git a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java index 95e67228..a0178635 100644 --- a/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java +++ b/src/main/java/com/clearfolio/viewer/service/DefaultDocumentValidationService.java @@ -145,11 +145,12 @@ private String extensionOf(final String fileName) { throw new IllegalArgumentException("File name contains null byte."); } - java.nio.file.Path leafName = java.nio.file.Path.of(fileName.strip()).getFileName(); - if (leafName == null) { + String cleanPath = org.springframework.util.StringUtils.cleanPath(fileName.strip()); + int lastSlash = cleanPath.lastIndexOf('/'); + String normalized = lastSlash != -1 ? cleanPath.substring(lastSlash + 1) : cleanPath; + if (normalized.isEmpty()) { return ""; } - String normalized = leafName.toString(); int lastDot = normalized.lastIndexOf('.'); if (lastDot <= 0 || lastDot == normalized.length() - 1) { return ""; @@ -199,6 +200,9 @@ private byte[] computeSignature(String approverId, String extension, String secr } private String tokenFingerprint(String approvalToken) { + if (approvalToken == null) { + return null; + } try { MessageDigest digest = MessageDigest.getInstance("SHA-256"); byte[] hashed = digest.digest(approvalToken.getBytes(StandardCharsets.UTF_8)); diff --git a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java index 4f27bdcc..d37b4ff5 100644 --- a/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java +++ b/src/test/java/com/clearfolio/viewer/service/DefaultDocumentValidationServiceTest.java @@ -32,7 +32,7 @@ void sanitizeFilenameReturnsNullWhenFilenameIsNull() throws Exception { DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); method.setAccessible(true); - String sanitized = (String) method.invoke(validationService, new Object[] {null}); + String sanitized = (String) method.invoke(validationService, (Object) null); assertNull(sanitized); } @@ -47,6 +47,46 @@ void sanitizeFilenameReturnsCleanPathWhenNoSlashIsPresent() throws Exception { assertEquals("simple-file.txt", sanitized); } + @Test + void sanitizeFilenameReturnsCleanPathWhenMultipleSlashesArePresent() throws Exception { + ConversionProperties conversionProperties = new ConversionProperties(); + DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); + java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); + method.setAccessible(true); + String sanitized = (String) method.invoke(validationService, "some/nested/path/file.txt"); + assertEquals("file.txt", sanitized); + } + + @Test + void sanitizeFilenameReturnsCleanPathWhenPathTraversalIsAttempted() throws Exception { + ConversionProperties conversionProperties = new ConversionProperties(); + DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); + java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("sanitizeFilename", String.class); + method.setAccessible(true); + String sanitized = (String) method.invoke(validationService, "../../../etc/passwd.txt"); + assertEquals("passwd.txt", sanitized); + } + + @Test + void tokenFingerprintReturnsNullWhenApprovalTokenIsNull() throws Exception { + ConversionProperties conversionProperties = new ConversionProperties(); + DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); + java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("tokenFingerprint", String.class); + method.setAccessible(true); + String fingerprint = (String) method.invoke(validationService, (Object) null); + assertNull(fingerprint); + } + + @Test + void tokenFingerprintReturnsHashWhenApprovalTokenIsProvided() throws Exception { + ConversionProperties conversionProperties = new ConversionProperties(); + DefaultDocumentValidationService validationService = new DefaultDocumentValidationService(conversionProperties); + java.lang.reflect.Method method = DefaultDocumentValidationService.class.getDeclaredMethod("tokenFingerprint", String.class); + method.setAccessible(true); + String fingerprint = (String) method.invoke(validationService, "some-token"); + // The exact hash doesn't matter for this test, just that it's not null and of expected length + org.junit.jupiter.api.Assertions.assertNotNull(fingerprint); + } @Test void stripsDirectoryTraversalFromFilename() {