TT Lab
블로그 시작하기
배우기 러닝패스 코스

인가와 입력 처리를 코드로 지키기

서버가 대신 가져올 곳은 정해 둔다

TT Lab 에서 이어서 보기

한 줄 요약

서버가 사용자 대신 URL 을 가져오는 기능은 정해 둔 https 호스트만 가져오고, 리다이렉트도 한 걸음마다 같은 규칙으로 다시 확인합니다. 규칙에 걸리면 요청을 한 번도 보내지 않습니다.

왜 이 요구가 필요한가

링크 미리보기, 웹훅 시험 발송, 이미지 주소로 가져오기 같은 기능은 사용자가 준 주소로 서버가 요청을 보냅니다. 서버는 사용자의 브라우저가 닿지 못하는 곳에 닿습니다. 같은 망의 관리 화면, 데이터베이스, 클라우드의 메타데이터 주소가 그렇습니다. 사용자가 주소를 고를 수 있다면 사용자가 서버의 네트워크 위치를 빌려 쓰는 셈이고, 이 결함을 SSRF(Server-Side Request Forgery)라고 부릅니다.

막을 곳을 나열하는 방식(사설 대역 금지, localhost 금지)은 잘 무너집니다. IP 를 쓰는 표기법이 여러 가지이고, 이름이 나중에 다른 주소로 풀리기도 합니다. OWASP 의 SSRF Prevention Cheat Sheet 는 금지 목록이 우회되기 쉽다고 적고 허용 목록을 권합니다. 이 기능이 정말로 가져와야 하는 곳은 대개 몇 군데뿐이기 때문입니다.

허용 목록을 만들어도 두 군데서 다시 열립니다. 하나는 호스트 비교입니다. endswith("example.com") 은 notexample.com 도 통과시키고, 주소 문자열을 직접 잘라 호스트를 꺼내면 @ 와 : 가 섞인 주소에서 사람이 읽는 호스트와 파서가 읽는 호스트가 달라집니다. 다른 하나는 리다이렉트입니다. 처음 주소가 허용된 곳이어도 그곳이 302 로 다른 곳을 가리키면, HTTP 클라이언트가 알아서 따라가는 순간 허용 목록은 의미가 없어집니다.

어떻게 지키나

def check_url(url):
    parts = urlsplit(url)
    host = parts.hostname or ""                  # 소문자로 바뀐 호스트
    if parts.scheme != "https":
        raise NotAllowed("scheme", host)
    if parts.username is not None or parts.password is not None:
        raise NotAllowed("userinfo", host)
    if host not in ALLOWED_HOSTS:                # 정확히 같은 이름만
        raise NotAllowed("host", host)
    if parts.port not in (None, 443):
        raise NotAllowed("port", host)

리다이렉트는 클라이언트에게 맡기지 않고(follow_redirects=False) 직접 따라갑니다. 3xx 응답의 Location 을 지금 주소 기준으로 풀고(urljoin), 그 주소를 다시 check_url 로 확인한 뒤에야 다음 요청을 보냅니다. 따라가는 횟수에도 상한(3번)을 둡니다. 같은 주소를 도는 리다이렉트가 서버를 붙잡지 못하게 하려는 것입니다.

시험은 가짜 네트워크로 합니다. httpx.MockTransport 로 만든 클라이언트를 넘기면 실습 파드처럼 바깥으로 나갈 수 없는 곳에서도 리다이렉트까지 흉내 낼 수 있고, 처리기가 받은 요청을 목록에 모으면 '요청을 보내지 않았다' 는 것까지 확인할 수 있습니다. 요구사항이 응답 코드뿐 아니라 보낸 요청을 말하는 이유입니다.

남는 한계도 있습니다. 허용 목록의 이름이 DNS 에서 어떤 주소로 풀리는지는 이 확인이 보지 않습니다. 그 이름의 DNS 를 남이 바꿀 수 있다면 이름 확인만으로는 부족하고, 풀린 주소를 확인한 뒤 그 주소로 연결을 고정해야 합니다. 이 저장소의 실습 파드가 그렇듯 네트워크 정책으로 바깥 통신 자체를 좁히는 것도 함께 갑니다.

현장에서 만나는 모습

웹훅을 등록받는 기능, 문서 변환기가 원격 이미지를 불러오는 기능, PDF 생성기가 HTML 안의 주소를 따라가는 기능이 모두 이 범주입니다. 기능을 만든 사람은 '사용자가 자기 서버 주소를 넣겠지' 라고 생각하기 때문에 서버가 어디까지 닿는지를 따지지 않습니다. 감사 로그에는 막은 이유와 호스트만 남기고, 주소 전체는 남기지 않습니다. 공유 링크의 경로와 쿼리에는 토큰과 서명이 실려 오기 때문입니다. 이 모듈의 비밀값 이야기는 다음 코스 '비밀번호·키·비밀값 다루기' 로 이어집니다.

English corner

다음 실습에서 할 것

링크 미리보기 서비스가 아무 주소나 가져오고, 리다이렉트는 httpx 가 알아서 따라갑니다. 요구 A(정해 둔 https 호스트만, 걸리면 요청을 보내지 않음)와 요구 B(리다이렉트도 한 걸음마다, 3번까지)를 MockTransport 로 시험하고 고칩니다.