Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

Питання на співбесіді: Заголовки й захист у браузері

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

14 питань

Content Security Policy (CSP) - заголовок відповіді, яким сервер каже браузеру, звідки дозволено завантажувати й виконувати ресурси на цій сторінці: скрипти, стилі, зображення, шрифти, фрейми, запити fetch.

Content-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; img-src 'self' https://cdn.example.com; object-src 'none'; base-uri 'self'; frame-ancestors 'none'

Головна ціль - зменшити наслідки XSS. Навіть якщо нападник зміг вставити <script> у сторінку, браузер його не виконає: скрипт не має дозволеного джерела чи правильного nonce. CSP - другий рубіж: основний захист від XSS - екранування виводу, а CSP спрацьовує, коли екранування десь пропустили.

Основні директиви:

  • default-src - запасне правило для всіх типів ресурсів;
  • script-src, style-src, img-src, connect-src (куди можна робити fetch/WebSocket), font-src, frame-src;
  • object-src 'none' - заборона застарілих плагінів;
  • base-uri 'self' - захист від підміни <base>, через яку відносні посилання на скрипти ведуть на чужий домен;
  • form-action - куди можна відправляти форми;
  • frame-ancestors - хто може вбудовувати сторінку у фрейм (захист від clickjacking).

Що CSP блокує за замовчуванням, щойно задано script-src:

  • інлайнові скрипти (<script>...</script>, onclick="...", javascript: у посиланнях);
  • eval() і new Function() - без 'unsafe-eval'.

Чого CSP не дає:

  • не захищає від CSRF, SQL-ін'єкцій та логічних помилок;
  • 'unsafe-inline' у script-src майже повністю знецінює захист від XSS - тому інлайнові скрипти дозволяють через nonce чи хеш, а не через 'unsafe-inline'.

У Laravel вбудованого middleware для CSP немає: заголовок ставлять власним middleware або пакетом spatie/laravel-csp, а Vite::useCspNonce() додає nonce до тегів, які генерує @vite.

Докладніше в документації: MDN: Content Security Policy

Clickjacking - нападник вбудовує ваш сайт у невидимий <iframe> на своїй сторінці й розміщує його над власною приманкою: «Натисніть, щоб отримати приз». Користувач думає, що клікає по кнопці нападника, а насправді натискає «Видалити акаунт», «Підтвердити переказ» чи «Надати доступ» на вашому сайті - зі своєю активною сесією.

<!-- сторінка нападника -->
<iframe src="https://bank.example/transfer?to=attacker" style="opacity:0; position:absolute; top:0"></iframe>
<button>Отримати подарунок</button>

Захист - заборонити вбудовувати сторінки у фрейми на чужих сайтах.

1. CSP frame-ancestors - сучасний спосіб:

Content-Security-Policy: frame-ancestors 'none'
Content-Security-Policy: frame-ancestors 'self' https://partner.example
  • 'none' - сторінку не можна вбудувати ніде;
  • 'self' - лише на сторінках того самого походження;
  • можна перелічити довірені домени.

2. X-Frame-Options - старий заголовок, ще корисний для старих браузерів:

X-Frame-Options: DENY
X-Frame-Options: SAMEORIGIN

ALLOW-FROM застарів і сучасними браузерами не підтримується - для вибіркового дозволу лише frame-ancestors. Якщо задано обидва заголовки, браузери з підтримкою CSP використовують frame-ancestors.

Що варто знати:

  • frame-ancestors не працює в тегу <meta> - лише в HTTP-заголовку;
  • захищати потрібно всі сторінки з діями, а не лише головну: найчастіше атакують налаштування облікового запису, підтвердження платежів, сторінки OAuth-згоди;
  • JavaScript-захист на кшталт «якщо top !== self, перейти на верхній рівень» (frame busting) легко обходиться атрибутом sandbox у фреймі нападника - заголовки надійніші;
  • якщо сайт справді має вбудовуватися (віджети, платіжні форми), - вбудовувати лише окремі сторінки з явним переліком дозволених доменів, а не весь застосунок.

