보안

LLM이 제안한 URL을 안전하게 가져오기: Go로 구현한 SSRF 가드

모델이 반환한 URL은 신뢰할 수 없는 입력입니다. 연결 시 IP 검사, DNS 리바인딩 방어, 홉별 리디렉션 검증, 페이지 검증 기능을 갖춘 Go 페처입니다.

이 글은 영어 원문을 AI 모델이 번역한 것입니다. 표현이 원문과 다를 수 있습니다. 영어 원문 보기

점점 더 많은 백엔드가 언어 모델에게 URL을 찾아 가져오도록 요청하고 있습니다. 검색 기반 모델이 공식 웹사이트를 제안하고, 에이전트가 문서에서 추출한 링크를 따라가며, 파이프라인이 모델이 선택한 페이지로 레코드를 보강합니다. 서버가 모델에서 나온 URL로 HTTP 요청을 보내는 순간, 전형적인 SSRF(서버 측 요청 위조) 공격 노출 영역이 만들어지며, 일반적인 "호스트 이름 검증" 조언만으로는 충분하지 않습니다. 이 게시물에서는 모델 출력을 신뢰할 수 없는 입력으로 취급하는 프로덕션 Go 페처를 단계별로 살펴봅니다. 여기에는 정적 URL 검사, DNS 리바인딩에 대응하는 다이얼 시점 IP 고정, 표준 라이브러리 헬퍼가 놓치는 범위를 포괄하는 주소 차단 목록, 홉별 리디렉션 재검증, 그리고 가져온 페이지가 실제로 원했던 페이지인지 확인하는 최종 검사가 포함됩니다.

모델이 반환한 URL이 신뢰할 수 없는 입력인 이유

모델이 제공하는 URL은 사용자가 제어할 수 없는 세 주체에 의해 형성됩니다. 첫째, 모델 자체가 환각을 일으키거나 유사한 도메인을 선택할 수 있습니다. 둘째, 모델이 검색 도구를 사용할 때 반환되는 링크는 종종 검색 공급자가 소유한 추적 리디렉터이므로, 보이는 URL과 실제 방문하게 될 URL이 다릅니다. 셋째, 그리고 최악의 경우, 모델이 읽은 콘텐츠가 모델을 조종할 수 있습니다. 즉, 페이지에 모델이 공격자가 선택한 URL을 생성하도록 유도하는 텍스트가 포함될 수 있습니다. 프롬프트 주입은 "모델이 링크를 제안했다"를 몇 단계 더 거쳐 "공격자가 링크를 제안했다"로 바꿉니다.

서버 관점에서 이는 사용자가 제출한 웹훅 URL과 동일한 위협입니다. 중요한 공격은 서버 측 요청 위조(Server-Side Request Forgery)입니다. 즉, 백엔드가 자신만 도달할 수 있는 대상에 요청을 보내도록 하는 것입니다. 전형적인 목표물은 서비스 계정 토큰을 제공하는 클라우드 메타데이터 엔드포인트와 사설 네트워크의 모든 호출자를 신뢰하는 내부 서비스입니다. 만약 페처(fetcher)가 클라우드 VM이나 컨테이너 플랫폼에서 실행된다면, 두 가지 모두 HTTP 요청 한 번으로 접근할 수 있습니다.

따라서 설계 규칙은 간단합니다. 페처는 URL 내용이나 DNS가 두 번째 조회에서 무엇을 말하든 상관없이, 자신이 여는 모든 연결이 공용 주소로 향하도록 자체적으로 강제해야 합니다.

정적 URL 검사가 먼저 실행되지만, 그것만으로는 충분하지 않습니다

비용이 적게 드는 검사가 먼저 실행되어 네트워크 작업이 시작되기 전에 대부분의 불필요한 데이터를 거부합니다:

