XSS атака: что это такое и как защитить свой код

Разработчик за ноутбуком разбирает код веб-страницы Безопасность

XSS атака, или межсайтовый скриптинг (cross-site scripting), происходит, когда сайт вставляет данные пользователя в страницу без экранирования и браузер выполняет их как код. Вредоносный скрипт злоумышленника запускается в браузере жертвы от имени уязвимого сайта и получает доступ ко всему, что видит страница.

XSS входит в число самых распространённых уязвимостей веб-приложений. Для примеров мы собрали учебный стенд на Python 3.14 без сторонних библиотек: уязвимая страница, та же страница с исправлением и проверка обеих в настоящем браузере (Microsoft Edge 154 в режиме без окна, headless). Стенд запускайте только у себя.

Как работает атака XSS

Уязвимость возникает везде, где данные пользователя попадают в разметку страницы: поле поиска, комментарий, имя в профиле, параметры URL. Схема атаки почти всегда одна:

  1. Злоумышленник находит точки внедрения: места, где сайт показывает введённые данные.
  2. Злоумышленник внедряет туда HTML с вредоносным кодом, например с обработчиком событий onerror.
  3. Сервер или JavaScript страницы вставляет эти данные в документ как есть.
  4. Браузер жертвы разбирает их как разметку и выполняет код с правами сайта.

В учебных примерах обычно пишут <script>alert(1)</script>. Окно alert ничего не крадёт, оно только показывает, что чужой скрипт выполнился.

Последствия атаки XSS

У внедрённого скрипта те же возможности, что у любого собственного JavaScript-кода страницы. Он позволяет злоумышленнику:

  • прочитать cookie без флага HttpOnly, токены сессии и другую информацию, которую хранит браузер пользователя;
  • через кражу токена сессии получить доступ к аккаунту пользователя;
  • выполнить действия от имени пользователя;
  • изменить контент страницы, например показать поддельную форму входа;
  • перенаправить пользователя на ресурс злоумышленника.

Скрипт действует от имени того, кто открыл страницу. Если это администратор, то и с правами администратора.

Виды атак XSS

Маркерная доска с тремя схемами движения данных между браузером и сервером

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

Вид Где вредоносный код Идёт через сервер Кого задевает
Отражённая (reflected) в запросе: параметры URL, поле формы да, сервер возвращает его в ответе того, кто перешёл по подготовленной ссылке
Хранимая (stored) в базе данных: комментарий, профиль, сообщение да, сохраняется на сервере каждого, кто открыл страницу
DOM-based в адресе или другом источнике на стороне клиента не обязательно того, кто перешёл по подготовленной ссылке

Отражённые XSS

Сервер берёт данные из запроса и сразу возвращает их в ответе: «Вы искали: …», текст ошибки, результаты поиска. Ссылку с внедрённым кодом злоумышленник присылает жертве письмом или размещает на другом сайте, тут работает социальная инженерия. Атака срабатывает за один запрос и ответ.

Хранимые XSS

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

XSS на основе DOM

Уязвимость целиком на стороне клиента: JavaScript страницы берёт данные, которые подготовил злоумышленник, из location, location.hash или document.URL и пишет их в innerHTML, document.write() или eval(). Часть адреса после # браузер на сервер не отправляет. В этом случае серверная защита ввод не видит вообще.

Читайте также:  SQL-инъекция: как работает и как защититься параметризованными запросами

Пример уязвимого кода на Python

Стенд использует только стандартную библиотеку и модуль wsgiref. Данные комментариев хранятся в списке вместо базы данных. Адреса три: /vuln выводит ввод как есть, /csp выводит так же, но с заголовком Content Security Policy, /fixed экранирует данные функцией escape() из модуля html и ставит cookie с флагом HttpOnly.

# app.py: учебный стенд, Python 3.14, только стандартная библиотека
from html import escape
from urllib.parse import parse_qs
from wsgiref.simple_server import make_server

