[Перевод] Что такое 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 лет, так что поведение описываемых библиотек изменилось, но даже устаревшие примеры по-прежнему полезны в качестве иллюстративного материала).
Запрос или имя пользователя
http://1.1.1.1 &@ 2.2.2.2# @3.3.3.3/
Как следует распарсить этот URL?
Если хост — это
1.1.1.1
, то всё после&
(@ 2.2.2.2
) — запрос, а остальное — фрагмент, поскольку идёт после#
. Именно такое поведение было встроено в библиотеку Pythonurllib2
.Если хост — это
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.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:
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, помогаюшей предотвращать подделку запросов на стороне сервера, даются рекомендации на такой случай, но даже в этом документе случай с вебхуками описан весьма туманно. Думаю, максимум, что можно сделать — изолировать сервис и лишить его привилегий при вызове вебхуков. Таким образом, если сервис всё-таки обманом заставят выполнять вебхуки, у него не будет доступа по сети к другим компонентам, а если и будет, то сервис не будет обладать нужными привилегиями, которые позволили бы ему повлиять на систему.