javascript

SOP & CORS

  • четверг, 13 августа 2026 г. в 00:00:22
https://habr.com/ru/articles/1069658/

Ситуация, в которой видно суть

Держите открытыми две вкладки. В одной — интернет-банк, куда вы залогинены и где на экране висит ваш баланс. В другой — какой-то сайт, на который вы забрели по ссылке из выдачи, ничего особенного. Теперь вопрос, который на первый взгляд кажется надуманным, а на деле упирается в фундамент всей веб-безопасности: что мешает скрипту со второго сайта взять и прочитать ваш баланс из первого?

Разберёмся, что технически ничего не мешает. Обе вкладки живут в одном браузере, браузер имеет доступ к обеим. Скрипт со случайного сайта вполне мог бы сформировать запрос к домену банка. Мало того, браузер по умолчанию приложил бы к этому запросу ваши куки — те самые, по которым банк отличает вас от постороннего, — то есть запрос ушёл бы как будто от вас, залогиненного. Ответ с балансом вернулся бы. Оставалось бы прочитать его и отправить куда угодно. Ни одного технически сложного шага в этой цепочке нет.

И тем не менее этого не происходит никогда. Не потому что банк как-то особо защищён, а потому что в каждый браузер вшито правило, которое работает всегда и по умолчанию, даже когда о нём никто не вспоминает. Правило это называется Same-Origin Policy, и именно оно не даёт одной вкладке читать содержимое другой. С него всё и начинается, а CORS, о который потом обязательно спотыкается каждый, кто впервые тянет на свой сайт данные с чужого API, — это уже надстройка над ним. Но чтобы понять CORS, надо сперва как следует понять само правило, потому что почти вся путаница вокруг CORS растёт из того, что Same-Origin Policy поняли неточно.

Что браузер считает «одним сайтом»

Прежде чем формулировать правило, надо договориться о том, вокруг чего оно вертится: что для браузера «то же самое место», а что «чужое». Человеческая интуиция здесь врёт, поэтому есть строгое понятие — origin.

Origin складывается ровно из трёх вещей: протокол, домен и порт. Возьмём https://shop.ru. Протокол тут https, домен — shop.ru, порт — стандартный для https, то есть 443, он просто не пишется, но подразумевается. Вот эта тройка целиком и есть origin. Два адреса считаются одним origin тогда и только тогда, когда у них совпадают все три составляющие. Стоит разойтись хотя бы одной — и с точки зрения браузера это уже два разных origin, чужих друг другу, между которыми правило действует в полную силу.

На примерах это видно резче. Адреса https://shop.ru/catalog и https://shop.ru/cart — один и тот же origin: путь после домена браузер в расчёт не берёт вообще, /catalog там или /cart — неважно, протокол-домен-порт совпали, значит origin один. А вот http://shop.ru и https://shop.ru — уже разные origin, потому что протокол отличается, http против https, и этого достаточно. Самый неинтуитивный случай — https://shop.ru и https://api.shop.ru: для человека это очевидно «один сайт», просто у API свой поддомен, но для браузера api.shop.ru — это другой домен, а значит и другой origin, и правило между основным доменом и его собственным API работает так же строго, как между двумя совершенно посторонними сайтами. Именно этот пункт потом становится причиной половины CORS-мучений: люди не ожидают, что их фронтенд и их же API — это, с точки зрения браузера, чужие друг другу origin.

Дальше всё крутится вокруг одного вопроса: перед нами один origin или разные. Само правило звучит коротко: код, исполняющийся в одном origin, не может прочитать данные из другого origin. Вернёмся к банку, чтобы приложить формулировку к жизни. Банк живёт на https://bank.ru, посторонний сайт — на https://randomsite.ru, это очевидно разные origin. Поэтому скрипт с randomsite.ru, даже если он отправит запрос к bank.ru и запрос дойдёт, ответ прочитать не сможет — браузер ему этого просто не даст. Вот и вся защита, ради которой Same-Origin Policy существует. Без неё веб в его нынешнем виде был бы невозможен: каждая открытая вкладка потрошила бы каждую.

Но именно здесь прячется тонкость, из которой потом растёт вся неразбериха. В формулировке ключевое слово — «прочитать». Правило запрещает читать чужие данные. Оно не запрещает чужое загружать. И это, вопреки первому впечатлению, две совершенно разные вещи.