COMMENTS = []  # вместо базы данных
CSP = "script-src 'self'; object-src 'none'; base-uri 'none'"


def page_vulnerable(q):
    items = "".join(f"<li>{c}</li>" for c in COMMENTS)
    return f"<p>Вы искали: {q}</p><ul>{items}</ul>"


def page_fixed(q):
    items = "".join(f"<li>{escape(c)}</li>" for c in COMMENTS)
    return f"<p>Вы искали: {escape(q)}</p><ul>{items}</ul>"


def app(environ, start_response):
    if environ["REQUEST_METHOD"] == "POST":  # новый комментарий
        size = int(environ.get("CONTENT_LENGTH") or 0)
        form = parse_qs(environ["wsgi.input"].read(size).decode("utf-8"))
        COMMENTS.append(form.get("text", [""])[0])
        start_response("303 See Other", [("Location", "/vuln")])
        return [b""]
    path = environ["PATH_INFO"]
    q = parse_qs(environ["QUERY_STRING"]).get("q", [""])[0]
    headers = [("Content-Type", "text/html; charset=utf-8")]
    if path == "/vuln":
        body = page_vulnerable(q)
    elif path == "/csp":
        body = page_vulnerable(q)
        headers.append(("Content-Security-Policy", CSP))
    elif path == "/fixed":
        body = page_fixed(q)
        headers.append(("Content-Security-Policy", CSP))
        headers.append(("Set-Cookie", "session=abc123; HttpOnly; SameSite=Lax; Path=/"))
    else:
        start_response("404 Not Found", [("Content-Type", "text/plain")])
        return [b"not found"]
    start_response("200 OK", headers)
    return [body.encode("utf-8")]


if __name__ == "__main__":
    make_server("127.0.0.1", 8765, app).serve_forever()

Уязвимость сидит в двух f-строках функции page_vulnerable: переменные q и c попадают в HTML без обработки. Исправленная функция отличается только вызовом escape().

Второй файл вызывает приложение напрямую, без сети: сохраняет вредоносный комментарий (хранимая XSS) и запрашивает все три адреса с параметром q, в котором лежит img с обработчиком onerror (отражённая XSS).

# check.py: вызывает приложение напрямую, без сети
from io import BytesIO
from urllib.parse import quote, urlencode
from wsgiref.util import setup_testing_defaults

from app import app


def request(method, path, query="", body=""):
    data = body.encode("utf-8")
    environ = {"REQUEST_METHOD": method, "PATH_INFO": path, "QUERY_STRING": query,
               "CONTENT_LENGTH": str(len(data)), "wsgi.input": BytesIO(data)}
    setup_testing_defaults(environ)
    headers = {}

    def start_response(status, response_headers):
        headers.update(response_headers)

    return headers, b"".join(app(environ, start_response)).decode("utf-8")


# хранимая XSS: комментарий остаётся на сервере
request("POST", "/comment", body=urlencode({"text": "<script>alert(2)</script>"}))

# отражённая XSS: вредоносный код приходит в параметре q
query = "q=" + quote("<img src=x onerror=alert(1)>")
for path in ("/vuln", "/csp", "/fixed"):
    headers, page = request("GET", path, query)
    print(path, "CSP:", headers.get("Content-Security-Policy"))
    print(page)

Запуск: python check.py из папки, где лежат оба файла. Вывод:

/vuln CSP: None
<p>Вы искали: <img src=x onerror=alert(1)></p><ul><li><script>alert(2)</script></li></ul>
/csp CSP: script-src 'self'; object-src 'none'; base-uri 'none'
<p>Вы искали: <img src=x onerror=alert(1)></p><ul><li><script>alert(2)</script></li></ul>
/fixed CSP: script-src 'self'; object-src 'none'; base-uri 'none'
<p>Вы искали: &lt;img src=x onerror=alert(1)&gt;</p><ul><li>&lt;script&gt;alert(2)&lt;/script&gt;</li></ul>

