Jomel.Tr

Побеждаем SSRF в Go: почему allowlist хостов не спасает

Побеждаем SSRF в Go: почему allowlist хостов не спасает

Опубликовано в июне 2026

Почему очевидные защиты не работают и как сделать http.Client, который не подведёт

Server-Side Request Forgery (SSRF) — это когда атакующий заставляет ваш сервер сходить по адресу, который выбрал он. Классический трофей — сервис облачных метаданных на 169.254.169.254: один запрос отдаёт временные IAM-ключи. Это не теория — именно так развивался взлом Capital One в 2019-м. Любое место, где бэкенд принимает URL от пользователя — вебхуки, превью ссылок, аватар-по-URL, рендер PDF, импорт-из-URL — это кандидат на SSRF.

Сложность SSRF не в том, чтобы его заметить. А в том, что защиты, к которым тянется рука в первую очередь, — ровно те, что атакующий и ожидает.


Ловушка №1: блок-лист «localhost»

Первый инстинкт — отклонять очевидные внутренние цели:

// НЕ ДЕЛАЙТЕ ТАК: блок-лист «плохих» хостов
func isInternal(host string) bool {
    bad := []string{"localhost", "127.0.0.1", "169.254.169.254"}
    for _, b := range bad {
        if host == b {
            return true
        }
    }
    return false
}

У 127.0.0.1 есть десяток написаний, и все ведут на loopback, а блок-лист не ловит ни одного:

http://127.0.0.1          # то, что вы заблокировали
http://127.1              # сокращение, всё ещё loopback
http://2130706433         # 127.0.0.1 как десятичное число
http://0x7f000001         # ...как hex
http://[::1]              # IPv6 loopback
http://[::ffff:127.0.0.1] # IPv4 внутри IPv6
http://0.0.0.0            # «этот хост» на многих стеках

Блок-лист — это попытка угадать каждое плохое значение. Эту игру вы проиграете.


Ловушка №2: allowlist хостов, проверенный слишком рано

Правильная идея — allowlist: пускать только к одобренным хостам. Но важно, где выполнена проверка. Вот так выглядит безопасно, но таковым не является:

// НЕ ДЕЛАЙТЕ ТАК: проверяем хост, потом ходим по хосту
func fetch(rawURL string) (*http.Response, error) {
    u, err := url.Parse(rawURL)
    if err != nil {
        return nil, err
    }
    if !allowedHosts[u.Hostname()] {
        return nil, errors.New("host not allowed")
    }
    // ... проходит время ...
    return http.Get(rawURL) // второй, независимый DNS-резолв
}

Здесь две беды.

DNS rebinding (это TOCTOU). Проверка резолвит evil.example и видит публичный IP. Пропускает. Затем http.Get резолвит то же имя ещё раз — а DNS-сервер атакующего с TTL=0 теперь отвечает 127.0.0.1. Вы проверили одно, а соединились с другим. Хост никогда и не был тем, что стоит проверять; проверять нужно IP, к которому вы реально подключаетесь.

Редиректы. Даже если первый хоп — чистый хост из allowlist, сервер может ответить 302 Location: http://169.254.169.254/..., и дефолтный клиент Go радостно за ним пойдёт.


Решение: проверять резолвленный IP в момент соединения

Единственная проверка, которую нельзя «обогнать», — это проверка конкретного IP прямо перед тем, как сокет установит соединение. В Go для этого есть ровно такой хук: net.Dialer.Control вызывается после резолва имени и перед connect(2) и получает резолвленный ip:port. DNS rebinding нечего подменять: во что бы ни разрезолвилось имя — это и есть адрес, с которым мы вот-вот заговорим.

package safehttp

import (
    "context"
    "fmt"
    "net"
    "net/http"
    "syscall"
    "time"
)

// isDisallowedIP сообщает, ведёт ли ip туда, куда серверный «ходок» по URL
// не должен попадать никогда: loopback, приватные диапазоны RFC 1918,
// link-local (включая адрес облачных метаданных 169.254.169.254),
// unspecified и multicast. IPv4-в-IPv6 учитывается, т.к. методы net.IP
// внутри нормализуют адрес через To4.
func isDisallowedIP(ip net.IP) bool {
    return ip.IsLoopback() ||
        ip.IsPrivate() ||
        ip.IsLinkLocalUnicast() ||
        ip.IsLinkLocalMulticast() ||
        ip.IsUnspecified() ||
        ip.IsMulticast()
}

