SSRF (Server-Side Request Forgery) описана в CWE-918 так: сервер получает URL от внешней стороны, скачивает его содержимое и «не проверяет в достаточной мере, что запрос уходит туда, куда должен». Атакующий не может достучаться до внутренней сети сам, поэтому просит об этом ваш сервер, у которого такой доступ есть. В OWASP Top 10:2025 SSRF больше не отдельная категория: «Server-Side Request Forgery (SSRF) has been rolled into this category», то есть в A01 Broken Access Control. Логика понятна: это обход контроля доступа, только проверку обходит не пользователь, а сервер от его имени. В API Security Top 10 это API7:2023.
Как устроена атака и чем грозитспросят на собеседовании
Везде, где сервер по просьбе пользователя сам идёт по адресу, есть место для SSRF. На маркетплейсе таких мест четыре, и все выглядят безобидно:
- Вебхук продавца. Продавец указывает URL, на который маркетплейс шлёт события «заказ оплачен», «заказ отменён». Адрес задаёт пользователь, запрос делает ваш сервер.
- «Загрузить картинку по ссылке». В карточке товара продавец вставляет ссылку на фото, сервис каталога скачивает его. Этот случай OWASP приводит первым в шпаргалке по SSRF.
- PDF-экспорт. Накладная или счёт собирается из HTML, в который попадает описание товара. Движок рендеринга послушно загружает каждый
<img src>, включая тот, что продавец вписал в описание. - Превью ссылки в чате поддержки или в отзыве: сервер открывает страницу, чтобы достать заголовок и картинку.
Куда атакующий направит запрос:
- Метаданные облака.
http://169.254.169.254/отвечает только изнутри виртуальной машины и отдаёт, среди прочего, временные ключи роли, под которой работает сервис (у AWS это разделiam/security-credentials/). OWASP прямо пишет, что в облаке SSRF «often used to access and steal credentials and access tokens from metadata services»; тот же адрес у AWS, GCP и Azure, у GCP есть ещё имяmetadata.google.internal. - localhost и служебные порты.
http://localhost:8080/actuator/env, админка на соседнем порту, Redis на6379, база поиска на9200: всё, что слушает только внутренний интерфейс в расчёте на «снаружи не достанут». - Соседние сервисы. Внутренний API склада или платежей, который принимает запросы без аутентификации, потому что «сеть внутренняя». Для него запрос от сервиса каталога выглядит как свой.
- Другие схемы. CWE-918 перечисляет
file://для чтения файлов,gopher://иtftp://, которые дают больше контроля над байтами запроса.
Даже если ответ не возвращается пользователю (так называемая слепая SSRF), остаются время ответа и текст ошибки: по ним сканируют внутреннюю сеть, отличая открытый порт от закрытого. Это не теоретическая угроза: в списке примеров CWE-918 есть CVE-2021-26855, SSRF в почтовом сервере, которая по данным каталога CISA KEV эксплуатировалась в реальных атаках.
Шпаргалка OWASP добавляет, что SSRF не ограничена HTTP: если запрос делает само приложение, оно может говорить и по FTP, SMB, SMTP, и через схемы вроде file://, gopher://, data://, dict://. А уязвимость XXE в разборе XML тоже превращается в SSRF (подробнее в статье про XXE и десериализацию).
Картинка по ссылке: ключи облака за два запроса
Это самый прямой путь, потому что ответ возвращается атакующему в виде «картинки». Пусть сервис каталога работает в AWS на виртуальной машине с ролью, которой разрешено писать в бакет с фото товаров и читать очередь заказов.
- Продавец вставляет в поле «фото по ссылке» адрес
http://169.254.169.254/latest/meta-data/iam/security-credentials/. Сервис каталога скачивает его. Для службы метаданных это запрос изнутри машины, то есть свой. - Ответ сохраняется как файл фото и раздаётся по публичной ссылке в карточке товара. Продавец открывает «картинку» как текст и видит имя роли.
- Второй запрос на
.../iam/security-credentials/<имя роли>. По документации AWS в ответе лежатAccessKeyId,SecretAccessKeyиToken: временные ключи с правами этой роли. - Дальше атакующему не нужен ваш сервер: с ключами он сам ходит в API облака со своей машины и делает всё, что разрешено роли каталога. Ключи временные, и AWS меняет их сама, но новые выдаёт заранее, не меньше чем за пять минут до истечения старых, так что пока дыра открыта, свежие ключи достаются тем же запросом.
AWS прямо предупреждает в той же документации: учётные данные роли можно выдать наружу через сервисы, которые делают HTTP-запросы от вашего имени, и в списке такие сервисы, как HTTP-прокси, валидаторы HTML и CSS и XML-процессоры с включением внешних документов.
Если каталог проверяет, что скачанное действительно картинка, и выбрасывает остальное, атака становится слепой, но не исчезает: разница между «не картинка», «соединение отклонено» и «таймаут» в ответе продавцу всё ещё рисует карту внутренней сети.
Вебхук продавца: сканер и доступ к внутренним API
Вебхук отличается тем, что маркетплейс сам решает, когда и что отправить: событие «заказ оплачен» методом POST с телом, которое собирает ваш код. Атакующий управляет только адресом, и этого хватает.
- Продавец меняет адрес вебхука на
http://10.0.0.7:8080/и жмёт «отправить тестовое событие». В кабинете обычно есть журнал доставки со статусом ответа, временем и текстом ошибки, чтобы продавцу было проще отлаживать интеграцию. - Перебирая адреса и порты, он по журналу видит, где ответили
200или404(там что-то живёт), где пришло «соединение отклонено» (машина есть, порт закрыт), где таймаут (адреса нет или его режет файрвол). Это сканирование портов чужими руками, о котором пишет CWE-918. - Найдя внутренний сервис без аутентификации, он направляет вебхук на его метод, который принимает
POST. Тело запроса составляет маркетплейс, но запрос приходит изнутри, с адреса сервиса уведомлений, и сервис, который «доверяет своей сети», его выполнит. - Если журнал доставки показывает ещё и тело ответа, вебхук становится читалкой внутренних страниц.
Вебхук опасен ещё и тем, что адрес сохраняют один раз, а запросы идут месяцами. Проверка при сохранении не спасает, если позже имя начнёт резолвиться в другой адрес, поэтому проверять надо при каждой отправке.
PDF-экспорт: сервер рендерит чужой HTML
Накладная собирается из шаблона, в который подставляется описание товара. Если описание попадает туда как HTML, продавец вписывает в него <img src="http://169.254.169.254/latest/meta-data/"> или ссылку на внутреннюю админку. Движок рендеринга работает на вашем сервере и загружает все ресурсы документа сам, до того как получится PDF.
Что получает атакующий, зависит от движка: картинка с внутреннего адреса окажется прямо в файле, который он скачает из кабинета, а движки, которые поддерживают фреймы или локальные файлы, могут вставить в документ и текст чужой страницы. Даже когда ничего не видно, остаётся слепой вариант: время сборки PDF и ошибки рендера.
Здесь две разные ошибки сразу. Первая: пользовательский текст попал в шаблон как разметка, это инъекция, и чинится она экранированием (статья про инъекции). Вторая: движку вообще разрешено ходить в сеть. Правильный рендер получает все картинки заранее скачанными и проверенными, а сеть ему закрыта совсем.
Превью ссылки: самая незаметная точка
Клиент пишет в чат поддержки ссылку, и чат показывает заголовок и картинку страницы. Чтобы их получить, сервер открывает ссылку сам, а потом ещё и картинку, адрес которой нашёл в разметке этой страницы. Второй адрес выбирает уже не пользователь напрямую, а страница, которую он контролирует, поэтому проверять надо каждый запрос, а не только ссылку из сообщения. Ответ при этом виден атакующему почти целиком: заголовок страницы и картинка попадают в чат.
Как выглядит в коде
Уязвимый обработчик «загрузить картинку по ссылке» короткий, и в этом его опасность: URL от продавца уходит в HTTP-клиент без единой проверки.
@PostMapping("/seller/products/{id}/image-from-url")
public ResponseEntity<Void> importImage(@PathVariable long id, @RequestBody ImageUrlRequest request) {
byte[] image = restClient.get()
.uri(URI.create(request.url()))
.retrieve()
.body(byte[].class);
productImages.save(id, image);
return ResponseEntity.noContent().build();
}
func (h *ProductHandler) ImportImage(w http.ResponseWriter, r *http.Request) {
var req struct {
URL string `json:"url"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad json", http.StatusBadRequest)
return
}
resp, err := http.Get(req.URL)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
defer resp.Body.Close()
image, _ := io.ReadAll(resp.Body)
h.images.Save(r.Context(), chi.URLParam(r, "id"), image)
w.WriteHeader(http.StatusNoContent)
}
app.post("/seller/products/:id/image-from-url", async (req, res) => {
const response = await fetch(req.body.url);
const image = Buffer.from(await response.arrayBuffer());
await productImages.save(req.params.id, image);
res.sendStatus(204);
});
@app.post("/seller/products/{product_id}/image-from-url", status_code=204)
async def import_image(product_id: int, body: ImageUrl) -> None:
async with httpx.AsyncClient(follow_redirects=True) as client:
response = await client.get(body.url)
await product_images.save(product_id, response.content)
Продавец отправляет {"url": "http://169.254.169.254/latest/meta-data/"}, и в карточке товара появляется «картинка», которая на деле содержит ответ службы метаданных. В варианте на Go ещё и текст ошибки уходит клиенту: connection refused против таймаута сразу говорит, какой порт открыт.
Первое, что приходит в голову, это список запрещённых строк. Программа ниже проверяет атакующие адреса таким фильтром и печатает, какой хост увидит HTTP-клиент:
live example
import java.net.URI;
import java.util.List;
public class NaiveCheck {
static final List<String> BLOCKED = List.of("localhost", "127.0.0.1", "169.254.169.254");
static boolean naiveAllowed(String url) {
return BLOCKED.stream().noneMatch(url::contains);
}
public static void main(String[] args) {
List<String> attacks = List.of(
"http://169.254.169.254/latest/meta-data/",
"http://2130706433/admin",
"http://0x7f.0.0.1:6379/",
"http://[::ffff:a9fe:a9fe]/latest/meta-data/",
"http://shop.example@10.0.0.5:8080/actuator/env",
"file:///etc/passwd");
for (String url : attacks) {
String verdict = naiveAllowed(url) ? "пропущен" : "отклонён";
System.out.println(verdict + " " + url + " хост: " + URI.create(url).getHost());
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import (
"fmt"
"net/url"
"strings"
)
var blocked = []string{"localhost", "127.0.0.1", "169.254.169.254"}
func naiveAllowed(raw string) bool {
for _, word := range blocked {
if strings.Contains(raw, word) {
return false
}
}
return true
}
func main() {
attacks := []string{
"http://169.254.169.254/latest/meta-data/",
"http://2130706433/admin",
"http://0x7f.0.0.1:6379/",
"http://[::ffff:a9fe:a9fe]/latest/meta-data/",
"http://shop.example@10.0.0.5:8080/actuator/env",
"file:///etc/passwd",
}
for _, raw := range attacks {
verdict := "отклонён"
if naiveAllowed(raw) {
verdict = "пропущен"
}
u, _ := url.Parse(raw)
fmt.Printf("%s %s хост: %q\n", verdict, raw, u.Hostname())
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
const BLOCKED = ["localhost", "127.0.0.1", "169.254.169.254"];
function naiveAllowed(url) {
return !BLOCKED.some((word) => url.includes(word));
}
const attacks = [
"http://169.254.169.254/latest/meta-data/",
"http://2130706433/admin",
"http://0x7f.0.0.1:6379/",
"http://[::ffff:a9fe:a9fe]/latest/meta-data/",
"http://shop.example@10.0.0.5:8080/actuator/env",
"file:///etc/passwd",
];
for (const url of attacks) {
const verdict = naiveAllowed(url) ? "пропущен" : "отклонён";
console.log(`${verdict} ${url} хост: ${JSON.stringify(new URL(url).hostname)}`);
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
import socket
from urllib.parse import urlsplit
BLOCKED = ("localhost", "127.0.0.1", "169.254.169.254")
def naive_allowed(url: str) -> bool:
return not any(word in url for word in BLOCKED)
attacks = [
"http://169.254.169.254/latest/meta-data/",
"http://2130706433/admin",
"http://0x7f.0.0.1:6379/",
"http://[::ffff:a9fe:a9fe]/latest/meta-data/",
"http://shop.example@10.0.0.5:8080/actuator/env",
"file:///etc/passwd",
]
for url in attacks:
verdict = "пропущен" if naive_allowed(url) else "отклонён"
print(f"{verdict:9} {url} хост: {urlsplit(url).hostname}")
print("2130706433 для сокета это", socket.inet_ntoa(socket.inet_aton("2130706433")))
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Фильтр поймал ровно одну строку, которую знал в лицо. Остальные пять ведут во внутреннюю сеть или к файлам, и все пропущены.
Обходы: почему список запрещённых строк не работаетспросят на собеседовании
У одного адреса много записей, и парсер URL, системный резолвер и HTTP-клиент читают их по-своему. OWASP формулирует коротко: «Deny-lists are bypass-prone. Prefer allow-lists».
- Другая запись IPv4.
2130706433это127.0.0.1одним десятичным числом,0x7f.0.0.1и0177.0.0.1тот же адрес в шестнадцатеричной и восьмеричной записи,127.1сокращённая форма. Парсер URL в Node по стандарту WHATWG сам превращает их в127.0.0.1, а Pythonsocket.inet_aton("2130706433")возвращает те же четыре байта. CWE-918 приводит реальную уязвимость с этой цепочкой (CVE-2016-4029): проверка ждала десятичную запись IP и пропускала восьмеричную и шестнадцатеричную. - IPv6.
[::1]это localhost, а[::ffff:a9fe:a9fe]это IPv4-адрес169.254.169.254, упакованный в IPv6 (IPv4-mapped). Проверка, которая знает только IPv4, его не узнает. @в URL. Вhttp://shop.example@10.0.0.5:8080/всё до@это имя пользователя, а хост10.0.0.5. Человек и проверка черезstartsWith("http://shop.example")видят доверенный домен.- Перенаправления. Проверку прошёл честный
https://img.seller.example/a.jpg, а он отвечает302 Location: http://169.254.169.254/.... Клиенты по умолчанию часто идут дальше сами:fetchпо MDN используетredirect: "follow",http.Clientв Go следует до 10 перенаправлений. OWASP требует: «Disable the support for the following of the redirection in your web client». - DNS. Имя
img.seller.exampleпринадлежит атакующему, и он сам решает, какой IP оно вернёт. Можно сразу указать10.0.0.5, а можно отдавать публичный адрес при проверке и внутренний при соединении (DNS rebinding, подробнее в разделе «Глубже»). - Имена-синонимы.
localhost.с точкой в конце,anything.localhost, внутренние имена облака.
Вывод: сравнивать надо не строку URL, а IP-адрес, к которому реально подключится клиент.
Почему каждый обход срабатывает
Все обходы держатся на одном разрыве: фильтр смотрит на текст, а сеть работает с числами. Между ними стоят парсер URL, резолвер и HTTP-клиент, и каждый может прочитать строку по-своему.
Десятичная и шестнадцатеричная запись. IPv4-адрес это 32-битное число, а 169.254.169.254 лишь привычный способ его записать. Тот же адрес одним числом: 2852039166. Фильтр сравнивает строки и совпадения не находит, а парсер URL по стандарту WHATWG и функция inet_aton принимают такие записи и превращают их в те же четыре байта (проверено запуском: new URL("http://2852039166/").hostname в Node возвращает 169.254.169.254). Строк, которые означают один адрес, слишком много, чтобы перечислить их в фильтре.
IPv6. Фильтр, написанный под IPv4, ищет четыре числа через точку и пропускает всё остальное. Но адреса вида ::ffff:a9fe:a9fe по RFC 4291, раздел 2.5.5.2 нужны именно для того, чтобы записать адрес IPv4-узла в форме IPv6: для фильтра это незнакомая строка, а для сетевого стека тот же 169.254.169.254. Отдельная история с AAAA-записями: имя может вернуть безобидный IPv4 и внутренний IPv6, и если проверить только первый, клиент вполне может подключиться ко второму.
@ в URL. По грамматике URL всё, что стоит между // и @, это имя пользователя и пароль, а хост начинается после @. Человек читает слева направо и видит знакомый домен, проверка startsWith видит то же самое, а клиент идёт на 10.0.0.5. Хуже, когда парсеры расходятся в мелочах, например в обратной косой черте перед @: об этом раздел «Глубже» ниже.
Перенаправления. Проверка выполняется один раз, над тем URL, который прислал пользователь. Атакующий даёт адрес своего публичного сервера, проверка его честно пропускает, сервер отвечает 302 с Location на внутренний адрес, и клиент сам делает второй запрос внутри библиотеки, мимо вашего кода. Умолчания у клиентов разные: fetch и http.Client в Go идут по перенаправлениям сами, у HttpClient из JDK по документации политика по умолчанию NEVER, у httpx по документации follow_redirects=False. Полагаться на умолчание всё равно не стоит: его поменяет первый, кому понадобится ходить по коротким ссылкам.
Свой DNS. Имя домена ничего не говорит об адресе: что вернёт img.seller.example, решает владелец домена, то есть атакующий. Проверка «домен не похож на внутренний» поэтому бессмысленна, проверять можно только IP, который вернул резолвер. А если резолвер вызывается дважды (при проверке и при соединении), владелец домена может ответить по-разному, это DNS rebinding.
Имена-синонимы. localhost. с точкой в конце это полная запись того же имени, а RFC 6761, раздел 6.3 говорит, что любые имена внутри .localhost. резолверу следует считать особыми и всегда возвращать для них адрес loopback. Сравнение строки с "localhost" ни одно из них не поймает, проверка полученного IP поймает все.
Как чинитьспросят на собеседовании
Защита строится слоями, потому что каждый слой по отдельности обходят. По порядку, от самого сильного:
- Не принимать URL, если можно обойтись. Картинку товара проще принять загрузкой файла. OWASP советует вообще не брать полный URL от пользователя: «Do not accept complete URLs from the user because URL are difficult to validate».
- Список разрешённых хостов, где адресаты известны (в шпаргалке это Case 1). PDF-движок может загружать картинки только с вашего CDN, всё остальное отрезает. Для вебхуков продавцов и превью ссылок такого списка нет (Case 2), и там работает следующий пункт.
- Проверка адреса. Схема только
httpиhttps, никаких учётных данных в URL, порты 80 и 443. Имя резолвится, и каждый полученный IP (записи A и AAAA) проверяется на принадлежность к запрещённым диапазонам. Минимальный список:127.0.0.0/8,0.0.0.0/8,::1, частные10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,fc00::/7, link-local169.254.0.0/16иfe80::/10, multicast224.0.0.0/4иff00::/8. Из link-local шпаргалка OWASP называет только адрес метаданных169.254.169.254и именаmetadata.amazonaws.comиmetadata.google.internal; мы закрываем весь диапазон169.254.0.0/16, в котором этот адрес живёт. - Соединение только с проверенным IP, и перенаправления выключены или проверяются заново на каждом шаге.
- Таймауты и лимит размера. Иначе тот же вебхук превращается в способ держать ваши соединения открытыми или заставить сервер скачать гигабайт.
- Сеть. Исходящий трафик сервиса, который ходит по чужим адресам, идёт через egress-прокси, который сам проверяет адрес назначения, а NetworkPolicy в Kubernetes или правила файрвола запрещают ему ходить во внутренние подсети. OWASP называет это сетевым слоем той же защиты.
- Отдельный сервис-скачиватель без привилегий. Загрузку по ссылке, вебхуки и рендер PDF выносят в маленький сервис без роли в облаке, без доступа к базе и к внутренним API. Если его обманут, красть у него нечего.
- IMDSv2 в AWS. В IMDSv2 метаданные отдаются только по сессионному токену, который получают запросом
PUTна/latest/api/tokenи передают в заголовкеX-aws-ec2-metadata-token; если инстансу включить обязательный токен, запросы без него получают401. AWS в разборе IMDSv2 (19 ноября 2019) пишет, что связка «сессия черезPUTплюс секретный токен» по их анализу реальных уязвимостей «protects against the vast majority of SSRF vulnerabilities». OWASP рекомендует «migrate to IMDSv2 and disable old IMDSv1», оговаривая, что это защита в глубину, а не решение.
Ниже проверка из пункта 3 целиком. Чтобы программа работала без сети, DNS заменён словарём: имя rebind.seller.example «резолвится» во внутренний 10.0.0.5, а hooks.seller.example в публичный адрес. В боевом коде на его месте настоящий резолвер (InetAddress.getAllByName, net.DefaultResolver.LookupIPAddr, dns.lookup с all: true, socket.getaddrinfo), и функция возвращает IP, к которому дальше разрешено подключаться.
live example
import java.net.InetAddress;
import java.net.URI;
import java.net.UnknownHostException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
public class SafeTarget {
record Verdict(boolean ok, String detail) {}
static final Map<String, List<String>> DNS = Map.of(
"hooks.seller.example", List.of("93.184.215.14"),
"rebind.seller.example", List.of("10.0.0.5"));
static final Set<String> BLOCKED_NAMES =
Set.of("localhost", "metadata.google.internal", "metadata.amazonaws.com");
static final Pattern NUMERIC_HOST =
Pattern.compile("^(0x[0-9a-f]*|[0-9]+)(\\.(0x[0-9a-f]*|[0-9]+))*$");
static final Pattern CANONICAL_V4 = Pattern.compile(
"^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$");
static boolean internal(InetAddress ip) {
byte[] b = ip.getAddress();
boolean uniqueLocalV6 = b.length == 16 && (b[0] & 0xfe) == 0xfc;
boolean zeroNetV4 = b.length == 4 && b[0] == 0;
return ip.isLoopbackAddress() || ip.isSiteLocalAddress() || ip.isLinkLocalAddress()
|| ip.isAnyLocalAddress() || ip.isMulticastAddress() || uniqueLocalV6 || zeroNetV4;
}
static Verdict safeTarget(String url) {
URI uri;
try {
uri = new URI(url);
} catch (Exception e) {
return new Verdict(false, "не разбирается");
}
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
if (!scheme.equals("http") && !scheme.equals("https")) {
return new Verdict(false, "схема " + scheme);
}
if (uri.getRawUserInfo() != null) {
return new Verdict(false, "логин в URL");
}
if (uri.getHost() == null) {
return new Verdict(false, "нет хоста");
}
String host = uri.getHost().toLowerCase(Locale.ROOT).replaceAll("\\.$", "");
int port = uri.getPort();
if (port != -1 && port != 80 && port != 443) {
return new Verdict(false, "порт " + port);
}
List<String> addresses;
if (host.startsWith("[")) {
addresses = List.of(host.substring(1, host.length() - 1));
} else if (NUMERIC_HOST.matcher(host).matches()) {
if (!CANONICAL_V4.matcher(host).matches()) {
return new Verdict(false, "нестандартная запись IP");
}
addresses = List.of(host);
} else if (BLOCKED_NAMES.contains(host) || host.endsWith(".localhost")) {
return new Verdict(false, "запрещённое имя " + host);
} else {
addresses = DNS.getOrDefault(host, List.of());
if (addresses.isEmpty()) {
return new Verdict(false, "имя не найдено в DNS");
}
}
for (String address : addresses) {
try {
if (internal(InetAddress.getByName(address))) {
return new Verdict(false, "внутренний адрес " + address);
}
} catch (UnknownHostException e) {
return new Verdict(false, "не IP: " + address);
}
}
return new Verdict(true, addresses.get(0));
}
public static void main(String[] args) {
List<String> cases = List.of(
"file:///etc/passwd",
"gopher://127.0.0.1:6379/_INFO",
"http://169.254.169.254/latest/meta-data/",
"http://2130706433/admin",
"http://0x7f.0.0.1/",
"http://[::ffff:a9fe:a9fe]/latest/meta-data/",
"http://[::1]/",
"http://shop.example@10.0.0.5/",
"http://localhost./actuator/env",
"http://metadata.google.internal/computeMetadata/v1/",
"https://hooks.seller.example:6379/",
"https://rebind.seller.example/orders",
"https://hooks.seller.example/orders");
for (String url : cases) {
Verdict v = safeTarget(url);
System.out.println((v.ok() ? "РАЗРЕШЁН " : "ОТКЛОНЁН ") + url + " (" + v.detail() + ")");
}
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
package main
import (
"fmt"
"net"
"net/url"
"regexp"
"strings"
)
var dns = map[string][]string{
"hooks.seller.example": {"93.184.215.14"},
"rebind.seller.example": {"10.0.0.5"},
}
var blockedNames = map[string]bool{
"localhost": true, "metadata.google.internal": true, "metadata.amazonaws.com": true,
}
var numericHost = regexp.MustCompile(`^(0x[0-9a-f]*|[0-9]+)(\.(0x[0-9a-f]*|[0-9]+))*$`)
var canonicalV4 = regexp.MustCompile(`^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$`)
func internal(ip net.IP) bool {
if v4 := ip.To4(); v4 != nil {
ip = v4
if v4[0] == 0 {
return true
}
}
return ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
ip.IsUnspecified() || ip.IsMulticast()
}
func safeTarget(raw string) (bool, string) {
u, err := url.Parse(raw)
if err != nil {
return false, "не разбирается"
}
if u.Scheme != "http" && u.Scheme != "https" {
return false, "схема " + u.Scheme
}
if u.User != nil {
return false, "логин в URL"
}
host := strings.TrimSuffix(strings.ToLower(u.Hostname()), ".")
if host == "" {
return false, "нет хоста"
}
if port := u.Port(); port != "" && port != "80" && port != "443" {
return false, "порт " + port
}
var addresses []string
switch {
case strings.Contains(host, ":"):
addresses = []string{host}
case numericHost.MatchString(host):
if !canonicalV4.MatchString(host) {
return false, "нестандартная запись IP"
}
addresses = []string{host}
case blockedNames[host] || strings.HasSuffix(host, ".localhost"):
return false, "запрещённое имя " + host
default:
addresses = dns[host]
if len(addresses) == 0 {
return false, "имя не найдено в DNS"
}
}
for _, address := range addresses {
ip := net.ParseIP(address)
if ip == nil || internal(ip) {
return false, "внутренний адрес " + address
}
}
return true, addresses[0]
}
func main() {
cases := []string{
"file:///etc/passwd",
"gopher://127.0.0.1:6379/_INFO",
"http://169.254.169.254/latest/meta-data/",
"http://2130706433/admin",
"http://0x7f.0.0.1/",
"http://[::ffff:a9fe:a9fe]/latest/meta-data/",
"http://[::1]/",
"http://shop.example@10.0.0.5/",
"http://localhost./actuator/env",
"http://metadata.google.internal/computeMetadata/v1/",
"https://hooks.seller.example:6379/",
"https://rebind.seller.example/orders",
"https://hooks.seller.example/orders",
}
for _, raw := range cases {
ok, detail := safeTarget(raw)
verdict := "ОТКЛОНЁН"
if ok {
verdict = "РАЗРЕШЁН"
}
fmt.Printf("%s %s (%s)\n", verdict, raw, detail)
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
const DNS = {
"hooks.seller.example": ["93.184.215.14"],
"rebind.seller.example": ["10.0.0.5"],
};
const BLOCKED_NAMES = new Set(["localhost", "metadata.google.internal", "metadata.amazonaws.com"]);
const IPV4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/;
function ipv6Bytes(text) {
const [head, tail] = text.split("::");
const left = head ? head.split(":") : [];
const right = tail ? tail.split(":") : [];
const zeros = Array(8 - left.length - right.length).fill("0");
const groups = tail === undefined ? left : [...left, ...zeros, ...right];
return groups.map((g) => parseInt(g, 16)).flatMap((w) => [w >> 8, w & 0xff]);
}
function internalV4([a, b]) {
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
}
function internal(address) {
const v4 = address.match(IPV4);
if (v4) return internalV4(v4.slice(1).map(Number));
const bytes = ipv6Bytes(address);
const mapped = bytes.slice(0, 10).every((x) => x === 0) && bytes[10] === 0xff && bytes[11] === 0xff;
if (mapped) return internalV4(bytes.slice(12));
const loopbackOrAny = bytes.slice(0, 15).every((x) => x === 0);
return loopbackOrAny || (bytes[0] & 0xfe) === 0xfc ||
(bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) || bytes[0] === 0xff;
}
function safeTarget(raw) {
let url;
try {
url = new URL(raw);
} catch {
return [false, "не разбирается"];
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
return [false, `схема ${url.protocol.slice(0, -1)}`];
}
if (url.username || url.password) return [false, "логин в URL"];
const host = url.hostname.replace(/\.$/, "");
if (!host) return [false, "нет хоста"];
if (url.port && url.port !== "80" && url.port !== "443") return [false, `порт ${url.port}`];
let addresses;
if (host.startsWith("[")) {
addresses = [host.slice(1, -1)];
} else if (IPV4.test(host)) {
addresses = [host];
} else if (BLOCKED_NAMES.has(host) || host.endsWith(".localhost")) {
return [false, `запрещённое имя ${host}`];
} else {
addresses = DNS[host];
if (!addresses) return [false, "имя не найдено в DNS"];
}
for (const address of addresses) {
if (internal(address)) return [false, `внутренний адрес ${address}`];
}
return [true, addresses[0]];
}
const cases = [
"file:///etc/passwd",
"gopher://127.0.0.1:6379/_INFO",
"http://169.254.169.254/latest/meta-data/",
"http://2130706433/admin",
"http://0x7f.0.0.1/",
"http://[::ffff:a9fe:a9fe]/latest/meta-data/",
"http://[::1]/",
"http://shop.example@10.0.0.5/",
"http://localhost./actuator/env",
"http://metadata.google.internal/computeMetadata/v1/",
"https://hooks.seller.example:6379/",
"https://rebind.seller.example/orders",
"https://hooks.seller.example/orders",
];
for (const raw of cases) {
const [ok, detail] = safeTarget(raw);
console.log(`${ok ? "РАЗРЕШЁН" : "ОТКЛОНЁН"} ${raw} (${detail})`);
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
live example
import ipaddress
import re
from urllib.parse import urlsplit
DNS = {
"hooks.seller.example": ["93.184.215.14"],
"rebind.seller.example": ["10.0.0.5"],
}
BLOCKED_NAMES = {"localhost", "metadata.google.internal", "metadata.amazonaws.com"}
NUMERIC_HOST = re.compile(r"^(0x[0-9a-f]*|[0-9]+)(\.(0x[0-9a-f]*|[0-9]+))*$")
CANONICAL_V4 = re.compile(r"^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$")
def internal(ip) -> bool:
if ip.version == 6 and ip.ipv4_mapped:
ip = ip.ipv4_mapped
return not ip.is_global or ip.is_multicast
def safe_target(url: str):
parts = urlsplit(url)
if parts.scheme not in ("http", "https"):
return False, f"схема {parts.scheme}"
if parts.username is not None or parts.password is not None:
return False, "логин в URL"
host = (parts.hostname or "").rstrip(".")
if not host:
return False, "нет хоста"
if parts.port not in (None, 80, 443):
return False, f"порт {parts.port}"
if ":" in host:
addresses = [host]
elif NUMERIC_HOST.match(host):
if not CANONICAL_V4.match(host):
return False, "нестандартная запись IP"
addresses = [host]
elif host in BLOCKED_NAMES or host.endswith(".localhost"):
return False, f"запрещённое имя {host}"
else:
addresses = DNS.get(host)
if not addresses:
return False, "имя не найдено в DNS"
for address in addresses:
if internal(ipaddress.ip_address(address)):
return False, f"внутренний адрес {address}"
return True, addresses[0]
cases = [
"file:///etc/passwd",
"gopher://127.0.0.1:6379/_INFO",
"http://169.254.169.254/latest/meta-data/",
"http://2130706433/admin",
"http://0x7f.0.0.1/",
"http://[::ffff:a9fe:a9fe]/latest/meta-data/",
"http://[::1]/",
"http://shop.example@10.0.0.5/",
"http://localhost./actuator/env",
"http://metadata.google.internal/computeMetadata/v1/",
"https://hooks.seller.example:6379/",
"https://rebind.seller.example/orders",
"https://hooks.seller.example/orders",
]
for url in cases:
ok, detail = safe_target(url)
print(f"{'РАЗРЕШЁН' if ok else 'ОТКЛОНЁН'} {url} ({detail})")
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Все двенадцать атак отклонены, публичный вебхук продавца принят. Детали различаются по языкам, и их стоит знать:
- Java.
InetAddress.getByNameдля строки с IP только проверяет формат и не ходит в DNS, это сказано в javadoc. IPv4-mapped адрес он сам превращает вInet4Address.isSiteLocalAddressпокрывает частные IPv4, но для IPv6 возвращаетtrueтолько на устаревшийfec0::/10, аfd00::1изfc00::/7считает публичным (проверено запуском), поэтому этот диапазон проверяется по байтам.java.net.URIвовсе не находит хоста в0x7f.0.0.1, и такой адрес отсекается как «нет хоста». - Go.
net.IPдаёт готовыеIsPrivate,IsLoopback,IsLinkLocalUnicast,IsUnspecified,IsMulticast, аTo4()разворачивает IPv4-mapped адрес. Сеть0.0.0.0/8проверяется отдельно. - Node. WHATWG-парсер сам нормализует десятичные и шестнадцатеричные записи, поэтому
2130706433приходит в проверку уже как127.0.0.1. Модуляnetв примере нет, диапазоны и разбор IPv6 написаны вручную. - Python.
ipaddressзнаетis_globalиipv4_mapped, а вотurlsplitпо документации «do not perform validation of inputs»: он отдаёт2130706433как есть, поэтому нестандартные числовые записи отклоняются явно.
Сама проверка ничего не стоит, если клиент потом сделает что-то другое: пойдёт по перенаправлению или зарезолвит имя второй раз. Поэтому клиент настраивается вместе с ней:
@Component
public class ImageFetcher {
private static final int MAX_BYTES = 5 * 1024 * 1024;
private final HttpClient http = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER)
.connectTimeout(Duration.ofSeconds(2))
.proxy(ProxySelector.of(new InetSocketAddress("egress-proxy.internal", 3128)))
.build();
public byte[] fetch(String url) throws IOException, InterruptedException {
SafeTarget.Verdict verdict = SafeTarget.safeTarget(url);
if (!verdict.ok()) {
throw new RejectedImageUrlException();
}
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
.timeout(Duration.ofSeconds(5))
.GET()
.build();
HttpResponse<InputStream> response = http.send(request, HttpResponse.BodyHandlers.ofInputStream());
try (InputStream body = response.body()) {
if (response.statusCode() != 200) {
throw new RejectedImageUrlException();
}
byte[] bytes = body.readNBytes(MAX_BYTES + 1);
if (bytes.length > MAX_BYTES) {
throw new RejectedImageUrlException();
}
return bytes;
}
}
}
const maxImageBytes = 5 << 20
func checkDialedAddress(network, address string, _ syscall.RawConn) error {
host, _, err := net.SplitHostPort(address)
if err != nil {
return err
}
if ip := net.ParseIP(host); ip == nil || internal(ip) {
return fmt.Errorf("соединение с %s запрещено", host)
}
return nil
}
var imageClient = &http.Client{
Timeout: 5 * time.Second,
Transport: &http.Transport{
DialContext: (&net.Dialer{Timeout: 2 * time.Second, Control: checkDialedAddress}).DialContext,
},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
func fetchImage(ctx context.Context, raw string) ([]byte, error) {
if ok, _ := safeTarget(raw); !ok {
return nil, errRejectedURL
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return nil, errRejectedURL
}
resp, err := imageClient.Do(req)
if err != nil {
return nil, errRejectedURL
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, errRejectedURL
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxImageBytes+1))
if err != nil || len(body) > maxImageBytes {
return nil, errRejectedURL
}
return body, nil
}
const dns = require("node:dns");
const http = require("node:http");
const https = require("node:https");
const MAX_BYTES = 5 * 1024 * 1024;
function checkedLookup(hostname, options, callback) {
dns.lookup(hostname, options, (err, address, family) => {
if (err) return callback(err);
const list = Array.isArray(address) ? address : [{ address, family }];
const denied = list.find((entry) => internal(entry.address));
if (denied) return callback(new Error(`соединение с ${denied.address} запрещено`));
callback(null, address, family);
});
}
function fetchImage(raw) {
const [ok] = safeTarget(raw);
if (!ok) return Promise.reject(new RejectedImageUrl());
const client = raw.startsWith("https:") ? https : http;
return new Promise((resolve, reject) => {
const req = client.get(raw, { lookup: checkedLookup, timeout: 5000 }, (res) => {
if (res.statusCode !== 200) {
res.resume();
return reject(new RejectedImageUrl());
}
const chunks = [];
let size = 0;
res.on("data", (chunk) => {
size += chunk.length;
if (size > MAX_BYTES) return req.destroy(new RejectedImageUrl());
chunks.push(chunk);
});
res.on("end", () => resolve(Buffer.concat(chunks)));
});
req.on("timeout", () => req.destroy(new RejectedImageUrl()));
req.on("error", reject);
});
}
MAX_BYTES = 5 * 1024 * 1024
image_client = httpx.AsyncClient(
follow_redirects=False,
timeout=httpx.Timeout(5.0, connect=2.0),
proxy="http://egress-proxy.internal:3128",
)
async def fetch_image(url: str) -> bytes:
ok, _ = safe_target(url)
if not ok:
raise HTTPException(status_code=400, detail="адрес картинки не принят")
async with image_client.stream("GET", url) as response:
if response.status_code != 200:
raise HTTPException(status_code=422, detail="картинку не удалось загрузить")
body = bytearray()
async for chunk in response.aiter_bytes():
body += chunk
if len(body) > MAX_BYTES:
raise HTTPException(status_code=413, detail="картинка больше 5 МБ")
return bytes(body)
В Go и Node проверка повторяется в момент соединения: Dialer.Control по документации вызывается «after creating the network connection but before actually dialing», а опция lookup у http.get подменяет резолвер. У HttpClient из JDK и у httpx простого крючка на момент соединения нет, поэтому в этих вариантах исходящие запросы идут через egress-прокси, который проверяет адрес сам. Ошибки клиенту отдаются одинаковые и без подробностей, чтобы их нельзя было использовать для сканирования портов.
Что выбрать для каждой точки маркетплейса
Слои выше не взаимозаменяемы, и для каждой функции подходит своя комбинация:
- Картинка товара. Лучший вариант: загрузка файла вместо ссылки, тогда SSRF нет совсем. Если ссылка нужна продавцам, её скачивает скачиватель с проверкой адреса, лимитом размера и проверкой, что пришла картинка.
- Вебхук продавца. Список разрешённых хостов невозможен, это Case 2. Проверка адреса при каждой отправке, без перенаправлений, через egress-прокси; продавцу в журнал доставки уходит только код ответа, без тела и текста сетевой ошибки.
- PDF-экспорт. Описание товара экранируется, а рендеру сеть закрыта. Картинки ему передаются уже скачанными, или он может загружать их только с вашего CDN (Case 1).
- Превью ссылки. То же, что вебхук, плюс проверка каждого вложенного адреса, найденного в разметке, и жёсткий лимит на число запросов на одно превью.
Общее правило: чем меньше свободы в адресе, тем проще защита. Хост из списка лучше любого URL, файл лучше любого хоста.
Архитектура: скачиватель без привилегий, egress-прокси, сеть и IMDSv2спросят на собеседовании
Проверка в коде закрывает обходы, которые вы знаете. Архитектура отвечает на другой вопрос: что получит атакующий, если найдёт обход, о котором вы не знаете. SSRF даёт ему ровно то, что может машина, которая делает запрос: её сеть, её роль в облаке, её соседей. Значит, у этой машины должно быть как можно меньше.
Отдельный сервис-скачиватель
Все места, где маркетплейс ходит по чужим адресам, сводятся в один маленький сервис: скачать картинку по ссылке, доставить вебхук, получить превью, собрать PDF. Каталог, уведомления и поддержка не ходят во внешнюю сеть сами, они просят скачиватель.
- Без привилегий. У скачивателя нет роли в облаке (или роль без прав), нет доступа к базе, нет секретов в переменных окружения, нет токенов к внутренним API. Если его обманут и он прочитает свои метаданные, ключей там нет.
- Узкий контракт. На вход он получает адрес и лимиты, на выход отдаёт байты, тип и статус. Он не возвращает текст ошибок соединения, только «не удалось».
- Его ответ недоверенный. Каталог заново проверяет, что пришла картинка нужного размера и формата, уведомления не показывают тело ответа продавцу целиком.
- Рендер PDF живёт там же или в такой же клетке. Это самый неуправляемый клиент: он сам решает, что загрузить из HTML. Ему сеть не нужна вовсе, если картинки скачаны заранее.
Плюс такой схемы в том, что проверку адреса, клиент без перенаправлений и лимиты достаточно написать и протестировать один раз, а не в каждом сервисе на своём языке.
Egress-прокси
Скачиватель не имеет прямого выхода наружу: сеть пропускает его только к прокси, а все исходящие запросы идут через прокси. Прокси сам резолвит имя, сам проверяет полученный IP по политике и сам подключается к этому IP. Проверка и соединение оказываются в одном месте, и разрыв, на котором держится DNS rebinding, исчезает.
Три выгоды, которых не даёт проверка в коде. Политика одна на все сервисы и языки, её не забудут в новом клиенте. Прокси пишет журнал всех исходящих запросов, и по нему видно, куда сервис пытался ходить. А если проверку в приложении всё-таки обошли, запрос во внутреннюю сеть упрётся в прокси. Важно, чтобы сам прокси тоже не мог ходить во внутренние подсети, иначе SSRF просто переедет на него.
Сетевая политика
Без правил под в Kubernetes может ходить куда угодно: по документации «By default, a pod is non-isolated for egress; all outbound connections are allowed». Для скачивателя ставят политику «запретить весь исходящий трафик» и разрешают только прокси. Документация отдельно предупреждает, что такой запрет отрезает и DNS, и разрешение на DNS-сервис кластера надо добавить явно (если резолвит прокси, скачивателю хватит адреса прокси).
У NetworkPolicy есть границы, и их надо знать. Она работает на уровне IP и портов (L3 и L4) и не умеет правила по доменным именам. Она требует сетевого плагина, который её поддерживает, без него объект создаётся, но ничего не делает. И главное для SSRF: по той же документации под не может закрыть себе доступ к localhost, а трафик между подом и его узлом разрешён всегда. Поэтому соседние контейнеры в поде и порты на 127.0.0.1 сеть не защитит, их закрывает только проверка адреса в коде.
IMDSv2
Типичная SSRF даёт атакующему адрес, но не метод и не заголовки: картинку каталог скачивает методом GET, вебхук уходит POST, заголовки ставит ваш код. IMDSv2 использует именно это. Чтобы получить метаданные, нужно сначала открыть сессию запросом PUT и получить токен, а потом передавать его в заголовке X-aws-ec2-metadata-token. Ни того, ни другого через поле «фото по ссылке» не сделать.
Есть ещё два слоя. PUT с заголовком X-Forwarded-For отклоняется: так IMDSv2 закрывается от открытых обратных прокси, которые этот заголовок добавляют. А ответ с токеном по умолчанию уходит с ограничением в один сетевой переход (TTL пакета 1), и машина, по ошибке настроенная как маршрутизатор или NAT, не выпустит его наружу; почему это работает, AWS подробно объясняет в разборе IMDSv2. Для контейнеров на машине этот предел иногда приходится поднимать, документация говорит об этом прямо.
Защита работает, только если токен обязателен. Пока IMDSv1 включён, старый путь без токена открыт, и атака из первого раздела проходит как раньше. И это защита в глубину, а не решение: если SSRF даёт управлять методом и заголовками (например, через gopher://, где атакующий пишет байты запроса сам), IMDSv2 не поможет. Проверка адреса и закрытая сеть остаются основой.
Как слои страхуют друг друга
Смысл набора в том, что каждый слой закрывает провал соседнего:
- Проверку адреса в коде обошли новой записью IP: запрос упирается в egress-прокси, который проверяет адрес сам.
- Прокси настроили с ошибкой или обошли: NetworkPolicy не пускает скачиватель никуда, кроме прокси.
- Сеть пропустила запрос к метаданным: у скачивателя нет роли, и ключей там нет, а IMDSv2 с обязательным токеном не отдаёт ответ на простой
GET. - Атакующий целится в localhost или соседний контейнер, где сеть бессильна: здесь работает только проверка в коде, поэтому её не заменяют сетевыми правилами.
Как проверить
- Тесты на функцию проверки. Набор из живого примера выше переносится в модульный тест один к одному: каждая строка атаки должна дать отказ, публичный адрес должен пройти. Добавляйте туда каждый новый обход, который найдёт пентест.
- Тест на перенаправление. Тестовый сервер на разрешённом адресе отвечает
302наhttp://127.0.0.1/; клиент не должен пойти дальше. - Проверка на стенде. Вебхук продавца с адресом
http://169.254.169.254/latest/meta-data/должен вернуть ошибку валидации, а в логах исходящего прокси не должно появиться запроса. - Статический анализ. OWASP ссылается на готовые правила Semgrep для SSRF; они находят места, где URL из запроса попадает в HTTP-клиент.
- В ревью ищите любой HTTP-клиент, который получает адрес не из конфигурации:
restClient.get().uri(...),http.Get,fetch,httpx.get, а также HTML-рендеры, парсеры XML с внешними сущностями и SDK, которые принимают URL. Отдельный вопрос: под какой ролью облака работает этот сервис и что он видит в сети.
Модульные тесты на safeTarget
Функция проверки должна получать резолвер параметром, как в живом примере, где DNS заменён словарём. Тогда тест не ходит в сеть, работает за миллисекунды и может подсунуть любой ответ DNS. Набор случаев стоит держать таблицей, по строке на обход:
- Схемы:
file:///etc/passwd,gopher://,ftp://,data:; разрешены толькоhttpиhttps. - Учётные данные и порты:
http://shop.example@10.0.0.5/,http://user:pass@host/, порты6379,9200,8080. - Записи IPv4:
2130706433,0x7f.0.0.1,0177.0.0.1,127.1,0.0.0.0. - IPv6:
[::1],[::ffff:a9fe:a9fe],[fd00::1],[fe80::1]. - Имена:
localhost,localhost.,api.localhost,metadata.google.internal. - DNS: имя, которое резолвится во внутренний адрес; имя с двумя адресами, публичным и внутренним (отказ должен быть, даже если внутренний второй); имя, которое не резолвится вовсе.
- Положительные случаи: публичный вебхук продавца проходит, и функция возвращает именно тот IP, к которому клиент потом подключится.
Тест проверяет не только «отказ», но и причину: иначе случай 0x7f.0.0.1 может зеленеть из-за того, что URL не разобрался, и никто не заметит, что проверка IP для него не работает. Каждый обход, найденный пентестом или в чужом разборе, добавляется строкой в эту таблицу.
Интеграционный тест с локальным слушателем
Модульный тест проверяет функцию, интеграционный проверяет, что клиент действительно её слушается. Для этого в тесте поднимается настоящий HTTP-сервер на 127.0.0.1 и случайном порту (com.sun.net.httpserver.HttpServer, httptest.NewServer в Go, http.createServer().listen(0) в Node, http.server в Python в отдельном потоке), который считает пришедшие запросы.
- Прямой адрес. Скачивателю дают URL слушателя. Ожидается не просто ошибка, а ноль запросов на счётчике: ошибка могла случиться и после того, как запрос ушёл.
- Перенаправление. Нужны два слушателя: «внешний», которому политика в тесте разрешена (резолвер-заглушка выдаёт его за публичный адрес), и «внутренний». Внешний отвечает
302на внутренний. Счётчик внутреннего должен остаться нулевым. - Rebinding. Резолвер-заглушка на первый вызов отвечает публичным адресом, на второй адресом слушателя. Тест падает, если клиент резолвит имя второй раз в обход проверки, и проходит, если соединение идёт на проверенный IP или проверяется в момент соединения.
- Лимиты. Слушатель отвечает бесконечным телом или молчит. Скачиватель должен оборвать загрузку по размеру и по таймауту за заданное время, а не висеть.
- Одинаковые ошибки. Закрытый порт и молчащий слушатель дают клиенту один и тот же ответ.
Тот же набор полезно прогнать на стенде против настоящего прокси: запрос к 169.254.169.254 должен остаться в журнале прокси как отклонённый, а не как выполненный.
Что смотреть в ревью
- Откуда адрес. Пользовательский ввод это не только поле запроса: адрес вебхука, сохранённый продавцом полгода назад, ссылка в описании товара, URL в загруженном файле. Всё, что когда-то пришло снаружи, проверяется перед каждым запросом.
- Какой клиент и с какими умолчаниями. Идёт ли он по перенаправлениям, резолвит ли имя второй раз после проверки, есть ли таймауты и лимит тела.
- Что уходит наружу. Тело ответа, заголовки, текст ошибки соединения, время ответа в журнале доставки.
- Неочевидные клиенты. Рендер HTML в PDF, парсер XML с внешними сущностями, SDK, которые принимают URL, библиотеки JWT: RFC 8725, раздел 3.10 предупреждает, что слепо идти по URL из заголовков
jkuиx5uзначит получить SSRF (подробнее в статье про ошибки с JWT). - Права и сеть. Под какой ролью работает сервис, видит ли он базу и внутренние API, закрыт ли ему исходящий трафик.
Типичные ошибки
- Проверять строку вместо адреса. Регулярное выражение,
containsилиendsWith("seller.example")над URL. Строку обходят десятичной записью IP, IPv6,@и синонимами, аendsWithпропускает ещё иevilseller.example. Проверять надо IP, к которому будет соединение, а домен сравнивать целиком или по границе точки. - Резолвить дважды. Функция проверки резолвит имя и смотрит IP, а HTTP-клиент получает исходный URL и резолвит его снова. Между двумя запросами к DNS ответ может смениться, это и есть DNS rebinding. Клиент должен подключаться к проверенному IP или проверять адрес в момент соединения.
- Проверить только первый шаг. Первый URL проверен, а перенаправления включены, или включены повторы и запасные соединения, которые идут мимо проверки. Шпаргалка OWASP отдельно требует применять политику к повторам и запасным соединениям.
- Забыть IPv6. Проверяется только запись A, а AAAA пропускается;
[::1], IPv4-mapped адреса иfc00::/7не входят в список. У Java есть ловушка:isSiteLocalAddressдля IPv6 знает только устаревшийfec0::/10. - Отдавать наружу ответ и подробности. Тело ответа, текст
connection refusedи время доставки в журнале вебхука превращают слепую SSRF в читалку внутренних страниц и сканер портов. - Скачивать в сервисе с широкими правами. Загрузка по ссылке живёт в каталоге, у которого роль на запись в бакеты и доступ к базе, а IMDSv1 не выключен. Любой пропущенный обход сразу даёт ключи облака.
- Не защищать внутренние сервисы. «Сеть внутренняя, аутентификация не нужна». SSRF как раз делает атакующего «внутренним», поэтому проверка подлинности между сервисами (статья про service-to-service) снижает и ущерб от SSRF.
Глубже: DNS rebinding и проверка в момент соединениярасширенноеспросят на собеседовании
Проверка «резолвим имя, смотрим IP» и само соединение разнесены во времени, и между ними HTTP-клиент обычно резолвит имя ещё раз. Атакующий, у которого свой DNS-сервер, отдаёт на первый запрос публичный адрес с минимальным временем жизни, а на второй 10.0.0.5. Проверка видела публичный адрес, соединение ушло во внутренний. Это и есть DNS rebinding, частный случай гонки «проверил, потом использовал».
По шагам на вебхуке продавца:
- Продавец сохраняет адрес
https://rebind.seller.example/orders. Его DNS-сервер отвечает93.184.215.14со временем жизни записи в одну секунду. - Функция проверки резолвит имя, видит публичный адрес и пропускает вебхук.
- HTTP-клиент получает исходный URL, кэш уже истёк, и он резолвит имя заново. Теперь DNS-сервер атакующего отвечает
10.0.0.5. - Запрос уходит во внутреннюю сеть, а в журнале остаётся запись «адрес проверен».
Список запрещённых диапазонов здесь ни при чём: второй ответ проверка честно отклонила бы, но её спросили только про первый. Лечится это не списком, а тем, что проверенный ответ и использованный совпадают.
Шпаргалка OWASP прямо говорит, что список разрешённых доменов от этого не спасает: «Domain allowlisting alone does not prevent DNS rebinding», и что «A second, unchecked DNS lookup between validation and connection can bypass these checks». Рабочих вариантов три:
- Соединяться с тем IP, который проверили. HTTP-клиент получает уже проверенный адрес, а исходное имя остаётся в заголовке
Host, в TLS SNI и при проверке сертификата. OWASP описывает именно это: «connects to the validated IP while preserving the original hostname». - Проверять в момент соединения. Крючок
Dialer.Controlв Go или свойlookupв Node видят тот адрес, к которому клиент действительно подключается, и проверка оказывается там, где её нельзя обойти вторым резолвом. Это закрывает и перенаправления: каждый новый запрос пройдёт через тот же крючок. - Проверять на egress-прокси. Прокси сам резолвит имя и сам решает, куда соединяться; приложение вообще не ходит в сеть напрямую. Политика одна на все сервисы и все языки, и её не забудут в очередном новом клиенте.
Тот же разрыв есть и у перенаправлений: если клиент следует им сам, после первого ответа он идёт уже по адресу, который никто не проверял. Поэтому перенаправления либо выключают, либо обрабатывают вручную, прогоняя Location через ту же проверку и тот же счётчик шагов.
Глубже: два парсера читают один URL по-разномурасширенное
Проверка и HTTP-клиент часто используют разные парсеры URL, а парсеры расходятся. Шпаргалка OWASP разбирает строку http://example.com\@evil.com: парсер по стандарту WHATWG считает обратную косую черту разделителем пути и видит хост example.com, а urllib.parse в Python берёт всё до последнего @ как имя пользователя и видит хост evil.com. Это легко повторить: new URL(...).hostname в Node возвращает example.com, urlsplit(...).hostname в Python возвращает evil.com.
Если проверка стоит в одном сервисе, а запрос делает другой, атакующий подбирает строку, которую они понимают по-разному. Правила из шпаргалки: принимать от пользователя хост, а не URL целиком, и собирать запрос самому из проверенного хоста с фиксированными схемой, портом и путём; а строку, которую парсеры читают по-разному, отклонять, а не примирять: «Reject a URL whose host is not read identically by every parser in play». В примере выше этот случай отсекается проверкой учётных данных: Python видит в строке имя пользователя example.com\.
Коротко
- SSRF (CWE-918): сервер делает запрос по адресу от пользователя и попадает туда, куда пользователь сам не достаёт. В OWASP Top 10:2025 входит в A01 Broken Access Control.
- На маркетплейсе это вебхуки продавцов, загрузка картинки по ссылке, PDF-экспорт из HTML и превью ссылок. Цели: метаданные облака
169.254.169.254, localhost и служебные порты, соседние сервисы без аутентификации, схемыfile://иgopher://. - Список запрещённых строк обходят десятичной и восьмеричной записью IP, IPv6 и IPv4-mapped адресами,
@в URL, перенаправлениями и своим DNS. - Проверяют IP, к которому будет соединение: схема
http/https, без учётных данных, разрешённые порты, каждый адрес из A и AAAA вне частных, loopback, link-local и multicast диапазонов. - Соединяются с проверенным адресом или проверяют в момент соединения, перенаправления выключены или проверяются на каждом шаге, есть таймауты и лимит размера, ответ и ошибки наружу не отдаются.
- Сеть страхует код: egress-прокси, запрет внутренних подсетей для сервиса-скачивателя, отдельный сервис без привилегий, IMDSv2 с обязательным токеном.
- Скачиватель без роли и секретов превращает удачную SSRF в запрос, который ничего не даёт; egress-прокси сводит проверку и соединение в одно место.
- NetworkPolicy не закрывает localhost и соседей по поду, а IMDSv2 не спасает, если атакующий управляет методом и заголовками: проверка адреса в коде нужна всегда.
- Тестируют таблицей обходов против функции проверки и интеграционным тестом с локальным слушателем: главное утверждение в нём «ноль запросов дошло».
Что почитать дальше
- Где ставить проверку: слои проверки и краткий обзор SSRF среди других дыр помимо доступа.
- Service-to-service аутентификация: почему внутренний сервис не должен доверять запросу только потому, что он пришёл изнутри.
- PII и секреты: безопасный обработчик ошибок, который не отдаёт подробности наружу.
- Безопасность браузера и сети: XSS, CSRF и CORS со стороны клиента.
- XXE и десериализация: как парсер XML с внешними сущностями становится той же SSRF.
- Open redirect, CSRF и CORS: если клиент идёт по перенаправлениям, открытый редирект на разрешённом домене обходит и список разрешённых хостов.
- Ошибки с JWT: почему нельзя ходить за ключом по
jkuиз токена. - Тестирование безопасности: где в конвейере место для таких тестов и сканеров.