Два окна браузера рядом: одна и та же страница до и после исправления

На /vuln и /csp приложение отдало теги как есть. На /fixed угловые скобки заменены на &lt; и &gt;, и браузер покажет их как обычный текст.

Что сделал браузер

Те же адреса мы открыли в Edge 154 при запущенном python app.py. На /vuln?q=<img src=x onerror=alert(1)> появилось окно с 1, комментариев ещё не было. Потом отправили тот же комментарий, что в check.py, и открыли /vuln?q=hello: даже без опасного ввода в адресе появилось окно с 2.

На /csp окон не было, а в консоли браузера появились две ошибки (начало сообщений, дальше браузер перечисляет директиву и способы разрешить скрипт):

Executing inline script violates the following Content Security Policy directive ...
Executing inline event handler violates the following Content Security Policy directive ...

Content Security Policy заблокировала и встроенный скрипт, и обработчик onerror, хотя разметка осталась уязвимой. На /fixed Edge показал ввод текстом, а document.cookie вернул пустую строку, хотя cookie session браузер сохранил: флаг HttpOnly закрывает её от JavaScript.

DOM-based XSS: innerHTML против textContent

Для работы третьего примера сервер не нужен: уязвимость живёт в самой странице. Файл ниже берёт имя из адреса после # и выводит его дважды: через innerHTML и через textContent.

<!doctype html>
<meta charset="utf-8">
<p id="unsafe"></p>
<p id="safe"></p>
<script>
  // имя берётся из адреса: dom.html#name=Аня
  const name = new URLSearchParams(window.location.hash.slice(1)).get("name") ?? "";

  // уязвимо: значение разбирается как HTML
  document.getElementById("unsafe").innerHTML = "Привет, " + name;

  // безопасно: значение остаётся текстом
  document.getElementById("safe").textContent = "Привет, " + name;
</script>

Что вышло в Edge 154 при разных адресах:

Последняя строка сбивает с толку: innerHTML не запускает вставленный скрипт, и кажется, что свойство безопасно. Обработчики событий у img и других элементов оно при этом выполняет. Для данных пользователя лучше использовать textContent, createTextNode() или insertAdjacentText(). Атрибуты задавайте через setAttribute() с фиксированным именем.

Крупный план экрана с размытым кодом JavaScript и панелью инструментов разработчика

Как защититься от атак XSS

Защита от XSS строится на выводе, а не на вводе. Одни и те же данные пользователя попадают в HTML, в атрибут, в URL, в JSON, и для каждого места свои правила экранирования. Фильтр, который вырезает слово script, пропустит img с onerror из стенда выше, а фильтр, который вырезает все <, испортит обычный ввод вроде 5 < 7.

Экранирование по контексту

В тексте между тегами меняют пять символов: &, <, >, ", '. Сделать это в Python можно функцией html.escape(). Значение атрибута всегда берите в кавычки, а quote=False не ставьте, иначе кавычка из ввода закроет атрибут. escape() не трогает javascript:alert(1), и такой href останется исполняемым, поэтому схему ссылки проверяют отдельно.

# contexts.py: атрибуты и ссылки
from html import escape
from urllib.parse import urlsplit

value = 'x" onmouseover="alert(1)'
print(escape(value, quote=False))  # кавычки остались
print(escape(value))               # кавычки заменены


def check_link(link):
    # пропускаем http, https и относительные ссылки
    return link if urlsplit(link).scheme in ("http", "https", "") else "#"


links = ("https://example.com", "/profile", "JavaScript:alert(1)",
         " javascript:alert(1)", "javascript&colon;alert(1)")
for link in links:
    print(repr(link), "->", escape(check_link(link)))
x" onmouseover="alert(1)
x&quot; onmouseover=&quot;alert(1)
'https://example.com' -> https://example.com
'/profile' -> /profile
'JavaScript:alert(1)' -> #
' javascript:alert(1)' -> #
'javascript&colon;alert(1)' -> javascript&amp;colon;alert(1)

