업로드 이미지 안전하게 처리하기
관리자 편집기에 이미지를 붙여넣으면 서버가 검사하고, 줄이고, 다시 저장한 뒤 를 본문에 넣는다. 사용자가 올린 파일은 믿을 수 없는 입력이라서 단순히 디스크에 쓰면 안 된다.
한눈에 보기
flowchart LR
A[업로드 바이트] --> B{헤더로 형식 확인<br/>PNG·JPEG·GIF?}
B -- 아니오 --> X[400 거절]
B -- 예 --> C{가로×세로<br/>4천만 화소 이하?}
C -- 아니오 --> X
C -- 예 --> D{GIF?}
D -- 예 --> E{프레임 300장 이하?} -- 예 --> F[원본 그대로 저장]
E -- 아니오 --> X
D -- 아니오 --> G[건너뛰며 읽기<br/>긴 변 1600px로 축소]
G --> H[다시 인코딩<br/>EXIF·텍스트 청크 제거]
H --> I[SHA-256 파일명으로 저장]
F --> I
| 위험 | 막는 방법 |
|---|---|
확장자만 .png인 가짜 파일 |
확장자·Content-Type 대신 파일 헤더로 형식 판단 |
| 압축 폭탄(작은 파일, 거대한 그림) | 픽셀을 풀기 전에 헤더의 가로·세로만 읽고 거절 |
| 큰 사진 때문에 메모리 부족 | 건너뛰며 읽기(subsampling) |
| 사진 속 GPS 위치 | 다시 인코딩해서 메타데이터 제거 |
| 파일 이름 충돌·경로 조작 | 사용자가 준 이름은 버리고 내용 해시로 이름 짓기 |
형식은 헤더로 판단한다
브라우저가 보내는 Content-Type이나 파일 이름은 보낸 사람이 마음대로 바꿀 수 있다. 실제 파일의 앞부분(매직 넘버)을 보고 판단한다. ImageIO의 ImageReader가 이 일을 한다.
try (ImageInputStream in = ImageIO.createImageInputStream(new ByteArrayInputStream(bytes))) {
Iterator<ImageReader> readers = ImageIO.getImageReaders(in);
if (!readers.hasNext()) throw new InvalidImageException("이미지 파일이 아닙니다");
ImageReader reader = readers.next();
reader.setInput(in);
String format = reader.getFormatName().toLowerCase(Locale.ROOT); // png, jpeg, gif만 허용
long pixels = (long) reader.getWidth(0) * reader.getHeight(0); // 헤더만 읽는다
if (pixels > 40_000_000L) throw new InvalidImageException("이미지가 너무 큽니다");
...
}
압축 폭탄
PNG는 압축 파일이다. 한 가지 색으로 채운 50000×50000 그림은 파일이 몇 KB여도, 풀면 50000 × 50000 × 4바이트 = 10GB다. 서버 메모리가 1GB면 바로 죽는다.
Important
순서가 핵심이다. 크기 확인 → 픽셀 읽기. ImageIO.read(...)를 먼저 부르면 확인하기 전에 이미 메모리가 터진다.
업로드 크기 제한(10MB)만으로는 못 막는다. 10MB는 압축된 크기이기 때문이다. 그래서 화소 수 제한(4천만)을 따로 둔다.
건너뛰며 읽기(subsampling)
4천만 화소 이하라도 원본 그대로 읽으면 수백 MB를 쓴다. 어차피 1600px로 줄일 거라면 처음부터 n번째 픽셀마다 하나씩만 읽는다.
int step = Math.max(1, Math.max(width, height) / 1600);
ImageReadParam param = reader.getDefaultReadParam();
param.setSourceSubsampling(step, step, 0, 0);
BufferedImage img = reader.read(0, param);
그 다음 남은 크기를 부드럽게 줄여 긴 변을 정확히 1600px로 맞춘다. 실제로 3000×1500, 3.8MB PNG가 1600×800, 522KB로 저장됐다.
메타데이터 제거: 다시 인코딩
스마트폰 사진의 EXIF에는 촬영 위치(GPS), 기기 모델, 시각이 들어 있다. PNG에도 tEXt 청크로 아무 글자나 숨길 수 있다. 공개 블로그에 그대로 올리면 집 주소가 새어 나갈 수 있다.
픽셀만 꺼내서 새 파일로 다시 쓰면 원본의 메타데이터는 따라오지 않는다.
| 형식 | 다시 쓰는 방법 |
|---|---|
| PNG | ARGB 그대로(투명도 유지) |
| JPEG | RGB, 품질 0.85 |
테스트는 tEXt 청크에 비밀 문자열을 넣은 PNG를 올리고, 저장된 파일에 그 문자열이 없는지 확인한다.
GIF는 원본 그대로
ImageIO로 GIF를 읽어 다시 쓰면 첫 장면만 남아서 움직이지 않는다. 선택지는 셋이었다.
| 선택 | 장점 | 단점 |
|---|---|---|
| A. 검사만 하고 원본 저장 | 움직임 유지, 코드 짧음 | 크기 축소 없음 |
| B. 프레임마다 다시 인코딩 | 축소 가능 | 코드 많고 프레임 지연·투명도 처리 까다로움 |
| C. 첫 장면만 저장 | 가장 단순 | 애니메이션이 사라짐 |
A를 골랐다. 대신 화소 수와 **프레임 수(300장)**를 확인해 프레임 폭탄을 막는다. GIF는 위치 정보를 담지 않아서 메타데이터 걱정이 적다.
파일 이름과 캐시
// bytes는 다시 인코딩한 결과(GIF는 원본). SHA-256 16진수 앞 32자
String name = sha256(bytes).substring(0, 32) + "." + ext;
- 사용자가 준 파일 이름(
../../etc/passwd.png같은 것)을 아예 쓰지 않는다. - 같은 이미지를 두 번 올리면 같은 파일이 된다(중복 저장 없음).
- 내용이 바뀌면 이름도 바뀌므로 브라우저가 영원히 캐시해도 된다.
registry.addResourceHandler("/images/**")
.addResourceLocations(dir.toUri().toString())
.setCacheControl(CacheControl.maxAge(Duration.ofDays(365)).cachePublic().immutable());
편집기에서 업로드
붙여넣기(paste)와 끌어 놓기(drop) 이벤트에서 파일을 꺼내 FormData로 보낸다. 세션 쿠키로 인증하는 화면 요청이므로 CSRF 헤더가 필요하다. 같은 업로드를 /api/images로도 열어서 /blog 스킬이 Bearer 토큰으로 이미지를 올릴 수 있다.
const form = new FormData();
form.append('file', file);
const res = await fetch('/admin/images', {
method: 'POST',
headers: { [meta('_csrf_header')]: meta('_csrf') },
body: form,
});
겪은 문제와 해결
Caution
실패 사유를 본문에 HTML 주석으로 넣은 실수. 업로드가 실패하면 <!-- 업로드 실패: ... -->를 본문에 남기면 공개 화면에는 안 보일 거라고 생각했다. 그런데 마크다운 렌더러가 XSS를 막으려고 원본 HTML을 이스케이프하므로, 주석도 <!-- ... --> 글자로 바뀌어 공개 화면에 그대로 보였다. Playwright로 실제 화면을 열어 보고서야 발견했다. 지금은 본문은 건드리지 않고 편집기 아래 상태 줄에 사유를 띄운다.
- 보안 장치(HTML 이스케이프)는 내가 쓴 글에도 똑같이 적용된다. "이 정도는 안 보이겠지"라는 가정은 실제 화면으로 확인한다.
- 노트를 지워도 이미지 파일은 남는다(여러 노트가 같은 이미지를 쓸 수 있어서). 안 쓰는 이미지 정리는 배포 단계에서 다룬다.
점검 체크리스트
- 형식을 확장자가 아닌 헤더로 판단하는가
- 픽셀을 읽기 전에 가로·세로를 확인하는가
- 다시 인코딩해서 EXIF·텍스트 청크를 지우는가
- 저장 이름에 사용자 입력이 들어가지 않는가
- 업로드 POST에 CSRF 또는 Bearer 인증이 걸려 있는가
댓글 0
로그인하고 댓글 달기