문자열이 아니라 실제 경로로 판단한다
한 줄 요약
사용자가 준 경로로 파일을 열 때는 링크를 끝까지 따라간 실제 경로가 기준 디렉터리 안인지 경로 조각 단위로 확인합니다. 파일을 저장할 때는 이름을 규칙에 맞는 것만 받고, 그 자리에 링크가 있으면 쓰지 않습니다.
왜 이 요구가 필요한가
내려받기 API 가 os.path.join(base, relpath) 로 파일을 연다고 해 봅시다. relpath 가 reports/2026/q1.csv 일 때는 문제가 없지만, ../ 가 섞이면 기준 디렉터리 밖으로 올라갑니다. 더 놓치기 쉬운 경우도 있습니다. os.path.join 은 두 번째 인자가 절대 경로이면 앞의 base 를 버립니다. 그리고 base 안에 밖을 가리키는 심볼릭 링크가 하나 있으면, 경로 문자열은 안쪽처럼 보여도 실제로 열리는 파일은 바깥에 있습니다.
흔한 수정 둘이 각각 하나씩을 놓칩니다. 경로를 정규화한 뒤 startswith(base) 로 비교하면, base 가 /srv/data 일 때 /srv/data-old/... 도 같은 글자로 시작하므로 안쪽으로 보입니다. os.path.abspath 나 normpath 로 정규화하면 .. 는 정리되지만 링크는 그대로라서, 링크 하나가 밖을 가리켜도 알아채지 못합니다. 두 수정 모두 가장 흔한 시험(../../etc 를 막는가)은 통과하기 때문에 오래 살아남습니다.
올리기도 같은 구조입니다. 이름에 슬래시나 .. 가 들어오면 uploads 밖에 쓰고, 점으로 시작하는 이름은 숨김 설정 파일이 됩니다. 그리고 저장 자리에 이미 링크가 있으면, 그 링크를 따라 바깥 파일을 덮어씁니다.
어떻게 지키나
def resolve_download(base, relpath):
root = Path(base).resolve()
path = (root / relpath).resolve() # 링크를 끝까지 따라간 실제 경로
if not path.is_relative_to(root): # 문자열이 아니라 경로 조각으로 비교
raise PermissionError(relpath)
if not path.is_file():
raise FileNotFoundError(relpath)
return str(path)
NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}")
Path.resolve() 는 .. 와 링크를 모두 풀고, root / "/etc/hostname" 처럼 절대 경로가 오면 root 를 버린 결과를 내므로 그다음 is_relative_to 비교에서 걸립니다. is_relative_to 는 경로를 조각 단위로 비교해 data 와 data-old 를 구별합니다. 막힌 파일과 없는 파일은 HTTP 에서 같은 404 로 답하고, 감사 로그에는 막은 것만 남깁니다.
이름 규칙은 허용 목록입니다. 막을 글자를 나열하지 않고 받을 모양을 적습니다. re.fullmatch 로 이름 전체가 모양에 맞는지 봐야 하고, re.match 나 search 를 쓰면 앞부분만 맞아도 통과합니다. 저장 자리는 is_symlink() 로 한 번 더 확인합니다.
남는 한계도 적어 둡니다. 확인한 순간과 파일을 여는 순간 사이에 누군가 링크를 바꿔치기하는 경쟁은 이 방법으로 완전히 막지 못합니다. 그 수준이 필요하면 os.open 의 O_NOFOLLOW 나 디렉터리 파일 기술자를 기준으로 여는 방식을 써야 하고, 무엇보다 사용자가 쓸 수 있는 디렉터리와 서비스가 읽는 디렉터리를 나누는 설계가 먼저입니다.
현장에서 만나는 모습
보고서 내려받기, 첨부 파일 미리보기, 이미지 썸네일, 압축 파일 풀기가 이 결함이 사는 자리입니다. 특히 압축 파일 안의 항목 이름은 사용자가 정한 경로라서, 풀 때도 같은 확인이 필요합니다. 이 결함에는 'zip slip' 이라는 이름까지 붙어 있고, 여러 언어의 압축 라이브러리가 같은 문제로 고쳐졌습니다. 경로 문제를 '파일 이름에 슬래시를 금지' 로 막으면 하위 디렉터리를 쓰는 정상 기능까지 막혀 결국 누군가 그 검사를 풀어 버립니다. 실습의 6단계가 하위 디렉터리 내려받기와 안쪽을 가리키는 링크를 기능 시험으로 두는 이유입니다.
English corner
- 「path traversal」 기준 디렉터리 밖으로 나가는 결함의 이름입니다. 'directory traversal' 이라고도 합니다.
- 「canonical path」 링크와
..를 모두 푼 경로입니다. "Compare canonical paths, not strings." 가 이 모듈의 요약입니다. - 「TOCTOU (time of check to time of use)」 확인한 때와 쓰는 때 사이의 틈입니다. 위에 적은 남는 한계가 바로 이것입니다.
다음 실습에서 할 것
파일 보관함이 os.path.join 으로 내려받기 경로를 만들고, 올리는 이름을 그대로 이어 붙입니다. 요구 A(실제 경로가 기준 안일 때만)와 요구 B(이름 규칙과 링크 자리 거절)를 tmp_path 의 디렉터리와 링크로 시험하고 고칩니다.