Загружать чужое можно, читать — нельзя, и это не одно и то же

Посмотрите на любую страницу в сети. Почти наверняка она подтягивает кучу всего с других origin: картинки с отдельного домена под статику, шрифты откуда-нибудь от гугла, скрипт аналитики, вставленный ролик с ютуба. Всё это чужие origin, и всё это спокойно грузится и работает. Никакая Same-Origin Policy этому не препятствует. Как так, если правило вроде бы про то, что чужое трогать нельзя?

А потому что оно не про «трогать», оно именно про «читать содержимое как данные». Показать чужую картинку пользователю — сколько угодно. Дать вашему коду залезть внутрь этой картинки и вытащить из неё пиксели — уже нет. Вот эту границу — между «показать» и «прочитать своим кодом» — надо прочувствовать один раз, и дальше вся тема раскладывается по полочкам сама, потому что держится она целиком на этом различии.

Три примера показывают, где именно проходит черта. Возьмём картинку. Вы ставите на страницу тег <img>, ведущий на чужой домен, — изображение отрисовывается, всё нормально. Но стоит вам попытаться перерисовать эту картинку в элемент <canvas> и оттуда программно считать пиксели — допустим, чтобы разобрать её по цветам, — браузер операцию заблокирует. Показать разрешено, прочитать пиксели как данные — запрещено. Одна и та же картинка, но два принципиально разных действия.

Со скриптом с чужого домена интереснее, потому что тут кажется, будто правило прямо нарушается. Вы подключаете, скажем, jQuery тегом <script> с чужого CDN, библиотека грузится и работает — то есть чужой код исполняется у вас на странице. Разве это не чтение чужого? Нет, и причина принципиальная: подключённый скрипт исполняется не как «чужой файл, который вы прочитали», а как ваш собственный код, внутри вашего origin. Он не остаётся посторонним — он становится частью вашей страницы и получает полный доступ к ней. Вы не вычитали чужие данные, вы пустили чужой код к себе домой и выдали ему свои права. Разница тонкая, но именно она объясняет, почему это не нарушение.

У этого поворота есть обратная и довольно неприятная сторона, про которую стоит знать. Раз подключённый чужой скрипт получает полные права на вашей странице, то в день, когда этот CDN взломают и подменят файл, вредоносный код исполнится уже у вас, с тем же полным доступом ко всему — включая данные ваших пользователей. Поэтому взрослые проекты либо держат такие библиотеки у себя, либо вешают на тег атрибут integrity с хешем файла: браузер сверит хеш и откажется подключать скрипт, если содержимое не совпало. Но это отдельный разговор, здесь важно другое — что чужой скрипт по правилам SOP становится своим, и именно поэтому его исполнение правило не нарушает.

Третий пример — iframe. Вы встраиваете чужую страницу целиком тегом <iframe>, она отображается внутри вашей. А вот дотянуться из своего кода до её содержимого — прочитать, что там внутри, вытащить текст или то, что пользователь набрал в её поля, — вы не сможете, браузер не даст. Показать чужую страницу у себя — да, прочитать её изнутри — нет. Ровно на этом держится возможность безопасно встроить, например, чужую форму оплаты: пользователь видит её и вводит туда карту, а сайт, который эту форму встроил, к введённым данным доступа не имеет и получить его не может.

Во всех трёх историях одно и то же по сути: чужое загружено, показано, работает — но ваш код не может прочитать его содержимое как данные. Это и есть Same-Origin Policy в действии, не в теории. И до кучи, чтобы закрыть периметр: поставить куку на чужой origin вы тоже не можете. Ваш сайт не пропишет куку для bank.ru — будь иначе, вся защита сессий не стоила бы ничего. Куки ставятся только на свой origin.

Момент, когда чужое нужно именно прочитать

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

Простейший случай — вам надо вывести на сайте погоду. Сами вы её не считаете, вы берёте её у стороннего погодного сервиса, у которого есть API: шлёшь запрос — получаешь в ответ JSON с температурой. И этот JSON вам нужно именно прочитать своим кодом и вставить число в вёрстку. Не чужую картинку показать, а получить данные и повертеть их. И вот вы пишете из браузера обычный запрос к этому сервису — а в консоли получаете ошибку, в которой фигурирует CORS, и данные вам недоступны. Причём запрос ушёл, сервис ответил, ответ физически вернулся в браузер — но браузер отказывается отдать его вашему коду.

