Към съдържанието
wannacode

XSS: чужд скрипт в твоята страница

Проблем

Търсене, което краде

Магазинът има търсачка. Страницата с резултатите показва какво е написал купувачът: „Резултати за: маратонки“. Сървърът слепва текста от търсенето в HTML:

function searchPage(query) {
return "<p>Резултати за: <b>" + query + "</b></p>";
}
console.log(searchPage("маратонки"));
console.log(searchPage("<img src=x onerror=\"steal(shopSession)\">"));
Изход
<p>Резултати за: <b>маратонки</b></p>
<p>Резултати за: <b><img src=x onerror="steal(shopSession)"></b></p>

Първият ред е обикновена страница. Във втория търсенето е <img src=x onerror="steal(shopSession)">, и то е стигнало до HTML-а цяло. Браузърът вижда таг за картинка, картинката x не се зарежда, и той пуска кода в onerror. Тук steal е измислена функция; в истинска атака кодът праща бисквитките или сесията на сървъра на атакуващия.

Атакуващият не пише в своята търсачка. Той праща на купувачите връзка към търсене в магазина с този текст в адреса. Който я отвори, пуска чуждия код в магазина, със своята сесия. Това е XSS (cross-site scripting): чужд скрипт в твоята страница.

Идеята

HTML е още един език

Това е инжекцията от предишния урок, в още един език. В HTML < започва таг, а " затваря стойност на атрибут. Кодът слепи текста на купувача в езика на браузъра, и знаците в текста станаха част от страницата.

Два вида се срещат най-често:

  • Отразен (reflected): текстът идва от адреса и се връща в отговора, като търсенето по-горе. Атакуващият трябва да накара някого да отвори връзката.
  • Запазен (stored): текстът е записан в базата, например отзив, и се показва на всеки, който отвори продукта. Атакуващият пише веднъж, а скриптът тръгва при всички.

Сървърът не е изпълнил нищо. Кодът тръгва в браузъра на купувача, с неговата сесия, като че ли е част от магазина.

Виж го

Пробвай сам

Напиши текст в търсачката на магазина, избери защита и виж какво става. Страницата е в изолиран iframe, без мрежа, и не вижда нищо от този сайт.

Пясъчник: злонамерен вход в страница на магазина

Защита

Пясъчникът е изолиран: няма мрежа и не вижда нищо от сайта. steal() тук е мнимият сървър на атакуващия; истинската атака праща данните с fetch.

Пробвай трите готови входа без защита, после с escape, после с CSP. Виж в „Какво стига до браузъра“ как изглежда HTML-ът в трите случая.

Идеята

Escape на изхода

Поправката е escape: преди да сложиш текст в HTML, сменяш петте знака, които HTML тълкува, с техните същности (entities). Браузърът показва същността като знак, но не я чете като част от страницата:

Знак Става Защо
& &amp; започва същност; сменя се първи
< &lt; започва таг
> &gt; затваря таг
" &quot; затваря стойност на атрибут
' &#39; затваря стойност в единични кавички
Node.js няма такава функция, затова я пишем:
function escapeHtml(text) {
return text
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}
function searchPage(query) {
return "<p>Резултати за: <b>" + escapeHtml(query) + "</b></p>";
}
console.log(searchPage("маратонки"));
console.log(searchPage("<img src=x onerror=\"steal(shopSession)\">"));
Изход
<p>Резултати за: <b>маратонки</b></p>
<p>Резултати за: <b>&lt;img src=x onerror=&quot;steal(shopSession)&quot;&gt;</b></p>

Сега търсенето стигна до браузъра като текст. Купувачът вижда точно това, което е написал, но таг няма.

Правилото е escape на изхода: при показване, на мястото, където текстът влиза в HTML. Не при приемане: тогава не знаеш къде ще отиде текстът (в HTML, в JSON, в имейл), и в базата остават &lt; вместо това, което купувачът е написал.

