토스트 사진 한 장이 3000x4000 픽셀, 3.3MB 였다. 휴대폰으로 찍어 그대로 올린 사진이다. 카드 크기로 작게 보이는 메뉴 썸네일에서도, 추천 목록에서도 브라우저는 그 3.3MB 를 매번 내려받았다. 화면이 굼뜬 이유를 찾다 보니 범인은 코드가 아니라 사진이었다.

처음 업로드 코드는 폼에서 넘어온 파일을 GUID 이름으로 wwwroot/Uploads/Recipes 에 저장할 뿐, 크기도 확장자도 내용도 묻지 않았다. 사진 한 장이 거치는 길을 다시 깔아야 했다.

파일을 다루는 코드를 한곳으로

손대기 전에 자리부터 정리했다. RecipeService 가 레시피 CRUD 와 파일 저장·삭제를 같이 하고 있어서, 여기에 검증과 리사이즈를 얹으면 레시피 서비스가 사진 코드로 뒤덮일 게 뻔했다.

그래서 IRecipeImageService 를 따로 뺐다. SaveAsync 는 저장하고 웹 경로를 돌려주고, Delete 는 웹 경로로 파일을 지운다. 이후의 변경은 모두 이 서비스 안에서 끝났다.

이 분리가 바로 쓸모를 보인 건 레시피 복사였다. 다른 사람의 공개 레시피나 공용 레시피를 "내 레시피로 담기" 하면 처음엔 원본의 ImagePath 를 그대로 참조했다. 원본이 삭제되면 사본의 사진이 깨진다. 이미지 서비스에 Duplicate 를 추가해 대표 사진과 조리 단계 사진을 전부 새 GUID 파일로 복사하게 했다. 복사에 실패하면 원래 경로를 돌려주고 경고만 남겨, 담기 자체는 사진 때문에 실패하지 않게 했다.

확장자는 믿을 게 못 된다

검증은 세 겹으로 시작했다. 파일 크기 상한, 확장자 화이트리스트(.jpg .jpeg .png .webp .gif), Content-Type 화이트리스트(image/jpeg 등 네 가지). 값은 appsettings.json 의 ImageUpload 섹션에 두고, 걸리면 InvalidImageUploadException 을 던져 컨트롤러가 ModelState 에 넣고 폼을 다시 보여 준다.

그런데 확장자와 Content-Type 은 둘 다 클라이언트가 보낸 값이다. 처음 리사이즈를 붙였을 때는 디코딩에 실패하면 원본을 그대로 저장하는 폴백을 뒀는데, 다시 보니 확장자만 이미지인 아무 파일이나 /Uploads 아래로 공개될 수 있다는 뜻이었다. 폴백을 뒤집어, 이미지로 읽히지 않으면 지우고 거부하게 했다.

한 번 더 뒤집힌 건 형식이었다. 이미지 라이브러리는 확장자가 아니라 내용으로 형식을 판별한다. 그러니 .jpg 라는 이름을 단 TIFF 도 TIFF 디코더로 열린다. 받을 생각이 없던 디코더까지 업로드 경로에 노출되는 셈이었고, 실제로 그 TIFF 디코더에는 알려진 취약점이 있었다. 결국 허용하는 건 확장자 목록이 아니라 "디코더 목록"이어야 했다. 지금의 코드는 헤더를 열어 본 형식이 네 가지 중 하나가 아니면 이미지가 아닌 것으로 친다.

private static readonly HashSet<SKEncodedImageFormat> Allowed = new()
{
    SKEncodedImageFormat.Jpeg, SKEncodedImageFormat.Png, SKEncodedImageFormat.Webp, SKEncodedImageFormat.Gif
};

private static ImageHeader? ToHeader(SKCodec? codec)
{
    if (codec is null || !Allowed.Contains(codec.EncodedFormat)) return null;
    var info = codec.Info;
    if (info.Width <= 0 || info.Height <= 0) return null;
    return new ImageHeader(codec.EncodedFormat, info.Width, info.Height, codec.EncodedOrigin);
}

헤더만 읽으니 픽셀을 풀기 전에 가로·세로를 안다. 파일은 작은데 해상도만 거대한 압축 폭탄도 디코딩 전에 픽셀 수 상한으로 돌려보낸다.