Первую строку мы вставили в <input value="..."> и открыли в браузере: у поля появился атрибут onmouseover. Со второй у поля остались только id и value, а в поле стоит исходный текст с кавычками. urlsplit приводит схему к нижнему регистру и отбрасывает ведущий пробел, поэтому JavaScript: и javascript: не проходят. Последнюю ссылку проверка пропускает: двоеточия в ней нет, схемы тоже. Без escape() браузер превратит &colon; в двоеточие: в нашем прогоне у такой ссылки protocol был javascript:. После escape() она стала обычной относительной ссылкой.

Есть места, где экранирование для HTML не помогает: код внутри <script>, обработчики on*, <style>, HTML-комментарии, аргументы eval(), setTimeout() и setInterval(). Данные пользователя туда не вставляйте. Передайте данные в data-атрибут или отдельный JSON, а обработчик повесьте через addEventListener().

Автоэкранирование в шаблонизаторах и фреймворках

Ноутбук с открытым файлом шаблона страницы в редакторе кода

Если приложение использует шаблонизатор, он экранирует сам, когда это включено. В Jinja2 по умолчанию выключено:

# jinja_check.py: Jinja2 3.1
from jinja2 import Environment

tpl = "<p>{{ name }}</p>"
print(Environment().from_string(tpl).render(name="<b>Аня</b>"))
print(Environment(autoescape=True).from_string(tpl).render(name="<b>Аня</b>"))
print(Environment(autoescape=True).from_string("<p>{{ name|safe }}</p>").render(name="<b>Аня</b>"))
<p><b>Аня</b></p>
<p>&lt;b&gt;Аня&lt;/b&gt;</p>
<p><b>Аня</b></p>

Flask включает автоэкранирование для шаблонов .html, .htm, .xml, .xhtml, .svg и для render_template_string(). Отключают его фильтр |safe, объект Markup и блок {% autoescape false %}. Каждое такое место в проекте считайте потенциальной уязвимостью, пока не доказано, что данные туда приходят только от вас.

React экранирует данные в JSX, Vue экранирует интерполяцию {{ }} и привязку атрибутов. Уязвимости остаются там, где фреймворк просит явно отказаться от защиты: dangerouslySetInnerHTML в React, v-html во Vue, bypassSecurityTrust... в Angular. Ссылки от пользователя проверяйте на сервере функцией вроде check_link() выше: React 19 выдаёт ошибку на ссылки javascript: в href и src, но проверку под ваше приложение это не заменяет, а Vue безопасность адресов на фронтенде не гарантирует.

Санитизация, когда нужен HTML

Если пользователь пишет комментарии с разметкой, экранировать всё нельзя. Тогда HTML чистят готовой библиотекой по белому списку тегов, например DOMPurify в браузере: DOMPurify.sanitize(dirty). Библиотеку регулярно обновляйте: браузеры меняют поведение, и способы обхода санитайзеров находят постоянно. HTML после санитизации не меняйте, иначе защита теряет смысл.

Ноутбук с открытым редактором кода и вкладкой с настройками заголовков ответа сервера

Content Security Policy

CSP задаётся заголовком ответа и позволяет указать браузеру список источников для загрузки и запуска скриптов. Если в политике есть script-src или default-src и нет 'unsafe-inline', встроенные скрипты, обработчики on* и ссылки javascript: не выполняются. Первые два случая видно на /csp выше. Свои встроенные скрипты в такой политике разрешают через nonce или хеш, а 'unsafe-inline' не ставят: он отключает большую часть защиты. Для нового проекта используйте строгую политику с nonce, случайным значением, которое сервер генерирует заново на каждый ответ:

Content-Security-Policy: script-src 'nonce-{RANDOM}'; object-src 'none'; base-uri 'none'

