[Перевод] HTTPWTF. Необычное в обычном протоколе

Прим. перев.: эту статью написал автор Open Source-утилиты HTTP Toolkit, предназначенной для исследования и модификации HTTP (S)-трафика для нужд отладки и тестирования. В материале собраны примечательные особенности стандарта HTTP, которые долгие годы живут вместе с нами, однако не каждый догадывается об их существовании.

Протокол HTTP имеет огромное значение для всей современной разработки — от фронтенда до бэкенда и мобильных систем. Но, как и у любого другого  широко распространенного зрелого стандарта, у него в шкафу запрятано несколько любопытных скелетов.

Часть из этих скелетов — малоизвестные, но вполне полезные функции, другие — странности, унаследованные от прошлых реализаций (на них ежедневно полагаются миллиарды соединений), а некоторые вообще не должны существовать. Что ж, давайте заглянем за занавеску…

No-cache на самом деле означает «кэшируй» 

Кэширование никогда не было легким занятием, но кэш-заголовки HTTP в этом смысле особенно преуспели. Худшие примеры —  no-cache и private. Как вы думаете, что делает приведенный ниже HTTP-заголовок ответа?

Cache-Control: private, no-cache

«Нигде не храни этот ответ», — так? Ха-ха-ха, а вот и нет!

На самом деле он означает: «Пожалуйста, храни этот ответ во всех кэшах браузера, но перепроверяй его перед использованием». То есть в реальности такой заголовок заставляет браузеры кэшировать ответы, поскольку он применяется даже к ответам, которые по умолчанию не кэшируются.

В частности, no-cache означает, что контент обязательно кэшируется, но всякий раз, когда браузер или CDN хотят его использовать, они должны отправить запрос с If-Match или If-Modified-Since, спросив сначала у сервера, актуален ли кэш. Между тем private означает, что контент можно кэшировать, но только в браузерах конечных пользователей, а не в CDN или на прокси-серверах.

Другими словами, у вас большие проблемы, если вы надеялись таким образом отключить кэширование, поскольку ответ содержит конфиденциальные или личные данные, которые нежелательно «светить» где-либо еще. В этом случае вам поможет no-store.

Если послать ответ с заголовком Cache-Control: no-store, его никто не будет кэшировать, и каждый раз он будет поступать прямо с сервера. Единственный нюанс связан с тем, что клиент уже может хранить ответ в кэше — в этом случае, он не будет удален. Чтобы удалить существующий кэш, добавьте к заголовку max-age=0.

Примечательно, что Twitter уже наступал на эти грабли. Они использовали Pragma: no-cache (устаревшую версию того же самого заголовка) вместо Cache-Control: no-store, в результате чего личные сообщения (DM) пользователей оставались в кэшах их браузеров. В случае личного компьютера это не проблема, но если к ПК имеют доступ несколько пользователей, или вы воспользовались публичной машиной, то ваши личные сообщения остались на жестком диске в незашифрованном и доступном для чтения виде. Упс.

HTTP Trailers

Вероятно, вы уже знаете об HTTP-заголовках (headers). HTTP-сообщение начинается с первой строки, которая содержит метод и URL (для запросов) или код состояния/сообщение (для ответов), затем идет ряд пар ключ/значение для метаданных, называемых заголовками (headers), а затем идет тело (body).

Но знаете ли вы, что trailer’ы позволяют добавлять метаданные после тела сообщения?

Они используются не так часто, но полностью стандартизированы и, теоретически, должны поддерживаться повсеместно (или хотя бы игнорироваться). Они могут пригодиться в случае, если метаданные сразу не доступны, но задерживать передачу тела сообщения нежелательно.

Они применяются в некоторых API-протоколах вроде gRPC и больше всего подходят для метаданных о самом ответе. Например, с помощью trailer’ов можно включать метаданные Server-Timing, чтобы дать клиенту метрики о производительности сервера во время запроса. В этом случае они будут добавляться после полной готовности ответа. Trailer’ы особенно полезны в случае затяжных ответов, например, чтобы включить метаданные о конечном статусе после продолжительного HTTP-потока.