func validateFetchURL(u *url.URL) error {
    if u.Scheme != "http" && u.Scheme != "https" {
        return fmt.Errorf("scheme not allowed: %q", u.Scheme)
    }
    if u.User != nil {
        return fmt.Errorf("userinfo in URL rejected")
    }
    switch u.Port() {
    case "", "80", "443", "8080":
    default:
        return fmt.Errorf("port not allowed: %s", u.Port())
    }
    host := u.Hostname()
    if host == "" {
        return fmt.Errorf("empty host")
    }
    if strings.EqualFold(host, "metadata.google.internal") {
        return fmt.Errorf("metadata host rejected")
    }
    return nil
}

스킴 고정은 file, gopher 및 관련 프로토콜을 차단합니다. userinfo를 거부하면 https://[email protected]/ 혼동 기법을 막을 수 있습니다. 포트 허용 목록은 fetcher가 일반적인 포트 스캐너로 사용되는 것을 막습니다. 명명된 메타데이터 호스트는 공격자가 GCP에서 가장 먼저 시도하는 호스트 이름이므로 특별히 처리할 가치가 있습니다.

이 함수가 의도적으로 수행하지 않는 작업은 호스트 이름을 해석하고 IP를 확인하는 것입니다. 여기서는 이 작업이 자연스럽게 느껴질 수 있지만, 바로 그 실수가 다음 허점을 만듭니다. 즉, 유효성 검사 중에 호스트 이름을 해석하고 연결 중에 HTTP 클라이언트가 다시 해석하도록 하면 두 결과가 일치하지 않을 수 있습니다.

한 번 확인, 모든 IP 검사, 확인된 주소로 연결

DNS 리바인딩은 검사 시점과 사용 시점의 차이를 이용하는 버그(time-of-check to time-of-use bug)입니다. 공격자가 제어하는 네임서버는 유효성 검사 쿼리에는 무해한 공용 주소로 응답한 다음, 클라이언트의 연결 쿼리에는 메타데이터 주소나 내부 RFC 1918 주소로 응답합니다. 검사는 통과했지만, 요청은 결국 내부 대상으로 전송됩니다. 낮은 TTL은 이 공격을 실제로 신뢰할 수 있게 만듭니다.

해결책은 또 다른 검사를 추가하는 것이 아니라, 다이얼러 내부에서 주소 확인과 정책 결정을 수행한 다음 방금 승인한 리터럴 IP로 연결하는 구조적인 방식입니다. Go에서는 http.Transport가 사용자 지정 DialContext를 허용하므로 이 방법을 깔끔하게 구현할 수 있습니다:

func safeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
    host, port, err := net.SplitHostPort(addr)
    if err != nil {
        return nil, err
    }
    ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
    if err != nil || len(ips) == 0 {
        return nil, fmt.Errorf("resolve failed for %s", host)
    }
    for _, ip := range ips {
        if !publicIP(ip) {
            return nil, fmt.Errorf("non-public IP blocked: %s", ip)
        }
    }
    d := &net.Dialer{Timeout: 10 * time.Second}
    var lastErr error
    for _, ip := range ips {
        conn, dialErr := d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
        if dialErr == nil {
            return conn, nil
        }
        lastErr = dialErr
    }
    return nil, lastErr
}

보안에서 중요한 두 가지 세부 사항이 있습니다. 클라이언트가 반환된 주소 중 어느 것으로든 장애 조치(fail over)할 수 있으므로, 첫 번째 주소뿐만 아니라 반환된 모든 주소가 정책을 통과해야 합니다. 그리고 다이얼(dial)은 호스트 이름으로 다시 돌아가지 않고 ip.String()으로 연결되므로, 공격자가 포이즈닝(poison)할 두 번째 해석(resolution)이 없습니다. TLS는 여전히 작동합니다. 전송 계층(transport)은 SNI 및 인증서 확인을 위해 원래 호스트 이름으로 핸드셰이크를 수행하므로, 검증된 IP에 TCP 연결을 고정해도 정확성에는 아무런 손실이 없습니다.

비공개 범위 확인이 놓치는 주소들

publicIP의 명백한 구현은 ip.IsPrivate()ip.IsLoopback()을 호출하고 멈춥니다. 이는 실질적인 허점을 남깁니다. 다음은 검토를 통과한 조건자(predicate)입니다.