Тогда выполняются только скрипты с тем же nonce. CSP работает как второй слой безопасности: экранирование она не заменяет.

Читайте также:  SQL-инъекция: как работает и как защититься параметризованными запросами

Флаг HttpOnly не позволяет читать cookie через document.cookie, и получить сессию простым скриптом злоумышленник уже не сможет. Запросы через fetch() браузер при этом отправит с этой cookie. Если ваш REST API принимает cookie сессии, внедрённый скрипт отправит туда запросы с правами пользователя, и флаг от этого не спасает. Ставьте его на cookie сессии и авторизации.

Как проверить сайт на уязвимости XSS

Проверяйте на XSS только свой сайт или стенд, не чужие.

  1. Введите в каждое поле формы и в каждый параметр URL безобидную разметку, например "'<b>xss-test</b>.
  2. Откройте страницу, где выводится ввод, и посмотрите исходный код (Ctrl+U в браузере). Для параметров после # и вывода через JavaScript исходный код не поможет: смотрите DOM во вкладке «Элементы» инструментов разработчика.
  3. Если скобки и кавычки заменены на сущности (&lt;, &gt;, &quot; или &#34;, &#x27; или &#39;), вывод экранирован. Если там тег <b> и текст на странице жирный, место уязвимо. Во вкладке «Элементы» признак другой: появился элемент b, значит, уязвимо; виден текст <b>xss-test</b>, значит, нет.
  4. Повторите для страниц, где данные появляются позже: профиль, список комментариев, письма и уведомления.

Разработчик просматривает исходный код страницы в браузере на втором мониторе

Ручная проверка находит не всё. Второй шаг: поиск по коду и разбор каждого места, где данные попадают в HTML в обход экранирования:

grep -rnF -e innerHTML -e outerHTML -e insertAdjacentHTML -e document.write -e dangerouslySetInnerHTML \
  -e v-html -e bypassSecurityTrust -e "|safe" -e "Markup(" -e "eval(" src/

На копии стенда эта команда нашла строку с innerHTML в DOM-примере. Найденное место не обязательно содержит уязвимость: если туда приходит только ваш собственный HTML, проблем нет. Если хоть часть пришла от пользователя, замените приёмник на textContent или добавьте санитизацию.

Частые вопросы об атаках XSS

Рабочее место с ноутбуком, на экране размытая схема запросов между браузером и сервером

Чем XSS отличается от SQL-инъекции?

Обе уязвимости появляются, когда ввод пользователя смешивается с кодом. При SQL-инъекции данные меняют запрос к базе данных на сервере, при XSS данные становятся JavaScript-кодом в браузере. Лечатся они по-разному: SQL параметризованными запросами, XSS экранированием при выводе и безопасными API.

Хватит ли фильтрации ввода или WAF?

Нет. Фильтры и WAF злоумышленники обходят, новые способы обхода появляются регулярно, а причину уязвимости, вывод без экранирования, они не устраняют. Проверка входных данных полезна для формата (email, число, дата), но межсайтовый скриптинг она не закрывает.

Нужно ли экранировать данные перед записью в базу?

Мы бы не советовали. Храните данные так, как их ввели, и экранируйте при выводе под конкретное место. Если экранировать при записи, в базе окажется &lt;b&gt;, и в JSON для мобильного приложения или в CSV-выгрузке пользователь увидит эти сущности вместо скобок.

Защищает ли CORS от XSS?

Нет, это разные механизмы. CORS решает, может ли скрипт с другого сайта читать ответы вашего API. При XSS вредоносный код выполняется внутри вашей же страницы, и для браузера его запросы приходят с вашего источника.

Безопасно ли приложение на React или Vue?

Безопаснее, чем сборка HTML склейкой строк: фреймворк экранирует данные сам, и в таких приложениях XSS встречается реже. Проверять на уязвимости нужно места из раздела про автоэкранирование.

Оцените статью
bestprogrammer.ru
Добавить комментарий