거부 이유가 화면에 안 보인다는 건 나중에 알았다. 컨트롤러는 사진 오류를 ImageFile 키로 ModelState 에 넣는데, 폼에는 그 자리가 없었고 ValidationSummary(true) 는 속성 오류를 빼고 보여 준다. 사용자는 아무 말 없이 폼이 다시 뜨는 것만 봤다. 대표 사진 칸 아래에 ValidationMessageFor(m => m.ImageFile) 을 넣고 나서야 "이미지 파일을 읽을 수 없습니다" 가 제자리에 떴다.

스트림은 두 번 읽히지 않는다

리사이즈 기준은 긴 변 1200px 로 잡았다. 넘으면 비율을 유지해 줄이고, JPEG·WebP 는 품질 85 로 다시 인코딩한다. 4032x3024 사진이 1200x900, 용량은 대략 10분의 1이 된다. GIF 는 애니메이션을 지키려고 줄이지 않는다. 세로 사진이 가로로 누워 보이는 문제도 같이 잡았다. EXIF Orientation 대로 픽셀을 먼저 돌린 다음에 줄여야 비율이 맞는다.

처음 구현에는 숨은 함정이 있었다. 정상 경로에서 IFormFile.OpenReadStream() 으로 읽어 디코딩하고, 실패하면 catch 에서 한 번 더 OpenReadStream() 을 열어 원본을 저장했다. IFormFile 구현에 따라 두 번째 스트림은 이미 끝 위치일 수 있고, 그러면 0바이트 파일이 조용히 남는다. 실패 경로에서만 드러나는 버그다.

순서를 바꿨다. 스트림은 딱 한 번, 디스크에 쓰는 데만 쓴다. 그다음부터는 디스크의 파일을 상대한다.

// 1단계: 항상 원본을 디스크에 저장 — 스트림은 한 번만 소비
await using (var raw = File.Create(fullPath))
{
    await file.CopyToAsync(raw, ct);
}

// 실제 이미지(허용 형식)인지 먼저 확인 — GIF·리사이즈 비활성이어도 거른다
if (ImageCodec.ReadHeader(fullPath) is null)
    RejectUnreadable(fullPath, null);

if (ext == ".gif" || _options.MaxDimension <= 0) return;

// 2단계: 디스크의 원본을 회전 보정·축소해 같은 경로에 덮어쓰기
try { await ReencodeInPlaceAsync(fullPath, ext, ct); }
catch (InvalidImageUploadException ex) { RejectUnreadable(fullPath, ex); }

2단계도 같은 파일을 읽으면서 같은 파일에 쓸 수는 없다. 그래서 ReencodeInPlaceAsync 는 .tmp 에 쓰고 File.Move(tmp, path, overwrite: true) 로 바꿔 끼운다. 다시 인코딩하면 EXIF 가 통째로 빠지는데, 회전은 이미 픽셀에 반영했으니 잃을 게 없다. 촬영 위치 같은 정보가 공개 폴더에 남지 않으니 오히려 이득이었다.

서버가 뜨고 30초 뒤

새 사진은 이제 작았다. 문제는 리사이즈 전에 올라온 사진들, 처음의 3.3MB 토스트 같은 것들이었다. 앱이 알아서 한 번 훑게 했다.

OversizedUploadShrinker 는 BackgroundService 다. 앱이 뜨고 30초 기다렸다가, 기동 직후의 요청 처리·마이그레이션과 겹치지 않게, 업로드 폴더의 jpg·png·webp 를 하나씩 본다. 헤더만 읽어서 긴 변이 1200px 이하면 건너뛰고, 넘는 것만 업로드와 같은 ReencodeInPlaceAsync 로 줄인다. 한 번 줄인 파일은 다음 기동부터 헤더만 읽고 지나간다.

배포하고 로그를 봤는데 운영 사진이 그대로였다. 파일별 예외 처리에서 IOException 만 잡고 있었던 게 원인이었다. 서버를 옮기면서 다른 계정으로 복사된 파일은 앱 풀 계정이 덮어쓰지 못하고, 그때 나는 건 UnauthorizedAccessException 이다. 그 하나가 루프 밖으로 튀어 나가 작업 전체가 멈췄다. 실패한 자리엔 .tmp 파일까지 남았다.

catch 조건에 UnauthorizedAccessException 을 더해 파일 단위로 '파일 쓰기 권한 없음' 경고를 남기고 다음 파일로 넘어가게 했고, ReencodeInPlaceAsync 의 finally 에서 남은 .tmp 를 지우게 했다. 사진 삭제와 복제도 같은 이유로 권한 오류를 경고로만 남긴다.

라이선스 키가 없습니다

