В прошлом году Дэниэл Стенберг, создатель curl, написал пост об одном забавном URL: Пост интересен, рекомендую его прочитать. Автор объясняет, как устроен URL, и как различные системы его обрабатывают.  Но в том посте не разобрано, в частности, как сказывается такая разница в обработке одних и тех же URL различными системами. В этой лекции 2017 года (слайды, видео) Оранж Цай рассматривает и многие другие несогласованности между различными библиотеками, а также риски из области безопасности, возникающие из-за такой несогласованности. В лекции данная тема раскрыта в мельчайших (и очень увлекательных) деталях, но здесь я хотел бы резюмировать суть. Как в вышеупомянутом посте, так и в лекции, на которую я обратил ваше внимание, сказано, что определить URL непросто. Для этого существует RFC, спецификация WHATWG и множество разношёрстных реализаций. В самом общем виде URL состоит из следующих частей: 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#) — это имя пользователя. Таково поведение встроенной библиотеки Pythonurllib. Разумеется, мы видим, как такая нестрогая реализация, в которой якобы реализуется стратегия достижения цели «малой кровью», может логически выйти на любой из трёх вариантов. Современные реализации 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, которые составлял кто-то другой. В особенности, люди, которым вы не доверяете — их ещё называют «пользователями».  Представьте, что строите систему на основе вебхуков. Ваши пользователи предоставляют вам URL, и всякий раз при определённом событии вы отправляете HTTP-запрос по данному URL. В подобной системе возникает риск, связанный с подделкой запросов на стороне сервера (SSRF), так как пользователь может заставить вас отправить запрос в случае, когда для вас это нежелательно. Например, у вас на порту 9000 может работать какой-нибудь критически важный сервис. Но, если пользователь установит URL-вебхук на http://localhost:9000/shutdown, то ваша система вебхуков отправит этот http-запрос критичному сервису, и сделает это изнутри сети! Чтобы предотвратить подобные случаи, можно написать, например, такой код: Как мы реализуем isLocalHost? Для начала давайте побеспокоимся только об IP-адресах. Можно вспомнить различные сложности, возникающие при предоставлении адресов IPv4 и IPv6. Поэтому не будем сравнивать их с конкретными строками, а лучше преобразуем адреса в десятичное представление и сравним десятичные значения (как рекомендовано в RFC). Таким образом, все 127.0.0.1, 127.0.1 и 127.1 будут отображаться на одно и то же значение: 2130706433. В таком случае код может принять вид Это уже выглядит довольно хорошо, за такой код можно и по плечу программиста похлопать. Но злоумышленник берёт и посылает нам URL: http://0:9000/shutdown. Как разобрано на приведённых выше слайдах, 0 в Linux отображается на localhost! Поскольку 0 не равно 1 или 2130706433, этот запрос проходит нашу валидацию. Мы последователи дополнительным советам, содержащимся в спецификации, и всё равно облажались.  Допустим, мы создаём сервис, который загружает в корзину S3 датасеты, собираемые ежедневно. Пользователь может просмотреть список файлов, содержащихся в корзине, но к самим файлам обратиться не может. Можно выбрать, какой файл тебя интересует, и отправить к нашему сервису URL того датасета, который нас интересует. Мы скачаем данные, проанализируем их и отправим резюме пользователю. Код для такой операции может выглядеть примерно так: В отличие от предыдущей ситуации, где у нас фактически был стоп-лист, здесь реализован список разрешённых адресов, что, как правило, лучше с точки зрения безопасности. Поскольку мы допускаем только такие URL, которые относятся к нашей корзине, мы можем быть в большей степени уверены, что не отправляем запрос на опасный хост. Правда, всё равно сохраняется проблема. Предположим, пользователь присылает нам такой URL: http://malicious-website.com#@ companyname.s3.amazonaws.com Для валидации 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, помогаюшей предотвращать подделку запросов на стороне сервера, даются рекомендации на такой случай, но даже в этом документе случай с вебхуками описан весьма туманно. Думаю, максимум, что можно сделать — изолировать сервис и лишить его привилегий при вызове вебхуков. Таким образом, если сервис всё-таки обманом заставят выполнять вебхуки, у него не будет доступа по сети к другим компонентам, а если и будет, то сервис не будет обладать нужными привилегиями, которые позволили бы ему повлиять на систему.