Они редко используются, но все же приятно, что такой инструмент есть, и он работает. Правда, есть несколько требований:

  • Для trailer’ов в ответе сервера клиент должен объявить об их поддержке с помощью заголовка TE: trailers в первоначальном запросе.

  • Заголовки исходного запроса должны включать поля trailer’ов, которые будут использоваться впоследствии: Trailer: .

  • Некоторые заголовки нельзя использовать в trailer’ах, в том числе Content-Length, Cache-Control, Authorization, Host и другие стандартные заголовки, которые необходимы для парсинга, аутентификации или маршрутизации запросов.

Для отправки трейлеров в HTTP/1.1 также потребуется кодировка chunked. В свою очередь, HTTP/2 использует отдельные фреймы для тела и заголовков, так что в этом нет необходимости.

Полный ответ с trailer’ами по HTTP/1.1 может выглядеть следующим образом:

HTTP/1.1 200 OK
Transfer-Encoding: chunked
Trailer: My-Trailer-Field

[...chunked response body...]

My-Trailer-Field: some-extra-metadata

Коды HTTP 1XX

Знаете ли вы, что HTTP-запрос может получать несколько кодов состояния ответа? Сервер может отправлять неограниченное число кодов 1ХХ перед конечным статусом (200, 404 или любым другим). Они выполняют функцию промежуточных ответов и могут включать свои собственные независимые заголовки.

Семейство 1ХХ включает в себя следующие коды: 100, 101, 102, и 103. Они редко используются, но незаменимы в некоторых нишевых сценариях:

HTTP 100

HTTP 100 — это ответ сервера о том, что запрос на данный момент в порядке и клиент может продолжать.

В большинстве случаев он не имеет смысла. Ведь, начав отправлять запрос, вы скорее всего продолжите отправлять его в любом случае. Впрочем, всегда приятно иметь поддержку и одобрение со стороны сервера.

Он становится полезен в случае, если запрос включает заголовок Expect: 100-continue. Этот заголовок сообщает серверу, что клиент ожидает код 100 и что полное тело запроса не будет отправлено, пока этот код не получен.

Отправка Expect: 100-continue позволяет серверу решить, следует ли получать все тело сообщения (что может занять продолжительное время и «съесть» массу трафика). Если URL-адреса и заголовков достаточно для того, чтобы отправить ответ (например, отклонить загрузку файла), HTTP 100 — быстрый и эффективный способ сделать это. Если сервер действительно хочет получить полное тело, он отправляет промежуточный ответ 100, после чего клиент продолжает пересылку. После завершения процесса передачи запрос обрабатывается как обычный.

HTTP 101

HTTP 101 используется для переключения протоколов. Он означает: «Я послал тебе URL и заголовки, а теперь хочу сделать с этим соединением нечто совершенно другое». А именно — переключиться на совершенно другой протокол.

Этот код преимущественно используется для организации веб-сокета. Клиент посылает запрос, содержащий следующие два заголовка:

Connection: upgrade
Upgrade: websocket

Если сервер согласен, он посылает следующий ответ:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: upgrade

После этого обе стороны переходят с HTTP на обмен raw-данными веб-сокета по данному соединению.

Статус 101 также используется для перехода с HTTP/1.1 на HTTP/2 на том же соединении. Также его можно использовать для переключения HTTP-соединений на любые другие протоколы на основе TCP.

Следует отметить, что HTTP/2 не поддерживает данный статус: в нем иной механизм согласования протоколов и абсолютно другой подход к организации веб-сокетов (который практически нигде не поддерживается — в настоящее время веб-сокеты всегда базируются на HTTP/1.1).

HTTP 102

HTTP 102 сообщает клиенту, что сервер все еще обрабатывает запрос и ответ скоро будет готов. От ситуации с кодом 100 он отличается тем, что запрос в данном случае был получен полностью и сервер его обрабатывает, а клиент просто ждет.

Насколько я могу судить, эта разновидность сообщений встречается редко и преимущественно используется для того, чтобы клиент не разорвал соединение из-за превышения времени ожидания ответа (другими словами, чтобы показать, что сервер не «умер»). Он есть в исходных спецификациях HTTP, но был удален из многих ее последующих версий.

Впрочем, он все же нашел применение в реальном мире, так что при необходимости его можно использовать.

HTTP 103