Почему — уже понятно из всего предыдущего. Это ровно тот сценарий, который Same-Origin Policy по умолчанию запрещает: чтение данных с чужого origin. Браузер вас не пускает, потому что так настроено по умолчанию для всех. И чтобы он вас всё-таки пустил, нужен механизм, который легально ослабит правило именно для этого случая. Этот механизм и есть CORS.

CORS: кто на самом деле выдаёт разрешение

CORS расшифровывается как Cross-Origin Resource Sharing — обмен ресурсами между origin. Это способ легально ослабить Same-Origin Policy и всё-таки прочитать чужое, но с одним жёстким условием: только если чужая сторона на это согласна.

И вот здесь самый недопонимаемый момент во всей теме, который стоит проговорить медленно, потому что именно на нём ломается интуиция у большинства. Разрешение прочитать ответ выдаёт не ваш сайт, не ваш код и не браузер по собственной инициативе. Его выдаёт тот сервер, к которому вы обращаетесь. Тот, чьи данные вы хотите прочитать, сам и решает, можно ли вам их читать.

На примере с погодой это выглядит так. Ваш код с https://mysite.ru шлёт запрос к погодному сервису на https://weather.com. Запрос доходит до сервера weather.com. И если сервер согласен, чтобы его ответ читали сторонние сайты, он добавляет в этот ответ специальный заголовок:

Access-Control-Allow-Origin: https://mysite.ru

Этим заголовком сервер по сути говорит: я разрешаю, чтобы именно mysite.ru прочитал мой ответ. Браузер видит заголовок, понимает, что разрешение выдано, — и отдаёт ответ вашему коду. Данные у вас. Если же такого заголовка в ответе нет, браузер ответ блокирует — и, что важно, не потому что запрос провалился. Запрос как раз удался, сервер его принял и ответил. Браузер блокирует чтение, потому что никто не разрешил ему показать этот ответ вашему коду. Нет заголовка — нет чтения, ответ уходит в никуда, а вашему коду достаётся ошибка.

Вместо конкретного адреса сервер может поставить звёздочку:

Access-Control-Allow-Origin: *

Это означает «мой ответ может читать кто угодно, с любого origin». Так поступают с публичными данными, которые и без того открыты всем, — курсы валют, та же погода, справочники. Смысла закрывать их нет, поэтому звёздочка тут уместна.

Почему CORS ничего не защищает, хотя все думают наоборот

Из всего разобранного вытекает вывод, который переворачивает интуицию новичка с ног на голову, и не понять его — значит потом построить дырявую систему в полной уверенности, что она надёжна. Вывод такой: CORS не защищает ваш сервер. Он вообще не про защиту сервера. Если уж на то пошло, это ослабление защиты — причём защиты браузерной, а не серверной.

Разберём по шагам, откуда это следует. Человек, впервые настроивший CORS, рассуждает примерно так: «я разрешил доступ вот этим origin, а остальным не разрешил — значит, я закрыл своё API от посторонних». Это ошибка, и вот в чём она. CORS действует исключительно внутри браузера. Всё, что он делает, — сообщает браузеру, показывать ли ответ чужому фронтенду. Но отправить запрос к вашему серверу можно и мимо браузера: из curl в терминале, из Postman, со своего собственного сервера, из любого скрипта. Там нет браузера, а значит, ни Same-Origin Policy, ни CORS в этой ситуации вообще не участвуют. Запрос спокойно уходит, ответ спокойно возвращается, и никакие CORS-заголовки этому не мешают, потому что их некому применять.

Отсюда прямое следствие: если ваше API отдаёт что-то, что не должно попадать посторонним, CORS не защитит это ни на секунду. От посторонних защищает аутентификация и проверка прав на стороне сервера — то есть проверка того, кто именно к вам пришёл и положено ли ему то, что он просит. Это живёт на бэкенде и к CORS не имеет никакого отношения. CORS отвечает на совсем другой вопрос — покажет ли браузер ваш ответ чужому сайту, — и только на него. Спутать эти две вещи легко, а цена ошибки высокая, поэтому стоит держать в голове чётко: CORS про браузер и про чтение ответа чужим фронтендом, аутентификация про сервер и про доступ к данным вообще. Разные слои, разные задачи.

