Пошук уроків, статей та іншого контенту
Застосуєте Trusted Types і сувору CSP для блокування небезпечних DOM-ін’єкцій у складних застосунках.
DOM-ін’єкція виникає тоді, коли дані, контрольовані користувачем або зовнішнім джерелом, потрапляють у небезпечний DOM-sink без належної обробки.
Наприклад:
const name = new URLSearchParams(location.search).get("name");
message.innerHTML = `<p>Привіт, ${name}</p>`;Якщо URL містить HTML або JavaScript-код, браузер інтерпретує його як розмітку:
/?name=<img src=x onerror=alert(document.domain)>Звичайне екранування рядків допомагає лише за умови, що розробник не забув застосувати його у кожному місці. У великому застосунку це складно контролювати.
Trusted Types додають типову перевірку для небезпечних DOM-операцій. Замість звичайного рядка sink має отримувати спеціальне довірене значення, наприклад TrustedHTML.
element.innerHTML = trustedHtml;Тут trustedHtml має бути результатом заздалегідь визначеної політики Trusted Types, а не довільним рядком.
Trusted Types:
централізують правила перетворення даних;
зменшують кількість місць, де потрібно вручну шукати DOM-XSS;
змушують застосунок явно визначати межу довіри;
працюють разом із CSP;
не є самостійним санітизатором HTML.
До типових sinks належать:
element.innerHTML = value;
element.outerHTML = value;
element.insertAdjacentHTML("beforeend", value);
document.write(value);Також небезпечними можуть бути операції, пов’язані зі скриптами:
script.src = value;
script.text = value;
eval(value);Конкретний набір sink-ів залежить від браузера та контексту. Під час проєктування захисту варто керуватися документацією браузерів і перевіряти поведінку застосунку в цільових середовищах.
Без Trusted Types браузер приймає звичайний рядок:
container.innerHTML = userInput;Після ввімкнення відповідної CSP-політики такий код має завершитися помилкою, якщо userInput не є дозволеним довіреним типом.
Trusted Types не визначають, чи є HTML безпечним. Вони визначають, який код має право створювати значення, що допускаються небезпечними sink-ами.
Політика створюється через trustedTypes.createPolicy:
const policy = trustedTypes.createPolicy("app-html", {
createHTML(value) {
return sanitize(value);
}
});Після цього політика може створювати TrustedHTML:
const trustedHtml = policy.createHTML(input);
container.innerHTML = trustedHtml;Метод createHTML повинен бути вузьким і передбачуваним. Не слід створювати політику, яка просто повертає будь-який отриманий рядок:
// Небезпечна політика: вона лише створює ілюзію захисту.
const unsafePolicy = trustedTypes.createPolicy("unsafe", {
createHTML(value) {
return value;
}
});Така політика не захищає застосунок. Вона лише дозволяє передавати небезпечні рядки до sink-ів через додатковий шар.
Для примусового режиму сервер має надсилати заголовок:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types app-htmlКлючові директиви:
require-trusted-types-for 'script' — вимагає Trusted Types для підтримуваних небезпечних операцій;
trusted-types app-html — дозволяє створювати лише політику з іменем app-html;
script-src 'self' — обмежує джерела скриптів;
object-src 'none' — вимикає застарілі плагінові контексти;
base-uri 'none' — забороняє змінювати базову URL-адресу документа через <base>.
Назва політики має бути явною та обмеженою. Наприклад:
trusted-types app-html app-script-urlЯкщо в CSP вказано trusted-types app-html, код не повинен створювати політику з довільним ім’ям:
// Браузер відхилить створення політики, якщо її ім’я
// не входить до списку trusted-types у CSP.
trustedTypes.createPolicy("another-policy", {
createHTML(value) {
return value;
}
});У production CSP краще встановлювати HTTP-заголовком. Це дає серверу централізований контроль і не залежить від того, чи встиг браузер обробити HTML-метаелемент.
Якщо потрібно відобразити звичайний текст, Trusted Types не потрібні. Найкращий вибір — textContent:
message.textContent = userInput;Це простіше та безпечніше, ніж створювати HTML-політику для кожного текстового повідомлення.
Однак у складному застосунку може існувати спільний компонент, який має використовувати innerHTML. У такому разі політика може перетворювати будь-який вхід на HTML, що містить лише текст.
const htmlPolicy = trustedTypes.createPolicy("app-html", {
createHTML(value) {
const escaped = String(value)
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'");
return escaped;
}
});Тут введення не стає довільною HTML-розміткою. Символи, що мають спеціальне значення в HTML, перетворюються на сутності.
Нижче наведено приклад із зовнішнім скриптом. Для його повної роботи сторінка має обслуговуватися з CSP-заголовком, наведеним раніше.
index.html<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>Trusted Types</title>
</head>
<body>
<form id="message-form">
<label>
Повідомлення
<input id="message-input" name="message" autocomplete="off">
</label>
<button type="submit">Показати</button>
</form>
<section aria-live="polite">
<h2>Результат</h2>
<div id="message-output"></div>
</section>
<script src="/app.js"></script>
</body>
</html>app.js"use strict";
const output = document.querySelector("#message-output");
const form = document.querySelector("#message-form");
const input = document.querySelector("#message-input");
if (!output || !form || !input) {
throw new Error("Не знайдено необхідні елементи сторінки");
}
if (!window.trustedTypes) {
throw new Error("Цей застосунок потребує підтримки Trusted Types");
}
const htmlPolicy = trustedTypes.createPolicy("app-html", {
createHTML(value) {
const text = String(value);
// Перетворюємо введення на текст, а не на довільну HTML-розмітку.
return text
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'")
.replaceAll("\n", "<br>");
}
});
form.addEventListener("submit", (event) => {
event.preventDefault();
const userInput = input.value.trim();
// Якщо потрібен лише текст, безпечнішим вибором був би textContent.
// Тут навмисно показано використання TrustedHTML для HTML-sink.
const trustedMessage = htmlPolicy.createHTML(userInput);
output.innerHTML = trustedMessage;
});Якщо користувач введе:
<img src=x onerror=alert(1)>на сторінці відобразиться текст:
<img src=x onerror=alert(1)>Обробник події не запуститься, оскільки символи < і > були екрановані до передачі в innerHTML.
Іноді застосунку справді потрібно дозволити частину HTML: наприклад, strong, em, p або списки в редакторі тексту.
У такому разі простого екранування недостатньо, адже воно перетворить усю розмітку на текст. Потрібен санітайзер HTML, який:
розбирає HTML як структуру, а не як регулярний вираз;
видаляє небезпечні елементи, наприклад script;
перевіряє атрибути;
контролює URL у href і src;
не дозволяє небезпечні схеми на кшталт javascript:;
має підтримувати актуальні браузери та регулярно оновлюватися.
Типова архітектура має вигляд:
const htmlPolicy = trustedTypes.createPolicy("app-html", {
createHTML(value) {
// sanitizeHtml має бути перевіреним HTML-санітайзером
// із явно визначеним списком дозволених елементів і атрибутів.
return sanitizeHtml(value, {
allowedTags: ["p", "strong", "em", "ul", "ol", "li"],
allowedAttributes: []
});
}
});Тут sanitizeHtml — умовне позначення конкретного санітайзера, налаштованого відповідно до вимог застосунку. Не можна підміняти його простим набором регулярних виразів.
Важливий принцип: політика повинна бути єдиним місцем, де сирий HTML перетворюється на TrustedHTML.
Trusted Types охоплюють не лише HTML.
Якщо застосунок динамічно формує адресу скрипта, потрібно контролювати дозволені URL:
const scriptUrlPolicy = trustedTypes.createPolicy("app-script-url", {
createScriptURL(value) {
const url = new URL(value, document.baseURI);
if (url.origin !== window.location.origin) {
throw new Error("Заборонене джерело скрипта");
}
if (!url.pathname.startsWith("/assets/")) {
throw new Error("Скрипти дозволені лише з /assets/");
}
return url.href;
}
});
const script = document.createElement("script");
script.src = scriptUrlPolicy.createScriptURL("/assets/widget.js");
document.head.append(script);Такий підхід перевіряє:
походження URL;
дозволений шлях;
відсутність несподіваних зовнішніх джерел.
Не слід створювати TrustedScript, якщо проблему можна розв’язати без виконання динамічного коду. Динамічна генерація JavaScript зазвичай є ознакою небезпечної архітектури.
Перевага має бути за:
статичними модулями;
даними замість коду;
конфігурацією замість eval;
textContent замість HTML, якщо розмітка не потрібна.
У деяких великих або старих застосунках неможливо одразу виправити всі місця, де рядки передаються до DOM-sink-ів. Для поступової міграції можна визначити політику з назвою default:
trustedTypes.createPolicy("default", {
createHTML(value, sinkName) {
console.warn("Неявне перетворення HTML", {
value,
sinkName
});
return escapeAsHtml(value);
}
});Default policy може перехоплювати неявні перетворення в місцях, де код ще не був оновлений.
Однак це не повинно бути фінальним рішенням:
виклики можуть залишитися непоміченими;
політика може приховати архітектурні проблеми;
розробники можуть помилково сприйняти її як універсальний санітизаційний шар;
необхідно контролювати, які саме sinks її викликають.
Для production-коду краще поступово замінювати неявні перетворення на явні виклики конкретних політик або на безпечні API.
Зручно розділити міграцію на етапи.
Знайдіть у коді:
innerHTML;
outerHTML;
insertAdjacentHTML;
document.write;
динамічні script.src;
eval, Function і подібні конструкції;
HTML, який генерується шаблонізаторами або компонентами.
Також перевірте сторонні бібліотеки, які можуть використовувати ці API всередині.
Замість:
title.innerHTML = titleValue;використовуйте:
title.textContent = titleValue;Замість створення HTML-рядка для одного елемента часто можна використати:
const item = document.createElement("li");
item.textContent = itemValue;
list.append(item);Для кожного HTML-sink-а потрібно відповісти:
чи справді тут потрібна HTML-розмітка;
які елементи дозволені;
які атрибути дозволені;
чи можуть URL бути зовнішніми;
де саме виконується санітизація;
хто має право створювати Trusted Types.
Не варто використовувати одну універсальну політику для всього застосунку, якщо контексти відрізняються.
Наприклад:
app-text-html — лише екранований текст;
app-markdown-html — HTML після санітизації Markdown;
app-script-url — URL скриптів із дозволеного каталогу.
Кожна політика повинна мати чітке призначення.
Під час підготовки можна використовувати звітний режим CSP:
Content-Security-Policy-Report-Only: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types app-htmlУ цьому режимі порушення звітуються, але не блокуються. Це допомагає знайти старий код і залежності, які ще не сумісні з Trusted Types.
Після виправлення порушень потрібно перейти до звичайного:
Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'; require-trusted-types-for 'script'; trusted-types app-htmlПеревірте щонайменше такі сценарії:
текст із символами <, >, ", ' і &;
HTML із <script>;
обробники подій, наприклад onerror;
URL зі схемою javascript:;
зовнішні URL скриптів;
вставлення через insertAdjacentHTML;
код сторонніх віджетів;
серверний і клієнтський рендеринг;
усі підтримувані браузери.
Корисно додати автоматизовані тести для політик:
const trustedValue = htmlPolicy.createHTML("<strong>Текст</strong>");
if (!(trustedValue instanceof TrustedHTML)) {
throw new Error("Політика не створила TrustedHTML");
}Також перевіряйте, що небезпечні значення відхиляються:
const scriptUrlPolicy = trustedTypes.createPolicy("app-script-url", {
createScriptURL(value) {
const url = new URL(value, document.baseURI);
if (url.origin !== location.origin) {
throw new TypeError("Зовнішні скрипти заборонені");
}
return url.href;
}
});
try {
scriptUrlPolicy.createScriptURL("https://attacker.example/payload.js");
throw new Error("Тест не пройдено");
} catch (error) {
if (!(error instanceof TypeError)) {
throw error;
}
}Trusted Types лише контролюють, хто може створити довірене значення. Політика має містити реальне екранування або санітизацію.
const policy = trustedTypes.createPolicy("app-html", {
createHTML(value) {
return value;
}
});Це обходить сенс захисту. Політика повинна змінювати або перевіряти дані.
innerHTML для звичайного текстуЯкщо розмітка не потрібна, використовуйте:
element.textContent = value;Це простіше, швидше для аналізу та не створює HTML-контексту.
CSP є важливим шаром захисту, але не замінює безпечну обробку даних. Так само Trusted Types не замінюють авторизацію, валідацію на сервері та правильне кодування в інших контекстах.
Якщо CSP містить обмежений список імен, застосунок має використовувати лише ці імена. Вільне створення політик ускладнює аудит і може дозволити небезпечні обхідні шляхи.
HTML має складний синтаксис і багато контекстів. Регулярні вирази не є надійним способом видалення XSS із довільної HTML-розмітки.
Навіть якщо власний код не використовує innerHTML, це може робити залежність. Перевірте її сумісність із Trusted Types або ізолюйте її в окремому компоненті з контрольованою політикою.
Сувора CSP може зламати легітимні сценарії: inline-скрипти, динамічне завантаження модулів або сторонні віджети. Спочатку використовуйте звітний режим, зберіть порушення та підготуйте міграцію.
Trusted Types змушують небезпечні DOM-sinks приймати спеціальні довірені типи замість довільних рядків.
Для примусового режиму використовуйте CSP із require-trusted-types-for 'script'.
Через trusted-types обмежуйте список дозволених політик.
Trusted Types не санітизують значення автоматично: безпечні правила потрібно реалізувати в політиці.
Для тексту використовуйте textContent, а не innerHTML.
Для дозволеної HTML-розмітки застосовуйте надійний HTML-санітизатор із чіткою конфігурацією.
Для динамічних адрес скриптів використовуйте TrustedScriptURL і перевіряйте походження та шлях.
Міграцію великого застосунку зручно починати з інвентаризації sink-ів і звітного режиму CSP.
Trusted Types і сувора CSP є шарами захисту, але не скасовують безпечну архітектуру та контроль даних на сервері.