func publicIP(ip net.IP) bool {
    if ip == nil {
        return false
    }
    if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
        ip.IsLinkLocalMulticast() || ip.IsMulticast() || ip.IsUnspecified() {
        return false
    }
    if v4 := ip.To4(); v4 != nil && v4[0] == 169 && v4[1] == 254 {
        return false
    }
    if v6 := ip.To16(); v6 != nil && ip.To4() == nil &&
        v6[0] == 0x00 && v6[1] == 0x64 && v6[2] == 0xff && v6[3] == 0x9b {
        return false
    }
    return true
}

링크-로컬 확인이 중요한 이유는 주요 클라우드의 메타데이터 주소인 169.254.169.254가 RFC 1918 사설 주소가 아닌 링크-로컬 주소이기 때문입니다. IsLinkLocalUnicast가 이를 처리하며, 명시적인 바이트 확인은 리팩터링으로 인해 절대 잃고 싶지 않은 계약을 위한 중복 장치입니다.

마지막 블록은 대부분의 페처(fetcher)가 놓치는 부분으로, 바로 NAT64 잘 알려진 접두사(well-known prefix)인 64:ff9b::/96입니다. NAT64 네트워크에서 해당 접두사 내의 IPv6 주소는 하위 32비트에 IPv4 주소를 인코딩하며, 변환기는 사용자의 연결을 해당 주소로 전달합니다. 필터를 통해 사설 IPv4 주소를 통과시킬 수 없는 공격자는 대신 NAT64로 인코딩된 IPv6 형식을 제출할 수 있습니다. IsPrivate는 해당 주소가 문제없다고 판단하는데, 이는 IPv6 주소로서는 전역 유니캐스트(global unicast) 주소이기 때문입니다. 이 접두사는 직접 거부해야 합니다. ::ffff: 접두사가 붙는 형식인 IPv4-매핑 IPv6 주소는 net 패키지가 표준 판별 함수(standard predicates)가 실행되기 전에 이를 정규화하기 때문에 Go에서는 덜 위험하지만, NAT64 접두사는 이러한 도움을 받지 못합니다.

리디렉션, 프록시, 응답 한도

검증된 첫 번째 홉은 두 번째 홉에 대해 아무것도 증명하지 못합니다. 모델이 제안한 링크의 경우 검색 리디렉터가 일반적인 사례이므로, 페처(fetcher)는 리디렉션을 따라야 하며 각 홉은 내부 어딘가에 도달할 새로운 기회입니다. 클라이언트는 모든 홉에서 동일한 정적 검증을 다시 실행하고 체인에 한도를 설정합니다.

client := &http.Client{
    Timeout: 20 * time.Second,
    Transport: &http.Transport{
        Proxy:       nil,
        DialContext: safeDialContext,
    },
    CheckRedirect: func(req *http.Request, via []*http.Request) error {
        if len(via) >= 5 {
            return fmt.Errorf("too many redirects")
        }
        return validateFetchURL(req.URL)
    },
}

Proxy: nil은 간과하기 쉽습니다. 기본 전송(transport)은 HTTP_PROXY 환경 변수를 따르며, 프록시된 연결은 대상이 아닌 프록시를 호출합니다. 이는 신중하게 작성한 다이얼러(dialer)가 프록시의 주소만 확인할 뿐 다른 것은 확인하지 않는다는 것을 의미합니다. 프록시를 끄면 다이얼 타임(dial-time) 가드가 권한을 유지하게 됩니다.

응답 측도 똑같이 신뢰하지 않아야 합니다. 하드 바이트 캡(hard byte cap)을 설정한 io.LimitReader를 통해 읽어서 악의적인 서버가 기가바이트 단위의 데이터를 전송할 수 없도록 하고, HTTP 200을 요구하며, 파싱하려는 내용과 Content-Type을 비교 확인하십시오. 리디렉션 후의 최종 URL을 정규화하여 기록하고, 이를 가져온 내용의 정식 주소로 취급하십시오. 리디렉션 전 URL을 저장하는 것은 리디렉터를 저장하는 것을 의미합니다.