Додатковий бар'єр - cookie сесії з SameSite=Lax чи Strict: у фреймі на чужому сайті браузер не надішле таку cookie, і користувач виявиться неавтентифікованим.

Докладніше в документації: OWASP: захист від clickjacking

Браузери історично вміли «вгадувати» тип вмісту (MIME sniffing): якщо сервер віддав файл як text/plain, а всередині схоже на HTML чи JavaScript, браузер міг обробити його як HTML чи скрипт. Це допомагало погано налаштованим серверам, але відкривало вразливість.

Атака: користувач завантажує на ваш сайт файл, нібито зображення чи текст, але з вмістом-скриптом. Якщо потім цей файл підключають як <script src="/uploads/avatar.jpg"> (наприклад, через XSS, що дозволяє лише посилання на свій домен) чи відкривають напряму, браузер може «впізнати» в ньому HTML або JavaScript і виконати.

X-Content-Type-Options: nosniff вимикає вгадування:

X-Content-Type-Options: nosniff
  • скрипти виконуються лише з JavaScript-типом (text/javascript та подібні) - файл з image/jpeg у <script> буде заблоковано;
  • стилі застосовуються лише з text/css;
  • браузер довіряє заголовку Content-Type, а не вмісту.

Що з цього випливає для бекенду:

  • правильний Content-Type для всього, що віддає сервер: JSON - application/json, JavaScript - text/javascript. З nosniff неправильний тип ламає сайт одразу, тому помилки знаходяться швидко;
  • файли користувачів - з типом, визначеним сервером за вмістом, а не за назвою файлу від користувача. Небезпечні типи (HTML, SVG з можливими скриптами) - з Content-Disposition: attachment, щоб браузер завантажував їх, а не відкривав;
  • окремий домен для користувацького контенту (як githubusercontent.com) - найнадійніший захист: навіть якщо файл виконається, він не матиме доступу до cookie й даних основного сайту.

Як додати в Laravel: власний middleware для групи web або налаштування вебсервера (Nginx/Caddy) для всіх відповідей. Заголовок дешевий і майже не має побічних ефектів - його ставлять завжди, разом з Referrer-Policy, frame-ancestors і CSP.

Докладніше в документації: MDN: X-Content-Type-Options

Коли користувач переходить за посиланням чи сторінка завантажує сторонній ресурс (зображення, скрипт), браузер надсилає заголовок Referer - адресу сторінки, звідки прийшов запит.

Чим це небезпечно: URL часто містить те, що не повинно потрапляти на інші сайти:

  • токени - посилання для скидання пароля (/reset-password?token=...), підтвердження email, запрошення;
  • ідентифікатори й персональні дані - /orders/9921, /patients/42, пошукові запити з іменами;
  • внутрішні адреси адмінок і службових сторінок.

Якщо на сторінці скидання пароля є посилання на зовнішній сайт чи підключено сторонній скрипт аналітики, токен опиниться в їхніх логах.

Referrer-Policy керує, скільки інформації передавати:

