Skip to content

Security-заголовки challenge-страницы ​

English version: security-headers.md

Когда браузер просит у сервера страницу, сервер присылает не только HTML. Вместе с ним идёт короткий список заголовков ответа — это маленькие «правила», которые говорят браузеру, как обращаться со страницей. Вы их не видите, но браузер выполняет их строго.

Наша страница 3-D Secure (тот самый экран банка «введите одноразовый код») работает внутри iframe на странице оформления заказа. Iframe — это одна веб-страница, показанная внутри другой. Так как речь про деньги и коды входа, эта страница шлёт продуманный набор таких правил, чтобы защитить себя. Все они лежат в одном месте: apps/bank-sim/src/acs/lib.ts → securityHeaders(nonce).

Этот документ разбирает каждый заголовок простыми словами:

  • Что это такое (с житейской аналогией)
  • Зачем он нам здесь
  • От какой атаки спасает
  • Какие есть альтернативы
  • Чем отличается от похожих заголовков

Если запомнить только одно: каждый заголовок закрывает одну конкретную дверь. Атакующему достаточно одной открытой двери — поэтому мы закрываем их все.


Общая картина ​

mermaid
flowchart TD
    B["Браузер грузит<br/>challenge-страницу в iframe"]
    B --> C1["Content-Security-Policy<br/>→ блокирует clickjacking + чужие скрипты"]
    B --> C2["X-Frame-Options<br/>→ то же, но для старых браузеров"]
    B --> C3["X-Content-Type-Options<br/>→ блокирует трюки с MIME-sniffing"]
    B --> C4["Referrer-Policy<br/>→ не даёт утечь URL"]
    B --> C5["COOP / COEP / CORP<br/>→ изолируют нас от других сайтов"]
    B --> C6["Флаги Set-Cookie<br/>→ защищают cookie сессии"]

Ещё один образ — эшелонированная оборона (defense in depth). Атакующему нужно пройти подряд все запертые двери. Промахнулся хоть на одной — атака останавливается:

mermaid
flowchart LR
    A["😈 Атакующий"] --> D1["🚪 CSP<br/>рамка + скрипты"]
    D1 --> D2["🚪 nosniff"]
    D2 --> D3["🚪 Referrer<br/>Policy"]
    D3 --> D4["🚪 COOP/COEP/CORP"]
    D4 --> D5["🚪 Флаги cookie"]
    D5 --> G["💰 ваша сессия<br/>и OTP"]
    D1 -.блок.-> STOP["🛑"]
    D2 -.блок.-> STOP
    D3 -.блок.-> STOP
    D4 -.блок.-> STOP
    D5 -.блок.-> STOP

Каждый заголовок — отдельный замок. Ниже открываем каждый.


1. Content-Type: text/html; charset=utf-8 ​

Что это простыми словами. Наклейка на посылке: «это HTML-страница, написанная алфавитом UTF-8». Она говорит браузеру, что за штука к нему пришла и каким набором символов её читать.

Зачем он нам здесь. Чтобы браузер показал нашу страницу как настоящую веб-страницу (а не как голый текст или файл для скачивания), и чтобы буквы с акцентами / кириллица отображались правильно, а не «кракозябрами».

От чего спасает. Явно указанный charset убирает старый трюк, когда атакующий подсовывает символы в другой кодировке, чтобы проскочить мимо фильтров (XSS через кодировку). «utf-8 и никак иначе» убирает эту неоднозначность.

Альтернативы. Можно вообще не указывать charset и дать браузеру угадать — но угадывание это ровно то, чего мы хотим избежать. Явно указать — безопасный выбор.

Чем отличается от похожих. Content-Type говорит, что это за файл. Следующий заголовок, X-Content-Type-Options, велит браузеру доверять этой наклейке и не перепроверять её. Работают в паре.


2. Content-Security-Policy (CSP) ​

Самый мощный заголовок. Представьте его как личного охранника страницы со списком правил: «скрипты только отсюда, формы отправлять только туда, вставлять меня в рамку могут только эти сайты». Браузер обеспечивает выполнение списка. В нашем коде правила склеены через ; в один заголовок.

Мы используем пять правил (директив):

2a. frame-ancestors http://localhost:5173 ​

Простыми словами. «Показывать меня в своей рамке может только этот сайт». http://localhost:5173 — это наше собственное приложение оформления заказа (значение берётся из настройки PARENT_ORIGIN).

От чего спасает — clickjacking (подмена клика). Атакующий копирует нашу настоящую страницу в скрытую прозрачную рамку на своём злом сайте и обманом заставляет вас ввести одноразовый код или нажать «Подтвердить», пока вы думаете, что находитесь совсем в другом месте. С frame-ancestors любой чужой сайт, попытавшийся вставить страницу в рамку, получит пустой квадрат. Атака просто не срабатывает.

