Что такое URL
В прошлом году Дэниэл Стенберг, создатель 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 лет, так что поведение описываемых библиотек изменилось, но даже устаревшие примеры по-прежнему полезны в качестве иллюстративного материала).
Запрос или имя пользователя
Как следует распарсить этот 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# ) — это имя пользователя. Таково поведение встроенной библиотеки Python urllib .
Разумеется, мы видим, как такая нестрогая реализация, в которой якобы реализуется стратегия достижения цели «малой кровью», может логически выйти на любой из трёх вариантов. Современные реализации requests и urllib сошлись на том, что нужно трактовать 1.1.1.1 &@ 2.2.2.2 как хост (urllib2 в Python 3 не существует, поэтому больше не поддерживается).
Порт или путь
Как же должен быть разобран этот 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.1 , 127.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:
Для валидации URL и для отправки HTTP-запроса мы пользуемся разными библиотеками. Как было указано выше, по данным urllib хост-имя — это companyname.s3.amazonaws.com , но библиотека requests отправила бы запрос на вредоносный сайт malicious-website.com ! Хуже того, этот запрос содержал бы ключ к AWS API, что открыло бы злоумышленнику полный доступ к нашей корзине!
Это сработало бы лишь в случае, если вы пользуетесь короткоживущими сеансовыми токенами. Если генерировать сигнатуры, специфичные для каждого запроса, то становится гораздо сложнее повторно использовать учётные данные.
Именно такой риск возникает из-за несогласованного парсинга 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 у вас в коде.
Разумеется, теперь фокус в том, чтобы правильно валидировать эти идентификаторы файлов! В OWASP предусмотрена шпаргалка, также помогающая и с валидацией ввода.
Пример с вебхуками гораздо сложнее. В шпаргалке OWASP, помогаюшей предотвращать подделку запросов на стороне сервера, даются рекомендации на такой случай, но даже в этом документе случай с вебхуками описан весьма туманно. Думаю, максимум, что можно сделать — изолировать сервис и лишить его привилегий при вызове вебхуков. Таким образом, если сервис всё-таки обманом заставят выполнять вебхуки, у него не будет доступа по сети к другим компонентам, а если и будет, то сервис не будет обладать нужными привилегиями, которые позволили бы ему повлиять на систему.