Значення Що отримує інший сайт
no-referrer нічого
same-origin повний URL лише для свого походження, іншим - нічого
strict-origin лише походження (https://example.com/), і нічого при переході з HTTPS на HTTP
strict-origin-when-cross-origin повний URL для своїх, лише походження для чужих, нічого при пониженні до HTTP
unsafe-url завжди повний URL - небезпечно
Referrer-Policy: strict-origin-when-cross-origin

strict-origin-when-cross-origin - типове значення сучасних браузерів, якщо політику не задано. Але покладатися на значення за замовчуванням не варто - краще задати явно.

Для чутливих сторінок (скидання пароля, сторінки з токенами в URL) - no-referrer на рівні сторінки чи окремого посилання:

<meta name="referrer" content="no-referrer">
<a href="https://external.example" rel="noreferrer">...</a>

Кращий підхід - не тримати секрети в URL узагалі: токен з посилання одразу обміняти на сесію чи прибрати з адресного рядка після першого використання (history.replaceState чи редирект на чисту адресу).

Аналітика: деякі маркетингові інструменти покладаються на повний Referer. Компроміс - strict-origin-when-cross-origin, а не unsafe-url: для аналізу джерел трафіку зазвичай досить домену.

Докладніше в документації: MDN: Referrer-Policy

Відкритий редирект - сайт перенаправляє на адресу з параметра запиту, не перевіряючи її:

// вразливо
return redirect($request->query('return_to'));
https://bank.example/login?return_to=https://bank-example.attacker.io/login

Посилання веде на справжній домен банку - користувач і поштовий фільтр йому довіряють. Після входу (чи одразу) людину перекидає на фішингову копію, яка просить «повторно ввести пароль» чи дані картки.

Чим ще небезпечно:

  • крадіжка токенів в OAuth: якщо redirect_uri чи проміжний редирект відкритий, код авторизації чи токен може піти на сайт нападника;
  • обхід перевірок «дозволених доменів» в інших системах, що довіряють вашому домену;
  • SSRF, якщо сервер сам переходить за такими редиректами.

Як повертати користувача безпечно:

1. Лише відносні шляхи свого сайту:

$target = $request->query('return_to', '/');

$isLocalPath = str_starts_with($target, '/')
    && ! str_starts_with($target, '//')
    && ! str_contains($target, '\\');

return redirect($isLocalPath ? $target : '/');

//attacker.io - це протокол-відносний URL на чужий домен, а деякі браузери трактують /\attacker.io так само. Тому перевірка лише на початковий / недостатня.

2. Дозволений список - якщо потрібні переходи на інші домени (піддомени, партнери): порівнювати розібраний хост (parse_url) з переліком, а не шукати підрядок (str_contains($url, 'example.com') пропустить example.com.attacker.io).

3. Не передавати URL у параметрі взагалі: зберігати ціль у сесії. Саме так працює Laravel: middleware auth запам'ятовує запитану сторінку (url.intended), а після входу redirect()->intended('/dashboard') повертає туди - URL не проходить через параметр, який може змінити нападник.

4. Ідентифікатори замість адрес: ?next=orders з мапою на маршрути замість довільного URL.

Проміжна сторінка «Ви переходите на зовнішній сайт ...» - компроміс, якщо зовнішні переходи справді потрібні (наприклад, посилання в повідомленнях користувачів).

Докладніше в документації: OWASP: неперевірені редиректи

Перелік дозволених доменів у script-src (script-src 'self' https://cdn.example.com) легко обходиться: на дозволеному CDN знайдеться бібліотека, яку можна використати для виконання довільного коду, або JSONP-ендпойнт. Тому сучасна рекомендація - строга CSP на основі nonce чи хешів.

Nonce - випадкове значення, нове для кожної відповіді. Скрипт виконується, лише якщо має той самий nonce, що й заголовок:

Content-Security-Policy: script-src 'nonce-R4nd0mV4lue' 'strict-dynamic'; object-src 'none'; base-uri 'none'
<script nonce="R4nd0mV4lue" src="/build/app.js"></script>
<script nonce="R4nd0mV4lue">window.App = {...}</script>

Скрипт, вставлений через XSS, nonce не знає - і не виконається.

Хеш - для незмінних інлайнових скриптів: script-src 'sha256-...' з хешем вмісту скрипта. Зручно для статичних сторінок, де nonce генерувати нікому.

'strict-dynamic' - довіра поширюється на скрипти, які завантажив уже дозволений (з nonce) скрипт. Потрібно для сучасних збірок: застосунок динамічно підвантажує частини (import()), а знати їхні адреси заздалегідь неможливо. Браузери з підтримкою strict-dynamic ігнорують перелік доменів і 'self' у script-src.

Laravel і Vite. У middleware - згенерувати nonce, передати Vite і поставити заголовок:

public function handle(Request $request, Closure $next): Response
{
    $nonce = Vite::useCspNonce();   // генерує випадковий nonce, якщо не передано свій

    return $next($request)->withHeaders([
        'Content-Security-Policy' => "script-src 'nonce-{$nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'",
    ]);
}

Після цього @vite додає nonce до всіх згенерованих тегів <script> і <link>. Для власних інлайнових скриптів - nonce="{{ Vite::cspNonce() }}".

Пастки:

  • nonce має бути новим на кожну відповідь. Сторінка з nonce, закешована на CDN, віддає всім той самий nonce - захист зникає;
  • обробники в атрибутах (onclick="...") nonce не підтримують - їх переносять у скрипти;
  • Livewire і Alpine обчислюють вирази директив через new Function(), що потребує 'unsafe-eval'. Без нього - CSP-збірка Alpine (у Livewire 4 - опція csp_safe), яка обмежує складні вирази в директивах.

Докладніше в документації: Laravel: CSP nonce для Vite

Строга CSP на великому сайті майже напевно щось зламає: інлайнові скрипти в шаблонах, onclick в атрибутах, сторонні віджети, аналітика, чат підтримки, стилі в атрибутах style. Вмикати її «одразу на продакшені» - ризиковано.

Режим лише звітування:

Content-Security-Policy-Report-Only: script-src 'nonce-...' 'strict-dynamic'; object-src 'none'; base-uri 'none'; report-to csp-endpoint
Reporting-Endpoints: csp-endpoint="https://example.com/csp-reports"

Браузер нічого не блокує, але відправляє звіт про кожне порушення: яка директива, яка адреса заблокувалася б, на якій сторінці. Старіший механізм - директива report-uri з адресою - досі широко підтримується, тож часто задають обидві.

Процес впровадження:

  1. Інвентаризація: які скрипти, стилі, фрейми, fetch-ендпойнти реально використовуються (зокрема сторонніми сервісами).
  2. Політика в режимі Report-Only на продакшені.
  3. Аналіз звітів - збирати в окремий ендпойнт чи сервіс (Sentry, Report URI). Відфільтрувати шум: розширення браузера генерують багато порушень (chrome-extension://, moz-extension://), які до вашого коду не стосуються.
  4. Виправлення коду: інлайнові обробники - у файли скриптів, eval - прибрати, сторонні сервіси - додати до політики свідомо.
  5. Перемикання на блокування (Content-Security-Policy), а Report-Only лишити для наступної, суворішої версії політики.

Можна мати обидва заголовки одночасно: чинна політика блокує, а нова, суворіша, - лише звітує. Так політику посилюють поступово.

Що варто врахувати:

  • ендпойнт звітів - публічний: обмеження частоти й розміру, без збереження довільних даних без перевірки (звіти може відправити будь-хто);
  • звіти містять URL сторінок - у них можуть бути токени й персональні дані;
  • CSP у <meta> працює для більшості директив, але не підтримує frame-ancestors, report-uri/report-to і режим Report-Only - для впровадження потрібен HTTP-заголовок;
  • різні політики для різних частин сайту (адмінка з багатьма віджетами й публічні сторінки) - нормальна практика.

Мета - не «щоб не було звітів», а щоб у script-src не лишилося 'unsafe-inline' і довгих переліків доменів.

Докладніше в документації: MDN: Content Security Policy

Subresource Integrity (SRI) - атрибут integrity з криптографічним хешем файлу. Браузер завантажує скрипт чи стиль, обчислює хеш і відмовляється виконувати файл, якщо хеш не збігся.

<script
  src="https://cdn.jsdelivr.net/npm/alpinejs@3.14.9/dist/cdn.min.js"
  integrity="sha384-..."
  crossorigin="anonymous"
  defer
></script>

Від чого захищає: від підміни файлу на сторонньому сервері. Якщо CDN зламали чи пакет на ньому підмінили, на сторінку підключиться не шкідливий код, а нічого - браузер заблокує файл з невідповідним хешем. Без SRI скомпрометований CDN отримує повний доступ до всіх сторінок, що його використовують.

Умови роботи:

  • crossorigin="anonymous" обов'язковий для файлів з іншого походження - браузер має завантажити файл з CORS, щоб мати право перевірити вміст. Без нього сторонній файл із SRI не завантажиться;
  • хеш прив'язаний до конкретного вмісту: посилання має вести на фіксовану версію (@3.14.9), а не на «останню» (@3, latest) - інакше оновлення файлу на CDN зламає сайт;
  • алгоритми - sha256, sha384, sha512; можна вказати кілька хешів.

Коли SRI має сенс:

  • файли зі сторонніх CDN - основний сценарій;
  • власний CDN чи об'єктне сховище, якщо їх компрометація - реальний ризик;
  • для файлів зі свого ж походження користі небагато: якщо сервер зламано, нападник змінить і HTML з хешами.

Коли SRI не допоможе:

  • скрипти, що змінюються (аналітика, тег-менеджери, віджети чату, що оновлюються постачальником) - хеш постійно ламатиметься. Для них - лише обмеження через CSP і довіра до постачальника;
  • скрипти, які сам довірений скрипт підвантажує динамічно, - SRI їх не охоплює.

У Laravel з Vite SRI для власних зібраних файлів додається плагіном vite-plugin-manifest-sri: Laravel бачить хеші в маніфесті й додає integrity до тегів @vite автоматично.

Найнадійніша альтернатива - не підключати сторонні CDN узагалі: зібрати залежності в свій бандл. Тоді SRI для них не потрібен, а кількість сторонніх доменів у CSP менша.

Докладніше в документації: MDN: Subresource Integrity

Permissions-Policy (раніше Feature-Policy) дозволяє сайту заборонити чи обмежити доступ до потужних можливостей браузера - для власних сторінок і для вбудованих фреймів.

Permissions-Policy: camera=(), microphone=(), geolocation=(), payment=(self), fullscreen=(self "https://player.example")
  • () - вимкнено для всіх, включно з самим сайтом;
  • (self) - лише для власного походження;
  • (self "https://...") - для себе й переліченого походження;
  • * - для всіх (рідко потрібно).

Що можна контролювати: камера, мікрофон, геолокація, повноекранний режим, платежі (Payment Request), USB, Bluetooth, MIDI, автовідтворення, датчики, буфер обміну, демонстрація екрана тощо.

Навіщо, якщо браузер і так запитує дозвіл у користувача:

  • принцип найменших можливостей: якщо сайт ніколи не використовує камеру, то навіть після XSS шкідливий скрипт не зможе попросити доступ до неї від імені вашого домену - запит буде заблоковано без діалогу;
  • вбудовані фрейми: реклама, віджети, сторонні сервіси у <iframe> не отримають доступу до можливостей, які ви не делегували;
  • захист довіри до домену: діалог «example.com хоче використовувати камеру» - від вашого імені. Сторонній код у фреймі не повинен мати змоги його викликати.

Делегування фрейму - атрибутом allow:

<iframe src="https://video.example/embed/42" allow="fullscreen; autoplay"></iframe>

Фрейм отримає лише ті можливості, які дозволено і заголовком сторінки, і атрибутом allow.

Практичний підхід:

  • типовий сайт без медіа - вимкнути камеру, мікрофон, геолокацію, платежі, USB й інші непотрібні можливості;
  • там, де можливість справді потрібна (відеодзвінки, мапа з геолокацією), - дозволити лише self і, можливо, конкретним доменам;
  • перевіряти після додавання нових функцій: заголовок, що вимикає геолокацію, тихо зламає нову кнопку «Знайти поруч».

Обмеження: це захист «в глибину», а не основний: якщо сайт уже має XSS, проблема серйозніша за доступ до камери. Але заголовок дешевий і закриває цілий клас зловживань.

Докладніше в документації: MDN: Permissions-Policy

Політика того самого походження не дає JavaScript з чужого сайту читати відповіді вашого сервера. CORS - спосіб послабити це правило для довірених походжень. Помилки в налаштуванні CORS знімають захист для всіх.

Найнебезпечніша комбінація - віддзеркалення Origin разом з cookie:

# запит з https://attacker.io
Origin: https://attacker.io

# відповідь вразливого сервера
Access-Control-Allow-Origin: https://attacker.io
Access-Control-Allow-Credentials: true

Сервер повторює будь-яке походження з запиту й дозволяє передачу cookie. Сторінка нападника робить fetch('https://app.example/api/me', { credentials: 'include' }) з браузера жертви - і читає її дані, токени, персональну інформацію. Це фактично вимкнена політика того самого походження.

Типові помилки:

  • динамічне віддзеркалення Origin «щоб усе працювало»;
  • перевірка за підрядком чи «закінченням»: endsWith('example.com') пропустить attacker-example.com; str_contains($origin, 'example.com') - example.com.attacker.io. Регулярний вираз без екранування крапки (example.com = example + будь-який символ + com);
  • дозвіл походження null: його надсилають пісочні фрейми (sandbox), локальні файли, деякі редиректи - нападник легко його отримає;
  • довіра всім піддоменам (*.example.com) - XSS на будь-якому забутому піддомені стає доступом до основного API;
  • дозвіл http://-походжень для HTTPS-сайту - нападник у тій самій мережі може підмінити незахищену сторінку.

Access-Control-Allow-Origin: * з credentials браузер не прийме - тому розробники й починають віддзеркалювати Origin, щоб «обійти» обмеження. Сама * без cookie прийнятна для справді публічних даних.

Правильно:

  • явний перелік дозволених походжень, порівняння на точну рівність;
  • supports_credentials лише там, де справді потрібні cookie (власний SPA на іншому піддомені);
  • Vary: Origin, якщо відповідь залежить від походження, - інакше CDN закешує відповідь з дозволом для одного походження й віддасть іншим.

У Laravel CORS налаштовується в config/cors.php: allowed_origins - явні значення, allowed_origins_patterns - обережно з регулярними виразами, supports_credentials - лише за потреби.

Пам'ятайте: CORS не захищає сервер від запитів - він лише керує тим, чи може JavaScript прочитати відповідь. Простий POST із чужого сайту все одно дійде до сервера - для цього є CSRF-захист.

Докладніше в документації: PortSwigger: CORS

DOM XSS виникає повністю в браузері: JavaScript бере дані з контрольованого нападником джерела (location.hash, location.search, postMessage, відповідь API) і передає в небезпечний приймач (sink):

element.innerHTML = location.hash.slice(1);      // HTML-приймач
script.src = params.get('widget');               // URL скрипта
setTimeout(userInput);                            // рядок як код
document.write(...);  eval(...);  new Function(...);

Сервер такої вразливості не бачить: шкідливе значення взагалі може не доходити до нього (фрагмент #... не надсилається на сервер). Знайти всі такі місця у великому застосунку й бібліотеках - важко.

Trusted Types змінюють правила гри: з увімкненою політикою небезпечні приймачі відмовляються приймати звичайні рядки. Їм потрібні спеціальні типізовані об'єкти (TrustedHTML, TrustedScript, TrustedScriptURL), які можна створити лише через зареєстровані політики.

Увімкнення - через CSP:

Content-Security-Policy: require-trusted-types-for 'script'; trusted-types app-sanitizer

Політика - єдине місце, де рядок стає «довіреним»:

const sanitizer = trustedTypes.createPolicy('app-sanitizer', {
  createHTML: (input) => DOMPurify.sanitize(input),
});

element.innerHTML = sanitizer.createHTML(userHtml);   // дозволено
element.innerHTML = userHtml;                         // TypeError - заблоковано

Що це дає:

  • кількість місць для перевірки зменшується до кількох політик замість сотень присвоєнь innerHTML по всьому коду;
  • порушення помітні: спроба записати рядок у приймач кидає помилку (і може звітувати через CSP);
  • контроль над бібліотеками: директива trusted-types обмежує, які політики можна створювати, - стороння бібліотека не створить «свою» лазівку непомітно.

Впровадження:

  1. Content-Security-Policy-Report-Only: require-trusted-types-for 'script' - зібрати всі місця, де рядки потрапляють у приймачі;
  2. переписати їх на безпечні API (textContent, setAttribute для безпечних атрибутів) або пропустити через політику з санітизацією;
  3. увімкнути блокування.

Обмеження:

  • підтримка браузерами: Trusted Types давно є в Chromium, інші браузери додавали її пізніше - перед покладанням на них як на основний захист варто перевірити актуальну підтримку. Там, де Trusted Types немає, код має лишатися безпечним сам по собі;
  • фреймворки (React, Vue) мають власний захист від XSS у шаблонах; Trusted Types закривають «аварійні виходи» на кшталт dangerouslySetInnerHTML, v-html і прямої роботи з DOM.

Докладніше в документації: MDN: Trusted Types API

postMessage - легальний спосіб обміну даними між вікнами різних походжень: сторінка й вбудований <iframe> платіжної форми, вікно OAuth-входу й основний застосунок, віджет на чужому сайті й ваш сервер.

// відправник
iframe.contentWindow.postMessage({ type: 'resize', height: 640 }, 'https://widget.example');

// отримувач
window.addEventListener('message', (event) => {
  if (event.origin !== 'https://app.example') return;   // обов'язкова перевірка
  if (event.data?.type === 'resize') {
    iframe.style.height = `${Number(event.data.height)}px`;
  }
});

Дві обов'язкові перевірки:

1. При відправленні - конкретне targetOrigin, а не '*'.

popup.postMessage({ token }, '*');   // небезпечно

Якщо вікно встигли перенаправити на інший сайт (наприклад, фрейм завантажив сторінку нападника), повідомлення з токеном отримає чужий код. З конкретним targetOrigin браузер просто не доставить повідомлення не тому адресату.

2. При отриманні - перевірка event.origin. Будь-яка сторінка, що має посилання на ваше вікно (відкрила його через window.open чи вбудувала у фрейм), може надіслати йому повідомлення. Без перевірки обробник виконає команди нападника.

Типові помилки:

  • перевірка походження за підрядком: event.origin.includes('example.com') пропустить https://example.com.attacker.io. Лише точне порівняння з переліком;
  • довіра вмісту від довіреного походження: навіть повідомлення від свого фрейму - це дані, які могли прийти від користувача. Небезпечно передавати їх у innerHTML, eval, location.href (схема javascript:);
  • event.source без перевірки - відповідати варто лише на event.source, а не на «будь-яке вікно»;
  • чутливі дані в повідомленнях без потреби - кожне повідомлення може побачити розширення браузера чи інший слухач на тій самій сторінці;
  • слухачі, що лишилися після закриття фрейму, - накопичуються в SPA.

Структура повідомлень: тип і версія в кожному повідомленні, валідація схеми (як для API). Повідомлення - це міжсайтовий API, і до нього ті самі вимоги.

Альтернатива для однієї вкладки свого ж походження - BroadcastChannel, а для запитів між вікнами з відповіддю - MessageChannel з окремими портами: менше слухачів на глобальному window.

Докладніше в документації: MDN: Window.postMessage()

Після атак класу Spectre стало зрозуміло, що код на сторінці може прочитати дані з пам'яті процесу браузера через побічні канали (точні таймери, спільна пам'ять). Тому браузери ізолюють сайти в окремих процесах, а потужні API (SharedArrayBuffer, таймери високої точності) доступні лише сторінкам у режимі крос-доменної ізоляції.

Три заголовки:

COOP - Cross-Origin-Opener-Policy: розриває зв'язок між вашим вікном і вікнами інших походжень, відкритими через window.open чи посилання.

Cross-Origin-Opener-Policy: same-origin

Чужа сторінка, що відкрила ваш сайт, не отримає посилання на ваше вікно (window.opener = null) - і не зможе ним маніпулювати (атаки на кшталт XS-Leaks, підміна вкладки). same-origin-allow-popups - компроміс для сайтів, яким потрібні спливаючі вікна OAuth чи платежів.

COEP - Cross-Origin-Embedder-Policy: сторінка завантажує сторонні ресурси лише якщо вони явно дозволили вбудовування.

Cross-Origin-Embedder-Policy: require-corp

credentialless - м'якший варіант: сторонні ресурси без дозволу завантажуються, але без cookie.

CORP - Cross-Origin-Resource-Policy: ставить власник ресурсу - хто може його вбудовувати.

Cross-Origin-Resource-Policy: same-origin   # лише свій сайт
Cross-Origin-Resource-Policy: same-site
Cross-Origin-Resource-Policy: cross-origin  # будь-хто (для публічного CDN)

Крос-доменна ізоляція = COOP: same-origin + COEP: require-corp (чи credentialless). Тоді window.crossOriginIsolated === true, і доступні SharedArrayBuffer, точні таймери, performance.measureUserAgentSpecificMemory().

Кому це потрібно:

  • застосункам на WebAssembly з потоками (відео- й аудіоредактори, ігри, CAD), обчисленням у браузері;
  • CORP і COOP корисні всім як захист від витоків між сайтами, навіть без повної ізоляції.

Ціна COEP: кожен сторонній ресурс (зображення з CDN, шрифти, рекламні й аналітичні скрипти, фрейми) має надсилати CORP чи CORS-заголовки - інакше перестане завантажуватися. Для сайтів з великою кількістю стороннього вмісту повна ізоляція часто нереальна без credentialless.

Практичний мінімум для звичайного сайту: COOP: same-origin (чи same-origin-allow-popups) і CORP: same-origin для приватних ресурсів (файли користувачів, API) - дешевий захист від частини витоків між вкладками.

Докладніше в документації: MDN: crossOriginIsolated

Скрипт, підключений на сторінку через <script src>, має ті самі права, що й ваш код: бачить DOM, введення в поля форм (включно з паролями й картками), робить запити від імені користувача, читає localStorage. Аналітика, пікселі реклами, чат підтримки, A/B-тести - кожен сторонній скрипт - це довіра до постачальника й усього його ланцюжка постачання.

Реальні сценарії атак:

  • компрометація постачальника - скрипт аналітики чи віджета на CDN підмінили, і він крав дані платіжних форм на тисячах сайтів (атаки класу Magecart);
  • тег-менеджер (Google Tag Manager тощо) - маркетологи додають скрипти без рев'ю розробників. Обліковий запис тег-менеджера стає способом виконати довільний код на сайті;
  • прострочений домен: скрипт підключено з домену, який постачальник перестав продовжувати, - його реєструє нападник.

Як зменшити ризик:

1. Мінімізація: прибирати скрипти, якими ніхто не користується. Не підключати сторонній код на найчутливіших сторінках (оплата, вхід, налаштування безпеки).

2. CSP: явний перелік дозволених джерел, connect-src - щоб навіть скомпрометований скрипт не міг відправити дані на довільний домен.

3. SRI для незмінних сторонніх файлів із фіксованою версією.

4. Власна копія (self-hosting) - бібліотеки з npm у своєму бандлі замість CDN.

5. Ізоляція в <iframe> з sandbox - сторонній віджет працює в окремому походженні й не має доступу до вашої сторінки:

<iframe
  src="https://widget.example/embed"
  sandbox="allow-scripts allow-forms"
  referrerpolicy="no-referrer"
></iframe>

sandbox без значень забороняє все: скрипти, форми, спливаючі вікна, навігацію верхнього вікна. Дозволи додаються по одному.

Головна пастка sandbox: allow-scripts разом із allow-same-origin для вмісту з вашого ж походження знецінює пісочницю: скрипт усередині може дістатися до батьківської сторінки й прибрати атрибут sandbox у свого фрейму. Користувацький HTML варто віддавати з окремого домену (як usercontent.example) і в пісочниці.

6. Процес: перелік сторонніх скриптів з власником і метою, рев'ю перед додаванням у тег-менеджер, двофакторна автентифікація й мінімальні права для облікових записів тег-менеджера.

Регуляторний аспект: PCI DSS 4.0 вимагає інвентаризації й контролю цілісності всіх скриптів на сторінках оплати.

Докладніше в документації: OWASP: керування сторонніми JavaScript