Пошук уроків, статей та іншого контенту
Навчитеся будувати надійні шаблони валідації, безпечно екранувати динамічні значення та уникати надмірних обмежень.
Валідація даних і екранування — це різні операції:
Валідація визначає, чи відповідає значення правилам застосунку.
Нормалізація приводить дані до передбачуваного вигляду.
Екранування змінює представлення значення відповідно до контексту виведення.
Безпечне складання шаблону не дозволяє даним користувача стати кодом або розміткою.
Наприклад, рядок:
<img src=x onerror=alert(1)>може бути допустимим текстом у полі «Опис профілю». Але він не повинен виконуватися як HTML.
Водночас екранування саме по собі не перевіряє бізнес-правила. Воно не визначає:
чи має ім’я допустиму довжину;
чи є вік цілим числом;
чи дозволений URL;
чи містить об’єкт невідомі поля;
чи не перевищує текст обмеження розміру.
Надійний процес зазвичай має такий вигляд:
Перевірити тип і структуру вхідних даних.
Нормалізувати значення.
Перевірити бізнес-правила.
Зберігати нормалізоване значення.
Екранувати його безпосередньо перед конкретним виведенням.
Дані потрібно перевіряти там, де вони входять у вашу систему:
HTTP-запит;
повідомлення з черги;
дані з файлу;
значення з localStorage;
відповідь зовнішнього сервісу;
параметри публічної функції.
Не варто покладатися лише на валідацію у фронтенді. Клієнтський код можна змінити або обійти, тому сервер має повторно перевіряти дані.
Небезпечно одразу викликати методи рядка:
const name = input.name.trim();Якщо input.name дорівнює null, числу або об’єкту, програма завершиться помилкою.
Спочатку потрібно перевірити структуру:
if (!input || typeof input !== 'object' || Array.isArray(input)) {
throw new TypeError('Очікувався об’єкт');
}
if (typeof input.name !== 'string') {
throw new TypeError('Поле name має бути рядком');
}Перевірка typeof value === "object" сама по собі недостатня: вона також пропускає null і масиви.
Відсутнє необов’язкове поле — не те саме, що поле з неправильним значенням.
function validateNickname(value) {
if (value === undefined) {
return { ok: true, value: undefined };
}
if (typeof value !== 'string') {
return { ok: false, error: 'nickname має бути рядком' };
}
const normalized = value.trim();
if (normalized.length < 3 || normalized.length > 30) {
return {
ok: false,
error: 'nickname має містити від 3 до 30 символів'
};
}
return { ok: true, value: normalized };
}Не слід автоматично перетворювати будь-які «неправильні» значення на значення за замовчуванням:
const age = Number(input.age) || 0;Такий код перетворює на 0 не лише відсутнє значення, а й:
порожній рядок;
NaN;
false;
текст, який не є числом.
Краще явно визначити, які типи та форми введення дозволені.
Нормалізація має бути передбачуваною і документованою.
Для тексту часто доречно:
const normalized = value.normalize('NFC').trim();NFC допомагає привести різні Unicode-послідовності до канонічної форми. Проте це не вирішує всі проблеми, пов’язані з Unicode:
один видимий символ може складатися з кількох кодових позицій;
value.length не завжди відповідає кількості видимих символів;
регістр і порівняння можуть мати мовні особливості.
Для технічних ідентифікаторів краще використовувати чітке правило, наприклад ASCII-символи. Для імен та описів не варто без потреби забороняти всі символи, крім [A-Za-z].
Якщо поле не може бути порожнім:
const name = input.name.normalize('NFC').trim();
if (name === '') {
return { ok: false, error: 'Ім’я не може бути порожнім' };
}Якщо пробіли всередині значення мають значення, не використовуйте агресивну нормалізацію на кшталт заміни всіх послідовностей пробілів без чіткої потреби.
Функція Number може бути корисною, але її потрібно використовувати разом із додатковими перевірками.
function parseAge(value) {
if (typeof value === 'string' && value.trim() === '') {
return { ok: false, error: 'Вік не може бути порожнім' };
}
const age = typeof value === 'number' ? value : Number(value);
if (!Number.isInteger(age)) {
return { ok: false, error: 'Вік має бути цілим числом' };
}
if (age < 0 || age > 130) {
return { ok: false, error: 'Вік має бути від 0 до 130' };
}
return { ok: true, value: age };
}Для чисел також потрібно враховувати:
Number.isFinite(value) для перевірки скінченності;
межі Number.MAX_SAFE_INTEGER;
одиниці вимірювання;
чи дозволені від’ємні значення;
чи потрібно зберігати число як число, а не як рядок.
Не використовуйте parseInt як універсальний валідатор:
parseInt('42abc', 10); // 42Якщо суфікс abc неприпустимий, такий результат приховує помилку.
Обмеження потрібні не лише для зручності користувача. Вони також зменшують ризик:
надмірного споживання пам’яті;
повільної обробки;
переповнення полів у базі даних;
атак на регулярні вирази;
створення надто великих HTML-відповідей.
Перевіряйте обмеження до дорогої обробки:
if (typeof description !== 'string') {
throw new TypeError('description має бути рядком');
}
if (description.length > 10_000) {
throw new RangeError('description перевищує допустимий розмір');
}Для даних, які надходять через HTTP, обмеження розміру бажано встановлювати також на рівні веб-сервера або фреймворку. Валідація вже розібраного тіла запиту може бути запізнілою, якщо тіло необмежено велике.
Регулярний вираз має перевіряти конкретне правило, а не намагатися описати «всі правильні значення».
Для простого ідентифікатора можна використати:
const identifierPattern = /^[A-Za-z0-9_]{3,30}$/;Для Unicode-імені таке правило буде надмірно обмежувальним. Воно відхилить, наприклад, українські, грецькі або японські символи.
У сучасному JavaScript можна використовувати Unicode-властивості:
const personNamePattern = /^\p{L}[\p{L}\p{M} .'-]{0,99}$/u;Цей вираз дозволяє літери, комбіновані позначки, пробіли, крапку, апостроф і дефіс, але конкретне правило все одно потрібно узгодити з вимогами продукту.
Регулярні вирази з вкладеними повтореннями можуть працювати експоненційно довго на спеціально підготовленому введенні:
// Приклад потенційно небезпечної структури
const pattern = /^(a+)+$/;Для зовнішнього введення:
обмежуйте довжину рядка до перевірки;
уникайте неоднозначних вкладених повторень;
не використовуйте регулярний вираз як повний парсер складного формату;
для URL, дат і структурованих значень використовуйте спеціалізовані вбудовані API, коли вони доступні.
Мовчазне ігнорування невідомих полів може приховати помилки клієнта або спробу передати небезпечні властивості.
Наприклад, об’єкт може містити:
{
name: 'Олена',
role: 'admin'
}Якщо API очікує лише name і bio, варто або відхилити role, або явно описати політику його ігнорування.
Під час перевірки власних властивостей не використовуйте безпосередній виклик input.hasOwnProperty(...): це поле може бути перезаписане або відсутнє.
const hasOwn = (object, key) =>
Object.prototype.hasOwnProperty.call(object, key);Для словників із довільними ключами корисно створювати об’єкти без прототипу:
const dictionary = Object.create(null);
dictionary['safe-key'] = 'value';Також не слід бездумно зливати зовнішні дані в конфігураційні об’єкти через глибоке об’єднання. Властивості на кшталт __proto__, constructor і prototype можуть бути небезпечними для деяких реалізацій такого злиття.
Не існує одного універсального escape() для всіх випадків.
Дані можуть потрапити в:
текстовий вузол HTML;
значення HTML-атрибута;
URL;
CSS;
JavaScript-код;
SQL-запит;
shell-команду.
Кожен контекст має власні правила.
Для HTML-тексту та атрибута в подвійних лапках потрібно щонайменше екранувати:
&;
<;
>;
";
'.
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}Порядок важливий: амперсанд потрібно обробити першим, інакше послідовності, створені під час екранування, будуть екрановані повторно.
Екранування не робить безпечним довільний атрибут на кшталт:
<div onclick="..."></div>Не вставляйте зовнішні дані в обробники подій, <script> або <style>. Використовуйте властивості DOM та окрему логіку JavaScript.
Інтерполяція в template literal не є безпечною автоматично:
const html = `<p>${userInput}</p>`;Якщо userInput містить HTML, він стане частиною розмітки.
Безпечніший підхід — екранувати кожне динамічне значення перед вставленням і валідовувати значення, яке має спеціальний формат.
Нижче наведено повністю виконуваний приклад для Node.js. Він:
перевіряє структуру профілю;
відхиляє невідомі поля;
нормалізує текст;
перевіряє довжину;
дозволяє лише http: та https: для URL;
екранує значення перед вставленням у HTML.
'use strict';
const hasOwn = (object, key) =>
Object.prototype.hasOwnProperty.call(object, key);
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
function validateText(value, field, { min = 0, max }) {
if (typeof value !== 'string') {
return {
ok: false,
error: `${field} має бути рядком`
};
}
const normalized = value.normalize('NFC').trim();
if (normalized.length < min || normalized.length > max) {
return {
ok: false,
error: `${field} має містити від ${min} до ${max} символів`
};
}
return { ok: true, value: normalized };
}
function validateHttpUrl(value) {
if (value === undefined) {
return { ok: true, value: undefined };
}
if (typeof value !== 'string') {
return {
ok: false,
error: 'website має бути рядком'
};
}
const trimmed = value.trim();
if (trimmed === '') {
return { ok: true, value: undefined };
}
let parsed;
try {
parsed = new URL(trimmed);
} catch {
return {
ok: false,
error: 'website має бути коректним URL'
};
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
return {
ok: false,
error: 'website має використовувати HTTP або HTTPS'
};
}
// Забороняємо URL із вбудованими обліковими даними.
if (parsed.username !== '' || parsed.password !== '') {
return {
ok: false,
error: 'website не може містити облікові дані'
};
}
return {
ok: true,
value: parsed.href
};
}
function validateProfile(input) {
if (
input === null ||
typeof input !== 'object' ||
Array.isArray(input)
) {
return {
ok: false,
errors: ['Очікувався об’єкт профілю']
};
}
const allowedFields = new Set(['name', 'bio', 'website']);
const errors = [];
for (const key of Object.keys(input)) {
if (!allowedFields.has(key)) {
errors.push(`Невідоме поле: ${key}`);
}
}
const nameResult = validateText(input.name, 'name', {
min: 2,
max: 80
});
if (!nameResult.ok) {
errors.push(nameResult.error);
}
const bioResult = validateText(input.bio, 'bio', {
min: 0,
max: 500
});
if (!bioResult.ok) {
errors.push(bioResult.error);
}
const websiteResult = validateHttpUrl(input.website);
if (!websiteResult.ok) {
errors.push(websiteResult.error);
}
if (errors.length > 0) {
return { ok: false, errors };
}
return {
ok: true,
value: {
name: nameResult.value,
bio: bioResult.value,
website: websiteResult.value
}
};
}
function renderProfile(profile) {
const websiteLink = profile.website
? `<a href="${escapeHtml(profile.website)}" rel="noopener noreferrer">Сайт</a>`
: '';
return `
<article class="profile">
<h2>${escapeHtml(profile.name)}</h2>
<p>${escapeHtml(profile.bio)}</p>
${websiteLink}
</article>
`.trim();
}
const input = {
name: 'Олена <розробниця>',
bio: 'Створюю вебзастосунки та автоматизую процеси.',
website: 'https://example.com/profile'
};
const result = validateProfile(input);
if (!result.ok) {
console.error(result.errors);
process.exitCode = 1;
} else {
console.log(renderProfile(result.value));
}У цьому прикладі URL не просто екранується. Спочатку він проходить перевірку схеми. Це важливо, оскільки HTML-екранування не перетворює небезпечний протокол на безпечний.
Наприклад, значення:
javascript:alert(1)може залишитися синтаксично коректним рядком після HTML-екранування, але його не можна використовувати як посилання. Саме тому потрібні і валідація URL, і екранування атрибута.
Якщо розмітку потрібно створити у браузері, краще не збирати HTML-рядок вручну:
const paragraph = document.createElement('p');
paragraph.textContent = userInput;textContent вставляє значення як текст, а не як HTML.
Для атрибутів використовуйте API елементів:
const link = document.createElement('a');
link.textContent = 'Відкрити сайт';
link.href = validatedUrl;
link.rel = 'noopener noreferrer';Не використовуйте innerHTML, якщо вам не потрібна саме динамічна HTML-розмітка. Якщо innerHTML неминучий, кожне значення має бути оброблене засобом, призначеним для конкретного HTML-контексту.
Окремо потрібно пам’ятати, що href у DOM не замінює перевірку протоколу. Значення все одно треба перевірити перед присвоєнням.
Template literals зручні для формування тексту, але вони не виконують автоматичного екранування:
const message = `Привіт, ${name}`;Для повторюваного HTML-шаблону можна створити власну функцію, яка екранує інтерполяції:
function html(strings, ...values) {
return strings.reduce((result, string, index) => {
const value = index < values.length
? escapeHtml(values[index])
: '';
return result + string + value;
}, '');
}
const name = '<script>alert(1)</script>';
const result = html`<p>${name}</p>`;
console.log(result);
// <p><script>alert(1)</script></p>Такий підхід корисний лише для значень, які справді перебувають у HTML-текстовому або сумісному з ним атрибутивному контексті. Він не повинен автоматично застосовуватися до:
URL, які спочатку потрібно перевірити;
CSS;
JavaScript-коду;
SQL;
shell-команд.
Крім того, простий tagged template не враховує всі тонкощі HTML-парсингу. Для складних застосунків краще використовувати безпечні DOM API або перевірений санітайзер HTML, якщо продукт дійсно дозволяє користувачам вводити форматований HTML.
Потрібно заздалегідь визначити модель поля:
Звичайний текст — HTML-розмітка користувача заборонена та екранується.
Обмежений форматований текст — дозволений лише невеликий набір елементів і атрибутів, а решта санітизується.
Довільний HTML — вкрай небезпечний варіант, який потребує складної політики безпеки та ізоляції.
Не можна замінювати санітизацію простим видаленням підрядків:
value.replaceAll('<script>', '');Атакувальник може використати інший синтаксис, регістр, атрибути подій, URL із небезпечною схемою або некоректно вкладені елементи.
Не складайте SQL через template literals:
const query = `SELECT * FROM users WHERE name = '${name}'`;Екранування HTML тут не допоможе. Для SQL потрібно використовувати параметризовані запити, які надає конкретний драйвер або ORM.
Не вставляйте зовнішні значення в JavaScript-код через рядки:
const script = `const value = '${input}'`;Навіть спеціальне екранування такого контексту легко зробити неправильно. Передавайте дані через JSON, властивості об’єктів або DOM API, а не генеруйте код із введення користувача.
URL потрібно:
розібрати через URL;
дозволити лише потрібні протоколи;
перевірити домен, якщо застосунок працює з allowlist;
лише потім вставити URL у HTML-атрибут або передати браузеру.
Кодування URL-компонента за допомогою encodeURIComponent не є перевіркою безпеки. Воно лише кодує частину рядка для певного URL-контексту.
Зручно повертати структурований результат замість довільного винятку:
{
ok: false,
errors: [
'name має містити від 2 до 80 символів',
'website має використовувати HTTP або HTTPS'
]
}Переваги такого підходу:
можна показати кілька помилок за один запит;
простіше тестувати функцію;
бізнес-логіка не залежить від формату HTTP-відповіді;
можна окремо локалізувати повідомлення;
внутрішні деталі не обов’язково розкривати користувачу.
Повідомлення для користувача та записи в логах мають бути різними. У логах не слід без потреби зберігати паролі, токени, персональні дані або повний вміст конфіденційних запитів.
const html = `<div>${value}</div>`;Template literal лише об’єднує рядки. Він не знає, що результат буде HTML.
Якщо небезпечний рядок уже передано в innerHTML, захист запізнився. Значення потрібно обробити до передачі в небезпечний sink або використовувати textContent.
escapeHtml не захищає від:
SQL-ін’єкцій;
shell-ін’єкцій;
небезпечного JavaScript;
некоректних URL;
CSS-ін’єкцій.
Навіть якщо ім’я перевірене регулярним виразом, його потрібно правильно обробити під час виведення. Правила можуть змінитися, а інші джерела даних можуть не пройти той самий шлях.
Правило «лише латинські літери» для імен може бути неприйнятним. Забороняйте конкретно небезпечні або непотрібні символи, а не все незнайоме.
Number(value) || defaultValueЦе приховує помилки та змішує різні випадки: нуль, порожнє значення, NaN і відсутність поля.
Складні формати краще розбирати спеціалізованим API. Регулярний вираз для email або URL часто або відхиляє допустимі значення, або пропускає неочікувані.
Перевірка лише того, що рядок «схожий на URL», недостатня. Потрібно визначити дозволені протоколи та, за потреби, домени.
Невідоме поле може бути:
помилкою клієнта;
ознакою несумісної версії API;
спробою змінити поле, якого не повинно бути в запиті.
Поведінку щодо таких полів потрібно визначити явно.
Перевіряйте не лише звичайні приклади, а й граничні випадки:
null;
масив замість об’єкта;
порожній рядок;
рядок лише з пробілів;
значення на межі довжини;
надто довгі значення;
NaN, Infinity;
невідомі поля;
Unicode;
HTML-спроби;
URL із javascript:;
URL з обліковими даними;
дубльовані або зайві пробіли.
Для HTML-виведення корисно перевірити, що результат містить текстові сутності, а не виконувані теги:
const malicious = '<img src=x onerror=alert(1)>';
const escaped = escapeHtml(malicious);
console.assert(escaped.includes('<img'));
console.assert(!escaped.includes('<img'));Тестуйте також негативні сценарії: неправильне значення має бути відхилене, а не мовчки перетворене на інше.
Валідація, нормалізація та екранування вирішують різні задачі.
Перевіряйте дані на межі системи та повторюйте перевірку на сервері.
Спочатку перевіряйте тип, потім нормалізуйте значення і лише після цього застосовуйте бізнес-правила.
Template literals не екранують інтерполяції автоматично.
Для HTML-тексту й атрибутів використовуйте контекстне екранування.
Для браузера надавайте перевагу textContent, createElement і властивостям DOM замість innerHTML.
URL потрібно перевіряти через URL і allowlist протоколів, а не лише екранувати.
HTML-екранування не захищає SQL, JavaScript, CSS або shell-контексти.
Обмеження мають бути достатніми для безпеки й продуктивності, але не повинні безпідставно забороняти коректні дані.
Невідомі поля, великі значення та складні регулярні вирази потребують окремої політики.