saip.learning notes

업로드 이미지 안전하게 처리하기

보안 · 마지막 수정 2026-10-24

관리자 편집기에 이미지를 붙여넣으면 서버가 검사하고, 줄이고, 다시 저장한 뒤 ![](/images/...)를 본문에 넣는다. 사용자가 올린 파일은 믿을 수 없는 입력이라서 단순히 디스크에 쓰면 안 된다.

한눈에 보기

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을 이스케이프하므로, 주석도 &lt;!-- ... --&gt; 글자로 바뀌어 공개 화면에 그대로 보였다. Playwright로 실제 화면을 열어 보고서야 발견했다. 지금은 본문은 건드리지 않고 편집기 아래 상태 줄에 사유를 띄운다.

  • 보안 장치(HTML 이스케이프)는 내가 쓴 글에도 똑같이 적용된다. "이 정도는 안 보이겠지"라는 가정은 실제 화면으로 확인한다.
  • 노트를 지워도 이미지 파일은 남는다(여러 노트가 같은 이미지를 쓸 수 있어서). 안 쓰는 이미지 정리는 배포 단계에서 다룬다.

점검 체크리스트

  • 형식을 확장자가 아닌 헤더로 판단하는가
  • 픽셀을 읽기 전에 가로·세로를 확인하는가
  • 다시 인코딩해서 EXIF·텍스트 청크를 지우는가
  • 저장 이름에 사용자 입력이 들어가지 않는가
  • 업로드 POST에 CSRF 또는 Bearer 인증이 걸려 있는가

댓글 0

로그인하고 댓글 달기