Почему очевидные защиты не работают и как сделать 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, — это правда.