
파이썬 인터프리터에서 딱 한 줄만 실행해봐도 문제가 바로 드러납니다. os.path.join("/var/app/uploads", "../../../etc/cron.d/x")을 넣고 os.path.normpath()로 정규화하면 결과는 /etc/cron.d/x가 됩니다. 업로드 디렉터리 안에 저장될 줄 알았던 파일이 시스템 크론 폴더로 튀어나가는 셈입니다. FastAPI 업로드 API에서 이런 경로 조작 취약점이 생기는 이유는 프레임워크가 파일명을 검증해주지 않기 때문이고, 이 글에서는 실제로 동작하는 코드로 막는 방법을 정리했습니다.
FastAPI가 파일명을 검증해주지 않는 구조적 이유
FastAPI의 UploadFile은 Starlette의 UploadFile을 그대로 감싸고 있고, 이 객체의 .filename 값은 클라이언트가 보낸 Content-Disposition 헤더에서 그대로 파싱됩니다. 서버 쪽에서 별도로 경로 구분자를 걸러내는 로직은 들어있지 않습니다.
그래서 개발자가 open(f"./uploads/{file.filename}", "wb")처럼 원본 파일명을 그대로 경로에 이어 붙이면, 파일명 자리에 ../../ 같은 상대 경로 조작 문자열을 넣는 것만으로 업로드 디렉터리를 벗어난 위치에 쓰기가 가능해집니다. 이건 FastAPI만의 문제가 아니라 사용자 입력을 파일시스템 경로에 그대로 연결하는 모든 웹 프레임워크에 공통으로 해당하는 디렉터리 순회 공격 유형입니다.
원본 파일명을 그대로 넣으면 무슨 일이 벌어지나요?
curl로 아래처럼 요청을 보내면 filename 값에 경로 조작 문자열을 자유롭게 넣을 수 있습니다.
curl -X POST http://localhost:8000/upload \
-F "file=@payload.txt;filename=../../../../etc/cron.d/evil"

서버 코드가 다음처럼 짜여 있다고 가정해보겠습니다.
from fastapi import FastAPI, UploadFile, File
app = FastAPI()
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
save_path = f"./uploads/{file.filename}"
with open(save_path, "wb") as f:
f.write(await file.read())
return {"filename": file.filename}
이 코드는 save_path를 만들 때 file.filename을 검증 없이 그대로 문자열 결합에 써버립니다. 요청에 담긴 filename 값이 ../../../../etc/cron.d/evil이면 실제 쓰기 위치는 업로드 폴더가 아니라 크론 설정 디렉터리가 됩니다. uvicorn 프로세스가 해당 경로에 쓰기 권한을 가진 계정으로 돌고 있다면 파일 하나 업로드로 시스템 파일이 덮어써지는 상황까지 갈 수 있습니다.
안전한 저장 경로를 만드는 실전 코드
핵심은 두 가지입니다. 원본 파일명을 저장 경로 조합에 절대 쓰지 않는 것, 그리고 최종 경로가 업로드 디렉터리 안쪽인지 다시 한번 확인하는 것입니다. 아래는 Python 3.11, FastAPI 0.110대 기준으로 실제 동작하는 예시입니다.
import uuid
from pathlib import Path
from fastapi import FastAPI, UploadFile, File, HTTPException
app = FastAPI()
UPLOAD_DIR = Path("/var/app/uploads").resolve()
ALLOWED_EXT = {".jpg", ".jpeg", ".png", ".pdf"}
@app.post("/upload")
async def upload_file(file: UploadFile = File(...)):
ext = Path(file.filename).suffix.lower()
if ext not in ALLOWED_EXT:
raise HTTPException(status_code=400, detail="허용되지 않는 확장자입니다.")
safe_name = f"{uuid.uuid4().hex}{ext}"
dest = (UPLOAD_DIR / safe_name).resolve()
if not dest.is_relative_to(UPLOAD_DIR):
raise HTTPException(status_code=400, detail="잘못된 저장 경로입니다.")
with dest.open("wb") as f:
f.write(await file.read())
return {"stored_name": safe_name, "original_name": file.filename}
여기서 파일명은 uuid.uuid4().hex로 새로 만들고, 확장자만 원본에서 뽑아 화이트리스트와 대조합니다. 원본 파일명은 응답이나 DB의 별도 컬럼에만 남기고 실제 저장 경로 조합에는 전혀 관여시키지 않습니다. Path.is_relative_to()는 파이썬 3.9부터 추가된 메서드라서, 그보다 낮은 버전을 쓰는 환경이면 dest.parts[:len(UPLOAD_DIR.parts)] == UPLOAD_DIR.parts 같은 방식으로 대체해야 합니다.
확장자는 화이트리스트로, 파일명은 UUID로 바꿔보세요
블랙리스트 방식으로 .., /, \ 문자만 걸러내는 방법도 종종 보이는데, 이 방식은 인코딩 우회나 유니코드 변형 문자에 취약할 수 있어 권장하지 않습니다. 대신 저장할 때 쓰는 실제 파일명 자체를 서버가 생성해버리면 그 클래스의 공격을 통째로 없앨 수 있습니다.