В отличие от остальных кодов семейства, HTTP 103 — новый (и модный) статус, предназначенный для частичной замены push-функционала серверов в HTTP/2 (который в настоящее время удаляется из Chrome).

В рамках HTTP 103 сервер может отправить некоторые заголовки заранее — до того, как полностью обработает запрос и отправит его. В первую очередь он предназначен для доставки заголовков со ссылками, таких как Link: ; rel=preload; as=style, тем самым давая клиенту знать о дополнительном контенте (вроде таблиц стилей, JS-скриптов и изображений в запрашиваемых веб-страницах), который можно начать загружать одновременно с полным ответом.

Когда сервер получает запрос, обработка которого занимает некоторое время, он часто не может полностью отправить заголовки ответа до окончания его подготовки. HTTP 103 позволяет серверу незамедлительно передать клиенту список ресурсов для загрузки, не дожидаясь окончания обработки запроса.

Referer

HTTP-заголовок Referer сообщает серверу, с какой страницы был осуществлен переход или какой URL-адрес вызвал загрузку ресурса. Этот заголовок используется практически повсеместно, хотя у него и есть некоторые проблемы с конфиденциальностью.

Примечательная черта referer — его неправильное написание. Он появился на заре Интернета, и тогдашняя проверка орфографии Unix не смогла отличить referer от referrer (правильного написания). К моменту, когда на это обратили внимание, заголовок активно использовался в инфраструктуре и инструментах по всему миру, так что изменить его уже не представлялось возможным, и теперь нам приходится жить с заголовком, написанным с ошибкой.

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

Чтобы жизнь не казалась медом, новые заголовки конфиденциальности/безопасности, связанные с этим, такие как Referrer-Policy, используют правильное написание.

«Случайный» UUID веб-сокетов

XKCD в тему. Комментарий в коде гласит: «Получено подбрасыванием кости. Это гарантированно случайное значение»XKCD в тему. Комментарий в коде гласит: «Получено подбрасыванием кости. Это гарантированно случайное значение»

Ранее рассказывалось о том, как запросы HTTP 101 используются для организации веб-сокетов. Полный такой запрос может выглядеть так:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==
Sec-WebSocket-Protocol: chat, superchat
Sec-WebSocket-Version: 13
Origin: http://example.com

…, а ответ, запускающий соединение по веб-сокету, — следующим образом:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: upgrade
Sec-WebSocket-Accept: HSmrc0sMlYUkAGmm5OPpG2HaGWk=
Sec-WebSocket-Protocol: chat

Особый интерес здесь вызывает ключ Sec-WebSocket-Accept. Он предотвращает случайное использование кэширующими прокси websocket-ответов, которые те не понимают, требуя, чтобы ответ включал заголовок, соответствующий заголовку клиента. А именно:

  • Сервер получает от клиента ключ веб-сокета, закодированный в base64;

  • Сервер добавляет к нему UUID 258EAFA5-E914–47DA-95CA-C5AB0DC85B11;

  • Сервер хэширует полученную строку, кодирует хэш в base64 и отправляет его обратно.

Это очень странно. Один неизменный случайный UUID, который используется в организации каждого веб-сокета? Добавление строк к строкам, закодированным в base64, без их предварительного декодирования, с последующим кодированием результата в base64?

Смысл в том, чтобы сделать процесс достаточно уникальным и неслучайным, гарантируя, что обе стороны осознанно и намеренно запускают соединение по веб-сокету. Это предотвращает случайное использование кэшированных данных сервером или прокси, и гарантирует, что клиент не был вовлечен в организацию websocket-соединения обманным путем.

Подобный подход отлично работает. Он широко применяется, а его реализация проста и доступна (что отлично), но все же странно, что каждое websocket-соединение в мире основано на одном «магическом» UUID.

Веб-сокеты и CORS

Коль скоро речь зашла о веб-сокетах: знали ли вы, что они игнорируют все политики CORS и single-origin, которые обычно применяются к HTTP-запросам?

CORS гарантирует, что JavaScript на a.com не может считывать данные с b.com, если только последний явно не разрешает это в своих заголовках ответа.

