11 ноября опубликовали описание уязвимости в Gotenberg, популярном сервисе, который превращает HTML и офисные документы в PDF. Это CVE-2026-126799, она затрагивает версии с 8.31.0 по 8.37.0 и исправлена в 8.38.0. В ней нет ничего экзотического: защита от SSRF честно блокировала [::1], [::] и внутренние диапазоны IPv6, но пропускала тот же адрес, если к нему дописать %25 и любое имя сетевого интерфейса. Например, http://[::%251]:9999/ проходил проверку и открывал доступ к сервисам на localhost.

Самое неприятное, что Gotenberg тут ни при чём в смысле «плохого кода». Проверка написана аккуратно, на net/netip, и выглядит так же, как в десятках других проектов на Go: вебхуках, превью ссылок, импортах по URL, прокси для картинок. Поэтому я собрал маленький сервис с такой же проверкой и разобрал проблему по шагам: проблема → воспроизведение → исправление → проверка.

Все выводы ниже взяты из реального прогона на Go 1.26.11 и 1.27.4 (это свежие патч-релизы от 10 ноября) и Python 3.13.5.

Что такое зона в IPv6-адресе

У IPv6 есть link-local адреса fe80::/10. Они одинаковые на каждом интерфейсе, поэтому системе нужно подсказать, через какой интерфейс идти. Для этого к адресу дописывают зону: fe80::1%eth0 или fe80::1%2. В URL символ % нужно экранировать, поэтому получается http://[fe80::1%25eth0]/.

Go это поддерживает: url.Parse принимает зону в квадратных скобках, netip.ParseAddr сохраняет её внутри netip.Addr, а net.Dial спокойно подключается. Проблема в том, что для адреса не из fe80::/10 зона ничего не меняет в маршрутизации, зато меняет результат некоторых проверок.

Где ломается проверка

Типичная функция «можно ли ходить на этот адрес»:

var nonPublicIPv6 = []netip.Prefix{
	netip.MustParsePrefix("fec0::/10"),      // site-local (устаревший)
	netip.MustParsePrefix("64:ff9b::/96"),   // NAT64
	netip.MustParsePrefix("64:ff9b:1::/48"), // локальный NAT64
	netip.MustParsePrefix("2002::/16"),      // 6to4
	netip.MustParsePrefix("2001::/32"),      // Teredo
	netip.MustParsePrefix("::/96"),          // IPv4-compatible
}

func isPublicVuln(addr netip.Addr) bool {
	addr = addr.Unmap()
	switch {
	case addr.IsLoopback(), addr.IsPrivate(), addr.IsLinkLocalUnicast(),
		addr.IsLinkLocalMulticast(), addr.IsMulticast(), addr.IsUnspecified(),
		addr.IsInterfaceLocalMulticast():
		return false
	}
	if addr.Is6() {
		for _, p := range nonPublicIPv6 {
			if p.Contains(addr) {
				return false
			}
		}
	}
	return true
}

Тут две дыры, и обе связаны с зоной:

  1. netip.Prefix.Contains возвращает false для любого адреса с зоной. Это не баг, так прямо написано в документации к методу. Но из-за этого весь список запрещённых префиксов перестаёт работать.

  2. IsUnspecified() сравнивает адрес целиком, вместе с зоной, поэтому ::%1 для неё уже не «неуказанный адрес».

При этом IsLoopback, IsPrivate и IsLinkLocalUnicast смотрят на байты адреса и зону игнорируют. Получается, что половина проверок в одном switch устойчива к зоне, а половина нет.

Проверил это маленькой программой, которая прогоняет адреса через все методы:

site := netip.MustParsePrefix("fec0::/10")
ula := netip.MustParsePrefix("fc00::/7")
nat64 := netip.MustParsePrefix("64:ff9b::/96")
for _, s := range []string{"fec0:1234::3", "fec0:1234::3%1", "::", "::%1",
	"::1", "::1%1", "fd00::5", "fd00::5%eth0", "64:ff9b::7f00:1", "64:ff9b::7f00:1%x", "fe80::1%lo"} {
	a := netip.MustParseAddr(s)
	fmt.Printf("%-20s fec0::/10=%-5v fc00::/7=%-5v nat64=%-5v IsUnspecified=%-5v IsLoopback=%-5v IsPrivate=%-5v IsLinkLocal=%-5v\n",
		s, site.Contains(a), ula.Contains(a), nat64.Contains(a),
		a.IsUnspecified(), a.IsLoopback(), a.IsPrivate(), a.IsLinkLocalUnicast())
}
fec0:1234::3         fec0::/10=true  fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=false
fec0:1234::3%1       fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=false
::                   fec0::/10=false fc00::/7=false nat64=false IsUnspecified=true  IsLoopback=false IsPrivate=false IsLinkLocal=false
::%1                 fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=false
::1                  fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=true  IsPrivate=false IsLinkLocal=false
::1%1                fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=true  IsPrivate=false IsLinkLocal=false
fd00::5              fec0::/10=false fc00::/7=true  nat64=false IsUnspecified=false IsLoopback=false IsPrivate=true  IsLinkLocal=false
fd00::5%eth0         fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=true  IsLinkLocal=false
64:ff9b::7f00:1      fec0::/10=false fc00::/7=false nat64=true  IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=false
64:ff9b::7f00:1%x    fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=false
fe80::1%lo           fec0::/10=false fc00::/7=false nat64=false IsUnspecified=false IsLoopback=false IsPrivate=false IsLinkLocal=true 

Вывод на 1.26.11 и 1.27.4 совпадает байт в байт, так что обновление Go здесь ничего не меняет: поведение задокументировано и останется таким.

Видно главное: fec0:1234::3 попадает в префикс, а fec0:1234::3%1 уже нет. :: распознаётся как неуказанный адрес, а ::%1 нет. Зато ::1%1 и fd00::5%eth0 по-прежнему ловятся, потому что их проверяют IsLoopback и IsPrivate.

Воспроизведение

Собрал два сервиса.

Внутренний сервис слушает только loopback и отдаёт «секрет»:

h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "INTERNAL-TOKEN-7f3a (local=%s remote=%s)\n",
		r.Context().Value(http.LocalAddrContextKey), r.RemoteAddr)
})
go func() { log.Fatal(http.ListenAndServe("127.0.0.1:9999", h)) }()
log.Fatal(http.ListenAndServe("[::1]:9999", h))

Внешний сервис делает превью ссылки. Он принимает ?url=, разбирает хост, проверяет все его адреса функцией выше и только потом делает запрос:

func checkURL(raw string, isPublic func(netip.Addr) bool, rejectZone bool) error {
	u, err := url.Parse(raw)
	if err != nil {
		return err
	}
	if u.Scheme != "http" && u.Scheme != "https" {
		return errors.New("blocked: scheme")
	}
	host := u.Hostname()
	if addr, err := netip.ParseAddr(host); err == nil {
		if rejectZone && addr.Zone() != "" {
			return errors.New("blocked: IPv6 zone in URL")
		}
		if !isPublic(addr) {
			return errBlocked
		}
		return nil
	}
	addrs, err := net.DefaultResolver.LookupNetIP(context.Background(), "ip", host)
	if err != nil {
		return err
	}
	for _, a := range addrs {
		if !isPublic(a) {
			return errBlocked
		}
	}
	return nil
}

Параметр rejectZone понадобится для исправления, в уязвимом режиме он выключен. Дальше скрипт attack.sh прогоняет набор адресов через /preview. Последняя строка нужна как контроль, что обычные внешние ссылки работают:

== mode=vuln
http://127.0.0.1:9999/             blocked: non-public address [HTTP 403]
http://localhost:9999/             blocked: non-public address [HTTP 403]
http://[::1]:9999/                 blocked: non-public address [HTTP 403]
http://[::]:9999/                  blocked: non-public address [HTTP 403]
http://[::ffff:127.0.0.1]:9999/    blocked: non-public address [HTTP 403]
http://[::1%25lo]:9999/            blocked: non-public address [HTTP 403]
http://[::%251]:9999/              fetched 200: INTERNAL-TOKEN-7f3a (local=[::1]:9999 remote=[::1]:51568) [HTTP 200]
http://[::%25lo]:9999/             fetched 200: INTERNAL-TOKEN-7f3a (local=[::1]:9999 remote=[::1]:51584) [HTTP 200]
http://[::%25zzz]:9999/            fetched 200: INTERNAL-TOKEN-7f3a (local=[::1]:9999 remote=[::1]:51594) [HTTP 200]
https://example.com/               fetched 200: <!doctype html><html lang=en><head><meta charset=utf-8><link rel=icon href=data:,><meta name=view