// safeControl выполняется после резолва адреса, прямо перед connect.
// address — это «ip:port» с *резолвленным* IP, поэтому rebinding не сможет
// протащить публичное имя мимо проверки и затем направить его на loopback.
func safeControl(network, address string, _ syscall.RawConn) error {
    if network != "tcp4" && network != "tcp6" {
        return fmt.Errorf("сеть %q не разрешена", network)
    }
    host, _, err := net.SplitHostPort(address)
    if err != nil {
        return err
    }
    ip := net.ParseIP(host)
    if ip == nil {
        return fmt.Errorf("не удалось разобрать IP из %q", address)
    }
    if isDisallowedIP(ip) {
        return fmt.Errorf("заблокировано соединение с запрещённым IP %s", ip)
    }
    return nil
}

// NewSafeClient возвращает http.Client, защищённый от SSRF.
func NewSafeClient() *http.Client {
    dialer := &net.Dialer{
        Timeout:   5 * time.Second,
        KeepAlive: 30 * time.Second,
        Control:   safeControl,
    }
    transport := &http.Transport{
        DialContext:           dialer.DialContext,
        TLSHandshakeTimeout:   5 * time.Second,
        ResponseHeaderTimeout: 10 * time.Second,
        // Игнорируем HTTP_PROXY/HTTPS_PROXY: прокси протуннелировал бы запрос,
        // и наша проверка IP в момент dial никогда бы не сработала.
        Proxy: nil,
    }
    return &http.Client{
        Timeout:   15 * time.Second,
        Transport: transport,
        // Не следуем за редиректами. Control сработал бы и на dial редиректа,
        // всё равно заблокировав внутренний IP, но остановка здесь убирает
        // целый класс сюрпризов и делает поведение предсказуемым.
        // Нужно следовать — разбирайте resp.Location вручную.
        CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
            return http.ErrUseLastResponse
        },
    }
}

Ключевое свойство: Control срабатывает на каждом dial — включая dial’ы для редиректов и для новых соединений в пуле. Между «проверили» и «подключились» не остаётся окна, которым можно воспользоваться.


Стройте защиту слоями, не полагайтесь на одно

Фильтрация IP в момент dial — это последний рубеж, а не вся стратегия. Сочетайте её с проверками, кодирующими вашу бизнес-логику:

func FetchPreview(client *http.Client, rawURL string) ([]byte, error) {
    u, err := url.Parse(rawURL)
    if err != nil {
        return nil, err
    }
    // 1. Allowlist схем — никаких file://, gopher://, dict:// ...
    if u.Scheme != "https" {
        return nil, errors.New("разрешён только https")
    }
    // 2. Опциональный allowlist хостов для бизнес-логики (НЕ граница безопасности)
    if !allowedHosts[u.Hostname()] {
        return nil, errors.New("хост не разрешён")
    }
    // 3. Жёсткий рубеж: безопасный клиент блокирует внутренние IP на этапе dial
    resp, err := client.Get(u.String())
    if err != nil {
        return nil, err
    }
    defer resp.Body.Close()

    // 4. Ограничиваем ответ, чтобы вредоносная цель не исчерпала память
    return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) // 1 МиБ
}

Короткий тест делает гарантию наглядной и не даёт ей сломаться при рефакторинге:

func TestSafeClientBlocksMetadata(t *testing.T) {
    client := safehttp.NewSafeClient()
    _, err := client.Get("http://169.254.169.254/latest/meta-data/")
    if err == nil {
        t.Fatal("ожидалось, что эндпоинт метаданных будет заблокирован")
    }
}

Чек-лист

  • Проверяйте IP в момент dial (Dialer.Control), а не хост заранее — именно это убивает DNS rebinding.
  • Блокируйте loopback, приватные, link-local, unspecified и multicast диапазоны.
  • Только HTTPS в allowlist схем; отклоняйте file://, gopher:// и компанию.
  • Запрещайте редиректы или перепроверяйте каждый хоп.
  • Везде ставьте таймауты и ограничивайте тело ответа через io.LimitReader.
  • Игнорируйте proxy-переменные окружения на транспорте «ходока».
  • Не возвращайте пользователю сырые ошибки и тела ответов — это превращает слепой SSRF в оракул.
  • Для настоящей изоляции выносите исходящие запросы в отдельный сетевой сегмент (k8s NetworkPolicy, security groups), чтобы метадата была недостижима и на уровне сети.

Не хотите писать руками — doyensec/safeurl упаковывает ровно такой подход с проверкой на этапе dial. В любом случае стоит держать в голове принцип: применяйте правило на той границе, где действие реально происходит. Имя хоста — это обещание; адрес, на который пошёл dial, — это правда.