Пошук уроків, статей та іншого контенту
Розберемо приклади XSS і SQL Injection та застосуємо екранування, параметризовані запити й безпечну роботу з даними.
XSS та SQL Injection — це атаки, під час яких дані від користувача починають трактуватися як код або команда.
Причина в обох випадках схожа:
застосунок змішує дані з інструкціями;
вхідні дані не перевіряються або не екрануються;
розробник довіряє даним із запиту, форми, cookie чи бази даних.
Важливо розділяти:
валідацію — перевірку, чи відповідає значення очікуваному формату;
екранування — перетворення спеціальних символів перед вставкою в конкретний контекст;
параметризацію — передавання значень до SQL окремо від SQL-команди.
Cross-Site Scripting, або XSS, виникає, коли введені користувачем дані потрапляють у HTML або JavaScript і браузер виконує їх як код.
Наприклад, сервер формує HTML через конкатенацію рядків:
const username = req.query.username;
res.send(`<h1>Вітаємо, ${username}!</h1>`);Якщо запит має такий вигляд:
/?username=<script>alert('XSS')</script>браузер отримає HTML із тегом script і виконає JavaScript.
У реальній атаці замість alert можуть бути дії від імені користувача, викрадення даних зі сторінки або зміна її вмісту.
Шкідливе значення приходить у поточному HTTP-запиті та одразу повертається у відповідь.
Приклади джерел:
параметр URL;
значення пошуку;
повідомлення про помилку;
значення форми.
Шкідливий код спочатку зберігається, наприклад у базі даних, а потім показується іншим користувачам.
Прикладами можуть бути:
коментарі;
назви профілів;
описи товарів;
повідомлення в чаті.
Важливо: база даних не робить значення безпечним. Навіть якщо рядок отримано з бази, його все одно потрібно безпечно вставляти в HTML.
Перед вставкою тексту в HTML потрібно замінити спеціальні символи на HTML-сутності:
& на &;
< на <;
> на >;
" на ";
' на '.
Ось мінімальна функція для екранування текстового HTML-контексту:
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
const username = '<script>alert("XSS")</script>';
const html = `<h1>Вітаємо, ${escapeHtml(username)}!</h1>`;
console.log(html);
// <h1>Вітаємо, <script>alert("XSS")</script>!</h1>Тепер браузер покаже значення як звичайний текст, а не виконає його.
Приклад сервера на Node.js та Express:
const express = require('express');
const app = express();
const port = 3000;
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
app.get('/greeting', (req, res) => {
const username = req.query.username ?? 'гість';
res.type('html').send(`
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>Привітання</title>
</head>
<body>
<h1>Вітаємо, ${escapeHtml(username)}!</h1>
</body>
</html>
`);
});
app.listen(port, () => {
console.log(`Сервер запущено на http://localhost:${port}`);
});Для запуску:
npm init -y
npm install express
node server.jsПісля відкриття адреси з небезпечним значенням воно відобразиться як текст:
http://localhost:3000/greeting?username=%3Cscript%3Ealert(1)%3C%2Fscript%3EHTML-екранування не є універсальним способом захисту для будь-якого місця.
Небезпечно вставляти дані користувача:
const color = req.query.color;
res.send(`
<div style="color: ${color}">Текст</div>
`);Навіть якщо значення екранувати як HTML, контекст CSS має власні правила.
Так само не варто вставляти дані у JavaScript:
const name = req.query.name;
res.send(`
<script>
const name = '${name}';
</script>
`);Практичні правила:
текст вставляйте як текст, а не як HTML;
для DOM використовуйте textContent, а не innerHTML;
не вставляйте введені дані безпосередньо в <script>, CSS або URL;
якщо користувачам дозволено форматування HTML, застосовуйте спеціалізований перевірений санітизатор;
у шаблонізаторах використовуйте звичайний режим екранування, а не режим «сирого HTML».
Наприклад, у браузері безпечніше написати:
const message = document.querySelector('#message');
message.textContent = userInput;А не:
message.innerHTML = userInput;SQL Injection виникає, коли значення користувача об'єднується із SQL-командою як звичайний текст.
Небезпечний приклад:
const email = req.query.email;
const sql = `
SELECT id, email
FROM users
WHERE email = '${email}'
`;Якщо користувач передасть спеціально сформований рядок, він може змінити структуру SQL-запиту.
Проблема не в символі ' як такому. Проблема в тому, що значення вставляється безпосередньо в SQL-код.
Наслідки SQL Injection:
читання чужих записів;
обхід перевірки автентифікації;
зміна або видалення даних;
отримання службової інформації;
виконання небажаних операцій, якщо обліковий запис бази даних має надмірні права.
Безпечний підхід — передавати значення окремим параметром.
Для PostgreSQL бібліотека pg використовує позиційні параметри $1, $2 тощо:
const result = await pool.query(
`
SELECT id, email
FROM users
WHERE email = $1
`,
[email]
);База даних розглядає email як значення, а не як частину SQL-команди.
const express = require('express');
const { Pool } = require('pg');
const app = express();
const port = 3000;
const pool = new Pool({
connectionString: process.env.DATABASE_URL
});
app.get('/users', async (req, res) => {
const email = req.query.email;
if (typeof email !== 'string' || email.length === 0) {
return res.status(400).json({
error: 'Параметр email є обов’язковим'
});
}
try {
const result = await pool.query(
`
SELECT id, email
FROM users
WHERE email = $1
`,
[email]
);
res.json(result.rows);
} catch (error) {
// Не повертаємо внутрішні деталі помилки клієнту.
console.error(error);
res.status(500).json({
error: 'Внутрішня помилка сервера'
});
}
});
app.listen(port, () => {
console.log(`Сервер запущено на http://localhost:${port}`);
});Для запуску потрібні пакети:
npm init -y
npm install express pg
DATABASE_URL="postgresql://user:password@localhost:5432/app" node server.jsПараметризація захищає значення email, навіть якщо воно містить лапки або фрагменти SQL.
Плейсхолдери використовують для значень:
await pool.query(
'SELECT * FROM users WHERE id = $1',
[userId]
);Не можна використовувати їх для назв таблиць або стовпців:
// Так робити не можна.
await pool.query(
'SELECT * FROM $1',
[tableName]
);Якщо назву стовпця потрібно обрати динамічно, використовуйте список дозволених значень:
const allowedSortFields = new Set(['name', 'created_at']);
const requestedField = req.query.sort;
const sortField = allowedSortFields.has(requestedField)
? requestedField
: 'created_at';
const result = await pool.query(`
SELECT id, name, created_at
FROM users
ORDER BY ${sortField}
`);Тут значення не вставляється без перевірки: воно може бути лише одним із двох заздалегідь визначених імен.
Параметризація та екранування вирішують різні проблеми:
параметризація захищає структуру SQL-запиту;
HTML-екранування захищає HTML-контекст;
валідація перевіряє, чи має дане значення допустимий формат.
Наприклад, для ідентифікатора можна перевірити, що це додатне ціле число:
const userId = Number(req.params.id);
if (!Number.isInteger(userId) || userId <= 0) {
return res.status(400).json({
error: 'Некоректний ідентифікатор'
});
}
const result = await pool.query(
'SELECT id, email FROM users WHERE id = $1',
[userId]
);Валідація не замінює параметризацію. Навіть якщо значення очікується числом, SQL-запити все одно краще виконувати з параметрами.
Також не слід повертати клієнту повний текст помилок бази даних:
res.status(500).json({
error: error.message
});Такі повідомлення можуть розкрити назви таблиць, стовпців або частини SQL-запиту. Деталі потрібно записувати в серверні журнали, а клієнту повертати загальне повідомлення.
Екранування під час запису в базу даних може зіпсувати дані та не враховує майбутній контекст використання.
Краще:
зберігати дані в нормальному вигляді;
екранувати їх під час виведення;
застосовувати правила, що відповідають конкретному контексту.
innerHTML для звичайного текстуЯкщо потрібно показати текст, використовуйте textContent. innerHTML потрібен лише тоді, коли ви свідомо працюєте з HTML і попередньо очистили його дозволеним інструментом.
Не слід намагатися вручну замінювати лапки або видаляти окремі символи:
const sql = `SELECT * FROM users WHERE email = '${email.replaceAll("'", "''")}'`;Такий підхід легко помилитися та складно підтримувати. Використовуйте параметризовані запити бібліотеки для роботи з базою даних.
Перевірки у браузері потрібні для зручності користувача, але їх можна обійти. Валідація має виконуватися і на сервері.
Дані з бази могли бути введені користувачем раніше або імпортовані з іншої системи. Перед HTML-виведенням їх потрібно обробляти так само, як і дані з HTTP-запиту.
Перед виведенням даних:
визначте контекст: HTML, атрибут, URL, CSS або JavaScript;
використовуйте механізми екранування цього контексту;
для звичайного тексту на клієнті використовуйте textContent;
не вставляйте довільний HTML без санітизації.
Перед виконанням SQL:
не об'єднуйте введені дані з SQL-рядком;
використовуйте параметризовані запити;
перевіряйте формат значень на сервері;
для динамічних імен таблиць або стовпців використовуйте allowlist;
не показуйте клієнту внутрішні помилки бази даних.
XSS виникає, коли введені дані виконуються браузером як HTML або JavaScript.
Для захисту від XSS потрібні контекстне екранування та безпечна робота з DOM.
SQL Injection виникає, коли дані користувача змінюють структуру SQL-запиту.
Параметризовані запити передають значення окремо від SQL-коду.
Валідація допомагає приймати лише очікувані дані, але не замінює екранування чи параметризацію.
Дані з бази та інших внутрішніх джерел також не можна автоматично вважати безпечними.