vyvchy
    Теми розділу

    03 · Веб і мережі для AQA

    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.comapp.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.

    API (api.example.com)БраузерСкрипт (app.example.com)API (api.example.com)БраузерСкрипт (app.example.com)Origin збігається з ACAOfetch('https://api.example.com/users')GET /users, Origin: https://app.example.com200 OK, Access-Control-Allow-Origin: https://app.example.comВіддає відповідь скриптуAPI (api.example.com)БраузерСкрипт (app.example.com)API (api.example.com)БраузерСкрипт (app.example.com)Origin збігається з ACAOfetch('https://api.example.com/users')GET /users, Origin: https://app.example.com200 OK, Access-Control-Allow-Origin: https://app.example.comВіддає відповідь скрипту

    Значення заголовка може бути:

    Значення 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.

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Крос-орідж-запит

    Метод GET/HEAD/POST?

    Preflight OPTIONS

    Лише безпечні заголовки?

    Content-Type простий?

    Простий запит: браузер шле одразу

    Ні

    Так

    Ні

    Так

    Ні

    Так

    Крос-орідж-запит

    Метод GET/HEAD/POST?

    Preflight OPTIONS

    Лише безпечні заголовки?

    Content-Type простий?

    Простий запит: браузер шле одразу

    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).

    API (api.example.com)БраузерAPI (api.example.com)БраузерPreflight пройшов (статус 2xx)OPTIONS /users (Access-Control-Request-Method: POST)204, Access-Control-Allow-Methods/HeadersPOST /users (справжній запит із JSON і токеном)200 OK, Access-Control-Allow-OriginAPI (api.example.com)БраузерAPI (api.example.com)БраузерPreflight пройшов (статус 2xx)OPTIONS /users (Access-Control-Request-Method: POST)204, Access-Control-Allow-Methods/HeadersPOST /users (справжній запит із JSON і токеном)200 OK, Access-Control-Allow-Origin

    Кілька важливих деталей про 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).

    За замовчуванням крос-орідж-запит через 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 passOPTIONS повертає не-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, — чи справді фронт і бек в очікуваному походженні.