В прошлом году Дэниэл Стенберг, создатель curl, написал пост об одном забавном URL:

http://http://http://@http://http://?http://#http://

Пост интересен, рекомендую его прочитать. Автор объясняет, как устроен URL, и как различные системы его обрабатывают.

Но в том посте не разобрано, в частности, как сказывается такая разница в обработке одних и тех же URL различными системами. В этой лекции 2017 года (слайдывидео) Оранж Цай рассматривает и многие другие несогласованности между различными библиотеками, а также риски из области безопасности, возникающие из-за такой несогласованности.

В лекции данная тема раскрыта в мельчайших (и очень увлекательных) деталях, но здесь я хотел бы резюмировать суть.

Элементы URL

Как в вышеупомянутом посте, так и в лекции, на которую я обратил ваше внимание, сказано, что определить URL непросто. Для этого существует RFC, спецификация WHATWG и множество разношёрстных реализаций.

В самом общем виде URL состоит из следующих частей:

scheme://username:password@host:port/path?query#fragment
  • scheme: используемый протокол (например, http или https).

  • username:password: Сайты, на которых используется базовая схема аутентификации, позволяют при аутентификации вставлять ваши имя пользователя и пароль прямо в URL. Такая практика считается очень небезопасной, поэтому не так много сайтов, где она поддерживается.

  • host: Это домен или IP-адрес, к которому вы хотите подключиться (например, google.com или 127.0.0.1).

  • port: порт напоминает номер абонентского ящика, и по этому номеру можно связаться с хостом. Если такого порта нет, то по умолчанию в такой схеме используется 80 для http и 443 для https).

  • path: это конкретная веб-страница на хосте. Например, путь к оригиналу этой статьи — /posts/what-is-a-url.html

  • query: это коллекция параметров, обычно представленных в форме пар key=value, которые объединяются знаком &. Они используются для отправки на сервер более конкретной информации.

  • fragment: обычно используется в качестве якоря для перехода в конкретный раздел документа. Например, именно к этому разделу можно перейти по ссылке #parts. Правда, обратите внимание, что сервер не видит этого фрагмента. Он обрабатывается (или игнорируется) именно на стороне клиента.

Отличия и сложности

С вышеприведённым определением есть проблема: неясно, что разрешено и что не разрешено в каждой части URL. В официальной спецификации определено гораздо больше деталей, но отличия в интерпретации сохраняются. В частности потому, что в Вебе заложено допущение о нестрогом парсинге, позволяющем подправлять ошибки других систем.

Процитирую некоторые примеры из той лекции, ссылка на которую дана выше (этим примерам 6 лет, так что поведение описываемых библиотек изменилось, но даже устаревшие примеры по-прежнему полезны в качестве иллюстративного материала).

Запрос или имя пользователя

http://1.1.1.1 &@ 2.2.2.2# @3.3.3.3/