Кто в этой схеме что видит

Чтобы всё окончательно улеглось, полезно разложить роли: когда ваш сайт стучится к чужому, кто из участников что видит. Сервер, к которому идёт запрос, видит запрос всегда, безусловно, независимо ни от какого CORS — запрос до него доходит в любом случае. Более того, если у браузера есть ваши куки для этого сервера, он при определённых условиях приложит их к запросу автоматически. Браузер видит и запрос, и ответ целиком — он здесь центральная фигура, именно он смотрит на заголовки ответа и решает, отдать его вашему коду или заблокировать. А ваш код видит ответ ровно в одном случае: если браузер разрешил, то есть если сервер прислал правильный разрешающий заголовок. Во всех прочих случаях для вашего кода ответа как будто и не было — только ошибка в консоли.

И здесь всплывает следствие, которое многие упускают, а оно важное. Раз запрос доходит до сервера при любом раскладе, то даже запрос, «заблокированный по CORS», мог что-то на сервере натворить — если это был запрос, меняющий состояние. Браузер не дал прочитать ответ, но само действие на сервере уже произошло. «Заблокировано по CORS» не равно «не выполнилось» — выполниться оно вполне могло, вы просто не увидели результат. На этом различии между «дошло и сработало» и «я не смог прочитать ответ» стоит целый класс атак, но это тема для отдельного текста.

Preflight: откуда берётся лишний запрос, который вы не отправляли

Ещё одна вещь, которая ставит в тупик при первой встрече. Открываешь вкладку Network в devtools, а там перед твоим запросом браузер зачем-то отправил ещё один, которого ты не писал, — методом OPTIONS. Это preflight, предварительный запрос, и появляется он не всегда. Разберёмся, когда именно.

Браузер делит все cross-origin запросы на простые и непростые, и ведёт себя с ними по-разному. Простой запрос — это что-то максимально обыденное: метод GET или POST, без нестандартных заголовков, с обычным типом содержимого, какой бывает у простой формы на сайте. Грубо говоря, это то, что веб умел отправлять испокон веков, ещё до всякого JSON и API. Такой запрос браузер отправляет сразу, без прелюдий. Непростой — это когда есть что-то сверх обыденного: метод вроде PUT или DELETE, заголовок авторизации, тип содержимого application/json и тому подобное. Вот перед таким запросом браузер сначала шлёт preflight — тот самый OPTIONS. В нём он заранее спрашивает у сервера: я собираюсь прислать вот такой запрос, с таким методом и такими заголовками, с такого-то origin — ты это разрешаешь? Сервер отвечает разрешением, тоже через заголовки. И только получив согласие, браузер отправляет настоящий запрос. Не разрешил — настоящий запрос даже не уйдёт.

Логика тут ровно та, что и во всём остальном: запросы, которые способны что-то изменить на сервере, браузер проверяет заранее, чтобы случайно не натворить необратимого на сервере, который к обращениям с чужого origin не готов. Preflight — это вежливый стук в дверь перед тем, как что-то сделать, а не после.

Где на CORS обжигаются

Поскольку CORS настраивают руками, а его логика неочевидна, ошибок вокруг него хватает, и часть из них превращается в полноценные дыры. Без обзора типичных промахов картина будет неполной, поэтому пройдёмся по ним — тем более что каждый из них наглядно показывает, как именно ослабление защиты оборачивается её отсутствием.

Первый и самый классический — отражение чужого origin как есть. Разработчику надо разрешить доступ нескольким сайтам, и вместо того чтобы завести список разрешённых, он берёт origin прямо из пришедшего запроса и его же подставляет в разрешающий заголовок. Выходит «разрешаю тому, кто пришёл» — а пришёл может кто угодно. Любой сайт делает запрос, получает в ответ разрешение на самого себя, и защиты не остаётся никакой. Формально CORS настроен, по факту он открыт всем.

Второй — звёздочка на приватных данных. Access-Control-Allow-Origin: * на публичных данных вроде погоды или курсов — нормально, они и так для всех. Но та же звёздочка на API, отдающем личные данные пользователя, означает, что любой сайт в интернете может эти данные вычитать. Для публичного эндпоинта — норма, для приватного — дыра, и разница только в том, что за данные стоят за этим эндпоинтом.

