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

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

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

4 питання

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