На прошлой неделе я внедрял в проект 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 нужен надежный способ забирать данные с серверов. Чтобы это была законно, а не по-воровски в обход протоколов и стандартов. Таким способом стал CORSCross-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-декоратор оборачивал ответ в заголовки только в положительном случае. Если не проходила валидация, приложение отсылало ответ еще до того, как доходила очередь декоратора. Браузер не мог внятно сказать, почему не прошел запрос. Пришлось поднять декоратор на самый верх стека.

Ссылки:

  • Using CORS

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

  • Enable CORS

    Сайт, целиком посвященный CORS: описание, статьи, конфиги, примеры кода.