Почему один сайт не может прочитать другой: разбираемся с Same-Origin Policy и CORS
Откройте две вкладки. В первой — ваш интернет-банк, вы туда залогинены. Во второй — какой-нибудь случайный сайт, на который вы зашли по ссылке из поисковика. Вопрос: почему сайт из второй вкладки не может взять и прочитать ваш баланс из первой?
Технически ведь ничто не мешает: обе вкладки открыты в одном браузере, у браузера есть доступ и туда, и туда. Скрипт со случайного сайта мог бы отправить запрос к вашему банку — браузер бы даже приложил к этому запросу ваши куки, по которым банк вас узнаёт, — получить ответ с балансом и отправить его куда угодно. Ничего сложного.
Но этого не происходит. И не происходит благодаря правилу, которое встроено в каждый браузер и работает всегда, даже если о нём никто не задумывался. Называется оно Same-Origin Policy. С него и начнём, а потом разберёмся, при чём тут CORS, о котором спотыкается каждый, кто впервые пытается подтянуть на свой сайт данные с чужого.
Что такое origin
Чтобы говорить о правиле, надо сначала договориться, что браузер вообще считает «одним сайтом», а что «разными». Здесь есть точное понятие — origin.
Origin — это связка из трёх вещей: протокол, домен и порт. Проще показать. Возьмём адрес https://shop.ru: протокол здесь https; домен — shop.ru; порт — стандартный для https, то есть 443, он просто не пишется явно.
Два адреса относятся к одному origin, только если у них совпадают все три части. Если отличается хоть одна — для браузера это уже разные origin, чужие друг другу. Смотрите:
-
https://shop.ru/catalogиhttps://shop.ru/cart— один origin. Путь после домена (/catalog,/cart) роли не играет, браузер на него не смотрит. -
http://shop.ruиhttps://shop.ru— разные origin. Протокол отличается. -
https://shop.ruиhttps://api.shop.ru— разные origin. Поддоменapi— это уже другой домен с точки зрения браузера.
Последний пункт особенно часто удивляет: shop.ru и api.shop.ru вроде бы «один сайт» для человека, но для браузера это два разных origin, и правило между ними действует в полную силу.
Запомним это. Дальше всё крутится вокруг того, один origin или разные. Теперь само правило. Формулируется оно так:
Код с одного origin не может прочитать данные с другого origin.
Вернёмся к банку. Ваш банк живёт на https://bank.ru, случайный сайт — на https://randomsite.ru. Это разные origin. Поэтому скрипт с randomsite.ru, даже отправив запрос к bank.ru, не сможет прочитать ответ — браузер ему этого не даст. Вот и вся защита, ради которой Same-Origin Policy существует. Без неё веб, каким мы его знаем, был бы невозможен: любая открытая вкладка воровала бы данные из любой другой.
Но здесь есть тонкость, которая и есть источник всей путаницы дальше. Обратите внимание на слово прочитать. Правило запрещает именно чтение чужих данных. Оно не запрещает загружать чужое. Это не одно и то же, и разница огромная.
Загрузить чужое — можно. Прочитать — нельзя
Посмотрите на любую страницу в интернете. Она почти наверняка подгружает что-то с других origin: картинки с отдельного домена для статики, шрифты от Google, скрипт аналитики, встроенное видео с ютуба. Всё это — чужие origin, и всё это прекрасно грузится. Никакая Same-Origin Policy этому не мешает.
Как так, если правило запрещает трогать чужое? А потому что оно запрещает не трогать, а именно читать содержимое как данные. Показать чужую картинку пользователю — пожалуйста. Дать вашему коду залезть внутрь этой картинки и прочитать её пиксели — нельзя. Вот эту границу и надо прочувствовать, дальше всё держится на ней.
Разберём на трёх примерах, потому что на них сразу видно, где проходит черта.
Картинка. Вы вставляете на страницу <img> с чужого домена — картинка отображается. Но если вы попробуете скопировать её в элемент <canvas> и оттуда прочитать пиксели программно (например, чтобы проанализировать цвета), браузер заблокирует операцию. Показать — да, прочитать данные — нет.
Скрипт с чужого домена. Вы подключаете библиотеку — скажем, jQuery — тегом <script> с чужого CDN. Она грузится и работает. И вот тут кажется, что правило нарушено: чужой код же исполняется у вас. Но нарушения нет, и причина важная. Подключённый скрипт исполняется как ваш собственный, в вашем origin. Он не остаётся «чужим» — он становится частью вашей страницы, с полным доступом к ней. Вы не прочитали чужой файл как данные — вы пустили чужой код к себе и дали ему свои права.
У этого, кстати, есть обратная сторона, о которой стоит знать. Раз чужой скрипт получает полные права на вашей странице, то если этот CDN взломают и подменят файл — вредоносный код исполнится у вас с полным доступом ко всему, включая данные пользователей. Поэтому серьёзные проекты либо держат такие библиотеки у себя, либо используют атрибут integrity, где прописан хеш файла: браузер сверит его и не подключит скрипт, если файл подменили. Но это уже отдельная история.
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» не значит «не выполнилось». На этом различии, кстати, стоит целый класс атак, но это тема отдельного разговора.
Preflight: почему иногда летит лишний запрос
Ещё одна вещь, которая всех удивляет при первой встрече. Открываешь вкладку Network в инструментах разработчика, а там перед твоим запросом браузер отправил ещё один, который ты не писал, — методом OPTIONS. Это называется preflight, предварительный запрос. Появляется он не всегда, и вот когда.
Браузер делит все cross-origin запросы на простые и непростые.
Простой запрос — это что-то совсем обычное: метод GET или POST, без нестандартных заголовков, с обычным типом содержимого (как у обычной формы на сайте). Грубо говоря, то, что веб умел отправлять всегда, ещё до всяких API. Такой запрос браузер отправляет сразу.
Непростой — это когда есть что-то сверх того: метод вроде PUT или DELETE, заголовок авторизации, тип содержимого application/json и тому подобное. Перед таким запросом браузер сначала отправляет preflight — тот самый OPTIONS. В нём он заранее спрашивает у сервера: «я собираюсь прислать вот такой запрос, вот с таким методом и такими заголовками, с этого origin — ты разрешаешь?». Сервер отвечает, что разрешает (тоже заголовками). Только после этого браузер шлёт настоящий запрос. Если сервер не разрешил — настоящий запрос даже не уйдёт.
Смысл простой: запросы, которые могут что-то изменить на сервере, браузер проверяет заранее, чтобы не натворить необратимого на сервере, который к таким обращениям с чужого origin не готов.
Где на CORS ошибаются
Раз CORS настраивают руками, а логика у него неочевидная, ошибок хватает — и часть из них превращается в настоящие дыры. Пройдёмся по самым частым, без этого картина неполная.
Отражают чужой origin как есть. Хочется разрешить доступ нескольким сайтам. Вместо того чтобы вести список, разработчик берёт origin прямо из пришедшего запроса и его же вставляет в разрешающий заголовок. Получается «разрешаю тому, кто пришёл» — то есть вообще любому. Любой сайт делает запрос и получает разрешение на самого себя. Защиты ноль.
Звёздочка на приватных данных. Access-Control-Allow-Origin: * для публичных данных (погода, курсы) — нормально, они и так для всех. Но та же звёздочка на API, которое отдаёт личные данные пользователя, означает, что любой сайт может эти данные вычитать. Для публичного — ок, для приватного — дыра.
Звёздочка вместе с куками. Тут есть отдельное коварство. Правила запрещают отдавать данные с куками (то есть аутентифицированные) сразу всем по звёздочке — браузер такое отклонит. Но разработчик обходит это, начиная отражать чужой origin (см. первый пункт) и при этом разрешая куки. Вот это самая опасная связка: получается, что любой сайт может обращаться к вашему API от имени залогиненного пользователя и читать ответы. Полное снятие защиты для аутентифицированных запросов.
Доверяют origin со значением null. Бывает разрешают доступ для origin, равного null, считая, что это «пусто, никого». Но null — это не «никто». Такое значение возникает в некоторых ситуациях (например, у запросов из специальным образом настроенного iframe), и атакующий может эти условия у себя воспроизвести, чтобы его запрос пришёл именно с null. Поэтому доверять null обычно опасно.
Кривая проверка поддоменов. Хотят разрешить все свои поддомены и проверяют origin «по-простому» — заканчивается ли он на shop.ru. Такую проверку легко обойти адресом вроде shop.ru.evil.com — он тоже «заканчивается на shop.ru», но принадлежит атакующему. Проверять надо строго, а не по кусочку строки.
Общее у всех этих ошибок одно: CORS ослабляет встроенную защиту браузера, и любая небрежность в его настройке эту защиту снимает. Поэтому оценивать серьёзность каждой ошибки надо вместе с тем, что за данные отдаёт эндпоинт и работает ли он с куками.
Что делать, если сервер CORS не отдаёт
Есть ситуация, в которой CORS бессилен: чужой сервер просто не присылает разрешающих заголовков и присылать не собирается. Из браузера вы его никак не заставите — заголовки ставит он, а не вы.
Выход — не пытаться читать чужой сервер из браузера вообще, а сходить к нему со своего сервера. Схема такая: ваш фронтенд обращается к вашему же бэкенду (это свой origin, тут никаких ограничений нет), а бэкенд сам идёт к чужому серверу за данными и возвращает их вам. Работает это потому, что Same-Origin Policy и CORS — правила браузера. На сервере браузера нет. Ваш бэкенд может обратиться к какому угодно чужому API совершенно свободно.
Заодно это часто и удобнее: на своём сервере можно кэшировать чужие данные, спрятать от посторонних глаз ключи доступа к чужому API, переделать ответ в удобный вам вид. Плата — лишний переход и нагрузка на ваш сервер. Но когда чужая сторона не отдаёт CORS-заголовки, это единственный чистый способ.
Коротко о главном
Same-Origin Policy — правило браузера, включённое всегда: код одного origin не может прочитать данные другого origin. Слово «прочитать» тут ключевое. Загружать и показывать чужое (картинки, скрипты, iframe) можно сколько угодно — нельзя прочитать его содержимое как данные своим кодом.
Origin — это протокол, домен и порт вместе. Отличается хоть что-то одно — origin уже другой, чужой.
CORS — способ легально ослабить это правило и всё же прочитать чужое. Разрешение даёт сервер, к которому идёт запрос, специальным заголовком в ответе. Применяет это разрешение браузер.
CORS не защищает сервер. Он работает только в браузере и только против чтения ответа чужим фронтендом. От запросов не из браузера (curl, другой сервер) он не спасает вообще — за это отвечает аутентификация на бэкенде.
Preflight — предварительный OPTIONS-запрос, которым браузер заранее спрашивает разрешение на непростые запросы (нестандартный метод, заголовки, application/json).
А если убрать вообще всё лишнее, останется одна мысль, из которой выводится остальное: отправить запрос и прочитать ответ — два разных события, и между ними стоит браузер, который решает, пускать вас к ответу или нет. Как только это щёлкает — вся путаница вокруг SOP и CORS исчезает.
ссылка на оригинал статьи https://habr.com/ru/articles/1066018/