SSRF и исходящие запросы
Приложение может не только отвечать браузеру, но и обращаться к другому сервису. Если посетитель выбирает произвольный URL такой операции, он получает возможность использовать сетевое положение сервера. Это класс ошибок SSRF — server-side request forgery.
В нашем примере нужна только учебная справка по двум материалам: markdown и xml. Результат урока — фиксированный сетевой договор с выбором известного ключа. Это самостоятельная модель интеграции security-lab; она не предоставляет загрузку любых внешних страниц.
Кто выбирает назначение
Сервер часто имеет доступ к адресам, которые браузеру недоступны. Они могут относиться к внутренним приложениям или локальному окружению. Поэтому проверка, что строка «похожа на URL», не определяет допустимый ресурс.
Небольшой denylist тоже недостаточен для общего загрузчика. Адрес может измениться через DNS или перенаправление, а разные записи одного назначения имеют собственные формы. Чем шире функция, тем больше правил требуется проверить.
Но наш продукт не нуждается в широком загрузчике. Посетитель выбирает ключ материала, а сервер связывает его с фиксированным путём известного сервиса. Хост, порт и протокол не поступают из формы.
Учебный источник
Отдельный reference_stub.py обслуживает вымышленные JSON на 127.0.0.1:4271. Этот внутренний адрес разрешён именно по нашей модели: приложение намеренно обращается к собственному локальному стенду.
Не следует выводить из этого правило «все локальные адреса допустимы». Смысл определяется назначением конкретной интеграции. Для публичного загрузчика страниц доступ к внутренней сети потребовал бы другого ограничения.
Два ключа связываются с постоянными путями:
REFERENCE_PATHS = {
"markdown": "/reference/markdown",
"xml": "/reference/xml",
}
Если запрошен неизвестный ключ, клиент отказывает до сетевого обращения. Пользователь не может добавить query с другим хостом и тем самым изменить соединение. Функция получает ключ, а не полный объект настроек клиента.
Ограниченный клиент
Ниже полный reference_client.py. Он не следует перенаправлениям, ограничивает ожидание и число полученных байтов. Ответ должен иметь ожидаемый тип и небольшую структуру.
import http.client
import json
REFERENCE_PATHS = {
"markdown": "/reference/markdown",
"xml": "/reference/xml",
}
def fetch_reference(key):
path = REFERENCE_PATHS.get(key)
if path is None:
raise ValueError("Неизвестный материал")
connection = http.client.HTTPConnection("127.0.0.1", 4271, timeout=2)
try:
connection.request("GET", path)
response = connection.getresponse()
if response.status != 200:
raise RuntimeError("Источник недоступен")
media_type = response.getheader("Content-Type", "").split(";")[0]
if media_type != "application/json":
raise RuntimeError("Неверный тип")
raw = response.read(16_385)
if len(raw) > 16_384:
raise RuntimeError("Ответ слишком большой")
data = json.loads(raw)
if not isinstance(data, dict) or set(data) != {"title", "summary"}:
raise RuntimeError("Неверная структура")
if any(not isinstance(value, str) or len(value) > 1000
for value in data.values()):
raise RuntimeError("Неверные значения")
return data
finally:
connection.close()
HTTPConnection используется для локального учебного транспорта. Это не инструкция передавать реальные секреты открытым внешним HTTP. Для настоящего внешнего сервиса понадобятся HTTPS, проверка сертификата и подтверждённая политика сети.
Статус 3xx здесь не ведёт к новому адресу: он отклоняется как неподходящий ответ. Поэтому даже доверенный источник не получает возможность изменить цель клиента через Location. Таймер и размер ответа решают ресурсные вопросы отдельно от выбора назначения.
Проверка схемы
JSON синтаксически корректен ещё не значит, что он подходит нашей операции. Мы ожидаем title и summary, строки ограниченной длины. Массив, дополнительные поля или огромное значение нарушают договор и не попадают в интерфейс как произвольное содержимое.
Полученный текст также выводится стандартным безопасным способом. Внутренний сервис не получает привилегии присылать исполняемый HTML. Граница вывода предыдущих уроков сохраняется после сетевого обращения.
Проверка длины ответа выполняется до разбора всего произвольного потока. Но задержка сервиса и число одновременных обращений могут потребовать дополнительных пределов. Если интеграция станет часто используемой, понадобятся кеширование по версии, очередь или ограничение параллельности.
Известная цель и сеть
Фиксированная цель уменьшает поверхность ввода. Внешняя DNS-запись и конфигурация сервиса всё равно являются частью доверия. Разрешённый домен не должен незаметно превращаться в произвольное сетевое назначение из-за небезопасной настройки.
Для функций, принимающих разные URL, нужно анализировать схемы, DNS, перенаправления, фактическое соединение и исходящую сетевую политику. Проверка одного имени до обращения не является полной защитой. Разбор этих проблем доступен в OWASP SSRF Prevention.
Наш пример специально сохраняет меньшую возможность. Он объясняет архитектурный приём: если продукту нужен один источник и два запроса, API не должен случайно разрешать весь интернет.
Наблюдение и отказ
При действующем локальном stub запрос markdown ожидает подготовленную справку. Неизвестный ключ не вызывает соединение. Остановленный источник или неверный JSON приводит к общей ошибке интеграции, а не к выполнению другого запасного URL.
Проверка отказа должна смотреть и на источник: действительно ли к нему не было обращения для неизвестного ключа. Одна ошибка браузера не говорит, на каком шаге приложение прекратило работу.
В журнал не нужно помещать полный ответ или сетевые секреты. Можно записать известный ключ, категорию отказа и длительность. Связь с requestId основного запроса поможет разбираться без раскрытия содержания.
Если позже появляется новый материал, редактор добавляет известный путь в справочник и данные stub. Это видимое расширение договора. Скрытое принятие произвольного URL в старом параметре разрушило бы смысл уже рассмотренной проверки.
Перенаправление и фактический получатель
Если stub неожиданно вернул 302, клиент не выбирает адрес Location. Он сообщает об отказе известного источника. Это позволяет проверить границу без обращения к третьему серверу.
Браузерный fetch и разные HTTP-библиотеки могут иметь другое поведение перенаправлений по умолчанию. Поэтому запрет должен соответствовать конкретному клиенту, а не существовать только в комментарии. В показанном HTTPConnection мы вручную проверяем единственный ответ.
Параметр ?url=... у маршрута references не участвует в построении назначения. Однако хорошая документация должна сообщить, какие параметры действительно поддерживаются. Иначе следующий разработчик может принять неиспользуемую строку за предусмотренную возможность и подключить её без анализа.
Проверьте также размер корректного JSON с длинным summary. Сеть могла вернуть 200 и подходящий MIME, но договор значений всё равно нарушен. Ограничение структуры действует после байтового предела и отвечает на другой вопрос.
Если интеграция требует секретного заголовка, он формируется серверной конфигурацией для известной цели. Не перенаправляйте его вслед за произвольным URL. Самостоятельная политика редиректов и назначения предотвращает появление такого неожиданного канала.
В нашем локальном источнике секретов нет, что позволяет наблюдать сетевой договор без личных данных и внешних аккаунтов.
Теперь исходящее обращение имеет фиксированную цель, ограниченные значения и понятный отказ. Следующий урок применит похожий подход к JSON-API, которое обслуживает собственный браузер приложения.