the frontendian

CORS


~

Аббревиатура CORS (Cross-Origin Resource Sharing) вызывает страх у многих веб-разработчиков. Подобно рассказам о мифическом морском чудовище, у каждого разработчика есть история о том, как однажды CORS схватил один из его запросов и навечно утащил его на немыслимые глубины.

"No 'Access-Control-Allow-Origin' header is present on the requested resource. Origin 'https://example.com' is therefore not allowed access."

Независимо от того, собираетесь ли вы отправить JSON или пытаетесь настроить CDN, CORS может застать вас врасплох в самый неподходящий момент. Поэтому разработчики научились одолевать CORS, в то же время позволяя ему укреплять репутацию некой неприятности, которая каким-то образом поддерживает безопасность наших пользователей. Этот пост нацелен на развеивание мифов о CORS и демонстрацию его светлой стороны, а именно спецификации, которая разрабатывалась не с целью всюду мешать веб-разработчикам, а наоборот, освобождать нас от привязки к политике одного источника (same-origin policy). Мы рассмотрим каждый из заголовков, необходимых для правильного удовлетворения ограничений CORS, а также обсудим пару мест, где встреча с CORS может вас удивить.

Краткая история CORS

CORS, или идея, которая должна была стать CORS, родилась в эпоху Web 2.0, примерно в 2005 году. Одним из ключевых слов, которые нам принес Web 2.0, был AJAX, или «Asynchronous JavaScript and XML», и это слово привело к идеи о том, что вы можете использовать API XMLHttpRequest для асинхронного обновления веб-страницы без полной её перезагрузки.

Однако, когда XMLHttpRequest впервые появился на сцене, его возможности были ограничены: вы могли использовать его API только для связи со службами, которые находились в том же домене, что и запрашивающий сайт. Это означает, что если ваш сайт жил на https://iloveajax.com, и вы хотели сделать запрос на https://externalresource.com (или даже https://subdomain.iloverajax.com), браузер просто отказывался инициировать запрос. Это называется политикой одного источника (same-origin policy).

Когда волна AJAX захлестнула всех, стало ясно, что нужно что-то делать с XMLHttpRequest и same-origin ограничениями. Коммьюнити веб-разработчиков увидело, что использование AJAX с другими доменами может привести к появлению новых сервисов и способов использования Интернета, которые и появились (спойлер) подобно Firebase, Mixpanel, New Relic и т.д. Примерно в это же время (2005 г.) люди начали обходить систему, используя абстракцию под названием JSONP, которая, по сути, захватила тег <script> (использовав его довольно слабую политику безопасности ресурсов) для запроса данных от удаленных сервисов.

В 2005 году был опубликован первый черновик того, что в будущем станет спецификацией CORS. Тем не менее, основные аспекты спецификации, такие как механизм предварительной проверки (preflight) и использование заголовков HTTP вместо XML, появились только после 2007 года. А еще через семь лет после этого спецификация стала рекомендацией W3C. К тому времени браузеры уже начали внедрять наиболее стабильные ее части.

Написание спецификаций -- непростая задача, но совершенно справедливо спросить, почему это заняло десятилетие. Однако, если вы думаете, что процесс затянулся из-за высокой безопасности, которую обеспечивает CORS, это не так. Главной сложностью был тот факт, что большинство, если не все, веб-сервисы ожидали, что запросы, не связанные с GET, будут поступать из определенных доменов (обычно принадлежащих тем же людям, которым принадлежит данный сервис, учитывая, что политика одного источника по-прежнему является всеобщим правилом). Однако, в случае если CORS был бы реализован, а политика одного источника для XMLHttpRequests была бы смягчена, упомянутые службы теперь могли бы получать потоки запросов DELETE, PUT и т.д. из любого источника. Таким образом, было бы неразумно ожидать, что каждый публичный веб-сервис будет адаптироваться к CORS до появления рекомендаций от W3C.

В результате было принято решение отказаться от повсеместного использования CORS. Это означало, что браузеры будут продолжать соблюдать политику одного источника, если только конкретный набор запросов к веб-сервису не будет разрешен для других источников. Мы обсудим специфику этого механизма, названного предварительной проверкой (preflighting), совсем кратко. Создание такой опции в дизайне CORS означало, что веб-сервисам не нужно было бы обслуживать поток неожиданных запросов, и веб-разработчики могли бы начать создавать новые поколения услуг и инструментов. Вы можете совсем не интересоваться CORS, но есть одна вещь, за которую мы все должны быть немного благодарны, а именно за то, что CORS обеспечивает как обратную совместимость, так и открытие огромного круга новых функций для веб-разработчиков. Достойный подвиг! И чтобы лучше продемонстрировать его, давайте проследим, как CORS может повлиять на ваши веб-запросы и как вы можете избежать некоторых мелких ошибок.

Предварительная проверка

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

POST https://api.users.com/me HTTP/1.1
Host: example.com
Content-Type: application/json; charset=utf-8

{
  "name": "Demo User",
  "description": "I'm a demo user!"
}

Если вы инициировали этот запрос в браузере, который реализует CORS, вы увидите, что браузер сначала отправит следующее:

OPTIONS https://api.users.com/me HTTP/1.1
Host: example.com
Access-Control-Request-Headers: content-type
Access-Control-Request-Method: POST
Origin: https://example.com

