XSS атака, или межсайтовый скриптинг (cross-site scripting), происходит, когда сайт вставляет данные пользователя в страницу без экранирования и браузер выполняет их как код. Вредоносный скрипт злоумышленника запускается в браузере жертвы от имени уязвимого сайта и получает доступ ко всему, что видит страница.
XSS входит в число самых распространённых уязвимостей веб-приложений. Для примеров мы собрали учебный стенд на Python 3.14 без сторонних библиотек: уязвимая страница, та же страница с исправлением и проверка обеих в настоящем браузере (Microsoft Edge 154 в режиме без окна, headless). Стенд запускайте только у себя.
- Как работает атака XSS
- Последствия атаки XSS
- Виды атак XSS
- Отражённые XSS
- Хранимые XSS
- XSS на основе DOM
- Пример уязвимого кода на Python
- Что сделал браузер
- DOM-based XSS: innerHTML против textContent
- Как защититься от атак XSS
- Экранирование по контексту
- Автоэкранирование в шаблонизаторах и фреймворках
- Санитизация, когда нужен HTML
- Content Security Policy
- Cookie с флагом HttpOnly
- Как проверить сайт на уязвимости XSS
- Частые вопросы об атаках XSS
- Чем XSS отличается от SQL-инъекции?
- Хватит ли фильтрации ввода или WAF?
- Нужно ли экранировать данные перед записью в базу?
- Защищает ли CORS от XSS?
- Безопасно ли приложение на React или Vue?
Как работает атака XSS
Уязвимость возникает везде, где данные пользователя попадают в разметку страницы: поле поиска, комментарий, имя в профиле, параметры URL. Схема атаки почти всегда одна:
- Злоумышленник находит точки внедрения: места, где сайт показывает введённые данные.
- Злоумышленник внедряет туда HTML с вредоносным кодом, например с обработчиком событий
onerror. - Сервер или JavaScript страницы вставляет эти данные в документ как есть.
- Браузер жертвы разбирает их как разметку и выполняет код с правами сайта.
В учебных примерах обычно пишут <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(). Часть адреса после # браузер на сервер не отправляет. В этом случае серверная защита ввод не видит вообще.
Пример уязвимого кода на 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>Вы искали: <img src=x onerror=alert(1)></p><ul><li><script>alert(2)</script></li></ul>

На /vuln и /csp приложение отдало теги как есть. На /fixed угловые скобки заменены на < и >, и браузер покажет их как обычный текст.
Что сделал браузер
Те же адреса мы открыли в 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 при разных адресах:
name после # | innerHTML | textContent | Окно alert |
|---|---|---|---|
Аня | Привет, Аня | Привет, Аня | нет |
<img src=x onerror=alert(3)> | элемент img, обработчик сработал | текст | да |
<script>alert(4)</script> | тег в DOM, скрипт не выполнился | текст | нет |
Последняя строка сбивает с толку: innerHTML не запускает вставленный скрипт, и кажется, что свойство безопасно. Обработчики событий у img и других элементов оно при этом выполняет. Для данных пользователя лучше использовать textContent, createTextNode() или insertAdjacentText(). Атрибуты задавайте через setAttribute() с фиксированным именем.

Как защититься от атак 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:alert(1)")
for link in links:
print(repr(link), "->", escape(check_link(link)))
x" onmouseover="alert(1)
x" onmouseover="alert(1)
'https://example.com' -> https://example.com
'/profile' -> /profile
'JavaScript:alert(1)' -> #
' javascript:alert(1)' -> #
'javascript:alert(1)' -> javascript&colon;alert(1)
Первую строку мы вставили в <input value="..."> и открыли в браузере: у поля появился атрибут onmouseover. Со второй у поля остались только id и value, а в поле стоит исходный текст с кавычками. urlsplit приводит схему к нижнему регистру и отбрасывает ведущий пробел, поэтому JavaScript: и javascript: не проходят. Последнюю ссылку проверка пропускает: двоеточия в ней нет, схемы тоже. Без escape() браузер превратит : в двоеточие: в нашем прогоне у такой ссылки 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><b>Аня</b></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 работает как второй слой безопасности: экранирование она не заменяет.
Cookie с флагом HttpOnly
Флаг HttpOnly не позволяет читать cookie через document.cookie, и получить сессию простым скриптом злоумышленник уже не сможет. Запросы через fetch() браузер при этом отправит с этой cookie. Если ваш REST API принимает cookie сессии, внедрённый скрипт отправит туда запросы с правами пользователя, и флаг от этого не спасает. Ставьте его на cookie сессии и авторизации.
Как проверить сайт на уязвимости XSS
Проверяйте на XSS только свой сайт или стенд, не чужие.
- Введите в каждое поле формы и в каждый параметр URL безобидную разметку, например
"'<b>xss-test</b>. - Откройте страницу, где выводится ввод, и посмотрите исходный код (Ctrl+U в браузере). Для параметров после
#и вывода через JavaScript исходный код не поможет: смотрите DOM во вкладке «Элементы» инструментов разработчика. - Если скобки и кавычки заменены на сущности (
<,>,"или",'или'), вывод экранирован. Если там тег<b>и текст на странице жирный, место уязвимо. Во вкладке «Элементы» признак другой: появился элементb, значит, уязвимо; виден текст<b>xss-test</b>, значит, нет. - Повторите для страниц, где данные появляются позже: профиль, список комментариев, письма и уведомления.

Ручная проверка находит не всё. Второй шаг: поиск по коду и разбор каждого места, где данные попадают в 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, число, дата), но межсайтовый скриптинг она не закрывает.
Нужно ли экранировать данные перед записью в базу?
Мы бы не советовали. Храните данные так, как их ввели, и экранируйте при выводе под конкретное место. Если экранировать при записи, в базе окажется <b>, и в JSON для мобильного приложения или в CSV-выгрузке пользователь увидит эти сущности вместо скобок.
Защищает ли CORS от XSS?
Нет, это разные механизмы. CORS решает, может ли скрипт с другого сайта читать ответы вашего API. При XSS вредоносный код выполняется внутри вашей же страницы, и для браузера его запросы приходят с вашего источника.
Безопасно ли приложение на React или Vue?
Безопаснее, чем сборка HTML склейкой строк: фреймворк экранирует данные сам, и в таких приложениях XSS встречается реже. Проверять на уязвимости нужно места из раздела про автоэкранирование.