Все «обычные» варианты локального адреса блокируются, включая ::1%lo. А :: с любой зоной проходит, и сервис отдаёт наружу внутренний токен. Обратите внимание на %25zzz: интерфейса zzz в системе нет, но подключение всё равно состоялось. Для :: Linux просто идёт на ::1, зона ни на что не влияет.

Одна оговорка, чтобы не преувеличивать. Через [::] получается достучаться только до сервисов, которые слушают IPv6 loopback: ::1 или все адреса (:9999 в Go по умолчанию слушает и IPv4, и IPv6). Сервис, привязанный строго к 127.0.0.1, так не достать, я проверил это отдельно:

http://127.0.0.1:9998/ 200
http://[::%251]:9998/ 000 Failed to connect to :: port 9998 after 0 ms: Could not connect to server

Для fec0::/10, NAT64 и 6to4 зона открывает уже не localhost, а внутреннюю сеть, если такие адреса в ней маршрутизируются. Своего NAT64 у меня нет, поэтому эту часть я проверил только на уровне функции: в тесте ниже старая проверка пропускает 64:ff9b::7f00:1%1 и 2002:7f00:1::%1, то есть адреса, за которыми по смыслу стоит 127.0.0.1.

Исправление

Шаг 1. Снять зону до классификации

Самое короткое исправление в одну строку:

func isPublicFixed(addr netip.Addr) bool {
	return isPublicVuln(addr.WithZone(""))
}

WithZone("") убирает зону, и после этого Contains и IsUnspecified работают как надо. Внутри isPublicVuln уже есть Unmap() для адресов вида ::ffff:127.0.0.1, это то же самое по смыслу: привести адрес к каноническому виду до проверки.

Шаг 2. Не принимать зону в URL вообще

Зона имеет смысл только для link-local адресов, а их сервис превью ссылок всё равно блокирует. Значит, URL с зоной можно отклонять сразу, ещё до классификации. Это закрывает целый класс обходов, а не один случай. В checkURL выше это ветка rejectZone.

Шаг 3. Проверять адрес в момент подключения

Проверка URL до запроса всегда немного врёт: между проверкой и подключением DNS может ответить по-другому, а HTTP-клиент пойдёт по редиректу на другой хост. Поэтому вторую проверку стоит повесить на сам net.Dialer, туда, где известен настоящий адрес подключения:

d := &net.Dialer{Timeout: 2 * time.Second}
d.Control = func(network, address string, _ syscall.RawConn) error {
	ap, err := netip.ParseAddrPort(address)
	if err != nil {
		return fmt.Errorf("blocked: cannot parse dial address %q", address)
	}
	if !isPublicFixed(ap.Addr()) {
		return fmt.Errorf("blocked at dial: %s", address)
	}
	return nil
}
client := &http.Client{
	Timeout:   3 * time.Second,
	Transport: &http.Transport{DialContext: d.DialContext, Proxy: nil},
}

Control вызывается для каждой попытки подключения, в том числе после редиректов и для каждого адреса из DNS. Proxy: nil тут не случайно: если клиент ходит через прокси, Control увидит адрес прокси, а не цели, и проверка потеряет смысл.

Проверка

Чтобы понять, что даёт каждый шаг, сервис умеет работать в четырёх режимах: vuln (как было), zone (только шаг 1), guard (старая проверка URL плюс шаг 3) и fixed (все три шага). run_all.sh поднимает все четыре и прогоняет тот же набор адресов.

Только шаг 1:

