CORS і політика одного походження
Зміст
Класична сцена на дейлі: фронтендер каже «CORS-помилка, винен бекенд», бекендер відкриває Postman, показує зелений 200 і відповідає «у мене все віддається», а тестувальник має розсудити, хто правий. Розсудити можна за півхвилини, якщо тримати в голові одну річ: заголовки Access-Control-* — це інструкція для браузера, а не для сервера. Postman їх не читає взагалі, тож його зелений статус не є аргументом ні в чий бік.
Це канонічна глава теми: тут повний виклад , й CORS, решта глав розділу посилаються сюди. Ціль — щоб із «якийсь CORS» у тебе виходив конкретний баг-репорт: який Origin пішов, якого заголовка не повернулося, чи був OPTIONS і хто це лагодить.
Походження: схема, хост, порт — і нічого більше
Походження (origin) — це трійка «схема (scheme) + хост (host) + порт (port)». Дві адреси належать одному походженню тоді й лише тоді, коли збігаються всі три складові; шлях, параметри запиту й фрагмент у неї не входять.
Складова https://app.example.com:443/users?id=5#top | Значення | В origin? |
|---|---|---|
| Схема | https | так |
| Хост | app.example.com | так |
| Порт | 443 | так |
| Шлях, запит, фрагмент | /users, ?id=5, #top | ні |
Це не деталь адресації, а межа безпеки: RFC 6454 називає походження доменом захисту (protection domain) і дозволяє доступ до обʼєктів — DOM, браузерних API — тоді й лише тоді, коли адреси належать одному походженню.
Дві речі, на яких спотикаються найчастіше. Піддомен — це інший хост: app.example.com і api.example.com — різні походження, попри спільний домен другого рівня. Порт залежить від схеми: «особливі» схеми ftp, http, https, ws, wss мають типовий порт, який у серіалізації опускають — для http це 80, для https 443, — тому https://site.com і https://site.com:443 — одне походження, а https://site.com:8443 — уже інше. Звідси й головна пастка локального стенду: фронт на http://localhost:3000 і API на http://localhost:4000 — це два різні походження з усіма правилами. Структура адреси розібрана в главі «URL і кодування», порт як мережеве поняття — у главі «DNS, IP, порти та мережа».
Специфікація обчислює походження switch-ом за схемою, і кортеж повертають лише пʼять схем — ftp, http, https, ws, wss, — причому кортеж чотиричленний: схема, хост, порт, домен. Для file кортежного походження немає взагалі: «When in doubt, return a new opaque origin». Непрозоре (opaque) походження — окремий вид, а не «порожня» трійка: єдина осмислена операція над ним — перевірка на рівність, серіалізується воно рядком null, а data:-URL не є same-origin навіть сам із собою. Звідси й те, що тест, відкритий як локальний file:///, поводиться інакше, ніж на стенді.
Рядок null браузер шле і як значення заголовка Origin — зокрема зі сторінок пісочного (sandboxed) <iframe> без allow-same-origin: «forces content into an opaque origin». Він же «prevents script from reading from or writing to the document.cookie IDL attribute, and blocks access to localStorage» — тобто «кукі не ставляться всередині фрейма» в пісочниці є очікуваною поведінкою, а не багом.
Походження — не те саме, що сайт
Ціна плутанини висока, бо кукі рахують сайт, а політика одного походження — походження. «Сайт» у HTML Standard — це пара «схема + хост», де хост згорнуто до (registrable domain — плюс мітка ліворуч від нього); порт і домен у сайтових перевірках ігноруються взагалі. Тому app.example.com і api.example.com — різні походження, але один сайт: те, що для SOP чужина, для кукі своє. А от http://example.com і https://example.com — не той самий сайт: схема входить у критерій. Сама специфікація радить уникати сайтових перевірок на користь звірки за походженням, бо поняття публічного суфікса й реєстрованого домену «cannot be relied-upon to provide a hard security boundary». Докладніше про кукі — у главі «Кукі, сесії та сховище браузера».
Що політика одного походження забороняє, а що ні
Політика одного походження (same-origin policy, SOP) — вбудований механізм безпеки браузера, який обмежує, як документ або скрипт з одного походження може взаємодіяти з ресурсом іншого. Це не заголовок і не налаштування: вона діє завжди й за замовчуванням. Мета, яку називає MDN, — ізолювати потенційно шкідливі документи, щоб чужий сайт не прочитав дані сервісу, у якому користувач авторизований (пошта, інтранет, банк), і не передав їх .
Ключова асиметрія в тому, що політика ділить дії на три класи, а не на «можна/не можна»:
| Клас дії | Приклади | Дозволено? |
|---|---|---|
| Записи (writes) | посилання, редиректи, надсилання форми | зазвичай так |
| Вбудовування (embedding) | <script src>, <link rel="stylesheet">, <img>, <video>, вміст <iframe> | зазвичай так |
| Читання (reads) | доступ до відповіді, до DOM чужого документа | зазвичай ні |
Звідси відповідь на класичне питання «чому <img> і <script> не блокуються»: вбудувати чуже можна, витягнути його вміст назад у скрипт — ні. Картинку браузер намалює, але щойно ти малюєш у <canvas> дані з іншого походження без CORS-дозволу, canvas стає «забрудненим» (tainted), і поіменно ламаються getImageData() на контексті та toBlob(), toDataURL(), captureStream() на самому елементі — з помилкою SecurityError. Знімається це конфігурацією сервера зображень, який має віддавати Access-Control-Allow-Origin, а не з боку клієнта.
Політика при цьому не абсолютна: сам факт вбудовування все одно щось повідомляє — розміри картинки, доступність ресурсу. Заборонити вбудовування свого документа сайт може заголовком X-Frame-Options. Посилання на вікно чужого походження (iframe.contentWindow, window.parent, window.opener) дають лише дуже обмежений доступ до Window і Location; штатний канал спілкування між документами різного походження — window.postMessage. localStorage і sessionStorage теж ізольовані за походженням, тож прочитати чуже сховище скриптом не вийде; у кукі область своя — домен і шлях, — тому їхні межі з походженням не збігаються.
Наша практика (не канон). Історичним обходом цієї заборони був JSONP: сервер повертав не чистий JSON, а виклик функції, і його підключали через <script>. Сьогодні прийом вважають застарілим і небезпечним, але саме він пояснює, навіщо взагалі знадобився CORS; окремого канонічного джерела під цей сюжет у нас немає.
CORS: дозвіл видає сервер, застосовує браузер
Чистий SOP занадто суворий для реального життя: застосунок на https://app.example.com майже завжди ходить по дані на https://api.example.com. CORS (Cross-Origin Resource Sharing) — механізм на основі HTTP-заголовків, яким сервер повідомляє, з яких чужих походжень браузеру дозволено вантажити його ресурси й читати відповіді. Він не «вимикає» безпеку, а дає серверу контрольований спосіб її послабити.
Чому механізм узагалі влаштований як opt-in, каже сама специфікація Fetch: інакше течуть дані з-за фаєрвола (інтранет), а разом з обліковими даними — ще й чутливі; поєднання «ділитися відповідями + дозволити облікові дані» вона називає доволі небезпечним і вказує клас — confused deputy.
Механіка простого випадку: скрипт робить запит на інше походження, і браузер сам додає заголовок 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
Браузер звіряє походження сторінки з тим, що дозволив сервер, і лише тоді віддає відповідь скрипту; якщо заголовка немає взагалі або значення інше — читання заблоковано. Access-Control-Allow-Origin називає одне дозволене походження або *, і зірочка припустима лише для запитів без облікових даних. Оскільки одне значення покриває один origin, сервери зазвичай читають вхідний Origin, звіряють його зі списком дозволених і лише за збігу віддають те саме значення назад. Пропущений крок звірки — це вже : віддзеркалення будь-якого Origin відкриває дані кому завгодно. І при віддзеркаленні відповідь мусить нести Vary: Origin, інакше кеш ( чи CDN) віддасть чужому походженню відповідь, призначену іншому — механіка кешу розібрана в главі «Кешування». Окремий дефект — Access-Control-Allow-Origin: null: створити документ із походженням null може будь-хто, тож джерело радить цього значення уникати.
Навіть коли браузер віддав тіло відповіді, скрипту типово видно лише сім «безпечних» заголовків: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. Решту сервер має явно перелічити в Access-Control-Expose-Headers. Це прямий гачок для тестів: якщо перевірка читає X-Total-Count з крос-оріджин відповіді й бачить null, причина зазвичай не в бекенді, який заголовок таки шле, а у відсутньому Access-Control-Expose-Headers.
Простий запит і той, що вимагає попереднього дозволу
Частину крос-оріджин запитів браузер шле одразу, а перед рештою питає в сервера дозволу окремим запитом. Термін «простий запит» варто вживати з обмовкою: MDN називає його спадком застарілої специфікації CORS — чинна Fetch, яка тепер CORS і визначає, слова simple request не вживає. Але поняття живе в обігу й на співбесіді його розуміють.
Запит іде без попереднього дозволу, якщо виконано всі умови:
| Умова | Що дозволено |
|---|---|
| Метод | GET, HEAD, POST |
| Заголовки, виставлені вручну | лише safelisted: Accept, Accept-Language, Content-Language, Content-Type, Range |
Значення Content-Type | application/x-www-form-urlencoded, multipart/form-data, text/plain |
| Довжина значення заголовка | не більша за 128 (інакше заголовок випадає з safelist) |
На практиці майже будь-який реальний API-запит із цих рамок виходить: він шле JSON із Content-Type: application/json, додає Authorization: Bearer … або використовує метод PUT, PATCH чи DELETE. Саме тому метод — найпомітніший тригер: GET летить сам, PATCH — ні. Семантика методів розібрана в главі «HTTP: методи, структура, заголовки».
Preflight (попередній запит) — окремий CORS-запит методом OPTIONS, який браузер надсилає сам, до «справжнього» запиту. У ньому він заявляє наміри заголовками Access-Control-Request-Method і Access-Control-Request-Headers, а сервер відповідає переліком дозволеного:
OPTIONS /users HTTP/1.1
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://app.example.com
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: authorization, content-type
Access-Control-Max-Age: 600
Три деталі, які кусаються найчастіше. Успішним preflight вважається лише «ok»-статус — «restricted to an ok status, e.g., 200 or 204»: якщо OPTIONS віддає 401, 404 чи редирект, справжній запит не полетить зовсім. Preflight ніколи не йде з обліковими даними, тому вішати на маршрут OPTIONS перевірку авторизації не можна — вона його завалить. І Access-Control-Max-Age каже, скільки секунд кешувати результат перевірки; типове значення за Fetch — 5 секунд, тож без явного заголовка OPTIONS повторюватиметься практично на кожен запит.
Для тестів звідси випливає найпоширеніша плутанина: один логічний запит дає два записи в мережі — спершу OPTIONS, потім основний. Тому і мок, і очікування описують парою «метод + URL»: сама лише адреса тепер покриває два різні звернення.
Облікові дані: чому зірочка з ними не працює
За замовчуванням крос-оріджин fetch і XMLHttpRequest не шлють кукі й HTTP-автентифікацію. Режим облікових даних має три значення — omit, same-origin (типове) та include; до самих облікових даних належать кукі, клієнтські TLS-сертифікати й заголовки Authorization/Proxy-Authorization.
// без цієї опції кукі на інше походження не підуть
await fetch('https://api.example.com/me', { credentials: 'include' });
Щойно запит іде з обліковими даними, вимоги до сервера жорсткішають:
| Заголовок відповіді | Без облікових даних | З обліковими даними |
|---|---|---|
Access-Control-Allow-Origin | можна * | лише конкретне походження |
Access-Control-Allow-Credentials | не потрібен | обовʼязково true |
Access-Control-Allow-Headers / -Methods / Expose-Headers | * розкривається | * не діє як «усі» |
Логіка проста: якщо ти віддаєш приватні дані під конкретну сесію, ти маєш назвати конкретне довірене походження, а не «будь-кого». Тому пара «фронт із credentials: 'include' + сервер із Access-Control-Allow-Origin: *» дає блокування, попри те що зірочка нібито дозволяє всім.
Один виняток ламає Bearer-запити найчастіше, і він безумовний — тобто не залежить від режиму облікових даних. Fetch означує: «A CORS non- request-header name is a header name that is a byte-case-insensitive match for Authorization», а в алгоритмі preflight: якщо таке імʼя є серед заголовків запиту й не перелічене поіменно в Access-Control-Allow-Headers, повертається мережева помилка. Отже сервер із Access-Control-Allow-Headers: * пропустить будь-що, крім Authorization. Другий сюрприз того самого роду: при редиректі на інше походження браузер знімає Authorization — «the moment another origin is seen after the initial request, the Authorization header is removed», тож тест, який іде за крос-оріджин редиректом, отримає неавторизовану відповідь, і сервер тут ні до чого.
Кукі в крос-оріджин контексті мають власний, незалежний рубіж — атрибут SameSite. Strict шле кукі лише для запитів із того самого сайту, Lax додатково пропускає верхньорівневу навігацію , None дозволяє міжсайтові запити, але обовʼязково разом із Secure. Механізми перетинаються, але один одного не заміняють: credentials: 'include' дозволяє браузеру спробувати надіслати кукі, а SameSite вирішує, чи вона взагалі поїде. Тому «розлогінювання посеред E2E-сценарію» буває і недоналаштованим Access-Control-Allow-Credentials, і SameSite без None; Secure.
CSRF: сусідня тема з іншим механізмом
Плутати CORS і CSRF не можна, і різниця тримається рівно на асиметрії з розділу про SOP. Крос-оріджин записи дозволені — саме з цього дозволу, як прямо каже RFC 6454, і виростає міжсайтова підробка запиту (cross-site request forgery, CSRF): чужий сайт може націлити форму чи <img> на твоє походження, браузер сам підставить кукі, і дія станеться. Відповіді атакувальник не прочитає — і йому вона не потрібна.
Тобто CORS — про читання відповіді, CSRF — про небажану дію. З цього випливає найважливіший практичний висновок: правильно налаштований CORS від CSRF не захищає, а «CORS заблокував» не означає «сервер нічого не зробив». Гайд тестування додає сюди ще одну звичну ілюзію: POST захистом не є, бо POST-запити автоматизуються через JavaScript, тож саме лише вживання POST вразливості не усуває.
Захист від CSRF — окрема тема з власним механізмом: синхронізований токен, SameSite, звірка Origin/Referer. Її канон живе в розділі про безпеку; тут важливо лише, що це інший рубіж, і наявність одного нічого не каже про наявність другого. Практична сторона токена очима автотесту розібрана в главі «Автентифікація та авторизація».
Інші межі: iframe і Shadow DOM
Походження — не єдина межа, крізь яку «не проходить» пошук елемента, і плутають ці межі постійно.
iframe — це не «ще один шар» сторінки, а окремий документ зі своїм window. Якщо він того самого походження, DOM усередині доступний, просто контекст треба перемкнути. Якщо чужого — політика одного походження лишає лише дуже обмежений доступ до Window і Location, тож до сторонніх платіжних форм і віджетів підтримки скрипт зі сторінки через DOM не дотягнеться: лишається postMessage або перевірка на рівні мережі. Драйвери при цьому працюють на рівні браузера, поза цим обмеженням: і frameLocator у Playwright, і switchTo().frame() у Selenium уміють перемкнути контекст у крос-оріджин фрейм.
Shadow DOM — межа зовсім іншої природи: це інкапсуляція компонента, а не безпека. document.querySelectorAll() не бачить вузлів усередині , тому селектор для основного документа не пробиває. Playwright відкриті shadow tree пробивають самі, а XPath — ні. Режим closed теж не є барʼєром безпеки: MDN прямо каже, що це «more of an indication» і «there are ways it can be evaded». Механіка дерев і подій — у главі «DOM, селектори та події».
Звідси порядок діагностики: симптом «у DevTools елемент видно, а локатор не знаходить» першим ділом перевіряють на інший контекст (iframe або Shadow DOM), і лише якщо фрейм чужий, це стає питанням походження.
Чому «у Postman працює» нічого не доводить
CORS перевіряє браузер. Postman, curl, HTTP-клієнт у бекенд-коді та APIRequestContext у Playwright політики одного походження не реалізують і заголовки Access-Control-* просто ігнорують: отримали відповідь — віддали її повністю. Тому зелений 200 в API-клієнті не є ані доказом, що з бекендом усе гаразд у , ані спростуванням баг-репорту.
Специфікація формулює це прямо: «it is up to the client to determine and enforce the restriction of whether the client has access to the response data based on this header». Звідси два наслідки, які варто вимовляти вголос у суперечці. CORS не є серверним контролем доступу — будувати на ньому авторизацію не можна. І Origin підробний поза браузером: із JavaScript його змінити не можна, але покладатися на нього в перевірках доступу — погана ідея.
Друга половина відповіді — що саме блокує браузер:
| Тип запиту | Чи доходить до сервера | Що блокує браузер |
|---|---|---|
| Без preflight | так, виконується повністю | лише читання відповіді скриптом |
| З preflight | основний — тільки після успішного OPTIONS | і основний запит, і читання відповіді |
Тобто «CORS заблокував» ніколи не означає «запит не дійшов»: для запиту без preflight сервер його виконує, і всі побічні ефекти стаються. Для ізоляції стану між тестами це прямий ризик: прибирай дані явно, а не покладайся на «браузер же заблокував». І дзеркально: APIRequestContext чудово готує стан через API повз усі CORS-налаштування — але саме тому браузерну CORS-помилку ним не відтвориш, для неї потрібен реальний браузерний контекст.
Наша практика (не канон). Далі — те, як розсуджують суперечку команди, з якими ми працювали; окремого стандарту під цей поділ немає. За замовчуванням CORS лагодять на бекенді: заголовки Access-Control-* віддає сервер, і фронтенд не може «домалювати» собі Access-Control-Allow-Origin. Винятків із боку фронта два — забутий режим із обліковими даними й зайвий , який без потреби тягне preflight. А замість «CORS не працює» у тікет ми пишемо факти: точний текст помилки з , значення Origin у запиті, наявні й відсутні Access-Control-* у відповіді та чи був OPTIONS. І перше, що звіряємо при незрозумілій мережевій помилці в E2E, — чи справді фронт і бек в очікуваному походженні: значна частина «працює на моїй машині, падає в CI» зводиться саме до різних origin між середовищами.
Що видно у вкладці Network і як це мокати
Крос-оріджин запити зазвичай народжуються з AJAX — обміну даними у фоні через fetch чи XMLHttpRequest, — тож і шукати їх треба у фільтрі Fetch/XHR. Кожен рядок вкладки Network — один запит; журнал пишеться, лише поки DevTools відкриті, тому панель відкривають перед відтворенням проблеми, а прапорець Preserve log рятує лог від редиректу після логіну.
Колонка Status показує код або помилку CORS. Вкладка Headers розділяє General, Response Headers і Request Headers — саме там видно, який Origin пішов і чи повернувся Access-Control-Allow-Origin; щоб бачити цей заголовок у кожному рядку, його додають окремою колонкою через Response Headers > Manage Header Columns. Фільтр розуміє властивості на кшталт method:OPTIONS, і кілька властивостей поєднуються лише через AND — «OR operations aren't supported». Санітизований HAR (без Cookie, Set-Cookie й Authorization) безпечно чіпляти до тікета. Повний розбір панелі — у главі «DevTools: вкладка Network і дебаг».
В автотестах головна пастка — мок, який забув про preflight. Мокаючи крос-оріджин відповідь, OPTIONS треба обробити окремо й повернути потрібні Access-Control-*, інакше справжній запит не піде:
await page.route('**/api/orders', async (route) => {
const headers = {
'Access-Control-Allow-Origin': 'http://localhost:3000',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, content-type',
};
if (route.request().method() === 'OPTIONS') {
return route.fulfill({ status: 204, headers });
}
await route.fulfill({ status: 201, headers, body: '{"id":1}' });
});
Ще одна деталь для перевірок: fetch не реджектиться на статусах 404 чи 500 — він реджектиться «on some errors, such as a network error», а невдала CORS-перевірка за специфікацією і є мережевою помилкою. Тому CORS-збій у коді виглядає як виняток, а не як відповідь із поганим статусом. Мокання й перехоплення глибше — у главі «Перехоплення й мокання мережі».
Типові помилки
- «У Postman
200, значить бекенд правий». Насправді Postman і curl політики одного походження не реалізують і заголовкиAccess-Control-*ігнорують — вони не можуть відтворити браузерну перевірку в принципі. - «CORS заблокував, отже запит не дійшов і дані не змінилися». Насправді запит без preflight сервер виконує повністю; браузер лише не віддає відповідь скрипту. Стан треба прибирати явно.
- «Поставили
Access-Control-Allow-Origin: *— тепер працює для всіх». Насправді з обліковими даними зірочка недійсна: браузер вимагає конкретне походження плюсAccess-Control-Allow-Credentials: true. - «
Access-Control-Allow-Headers: *пропускає будь-що». НасправдіAuthorization— CORS non-wildcard request-header name, і зірочка його не покриває ніколи, незалежно від облікових даних. - «Зайвий
OPTIONSу Network — баг фронта». Насправді це preflight, який браузер шле сам; без явногоAccess-Control-Max-Ageвін повторюватиметься майже на кожен запит. - «
localhost:3000іlocalhost:4000— один і той самий localhost». Насправді порт входить у трійку походження, тож це два різні походження з усіма крос-оріджин правилами. - «Заголовок сервер шле — тест його прочитає». Насправді скрипту типово видно лише сім
safelisted-заголовків відповіді; кастомний треба перелічити вAccess-Control-Expose-Headers. - «CORS налаштований — від CSRF ми захищені». Насправді це різні рубежі: CORS обмежує читання відповіді, CSRF експлуатує дозволений запис, для якого відповідь не потрібна.
- «Локатор не знаходить елемент у чужому
iframe— селектор поганий». Насправді це інший контекст: селектор основного документа межі фрейма не перетинає, тож драйвер треба явно перемкнути (frameLocator,switchTo().frame()). А от скрипт зі сторінки до DOM крос-оріджин фрейма справді не дотягнеться — це вже межа походження.
Підсумок
- Походження — це трійка «схема + хост + порт», і розбіжність хоч в одній складовій робить його іншим. Сайт — ширше поняття, і кукі рахують саме його.
- Політика одного походження обмежує читання, а не звернення. Записи й вбудовування зазвичай дозволені — звідси і
<img>/<script>без блокування, і CSRF як зворотний бік тієї самої дозволеності. - CORS — це дозвіл, який видає сервер, а застосовує браузер. Рішення «віддати скрипту дані чи ні» ухвалює браузер, тому клієнти без SOP тут нічого не доводять.
- Preflight — окремий
OPTIONSперед справжнім запитом, і в реальних API він летить майже завжди: JSON,Authorization,PUT/PATCH/DELETE. Успішним він вважається лише за ok-статусу й ніколи не йде з обліковими даними. - Облікові дані змінюють правила:
*перестає діяти, потрібне конкретне походження йAccess-Control-Allow-Credentials: true, аAuthorizationназивають поіменно завжди.
Можливі питання
- «Що таке origin і що в нього входить?» Чекають трійку «схема + хост + порт» і чітке «шлях і параметри не входять». Сильна відповідь дає приклад із різними портами на локалхості й згадує, що піддомен — інший хост.
- «Що саме забороняє same-origin policy?» Перевіряють, чи розуміє кандидат асиметрію. Слабка відповідь — «забороняє крос-оріджин запити»; сильна — «обмежує читання даних скриптом, а записи й вбудовування зазвичай дозволені», з прикладом
<img>і забрудненого . - «Чому в Postman працює, а в браузері CORS-помилка?» Найчастіше питання теми. Відповідь: CORS застосовує браузер, а API-клієнти SOP не реалізують; заголовки
Access-Control-*адресовані браузеру. - «Коли летить preflight і що це таке?» Дивляться на перелік тригерів: метод поза
GET/HEAD/POST, заголовок поза перелікомsafelisted,Content-Type: application/json. Сильна відповідь додає, щоOPTIONSмусить віддати ok-статус і йде без кукі. - «Чому
Access-Control-Allow-Origin: *не працює з кукі?» Перевіряють розуміння режиму облікових даних: приватні дані під конкретну сесію вимагають конкретного походження плюсAccess-Control-Allow-Credentials: true. - «CORS і CSRF — це одне й те саме?» Питання-пастка на межу тем. Відповідь: ні, CORS про читання відповіді, CSRF про небажану дію; правильний CORS від CSRF не рятує.
- «Заблокований CORS-запит змінив дані на сервері?» Дивляться, чи знає кандидат різницю простого запиту й preflight: без preflight сервер виконує запит повністю, тож так, міг змінити.
Джерела
Походження: схема, хост, порт — і нічого більше
- MDN — Same-origin policy — походження як кортеж «схема/хост/порт», піддомен як інший хост, шлях і запит поза межами походження.
- WHATWG URL Standard — обчислення походження за схемою, чотиричленний кортеж,
fileяк непрозоре походження, типові порти особливих схем, ненадійність публічних суфіксів як межі безпеки. - RFC 6454 — The Web Origin Concept — походження як домен захисту й доступ до обʼєктів лише в його межах.
- WHATWG HTML Standard — Origins, Sites, Sandboxing (§7.1.1, §7.1.1.1, §7.1.5) — непрозоре походження й серіалізація в
null, прапорець пісочниці, означення сайту та роль схеми в ньому. - IETF Internet-Draft — Cookies: HTTP State Management Mechanism (rfc6265bis-22) — реєстрований домен як межа сайту й розведення понять «походження» і «сайт».
- RFC 9110 — HTTP Semantics — типові порти схем:
80дляhttp,443дляhttps.
Що політика одного походження забороняє, а що ні
- MDN — Same-origin policy — мета політики, три класи дій,
X-Frame-Options, обмежений доступ до чужогоWindow,postMessage, CORS як легальне послаблення. - MDN — Allowing cross-origin use of images and canvas — забруднення canvas, перелік методів, що ламаються, і
SecurityError. - RFC 6265 — HTTP State Management Mechanism — привʼязка кукі до походження й недоступність чужих кукі скрипту.
- MDN — Web Storage API — ізоляція
localStorage/sessionStorageза походженням.
CORS: дозвіл видає сервер, застосовує браузер
- MDN — Cross-Origin Resource Sharing (CORS) — CORS як механізм заголовків,
Access-Control-Allow-Originі його значення,Vary: Origin,safelisted-заголовки відповіді таAccess-Control-Expose-Headers. - WHATWG Fetch Standard — нормативний алгоритм CORS-перевірки, підстава opt-in (витік даних інтранету, confused deputy), перелік із семи заголовків відповіді.
- MDN — Access-Control-Allow-Origin — звірка
Originзі списком дозволених, вимогаVary: Origin, чому значенняnullтреба уникати.
Простий запит і той, що вимагає попереднього дозволу
- MDN — Cross-Origin Resource Sharing (CORS) — «простий запит» як термін застарілої специфікації, умови без preflight,
OPTIONSяк preflight і його заголовки,Access-Control-Max-Age. - WHATWG Fetch Standard — перелік safelisted заголовків і
Range, ліміт 128 на значення, дозволені значенняContent-Type, вимога ok-статусу, відсутність облікових даних у preflight, типове значенняMax-Ageу 5 секунд.
Облікові дані: чому зірочка з ними не працює
- MDN — Cross-Origin Resource Sharing (CORS) — режим із обліковими даними, заборона
*разом із ними та вимогаAccess-Control-Allow-Credentials: true. - WHATWG Fetch Standard — режими облікових даних, нерозкриття
*у режимі з обліковими даними,Authorizationяк CORS non-wildcard request-header name і його зняття на крос-оріджин редиректі. - MDN — Using the Fetch API — склад облікових даних і три значення опції
credentialsіз типовимsame-origin. - MDN — Using HTTP cookies — значення
SameSiteі вимогаSecureдляNone.
CSRF: сусідня тема з іншим механізмом
- RFC 6454 — The Web Origin Concept — дозвіл надсилати запити в чуже походження як джерело CSRF-вразливостей.
- MDN — Same-origin policy — крос-оріджин записи як дозволений клас дій і CSRF як його зворотний бік.
- MDN — Cross-Origin Resource Sharing (CORS) — CORS як механізм доступу до відповіді, а не до дії.
- OWASP — CSRF Prevention Cheat Sheet — синхронізований токен, звірка
Origin/Refererі кастомний заголовок як заходи захисту. - OWASP WSTG — 4.6.5 Testing for Cross Site Request Forgery (WSTG-SESS-05) — чому саме лише вживання
POSTне усуває CSRF.
Інші межі: iframe і Shadow DOM
- MDN — Same-origin policy — обмежений доступ до вікна чужого походження й
postMessageяк штатний канал. - MDN — Using shadow DOM — shadow tree як інкапсуляція, невидимість вузлів для
querySelectorAll,closedяк індикація, а не механізм безпеки. - Playwright — Locators — пробивання відкритих shadow tree локаторами й межа для XPath.
- Playwright — Frames — перемикання контексту у фрейм.
- Selenium — Working with IFrames and frames — фрейм як інший документ і потреба перемкнути контекст.
Чому «у Postman працює» нічого не доводить
- MDN — Cross-Origin Resource Sharing (CORS) — заголовки
Access-Control-*як інструкція для браузера. - MDN — Same-origin policy — політика як властивість браузера, а не сервера.
- Playwright — class APIRequestContext — HTTP-клієнт поза браузерним контекстом.
- OWASP WSTG — 4.11.7 Testing Cross Origin Resource Sharing — обмеження застосовує клієнт,
Originпідробний поза браузером, віддзеркаленняOriginбез перевірки як дефект. - OWASP — HTTP Security Response Headers Cheat Sheet — заголовок не захищає, а послаблює захист SOP; перевіряють широту дозволу.
Що видно у вкладці Network і як це мокати
- Chrome DevTools — Network features reference — запис журналу лише при відкритих DevTools, Preserve log, колонки й вкладка Headers, власні колонки заголовків, синтаксис фільтра з
AND, санітизований HAR. - MDN — Using the Fetch API —
fetchне реджектиться на HTTP-помилках і реджектиться на мережевій. - WHATWG Fetch Standard — невдала CORS-перевірка як мережева помилка.
- Playwright — Mock APIs — підміна відповіді через
route/fulfill. - Playwright — Network — очікування конкретної відповіді за URL і методом.
Що таке походження (origin) і що в нього входить?
Це трійка «схема + хост + порт», якою браузер відрізняє «своє» від «чужого». Дві адреси вважаються одним лише за повного збігу всіх трьох складових — розбіжність хоч в одній робить їх різними. Шлях, рядок запиту й фрагмент до трійки не належать взагалі, тому https://shop.io/cart і https://shop.io/checkout?step=2 — це одне походження, а http://shop.io/cart — уже інше, бо схема інша. Для браузера це не деталь адресації, а межа безпеки: у RFC 6454 походження описане як домен захисту, всередині якого дозволено діставатися до DOM і браузерних API. Практично це означає, що відповідь на питання «чому мій запит крос-оріджин» завжди зводиться до порівняння трьох значень, а не до відчуття «це ж той самий сайт».
Фронт на http://localhost:3000, мок API на http://localhost:4000. Це одне походження?
Ні, і це найчастіша пастка локального стенду. Хост однаковий, схема однакова, але порт входить у трійку — отже це два різні походження з усіма правилами, включно з preflight і заголовками дозволу. Плутанину додає те, що частину портів у адресі не видно: особливі схеми мають типовий порт, який у серіалізації опускають (80 для http, 443 для https), тому https://site.com і https://site.com:443 — таки одне походження, а https://site.com:8443 — ні. Той самий рівень уважності потрібен до хоста: app.example.com і api.example.com збігаються доменом другого рівня, але хост порівнюють цілком, тож піддомен — це інший хост і інше походження. Наслідок для тестів прозорий: значна частина «на моїй машині працює, у CI падає» — це просто різні origin між середовищами, і звірка адрес дешевша за будь-який дебаг.
Чим походження відрізняється від сайту?
Це два різні критерії, і плутати їх дорого, бо різні механізми спираються на різні. Сайт — вужчий набір ознак: схема плюс хост, згорнутий до ; порт у сайтових перевірках не враховується взагалі. Звідси головна асиметрія: app.example.com і api.example.com — різні походження, але один сайт, тобто те, що вважає чужиною, кукі вважають своїм. Дзеркально http://example.com і https://example.com — це не той самий сайт, бо схема в . Практичний наслідок трапляється щодня: кука спокійно їде на піддомен, а fetch на нього ж блокується, і жодного протиріччя тут немає — просто працюють два різні поняття. Варто памʼятати й застереження самої специфікації: і реєстрований домен не є надійною межею безпеки, тож перевірки краще будувати на походженні.
Що саме забороняє політика одного походження (same-origin policy)?
Не крос-оріджин запити, як часто відповідають, а читання їхнього результату скриптом. Політика ділить дії на три класи: записи (перехід за посиланням, редирект, надсилання форми) зазвичай дозволені, вбудовування чужого ресурсу (<script src>, <link rel="stylesheet">, <img>, <iframe>) теж зазвичай дозволене, а от читання — доступ до тіла відповіді або до DOM чужого документа — за замовчуванням ні. Це вбудована поведінка браузера, а не заголовок і не налаштування: вона діє завжди, і вимкнути її з боку сторінки неможливо. Мета в неї цілком конкретна — щоб відкрита в сусідній вкладці шкідлива сторінка не вичитала пошту, банк чи інтранет, у яких користувач авторизований. Саме на цій асиметрії тримається половина теми: <img> з чужого домену не блокується, бо це вбудовування; CSRF існує, бо запис дозволений; CORS потрібен, бо заборонене саме читання.
Чому картинка з чужого домену малюється, а зчитати її з <canvas> не вдається?
Бо намалювати — це вбудовування, а витягнути пікселі назад у скрипт — читання. Щойно в canvas потрапляють дані з іншого походження без CORS-дозволу, воно вважається забрудненим (tainted), і подальші спроби прочитати вміст падають із SecurityError. Перестає працювати все, чим із canvas дістають дані, — getImageData(), toDataURL(), toBlob(), captureStream(). Важлива частина відповіді — де це лагодиться: не в коді сторінки й не прапорцем у тесті, а на сервері, який віддає зображення, — він має повернути Access-Control-Allow-Origin. Для QA це готовий сценарій перевірки функцій на кшталт «зберегти скріншот canvas» чи «обрізати аватар»: варто окремо переконатися, що картинки вантажаться з походження, яке видало дозвіл.
Що таке CORS і хто ухвалює рішення — сервер чи браузер?
CORS (Cross-Origin Resource Sharing) — це набір HTTP-заголовків, якими сервер оголошує, з яких чужих походжень браузеру дозволено вантажити його ресурси й віддавати скрипту відповіді. Ролі розподілені так: дозвіл видає сервер, а застосовує його браузер. Механіка мінімальна — браузер сам додає до крос-оріджин запиту заголовок Origin, сервер у відповіді називає дозволене походження в Access-Control-Allow-Origin, браузер звіряє одне з одним і лише за збігу пускає відповідь у код. Заголовка немає або значення не те — тіло є, але скрипт його не побачить. Механізм навмисно зроблено opt-in: без цього текли б дані з-за фаєрвола, а разом з обліковими даними — ще й приватні, і специфікація прямо називає клас confused deputy. Тому правильна формула на співбесіді — «CORS не вимикає захист, а дає серверу керований спосіб його послабити».
Чому в Postman відповідь 200, а в браузері CORS-помилка?
Тому що перевіряє браузер, і тільки він. Postman, curl, HTTP-клієнт усередині бекенду й APIRequestContext у Playwright політики одного походження не реалізують: заголовки Access-Control-* для них — звичайні рядки, які нікого ні до чого не зобовʼязують. Отримали відповідь — віддали її повністю, тому зелений статус в API-клієнті ні підтверджує баг-репорт, ні спростовує його. Звідси два висновки, які варто вимовляти в суперечці вголос. Перший: CORS не заміняє , тож авторизацію на ньому будувати не можна — будь-хто поза браузером спокійно надішле який завгодно Origin. Другий: щоб відтворити чи спростувати CORS-помилку, потрібен реальний браузерний контекст, і жодна кількість прогонів у Postman цього не замінить.
Коли крос-оріджин запит летить одразу, а коли перед ним іде preflight?
Без попереднього дозволу запит іде лише тоді, коли виконано всі умови разом: метод — GET, HEAD або POST; вручну виставлені заголовки — лише з безпечного переліку (Accept, Accept-Language, Content-Language, Content-Type, Range); Content-Type — один із трьох (application/x-www-form-urlencoded, multipart/form-data, text/plain); значення заголовка не довше за 128 — інакше воно з переліку випадає. Достатньо порушити одну умову, щоб зʼявився preflight. Реальний API-запит виходить за ці рамки майже завжди: він шле JSON, тобто Content-Type: application/json, додає Authorization: Bearer … або взагалі використовує PUT, PATCH чи DELETE. Через це найпомітніший тригер — метод: GET летить сам, PATCH без дозволу не полетить ніколи. Сам термін «простий запит» варто вживати з обмовкою: він походить із застарілої редакції специфікації, чинна Fetch його не вживає, але в розмові він досі впізнаваний.
Що браузер питає в preflight і що на нього має відповісти сервер?
Preflight — це окремий запит методом OPTIONS, який браузер відправляє сам, до справжнього. У ньому він заявляє наміри: Access-Control-Request-Method називає метод, а Access-Control-Request-Headers — заголовки, які збирається надіслати. Сервер має відповісти переліком дозволеного — Access-Control-Allow-Origin, Access-Control-Allow-Methods, Access-Control-Allow-Headers — і бажано Access-Control-Max-Age, щоб браузер закешував результат перевірки. Три деталі кусаються найчастіше. Успішним preflight вважається лише ok-статус — будь-який із діапазону 200–299, на практиці 200 або 204: 401, 404 або редирект на OPTIONS означають, що справжній запит не полетить узагалі. Preflight ніколи не йде з обліковими даними, тому вішати авторизацію на маршрут OPTIONS — гарантований спосіб зламати всі крос-оріджин виклики. І без явного Max-Age кеш перевірки живе за замовчуванням лічені секунди (за Fetch — пʼять), тож OPTIONS повторюватиметься практично перед кожним запитом.
У Network на один логічний виклик два записи. Що це і чим воно загрожує тесту?
Це preflight і сам запит: спершу OPTIONS, потім POST (або PATCH, DELETE — той, заради якого все затівалося). Ніякого дефекту фронта тут немає, зайвий OPTIONS генерує браузер, а не код застосунку. Для автотесту наслідок практичний: одна адреса тепер покриває два різні звернення, тож і підміну, і очікування описують парою «метод + URL» основного запиту — інакше , написаний під POST, відповість тим самим тілом і на preflight. Побічна користь від такого розбору: якщо в мережі видно OPTIONS і немає основного запиту, діагноз ставиться миттєво — preflight не отримав дозволу, і далі треба дивитися його статус та заголовки відповіді.
Чому Access-Control-Allow-Origin: * не працює разом із кукі?
Бо зірочка означає «будь-хто», а приватні дані під конкретну сесію віддавати будь-кому не можна. Щойно запит іде з обліковими даними, вимоги до відповіді жорсткішають: Access-Control-Allow-Origin мусить називати конкретне походження, а не *, і додатково потрібен Access-Control-Allow-Credentials: true. У цьому режимі зірочка перестає бути «усі» й у решті заголовків теж — ані Allow-Headers, ані Allow-Methods, ані Expose-Headers її вже не розкривають. Класична збірка проблеми виглядає так: фронт додає credentials: 'include', сервер лишається зі своїм зручним *, і запит блокується, попри те що дозвіл нібито найширший із можливих. Варто памʼятати й початкову умову: за замовчуванням крос-оріджин fetch і XMLHttpRequest кукі та HTTP-автентифікацію не шлють узагалі — типовий режим same-origin, і include треба вмикати свідомо.
Тест читає X-Total-Count із крос-оріджин відповіді й отримує порожнечу. Де шукати причину?
Майже напевно не в бекенді. Навіть коли браузер віддав тіло відповіді, скрипту типово доступні лише сім заголовків: Cache-Control, Content-Language, Content-Length, Content-Type, Expires, Last-Modified, Pragma. Усе інше сервер має явно перелічити в Access-Control-Expose-Headers, інакше воно існує в мережі, але не існує для коду сторінки. Тому перша перевірка — не «чи шле сервер заголовок» (у DevTools він, найімовірніше, видно), а «чи є він у переліку відкритих». Тікет у такому разі адресується бекенду й формулюється точно: заголовок приходить, але не експонований, тому крос-оріджин клієнт його не бачить. І окремо для режиму з обліковими даними: Expose-Headers: * там не рятує, потрібен поіменний перелік.
Чому Access-Control-Allow-Headers: * пропускає все, крім Authorization?
Тому що Authorization виділений специфікацією в окрему категорію — заголовок запиту, який зірочкою не покривається. Правило безумовне: воно не залежить від режиму облікових даних, і якщо Authorization є серед заголовків запиту, а поіменно в Access-Control-Allow-Headers його немає, preflight завершується мережевою помилкою. Практично це найчастіша причина, чому «ми ж поставили зірочку, а Bearer-запити все одно падають»: лікується додаванням authorization до переліку явно. Поруч живе споріднений сюрприз: при редиректі на інше походження браузер знімає Authorization, тож запит, який пішов з токеном, доїжджає до цільового сервера вже без нього і отримує . У тесті це виглядає як раптовий 401 на робочому токені, і сервер до цього непричетний — винна крос-оріджин ланка в ланцюзі редиректів.
У консолі CORS-помилка. Чи міг цей запит уже змінити дані на сервері?
Так, і це найнебезпечніша ілюзія теми. Якщо запит ішов без preflight, він доходить до сервера й виконується повністю з усіма побічними ефектами — браузер лише не віддає відповідь скрипту. Блокується читання, а не звернення. Інша картина з preflight: там основний запит вирушає тільки після успішного OPTIONS, тож при провалі перевірки він справді не летить. Для автотестів звідси випливає конкретна дисципліна: не покладатися на «браузер же заблокував» як на гарантію чистоти стану — прибирати створені дані явно, інакше наступний прогін отримає дублікат або конфлікт. І в баг-репорті формулювання «запит не дійшов» треба замінити на факти: чи був OPTIONS, який статус він отримав і що прийшло на основний запит.
CORS і CSRF — це одне й те саме?
Ні, це різні рубежі, і тримаються вони на протилежних кінцях тієї самої асиметрії. CORS керує тим, чи побачить скрипт відповідь; міжсайтова підробка запиту (cross-site request forgery, CSRF) експлуатує те, що сам запит надіслати дозволено. відповідь не потрібна: досить, щоб чужа сторінка націлила форму на твоє походження, браузер сам підставив кукі — і дія сталася. Звідси головний висновок, за який дають бали: правильно налаштований CORS від CSRF не захищає, а фраза «CORS заблокував» не означає «сервер нічого не зробив». Захист від CSRF будується власними засобами — синхронізований токен, SameSite, звірка Origin чи Referer. І окремо варто зняти поширену ілюзію: сам по собі вибір методу POST вразливості не усуває, бо такі запити чудово автоматизуються з JavaScript.
Що не так із конфігурацією, яка віддає назад будь-який отриманий Origin?
Формально вона працює, фактично це . Оскільки Access-Control-Allow-Origin називає рівно одне походження, сервери справді читають вхідний Origin і повертають його у відповіді — але робити це можна лише після звірки зі списком дозволених. Віддзеркалення без перевірки означає, що дані відкриті будь-якому сайту, який спробує їх запитати з браузера жертви. Друга частина того самого дефекту — кеш: відповідь із віддзеркаленим походженням зобовʼязана нести Vary: Origin, інакше чи CDN віддасть чужому походженню відповідь, приготовану для іншого, і дозвіл витече разом із нею. Окремим рядком іде Access-Control-Allow-Origin: null — значення виглядає нешкідливо, але створити документ із походженням null може будь-хто, тож такого дозволу просто не має бути в конфігурації. У чеклист безпеки це лягає одним питанням: наскільки широкий дозвіл і чи звіряється він зі списком.
Що таке непрозоре походження (opaque origin) і коли браузер шле Origin: null?
Це окремий вид походження, а не «порожня трійка»: осмислена операція над ним одна — порівняння на рівність, а серіалізується воно рядком null. Так поводяться сторінки, відкриті як локальний файл через file:///, документи з data:-URL (вони не є same-origin навіть самі із собою) і вміст пісочного (sandboxed) <iframe> без allow-same-origin. не просто змінює Origin — він додатково блокує доступ до document.cookie й localStorage, тому «всередині фрейма кукі не ставляться» в такій конфігурації є очікуваною поведінкою, а не дефектом. Для QA це дві практичні речі. По-перше, тестова сторінка, відкрита подвійним кліком з диска, поводиться інакше, ніж та сама сторінка на стенді, — і половина «дивних» CORS-помилок під час ручної перевірки родом саме звідти. По-друге, побачивши Origin: null у запиті, не варто одразу лагодити сервер: спершу зʼясуй, звідки запит стартував.
Локатор не знаходить елемент, хоча в DevTools він видно. Коли винне походження, а коли ні?
Найчастіше не винне: перша гіпотеза — інший контекст, а не інше походження. iframe — це окремий документ зі своїм window, і селектор основного документа його межу не перетинає; лікується перемиканням контексту, яке драйвери роблять штатно (frameLocator у Playwright, switchTo().frame() у Selenium) — вони працюють на рівні браузера й до тіла фрейма дістаються навіть тоді, коли він чужого походження. Друга гіпотеза — Shadow DOM: це інкапсуляція компонента, а не безпека, і document.querySelectorAll() вузлів у не бачить; CSS- і текстові Playwright у відкрите shadow tree заходять самі, а XPath цю межу не перетинає; режим closed теж не є барʼєром безпеки, лише позначкою наміру. І тільки третій випадок справді про походження: скрипт із самої сторінки до DOM крос-оріджин фрейма не дотягнеться, тож перевірити сторонній платіжний віджет через page.evaluate не вийде — лишається postMessage або перевірка на рівні мережі.
Як CORS-збій виглядає в коді тесту й чому мок може його спровокувати?
У коді він виглядає не як відповідь із поганим статусом, а як виняток: невдала CORS-перевірка за специфікацією є мережевою помилкою, а на мережевих помилках fetch саме реджектиться, тоді як 404 чи 500 спокійно доїжджають до then як звичайні відповіді. Тому обробник, написаний під «перевірити response.ok», такого збою не помітить взагалі — впаде вся операція. Друга частина сюжету стосується моків: підмінюючи крос-оріджин відповідь, треба окремо обробити OPTIONS і повернути на нього потрібні Access-Control-*, інакше preflight не отримає дозволу й до підміненого маршруту справа не дійде — тест упаде на порожньому місці, і виглядатиме це як «мок не спрацював». Симптом упізнаваний: у трасі є OPTIONS без пари. І дзеркальна межа, про яку варто памʼятати: APIRequestContext готує стан через API повз усі CORS-налаштування, але саме тому браузерну CORS-помилку ним не відтвориш — для неї потрібна сторінка в реальному браузері.
Три кейси, у яких CORS з «якоїсь помилки в консолі» перетворюється на конкретну дію: розсуд суперечки «фронт чи бекенд» за трьома фактами з мережі, API, який не забуває про preflight, і різниця середовищ, через яку та сама зелена локально й червона в CI.
Кейс 1. «Винен бекенд» проти «у мене все віддається»: розсуд за трьома фактами
Суперечка на дейлі не розвʼязується аргументом «у Postman 200» — цей клієнт не реалізує взагалі. Розвʼязують її три факти, які знімаються з вкладки Network за пару хвилин. Панель відкривають до відтворення проблеми (журнал пишеться лише поки DevTools відкриті) і вмикають Preserve log, якщо в сценарії є логін із редиректом.
| Що знімаємо | Де дивитися | Навіщо |
|---|---|---|
Чи був OPTIONS і з яким статусом | фільтр method:OPTIONS у Network | відсікає «preflight не пройшов» від «дозволу немає на основному запиті» |
Значення Origin у запиті | вкладка Headers, Request Headers | показує, з якого походження реально стартував запит — часто воно не те, що очікували |
Які Access-Control-* повернулися | вкладка Headers, Response Headers | видно, чого саме бракує; заголовок можна винести окремою колонкою через Manage Header Columns |
Далі три факти складаються в діагноз майже механічно.
| Симптом | Діагноз | Куди йде тікет |
|---|---|---|
OPTIONS є, статус 401 або 404 | на маршруті OPTIONS висить авторизація або його немає — preflight провалено, основний запит не полетів | бекенд: preflight мусить віддавати ok-статус і не вимагати облікових даних |
OPTIONS пройшов, у відповіді немає Access-Control-Allow-Origin | дозволу на основну відповідь немає | бекенд: додати походження фронта до списку дозволених |
Access-Control-Allow-Origin: *, а запит іде з кукі | зірочка з обліковими даними недійсна | бекенд: конкретне походження плюс Access-Control-Allow-Credentials: true |
Блокується Bearer-запит, хоча стоїть Access-Control-Allow-Headers: * | Authorization зірочкою не покривається | бекенд: перелічити authorization поіменно |
| Тіло прийшло, а кастомного заголовка в коді немає | заголовок не експонований | бекенд: Access-Control-Expose-Headers |
Origin не той, що очікували (інший порт, інша схема, null) | проблема середовища або контексту сторінки, а не конфігурації CORS | фронт/девопс: адреси стенду, пісочний фрейм, сторінка з диска |
OPTIONS зʼявляється на кожен запит підряд | немає Access-Control-Max-Age | бекенд: додати кеш preflight (не баг, але зайвий трафік) |
Що з цього виносити в тікет замість фрази «CORS не працює»:
- Точний текст помилки з . Браузер сам пише, чого не вистачило — відсутнього заголовка, невідповідного чи провалу preflight. Переказ «щось про CORS» цю інформацію знищує.
- Значення
Originі повний перелікAccess-Control-*із відповіді. Це і є предмет суперечки; без них тікет розбирають ще один день. - Факт наявності
OPTIONSта його статус. Одразу відповідає на питання, чи дійшов справжній запит до сервера. - або санітизований HAR. З HAR перед прикріпленням прибирають
Cookie,Set-CookieйAuthorization.
Окремо варто зафіксувати, чого цей розбір не доводить: заблокований крос-оріджин запит без preflight сервер уже виконав, тож фраза «браузер заблокував, значить дані не змінилися» в тікеті недоречна.
Кейс 2. Playwright: мок, який не забуває про preflight
Найпоширеніша поломка мокання крос-оріджин API виглядає загадково: маршрут описаний правильно, а тест падає так, ніби мока немає. У трасі при цьому видно OPTIONS без пари — тобто preflight не отримав дозволу, і до основного запиту справа не дійшла. Лікується це обробкою OPTIONS окремою гілкою.
import { test, expect } from '@playwright/test';
const API = 'https://api.example.com';
const ORIGIN = 'http://localhost:3000';
// заголовки дозволу однакові для preflight і для основної відповіді
const cors = {
'Access-Control-Allow-Origin': ORIGIN,
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'authorization, content-type',
// без цього рядка кастомний заголовок існує в мережі, але не існує для коду сторінки
'Access-Control-Expose-Headers': 'X-Total-Count',
};
test('список замовлень показує лічильник із заголовка відповіді', async ({ page }) => {
await page.route(`${API}/orders`, async (route) => {
if (route.request().method() === 'OPTIONS') {
// preflight успішний лише з ok-статусом (200–299)
return route.fulfill({ status: 204, headers: cors });
}
return route.fulfill({
status: 200,
headers: { ...cors, 'Content-Type': 'application/json', 'X-Total-Count': '137' },
body: JSON.stringify([{ id: 1, total: 250 }]),
});
});
// звужуємо очікування до основного запиту: пара «метод + URL», а не сама адреса
const answered = page.waitForResponse(
(r) => r.url() === `${API}/orders` && r.request().method() === 'GET',
);
await page.goto('/orders');
const response = await answered;
expect(response.headers()['x-total-count']).toBe('137');
await expect(page.getByTestId('orders-total')).toHaveText('137');
});
Що дивитися і чому:
OPTIONSбез пари в трасі — це діагноз, а не шум. Він означає, що справжній запит не летів узагалі, тож шукати треба причину провалу preflight, а не помилку в тілі мока.- Одна адреса — два різні звернення. На один логічний виклик у мережі два записи, і перший із них
OPTIONS; тому і гілка в обробнику, і очікування описуються парою «метод + URL», а не самим URL. Expose-Headersпотрібен навіть у моці. Заголовок, якого підмінена відповідь не відкрила, лишається видимим у мережі, але для коду сторінки його немає — лічильник на екрані не оновиться, і це буде дефект мока, а не продукту.- Мок не є доказом того, що продакшн налаштований так само. Він перевіряє поведінку фронта за коректних заголовків; чи віддає їх реальний сервіс — окрема перевірка на реальному стенді.
Кейс 3. Локально зелено, у CI червоно: різниця в походженні
Сюїта проходить на машині розробника й падає в з мережевими помилками, які виглядають як недоступність бекенду. Перше, що варто звірити, — з якого походження фронт ходить в API в кожному середовищі: значна частина таких падінь не має до застосунку жодного стосунку.
| Середовище | Походження фронта | Походження API | Чи крос-оріджин |
|---|---|---|---|
| Локально через dev-проксі | http://localhost:3000 | http://localhost:3000/api | ні — той самий origin, CORS не задіяний |
| Локально без проксі | http://localhost:3000 | http://localhost:4000 | так: інший порт — інше походження |
| Стенд | https://app.stage.example.com | https://api.stage.example.com | так: інший хост, хоч і той самий сайт |
| Превʼю-деплой у CI | https://pr-482.preview.example.com | https://api.stage.example.com | так, і походження щоразу нове |
Найпідступніший рядок — останній: адреса превʼю змінюється з кожним PR, а список дозволених походжень на бекенді фіксований, тож перевірка не проходить у середовищі, якого раніше не існувало. Локальний dev- при цьому маскує проблему до самого CI: він робить запити тим самим походженням, і CORS у грі не бере участі взагалі.
Друга типова пара «працює/не працює» — режим облікових даних:
// той самий виклик у двох конфігураціях
await fetch(`${API}/me`); // кукі не поїдуть: типовий режим same-origin
await fetch(`${API}/me`, { credentials: 'include' }); // поїдуть, але сервер має назвати конкретне походження
Що дивитися і чому:
- Звіряй адреси, а не поведінку. Питання «чи однакові схема, хост і порт у фронта та API в цьому середовищі» відповідає на більшість «CI-only» падінь швидше за будь-який дебаг.
- Превʼю- потребують окремої домовленості. Динамічне походження в списку дозволених не зʼявиться саме; або воно додається шаблоном, або превʼю ходить у власний бекенд.
credentials: 'include'разом із*не працює за жодних умов. Якщо сюїта авторизується кукою, у відповіді має стояти конкретне походження йAccess-Control-Allow-Credentials: true— інакше сценарій розлогінюється посеред прогону.- Кука може не поїхати й з іншої причини.
SameSite— незалежний рубіж: у міжсайтовому контексті потрібнеNoneразом ізSecure, і жоден CORS-заголовок цього не замінить. - Зелений API-рівень нічого не каже про браузерний.
APIRequestContextходить повз політику одного походження, тому підготовка стану через нього працює завжди — а браузерну CORS-помилку відтворює лише сценарій у реальній сторінці.
Походження і сайт
- Називаю склад (origin) без підказки — схема, хост, порт — і памʼятаю, що шлях, параметри та фрагмент до нього не входять.
- Знаю різницю «інший порт» vs «той самий порт»:
:3000і:4000на одному хості — два походження, аhttps://site.comіhttps://site.com:443— одне, бо типовий порт опускають; піддомен при цьому завжди інший хост. - Можу пояснити різницю «походження vs сайт» і її наслідок: кукі рахують сайт, — походження, тому кука на піддомен їде, а
fetchблокується. - Розумію, що таке непрозоре (opaque) походження і звідки береться
Origin: null:file:///,data:-URL, пісочний<iframe>безallow-same-origin.
Що політика одного походження забороняє
- Відповідаю на «що саме забороняє SOP» не словами «крос-оріджин запити», а через три класи дій: записи й вбудовування зазвичай дозволені, читання — ні.
- Можу пояснити, чому чужий
<img>малюється, алеcanvasпісля нього стає забрудненим іgetImageData()падає зSecurityError— і що лагодять це на сервері зображень.
CORS: хто видає дозвіл і хто застосовує
- Кажу одним реченням, що заголовки
Access-Control-*адресовані браузеру, а не серверу: дозвіл видає сервер, застосовує браузер. - Знаю, чому «у Postman
200» не є аргументом: API-клієнти політики одного походження не реалізують і ці заголовки ігнорують. - Розумію, чому
Access-Control-Allow-Originназиває одне походження, чому віддзеркалювати вхіднийOriginбез звірки зі списком — і навіщо при віддзеркаленні потрібенVary: Origin. - Памʼятаю про сім заголовків відповіді, доступних скрипту за замовчуванням, і те, що кастомний (
X-Total-Count,X-Request-Id) відкривають черезAccess-Control-Expose-Headers.
Preflight
- Можу перелічити умови, за яких запит іде без preflight (метод
GET/HEAD/POST, лише safelisted заголовки,Content-Typeз трьох дозволених), і пояснити, чому реальний API-запит із них виходить майже завжди. - Памʼятаю три вимоги до
OPTIONS: ok-статус, відсутність облікових даних у самому preflight (звідси заборона вішати на нього авторизацію) іAccess-Control-Max-Age, без якого перевірка кешується лічені секунди. - Розумію, чому один логічний виклик дає два записи в Network, і привʼязую та очікування до методу й URL основного запиту, а не до самої адреси.
Облікові дані
- Знаю, що типовий режим
fetch—same-origin, тобто безcredentials: 'include'кукі й HTTP-автентифікація не їдуть, а поїде кука чи ні, вирішує ще йSameSite(None— тільки разом ізSecure). - Можу пояснити, чому
Access-Control-Allow-Origin: *не діє з обліковими даними і що потрібне замість нього: конкретне походження плюсAccess-Control-Allow-Credentials: true. - Тримаю в голові дві особливості
Authorization: зірочка вAccess-Control-Allow-Headersйого не покриває ніколи, а на крос-оріджин редиректі браузер його знімає — звідси раптовий401на робочому токені.
CORS vs CSRF
- Розводжу теми одним рядком: CORS про читання відповіді, CSRF про небажану дію, для якої відповідь не потрібна; правильний CORS від CSRF не рятує, і сам по собі метод
POSTзахистом не є.
Діагностика й автотести
- Розумію, що «CORS заблокував» не дорівнює «запит не дійшов»: без preflight сервер виконує його повністю, тому стан у тестах прибираю явно, а в коді збій приходить як відхилений
fetch, а не як відповідь із поганим статусом. - Памʼятаю, що мок крос-оріджин маршруту має окремо відповісти на
OPTIONSпотрібнимиAccess-Control-*, інакше основний запит не полетить і виглядатиме це як «мок не спрацював». - Знаю, з чого складається адекватний тікет замість фрази «CORS не працює»: дослівна помилка з , походження, з якого стартував запит, перелік заголовків дозволу у відповіді та статус
OPTIONS, якщо він був.
Квіз
Перед стартом
- Питань: 16
- Поріг «зараховано»: ≥70% правильних відповідей.
- Результат впливає на прогрес; завалені питання підуть у чергу повторення.
- Квіз впливає на компліт теми: тема стає «пройдено», лише коли прочитано теорію І квіз складено на ≥70%.
Питання
З яких трьох складових утворене походження (origin)?