mermaid
flowchart LR
    OK["Наш чекаут<br/>localhost:5173"] -->|"разрешён → страница видна"| P1["✅ challenge-страница"]
    EVIL["evil-site.com<br/>(рамка атакующего)"] -->|"нет в списке → блок"| P2["🚫 пустой квадрат"]

Альтернативы. Старый способ — заголовок X-Frame-Options (см. #3). CSP frame-ancestors — современная замена: гибче (можно перечислить несколько сайтов) и реально уважается всеми актуальными браузерами.

2b. default-src 'none' ​

Простыми словами. «По умолчанию странице нельзя грузить ничего — ни скриптов, ни картинок, ни шрифтов». А потом мы открываем лишь то немногое, что реально нужно. Это подход «запретить всё, потом разрешить чуть-чуть», и он куда безопаснее, чем «разрешить всё, потом заблокировать пару плохих вещей».

От чего спасает. Если атакующему удастся вставить тег, который пытается подтянуть код или данные с его сервера, default-src 'none' это заблокирует — мы такого не разрешали.

Альтернативы. Можно по умолчанию разрешить 'self' (свой сайт). Тоже разумно, но 'none' строже и заставляет каждое исключение делать осознанно.

2c. style-src 'unsafe-inline' ​

Простыми словами. «Inline-<style>, написанный прямо внутри страницы, разрешён». Наша страница держит свой небольшой CSS прямо в HTML, поэтому мы это позволяем.

Почему не строже? Слово 'unsafe-inline' звучит страшно, и для скриптов это было бы опасно. Для стилей риск маленький. Более чистая альтернатива — вынести CSS в отдельный .css-файл и разрешить только 'self': строже ценой лишнего файла.

Чем отличается от script-src. Стилям — мягкое правило, скриптам — строгое, на основе nonce (ниже). Разница специально: вредный скрипт может украсть ваш код, вредный стиль — в основном нет.

2d. script-src 'nonce-...' ​

Простыми словами. Nonce — это случайный одноразовый пароль, который заново генерируется на каждую загрузку страницы. Наш единственный легитимный inline-<script> несёт этот пароль. Браузер запускает только те скрипты, что показывают подходящий пароль, и отклоняет все остальные.

От чего спасает — XSS (межсайтовый скриптинг). Это главное. Если атакующему удастся внедрить свой <script> в страницу, у него не будет сегодняшнего случайного пароля — и браузер откажется его запускать. Атака мертва сразу.

mermaid
flowchart TD
    N["Сегодняшний случайный nonce:<br/>напр. 'a1b2c3'"]
    S1["Наш скрипт<br/>nonce='a1b2c3'"] -->|"пароль совпал"| R1["✅ запускается"]
    S2["Внедрённый скрипт атакующего<br/>без nonce / неверный nonce"] -->|"пароля нет"| R2["🚫 отклонён"]
    N -.проверка.-> S1
    N -.проверка.-> S2

Альтернативы.

  • Hash (хэш) — вместо случайного пароля разрешить скрипт, чьё содержимое совпадает с известным «отпечатком». Хорошо для скриптов, которые не меняются.
  • Внешний файл + 'self' — вынести скрипт в .js и разрешить только свой домен. Тоже надёжно.
  • 'unsafe-inline' — разрешить любой inline-скрипт. Для скриптов так делать нельзя никогда — это убивает весь смысл.

Чем отличается от правила для стилей. Идея та же (контролировать, что выполняется), но скрипты держат на самом коротком поводке, потому что они опаснее всего.

2e. form-action 'self' ​

Простыми словами. «Любая форма на этой странице может отправляться только к нам». Так что даже если кто-то изменит страницу, введённый вами код нельзя будет отправить на чужой сервер.

От чего спасает. Утечка данных — подменённая форма, которая тихо шлёт ваш ввод куда-то ещё.

Альтернативы. Вместо 'self' перечислить конкретные разрешённые URL, если вы законно отправляете данные не в одно место. Мы шлём только себе — поэтому 'self' точен.


3. X-Frame-Options: ALLOW-FROM http://localhost:5173 ​

Простыми словами. Старый способ сказать «кто может вставить меня в рамку» — та же работа, что у frame-ancestors (#2a), но из более ранней эпохи веба.

Почему всё ещё шлём. Некоторые очень старые браузеры понимают X-Frame-Options, но не понимают CSP frame-ancestors. Отправляя оба, мы покрываем и старые, и новые браузеры.

Важная оговорка. Значение ALLOW-FROM фактически мертво — современные браузеры его полностью игнорируют и полагаются на CSP. То есть этот заголовок — вежливость по отношению к устаревшим браузерам, а реальную работу сегодня делает frame-ancestors.

Чем отличается от frame-ancestors. Цель та же (защита от clickjacking). frame-ancestors умеет перечислять много сайтов и уважается везде; X-Frame-Options допускает максимум один сайт и считается устаревшим. Классическая пара «новый заголовок заменяет старый».


4. X-Content-Type-Options: nosniff ​

Простыми словами. «Браузер, доверяй моей наклейке Content-Type и не пытайся угадать, что это за файл на самом деле». «Sniffing» — привычка браузера подглядывать в содержимое файла и переопределять заявленный тип.

От чего спасает — атаки через MIME-sniffing. Допустим, атакующий загружает файл, который притворяется безобидной картинкой, но на деле содержит скрипт. Без nosniff браузер может «услужливо» заметить скрипт и запустить его. С nosniff браузер верит нашей наклейке и никогда не примет картинку за код.

mermaid
flowchart LR
    F["Файл с меткой 'картинка'<br/>(внутри тайком скрипт)"]
    F -->|"без nosniff → браузер подглядел и запустил"| BAD["🚫 скрипт выполняется"]
    F -->|"nosniff → браузер верит метке"| GOOD["✅ остаётся безобидной картинкой"]

Альтернативы. Мягкой версии, которую стоило бы использовать, нет — либо шлёшь nosniff, либо оставляешь дверь открытой. Это простой выключатель, и «включено» — правильный вариант.

Чем отличается от Content-Type. Content-Type заявляет тип; nosniffзапрещает перепроверять это заявление. Один объявляет — другой обеспечивает.


5. Referrer-Policy: no-referrer ​

Простыми словами. Когда вы кликаете по ссылке или страница грузит другой ресурс, браузер обычно шепчет получателю: «кстати, я пришёл с этого URL». Это referrer. no-referrer велит браузеру молчать.

Зачем он нам здесь. URL нашей страницы может содержать чувствительные идентификаторы (id сессии или транзакции). Мы не хотим, чтобы они утекали на любой другой сервер, с которым общается страница.

От чего спасает. Утечка URL — приватная информация, спрятанная в адресе, тихо оседает в чужих логах сервера.

Альтернативы (от мягкого к строгому).

  • no-referrer-when-downgrade — старое поведение по умолчанию; сливает полный URL другим защищённым сайтам.
  • strict-origin — шлёт только имя сайта (например, https://bank.com), никогда не полный путь. Хорошая золотая середина.
  • no-referrer — не шлёт вообще ничего. Мы выбрали самый строгий вариант, потому что страница работает с деньгами.

Чем отличается от cross-origin заголовков ниже. Referrer-Policy управляет тем, какую информацию мы раскрываем, когда сами обращаемся наружу; COOP/COEP/CORP управляют тем, как другие страницы могут взаимодействовать с нами. Разное направление защиты.


6. Cross-Origin-Opener-Policy: same-origin (COOP) ​

Простыми словами. «Помести меня в отдельную комнату, подальше от любого другого сайта, который меня открыл». Обычно, если страница A открывает страницу B в новом окне/вкладке, между ними остаётся тонкая связь. COOP обрывает эту связь, когда с той стороны не мы.

От чего спасает. Вредная страница, открывшая нашу, больше не может копаться в нашем объекте окна или использовать его как ступеньку для межсайтовых атак.

Альтернативы. unsafe-none выключает это (старое поведение по умолчанию). same-origin — безопасный выбор.

Чем отличается от COEP/CORP. См. общее сравнение после #8.


7. Cross-Origin-Embedder-Policy: require-corp (COEP) ​

Простыми словами. «Всё, что я гружу со стороны, должно явно согласиться быть загруженным мной». Он отказывается подтягивать чужие ресурсы, которые на это не подписались.

От чего спасает. Вместе с COOP он переводит страницу в укреплённое, изолированное состояние. Это закрывает целое семейство хитрых атак на тайминги памяти (типа Spectre), когда один сайт пытается подсмотреть данные другого.

Альтернативы. unsafe-none выключает. require-corp — строгая, изолирующая настройка.


8. Cross-Origin-Resource-Policy: cross-origin (CORP) ​

Простыми словами. «Я разрешаю другому origin (а именно нашему приложению чекаута) встраивать этот ответ». Так как наша страница специально грузится внутри iframe другого сайта, мы обязаны сказать «да, межоригинное встраивание для меня допустимо».

От чего спасает (и почему тут стоит cross-origin). Обычно CORP защищает ресурс от встраивания чужаками. Но весь наш смысл — быть встроенными нашим же приложением, которое находится на другом origin, — поэтому мы намеренно открываемся на cross-origin. Если поставить same-origin, то заблокируется наш собственный iframe и поток сломается.

Альтернативы. same-origin (встраивать можем только мы) или same-site. Нам нужен cross-origin, потому что в этой схеме родитель и ребёнок — разные origin.

COOP vs COEP vs CORP — сравнение семейства ​

Звучат похоже, но охраняют разное:

ЗаголовокНа какой вопрос отвечаетВ одну строку
COOPКто делит со мной «комнату» браузера?Изолировать моё окно от других сайтов.
COEPЧто мне разрешено подтягивать?Грузить только чужое, что дало согласие.
CORPКому разрешено встраивать меня?Дать нашему приложению встроить страницу через origin.

COOP + COEP вместе включают «cross-origin isolation» (сильное состояние против Spectre); CORP — это согласие на уровне ресурса, которое делает встраивание рабочим.


Простыми словами. Cookie — это маленькая записка, которую браузер хранит и отдаёт обратно при каждом визите; здесь это id сессии challenge (acs_sid). Слова после него — это флаги безопасности:

  • HttpOnly — JavaScript на странице не может прочитать эту cookie. Даже если атакующий протащил скрипт, украсть им id сессии не выйдет.
  • Secure — cookie отправляется только по HTTPS (шифрование), никогда по голому HTTP. Её нельзя перехватить в сети.
  • SameSite=None — вот это хитрое. По умолчанию браузеры отказываются слать cookie странице, живущей внутри стороннего iframe (а это ровно наш случай). SameSite=None говорит «да, шли эту cookie даже в межсайтовом iframe». Работает только в паре с Secure.
  • Path=/ — cookie действует на весь сайт.

От чего спасает. HttpOnly блокирует кражу cookie через внедрённые скрипты; Secure блокирует кражу по сети; вместе они защищают сессию, которая доказывает, что «это тот же пользователь, что начал challenge».

Почему SameSite=None, а не что-то строже?

ЗначениеПоведениеПодходит нам?
StrictНикогда не шлётся из контекста другого сайтаНет — наш iframe не получит cookie
LaxШлётся только при переходе на верхнем уровнеНет — внутри iframe всё равно блок
NoneШлётся везде, включая межсайтовые iframeДа — тут это обязательно (с Secure)

Мы используем самый нестрогий SameSite намеренно, потому что весь поток зависит от того, чтобы cookie выжила внутри межсайтового iframe. Безопасность отыгрываем обратно через HttpOnly и Secure.

mermaid
flowchart TD
    subgraph frame["Наша challenge-страница внутри iframe чекаута (межсайтовый контекст)"]
      L["SameSite=Lax / Strict"] -->|"браузер здесь её выбрасывает"| X["🚫 нет cookie → сессия потеряна"]
      NN["SameSite=None; Secure"] -->|"браузер здесь её сохраняет"| OKC["✅ cookie шлётся → сессия работает"]
    end

Шпаргалка ​

ЗаголовокОт чего защищаетЧастая альтернатива
Content-Type + charsetXSS через кодировку, «кракозябры»дать браузеру угадать (хуже)
CSP frame-ancestorsclickjackingX-Frame-Options (устаревший)
CSP default-src 'none'загрузка чего угодно лишнегоdefault-src 'self'
CSP style-src(разрешает наш inline CSS)вынести CSS в файл + 'self'
CSP script-src 'nonce-…'XSS (внедрённые скрипты)hash или внешний файл + 'self'
CSP form-action 'self'утечка данных через формыявный список URL
X-Frame-Optionsclickjacking (старые браузеры)CSP frame-ancestors
X-Content-Type-Options: nosniffатаки через MIME-sniffing(нет — держать включённым)
Referrer-Policy: no-referrerутечка URL / секретовstrict-origin
COOP same-originподглядывание между окнамиunsafe-none (выкл.)
COEP require-corpутечки типа Spectreunsafe-none (выкл.)
CORP cross-origin(разрешает нашему приложению встроить нас)same-origin (заблокирует нас)
Cookie HttpOnly; Secure; SameSite=Noneкража cookie; блок cookie в iframeSameSite=Lax/Strict (сломает iframe)

Где это в коде ​

Всё это задаётся в одной функции: apps/bank-sim/src/acs/lib.ts → securityHeaders(nonce).

Хотите увидеть вживую? Откройте challenge-страницу в DevTools браузера → вкладка Network → кликните на запрос → Headers. Все правила выше будут в разделе Response Headers.

Apache-2.0 · Headless-движок, адаптеры провайдеров и запускаемые примеры.