== mode=zone
http://[::1%25lo]:9999/            blocked: non-public address [HTTP 403]
http://[::%251]:9999/              blocked: non-public address [HTTP 403]
http://[::%25lo]:9999/             blocked: non-public address [HTTP 403]
http://[::%25zzz]:9999/            blocked: non-public address [HTTP 403]
https://example.com/               fetched 200: <!doctype html><html lang=en><head><meta charset=utf-8><link rel=icon href=data:,><meta name=view

Только проверка в Dialer.Control, при этом проверка URL осталась уязвимой:

== mode=guard
http://[::%251]:9999/              fetch error: Get "http://[::%251]:9999/": dial tcp [::%1]:9999: blocked at dial: [::%1]:9999 [HTTP 502]
http://[::%25lo]:9999/             fetch error: Get "http://[::%25lo]:9999/": dial tcp [::%lo]:9999: blocked at dial: [::%lo]:9999 [HTTP 502]
http://[::%25zzz]:9999/            fetch error: Get "http://[::%25zzz]:9999/": dial tcp [::%zzz]:9999: blocked at dial: [::%zzz]:9999 [HTTP 502]
https://example.com/               fetched 200: <!doctype html><html lang=en><head><meta charset=utf-8><link rel=icon href=data:,><meta name=view

Вторая линия обороны сработала сама по себе: первая проверка пропустила запрос, но подключение не состоялось. Видно и то, что в Control адрес приходит вместе с зоной ([::%1]:9999). Значит, если в этой функции забыть WithZone(""), получится та же дыра, только на другом уровне.

Всё вместе:

== mode=fixed
http://127.0.0.1:9999/             blocked: non-public address [HTTP 403]
http://localhost:9999/             blocked: non-public address [HTTP 403]
http://[::1]:9999/                 blocked: non-public address [HTTP 403]
http://[::]:9999/                  blocked: non-public address [HTTP 403]
http://[::ffff:127.0.0.1]:9999/    blocked: non-public address [HTTP 403]
http://[::1%25lo]:9999/            blocked: IPv6 zone in URL [HTTP 403]
http://[::%251]:9999/              blocked: IPv6 zone in URL [HTTP 403]
http://[::%25lo]:9999/             blocked: IPv6 zone in URL [HTTP 403]
http://[::%25zzz]:9999/            blocked: IPv6 zone in URL [HTTP 403]
https://example.com/               fetched 200: <!doctype html><html lang=en><head><meta charset=utf-8><link rel=icon href=data:,><meta name=view

Внутренний токен не утёк ни разу, а внешняя ссылка работает во всех режимах.

Чтобы это не вернулось при следующем рефакторинге, нужен тест. Он требует, чтобы новая проверка блокировала все адреса из списка, и заодно пишет в лог, что пропускала старая:

func TestZoneDoesNotBypass(t *testing.T) {
	blocked := []string{
		"::", "::%1", "::%lo", "::%zzz",
		"::1", "::1%lo",
		"fec0:1234::3", "fec0:1234::3%eth0",
		"fd00::5", "fd00::5%eth0",
		"64:ff9b::7f00:1", "64:ff9b::7f00:1%1",
		"2002:7f00:1::", "2002:7f00:1::%1",
		"fe80::1%lo",
		"::ffff:127.0.0.1", "127.0.0.1", "10.0.0.1", "169.254.169.254",
	}
	for _, s := range blocked {
		a := netip.MustParseAddr(s)
		if isPublicFixed(a) {
			t.Errorf("isPublicFixed(%s) = true, want false", s)
		}
		if isPublicVuln(a) {
			t.Logf("старая проверка пропускает %s", s)
		}
	}
	for _, s := range []string{"2a00:1450:4001:82a::200e", "93.184.215.14"} {
		if !isPublicFixed(netip.MustParseAddr(s)) {
			t.Errorf("isPublicFixed(%s) = false, want true", s)
		}
	}
}
=== RUN   TestZoneDoesNotBypass
    filter_test.go:25: старая проверка пропускает ::%1
    filter_test.go:25: старая проверка пропускает ::%lo
    filter_test.go:25: старая проверка пропускает ::%zzz
    filter_test.go:25: старая проверка пропускает fec0:1234::3%eth0
    filter_test.go:25: старая проверка пропускает 64:ff9b::7f00:1%1
    filter_test.go:25: старая проверка пропускает 2002:7f00:1::%1