SSRF 방지 fetch 파이프라인 1 2 3 4 리디렉션 홉 모델 URL 신뢰할 수 없음 정적 검사 스킴, 포트 검증된 다이얼 IP 고정 200 // 모든 리디렉션 홉은 유효성 검사를 다시 거침; 다이얼러는 절대 다시 확인하지 않음

요청한 페이지가 맞는지 확인

지금까지의 모든 것은 네트워크를 보호합니다. 정확성에 대해서는 아무것도 하지 않습니다. 모델은 완벽하게 공개적이고, 완벽하게 안전하며, 완전히 잘못된 페이지를 건네줄 수 있습니다. 가져오기(fetch)의 목적이 특정 엔티티의 공식 사이트임을 확인하는 것이라면, 가져오는 주체(fetcher)는 다운스트림의 어떤 것도 신뢰하기 전에 그 주장을 확인해야 합니다.

두 가지 간단한 확인으로 대부분의 잘못된 응답을 잡아낼 수 있습니다. 첫째, 언어입니다. 한국어 페이지를 예상한다면, 태그, 주석, 스크립트 및 스타일 블록을 제거한 후 보이는 텍스트에 최소 한 글자의 한글이 포함되어야 합니다. 둘째, 이름 근접성입니다. 예상되는 엔티티 이름과 페이지 텍스트 모두 공백을 제거하고 소문자로 변환하여 정규화한 다음, 텍스트에 해당 이름이 나타나도록 요구해야 합니다. 실제 페이지는 이름의 공백을 일관성 없게 사용하며, 내부 공백이 있거나 없는 동일한 이름이 일치해야 하므로 정규화가 중요합니다.

func normalizeForMatch(s string) string {
    var b strings.Builder
    for _, r := range strings.ToLower(s) {
        if !unicode.IsSpace(r) {
            b.WriteRune(r)
        }
    }
    return b.String()
}

인증과 함께, 소셜 프로필, 지도 목록, 블로그 플랫폼과 같이 도메인에서 정답이 될 수 없는 호스트에 대한 일반 도메인 차단 목록을 유지하고, 리디렉터가 끊임없이 해당 호스트로 이동하므로 페치 전과 모든 리디렉션 홉에서 이 목록을 확인하세요. 이는 보안 제어가 아닌 관련성 필터이지만, 동일한 위치에서 실행되며 페치를 절약합니다.

이 디자인이 해결하려고 하지 않는 것

몇 가지 솔직한 한계점입니다. 다이얼러는 사용자의 리졸버를 신뢰합니다. 만약 공격자가 사용자의 DNS 경로를 완전히 제어한다면, 이 페처보다 더 많은 것을 제어할 수 있습니다. IPv6에는 잘 알려진 것 외에 다른 변환 접두사가 있습니다. 만약 네트워크에서 사용자 지정 NAT64 접두사를 사용한다면, 이를 차단 목록에 추가하십시오. 페이지 검증은 휴리스틱이며, 단호한 공격자는 자신이 제어하는 페이지에 예상되는 이름을 넣을 수 있습니다. 이를 인증이 아닌, 우발적인 잘못된 등록을 줄이는 것으로 취급하십시오. 그리고 응답 제한은 이차 동작을 하는 파서에 HTML을 전달하는 경우에는 도움이 되지 않으므로, 파싱도 제한적으로 유지해야 합니다.

이 패턴은 LLM 출력을 넘어 일반화됩니다. 웹훅 URL, 사용자가 제출한 RSS 피드, 링크 미리보기, OAuth 리디렉션 대상은 모두 동일한 형태를 원합니다. 즉, 사전 정적 검사, 리터럴 주소에 대해 다이얼 시간에 정책 적용, 모든 홉 재검증, 응답 크기 및 유형 제한, 그리고 사용 사례에 대한 도메인 수준 온전성 검사입니다. 일단 사용자 지정 다이얼러를 갖춘 작은 패키지로 가드가 존재하게 되면, 이를 사용하는 것은 코드베이스의 모든 클라이언트에서 Transport를 한 번 교체하는 것만으로 가능합니다. 이는 보안 검토 결과와 기본값의 차이를 만듭니다.

관련 글