업로드 보안의 기본 모델은 사용자 입력, 격리 저장소, 검사한 버전, 공개 객체를 서로 다른 신뢰 경계로 두는 것입니다. 파일명·MIME·확장자는 표시 정보일 뿐이고, 실제 안전성은 파일 ID 매핑, 경로 해석, 제한된 파싱, 버전 고정, 공개 시점의 권한 검사로 만들어집니다.
파일명·MIME 주장과 파일 내용 안전성
사용자가 photo.jpg라는 이름과 image/jpeg MIME을 보냈다고 합시다. 둘 다 클라이언트가 지정한 주장입니다. 실제 내용이 다른 형식이거나 매우 큰 해상도로 디코더 메모리를 소모할 수 있습니다. 확장자 검사는 사용자 경험과 형식 선택에 도움되지만 안전한 파싱·저장·제공을 대신하지 않습니다.
실패를 진단할 때는 먼저 입력 주장이 아니라 서버가 계산한 파일 ID·크기·해시·실제 형식을 확인합니다. 그 다음 객체가 quarantine에 남아 있는지, 검사 결과가 어느 버전에 묶였는지, 공개 경계가 그 버전과 권한을 다시 확인했는지 순서대로 추적하면 “업로드 성공”과 “다운로드 가능”을 혼동하지 않게 됩니다.
다운로드 API에서도 파일명을 저장 경로에 직접 붙이면 문자열 입력이 서버 파일 선택 권한이 됩니다. 공개 파일명과 내부 저장 식별자를 분리하고, 인증된 사용자가 접근할 수 있는 파일 ID를 서버의 검증된 객체·경로에 매핑하는 구조를 우선합니다.
경로 문자열과 파일시스템 해석
| 잘못된 단순화 | 놓치는 경우 | 더 강한 경계 |
|---|---|---|
../ 제거 |
인코딩·구분자·절대 경로·충돌 | 파일 ID 매핑·허용 이름 |
| 문자열 prefix | /data/uploads-old도 통과 |
경로 구성요소의 포함 관계 |
| 정규화 후 열기 | 검사 뒤 symlink 교체 | directory-relative 접근·안전한 OS 옵션 |
| 최종 파일 링크만 금지 | 중간 디렉터리 링크 | 전체 경로 해석의 제한 |
예를 들어 /data/uploads로 시작하는지만 보면 형제 디렉터리 /data/uploads-old도 들어옵니다. 실제 경로 구성요소를 비교해야 합니다. realpath로 검사한 뒤 나중 open하는 사이 공격 가능한 디렉터리의 링크가 바뀌면 TOCTOU가 남습니다.
가능한 플랫폼에서는 먼저 신뢰한 업로드 디렉터리의 핸들을 잡고, 그 기준에서만 하위 경로를 열며 경로가 그 디렉터리 아래에만 머물도록 하는 기능을 사용하여 검사한 경로와 실제로 연 경로를 같은 기준에 둡니다. 경로 구성요소의 링크 해석을 제한하는 기능을 함께 사용하되, O_NOFOLLOW 하나가 중간 디렉터리 링크까지 모두 막는다고 일반화하지 않습니다.
Linux의 openat2 같은 해석 제약도 지원 버전과 파일시스템 계약을 확인한 뒤 적용해야 합니다. 업로드 writer 프로세스의 OS 권한은 부모 디렉터리나 다른 사용자의 객체를 바꾸지 못하는 범위로 좁힙니다.
접수 상태와 공개 상태 분리
그림을 글로 읽기
- 업로드 접수: 서버 ID·크기 상한
- quarantine: 일반 다운로드 금지
- 제한된 파싱·검사·변환
- 검사한 버전만 공개
- 업로드 접수 → quarantine: 접수 완료
- quarantine → 제한된 파싱·검사·변환: 격리 worker
- 제한된 파싱·검사·변환 → 검사한 버전만 공개: 정책 통과
검사 worker가 객체 K의 버전 v1을 확인했더라도, 공개 전에 같은 K를 덮어쓸 권한이 남아 있으면 실제 객체는 v2로 바뀔 수 있습니다. 따라서 검사 결과를 K의 버전과 해시에 묶고, 그 결과와 다른 버전이 공개 경계에 들어오지 않도록 임의 덮어쓰기를 막아야 합니다. DB의 파일 상태와 객체 저장 성공이 서로 다른 시스템에 나뉘어 있으면, 중단 뒤 미검사 객체와 실제 객체가 없는 포인터를 찾아 상태를 맞추는 복구 작업도 필요합니다.
업로드 성공 응답은 접수 완료이지 공개 가능 증명이 아닙니다. 변환 worker가 실패하면 상태와 오류를 남기고 제한된 재시도·수동 확인·보관 만료를 적용합니다. 무제한 재시도로 위험하거나 큰 파일을 계속 처리하지 않습니다.
압축 해제 자원 상한과 미리보기 격리 실행
압축 파일은 전송 크기 외에 해제 후 총 바이트·파일 수·깊이·압축 중첩·CPU 시간을 제한합니다. 각 항목의 경로·절대 경로·symlink·hardlink·중복 이름·기존 파일 덮어쓰기 정책을 검사합니다. 외부 입력에서 얻은 경로를 안전한 업로드 경로라고 재사용하지 않습니다.
이미지·문서 미리보기는 최소 권한의 격리 프로세스에서 실행하고 네트워크·비밀 자격·파일 접근·메모리·시간을 제한합니다. 파서가 외부 링크·매크로·스크립트를 처리하는 옵션도 확인합니다. 악성코드 검사 통과는 알려지지 않은 파서 결함과 모든 액티브 콘텐츠가 없다는 증명은 아닙니다.
원본·변환본 공개 정책 분리
안전한 이미지로 변환했어도 원본 다운로드 링크를 앱 origin에서 그대로 열면 다른 위험이 남을 수 있습니다. 원본·변환본의 접근권한을 따로 정하고 적절한 Content-Type·Content-Disposition·nosniff를 사용합니다. 비신뢰 액티브 콘텐츠를 제공해야 한다면 앱 쿠키·권한과 분리된 origin 및 sandbox 정책을 검토합니다. MIME 하나로 모든 브라우저 해석을 제한한다고 보지 않습니다.
삭제도 DB 표시만 바꿨는지 원본·변환본·CDN·미완료 업로드를 정리했는지 구분합니다. 보관 기간과 법적·제품 요구를 정하고, 내부 실제 경로·개인정보가 오류와 로그로 새지 않게 합니다.
합성 파일과 저장·공개 경쟁 조건
테스트용 디렉터리에서 형제 prefix·symlink 교체·중복 파일명·권한 없는 ID를 시험합니다. 파일 내용은 형식 불일치·작은 테스트 압축·변환 실패·중단을 안전한 크기로 구성합니다. 큰 자원 시험은 별도 격리와 상한 안에서만 수행합니다.
검사 중 객체 덮어쓰기, 검사 성공 후 DB 커밋 전 중단, 공개 후 원본 제거를 재현하고 사용자에게 어떤 버전이 보이는지 확인합니다. 기준은 확장자 통과가 아니라 미검사·무권한 내용이 공개되지 않고 실패 상태가 복구 가능하게 남는 것입니다. 이 노트는 설계와 시험 절차이며 실제 파일 검사 제품의 실행 결과가 아닙니다.
재현 연습은 형제 prefix 경로, 중간 디렉터리 symlink 교체, 검사 성공 뒤 버전 덮어쓰기, 압축 해제 상한 초과, 변환 worker 중단을 각각 시험하는 것입니다. 예상 결과는 허용 디렉터리 밖 경로가 열리지 않고, 검사한 v1 대신 v2가 조용히 공개되지 않으며, 상한·변환 실패가 격리 상태와 재처리 가능한 오류로 남는 것입니다. 실제 OS 경계와 parser 동작은 배포 환경에서 별도로 검증해야 합니다.