Это важно по многим причинам. Например, актуально для серверов в локальной сети (публичная веб-страница не должна иметь доступа к маршрутизатору) и состояния браузера (запросы от одного домена не должны иметь доступа к файлам cookies от другого).

К сожалению, веб-сокеты полностью игнорируют CORS, вместо этого предполагая, что все websocket-серверы достаточно современны и продвинуты, чтобы самостоятельно проверять заголовок Origin. Но серверы, как правило, этого не умеют, а многие разработчики понятия об этом не имели, пока я им не рассказал.

Это открывает целый новых мир любопытных уязвимостей, обобщенных в этой прекрасной статье.

Короче говоря, при использовании WebSocket API, проверяйте заголовок Origin и/или используйте токены CSRF, прежде чем доверять входящим соединениям.

Заголовки X-*

Давным-давно (в 1982-м) в RFC было заявлено, что использование префикса X- для заголовков сообщений — отличный способ отличить кастомные расширения от стандартизированных имен.

Тогда это было актуально для метаданных электронной почты, но позже стало применяться и в HTTP-заголовках.

Паттерн распространен до сих пор — часто его можно встретить в HTTP-запросах:

  • X-Shenanigans: none — встречается в каждом ответе от API Twilio. Понятия не имею, почему, но приятно знать, что на этот раз никаких махинаций точно не будет.

  • X-Clacks-Overhead: GNU Terry Pratchett — дань уважения Терри Пратчетту; название заимствовано из серии книг писателя «Плоский мир».

  • X-Requested-With: XMLHttpRequest — добавляется различными JS-фреймворками, включая jQuery, чтобы четко отличать AJAX-запросы от запросов ресурсов (они не могут включать кастомные заголовки вроде этого).

  • X-Recruiting: <сообщение-приманка для потенциального сотрудника> — многие компании используют подобные заголовки в попытке привлечь специалистов, которые настолько увлечены процессом, что читают заголовки HTTP.

  • X-Powered-By: <фреймворк> — рекламирует фреймворк, используемый сервером (или соответствующую технологию). Как правило, это плохая затея.

  • X-Http-Method-Override — указывает метод, который по какой-либо причине не может использоваться в качестве метода для запроса (обычно это связано с ограничениями клиента/сети). Плохая идея в наши дни, однако она до сих пор популярна и многие фреймворки ее поддерживают.

  • X-Forwarded-For:  — используется многими прокси-серверами и балансировщиками нагрузки для включения исходного IP-адреса запроса в upstream-запросы.

Каждый из этих заголовков по-своему странен и прекрасен, но сам подход нельзя назвать хорошим, поэтому в новых RFC (2011) его применение формально не рекомендуется.

Проблема в том, что многие нестандартные заголовки со временем становятся стандартными. Когда это происходит, приходится либо менять название (убирая префикс X-), «ломая» все существующие реализации, либо включать название с префиксом X- в стандарт (полностью нивелируя изначальный смысл префикса и навсегда фиксируя в названии лишний шум).

Это крайне неприятно, и некоторые формальные стандарты уже пострадали от этого:

  • Почти все веб-формы в Интернете пересылают данные, используя излишне мудреный и пространный заголовок Content-Type: application/x-www-form-url-encoded.

  • В RFC к HTTP от 1997 года в разделе, где определяются правила парсинга для content-encoding, предписывается, что все реализации считали x-gzip и x-compress эквивалентами gzip и compress соответственно.

  • Стандартным заголовком для настройки фреймов на веб-странице теперь навсегда останется X-Frame-Options вместо Frame-Options.

  • Также у нас теперь есть X-Content-Type-Options, X-DNS-Prefetch-Control, X-XSS-Protection и различные заголовки X-Forwarded-* от CDN/прокси. Все они широко используются и уже формально или фактически стали стандартными заголовками для повсеместного применения.

Если необходимо использовать кастомный заголовок, достаточно выбрать имя, не занятое каким-либо стандартом. Чтобы избежать любых возможных коллизий, рассмотрите возможность сделать его уникальным, включив в некое пространство имен (namespacing).

Стандартизация — непростое занятие, и HTTP полон нелепых сюрпризов и странных деталей (стоит только пристально на него посмотреть). Жду ваших мыслей/замечаний в Twitter.

P.S. от переводчика

© Habrahabr.ru