--- PASS: TestZoneDoesNotBypass (0.00s)
PASS

Шесть адресов из списка старая проверка пропускала, новая блокирует все. fd00::5%eth0 старая проверка ловила и раньше, потому что его закрывает IsPrivate.

А что в Python

Для сравнения прогнал те же URL через urllib.parse и ipaddress:

nets = [ipaddress.ip_network(n) for n in ("fec0::/10", "64:ff9b::/96", "2002::/16")]
for raw in ("http://[::%251]:9999/", "http://[::%25lo]:9999/", "http://[fec0:1234::3%25eth0]:8080/"):
    host = urlsplit(raw).hostname
    a = ipaddress.ip_address(host)
    print(f"{raw:36} host={host!r:22} scope_id={a.scope_id!r:8} "
          f"unspecified={a.is_unspecified!s:5} in_deny_nets={any(a in n for n in nets)!s:5} is_global={a.is_global}")
http://[::%251]:9999/                host='::%251'               scope_id='251'    unspecified=True  in_deny_nets=False is_global=False
http://[::%25lo]:9999/               host='::%25lo'              scope_id='25lo'   unspecified=True  in_deny_nets=False is_global=False
http://[fec0:1234::3%25eth0]:8080/   host='fec0:1234::3%25eth0'  scope_id='25eth0' unspecified=False in_deny_nets=True  is_global=True

Здесь именно этой дыры нет: ipaddress проверяет вхождение в сеть и is_unspecified без учёта зоны. Но есть две другие странности. Во-первых, urlsplit не раскодирует %25, и зона получается 25eth0 вместо eth0. Во-вторых, is_global для fec0:: возвращает True, так что фильтр «пускаем только is_global» этот диапазон пропустит. Так что и в Python зону лучше отклонять явно, а не надеяться на библиотеку.

Как найти это у себя

Быстрый поиск по Go-коду: файлы, где адреса проверяют через netip и Contains или IsUnspecified, но зону нигде не трогают. Это эвристика, она ничего не доказывает, но показывает, куда смотреть:

#!/usr/bin/env bash
dir=${1:-.}
rg -l --type go -e '\.Contains\(' -e 'IsUnspecified\(\)' "$dir" | while read -r f; do
  rg -q 'netip' "$f" || continue
  if ! rg -q -e 'WithZone\(""\)' -e '\.Zone\(\)' "$f"; then
    echo "ПРОВЕРИТЬ: $f"
    rg -n -e '\.Contains\(' -e 'IsUnspecified\(\)' "$f" | sed 's/^/    /'
  fi
done

На копии старого фильтра:

ПРОВЕРИТЬ: sample-old/filter.go
    22:		addr.IsLinkLocalMulticast(), addr.IsMulticast(), addr.IsUnspecified(),
    28:		if p.Contains(addr) {

На исправленном коде находок нет.

Короткий чек-лист

  1. Если вы используете Gotenberg с включённой защитой от приватных адресов, обновитесь как минимум до 8.38.0.

  2. В своём коде перед любыми проверками приводите адрес к одному виду: addr.Unmap().WithZone("").

  3. Отклоняйте URL с зоной в хосте, если вам не нужны link-local адреса. Скорее всего, не нужны.

  4. Повесьте проверку на net.Dialer.Control и не забудьте снять зону там тоже. Следите, чтобы клиент не ходил через прокси из переменных окружения.

  5. Добавьте в тесты адреса с зоной: ::%1, fec0::1%eth0, 64:ff9b::7f00:1%1. Для SSRF-фильтра стоит держать отдельный набор «странных» записей адреса и прогонять его на каждом изменении.

А у вас SSRF-фильтр как устроен: свой список диапазонов, готовая библиотека или отдельный прокси для исходящих запросов? И проверяли ли вы его на IPv6 вообще, или сервис живёт там, где IPv6 выключен, и считается, что это не про вас?