Давайте остановимся в этом месте, чтобы разобраться в происходящем. Первый запрос OPTIONS называется предварительным запросом и является наглядным примером работы механизма CORS, как это было упомянуто выше. Кроме запросов, отправляемых элементом <form> которые называются "простыми запросами", спецификация CORS требует, чтобы браузеры проверяли серверы перед тем, как делать запрос на другой домен.

Как выглядит ответ на предварительный запрос? Если наша конечная точка не знакома с CORS, она может вернуть код состояния, например, 404 или 501, что приведет к тому, что браузер немедленно отменит запрос.

Если сервер поддерживает CORS, но не разрешает запросы из нашего домена, мы можем увидеть что-то вроде:

OPTIONS https://api.users.com/me HTTP/1.1
Status: 200
Access-Control-Allow-Origin: https://notyourdomain.com
Access-Control-Allow-Method: POST

Этот ответ сообщает браузеру, что запросы на данный адрес доступны только от домена https://notyourdomain.com, и что другие домены не могут взаимодействовать с ним. В этом случае браузер подчинится и отменит ваш основной запрос.

Если сервер поддерживает CORS и не ограничивает взаимодействие с адресом назначения, о которой идет речь, мы, скорее всего, увидим:

OPTIONS https://api.users.com HTTP/1.1
Status: 200
Access-Control-Allow-Origin: *
Access-Control-Allow-Method: POST

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

Простые запросы

Если бы меня спросили, что первое я бы хотел узнать о работе CORS, то я бы ответил "то, как происходит обработка простых запросов". Думайте о них, как о любых запросах, которые может инициировать элемент <form>. Почему это важно? До появления CORS, единственные запросы, которые могла отправлять веб-страница, инициировались из элементов <form>. Таким образом, поскольку такие запросы были допустимыми до CORS, спецификация не требует, чтобы браузер выполнял предварительный запрос для них.

Однако, говоря конкретно, простые запросы представляют собой комбинацию из простых методов и простых заголовков.

Простыми методами являются GET, HEAD и POST. Достаточно легко запомнить.

Простыми заголовками являются Accept, Accept-Language, Content-Language, или (это важно) Content-Type, если он имеет любое из трех значений: application/x-www-form-urlencoded, multipart/form-data, или text/plain.

Почему использование этих трех магических значений Content-Type делает заголовок простым? Ответ кроется в HTML элементе <form> и в трех типах кодировки (MIME типах), которые он позволяет отправить. Обратитесь к этой статье на MDN, чтобы узнать больше. Авторы CORS считали, что не нужно задерживать такие запросы, поскольку формы существуют уже несколько лет, и серверы, вероятно, знают, что такие запросы со стороны клиента возможны.

Чтобы разобраться с этим, рассмотрим пару простых запросов:

GET https://api.users.com/user/1 HTTP/1.1
POST https://api.users.com/user/1 HTTP/1.1
Content-Type: application/x-www-form-urlencoded

name=Demo%20User&description=I%27m%20a%20demo%20user%21

А также аналогичные запросы, но с минимальными изменениями, дабы привести к тому, чтобы их отправление сопровождалось предварительными запросами:

GET https://api.users.com/user/1 HTTP/1.1
X-Random-Header: 42
POST https://api.users.com/user/1 HTTP/1.1
Content-Type: application/json

{
  "name": "Demo User",
  "description": "I'm a demo user!"
}

В обоих случаях, хоть мы и используем простые методы, добавление заголовков, не попадающих под определение "простой заголовок", приводит к отправке предварительного запроса. А основной запрос будет отправлен только в случае, если ответ на предварительны запрос содержит в поле Access-Control-Allow-Headers эти, отличные от простых, заголовки. Например:

OPTIONS https://api.users.com HTTP/1.1
Status: 200
Access-Control-Allow-Headers: Content-Type
Access-Control-Allow-Origin: *
Access-Control-Allow-Method: POST

Надеюсь, этот раздел пролил свет на то, почему одни запросы проходят через CORS и остаются нетронутыми, а другие блокируются. Достаточно добавить один заголовок или использовать альтернативный метод, чтобы привести CORS в действие и заблокировать ваш запрос. В заключение, стоит отметить, что если запрос является простым, еще не значит, что он полностью избежит воздействия CORS. Иными словами, браузер может немедленно инициировать фактический запрос, не выполняя предварительную проверку. Если в ответе на простой запрос поле Access-Control-Allow-Origin не содержит домен, с которого этот запрос сделан, или поле Access-Control-Allow-Credentials выставлено, как false, хотя учетные данные уже были фактически использованы, ответ может быть заблокирован не будучи законченным. Результат ответа отбрасывается и становится недоступным для попыток обратится к нему со стороны JavaScript.

Области применения CORS

После нашего короткого исследования предварительных и простых запросов, будет полезно узнать, где еще можно встретить CORS, помимо XMLHttpRequest или fetch API. Существуют две дополнительные спецификации, которые обязывают использовать CORS:

Заключение

Я надеюсь, что этот пост дал вам более точное восприятие целей, стоящих за спецификацией CORS. Есть еще несколько тем, которые не были освещены, например, как кэшировать ответы на предварительные запросы с заголовком Access-Control-Max-Age. Между тем, я добавил короткий список ссылок, которые были мне полезны во время написания этой статьи. Если вы заметили ошибку, будь то фактическая или синтаксическая, дайте мне знать в комментариях!

Ссылки

MDN

W3C CORS Specification

W3C Fetch Specification - CORS Section

Wonderful Stack Overflow Thread

Особая благодарность

Перевод на русский Kirill Galushko.