여기까지는 ImageSharp 로 만들었다. 의존성 점검에서 쓰고 있던 3.1.12 에 알려진 취약점이 여럿 걸렸고, 수정은 4.x 에만 있었다. 4.1.3 으로 올리고 앞에서 말한 디코더 제한을 넣었다.

배포 빌드에서 깨졌다. ImageSharp 4.x 는 Release 빌드에 Six Labors 라이선스 키가 없으면 빌드 오류를 낸다. Debug 에서는 경고만 띄워서 로컬에서는 놓쳤다. 무료 Community 라이선스는 비영리·오픈소스 대상이라 이 서비스가 받기는 어려웠다. 일단 3.1.12 로 되돌렸다. 디코더 제한 덕분에 TIFF 쪽 문제는 막혀 있었지만, JPEG 를 읽는 경로의 취약점 하나는 그대로 남았다. 시간을 번 것뿐이었다.

선택지는 둘이었다. 유료 라이선스를 사거나, 라이브러리를 바꾸거나. 필요한 건 형식 판별, 회전, 축소, 인코딩 네 가지뿐이었다. MIT 라이선스인 SkiaSharp 로 옮기고, 이미지 처리를 정적 클래스 ImageCodec 하나로 모아 업로드·기존 사진 축소·URL 사진 판별이 함께 쓰게 했다.

ImageSharp 의 AutoOrient() 한 줄이 하던 회전 보정은 직접 써야 했다. EXIF Orientation 여덟 가지를 캔버스의 회전·반전으로 옮기는 코드라 눈으로는 맞는지 알 수 없다. 테스트에서 방향 1·3·6·8 사진을 만들어 회전 뒤 특정 픽셀이 기대한 자리에 있는지까지 확인했고, 실제 앱에서도 세로로 찍힌 사진이 800x1200 세로로 저장되고 EXIF 가 빠진 것을 봤다. 처음 쓴 DrawBitmap 이 사용 중단(obsolete) 경고를 내서 SKImage 와 DrawImage 로 바꿨는데, 90도 단위 회전이라 보간 없이 Nearest 로 픽셀을 그대로 옮긴다.

청구서가 하나 더 있었다. SkiaSharp 는 네이티브 라이브러리를 플랫폼별로 싣는데, 그대로 게시하면 ARM·macOS 용과 개당 100MB 가까운 디버그 심볼까지 따라와 게시 결과가 530MB 가 됐다. csproj 에 빌드 타깃을 하나 넣어 운영 윈도우(x64, 혹시 모를 x86)와 Docker 용 linux-x64 만 남겼더니 78MB 로 내려왔다.

사진이 없는 레시피

반대편도 있었다. 처음부터 들어 있는 공용 레시피는 사진이 없어서 목록의 카드가 전부 같은 아이콘으로 보였다. 외부 음식 사진을 받아 오면 라이선스와 네트워크가 엮인다.

그래서 그림을 만들었다. RecipePlaceholderImageSeeder 가 앱 시작 때 사진 없는 공용 레시피마다 600x600 SVG 를 쓴다. 위쪽은 종류별 색의 그라데이션과 원, 큰 이모지, 아래쪽은 레시피 제목 띠다. 찌개는 호박색에 🍲, 양식은 남색에 🍝 하는 식으로 열다섯 종류에 색과 이모지를 붙였다. 제목은 HtmlEncode 해서 넣는다.

레시피 상세 화면. 사진 없는 메인반찬 레시피에 붉은 배경, 고기 이모지, 제목 띠로 된 플레이스홀더가 대표 사진 자리에 들어가 있다

이것도 한 번 어긋났다. 로컬에서 생성된 seed-{id}.svg 가 게시 패키지에 실려 운영 파일을 같은 이름으로 덮어썼다. ID 가 다르니 호박 수프 카드에 카야 토스트 그림이 붙었다. 업로드 폴더를 게시 대상에서 빼고(CopyToPublishDirectory="Never"), 시더가 자기 SVG 를 쓰는 레시피에 대해서는 파일 내용이 지금 제목·종류로 만든 SVG 와 다르면 다시 쓰게 했다. 다음 재시작 때 저절로 맞춰지고, 관리자가 제목을 바꿔도 따라간다.

이제 사진 한 장은 이름과 크기를 묻는 문을 지나, 헤더로 정체를 확인받고, 디스크에 한 번 내려앉았다가, 돌려지고 줄어든 채 자리를 잡는다. 3.3MB 토스트도 결국 그 길을 다시 걸어 작아졌다.

다음 편은 업로드 검증에서 한 걸음 더 나가, 운영하던 서비스를 공격자의 눈으로 다시 읽어 본 이야기다.