로그인도 하고 소유자 검증도 통과했는데, 화면에 뜬 사진은 다른 사람의 것일 수 있었습니다. 제가 운영하는 사진 동선 앱 Iter에서 실제로 있었던 버그입니다. 권한 검사 코드는 멀쩡했고, 문제는 파일 이름이었습니다.

Iter는 구글 포토 사진에서 촬영 시각과 GPS를 뽑아 날짜별 이동 동선을 지도에 그려주는 서비스입니다. 사용자가 구글 테이크아웃 zip을 올리면 서버가 압축을 풀고, 사진마다 좌표를 추출하고, 지도 마커 팝업에 쓸 300px 썸네일을 만듭니다. 원본은 보관하지 않고 좌표와 썸네일만 남기는 구조입니다.

그런데 바로 그 썸네일 저장 방식이 사용자 사이의 경계를 넘어서고 있었습니다. 7월 21일에 고친 이 버그를 정리하면서, 위치 데이터를 다루는 서비스에서 놓치기 쉬운 지점을 함께 적어 둡니다.

무엇이 잘못됐나: IMG_0001.JPG는 모두의 파일명이다

원인은 두 가지가 겹친 데 있었습니다. 썸네일 파일명을 zip 안 사진의 원본 파일명으로 정했고, 모든 사용자가 같은 디렉터리 writable/uploads/thumbnails/를 썼습니다.

// 수정 전 — GdThumbnailGenerator::generate()
$this->ensureOutputDir();
$path = $this->outputDir . '/' . $this->safeFileName($mediaItemId) . '.jpg';

$mediaItemId에는 원본 파일명이 그대로 들어옵니다. 개발할 때는 이 값이 충분히 고유하다고 생각했습니다. 하지만 카메라와 휴대폰은 IMG_0001.JPG 같은 기본 파일명을 씁니다. 사용자가 둘만 돼도 같은 이름이 나오는 건 시간문제입니다.

같은 이름이 들어오면 일은 이렇게 흘러갑니다.

  1. 사용자 A가 사진을 올린다. 썸네일이 thumbnails/IMG_0001.JPG.jpg로 저장되고, A의 좌표 행이 이 경로를 가리킨다.
  2. 사용자 B가 같은 이름의 사진을 올린다. 같은 경로에 B의 썸네일이 덮어써진다.
  3. A가 지도를 연다. A의 좌표 행은 그대로인데, 그 경로의 파일은 이제 B의 사진이다.

DB에는 아무 이상이 없습니다. 경로 문자열도, 소유자도 그대로입니다. 바뀐 것은 디스크 위 파일의 내용뿐이라 로그에도 흔적이 남지 않습니다.

소유자 검증은 통과했다

이 버그가 무서운 이유는 권한 검사를 제대로 해 두었는데도 막지 못한다는 점입니다. 썸네일은 GET /thumbnails/{id}로 서빙하고, RouteController::thumbnail()이 먼저 그 좌표 행이 요청한 사용자 소유인지 확인합니다. 다른 사람의 id를 넣어 보는 식의 접근은 여기서 걸러집니다.

그런데 이 검사가 확인하는 것은 DB 행의 소유자입니다. 행이 가리키는 파일의 내용이 누구 것인지는 확인하지 않습니다. 검증은 포인터를 보고, 사용자는 포인터 끝에 있는 바이트를 봅니다. 그 사이에서 공유 디렉터리가 다른 사람의 사진을 끼워 넣을 수 있었습니다.

그래서 이 문제는 권한 코드를 아무리 꼼꼼히 리뷰해도 보이지 않습니다. 컨트롤러도, 모델 쿼리도, 테스트도 전부 정상입니다. 원인은 저장 계층의 이름 규칙에 있었고, 그건 권한 문제로 분류되지 않는 코드였습니다.

위치 앱에서는 이 결과가 더 무겁습니다. 썸네일은 지도 위 특정 좌표, 특정 시각에 붙어서 나옵니다. 남의 사진 한 장이 아니라, 남의 사진이 내 동선 위에 놓이는 것입니다. 반대로 내 사진이 다른 사람의 동선 위에 나타날 수도 있습니다.

수정: 경로에 소유자를 넣는다

고친 방법은 단순합니다. 썸네일 생성기에 userId를 넘겨 저장 경로를 사용자별로 나눴습니다.

// 수정 후
public function generate(string $sourcePath, string $mediaItemId, int $userId): ?string
{
    // ...
    $userDir = $this->outputDir . '/' . $userId;
    $this->ensureOutputDir($userDir);
    $path = $userDir . '/' . $this->safeFileName($mediaItemId) . '.jpg';
}

경로는 thumbnails/{userId}/{안전한 파일명}.jpg가 됩니다. 이제 같은 파일명이 들어와도 다른 사용자의 디렉터리에는 닿지 않습니다. ThumbnailGeneratorInterface, TakeoutIngestService::ingest(), 이를 부르는 TakeoutController::upload()까지 시그니처를 함께 바꿨습니다. 인터페이스 주석에는 mediaItemId가 사용자당 유일하지 않을 수 있다고 적어 두었습니다. 다음에 이 코드를 만지는 사람이 같은 가정을 하지 않도록요.

수정보다 중요했던 건 재현 테스트입니다. 같은 파일명, 다른 사용자, 다른 크기의 이미지로 충돌을 그대로 재현했습니다.