확장자 검증도 Content-Type 헤더만 믿기보다는 실제 파일 내용을 확인하는 편이 안전합니다. 이미지 업로드라면 Pillow로 Image.open() 후 verify()를 호출해 실제 이미지 포맷인지 재확인하는 식입니다. Content-Type은 클라이언트가 임의로 조작해서 보낼 수 있는 값이라 단독으로는 신뢰하기 어렵습니다.
resolve()로 검증했는데 왜 여전히 뚫릴 수 있나요?
경로 검증 로직 자체에도 함정이 있습니다. str(dest).startswith(str(UPLOAD_DIR)) 방식으로 비교하면 /var/app/uploads와 /var/app/uploads_backup처럼 접두사만 같은 다른 디렉터리를 같은 디렉터리로 오인할 수 있습니다. is_relative_to()를 쓰면 경로 계층 구조 자체를 비교하기 때문에 이 문제는 피할 수 있습니다.
또 하나 짚어야 할 트레이드오프는 심볼릭 링크입니다. resolve()는 심볼릭 링크를 따라가서 실제 경로로 바꿔주는데, 만약 업로드 요청 처리 시점 사이에 공격자가 업로드 디렉터리 안에 외부를 가리키는 심볼릭 링크를 미리 만들어둘 수 있는 상황이라면(예: 같은 디렉터리에 쓰기 권한을 가진 다른 프로세스가 있는 경우) 검증과 실제 쓰기 사이의 시간차를 노리는 공격이 이론적으로 가능합니다. 이건 파일명을 UUID로 완전히 새로 생성하는 방식으로도 막을 수 없는 별개의 문제라서, 업로드 디렉터리에 대한 쓰기 권한 자체를 애플리케이션 프로세스로만 제한하는 인프라 설정이 함께 필요합니다.
체크리스트: 업로드 API 배포 전에 확인할 점
아래 표는 지금까지 다룬 검증 지점을 배포 전 점검용으로 정리한 것입니다.
| 점검 항목 | 확인 방법 | 비고 |
|---|---|---|
| 원본 파일명 사용 여부 | 저장 경로 조합 코드에서 file.filename 직접 사용 여부 검색 |
UUID 기반 파일명으로 대체 |
| 확장자 검증 | 화이트리스트 대조 로직 존재 여부 | 블랙리스트 방식 지양 |
| 최종 경로 검증 | is_relative_to() 또는 동등한 계층 비교 |
문자열 startswith 단독 사용 지양 |
| Content-Type 신뢰 여부 | 실제 파일 시그니처 재확인 로직 존재 여부 | 이미지라면 Pillow verify() 등 |
| 업로드 디렉터리 권한 | 애플리케이션 프로세스 외 쓰기 권한 제한 | 심볼릭 링크 공격 대비 |
이 다섯 항목 중 하나라도 비어 있으면 FastAPI 업로드 API에서 경로 조작 취약점이 남아있을 가능성이 높습니다. 특히 원본 파일명을 저장 경로에 그대로 쓰는 코드는 기존 프로젝트에 의외로 자주 남아있는 패턴이라, 코드베이스 전체에서 file.filename을 검색해 저장 경로 조합에 쓰이는 곳이 있는지부터 확인해보시길 권합니다.