Третий, с отдельным коварством, — звёздочка вместе с куками. Правила намеренно запрещают отдавать аутентифицированные ответы, то есть ответы на запросы с куками, сразу всем по звёздочке — браузер такую комбинацию отклонит сам. Но разработчик обходит это ограничение, начиная отражать чужой origin (см. первый промах) и при этом разрешая передачу кук. Получается самая опасная связка из возможных: любой сторонний сайт может обращаться к вашему API от имени залогиненного пользователя и читать ответы. Это полное снятие защиты именно для аутентифицированных запросов — то есть для самого ценного.

Четвёртый — доверие к origin со значением null. Иногда доступ разрешают для origin, равного null, рассуждая, что это «пусто, никого нет». Но null — это не «никто». Такое значение возникает в ряде ситуаций, например у запросов из определённым образом настроенного iframe, и атакующий вполне может воспроизвести эти условия у себя, чтобы его запрос пришёл именно с origin null и получил доверие. Поэтому доверять null в общем случае опасно.

Пятый — кривая проверка поддоменов. Хотят разрешить все свои поддомены и проверяют origin «на глазок» — заканчивается ли он на shop.ru. Такую проверку тривиально обойти адресом вроде shop.ru.evil.com: он ведь тоже заканчивается на shop.ru, только принадлежит атакующему. Проверять принадлежность к своим доменам надо строго, по точному совпадению, а не по хвосту строки.

Общее у всех этих промахов одно, и это стоит зафиксировать как принцип: CORS ослабляет встроенную защиту браузера, поэтому любая небрежность в его настройке эту защиту снимает. И оценивать серьёзность конкретной ошибки надо всегда в связке с тем, что за данные отдаёт эндпоинт и работает ли он с куками, — одна и та же настройка может быть безобидной на публичном справочнике и катастрофой на личном кабинете.

Что делать, когда чужой сервер CORS-заголовки не отдаёт

Есть ситуация, в которой CORS бессилен в принципе: чужой сервер просто не присылает разрешающих заголовков и присылать не намерен. Из браузера вы его к этому не принудите — заголовки ставит он, это его сторона, не ваша.

Выход в том, чтобы не пытаться читать чужой сервер из браузера вообще, а сходить к нему со своего сервера. Схема получается такая: ваш фронтенд обращается к вашему же бэкенду — а это ваш собственный origin, тут никаких ограничений нет и быть не может, — а бэкенд уже сам идёт к чужому серверу за данными и возвращает их вам. Работает это по той же причине, по которой вообще всё в этой теме работает: Same-Origin Policy и CORS — правила браузера, а на сервере браузера нет. Ваш бэкенд может обращаться к какому угодно чужому API совершенно свободно, никакие origin-ограничения на него не распространяются.

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

Если убрать всё лишнее

Соберём костяк, на который нанизано остальное. Same-Origin Policy — это встроенное в браузер и всегда включённое правило: код одного origin не может прочитать данные другого origin. Слово «прочитать» тут несущее — загружать и показывать чужое, будь то картинки, скрипты или iframe, можно сколько угодно, нельзя лишь прочитать чужое содержимое как данные своим кодом. Origin при этом — это протокол, домен и порт вместе взятые, и стоит разойтись хоть одной из трёх составляющих, как origin уже считается чужим, включая собственный поддомен вашего же сайта.

CORS — это узаконенный способ ослабить это правило и всё-таки прочитать чужое, и ключевое в нём то, что разрешение выдаёт сервер, к которому идёт запрос, специальным заголовком в ответе, а применяет это разрешение браузер. Из чего следует главное, что нужно унести с собой: CORS не защищает сервер. Он работает только в браузере и только против чтения ответа чужим фронтендом, а от запросов, идущих мимо браузера — из curl, из другого сервера, — не спасает вообще никак, потому что за это отвечает аутентификация на бэкенде, живущая совсем в другом слое. Preflight в этой картине — предварительный OPTIONS-запрос, которым браузер заранее спрашивает у сервера разрешение на непростые запросы, способные что-то изменить.

А если ужать всё до одной мысли, из которой выводится любая деталь, то она такая: отправить запрос и прочитать ответ — это два разных события, и между ними стоит браузер, который решает, пускать вас к ответу или нет. Как только в голове щёлкает это разделение, вся многолетняя путаница вокруг SOP и CORS растворяется без остатка.