SOP & CORS
- четверг, 13 августа 2026 г. в 00:00:22
Держите открытыми две вкладки. В одной — интернет-банк, куда вы залогинены и где на экране висит ваш баланс. В другой — какой-то сайт, на который вы забрели по ссылке из выдачи, ничего особенного. Теперь вопрос, который на первый взгляд кажется надуманным, а на деле упирается в фундамент всей веб-безопасности: что мешает скрипту со второго сайта взять и прочитать ваш баланс из первого?
Разберёмся, что технически ничего не мешает. Обе вкладки живут в одном браузере, браузер имеет доступ к обеим. Скрипт со случайного сайта вполне мог бы сформировать запрос к домену банка. Мало того, браузер по умолчанию приложил бы к этому запросу ваши куки — те самые, по которым банк отличает вас от постороннего, — то есть запрос ушёл бы как будто от вас, залогиненного. Ответ с балансом вернулся бы. Оставалось бы прочитать его и отправить куда угодно. Ни одного технически сложного шага в этой цепочке нет.
И тем не менее этого не происходит никогда. Не потому что банк как-то особо защищён, а потому что в каждый браузер вшито правило, которое работает всегда и по умолчанию, даже когда о нём никто не вспоминает. Правило это называется 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 расшифровывается как 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, рассуждает примерно так: «я разрешил доступ вот этим origin, а остальным не разрешил — значит, я закрыл своё API от посторонних». Это ошибка, и вот в чём она. CORS действует исключительно внутри браузера. Всё, что он делает, — сообщает браузеру, показывать ли ответ чужому фронтенду. Но отправить запрос к вашему серверу можно и мимо браузера: из curl в терминале, из Postman, со своего собственного сервера, из любого скрипта. Там нет браузера, а значит, ни Same-Origin Policy, ни CORS в этой ситуации вообще не участвуют. Запрос спокойно уходит, ответ спокойно возвращается, и никакие CORS-заголовки этому не мешают, потому что их некому применять.
Отсюда прямое следствие: если ваше API отдаёт что-то, что не должно попадать посторонним, CORS не защитит это ни на секунду. От посторонних защищает аутентификация и проверка прав на стороне сервера — то есть проверка того, кто именно к вам пришёл и положено ли ему то, что он просит. Это живёт на бэкенде и к CORS не имеет никакого отношения. CORS отвечает на совсем другой вопрос — покажет ли браузер ваш ответ чужому сайту, — и только на него. Спутать эти две вещи легко, а цена ошибки высокая, поэтому стоит держать в голове чётко: CORS про браузер и про чтение ответа чужим фронтендом, аутентификация про сервер и про доступ к данным вообще. Разные слои, разные задачи.
Чтобы всё окончательно улеглось, полезно разложить роли: когда ваш сайт стучится к чужому, кто из участников что видит. Сервер, к которому идёт запрос, видит запрос всегда, безусловно, независимо ни от какого CORS — запрос до него доходит в любом случае. Более того, если у браузера есть ваши куки для этого сервера, он при определённых условиях приложит их к запросу автоматически. Браузер видит и запрос, и ответ целиком — он здесь центральная фигура, именно он смотрит на заголовки ответа и решает, отдать его вашему коду или заблокировать. А ваш код видит ответ ровно в одном случае: если браузер разрешил, то есть если сервер прислал правильный разрешающий заголовок. Во всех прочих случаях для вашего кода ответа как будто и не было — только ошибка в консоли.
И здесь всплывает следствие, которое многие упускают, а оно важное. Раз запрос доходит до сервера при любом раскладе, то даже запрос, «заблокированный по CORS», мог что-то на сервере натворить — если это был запрос, меняющий состояние. Браузер не дал прочитать ответ, но само действие на сервере уже произошло. «Заблокировано по CORS» не равно «не выполнилось» — выполниться оно вполне могло, вы просто не увидели результат. На этом различии между «дошло и сработало» и «я не смог прочитать ответ» стоит целый класс атак, но это тема для отдельного текста.
Ещё одна вещь, которая ставит в тупик при первой встрече. Открываешь вкладку Network в devtools, а там перед твоим запросом браузер зачем-то отправил ещё один, которого ты не писал, — методом OPTIONS. Это preflight, предварительный запрос, и появляется он не всегда. Разберёмся, когда именно.
Браузер делит все cross-origin запросы на простые и непростые, и ведёт себя с ними по-разному. Простой запрос — это что-то максимально обыденное: метод GET или POST, без нестандартных заголовков, с обычным типом содержимого, какой бывает у простой формы на сайте. Грубо говоря, это то, что веб умел отправлять испокон веков, ещё до всякого JSON и API. Такой запрос браузер отправляет сразу, без прелюдий. Непростой — это когда есть что-то сверх обыденного: метод вроде PUT или DELETE, заголовок авторизации, тип содержимого application/json и тому подобное. Вот перед таким запросом браузер сначала шлёт preflight — тот самый OPTIONS. В нём он заранее спрашивает у сервера: я собираюсь прислать вот такой запрос, с таким методом и такими заголовками, с такого-то origin — ты это разрешаешь? Сервер отвечает разрешением, тоже через заголовки. И только получив согласие, браузер отправляет настоящий запрос. Не разрешил — настоящий запрос даже не уйдёт.
Логика тут ровно та, что и во всём остальном: запросы, которые способны что-то изменить на сервере, браузер проверяет заранее, чтобы случайно не натворить необратимого на сервере, который к обращениям с чужого origin не готов. Preflight — это вежливый стук в дверь перед тем, как что-то сделать, а не после.
Поскольку 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 бессилен в принципе: чужой сервер просто не присылает разрешающих заголовков и присылать не намерен. Из браузера вы его к этому не принудите — заголовки ставит он, это его сторона, не ваша.
Выход в том, чтобы не пытаться читать чужой сервер из браузера вообще, а сходить к нему со своего сервера. Схема получается такая: ваш фронтенд обращается к вашему же бэкенду — а это ваш собственный 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 растворяется без остатка.