[SEC-006][기초] 서버 요청·파일 업로드·경로를 안전하게 다루기
페이지 정보

본문
[이번 수업]
SSRF는 사용자가 준 주소를 서버가 대신 요청해 허용하지 않은 대상에 닿는 문제입니다. 업로드는 위험한 파일이 저장·실행될 수 있고, 경로 조작은 외부 입력으로 만든 경로가 저장 구역을 벗어나는 문제입니다. 세 위험을 여러 방어층으로 줄이는 법을 배웁니다.
[선수지식]
HTTP 요청·응답, 입력 검증, 파일 권한을 알면 좋습니다. Python 3만 사용하며 요청이나 저장은 하지 않습니다.
[학습목표]
1. SSRF·위험한 업로드·경로 조작의 경계를 설명한다.
2. URL 허용 목록과 서버 생성 파일명의 역할을 이해한다.
3. 애플리케이션·네트워크·저장소 방어를 함께 설계한다.
[핵심개념]
SSRF 방어는 전체 URL 대신 짧은 ID를 받아 서버가 신뢰된 주소에 매핑하는 것부터 시작합니다. URL이 필요하면 허용할 HTTPS 호스트와 포트를 명시합니다. URL 파서는 안전성을 보장하지 않습니다. 요청 직전과 리다이렉트마다 DNS 결과를 확인하고 사설·루프백·링크 로컬·예약 주소를 거부합니다. 외부 통신과 시간·응답 크기도 제한합니다.
업로드는 확장자, Content-Type, 실제 파일 서명을 함께 검사하고 허용 형식과 크기만 받습니다. 서버가 만든 이름을 쓰고 웹 실행 경로 밖에 격리하며 권한 확인과 악성 파일 검사를 추가합니다.
경로는 사용자 문자열을 저장 루트와 바로 잇지 않습니다. 서버의 객체 ID를 경로에 매핑하는 편이 안전합니다. 이름이 꼭 필요하면 구분자·길이·확장자를 검사하고 최종 경로가 허용 루트 안인지 확인합니다.
[따라하기]
아래를 `defense_demo.py`로 저장합니다.
```python
from pathlib import Path
from secrets import token_hex
from urllib.parse import urlsplit
TRUSTED_HOSTS = {"api.example.test"}
def approved_url(raw):
try:
parsed = urlsplit(raw)
port = parsed.port
except ValueError:
return False
return (
parsed.scheme == "https"
and parsed.hostname in TRUSTED_HOSTS
and parsed.username is None
and parsed.password is None
and port in (None, 443)
)
def upload_target(name):
if len(name) > 80 or any(ch in name for ch in ("/", "\\", "\0")):
raise ValueError("허용되지 않은 파일명")
suffix = Path(name).suffix.lower()
if suffix != ".txt":
raise ValueError("허용되지 않은 형식")
return Path("quarantine") / f"{token_hex(8)}{suffix}"
print(approved_url("https://api.example.test/data"))
print(approved_url("http://api.example.test/data"))
target = upload_target("report.txt")
print(target.parent.name, target.suffix, target.name != "report.txt")
```
macOS·Linux는 `python3 defense_demo.py`, Windows PowerShell은 `python defense_demo.py`로 실행합니다.
```text
True
False
quarantine .txt True
```
HTTPS 허용 호스트만 통과하고 저장 대상은 격리 폴더의 새 이름이 됩니다. 이 코드는 DNS·IP 재검증, 파일 내용 검사, 접근 권한을 대신하지 않습니다.
[흔한 실수]
문자열 일부만 보고 URL을 안전하다고 판단하거나 검사 뒤 리다이렉트를 그대로 따르지 않습니다. 업로드의 Content-Type과 확장자만 믿거나 원래 이름을 저장 경로로 쓰지 않습니다. 최종 저장 위치 확인도 생략하지 않습니다.
[보안 주의]
요청 기능은 최소 권한으로 실행하고 내부 관리망 통신을 차단합니다. 업로드 폴더에는 실행 권한을 주지 말고 용량 제한과 감사 로그를 둡니다. 오류에 내부 주소나 실제 경로를 노출하지 않습니다. 본인 소유 로컬·격리 환경에서만 검증하며 타 시스템 주소를 시험하지 않습니다.
[직접 해볼 과제]
허용 호스트에 `files.example.test`를 추가해 HTTPS 주소를 확인하세요. `.md`도 허용하되 반환 경로가 항상 `quarantine` 아래인지 검사하는 조건을 추가합니다.
[확인문제]
1. URL 파싱만으로 SSRF 방어가 완성되지 않는 이유는 무엇인가요?
2. 업로드 원본 파일명 대신 서버 생성 이름을 쓰는 이유는 무엇인가요?
3. 경로 조작을 막기 위해 최종 경로에서 확인할 조건은 무엇인가요?
[다음 학습]
SEC-007에서는 암호화의 목적, 키 수명주기, TLS 인증서 검증과 안전한 키 보관을 배웁니다.
[공식 참고 자료]
- SSRF 방어 지침: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
- 파일 업로드 방어 지침: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- 경로 제한 오류 CWE-22: https://cwe.mitre.org/data/definitions/22.html
- URL 파싱 보안 주의: https://docs.python.org/3/library/urllib.parse.html
SSRF는 사용자가 준 주소를 서버가 대신 요청해 허용하지 않은 대상에 닿는 문제입니다. 업로드는 위험한 파일이 저장·실행될 수 있고, 경로 조작은 외부 입력으로 만든 경로가 저장 구역을 벗어나는 문제입니다. 세 위험을 여러 방어층으로 줄이는 법을 배웁니다.
[선수지식]
HTTP 요청·응답, 입력 검증, 파일 권한을 알면 좋습니다. Python 3만 사용하며 요청이나 저장은 하지 않습니다.
[학습목표]
1. SSRF·위험한 업로드·경로 조작의 경계를 설명한다.
2. URL 허용 목록과 서버 생성 파일명의 역할을 이해한다.
3. 애플리케이션·네트워크·저장소 방어를 함께 설계한다.
[핵심개념]
SSRF 방어는 전체 URL 대신 짧은 ID를 받아 서버가 신뢰된 주소에 매핑하는 것부터 시작합니다. URL이 필요하면 허용할 HTTPS 호스트와 포트를 명시합니다. URL 파서는 안전성을 보장하지 않습니다. 요청 직전과 리다이렉트마다 DNS 결과를 확인하고 사설·루프백·링크 로컬·예약 주소를 거부합니다. 외부 통신과 시간·응답 크기도 제한합니다.
업로드는 확장자, Content-Type, 실제 파일 서명을 함께 검사하고 허용 형식과 크기만 받습니다. 서버가 만든 이름을 쓰고 웹 실행 경로 밖에 격리하며 권한 확인과 악성 파일 검사를 추가합니다.
경로는 사용자 문자열을 저장 루트와 바로 잇지 않습니다. 서버의 객체 ID를 경로에 매핑하는 편이 안전합니다. 이름이 꼭 필요하면 구분자·길이·확장자를 검사하고 최종 경로가 허용 루트 안인지 확인합니다.
[따라하기]
아래를 `defense_demo.py`로 저장합니다.
```python
from pathlib import Path
from secrets import token_hex
from urllib.parse import urlsplit
TRUSTED_HOSTS = {"api.example.test"}
def approved_url(raw):
try:
parsed = urlsplit(raw)
port = parsed.port
except ValueError:
return False
return (
parsed.scheme == "https"
and parsed.hostname in TRUSTED_HOSTS
and parsed.username is None
and parsed.password is None
and port in (None, 443)
)
def upload_target(name):
if len(name) > 80 or any(ch in name for ch in ("/", "\\", "\0")):
raise ValueError("허용되지 않은 파일명")
suffix = Path(name).suffix.lower()
if suffix != ".txt":
raise ValueError("허용되지 않은 형식")
return Path("quarantine") / f"{token_hex(8)}{suffix}"
print(approved_url("https://api.example.test/data"))
print(approved_url("http://api.example.test/data"))
target = upload_target("report.txt")
print(target.parent.name, target.suffix, target.name != "report.txt")
```
macOS·Linux는 `python3 defense_demo.py`, Windows PowerShell은 `python defense_demo.py`로 실행합니다.
```text
True
False
quarantine .txt True
```
HTTPS 허용 호스트만 통과하고 저장 대상은 격리 폴더의 새 이름이 됩니다. 이 코드는 DNS·IP 재검증, 파일 내용 검사, 접근 권한을 대신하지 않습니다.
[흔한 실수]
문자열 일부만 보고 URL을 안전하다고 판단하거나 검사 뒤 리다이렉트를 그대로 따르지 않습니다. 업로드의 Content-Type과 확장자만 믿거나 원래 이름을 저장 경로로 쓰지 않습니다. 최종 저장 위치 확인도 생략하지 않습니다.
[보안 주의]
요청 기능은 최소 권한으로 실행하고 내부 관리망 통신을 차단합니다. 업로드 폴더에는 실행 권한을 주지 말고 용량 제한과 감사 로그를 둡니다. 오류에 내부 주소나 실제 경로를 노출하지 않습니다. 본인 소유 로컬·격리 환경에서만 검증하며 타 시스템 주소를 시험하지 않습니다.
[직접 해볼 과제]
허용 호스트에 `files.example.test`를 추가해 HTTPS 주소를 확인하세요. `.md`도 허용하되 반환 경로가 항상 `quarantine` 아래인지 검사하는 조건을 추가합니다.
[확인문제]
1. URL 파싱만으로 SSRF 방어가 완성되지 않는 이유는 무엇인가요?
2. 업로드 원본 파일명 대신 서버 생성 이름을 쓰는 이유는 무엇인가요?
3. 경로 조작을 막기 위해 최종 경로에서 확인할 조건은 무엇인가요?
[다음 학습]
SEC-007에서는 암호화의 목적, 키 수명주기, TLS 인증서 검증과 안전한 키 보관을 배웁니다.
[공식 참고 자료]
- SSRF 방어 지침: https://cheatsheetseries.owasp.org/cheatsheets/Server_Side_Request_Forgery_Prevention_Cheat_Sheet.html
- 파일 업로드 방어 지침: https://cheatsheetseries.owasp.org/cheatsheets/File_Upload_Cheat_Sheet.html
- 경로 제한 오류 CWE-22: https://cwe.mitre.org/data/definitions/22.html
- URL 파싱 보안 주의: https://docs.python.org/3/library/urllib.parse.html
- 이전글[AI-006][기초] Transformer와 대규모 언어모델의 작동 원리 26.09.02
- 다음글[DEVOPS-006][기초] 파드·디플로이먼트·서비스 연결하기 26.09.02
댓글목록
등록된 댓글이 없습니다.
