공공기관이나 행정 시스템에서 파일 업로드 기능을 만들 때는 단순히 확장자만 검사하는 것으론 부족합니다. 보안 진단 항목에는 “화이트리스트 기반 확장자 검증”, “이중 확장자 차단”, “매직바이트 확인” 등이 반드시 포함되죠. 이번 글에서는 실제 공공 프로젝트에서 사용했던 업로드 검증기를 Spring Boot + Java 기반으로 직접 구현한 방법을 정리해봅니다.
1️⃣ 설계 개요
파일 업로드 시점에 다음 네 가지 단계를 거치도록 설계했습니다.
- 확장자 화이트리스트 검사 — 허용된 확장자만 업로드 가능
- 이중 확장자(double extension) 차단 —
test.jsp.jpg형태 방지 - 파일명 정규식 검증 — 한글/영문/숫자 및 일부 특수문자만 허용
- 매직바이트 검사 — 파일 내용이 실제 확장자와 일치하는지 확인
이 검증은 파일을 실제로 저장하기 직전 단계에서 수행하며, 저장 위치나 네트워크 공유 경로는 모두 프로퍼티에서 동적으로 읽도록 설계했습니다.
2️⃣ 구현 코드 (핵심 부분)
코드 전체는 범용적인 형태로 정리한 예시입니다.
package com.example.security;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.text.Normalizer;
import java.text.SimpleDateFormat;
import java.util.*;
/**
* UnifiedUploadValidator
*
* - 화이트리스트 기반 확장자 검증
* - base name 내 '.' 금지 → 이중 확장자 차단
* - 매직바이트 검사로 파일 내용 확인
* - 파일명 정규식: 한글/영문/숫자 + (_ - () 공백)
*/
public class UnifiedUploadValidator {
private final Set<String> allowedExt;
private final long maxFileBytes;
private final String baseNameRegex = "^[\\p{L}0-9 _\\-()]{1,200}$";
public UnifiedUploadValidator(Set<String> allowedExt, long maxFileBytes) {
this.allowedExt = new HashSet<>();
for (String e : allowedExt) this.allowedExt.add(e.toLowerCase(Locale.ROOT));
this.maxFileBytes = maxFileBytes;
}
/** 파일 내용 검증 + 저장 */
public String validateAndStore(Path baseDir, String subDir, String fileName, byte[] bytes) throws IOException {
ParsedName parsed = parseFullNameStrict(fileName);
validateCommon(parsed.base, parsed.ext, bytes);
// 매직바이트로 파일 내용 검증
byte[] header = Arrays.copyOf(bytes, Math.min(bytes.length, 512));
if (!matchesMagicBytes(header, parsed.ext)) {
throw new IllegalArgumentException("File content mismatch: " + parsed.ext);
}
// 안전한 파일명 생성
String timeSuffix = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
String safeFileName = parsed.base + "(" + timeSuffix + ")." + parsed.ext;
Path target = baseDir.resolve(subDir).resolve(safeFileName).normalize();
Files.createDirectories(target.getParent());
try (OutputStream os = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
os.write(bytes);
}
return safeFileName;
}
/** 내부 유틸: 파일명 파싱 */
private ParsedName parseFullNameStrict(String fullName) {
String name = Normalizer.normalize(fullName.trim(), Normalizer.Form.NFC);
int idx = name.lastIndexOf('.');
if (idx <= 0 || idx == name.length() - 1) {
throw new IllegalArgumentException("Invalid file name: " + name);
}
String base = name.substring(0, idx);
String ext = name.substring(idx + 1).toLowerCase(Locale.ROOT);
if (base.contains(".")) throw new IllegalArgumentException("Double extension blocked: " + base);
if (!allowedExt.contains(ext)) throw new IllegalArgumentException("Disallowed extension: " + ext);
if (!base.matches(baseNameRegex)) throw new IllegalArgumentException("Invalid base name: " + base);
return new ParsedName(base, ext);
}
/** 내부 유틸: 크기 제한 및 확장자 검사 */
private void validateCommon(String base, String ext, byte[] bytes) {
if (bytes == null) throw new IllegalArgumentException("File bytes required");
if (bytes.length > maxFileBytes) throw new IllegalArgumentException("File too large");
if (!allowedExt.contains(ext)) throw new IllegalArgumentException("Disallowed extension: " + ext);
}
/** 내부 유틸: 매직바이트 검사 */
private boolean matchesMagicBytes(byte[] header, String ext) {
final java.util.function.BiPredicate<byte[], byte[]> startsWith = (hay, needle) -> {
if (hay.length < needle.length) return false;
for (int i = 0; i < needle.length; i++) if (hay[i] != needle[i]) return false;
return true;
};
switch (ext) {
case "png": return startsWith.test(header, new byte[]{(byte)0x89, 0x50, 0x4E, 0x47});
case "jpg": case "jpeg": return startsWith.test(header, new byte[]{(byte)0xFF, (byte)0xD8});
case "gif": return startsWith.test(header, "GIF".getBytes(StandardCharsets.US_ASCII));
case "pdf": return startsWith.test(header, "%PDF".getBytes(StandardCharsets.US_ASCII));
case "zip": return startsWith.test(header, new byte[]{0x50, 0x4B, 0x03, 0x04});
default: return true; // 단순 텍스트 등
}
}
private static class ParsedName {
final String base; final String ext;
ParsedName(String base, String ext) { this.base = base; this.ext = ext; }
}
}
3️⃣ 사용 예시
Set<String> allowed = Set.of("jpg", "jpeg", "png", "gif", "pdf", "zip", "txt");
UnifiedUploadValidator validator = new UnifiedUploadValidator(allowed, 10 * 1024 * 1024); // 10MB
// 실제 업로드 요청이 들어왔을 때:
Path baseDir = Paths.get("//fileserver/share/uploads");
String savedName = validator.validateAndStore(baseDir, "temp/", "profile.jpg", fileBytes);
System.out.println("저장된 파일명: " + savedName);
4️⃣ 결과 및 보안 효과
- 이중 확장자 (
test.jsp.jpg) 즉시 차단 - 허용되지 않은 확장자 자동 거부
- 악성 스크립트를 숨긴 파일 매직바이트로 식별
- 업로드 디렉터리 자동 생성 + 경로 traversal 차단
보안 진단 도중 “파일 업로드 유효성 검증 미비” 항목이 지적되더라도, 이 클래스를 적용하면 대부분 항목이 충족됩니다.
5️⃣ 마무리
보안 진단에서 가장 흔히 적발되는 취약점 중 하나가 바로 “파일 업로드 취약점”입니다. 단순히 확장자만 제한하거나 프론트엔드에서만 검사하는 건 전혀 안전하지 않아요. 서버단에서 확장자, 파일명, 크기, 매직바이트를 모두 검증하는 습관을 들이면 공공 서비스에서도 안정적으로 파일 기능을 제공할 수 있습니다.
이 코드는 오픈 기술 기반이며, 특정 기관이나 프로젝트에 종속되지 않습니다. 필요하다면 파일시스템 경로나 환경설정을 외부 .properties 파일로 분리해 운영/개발 환경별로 손쉽게 관리할 수도 있습니다.
더 좋은 방법이 있다면 알려주세요!
© 2025 by 재명. 모든 예시는 비식별 처리된 보안 설계 예시입니다.
'개발' 카테고리의 다른 글
| DB 커넥션 풀과 Redis — 확장 가능한 서버 구조를 위한 핵심 정리 (0) | 2025.11.19 |
|---|---|
| 웹서버, WAS, 톰캣, JEUS의 관계 완전 정리 (0) | 2025.11.03 |
| 왜 브라우저는 HTTPS가 되는데 Java 서버는 실패할까? (0) | 2025.09.16 |
| React + Capacitor로 안드로이드 앱 실행하기 (Android Studio) (1) | 2025.07.30 |
| Vue.js에서 URL 쿼리로 테이블 정렬 상태 기억하기 - 새로고침에도 유지돼는 UX 만들기 (0) | 2025.07.21 |