public function testDifferentUsersWithSameSourceItemIdDoNotOverwriteEachOther(): void
{
    $sourceA = $this->makeJpegFixture(400, 400);
    $sourceB = $this->makeJpegFixture(200, 200);

    $generator = new GdThumbnailGenerator($this->outputDir);
    $pathA = $generator->generate($sourceA, 'IMG_0001.JPG', 1);
    $pathB = $generator->generate($sourceB, 'IMG_0001.JPG', 2);

    $this->assertNotSame($pathA, $pathB);

    // A의 썸네일이 B의 업로드로 덮어써지지 않아야 한다.
    [$widthA] = getimagesize($pathA);
    [$widthB] = getimagesize($pathB);
    $this->assertSame(300, $widthA);
    $this->assertSame(200, $widthB);
}

이 테스트는 경로가 다른지만 보지 않습니다. 이미지 크기를 다르게 만들어, 파일 내용이 실제로 각자의 것인지까지 확인합니다. 버그의 본질이 "경로는 맞는데 내용이 틀린" 것이었으니, 검증도 내용까지 내려가야 했습니다.

한 가지는 이 수정으로 해결되지 않습니다. 이미 덮어써진 기존 썸네일은 소급 복구되지 않아서, 별도로 다시 생성해야 합니다. 커밋 메시지에도 이 점을 남겨 두었습니다.

같은 날 함께 정리한 것: 파일은 DB보다 오래 산다

썸네일 경로를 손보고 나니 다른 질문이 따라왔습니다. 이 파일들은 언제 생기고 언제 사라지는가. 같은 날 보안 점검 항목 세 가지를 묶어 데이터 수명주기를 정리했습니다.

문제

남는 것

수정

이상치 필터에 걸릴 사진에도 썸네일을 먼저 생성

DB 참조 없는 고아 썸네일

필터를 먼저 돌리고, 남은 좌표에만 썸네일 생성

좌표 저장이 실패해도 썸네일은 그대로

고아 썸네일

저장 실패 시 방금 만든 썸네일 즉시 삭제

초고해상도 이미지 디코딩 중 메모리 부족으로 요청 중단

압축 해제된 원본이 임시 디렉터리에 잔존

약 40MP 넘는 이미지는 디코딩하지 않음 + iter:cleanup 커맨드를 크론으로 매시간 실행

계정을 지워도 파일과 외부 권한이 남음

썸네일 디렉터리, Google 접근 토큰

좌표·토큰·사용자 행을 트랜잭션으로 삭제한 뒤 썸네일 디렉터리 삭제, Google 토큰 폐기

세 번째 줄이 특히 기억에 남습니다. 원본은 보관하지 않는다는 정책을 세워 두었지만, finally 블록이 실행되지 못하는 치명적 오류 앞에서는 그 정책이 지켜지지 않습니다. 정책은 정상 경로만 보장하고, 비정상 경로는 별도의 안전망이 맡아야 합니다.

네 번째 줄은 개인정보처리방침의 "삭제 요청 시 전체 삭제" 약속을 실제 코드로 옮긴 것입니다. 파일은 DB 트랜잭션에 묶이지 않으니 커밋 후 마지막에 지웁니다. Google 토큰 폐기는 외부 호출이라 실패해도 로컬 삭제를 막지 않고 로그만 남깁니다. 앞의 프라이버시 버그를 고치면서 썸네일이 사용자별 디렉터리로 모였기 때문에, 계정 삭제 때 디렉터리 하나만 지우면 되는 구조가 됐습니다.

위치 데이터를 다룰 때 놓치기 쉬운 것

이번 일을 거치며 사진과 위치를 다루는 서비스에서 점검할 항목을 정리했습니다.

  • 사용자가 준 이름을 저장 키로 쓰지 않는다. 원본 파일명, 업로드 파일명은 사용자 사이에서 유일하지 않습니다. 저장 경로에는 소유자 id나 서버가 만든 식별자를 넣습니다.
  • 권한 검사가 무엇을 확인하는지 따져 본다. 행의 소유자를 확인했다고 그 행이 가리키는 파일의 주인까지 보장되지는 않습니다. 공유 저장소를 쓰는 모든 경로를 의심합니다.
  • 테스트는 내용까지 확인한다. "경로가 다르다"만으로는 부족합니다. 서로 다른 픽스처를 넣고, 꺼낸 결과가 각자의 것인지 검증합니다.
  • 파생 데이터도 원본만큼 민감하다. 원본을 버려도 썸네일은 사진이고, 좌표와 시각이 붙으면 동선이 됩니다. 썸네일, 캐시, 임시 파일도 같은 기준으로 다룹니다.
  • 비정상 종료 경로를 따로 본다. 메모리 부족이나 타임아웃이 나면 finally도 실행되지 않습니다. 남은 파일을 걷어낼 정리 작업을 주기적으로 돌립니다.
  • 삭제는 파일과 외부 권한까지 간다. DB 행만 지우는 삭제는 반쪽입니다. 파일 디렉터리와 OAuth 토큰까지 회수해야 "전체 삭제" 약속이 지켜집니다.
  • 고친 뒤에 이미 망가진 데이터를 따로 챙긴다. 코드 수정은 앞으로의 데이터만 바로잡습니다. 이미 오염된 데이터는 재생성이나 정리 작업이 따로 필요합니다.

마치며

돌아보면 고친 코드는 몇 줄에 불과합니다. 경로에 사용자 id 하나를 끼워 넣은 것이 전부입니다. 어려웠던 건 그 몇 줄이 필요하다는 걸 알아차리는 일이었습니다.

권한 검사는 보통 컨트롤러와 쿼리에서 찾습니다. 이번 구멍은 썸네일 파일 이름을 짓는 한 줄에 있었습니다. 개인 사진과 위치를 다루는 서비스라면, 사용자 데이터가 머무는 모든 곳에서 "이 이름은 누구 것인가"를 한 번씩 물어볼 필요가 있습니다.

참고