Как следует распарсить этот URL?

  • Если хост — это 1.1.1.1, то всё после & (@ 2.2.2.2) — запрос, а остальное — фрагмент, поскольку идёт после #. Именно такое поведение было встроено в библиотеку Python urllib2.

  • Если хост — это 2.2.2.2, то всё до первого @ (1.1.1.1 &)  — это имя пользователя, а всё после # (@3.3.3.3/) — это фрагмент. Это поведение библиотеки requests на Python.

  • Если хост — это 3.3.3.3, то всё до второго @ (1.1.1.1 &@ 2.2.2.2#) — это имя пользователя. Таково поведение встроенной библиотеки Pythonurllib.

Разумеется, мы видим, как такая нестрогая реализация, в которой якобы реализуется стратегия достижения цели «малой кровью», может логически выйти на любой из трёх вариантов. Современные реализации requests и urllib сошлись на том, что нужно трактовать 1.1.1.1 &@ 2.2.2.2 как хост (urllib2 в Python 3 не существует, поэтому больше не поддерживается).

Порт или путь

http://127.0.0.1:5000:80/

Как же должен быть разобран этот URL?

  • Если порт 5000, то путь :80/. Именно такое поведение было свойственно вызову readfile в PHP.

  • Если порт 80, то хост 127.0.0.1:5000. Именно так действовал parse_url в PHP.

Путаница с хостами

В поле с хостом система находит информацию о том, куда направлять запрос. Это самая важная часть URL, и с ней сопряжена уйма сложностей.

Хост может выглядеть, как доменное имя, например google.com, как адрес IPv4, например 127.0.0.1, или как адрес IPv6, например ::1. Как с IPv4, так и с IPv6 бывают особые случаи, и применяются особые правила форматирования, и поддерживаться они могут несогласованно. Например, в самом документе RFC подчёркиваются возможные рассогласования при синтаксическом разборе адресов IPv4:

  • В некоторых реализациях поддерживается менее 4 частей. В адресе с тремя полями последнее значение трактуется как 16-разрядное (127.0.1). В адресе с двумя полями последнее значение трактуется как 24-разрядное (127.1). Если в адресе 1 часть, то всё это значение разбирается просто как единое 32-разрядное целое число (2130706433).

  • Есть и такие реализации, в которых каждая часть также может быть представлена в десятичном (127), восьмеричном (0177) или шестнадцатеричном формате (0x7F)

Итак, в зависимости от реализации http://2130706433 может считаться (или не считаться) равным http://127.0.0.1

Риск

Да, конечно, какие-то разбежки существуют, но в чём реальная проблема? Просто не надо делать странных URL — и вы не столкнётесь с пограничными случаями.

Проблема в том, что иногда приходится иметь дело с URL, которые составлял кто-то другой. В особенности, люди, которым вы не доверяете — их ещё называют «пользователями».

Защита Localhost

Представьте, что строите систему на основе вебхуков. Ваши пользователи предоставляют вам URL, и всякий раз при определённом событии вы отправляете HTTP-запрос по данному URL.

В подобной системе возникает риск, связанный с подделкой запросов на стороне сервера (SSRF), так как пользователь может заставить вас отправить запрос в случае, когда для вас это нежелательно. Например, у вас на порту 9000 может работать какой-нибудь критически важный сервис. Но, если пользователь установит URL-вебхук на http://localhost:9000/shutdown, то ваша система вебхуков отправит этот http-запрос критичному сервису, и сделает это изнутри сети!

Сервис вебхука может открыть окольный путь к такому критически важному сервису, который в нормальных условиях никогда не был бы доступен извне сети. Если пользователь-злоумышленник направит прямой запрос к критичному сервису, то сеть этот запрос заблокирует (красная стрелка). Но если этот злоумышленник обманом заставит сервис с вебхуком обратиться к тому же критичному сервису, то запрос пройдёт успешно (зелёная стрелка).
Сервис вебхука может открыть окольный путь к такому критически важному сервису, который в нормальных условиях никогда не был бы доступен извне сети. Если пользователь-злоумышленник направит прямой запрос к критичному сервису, то сеть этот запрос заблокирует (красная стрелка). Но если этот злоумышленник обманом заставит сервис с вебхуком обратиться к тому же критичному сервису, то запрос пройдёт успешно (зелёная стрелка).

Чтобы предотвратить подобные случаи, можно написать, например, такой код:

def call_webhook(url):
  parts = urllib.parse.urlparse(url)
  if isLocalHost(parts.hostname):
    raise Exception("localhost is not allowed!")
  requests.get(url)

Как мы реализуем isLocalHost? Для начала давайте побеспокоимся только об IP-адресах. Можно вспомнить различные сложности, возникающие при предоставлении адресов IPv4 и IPv6. Поэтому не будем сравнивать их с конкретными строками, а лучше преобразуем адреса в десятичное представление и сравним десятичные значения (как рекомендовано в RFC). Таким образом, все 127.0.0.1127.0.1 и 127.1 будут отображаться на одно и то же значение: 2130706433. В таком случае код может принять вид

def isLocalHost(hostname):
  if isIPv4(hostname):
    decimal = int(ipaddress.IPv4Address(hostname))
    return decimal == 2130706433
  if isIPv6(hostname):
    decimal = int(ipaddress.IPv6Address(hostname))
    return decimal == 1
  return False

Это уже выглядит довольно хорошо, за такой код можно и по плечу программиста похлопать. Но злоумышленник берёт и посылает нам URL: http://0:9000/shutdown. Как разобрано на приведённых выше слайдах, 0 в Linux отображается на localhost! Поскольку 0 не равно 1 или 2130706433, этот запрос проходит нашу валидацию.

Мы последователи дополнительным советам, содержащимся в спецификации, и всё равно облажались.

Список разрешённых доменов

Допустим, мы создаём сервис, который загружает в корзину S3 датасеты, собираемые ежедневно. Пользователь может просмотреть список файлов, содержащихся в корзине, но к самим файлам обратиться не может. Можно выбрать, какой файл тебя интересует, и отправить к нашему сервису URL того датасета, который нас интересует. Мы скачаем данные, проанализируем их и отправим резюме пользователю.

Код для такой операции может выглядеть примерно так:

def pull_data(url):
  parts = urllib.parse.urlparse(url)
  hostname = parts.hostname
  if hostname != "companyname.s3.amazonaws.com":
    raise Exception("Only companyname bucket allowed")
  
  data = requests.get(url, AWS_KEY_FOR_BUCKET)
  return analyze(data)

В отличие от предыдущей ситуации, где у нас фактически был стоп-лист, здесь реализован список разрешённых адресов, что, как правило, лучше с точки зрения безопасности. Поскольку мы допускаем только такие URL, которые относятся к нашей корзине, мы можем быть в большей степени уверены, что не отправляем запрос на опасный хост.

Правда, всё равно сохраняется проблема. Предположим, пользователь присылает нам такой URL:

http://malicious-website.com#@ companyname.s3.amazonaws.com

Для валидации URL и для отправки HTTP-запроса мы пользуемся разными библиотеками. Как было указано выше, по данным urllib хост-имя — это companyname.s3.amazonaws.com, но библиотека requests отправила бы запрос на вредоносный сайт malicious-website.com! Хуже того, этот запрос содержал бы ключ к AWS API, что открыло бы злоумышленнику полный доступ к нашей корзине!

Hidden text

Это сработало бы лишь в случае, если вы пользуетесь короткоживущими сеансовыми токенами. Если генерировать сигнатуры, специфичные для каждого запроса, то становится гораздо сложнее повторно использовать учётные данные. 

Именно такой риск возникает из-за несогласованного парсинга URL от системы к системе и от библиотеки к библиотеке.

Так что же?

Те уязвимости, что я упомянул выше, были найдены и исправлены в 2016/2017. Но сама проблема никуда не исчезла. Вот баг от декабря 2022, из библиотеки, использующей requests; она отправляла бы запросы по поводу http://domain:0 на заданный по умолчанию порт: http://domain:80. Вот баг от мая 2022, найденный в curl, который привёл бы к отправке запроса http://example.com%2F10.0.0.1/ на http://example.com/10.0.0.1/.

В обеих этих ситуациях нашу валидацию можно было бы обойти. В URL указан порт 80? Нет. В URL содержится хост-имя example.com? Нет. И, всё-таки, запрос пошёл бы, соответственно, на порт 80 и к домену example.com.

Поэтому, если данная проблема никуда не девается, что мы можем сделать? Ответ такой же как и с большинством бед из области безопасности: не доверяйте пользовательскому вводу. Но в идеале недоверие пользовательскому вводу должно быть предусмотрено ещё на уровне архитектуры. Возвращаясь к ситуации, где пользователь отправлял нам URL на корзину S3, нам нет никакого резона принимать от пользователя полный URL. Пусть пользователь пришлёт вам какой-нибудь идентификатор файла, а затем вы сами соберёте URL у вас в коде.

Hidden text

Разумеется, теперь фокус в том, чтобы правильно валидировать эти идентификаторы файлов! В OWASP предусмотрена шпаргалка, также помогающая и с валидацией ввода

Пример с вебхуками гораздо сложнее. В шпаргалке OWASP, помогаюшей предотвращать подделку запросов на стороне сервера, даются рекомендации на такой случай, но даже в этом документе случай с вебхуками описан весьма туманно. Думаю, максимум, что можно сделать — изолировать сервис и лишить его привилегий при вызове вебхуков. Таким образом, если сервис всё-таки обманом заставят выполнять вебхуки, у него не будет доступа по сети к другим компонентам, а если и будет, то сервис не будет обладать нужными привилегиями, которые позволили бы ему повлиять на систему.

Комментарии (0)