По-точно, за любопитните
  • В истински проект HTML-ът се прави с шаблони или с React, а те правят escape сами: {text} в JSX е безопасно. dangerouslySetInnerHTML изключва escape, и името му казва защо. В браузъра element.textContent = text е безопасно, а element.innerHTML = text чете текста като HTML.
  • Escape зависи от мястото. Петте знака стигат за текст между тагове и за стойност на атрибут в кавички. Атрибут без кавички, текст вътре в <script>, стил и адрес в href имат други правила. Най-простото: слагай текст отвън само между тагове и в атрибути с кавички.
  • Адресът в href. javascript:steal(shopSession) няма нито един от петте знака, а пак пуска код при клик. За адреси от потребителя трябва allow-list, както в урока „Недоверен вход“: адресът започва с https:// или http://, иначе не става връзка.

Пробвай

Намери полето

Страницата с отзиви показва заглавие, автор и текст. Кое поле стига до браузъра сурово?

Избери реда с грешката.

Пробвай

Какво ще отпечата

Какво ще отпечата escape за Tom & Jerry <3?

console.log(escapeHtml("Tom & Jerry <3"));
Отговор

Идеята

CSP: втората линия

В голям сайт има стотици места, където текст влиза в HTML. Достатъчно е да пропуснеш едно. Затова има втора защита: Content-Security-Policy (CSP). Това е заглавка в отговора, която казва на браузъра кои скриптове може да тръгнат:

Content-Security-Policy: script-src 'self'; object-src 'none'; base-uri 'none'

script-src 'self' значи: само скриптове от файлове на същия сайт. Скрипт, написан вътре в страницата (<script>…</script> или onerror="…"), не тръгва, а браузърът записва нарушението. Затова и собственият JavaScript на сайта трябва да е във файлове. Ако все пак ти трябва скрипт в страницата, CSP приема такъв с nonce: случайна стойност, нова при всеки отговор, която атакуващият не знае. Така работи защитата „CSP“ в пясъчника.

CSP не замества escape. Тя спира скрипта, но не и чужд HTML: фалшив формуляр „Влез отново“ или подменена цена не са скрипт. Escape е първата линия, CSP е мрежата под нея.

По-точно, за любопитните
  • Пробвай без да чупиш. Content-Security-Policy-Report-Only само съобщава какво би спряла политиката. Така се въвежда CSP в стар сайт: първо се гледат нарушенията, после се включва.
  • Бисквитката на сесията. Дори скриптът да тръгне, бисквитка с флаг HttpOnly не се вижда от JavaScript. Това идва в урока за сесиите.

Упражнение

Отзивът

Реши задачата в редактора си.

Отзив с автор. Напиши renderReview(author, text). Тя връща <article><h3>автор</h3><p>текст</p></article>, като и авторът, и текстът минават през escape. Пробвай с обикновен отзив и с автор, който се казва <script>steal(shopSession)</script>:

Очакваш
<article><h3>Ани</h3><p>Удобни са &lt;3</p></article>
<article><h3>&lt;script&gt;steal(shopSession)&lt;/script&gt;</h3><p>Супер!</p></article>

Още задачи

  • Връзка в отзива. Добави на отзива поле „сайт на автора“. Покажи го като <a href="…"> само ако адресът започва с https:// или http://; иначе го покажи като обикновен текст. Пробвай с javascript:steal(shopSession).
  • Двоен escape. Какво ще види купувачът, ако отзивът мине през escape веднъж при записа в базата и още веднъж при показването? Пробвай с <3.

Тест

Провери се

1. Търсачката само показва какво е написал купувачът. Защо <img src=x onerror="…"> в търсенето пуска код?
2. Escape превръща < в &lt;. Какво прави браузърът с &lt;?
3. Защо escape сменя & първо, преди < и >?
4. Отзивът има връзка <a href="…"> с адрес от купувача, минал през escape. Купувачът пише javascript:steal(shopSession). Спира ли го escape?
5. Страницата има CSP: script-src 'self'. Още ли трябва escape?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·