Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
47 changes: 47 additions & 0 deletions docs/reference/file-upload-security.md
Original file line number Diff line number Diff line change
@@ -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` 로 추상화돼 있어 구현체만 추가하면 된다.
비용과 계정이 필요한 결정이라 따로 진행한다. 다중 인스턴스로 늘리기 전에는 반드시 필요하다
(지금은 로컬 디스크라 인스턴스끼리 파일을 공유하지 못한다).
3 changes: 3 additions & 0 deletions src/main/java/com/carecode/core/exception/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
82 changes: 82 additions & 0 deletions src/main/java/com/carecode/core/storage/ClamAvFileScanner.java
Original file line number Diff line number Diff line change
@@ -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 로 말한다.
*
* <p>프로토콜: {@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();
}
}
29 changes: 29 additions & 0 deletions src/main/java/com/carecode/core/storage/FileScanner.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.carecode.core.storage;

/**
* 업로드 파일 악성코드 검사 지점.
*
* <p>저장 직전에 한 번 부른다. 기본 구현({@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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;

/**
* 파일 앞부분(매직 넘버)이 확장자와 맞는지 확인한다.
*
* <p>확장자와 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<String> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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)
Expand All @@ -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 로 대체한다.
Expand All @@ -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, "파일 저장에 실패했습니다.");
Expand Down Expand Up @@ -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 "";
Expand Down
18 changes: 18 additions & 0 deletions src/main/java/com/carecode/core/storage/NoOpFileScanner.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
7 changes: 7 additions & 0 deletions src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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://}
Expand Down
Loading
Loading