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("