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

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

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

5 питань

Перелік дозволених доменів у 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