CORS і політика однакового походження
Зміст
Уяви типову ситуацію на роботі: фронтендер каже «у мене CORS-помилка, винен бекенд», бекендер відповідає «а в мене все віддається, дивись — curl показує 200», а тестувальник має розсудити, хто правий. Щоб не гадати, треба розуміти механіку. Тема лякає новачків, бо помилка спалахує в браузері, а причина майже завжди на сервері — ця рознесеність збиває з пантелику. Розберемо все по цеглинці.
Що таке origin (походження)
Походження (origin) — це трійка: схема (scheme) + хост (host) + порт (port). Не шлях, не query-параметри, не фрагмент — саме ці три складові. Два URL належать до одного походження тоді й лише тоді, коли всі три збігаються.
Розберемо на прикладі https://app.example.com:443/users?id=5:
| Складова | Значення | Входить в origin? |
|---|---|---|
| Схема (scheme) | https | так |
| Хост (host) | app.example.com | так |
| Порт (port) | 443 | так |
| Шлях (path) | /users | ні |
| Query | ?id=5 | ні |
| Фрагмент | #section | ні |
Тепер порівняймо кілька URL з базовим https://app.example.com (порт 443 — стандартний для https):
| URL | Той самий origin? | Чому |
|---|---|---|
https://app.example.com/dashboard | так | схема, хост, порт збігаються (шлях не рахується) |
http://app.example.com | ні | інша схема (http замість https) |
https://api.example.com | ні | інший хост (піддомен теж рахується) |
https://app.example.com:8443 | ні | інший порт |
https://example.com | ні | інший хост (example.com ≠ app.example.com) |
Два моменти, на яких новачки спотикаються. По-перше, піддомен — це інший хост: app.example.com і api.example.com — різні походження, хоча домен другого рівня в них спільний. По-друге, порт за замовчуванням залежить від схеми: 443 для https, 80 для http. Тому https://site.com і https://site.com:443 — одне походження, а http://site.com і http://site.com:80 — теж одне.
Окремий випадок, який варто впізнавати, — коли браузер надсилає Origin: null. Так буває, зокрема, для сторінок із пісочного (sandboxed) <iframe> без дозволу allow-same-origin, а також у деяких інших контекстах. Сервер, що звіряє Origin за білим списком, має свідомо вирішити, як чинити з null, а не приймати його за «своє» походження.
Чому це важливо для тебе як для AQA? Бо коли в тесті ти піднімаєш застосунок на http://localhost:3000, а мок-сервер API — на http://localhost:4000, це два різні походження (різні порти), і між ними діють усі крос-орідж-правила. Добра частина «дивних» помилок у локальних E2E-середовищах — саме через це.
Політика однакового походження (Same-Origin Policy)
Політика однакового походження (Same-Origin Policy, SOP) — базове правило безпеки браузера: скрипт, завантажений з одного походження, за замовчуванням не може читати дані з іншого походження. Це не окремий заголовок і не налаштування — це вбудована поведінка браузера, яка діє завжди.
Навіщо вона потрібна? Уяви, що ти залогінений у своєму інтернет-банку в одній вкладці, а в сусідній відкриваєш шкідливий сайт evil.com. Без SOP скрипт на evil.com міг би зробити запит на bank.com/api/balance, браузер автоматично підставив би твої cookie сесії, і скрипт прочитав би відповідь із балансом та номерами рахунків. SOP існує саме для того, щоб цього не сталося: запит браузер, можливо, і надішле, але прочитати відповідь чужому скрипту не дасть.
Що саме обмежує SOP
Важливо чітко розділяти, що блокується, а що ні:
| Дія | Блокує SOP? |
|---|---|
Читання відповіді на fetch/XHR до чужого походження | так (без CORS) |
Доступ до DOM чужого <iframe> (iframe.contentDocument) | так |
| Читання cookie іншого походження через JS | так |
Читання localStorage/sessionStorage іншого походження | так |
Вбудовування чужого зображення через <img> | ні |
Підключення чужого скрипта через <script> | ні |
| Відправлення форми на чуже походження | ні |
Ключова ідея: SOP обмежує читання даних скриптом, а не саме звернення до ресурсу. Це пояснює наступний, на перший погляд дивний, пункт.
Чому теги img і script історично не блокуються
Одне з найпоширеніших питань на співбесідах. Відповідь: SOP забороняє читати чужий вміст, але дозволяє його вбудовувати (embed).
Коли браузер зустрічає <img src="https://cdn.other.com/logo.png">, він завантажує картинку й малює її. Але JavaScript на сторінці не може прочитати пікселі цієї картинки: спроба намалювати чуже зображення на <canvas> і зчитати дані «забруднює» (taint) canvas, і будь-яке подальше читання пікселів (getImageData, toDataURL) кидає помилку безпеки. Вбудувати — можна, витягнути дані назад у скрипт — ні. Витоку інформації немає, тож і блокувати немає сенсу.
Так само <script src="https://cdn.other.com/lib.js"> виконає чужий код, але не дасть скрипту-хосту прочитати вихідний текст цього файлу як рядок. Історично ця «дірка» породила прийом JSONP: сервер повертав не чистий JSON, а виклик функції myCallback({...дані...}), і його підключали через <script>, обходячи SOP ще до появи CORS. Сьогодні JSONP вважають застарілим і небезпечним, але розуміти його корисно — це пряме джерело того, чому CORS узагалі знадобився.
Зворотний бік цієї «дозволеності» вбудовування — атака CSRF (Cross-Site Request Forgery). Оскільки форму чи <img> можна націлити на чуже походження, а браузер підставить cookie, зловмисник здатен змусити браузер виконати дію (наприклад, POST на переказ грошей), навіть якщо прочитати відповідь не зможе. Тому CORS і CSRF — це різні проблеми: CORS про читання відповіді, CSRF про небажану дію. Плутати їх не можна.
CORS: що це і яку проблему вирішує
Проблема: чистий SOP занадто суворий для реального життя. Сучасний застосунок на https://app.example.com майже завжди ходить по дані на окремий бекенд https://api.example.com — інше походження. За чистим SOP це було б заборонено.
CORS (Cross-Origin Resource Sharing, спільне використання ресурсів між походженнями) — механізм, який дозволяє серверу явно дозволити певним чужим походженням читати свої відповіді. Працює він через HTTP-заголовки (headers): сервер додає у відповідь спеціальні заголовки, а браузер їх читає й вирішує, чи віддавати відповідь скрипту.
CORS не «вимикає» безпеку — він дає серверу контрольований спосіб сказати браузеру: «цьому походженню я довіряю, віддай йому відповідь».
Access-Control-Allow-Origin
Головний заголовок відповіді — Access-Control-Allow-Origin (скорочено ACAO). Він каже браузеру, якому походженню дозволено читати цю відповідь.
Простий запит виглядає так. Скрипт з https://app.example.com робить fetch('https://api.example.com/users'). Браузер сам додає до запиту заголовок Origin:
GET /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Сервер відповідає й підтверджує дозвіл:
HTTP/1.1 200 OK
Content-Type: application/json
Access-Control-Allow-Origin: https://app.example.com
[{"id": 1, "name": "Ada"}]
Браузер бачить, що Access-Control-Allow-Origin збігається з походженням сторінки, — і віддає відповідь скрипту. Якщо цього заголовка немає або значення інше — браузер блокує доступ до відповіді, і в консолі зʼявляється знайома помилка на кшталт has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.
Значення заголовка може бути:
Значення Access-Control-Allow-Origin | Сенс |
|---|---|
https://app.example.com | дозволено лише цьому конкретному походженню |
* | дозволено будь-якому походженню (але не разом із credentials — про це нижче) |
| відсутній | доступ до відповіді заблоковано |
Часта практика на сервері: не хардкодити одне значення, а перевіряти вхідний Origin за білим списком і віддзеркалювати (echo) дозволене походження назад. Якщо сервер так робить, він має додати заголовок Vary: Origin, інакше кеш (проксі чи CDN) може віддати одному походженню відповідь, закешовану для іншого, — і ти отримаєш плаваючу (флакову) CORS-помилку, яку важко зловити.
Які заголовки відповіді видно скрипту
Навіть коли браузер віддав скрипту тіло відповіді, він за замовчуванням дозволяє прочитати лише невеликий «безпечний» набір заголовків відповіді (CORS-safelisted response headers). Їх рівно сім: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. Решту — наприклад, кастомний X-Total-Count для пагінації чи X-Request-Id — виклик response.headers.get(...) поверне як null, доки сервер явно не перелічить їх у Access-Control-Expose-Headers:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Expose-Headers: X-Total-Count, X-Request-Id
Це прямий практичний гачок для тестів: якщо перевірка читає X-Total-Count з крос-орідж-відповіді й падає на «undefined», причина часто не в бекенді, який заголовок таки шле, а у відсутньому Access-Control-Expose-Headers.
Простий запит проти preflight
Не всі крос-орідж-запити однакові. Браузер ділить їх на «прості» (simple) і ті, що потребують попередньої перевірки (preflight).
Простий запит браузер надсилає одразу, без попереднього дозволу. Запит вважається простим, якщо виконуються всі умови:
| Умова | Що дозволено |
|---|---|
| Метод | GET, HEAD або POST |
| Заголовки | лише зі «безпечного списку» (safelist): Accept, Accept-Language, Content-Language, Content-Type (а також Range з обмеженнями на значення) |
Значення Content-Type | лише text/plain, multipart/form-data або application/x-www-form-urlencoded |
Щойно запит виходить за ці рамки — браузер вмикає preflight. На практиці майже будь-який реальний API-запит потрапляє під preflight, бо:
- він шле JSON із
Content-Type: application/json(немає в безпечному списку); - він додає
Authorization: Bearer ...(немає в безпечному списку); - він використовує метод
PUT,PATCHабоDELETE.
Preflight-запит (OPTIONS)
Preflight — це попередній запит методом OPTIONS, який браузер надсилає сам, автоматично, ще до «справжнього» запиту, щоб спитати сервер: «чи можна мені взагалі зробити цей запит із цим методом і цими заголовками?».
Наприклад, скрипт хоче зробити POST із JSON та токеном. Спершу браузер тихо шле:
OPTIONS /users HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
Тут Access-Control-Request-Method і Access-Control-Request-Headers описують, що браузер збирається зробити далі. Сервер має відповісти дозволом:
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
Тільки якщо preflight пройшов успішно, браузер надсилає справжній POST. Успішним вважається лише відповідь з «ok»-статусом, тобто в діапазоні 200–299 включно (на практиці сервери віддають 200 або 204); за специфікацією Fetch будь-який інший код — 3xx-редирект, 401, 404, 500 — валить перевірку. Тому якщо на OPTIONS сервер відповів не-2xx, браузер навіть не надішле основний запит, а в консолі буде помилка виду Response to preflight request doesn't pass access control check (у Chrome — з уточненням It does not have HTTP ok status).
Кілька важливих деталей про preflight:
- Preflight не містить тіла й не містить credentials (cookie на сам
OPTIONSне надсилаються — за специфікацією preflight іде без облікових даних). Це часта пастка: не варто вішати на маршрутOPTIONSперевірку авторизації, бо вона його завалить. - Заголовок
Access-Control-Max-Ageкаже браузеру, скільки секунд кешувати результат preflight, щоб не питати щоразу. Це прямо впливає на кількість мережевих звернень. Браузери обмежують це значення згори: Firefox — до 86400 секунд (24 години), Chromium — до 7200 (2 години, від версії 76; у старіших версіях межа була 600). Значення більше за межу браузер трактує як саму межу.
Для тебе як для AQA preflight — часте джерело плутанини під час перехоплення мережі. У логах Playwright чи у вкладці Network ти побачиш два записи на один логічний запит: спершу OPTIONS, потім POST. Якщо в тесті ти чекаєш чи мокаєш лише POST, а падає OPTIONS — тест поводитиметься незрозуміло. Це реальна причина флаку (flakiness).
Credentials: cookie, авторизація та особливий режим
За замовчуванням крос-орідж-запит через fetch не надсилає cookie й іншу авторизацію (credentials). Щоб їх додати, режим із credentials треба ввімкнути явно:
// fetch
fetch('https://api.example.com/me', {
credentials: 'include'
});
// XMLHttpRequest
const xhr = new XMLHttpRequest();
xhr.open('GET', 'https://api.example.com/me');
xhr.withCredentials = true;
xhr.send();
Під credentials тут розуміють cookie, HTTP-автентифікацію та клієнтські TLS-сертифікати.
Коли запит іде з credentials, до сервера висуваються суворіші вимоги — і саме на них найчастіше «горять» команди:
| Заголовок відповіді | Без credentials | З credentials |
|---|---|---|
Access-Control-Allow-Origin | можна * | не можна * — лише конкретне походження |
Access-Control-Allow-Credentials | не потрібен | обовʼязково true |
Access-Control-Allow-Headers / -Methods | можна * | * не діє як «усі» — треба перелічити явно |
Тобто якщо фронтенд шле запит із credentials: 'include', а сервер відповідає Access-Control-Allow-Origin: *, браузер заблокує відповідь, попри те що зірочка нібито «дозволяє всім». Логіка проста: якщо ти віддаєш приватні дані під конкретну сесію, ти маєш назвати конкретне довірене походження, а не «будь-кого».
Правильна відповідь для запиту з credentials:
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Це прямо стосується ізоляції стану в автотестах. Якщо E2E-сценарій залежить від сесійної cookie, а фронт і API — на різних портах або доменах, саме поведінка credentials у CORS визначає, чи дійде сесія взагалі. «Розлогінювання» посеред тесту на локальному стенді часто виявляється недоналаштованим Access-Control-Allow-Credentials, а не багом застосунку.
Чому CORS працює в браузері, але не в Postman чи curl
Це коронне питання, і відповідь на нього знімає більшість суперечок «фронт vs бек».
CORS перевіряє браузер, а не сервер. Заголовки Access-Control-Allow-* — це інструкція для браузера, як йому вчинити з відповіддю. Postman, curl, HTTP-клієнт у бекенд-коді, APIRequestContext у Playwright — це не браузери. Вони не реалізують SOP і просто ігнорують CORS-заголовки: отримали відповідь — віддали її тобі повністю, крапка.
# curl отримає відповідь незалежно від CORS-заголовків —
# він просто не перевіряє Access-Control-Allow-Origin
curl -i https://api.example.com/users \
-H "Origin: https://evil.com"
Тому теза «у Postman усе працює, значить бекенд правий» — хибна. Postman завжди «працює», бо йому байдуже до CORS. Це не доказ, що з бекендом усе гаразд у контексті браузера. Так само «в curl 200» нічого не каже про те, чи віддасть браузер цю відповідь скрипту.
Практичний висновок для тестів: якщо треба відтворити саме браузерну CORS-помилку — відтворюй її в браузері (E2E), а не в API-клієнті. І навпаки: якщо API треба перевірити ізольовано від CORS, зручно бити по ньому з HTTP-клієнта, який SOP не застосовує.
Чому CORS — це обмеження браузера, а не сервера
Тонкий, але критичний момент, який відрізняє того, хто «зазубрив», від того, хто розуміє.
Для простого запиту сервер узагалі виконує його повністю. Запит доходить, база оновлюється, лист відправляється — усі побічні ефекти стаються. Браузер лише не дає скрипту прочитати відповідь, якщо CORS-заголовки не дозволяють. Тобто «CORS заблокував» не означає «запит не дійшов»: для простого POST він цілком міг дійти й спрацювати.
Для preflight-запиту інакше: тут браузер здатен не пустити основний запит зовсім, якщо OPTIONS не дав дозволу. Основний POST тоді навіть не полетить.
| Тип запиту | Чи доходить до сервера | Що блокує браузер |
|---|---|---|
| Простий (simple) | так, виконується попри CORS | лише читання відповіді скриптом |
| З preflight | основний — лише після успішного OPTIONS | і основний запит, і читання відповіді |
Звідси й випливає, чому це «обмеження браузера»: сервер зі свого боку відповідає всім однаково, він не «відхиляє за CORS». Рішення «дати чи не дати скрипту дані» ухвалює браузер на основі заголовків. Сервер лише повідомляє свою політику — примусово застосовує її браузер.
Для тестування це означає: не можна вважати, що заблокований CORS-запит не змінив стан системи. У сценаріях, де важлива ізоляція стану, це реальний ризик «брудних» даних між тестами.
Чий це фікс — фронта чи бека
Тепер маємо все, щоб розсудити суперечку.
Правило за замовчуванням: CORS лагодиться на бекенді. Заголовки Access-Control-Allow-* віддає сервер — отже, якщо їх немає, вони неправильні або не покривають потрібний метод чи заголовок, це виправляє бекенд. Фронтенд не може «домалювати» собі Access-Control-Allow-Origin: браузер його все одно проігнорує, бо джерело істини тут — сервер.
Швидка діагностика:
| Симптом у консолі | Найімовірніша причина | Хто лагодить |
|---|---|---|
No 'Access-Control-Allow-Origin' header is present | сервер не віддає ACAO | бекенд |
| ACAO є, але значення — інше походження | сервер не дозволив саме це походження | бекенд |
Access-Control-Allow-Origin: * + запит із credentials | конфлікт * та credentials | бекенд (назвати конкретний origin) + перевірити, чи фронту взагалі потрібні credentials |
Response to preflight ... doesn't pass | OPTIONS повертає не-2xx або без потрібних Allow-Methods/Allow-Headers | бекенд (обробити OPTIONS, дозволити метод/заголовок) |
Method PATCH is not allowed by Access-Control-Allow-Methods | сервер не перелічив метод | бекенд |
| запит іде без cookie, хоч має йти з нею | фронт не поставив credentials: 'include' | фронтенд |
| зайвий кастомний заголовок породжує непотрібний preflight | фронт додає заголовок, якого можна уникнути | фронтенд (за бажання оптимізувати) |
Отже, майже завжди — бекенд, за винятком двох випадків із боку фронта: він забув увімкнути credentials або сам створює запит, що виходить за межі «простого» й тягне preflight (наприклад, дописує зайвий заголовок). Ще один суто фронтовий/девелоперський обхід — проксі: dev-сервер (Vite, webpack, nginx) приймає запит зі свого ж походження й переадресовує його на API вже на боці сервера, де CORS не діє. Це не «фікс CORS», а спосіб зробити запит однопохідним для браузера.
Як AQA у баг-репорті замість «CORS не працює» варто одразу дати команді факти: точний текст помилки з консолі, значення Origin у запиті, наявні/відсутні заголовки Access-Control-* у відповіді (з вкладки Network) і чи був preflight OPTIONS. Це відразу спрямовує фікс у правильний бік і економить раунд «а покажи в Postman».
Звʼязок з автотестами
Кілька практичних наслідків, які варто тримати в голові під час автоматизації.
Перехоплення мережі. Коли мокаєш крос-орідж-запити (page.route у Playwright, перехоплення у Cypress), памʼятай про preflight: браузер може спершу вистрілити OPTIONS. Якщо мок не обробляє OPTIONS окремо або не повертає у своєму fulfill потрібних Access-Control-Allow-*, справжній запит не піде, і тест впаде з CORS-помилкою, «якої не мало бути». Мокаючи відповідь, додавай і CORS-заголовки.
Флак і waits. Один логічний запит = два записи в мережі (OPTIONS + основний). Якщо чекання (waitForResponse) прив’язане не до того запису, тест плаватиме. Прив’язуйся до основного методу та URL, а не лови випадково OPTIONS.
API-контекст в обхід CORS. request / APIRequestContext у Playwright — це HTTP-клієнт, а не браузер, тож він не застосовує SOP. Це зручно для підготовки стану (seeding) через API незалежно від CORS-налаштувань, але водночас означає, що такою перевіркою ти не відтвориш браузерну CORS-помилку — для неї потрібен реальний браузерний контекст.
Ізоляція стану. Оскільки заблокований CORS не гарантує, що запит не дійшов до сервера (для простих запитів він доходить і виконується), не покладайся на «браузер же заблокував» як на захист від зміни даних. Прибирай стан явно.
Локальні середовища. Фронт на одному порту, API-мок на іншому — це вже крос-орідж. Значна частина «працює на моїй машині, падає в CI» зводиться до різних origin між середовищами й відповідно різної CORS-поведінки. Перше, що варто звірити при незрозумілій мережевій помилці в E2E, — чи справді фронт і бек в очікуваному походженні.
Що таке походження (origin) і з чого воно складається?
Origin — це трійка «схема + хост + порт»: два URL належать до одного походження лише тоді, коли всі три складові збігаються. Шлях, query-параметри та фрагмент в origin не входять, тож https://app.example.com/dashboard і https://app.example.com/users — одне походження. А от http://app.example.com проти https://app.example.com — уже різні (інша схема), як і https://app.example.com:8443 (інший порт). Важлива деталь: порт за замовчуванням залежить від схеми — 443 для https, 80 для http, тому https://site.com і https://site.com:443 — це одне й те саме походження. Саме від цього визначення відштовхуються і Same-Origin Policy, і CORS.
Чи є app.example.com та api.example.com одним походженням? А localhost:3000 і localhost:4000?
Ні в першому випадку, ні в другому. Піддомен — це інший хост: у app.example.com і api.example.com спільний лише домен другого рівня, а хости різні, отже, походження різні. http://localhost:3000 і http://localhost:4000 відрізняються портом — теж різні походження, і між ними діють усі крос-орідж-правила. Для AQA це щоденна практика: фронт на одному порту, мок-сервер API на іншому — це вже крос-орідж, і добра частина «дивних» помилок у локальних E2E-середовищах береться саме звідси. Тому при незрозумілій мережевій помилці в тесті перше, що варто звірити, — чи справді фронт і бек в очікуваному походженні.
Що таке політика однакового походження (Same-Origin Policy) і навіщо вона потрібна?
SOP — базове правило безпеки браузера: скрипт, завантажений з одного походження, за замовчуванням не може читати дані з іншого походження. Це не заголовок і не налаштування — це вбудована поведінка браузера, яка діє завжди. Класична мотивація: ти залогінений у банку в одній вкладці, а в сусідній відкрив evil.com; без SOP скрипт зловмисника зробив би запит на API банку, браузер автоматично підставив би твої cookie, і скрипт прочитав би баланс та номери рахунків. SOP гарантує, що навіть якщо запит полетить, прочитати відповідь чужому скрипту браузер не дасть. CORS потім з'явився як контрольований спосіб послабити це правило з боку сервера.
Що саме обмежує SOP, а що — ні?
Ключова ідея: SOP обмежує читання даних скриптом, а не саме звернення до ресурсу. Блокується: читання відповіді на fetch/XHR до чужого походження (без CORS), доступ до DOM чужого iframe через contentDocument, читання cookie та localStorage/sessionStorage іншого походження. Не блокується: вбудовування чужого зображення через img, підключення чужого скрипта через script, відправлення форми на чуже походження. Тобто браузер, можливо, і надішле запит, і навіть покаже картинку — але витягнути дані назад у скрипт не дасть. Розуміння цієї межі «читати не можна, вбудовувати/надсилати можна» — фундамент, з якого виводяться і CORS, і CSRF.
Чому теги img і script історично не блокуються SOP?
Бо SOP забороняє читати чужий вміст, але дозволяє його вбудовувати (embed) — витоку даних при вбудовуванні немає. Браузер завантажить img і намалює картинку, але JS не зможе прочитати її пікселі: спроба зчитати чуже зображення з canvas «забруднює» (taint) його, і getImageData/toDataURL кидають помилку безпеки. Так само чужий script виконається, але прочитати його вихідний текст як рядок скрипт-хост не зможе. Історично цю «дірку» використовував JSONP: сервер повертав виклик функції myCallback(...) замість чистого JSON, і його підключали через script, обходячи SOP ще до появи CORS — сьогодні прийом вважають застарілим і небезпечним. Зворотний бік дозволеного вбудовування — атака CSRF: форму чи img можна націлити на чуже походження, і браузер підставить cookie.
Що таке CORS і яку проблему він вирішує?
CORS (Cross-Origin Resource Sharing) — механізм, яким сервер явно дозволяє певним чужим походженням читати свої відповіді. Проблема в тому, що чистий SOP занадто суворий: сучасний застосунок на https://app.example.com майже завжди ходить по дані на окремий бекенд https://api.example.com, а це інше походження. Працює CORS через HTTP-заголовки: сервер додає у відповідь Access-Control-Allow-*, а браузер їх читає й вирішує, чи віддавати відповідь скрипту. Важливо формулювати саме так: CORS не «вимикає» безпеку, а дає серверу контрольований спосіб сказати браузеру «цьому походженню я довіряю». Примусово застосовує політику завжди браузер, сервер лише повідомляє її.
Як працює заголовок Access-Control-Allow-Origin?
Це головний CORS-заголовок відповіді: він каже браузеру, якому походженню дозволено читати цю відповідь. Механіка: браузер сам додає до крос-орідж-запиту заголовок Origin з походженням сторінки; сервер відповідає, і якщо Access-Control-Allow-Origin збігається з цим origin (або дорівнює *), браузер віддає відповідь скрипту. Якщо заголовка немає або значення інше — доступ блокується, і в консолі з'являється помилка на кшталт No 'Access-Control-Allow-Origin' header is present. Значення * означає «будь-кому», але воно не працює разом із credentials. Фронтенд не може «домалювати» собі цей заголовок — його джерело істини завжди сервер.
Які крос-орідж-запити браузер вважає «простими», а які потребують preflight?
Простий запит браузер шле одразу, якщо виконуються всі умови: метод GET, HEAD або POST; заголовки лише з безпечного списку (Accept, Accept-Language, Content-Language, Content-Type); а Content-Type — лише text/plain, multipart/form-data або application/x-www-form-urlencoded. Щойно запит виходить за ці рамки, браузер спершу шле preflight. На практиці майже будь-який реальний API-запит потрапляє під preflight: JSON із Content-Type: application/json, токен в Authorization, методи PUT/PATCH/DELETE — кожного з цих пунктів окремо вже достатньо. Тому в Network на один логічний запит часто видно два записи: OPTIONS і основний.
Що таке preflight-запит і як він виглядає?
Preflight — це попередній запит методом OPTIONS, який браузер надсилає сам, автоматично, ще до справжнього запиту: «чи можна мені зробити запит із цим методом і цими заголовками?». У ньому браузер передає Origin, Access-Control-Request-Method і Access-Control-Request-Headers — опис того, що він збирається зробити. Сервер має відповісти статусом 2xx із Access-Control-Allow-Origin, Access-Control-Allow-Methods та Access-Control-Allow-Headers; лише тоді полетить основний запит. Будь-який інший код — редирект, 401, 404, 500 — валить перевірку, і основний запит браузер навіть не надішле. Результат preflight кешується на час з Access-Control-Max-Age, щоб не питати щоразу (браузери обмежують значення згори: Firefox — до 86400 с, Chromium — до 7200 с).
Чому не можна вішати перевірку авторизації на маршрут OPTIONS?
Бо preflight за специфікацією йде без credentials: cookie на сам OPTIONS не надсилаються, тіла він теж не містить. Якщо на маршруті стоїть перевірка авторизації, вона завалить preflight (наприклад, віддасть 401), а не-2xx на OPTIONS означає, що браузер навіть не надішле основний запит. У консолі це виглядає як Response to preflight request doesn't pass access control check — і команда може довго шукати проблему в токенах основного запиту, хоча падає службовий OPTIONS. Це типова пастка бекенд-конфігурації, яку варто впізнавати з одного погляду на вкладку Network: дивись статус саме OPTIONS-запису, а не основного.
Чому CORS-помилка є в браузері, а в Postman чи curl «усе працює»?
Бо CORS перевіряє браузер, а не сервер: заголовки Access-Control-Allow-* — це інструкція для браузера, як вчинити з відповіддю. Postman, curl, HTTP-клієнти в коді, APIRequestContext у Playwright — не браузери: вони не реалізують SOP і просто ігнорують CORS-заголовки — отримали відповідь і віддали її повністю. Тому теза «у Postman працює, значить бекенд правий» хибна: Postman «працює» завжди, бо йому байдуже до CORS, і це нічого не каже про те, чи віддасть браузер відповідь скрипту. Практичний висновок для тестів: браузерну CORS-помилку відтворюй у браузері (E2E), а якщо API треба перевірити ізольовано від CORS — бий по ньому з HTTP-клієнта.
Чи означає повідомлення «blocked by CORS policy», що запит не дійшов до сервера?
Ні, не обов'язково — і це відрізняє того, хто розуміє механізм, від того, хто зазубрив. Для простого запиту сервер виконує його повністю: запит доходить, база оновлюється, всі побічні ефекти стаються — браузер лише не дає скрипту прочитати відповідь. Для запиту з preflight інакше: якщо OPTIONS не дав дозволу, основний запит браузер не надішле зовсім. Тобто «CORS заблокував» для простого POST цілком може означати «запит дійшов і спрацював, але відповідь тобі не показали». Для автотестів це прямий ризик: не можна покладатися на «браузер же заблокував» як на захист від зміни даних — стан прибирай явно, інакше отримаєш «брудні» дані між тестами.
Фронтендер каже «CORS-помилка, винен бекенд», бекендер — «у curl 200, все ок». Хто лагодить?
Правило за замовчуванням: CORS лагодиться на бекенді, бо заголовки Access-Control-Allow-* віддає сервер, а «в curl 200» нічого не доводить — curl не перевіряє CORS. Якщо в консолі No 'Access-Control-Allow-Origin' header is present, ACAO з іншим походженням, метод не перелічений в Allow-Methods або OPTIONS повертає не-2xx — усе це фіксить бекенд. Винятки з боку фронта два: він забув credentials: 'include' (запит іде без cookie), або сам додає зайвий заголовок, який тягне непотрібний preflight. Окремий обхід — проксі на dev-сервері (Vite, webpack, nginx), який робить запит однопохідним для браузера, але це не «фікс CORS». Як AQA у баг-репорті замість «CORS не працює» давай факти: точний текст помилки з консолі, значення Origin у запиті, наявні заголовки Access-Control-* у відповіді й чи був preflight OPTIONS — це одразу спрямовує фікс і економить раунд «а покажи в Postman».
Фронт шле запит із credentials: 'include', сервер відповідає Access-Control-Allow-Origin: *. Спрацює?
Ні, браузер заблокує відповідь, попри те що зірочка нібито «дозволяє всім». Коли запит іде з credentials (cookie, HTTP-автентифікація, клієнтські TLS-сертифікати), вимоги суворішають: в Access-Control-Allow-Origin не можна * — лише конкретне походження; обов'язковий Access-Control-Allow-Credentials: true; а * в Access-Control-Allow-Headers/-Methods перестає діяти як «усі» — треба перелічувати явно. Логіка проста: якщо сервер віддає приватні дані під конкретну сесію, він має назвати конкретне довірене походження, а не «будь-кого». Для E2E це часте пояснення «розлогінювання» посеред тесту на локальному стенді, коли фронт і API на різних портах: сесія не доходить через недоналаштований Access-Control-Allow-Credentials, а не через баг застосунку.
Бекенд шле заголовок X-Total-Count, але скрипт через response.headers.get(...) отримує null. Чому?
Бо навіть коли браузер віддав скрипту тіло крос-орідж-відповіді, за замовчуванням читати можна лише безпечний набір заголовків відповіді — їх рівно сім: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. Кастомні заголовки на кшталт X-Total-Count чи X-Request-Id скрипт не побачить, доки сервер явно не перелічить їх у Access-Control-Expose-Headers. У Network при цьому заголовок видно — він реально приходить, просто браузер не віддає його скрипту. Це прямий практичний гачок для тестів: якщо перевірка читає X-Total-Count із крос-орідж-відповіді й падає на «undefined», причина часто не в бекенді, який заголовок таки шле, а у відсутньому Access-Control-Expose-Headers.
Чим CORS відрізняється від CSRF?
Це різні проблеми, які часто плутають: CORS — про читання відповіді, CSRF — про небажану дію. CSRF (Cross-Site Request Forgery) експлуатує те, що SOP дозволяє: форму чи img можна націлити на чуже походження, браузер підставить cookie, і сервер виконає дію (наприклад, POST на переказ грошей) — навіть якщо прочитати відповідь зловмисник не зможе. CORS цю атаку не зупиняє: для простого запиту сервер виконує його повністю, браузер блокує лише читання відповіді скриптом. Тому «у нас налаштований CORS» — не відповідь на питання про захист від CSRF: це різні механізми з різними цілями. Сильний кандидат формулює межу так: CORS вирішує, кому можна читати дані, а захист від CSRF — чи справді дію ініціював сам користувач.
Коли браузер надсилає Origin: null і чим небезпечно приймати його за «своє» походження?
Origin: null браузер шле, зокрема, для сторінок із пісочного (sandboxed) iframe без дозволу allow-same-origin, а також у деяких інших контекстах. Небезпека в тому, що null — це не конкретне довірене походження, а маркер «походження немає»: сервер, який звіряє Origin за білим списком і механічно додає туди null, фактично відчиняє двері всім контекстам, що його генерують. Тому сервер має свідомо вирішити, як чинити з null, а не приймати його за «своє». На співбесіді це питання перевіряє, чи розумієш ти, що значення Origin не завжди «нормальний URL», і чи бачив ти цей кейс у реальному трафіку.
Сервер віддзеркалює дозволений Origin із білого списку назад у відповідь. Який заголовок обов'язково додати і що буде без нього?
Обов'язково Vary: Origin. Віддзеркалення (echo) — часта практика: замість хардкоду одного значення сервер перевіряє вхідний Origin за білим списком і повертає дозволене походження в Access-Control-Allow-Origin. Але тоді та сама URL-адреса віддає різні відповіді різним походженням, і без Vary: Origin кеш (проксі чи CDN) може віддати одному походженню відповідь, закешовану для іншого — з чужим ACAO. Результат — плаваюча (флакова) CORS-помилка, яку дуже важко зловити: усе працює, потім раптово блокується, потім знову працює залежно від того, чия відповідь лежить у кеші. Для AQA це один із небагатьох випадків, коли CORS-помилка справді нестабільна за своєю природою, а не через тест.
Як CORS і preflight впливають на мокання мережі та очікування в E2E-тестах?
Три головні наслідки. Перше — мокання: коли перехоплюєш крос-орідж-запити (page.route у Playwright, перехоплення в Cypress), браузер може спершу вистрілити OPTIONS; якщо мок не обробляє його окремо або не повертає у fulfill потрібних Access-Control-Allow-*, справжній запит не піде, і тест впаде з CORS-помилкою, «якої не мало бути» — тож у мок-відповідь додавай і CORS-заголовки. Друге — waits: один логічний запит дає два записи в мережі (OPTIONS плюс основний), тож очікування прив'язуй саме до основного методу та URL, а не до службового OPTIONS. Третє — API-контекст: request/APIRequestContext у Playwright — це HTTP-клієнт без SOP, зручний для підготовки стану (seeding) в обхід CORS, але браузерну CORS-помилку ним не відтвориш — для неї потрібен реальний браузерний контекст.
Три кейси, де CORS вирішує, зелений тест чи флак: як прочитати CORS-помилку в консолі й Network та відрізнити preflight від справжнього запиту, як мок у Playwright валить тест «неіснуючою» CORS-помилкою, бо не обробив OPTIONS, і чому та сама відповідь проходить у APIRequestContext, але не відтворює браузерний баг. Скрізь — що дивитися і чому.
Кейс 1. Читаємо CORS-помилку в консолі та Network: хто винен
Фронтендер приніс скриншот консолі й каже «винен бекенд». Перш ніж заводити баг, тестувальник має зібрати факти, а не переказати текст помилки. Типова помилка виглядає так:
Access to fetch at 'https://api.example.com/orders' from origin
'https://app.example.com' has been blocked by CORS policy:
No 'Access-Control-Allow-Origin' header is present on the requested resource.
У вкладці Network той самий логічний запит із JSON-тілом та токеном дає два записи — спершу preflight, потім основний:
OPTIONS /orders HTTP/1.1
Host: api.example.com
Origin: https://app.example.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: authorization, content-type
HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://other.example.com
Зверни увагу: це відповідь на preflight, і заголовок Access-Control-Allow-Origin у ній є — просто з чужим origin. Тому реальний текст у консолі буде про невідповідність значення (...that is not equal to the supplied origin), а варіант No '...' header is present зʼявляється, коли заголовка немає взагалі. Обидва читаються однаково: звір Origin запиту зі значенням у відповіді.
Що дивитися і чому:
- Спочатку — який саме запит підсвічено червоним. Якщо валить
OPTIONS, основнийPOSTнавіть не полетить, і текст будеResponse to preflight request doesn't pass access control check. Якщо валить основний запит — preflight пройшов, а не збігсяAccess-Control-Allow-Originна реальній відповіді. Це два різні діагнози на одному екрані. - Звірити
Originзапиту зі значеннямAccess-Control-Allow-Originвідповіді. У прикладі сервер віддавhttps://other.example.com, а сторінка живе наhttps://app.example.com— не збігається, браузер блокує читання. Причина на бекенді: не дозволив саме це походження. No 'Access-Control-Allow-Origin' header is presentмайже завжди бекенд. Фронт не може «домалювати» собі цей заголовок — браузер бере його виключно з відповіді сервера.- У баг-репорт — факти, не «CORS не працює». Точний текст із консолі, значення
Originу запиті, наявні/відсутніAccess-Control-*у відповіді та чи був preflightOPTIONS. Це відразу спрямовує фікс і економить раунд «а покажи в Postman».
Кейс 2. Мок у Playwright валить тест CORS-помилкою, «якої не мало бути»
Найпідступніший клас у перехопленні мережі: мокаєш крос-орідж-POST, а тест падає з CORS-помилкою, хоч жодного реального сервера в грі немає. Причина — page.route перехопив основний запит, але браузер спершу вистрілив preflight, і мок його не обробив.
import { test, expect } from '@playwright/test';
test('мок крос-орідж-POST має обробити і preflight', async ({ page }) => {
await page.route('https://api.example.com/orders', async (route) => {
const req = route.request();
// preflight: віддати дозвіл, інакше основний запит не піде
if (req.method() === 'OPTIONS') {
return route.fulfill({
status: 204,
headers: {
'Access-Control-Allow-Origin': 'https://app.example.com',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, content-type',
},
});
}
// основна відповідь теж потребує CORS-заголовка
return route.fulfill({
status: 201,
contentType: 'application/json',
headers: { 'Access-Control-Allow-Origin': 'https://app.example.com' },
body: JSON.stringify({ id: 42 }),
});
});
await page.goto('https://app.example.com/checkout');
// прив'язка до ОСНОВНОГО методу, не до випадкового OPTIONS
const resPromise = page.waitForResponse(
(r) => r.url().includes('/orders') && r.request().method() === 'POST',
);
await page.click('#place-order');
const res = await resPromise;
expect(res.status()).toBe(201);
});
Що дивитися і чому:
- Мок мусить окремо відповісти на
OPTIONS. Без гілки для preflight основнийPOSTне полетить, і тест впаде зResponse to preflight request doesn't pass— CORS-помилка, «якої не мало бути». Мокаючи крос-орідж-відповідь, завжди додавайAccess-Control-Allow-*. waitForResponseприв'язуй до основного методу й URL. Один логічний запит = два мережеві записи (OPTIONS+POST). Якщо чекання зловитьOPTIONS, тест плаватиме: інколи повертає preflight, інколи справжню відповідь.Access-Control-Allow-Originтреба і на preflight, і на основній відповіді. ПройшовOPTIONS, але на відповіді201заголовка немає → браузер не віддасть тіло скрипту, тест побачить CORS-помилку вже на другому кроці.- Якщо запит іде з
credentials: 'include'—Access-Control-Allow-Origin: *у моці не спрацює: для credentials треба конкретне походження плюсAccess-Control-Allow-Credentials: true.
Кейс 3. APIRequestContext проходить, а браузер — ні: не той інструмент для CORS
«У Postman усе працює, значить бекенд правий» — хибна теза. Postman, curl та APIRequestContext у Playwright — це HTTP-клієнти, а не браузери: SOP вони не реалізують і CORS-заголовки просто ігнорують. Тому один і той самий сервер поводиться по-різному залежно від того, ким його б'єш.
import { test, expect } from '@playwright/test';
// APIRequestContext — HTTP-клієнт, а не браузер: CORS йому байдуже
test('seeding через API проходить попри CORS', async ({ request }) => {
const res = await request.post('https://api.example.com/orders', {
headers: { origin: 'https://evil.example.com' }, // навіть чужий origin — 201
data: { sku: 'A-1', qty: 2 },
});
expect(res.status()).toBe(201); // зелено, бо SOP тут не діє
});
// той самий бекенд у браузері блокується, якщо ACAO не дозволяє походження
test('браузерний CORS реально блокує читання відповіді', async ({ page }) => {
await page.goto('https://app.example.com/checkout');
const result = await page.evaluate(async () => {
try {
const r = await fetch('https://api.example.com/orders', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sku: 'A-1', qty: 2 }),
});
return { ok: true, status: r.status };
} catch (e) {
return { ok: false, error: String(e) }; // TypeError: Failed to fetch
}
});
expect(result.ok).toBe(false); // браузер не дав прочитати відповідь
});
Що дивитися і чому:
- Зелений API-тест не доводить, що з CORS усе гаразд.
request.postвіддасть201навіть зOrigin: https://evil.example.com, бо не перевіряєAccess-Control-Allow-Origin. Щоб відтворити саме браузерну CORS-помилку, потрібен реальний браузерний контекст (E2E черезpage), а не API-клієнт. - Заблокований CORS ≠ запит не дійшов. Для простого запиту сервер виконує його повністю — база оновлюється, лист іде — браузер лише не дає скрипту прочитати відповідь. Тому не покладайся на «браузер же заблокував» як на захист від зміни стану: прибирай дані явно між тестами.
APIRequestContext— ідеальний для seeding в обхід CORS. Готуй стан через API незалежно від CORS-налаштувань стенду. Тільки не плутай це з перевіркою: такою перевіркою браузерний баг ти не спіймаєш.- «Розлогінування» посеред E2E на різних портах часто виявляється недоналаштованим
Access-Control-Allow-CredentialsабоAccess-Control-Allow-Origin: *разом із credentials, а не багом застосунку. Фронт наlocalhost:3000та API-мок наlocalhost:4000— це вже два різні походження.
Origin і Same-Origin Policy
- Можу назвати, з чого складається походження (origin): схема + хост + порт; шлях, query і фрагмент не рахуються.
- Розумію, чому піддомен — це інший origin, а
https://site.comіhttps://site.com:443— той самий (порт за замовчуванням залежить від схеми: 443 для https, 80 для http). - Знаю, що фронт на
localhost:3000і мок-API наlocalhost:4000— це два різні походження (різні порти) з усіма крос-орідж-правилами. - Можу пояснити, що робить Same-Origin Policy (SOP): забороняє скрипту читати дані з чужого походження, але не блокує саме звернення до ресурсу.
- Знаю, що SOP блокує читання відповіді
fetch/XHR, DOM чужого<iframe>, cookie йlocalStorageіншого origin, але НЕ блокує<img>,<script>чи відправлення форми на чуже походження. - Розумію окремий випадок
Origin: null(напр. sandboxed<iframe>безallow-same-origin) і чому сервер має свідомо вирішувати, як з ним чинити.
Чому img/script не блокуються, JSONP і CSRF
- Можу пояснити, чому
<img>і<script>історично не під SOP: вбудувати (embed) чуже можна, а прочитати вміст скриптом — ні (чуже зображення «забруднює» canvas і блокуєgetImageData/toDataURL). - Знаю, що таке JSONP і чому це історичний обхід SOP до появи CORS (сервер повертав
callback({...})через<script>). - Можу пояснити різницю CORS vs CSRF: CORS про читання відповіді, CSRF про небажану дію (браузер підставляє cookie в запит на чуже походження).
CORS: заголовки й механіка
- Розумію, що CORS дозволяє серверу явно дозволити чужим походженням читати свої відповіді через HTTP-заголовки.
- Можу пояснити роль
Access-Control-Allow-Origin(ACAO) і три його стани: конкретне походження,*, або відсутній (доступ заблоковано). - Знаю, навіщо при echo
Originза білим списком потрібенVary: Origin— інакше кеш/CDN віддасть чужу закешовану відповідь і буде флакова CORS-помилка. - Розумію, що скрипт за замовчуванням бачить лише 7 safelisted-заголовків відповіді, а решту (напр.
X-Total-Count) треба перелічити уAccess-Control-Expose-Headers.
Простий запит, preflight і credentials
- Можу назвати умови «простого» запиту (метод GET/HEAD/POST, safelist-заголовки,
Content-Typeз трьох дозволених) і чому майже будь-який реальний API-запит тягне preflight. - Розумію, що preflight — це автоматичний
OPTIONSізAccess-Control-Request-Method/-Headers, який має пройти (2xx-статус) перед справжнім запитом; сам preflight іде без тіла й без credentials. - Знаю роль
Access-Control-Max-Age(кеш preflight) і що браузери ріжуть його згори (Chromium — 7200 с, Firefox — 86400 с). - Розумію правила режиму credentials (
credentials: 'include'): не можна*у ACAO, обовʼязковоAccess-Control-Allow-Credentials: true,*не діє як «усі» для Headers/Methods.
Браузер vs сервер, діагностика й автотести
- Можу пояснити, чому CORS «працює» в браузері, але не в curl/Postman/
APIRequestContext: CORS перевіряє браузер, а не сервер; HTTP-клієнти ігноруютьAccess-Control-*. - Розумію, чому «в Postman/curl 200» не доводить правоту бекенду в контексті браузера.
- Розумію, що для простого запиту сервер виконує його повністю (побічні ефекти стаються) — браузер лише не дає прочитати відповідь; для preflight основний запит може взагалі не полетіти.
- Знаю, що CORS майже завжди лагодить бекенд (заголовки віддає сервер), крім фронтових випадків: забутий
credentials: 'include', зайвий заголовок → зайвий preflight, або обхід через dev-проксі. - Розумію наслідки для автотестів: мокати
OPTIONSразом із CORS-заголовками, чіплятиwaitForResponseдо основного методу (а неOPTIONS), сідати стан через API в обхід SOP і явно прибирати стан (заблокований CORS ≠ запит не дійшов).
З яких складових складається походження (origin)?
Питання
З чого складається origin (походження) і що в нього НЕ входить?