From 7332d37657b5a106957827d3dec94f0560d9665a Mon Sep 17 00:00:00 2001 From: RosieOh Date: Tue, 22 Sep 2026 21:06:35 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=85=EB=A1=9C=EB=93=9C=20=ED=8C=8C?= =?UTF-8?q?=EC=9D=BC=20=EB=82=B4=EC=9A=A9(=EC=8B=9C=EA=B7=B8=EB=8B=88?= =?UTF-8?q?=EC=B2=98)=20=ED=99=95=EC=9D=B8=EA=B3=BC=20=EC=95=85=EC=84=B1?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EA=B2=80=EC=82=AC=20=EC=A7=80=EC=A0=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 확장자와 Content-Type 은 클라이언트가 정하는 값이라, HTML·SVG 를 .png 로 올리면 기존 검사를 모두 통과했다. 브라우저가 내용을 보고 문서로 해석하면 저장형 XSS 가 된다. - FileSignatureValidator: 앞 16바이트가 확장자 형식(jpg/png/gif/webp/heic/pdf)인지 확인. 400 C006 - FileScanner 검사 지점: 기본 NoOp, STORAGE_SCAN_ENABLED=true 면 ClamAV(clamd INSTREAM, 의존성 없음) 악성 400 C007, 검사기 장애는 받지 않는다(fail-closed) 503 C008 - 저장은 검사한 바이트 그대로 쓴다 (스트림을 다시 열지 않음) - docker compose --profile scan 으로 clamav 를 함께 띄울 수 있다 - docs/reference/file-upload-security.md S3 + 서명 URL 은 비용·계정 결정이 필요해 이 변경에 넣지 않았다. --- docker-compose.yml | 11 ++ docs/reference/file-upload-security.md | 47 ++++++++ .../carecode/core/exception/ErrorCode.java | 3 + .../core/storage/ClamAvFileScanner.java | 82 +++++++++++++ .../carecode/core/storage/FileScanner.java | 29 +++++ .../core/storage/FileSignatureValidator.java | 58 ++++++++++ .../core/storage/LocalFileStorageService.java | 51 ++++++++- .../core/storage/NoOpFileScanner.java | 18 +++ src/main/resources/application.yml | 7 ++ .../core/storage/ClamAvFileScannerTest.java | 108 ++++++++++++++++++ .../storage/FileSignatureValidatorTest.java | 47 ++++++++ .../LocalFileStorageServiceSecurityTest.java | 83 ++++++++++++++ 12 files changed, 539 insertions(+), 5 deletions(-) create mode 100644 docs/reference/file-upload-security.md create mode 100644 src/main/java/com/carecode/core/storage/ClamAvFileScanner.java create mode 100644 src/main/java/com/carecode/core/storage/FileScanner.java create mode 100644 src/main/java/com/carecode/core/storage/FileSignatureValidator.java create mode 100644 src/main/java/com/carecode/core/storage/NoOpFileScanner.java create mode 100644 src/test/java/com/carecode/core/storage/ClamAvFileScannerTest.java create mode 100644 src/test/java/com/carecode/core/storage/FileSignatureValidatorTest.java create mode 100644 src/test/java/com/carecode/core/storage/LocalFileStorageServiceSecurityTest.java diff --git a/docker-compose.yml b/docker-compose.yml index d9e4d12c..fb40d846 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -90,9 +90,20 @@ services: # `SEED_SAMPLE_DATA=true docker compose up` 처럼 셸에서 넘긴 값이 컨테이너까지 가도록 명시한다. SEED_SAMPLE_DATA: ${SEED_SAMPLE_DATA:-false} + + # 업로드 악성코드 검사. `STORAGE_SCAN_ENABLED=true docker compose --profile scan up` 으로 clamav 와 함께 켠다. + STORAGE_SCAN_ENABLED: ${STORAGE_SCAN_ENABLED:-false} + CLAMD_HOST: ${CLAMD_HOST:-clamav} volumes: - uploads:/app/uploads + # 선택: 업로드 악성코드 검사기. 시그니처 DB 를 받느라 첫 기동에 몇 분 걸리고 메모리를 1GB 넘게 쓴다. + clamav: + image: clamav/clamav:1.4 + profiles: ["scan"] + ports: + - "${CLAMD_PORT:-3310}:3310" + volumes: mariadb-data: uploads: diff --git a/docs/reference/file-upload-security.md b/docs/reference/file-upload-security.md new file mode 100644 index 00000000..0e502997 --- /dev/null +++ b/docs/reference/file-upload-security.md @@ -0,0 +1,47 @@ +# 업로드 파일 보안 + +> 관련 이슈: #49 + +업로드는 건강기록 첨부와 프로필 이미지 두 곳이다. 저장은 `FileStorageService` 하나를 거친다. + +## 검사 순서 + +| 단계 | 무엇을 | 실패 시 | +|------|--------|---------| +| 1. 크기 | 10MB (`STORAGE_MAX_FILE_SIZE`) | 400 | +| 2. 확장자 | jpg·jpeg·png·gif·webp·heic·pdf | 400 | +| 3. Content-Type | 위 형식의 MIME | 400 | +| 4. **내용(시그니처)** | 앞 16바이트가 확장자 형식인지 | 400 `C006` | +| 5. **악성코드** | ClamAV (켠 경우) | 400 `C007` / 검사기 장애 503 `C008` | + +2·3 은 클라이언트가 정하는 값이라 속일 수 있다. HTML·SVG 를 `.png` 로 올리면 둘 다 통과하고, +브라우저가 내용을 보고 문서로 해석하면 저장형 XSS 가 된다. 4 가 이것을 막는다. + +저장은 **검사한 바이트 그대로** 쓴다. 스트림을 다시 열면 검사한 것과 다른 내용이 저장될 여지가 있다. + +## 악성코드 검사 켜기 + +기본은 꺼져 있다(`NoOpFileScanner`). 켜면 clamd 의 INSTREAM 명령으로 검사한다(`ClamAvFileScanner`, 추가 의존성 없음). + +```bash +# 로컬 +STORAGE_SCAN_ENABLED=true docker compose --profile scan up --build +``` + +운영은 서버에 clamd 를 띄우고 `STORAGE_SCAN_ENABLED=true`, `CLAMD_HOST`, `CLAMD_PORT` 를 준다. + +**검사기에 닿지 못하면 업로드를 받지 않는다(fail-closed, 503).** 검사를 켠 환경에서 장애 때 조용히 +통과시키면 켠 의미가 없다. 검사기 가용성이 업로드 가용성이 된다는 뜻이므로 clamd 를 헬스체크 대상에 넣는다. + +## 내려줄 때 + +- 건강기록 첨부는 정적 경로(`/files/**`)로 공개하지 않는다. 소유권을 확인한 뒤 서버가 직접 내려준다. +- 항상 `Content-Disposition: attachment` — 브라우저가 페이지로 열지 않는다. +- Spring Security 기본 헤더 `X-Content-Type-Options: nosniff` 로 내용 추측을 막는다. +- 공개 경로는 프로필 이미지(`/files/profile-images/**`) 뿐이다. + +## 남은 일 + +- 외부 저장소(S3 등) + 서명 URL. 저장소는 `FileStorageService` 로 추상화돼 있어 구현체만 추가하면 된다. + 비용과 계정이 필요한 결정이라 따로 진행한다. 다중 인스턴스로 늘리기 전에는 반드시 필요하다 + (지금은 로컬 디스크라 인스턴스끼리 파일을 공유하지 못한다). diff --git a/src/main/java/com/carecode/core/exception/ErrorCode.java b/src/main/java/com/carecode/core/exception/ErrorCode.java index e4abfc36..086a3dd4 100644 --- a/src/main/java/com/carecode/core/exception/ErrorCode.java +++ b/src/main/java/com/carecode/core/exception/ErrorCode.java @@ -14,6 +14,9 @@ public enum ErrorCode { FORBIDDEN("C003", "접근 권한이 없습니다", HttpStatus.FORBIDDEN), RESOURCE_NOT_FOUND("C004", "요청한 리소스를 찾을 수 없습니다", HttpStatus.NOT_FOUND), RATE_LIMIT_EXCEEDED("C005", "요청이 너무 많습니다. 잠시 후 다시 시도해주세요", HttpStatus.TOO_MANY_REQUESTS), + FILE_CONTENT_MISMATCH("C006", "파일 내용이 확장자와 맞지 않습니다", HttpStatus.BAD_REQUEST), + FILE_REJECTED_BY_SCAN("C007", "보안 검사에서 차단된 파일입니다", HttpStatus.BAD_REQUEST), + FILE_SCAN_UNAVAILABLE("C008", "파일 보안 검사를 할 수 없어 업로드를 받지 않았습니다. 잠시 후 다시 시도해주세요", HttpStatus.SERVICE_UNAVAILABLE), // ===== 사용자 관련 에러 (U000) ===== USER_NOT_FOUND("U001", "사용자를 찾을 수 없습니다", HttpStatus.NOT_FOUND), diff --git a/src/main/java/com/carecode/core/storage/ClamAvFileScanner.java b/src/main/java/com/carecode/core/storage/ClamAvFileScanner.java new file mode 100644 index 00000000..d85cda45 --- /dev/null +++ b/src/main/java/com/carecode/core/storage/ClamAvFileScanner.java @@ -0,0 +1,82 @@ +package com.carecode.core.storage; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; + +/** + * clamd 의 INSTREAM 명령으로 검사한다. 별도 라이브러리 없이 TCP 로 말한다. + * + *

