Руководство по кросс-доменным запросам (CORS)
На прошлой неделе я внедрял в проект CORS-запросы – современный способ кросс-доменного Аякса. По следам прочитанной документации и набитых шишек подготовил небольшой мануал. Это вольный пересказ англоязычных статей, вопросов со Стека и скромный личный опыт.
Но сначала короткая предыстория. Я неравнодушен к собеседованиям, как вы, наверное, знаете. Чаще я собеседовал бекенд, т.к. к фронтенду отношусь прохладно. Но тема фронтенда обладает огромным потенциалом для оценки уровня кандидата. Я считаю, что разработчик должен в деталях понимать протокол HTTP и тонкости работы браузера.
Один из вопросов на тему фронтенда звучит банально: как на клиенте получить данные с другого домена?
Некоторые кандидаты отвечают, что проблемы нет, достаточно выполнить Аякс-запрос. И с большим удивлением узнают, что, оказывается, нельзя: сработают какие-то там политики безопасности.
Один собеседуемый заявил, что в Фаерфоксе это работает, достаточно зайти на страницу для разработчиков и что-то там переключить. Я не против такого ответа. Следующий вопрос, как вы заставите всех пользователей установить именно Фаерфокс и поменять системный флаг?
С понятием JSONP вообще беда – никто не может объяснить, как это устроено. Разработчики думают, что это обычный Аякс, только с каким-то P на конце, то ли баг, то ли фича. А это вообще ни разу не Аякс.
Аббревиатура CORS появилась недавно, и спрашивать о ней нет смысла. Этот пробел восполняет данный мануал.
Разберем вопросы из предыдущих абзацев. Действительно, слать Аякс-запросы к серверам с другим доменом запрещено на уровне браузера. Однако, в интернете полно сайтов, где значимая часть контента подгружается со сторонних серверов. Например, этот блог работает на статичном генераторе Jekyll, в котором нет комментариев. Делиться мнениями помогает сервис Discuss: лента комментариев встраивается Джаваскриптом. Получая и отправляя комментарии, вы взаимодействуете с серверами Discuss, а мой блог вообще ни при чем. Значит, слать запросы Аяксом все же можно?
Нет, здесь работает JSONP. Аббревиатура значит JSON with Padding (с
подкладкой). Идея основана на лазейке в стандартах: загружать скрипты с других
доменов не запрещено! Скажем, если в файле example.js
на чужом сервере
написано что-то вроде:
alert("hello!");
, то достаточно подгрузить его тегом <script>
на страницу:
<script src="http://example.com/static/example.js"></script>
и браузер выполнит все, что внутри. В данном случае покажет окошко.
Предположим теперь, что сервер отдает не статичный файл, а генерит его динамически в зависимости от параметров. Например, мы добавляем на страницу скрипт с адресом:
<script
src="http://example.com/api.js?method=get_user&user_id=42&callback=processUser"
></script>
В переменой method
указываем, какое действие требуем от сервера. В данном
случае, получить пользователя по идентификатору. Айдишку передаем следующим
параметром user_id
. Пусть такой пользователь найден на сервере, теперь его
нужно отдать клиенту. Если просто выплюнуть объект:
{name: "Ivan", age: 30}
, то на клиенте ничего не произойдет: объект просто считается в память. Именно
поэтому передают третий параметр callback
– имя функции, объявленной на
клиенте, в которую нужно завернуть пользователя. С этим параметром ответ станет
другим:
processUser({
name: "Ivan",
age: 30
});
На клиенте отработает код, зашитый в функцию processUser
: вывести данные в
консоль, отрисовать виджет и т.д. Вот как работает JSONP.
Теперь, имея полную картину, нельзя не признать, что технология очень крива. Прежде всего, это лазейка, костыль. Разработчики стандартов просто не были настолько хитры, чтобы предугадать динамическое взаимодействие на уровне скриптов.
Далее, подгрузка скриптов ни разу не безопасней, чем Аякс. Целое семейство вирусов занимается тем, что добавляет на страницу браузера скрипты для отрисовки баннеров порно и казино. Когда вы подключаетесь к интернету через мобильных операторов, обсосы вставляют в HTML-трафик скрипты для отрисовки виджетов (если соединение не HTTPS).
JSONP работает только методом GET, что сводит на нет возможности REST-интерфейса. Для REST-сервисов приходится писать прокладки-прокси, т.е. множить костыли.
Добавив скрипт на страницу, в дальнейшем вы не можете отследить его судьбу. Если у Аякс-запроса есть специальные коллбеки для основных событий (начало, удачное завершение, таймаут, неудачное завершение), то у скрипта ничего такого нет. Загрузился ли он? Ответил ли сервер? Была ли ошибка? Никто не знает.
Ясно, что в 2016 году приложениям на js нужен надежный способ забирать данные с
серверов. Чтобы это была законно, а не по-воровски в обход протоколов и
стандартов. Таким способом стал CORS
– Cross-Origin Resource Sharing
,
кросс-доменные запросы.
Идея проста – пусть клиент шлет Аякс-запрос к чужому серверу. Браузер добавит в запрос особые заголовки с информацией о том, что запрос с другого домена. На их основании сервер решит, как обрабатывать такой запрос, и добавит особые заголовки в ответ. Удобно, правда?
Техническая реализация несколько сложнее. Стандарт CORS различает “простые” и “сложные” запросы. Простым считается запрос методами:
- HEAD
- GET
- POST
и заголовками:
- Accept
- Accept-Language
- Content-Language
- Last-Event-ID
- Content-Type, но только со значениями:
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
Если ваш запрос удовлетворяет этим критериям, можно слать Аякс к другому домену
из любого современного браузера. При этом браузер добавит заголовок Origin
с
адресом страницы, откуда инициирован запрос. Подделать заголовок скриптом не
удастся.
Сервер, получив на обработку подобный запрос, должен прочесть Origin и решить,
как его обрабатывать. Заголовок ответа Access-Control-Allow-Origin
регулирует,
с какого домена разрешено запрашивать данные. Это может быть как веб-адрес, так
и знак астерикса (звездочки), если разрешено всем. Несколько разных адресов
через запятую, к сожалению, не поддерживаются.
Пример CORS-запроса:
POST /foo/bar HTTP/1.1
Origin: http://foreign.com
Host: test.com
и ответа с разрешением на получение данных:
200 OK HTTP/1.1
Access-Control-Allow-Origin: http://foreign.com
Content-Type: text/html; charset=utf-8
<h1>Welldone</h1>
Обратите внимание на такую вещь: мы намерены использовать CORS, чтобы дергать
чужие API. С вероятностью почти 100% они работают по протоколу JSON
, то есть
принимают и отдают заголовок Content-Type: application/json
. Вроде бы мелочь,
но такой запрос автоматом перестает быть простым и переходит в разряд “сложных”,
где схема взаимодействия иная.
Сложные запросы проходят в два этапа. Сначала браузер делает запрос по тому
же урлу, но методом OPTIONS
. Сервер должен ответить: какими другими методами и
дополнительными заголовками (помимо стандартных) можно обращаться к этому
урлу. И только получив разрешение, браузер сделает запрос на основной урл.
При этом браузер не дурак и все запомнит: если разрешили только методы GET и
POST, то PUT и DELETE не сработают. Аналогично с заголовками: если помимо
стандартных разрешено использовать только Authorization
, то нужно передать его
и ничего другого.
Пример сложного запроса:
OPTIONS /cors HTTP/1.1
Origin: http://api.bob.com
Access-Control-Request-Method: PUT
Access-Control-Request-Headers: X-Custom-Header
Host: api.alice.com
Accept-Language: en-US
Connection: keep-alive
User-Agent: Mozilla/5.0...
Клиент хотел отправить Аяксом запрос методом PUT
на урл
http://api.alice.com/cors
с сайта http://api.bob.com
. Поскольку это сложный
запрос, браузер запросил разрешение: типа, хочу сделать PUT на этот урл с особым
заголовком X-Custom-Header
. Сервер ему на это:
200 OK HTTP/1.1
Access-Control-Allow-Origin: http://api.bob.com
Access-Control-Allow-Methods: GET, POST, PUT
Access-Control-Allow-Headers: X-Custom-Header
Content-Type: text/html; charset=utf-8
Или иными слвами: разрешено ходить методами GET, POST, PUT и с заголовком X-Custom-Header. Это подходит под критерии первоначального запроса. Браузер делает второй запрос куда мы намеревались вначале.
Первая стадия, когда делается запрос OPTION, официально называется preflight
request
. Надо сказать, такое взаимодействие весьма прозрачно отражается в
браузере. Например, в консоли разработчика в Хроме видны оба запроса со всеми
заголовками.
Вот такие строгости. В нашем проекте API на стороне сервера требует заголовки Version (версия операции), Authorization (авторизация по токену) и Content-Type (JSON), поэтому в ответе указываем
Access-Control-Allow-Headers: Version, Authorization, Content-Type
, иначе запрос не пройдет.
Теперь все это можно протестировать. Запускаем локальный сервер, открываем консоль Хрома и пишем:
var xhr = new XMLHttpRequest();
xhr.open("GET", "http://127.0.0.1:5000/api/users", true);
xhr.setRequestHeader("authorization", "Token xxxxxx");
xhr.setRequestHeader("Version", "1");
xhr.send();
xhr.responseText
>> "{"users":[{name "Ivan"...
Забавно, что заголовок Origin
в этом случае будет равен https://google.com
,
потому что Хром считает пустой страницей главную Гугла.
Видно теперь, что даже сложная версия стандарта весьма понятна, если разобраться. На мой взгляд, она все же избыточна: может быть либо один, либо два запроса. Я бы постарался привести к общему знаменателю. Как этот ад поддерживают разработчики браузеров, не понимаю.
Несмотря на кажущуюся простоту, реализовать поддержку CORS на сервере требует времени. Первоначально я хотел использовать чужую библиотеку, но после 10 минут чтения исходного кода понял, что автор НЕПРАВИЛЬНО понял спецификацию и реализовал ее с ошибками. Лишний раз убедился, авторы сторонних библиотек – не боги, а такие же смертные. Они могут тупить, ошибаться. Лучше потратить день на чтение спеки и сделать все правильно, чем доверять первому встречному решению.
Расскажу теперь о тонкостях, с которыми столкнулся при внедрении CORS в
проекте. Прежде всего, чтобы сократить число preflight-запросов, стоит
либо кешировать эндпоинт OPTIONS
заголовками:
Cache-Control: no-cache, must-revalidate
, либо вообще объявить его на уровне Nginx:
location / {
if ($request_method = 'OPTIONS') {
add_header 'Access-Control-Allow-Origin' '*';
add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS';
add_header 'Access-Control-Allow-Headers' 'Version, Authorization, Content-Type';
}
Еще одна тонкость: даже если возвращаете ответ с не-положительным статусом,
например, не прошла валидация или нет прав, заголовок
Access-Control-Allow-Origin
обязан присутствовать. Если заголовка нет, браузер
решит, что CORS-запрос запрещен и не прочитает ответ.
В нашем случае это вылилось в следующее: CORS-декоратор оборачивал ответ в заголовки только в положительном случае. Если не проходила валидация, приложение отсылало ответ еще до того, как доходила очередь декоратора. Браузер не мог внятно сказать, почему не прошел запрос. Пришлось поднять декоратор на самый верх стека.
С CORS разобрались, теперь не будет лишним почитать другое руководство про часовые пояса
Ссылки:
-
Отличный туториал на английском языке, откуда я подчерпнул большую часть материала.
-
Сайт, целиком посвященный CORS: описание, статьи, конфиги, примеры кода.
Нашли ошибку? Выделите мышкой и нажмите Ctrl/⌘+Enter
wdtfoxs, 21st Jul 2017, link
Хорошая статья, чувак
Aleksandr Verber, 15th Aug 2017, link
Очень полезная статья для ознакомления с технологией
алик, 5th Sep 2017, link , parent
Че хорошего?
John Kent, 16th Nov 2017, link
Хорошая статья
Эдуард Гринченко, 7th Dec 2017, link
Хорошая статья, спасибо!
alxcube, 9th Dec 2017, link
Спасибо, полезно.
Однако, JSONP -- это тоже аякс, по определению. Просто транспорт -- скрипт, а не xhr
hyposlasher, 10th Dec 2017, link
Спасибо за статью!
Kogot Tigra, 11th Dec 2017, link
Можно на сервере фронта проксироваться на сторонний домен апихи.
Константин Мельников, 1st Jan 2018, link
> Если у Аякс-запроса есть специальные коллбеки для основных событий (начало, удачное завершение, таймаут, неудачное завершение), то у скрипта ничего такого нет. Загрузился ли он? Ответил ли сервер? Была ли ошибка? Никто не знает.
Есть же: onload, onerror и onreadystatechange.
Alexander R., 14th Oct 2018, link
Спасибо за такую отличную статью!
vito novo, 8th Apr 2019, link
Сам себе противоречишь:
"Некоторые кандидаты отвечают, что проблемы нет, достаточно выполнить Аякс-запрос. И с большим удивлением узнают, что, оказывается, нельзя: сработают какие-то там политики безопасности."
"Идея проста – пусть клиент шлет Аякс-запрос к чужому серверу. Браузер добавит в запрос особые заголовки с информацией о том, что запрос с другого домена. На их основании сервер решит, как обрабатывать такой запрос, и добавит особые заголовки в ответ. Удобно, правда?"
Сам же сказал что можно))
Любит он собеседовать.
Скажи просто: раньше было нельзя, сейчас - можно, но с ограничениями.
Артем, 15th May 2020, link
Ключевой вопрос: как CORS связан с фронтом если настраивать его надо на сервере? По такой логике фротендер должен все что с браузерам связано знать, включая движок на котором он написан)
Петр, 6th May 2021, link
Нихрена не понятно из сатьи, кроме общих слов. Типо корс, он короче блочит надо правильно его обрабатывать.
Комон. Покажи на работающем примере. типо на голой убунте накати nginx и там продемонстрируй. Ну это я к тому уж если ты хотел реально помочь народу и показать как работает. А так на словах текста накопировать можно с разных источников и вот молодец какой.
Василий Топоров, 24th Apr 2024, link
Что-то мне сомнительно, что заголовок Cache-Control: no-cache, must-revalidate закеширует OPTIONS-запрос. Судя по https://developer.mozilla.org/en-US/docs/Web/HTTP/Caching#provide_up-to-date_content_every_time и https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control браузер всё равно будет посылать запрос, чтобы удостовериться, что ресурс не изменился. Это в случае OPTIONS-запроса не имеет особого смысла, так как тела там всё равно нет.
Закеширует скорее вот так: Access-Control-Max-Age: 86400 Cache-Control: max-age=86400 public