프로토콜: {@code zINSTREAM\0} 다음에 [4바이트 길이 + 데이터] 조각들, 마지막에 길이 0. + * 응답은 {@code stream: OK} 또는 {@code stream: <이름> FOUND}. + */ +@Slf4j +@Component +@ConditionalOnProperty(name = "app.storage.scan.enabled", havingValue = "true") +public class ClamAvFileScanner implements FileScanner { + + private static final int CHUNK = 64 * 1024; + + private final String host; + private final int port; + private final int timeoutMillis; + + public ClamAvFileScanner( + @Value("${app.storage.scan.clamd.host:localhost}") String host, + @Value("${app.storage.scan.clamd.port:3310}") int port, + @Value("${app.storage.scan.clamd.timeout-millis:10000}") int timeoutMillis) { + this.host = host; + this.port = port; + this.timeoutMillis = timeoutMillis; + } + + @Override + public ScanResult scan(byte[] content) { + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(host, port), timeoutMillis); + socket.setSoTimeout(timeoutMillis); + + OutputStream out = socket.getOutputStream(); + out.write("zINSTREAM\0".getBytes(StandardCharsets.US_ASCII)); + for (int offset = 0; offset < content.length; offset += CHUNK) { + int length = Math.min(CHUNK, content.length - offset); + out.write(ByteBuffer.allocate(4).putInt(length).array()); + out.write(content, offset, length); + } + out.write(new byte[4]); + out.flush(); + + String reply = readReply(socket.getInputStream()); + if (reply.endsWith("OK")) { + return ScanResult.ok(); + } + if (reply.endsWith("FOUND")) { + String threat = reply.replaceFirst("^stream:\s*", "").replaceFirst("\s*FOUND$", ""); + log.warn("업로드 파일에서 악성코드 탐지: {}", threat); + return ScanResult.infected(threat); + } + throw new ScannerUnavailableException("clamd 응답을 해석할 수 없습니다: " + reply, null); + } catch (IOException e) { + throw new ScannerUnavailableException("clamd 에 연결할 수 없습니다: " + host + ":" + port, e); + } + } + + private static String readReply(InputStream in) throws IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + int b; + while ((b = in.read()) != -1 && b != 0) { + buffer.write(b); + } + return buffer.toString(StandardCharsets.US_ASCII).trim(); + } +} diff --git a/src/main/java/com/carecode/core/storage/FileScanner.java b/src/main/java/com/carecode/core/storage/FileScanner.java new file mode 100644 index 00000000..be8b4e9f --- /dev/null +++ b/src/main/java/com/carecode/core/storage/FileScanner.java @@ -0,0 +1,29 @@ +package com.carecode.core.storage; + +/** + * 업로드 파일 악성코드 검사 지점. + * + *

저장 직전에 한 번 부른다. 기본 구현({@link NoOpFileScanner})은 검사하지 않고 통과시키며, + * {@code app.storage.scan.enabled=true} 이면 ClamAV(clamd) 로 검사한다. + */ +public interface FileScanner { + + ScanResult scan(byte[] content); + + record ScanResult(boolean clean, String threat) { + public static ScanResult ok() { + return new ScanResult(true, null); + } + + public static ScanResult infected(String threat) { + return new ScanResult(false, threat); + } + } + + /** 검사기에 닿을 수 없을 때. 업로드는 받지 않는다(fail-closed). */ + class ScannerUnavailableException extends RuntimeException { + public ScannerUnavailableException(String message, Throwable cause) { + super(message, cause); + } + } +} diff --git a/src/main/java/com/carecode/core/storage/FileSignatureValidator.java b/src/main/java/com/carecode/core/storage/FileSignatureValidator.java new file mode 100644 index 00000000..caf9b167 --- /dev/null +++ b/src/main/java/com/carecode/core/storage/FileSignatureValidator.java @@ -0,0 +1,58 @@ +package com.carecode.core.storage; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; + +/** + * 파일 앞부분(매직 넘버)이 확장자와 맞는지 확인한다. + * + *

확장자와 Content-Type 은 클라이언트가 정하는 값이라 얼마든지 속일 수 있다. + * 예를 들어 HTML·SVG 를 {@code .png} 로 올리면 둘 다 통과하고, 브라우저가 내용을 보고 + * 문서로 해석하면 저장형 XSS 가 된다. 실제 바이트를 봐야 막을 수 있다. + */ +public final class FileSignatureValidator { + + /** 판별에 필요한 최대 길이. HEIC 의 ftyp 브랜드가 8~12 바이트에 있다. */ + public static final int HEADER_LENGTH = 16; + + private static final byte[] JPEG = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}; + private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + private static final byte[] GIF87 = ascii("GIF87a"); + private static final byte[] GIF89 = ascii("GIF89a"); + private static final byte[] RIFF = ascii("RIFF"); + private static final byte[] WEBP = ascii("WEBP"); + private static final byte[] FTYP = ascii("ftyp"); + private static final byte[] PDF = ascii("%PDF-"); + private static final Set HEIC_BRANDS = Set.of("heic", "heix", "hevc", "hevx", "mif1", "msf1"); + + private FileSignatureValidator() { + } + + /** 앞부분 바이트가 확장자가 말하는 형식과 맞으면 true. 모르는 확장자는 false. */ + public static boolean matches(String extension, byte[] header) { + if (extension == null || header == null) { + return false; + } + return switch (extension.toLowerCase(Locale.ROOT)) { + case "jpg", "jpeg" -> startsWith(header, 0, JPEG); + case "png" -> startsWith(header, 0, PNG); + case "gif" -> startsWith(header, 0, GIF87) || startsWith(header, 0, GIF89); + case "webp" -> startsWith(header, 0, RIFF) && startsWith(header, 8, WEBP); + case "heic" -> startsWith(header, 4, FTYP) && header.length >= 12 + && HEIC_BRANDS.contains(new String(header, 8, 4, StandardCharsets.US_ASCII)); + case "pdf" -> startsWith(header, 0, PDF); + default -> false; + }; + } + + private static boolean startsWith(byte[] data, int offset, byte[] prefix) { + return data.length >= offset + prefix.length + && Arrays.equals(data, offset, offset + prefix.length, prefix, 0, prefix.length); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } +} diff --git a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java index c71327ba..b9a33848 100644 --- a/src/main/java/com/carecode/core/storage/LocalFileStorageService.java +++ b/src/main/java/com/carecode/core/storage/LocalFileStorageService.java @@ -12,7 +12,6 @@ import org.springframework.web.multipart.MultipartFile; import java.io.IOException; -import java.io.InputStream; import java.nio.file.*; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -37,11 +36,14 @@ public class LocalFileStorageService implements FileStorageService { private final Path rootLocation; private final String publicBaseUrl; private final long maxFileSize; + private final FileScanner fileScanner; public LocalFileStorageService( @Value("${app.storage.local.root:./uploads}") String root, @Value("${app.storage.public-base-url:/files}") String publicBaseUrl, - @Value("${app.storage.max-file-size-bytes:10485760}") long maxFileSize) { + @Value("${app.storage.max-file-size-bytes:10485760}") long maxFileSize, + FileScanner fileScanner) { + this.fileScanner = fileScanner; this.rootLocation = Paths.get(root).toAbsolutePath().normalize(); this.publicBaseUrl = publicBaseUrl.endsWith("/") ? publicBaseUrl.substring(0, publicBaseUrl.length() - 1) @@ -59,6 +61,9 @@ public LocalFileStorageService( @Override public StoredFile store(MultipartFile file, String directory) { validate(file); + byte[] content = readContent(file); + verifySignature(file.getOriginalFilename(), content); + scan(content); String extension = extractExtension(file.getOriginalFilename()); // 원본 파일명을 그대로 쓰면 경로 조작(../)과 파일명 충돌 위험이 있으므로 UUID 로 대체한다. @@ -73,9 +78,8 @@ public StoredFile store(MultipartFile file, String directory) { try { Files.createDirectories(targetDir); - try (InputStream in = file.getInputStream()) { - Files.copy(in, targetDir.resolve(storedName), StandardCopyOption.REPLACE_EXISTING); - } + // 검사한 바이트를 그대로 쓴다. 스트림을 다시 열면 검사한 것과 다른 내용이 저장될 여지가 생긴다. + Files.write(targetDir.resolve(storedName), content); } catch (IOException e) { log.error("파일 저장 실패 - key={}", key, e); throw new BusinessException(ErrorCode.INTERNAL_SERVER_ERROR, "파일 저장에 실패했습니다."); @@ -158,6 +162,43 @@ private void validate(MultipartFile file) { } } + /** 최대 크기(기본 10MB)를 이미 확인했으므로 메모리에 올려도 된다. */ + private byte[] readContent(MultipartFile file) { + try { + return file.getBytes(); + } catch (IOException e) { + throw new BusinessException(ErrorCode.INVALID_INPUT, "업로드한 파일을 읽을 수 없습니다."); + } + } + + /** + * 확장자와 Content-Type 은 클라이언트가 정하는 값이다. 실제 바이트가 그 형식인지 본다. + * (HTML·SVG 를 .png 로 올려도 앞의 두 검사는 통과한다.) + */ + private void verifySignature(String filename, byte[] content) { + String extension = extractExtension(filename); + byte[] header = java.util.Arrays.copyOf(content, Math.min(content.length, FileSignatureValidator.HEADER_LENGTH)); + if (!FileSignatureValidator.matches(extension, header)) { + log.warn("확장자와 내용이 다른 업로드를 거부했습니다 - extension={}", extension); + throw new BusinessException(ErrorCode.FILE_CONTENT_MISMATCH, + "파일 내용이 ." + extension + " 형식이 아닙니다."); + } + } + + /** 검사기에 닿지 못하면 받지 않는다. 검사를 켠 환경에서 조용히 통과시키면 켠 의미가 없다. */ + private void scan(byte[] content) { + FileScanner.ScanResult result; + try { + result = fileScanner.scan(content); + } catch (FileScanner.ScannerUnavailableException e) { + log.error("파일 검사 실패로 업로드를 거부했습니다", e); + throw new BusinessException(ErrorCode.FILE_SCAN_UNAVAILABLE, ErrorCode.FILE_SCAN_UNAVAILABLE.getMessage()); + } + if (!result.clean()) { + throw new BusinessException(ErrorCode.FILE_REJECTED_BY_SCAN, ErrorCode.FILE_REJECTED_BY_SCAN.getMessage()); + } + } + private String extractExtension(String filename) { if (filename == null) { return ""; diff --git a/src/main/java/com/carecode/core/storage/NoOpFileScanner.java b/src/main/java/com/carecode/core/storage/NoOpFileScanner.java new file mode 100644 index 00000000..70b22a5c --- /dev/null +++ b/src/main/java/com/carecode/core/storage/NoOpFileScanner.java @@ -0,0 +1,18 @@ +package com.carecode.core.storage; + +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +/** + * 검사하지 않는 기본 구현. 로컬·테스트용이다. + * 운영에서 검사를 켜려면 clamd 를 띄우고 {@code STORAGE_SCAN_ENABLED=true} 로 둔다. + */ +@Component +@ConditionalOnProperty(name = "app.storage.scan.enabled", havingValue = "false", matchIfMissing = true) +public class NoOpFileScanner implements FileScanner { + + @Override + public ScanResult scan(byte[] content) { + return ScanResult.ok(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 737a2a65..37972103 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -175,6 +175,13 @@ app: root: ${STORAGE_ROOT:./uploads} public-base-url: ${STORAGE_PUBLIC_BASE_URL:/files} max-file-size-bytes: ${STORAGE_MAX_FILE_SIZE:10485760} + # 업로드 악성코드 검사 (ClamAV clamd). 켜면 검사기에 닿지 못할 때 업로드를 받지 않는다(503). + scan: + enabled: ${STORAGE_SCAN_ENABLED:false} + clamd: + host: ${CLAMD_HOST:localhost} + port: ${CLAMD_PORT:3310} + timeout-millis: ${CLAMD_TIMEOUT_MILLIS:10000} notification: # 알림에서 앱 화면으로 이동하는 스킴. 클릭 집계 후 이 주소로 리다이렉트한다 deep-link-base: ${NOTIFICATION_DEEP_LINK_BASE:carecode://} diff --git a/src/test/java/com/carecode/core/storage/ClamAvFileScannerTest.java b/src/test/java/com/carecode/core/storage/ClamAvFileScannerTest.java new file mode 100644 index 00000000..fa7db8a3 --- /dev/null +++ b/src/test/java/com/carecode/core/storage/ClamAvFileScannerTest.java @@ -0,0 +1,108 @@ +package com.carecode.core.storage; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.DataInputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** clamd INSTREAM 프로토콜을 흉내 내는 가짜 서버로 요청 형식과 응답 해석을 확인한다. */ +@DisplayName("ClamAV 검사기 — INSTREAM 프로토콜") +class ClamAvFileScannerTest { + + @Test + @DisplayName("파일을 길이 접두 조각으로 보내고, OK 면 통과") + void cleanFile() throws Exception { + byte[] payload = new byte[150_000]; // 64KB 조각 3개로 나뉜다 + payload[149_999] = 7; + try (FakeClamd clamd = new FakeClamd("stream: OK")) { + FileScanner.ScanResult result = new ClamAvFileScanner("127.0.0.1", clamd.port(), 3000).scan(payload); + + assertThat(result.clean()).isTrue(); + assertThat(clamd.received()).isEqualTo(payload); + assertThat(clamd.command()).isEqualTo("zINSTREAM"); + } + } + + @Test + @DisplayName("FOUND 면 위협 이름과 함께 차단") + void infectedFile() throws Exception { + try (FakeClamd clamd = new FakeClamd("stream: Eicar-Test-Signature FOUND")) { + FileScanner.ScanResult result = new ClamAvFileScanner("127.0.0.1", clamd.port(), 3000) + .scan("X5O!P%@AP".getBytes(StandardCharsets.US_ASCII)); + + assertThat(result.clean()).isFalse(); + assertThat(result.threat()).isEqualTo("Eicar-Test-Signature"); + } + } + + @Test + @DisplayName("연결할 수 없으면 ScannerUnavailableException") + void unreachable() throws Exception { + int closedPort; + try (ServerSocket s = new ServerSocket(0)) { + closedPort = s.getLocalPort(); + } + assertThatThrownBy(() -> new ClamAvFileScanner("127.0.0.1", closedPort, 1000).scan(new byte[]{1})) + .isInstanceOf(FileScanner.ScannerUnavailableException.class); + } + + /** 요청 한 번을 받아 조각을 모으고 정해진 응답을 돌려준다. */ + static final class FakeClamd implements AutoCloseable { + private final ServerSocket server = new ServerSocket(0); + private final CompletableFuture data = new CompletableFuture<>(); + private final CompletableFuture command = new CompletableFuture<>(); + + FakeClamd(String reply) throws Exception { + CompletableFuture.runAsync(() -> { + try (Socket socket = server.accept()) { + DataInputStream in = new DataInputStream(socket.getInputStream()); + ByteArrayOutputStream cmd = new ByteArrayOutputStream(); + int b; + while ((b = in.read()) != 0) { + cmd.write(b); + } + command.complete(cmd.toString(StandardCharsets.US_ASCII)); + ByteArrayOutputStream body = new ByteArrayOutputStream(); + int length; + while ((length = in.readInt()) > 0) { + body.write(in.readNBytes(length)); + } + data.complete(body.toByteArray()); + OutputStream out = socket.getOutputStream(); + out.write((reply + "\0").getBytes(StandardCharsets.US_ASCII)); + out.flush(); + } catch (Exception e) { + data.completeExceptionally(e); + command.completeExceptionally(e); + } + }); + } + + int port() { + return server.getLocalPort(); + } + + byte[] received() throws Exception { + return data.get(3, TimeUnit.SECONDS); + } + + String command() throws Exception { + return command.get(3, TimeUnit.SECONDS); + } + + @Override + public void close() throws Exception { + server.close(); + } + } +} diff --git a/src/test/java/com/carecode/core/storage/FileSignatureValidatorTest.java b/src/test/java/com/carecode/core/storage/FileSignatureValidatorTest.java new file mode 100644 index 00000000..ef97ca7f --- /dev/null +++ b/src/test/java/com/carecode/core/storage/FileSignatureValidatorTest.java @@ -0,0 +1,47 @@ +package com.carecode.core.storage; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("파일 시그니처") +class FileSignatureValidatorTest { + + private static byte[] bytes(int... values) { + byte[] out = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + out[i] = (byte) values[i]; + } + return out; + } + + private static byte[] ascii(String s) { + return s.getBytes(StandardCharsets.ISO_8859_1); + } + + @Test + void recognizesAllowedFormats() { + assertThat(FileSignatureValidator.matches("jpg", bytes(0xFF, 0xD8, 0xFF, 0xE0))).isTrue(); + assertThat(FileSignatureValidator.matches("JPEG", bytes(0xFF, 0xD8, 0xFF, 0xE1))).isTrue(); + assertThat(FileSignatureValidator.matches("png", bytes(0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A))).isTrue(); + assertThat(FileSignatureValidator.matches("gif", ascii("GIF89a...."))).isTrue(); + assertThat(FileSignatureValidator.matches("webp", ascii("RIFF\0\0\0\0WEBPVP8 "))).isTrue(); + assertThat(FileSignatureValidator.matches("heic", ascii("\0\0\0\u0018ftypheic\0\0\0\0"))).isTrue(); + assertThat(FileSignatureValidator.matches("pdf", ascii("%PDF-1.7\n"))).isTrue(); + } + + @Test + @DisplayName("다른 형식이거나, 짧거나, 모르는 확장자면 거절한다") + void rejectsMismatches() { + assertThat(FileSignatureValidator.matches("png", ascii(" storage(new NoOpFileScanner()) + .store(new MockMultipartFile("file", "x.png", "image/png", html), "health-records")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.FILE_CONTENT_MISMATCH); + assertThat(storedFileCount()).isZero(); + } + + @Test + @DisplayName("검사기가 악성으로 판정하면 저장하지 않는다") + void infectedIsRejected() throws Exception { + FileScanner infected = content -> FileScanner.ScanResult.infected("Eicar-Test-Signature"); + + assertThatThrownBy(() -> storage(infected) + .store(new MockMultipartFile("file", "x.png", "image/png", PNG), "health-records")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getErrorCode()) + .isEqualTo(ErrorCode.FILE_REJECTED_BY_SCAN); + assertThat(storedFileCount()).isZero(); + } + + @Test + @DisplayName("검사기에 닿지 못하면 받지 않는다 (503)") + void scannerDownFailsClosed() { + FileScanner down = content -> { + throw new FileScanner.ScannerUnavailableException("down", null); + }; + + assertThatThrownBy(() -> storage(down) + .store(new MockMultipartFile("file", "x.png", "image/png", PNG), "health-records")) + .isInstanceOf(BusinessException.class) + .extracting(e -> ((BusinessException) e).getHttpStatus().value()) + .isEqualTo(503); + } + + private long storedFileCount() throws Exception { + try (Stream files = Files.walk(root)) { + return files.filter(Files::isRegularFile).count(); + } + } +}