Пошук уроків, статей та іншого контенту
Дізнаєтеся, як виявляти катастрофічний backtracking, оцінювати продуктивність і захищати застосунки від ReDoS.
Регулярний вираз часто здається невеликою декларативною перевіркою, але його виконання може вимагати значних ресурсів процесора. Особливо це стосується рушіїв із backtracking, які використовують JavaScript.
Backtracking — це перебір різних варіантів зіставлення, коли попередня спроба не завершилася успішно. Рушій повертається назад і пробує іншу комбінацію повторень, альтернатив або груп.
Для більшості шаблонів це достатньо швидко. Проте деякі комбінації конструкцій створюють експоненційний або близький до нього перебір. Якщо зловмисник може передати такий рядок у застосунок, це створює вразливість класу ReDoS — Regular Expression Denial of Service.
Розглянемо шаблон:
/^(a+)+$/У ньому:
a+ споживає одну або більше літер a;
зовнішня група (a+)+ повторює цю конструкцію;
$ вимагає завершення рядка.
Для рядка, що складається лише з a, рушій може швидко знайти збіг. Але якщо наприкінці додати символ, який не відповідає шаблону, наприклад !, рушій почне перебирати різні способи поділу послідовності a між внутрішніми повтореннями.
Для такого рядка:
aaaaaaaaaaaaaaaaaaaa!рушій може перевіряти багато варіантів:
a a a a a a a a ...
aa a a a a a a ...
a aa a a a a a ...
aa aa a a a ...
...Кількість комбінацій швидко зростає зі збільшенням довжини рядка.
Найчастіше ризик з’являється, коли в шаблоні одночасно є:
Вкладені квантифікатори:
(a+)+Два або більше повторення, які можуть споживати однакові символи:
(a|aa)+Необмежене повторення групи з альтернативами:
(\w|\w\w)+Велика кількість необов’язкових частин:
(a?){20}a{20}Шаблон, який майже збігається з довгим рядком, але відхиляє його лише в кінці.
Не кожна така конструкція автоматично є вразливою. Важливо оцінювати, чи має рушій багато способів розподілити один і той самий фрагмент вхідних даних.
Наступний приклад можна запустити в Node.js. Він порівнює вразливий і простий шаблони на рядках, які завершуються символом !.
const { performance } = require("node:perf_hooks");
const vulnerable = /^(a+)+$/;
const safe = /^a+$/;
function measure(regex, input) {
const startedAt = performance.now();
const result = regex.test(input);
const elapsed = performance.now() - startedAt;
return {
result,
milliseconds: elapsed.toFixed(3),
};
};
for (const length of [10, 14, 18, 22]) {
const input = "a".repeat(length) + "!";
console.log(`Довжина: ${length + 1}`);
console.log("Вразливий шаблон:", measure(vulnerable, input));
console.log("Безпечний шаблон:", measure(safe, input));
console.log();
}Час виконання залежить від версії Node.js, процесора та інших процесів у системі. Не слід використовувати дуже великі значення довжини під час експерименту: синхронний виклик RegExp.prototype.test() може надовго заблокувати потік JavaScript.
Безпечний шаблон /^a+$/ має набагато простішу структуру. Для нього немає вкладених повторень і конкуруючих способів розподілу символів.
У браузері регулярний вираз виконується в основному потоці, якщо явно не використовується Web Worker. Поки виконується довгий збіг:
інтерфейс перестає реагувати;
не обробляються події;
затримується промальовування;
можуть зависнути таймери й обробники мережевих подій.
У Node.js синхронний виклик регулярного виразу блокує event loop:
інші запити не обробляються;
зростає затримка всіх клієнтів;
один спеціально сформований запит може впливати на весь процес.
Асинхронний HTTP-обробник не робить регулярний вираз асинхронним. Наприклад, цей код усе одно блокує event loop:
app.post("/validate", async (request, response) => {
const isValid = dangerousRegex.test(request.body.value);
response.json({ isValid });
});Ключове значення має не async, а складність самого зіставлення.
Тестувати регулярний вираз лише на коректних коротких рядках недостатньо. Для кожного шаблону потрібно перевірити:
порожній рядок;
мінімальне коректне значення;
мінімальне некоректне значення;
довгий коректний рядок;
довгий рядок, який відхиляється в останній позиції;
рядок із повторюваними символами;
рядок, який запускає альтернативний шлях backtracking.
Наприклад, для шаблону, що перевіряє ідентифікатор, корисними тестами будуть:
const cases = [
"",
"a",
"valid_name_123",
"a".repeat(1000),
`${"a".repeat(1000)}!`,
`${"a".repeat(1000)}-`,
];
for (const value of cases) {
const startedAt = performance.now();
const result = /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
const elapsed = performance.now() - startedAt;
console.log({
length: value.length,
result,
milliseconds: elapsed,
});
}Один запуск може бути неточним через:
прогрівання JIT-компілятора;
навантаження на систему;
збірку сміття;
різні версії рушія JavaScript;
різну довжину вхідних даних.
Для порівняння можна виконувати кожен тест багато разів, але це не замінює аналіз структури регулярного виразу. Якщо шаблон має експоненційний найгірший випадок, бенчмарк із малими рядками може не показати проблему.
Корисне практичне правило:
збільшили довжину введення в 2 рази;
перевірили, як змінився час;
повторили для кількох довжин.
Якщо час зростає приблизно пропорційно довжині, це може свідчити про лінійну поведінку. Якщо він зростає набагато швидше, потрібно дослідити шаблон.
Це лише евристика, а не математичний доказ безпеки.
Небезпечний варіант:
^(a+)+$Якщо потрібна просто послідовність літер a, використовуйте:
^a+$Загальне правило: не вкладайте *, + або {m,} один в один без чіткої потреби та доказу безпечності.
Небезпечний приклад:
^(a|aa)+$Для рядка aaaa...! рушій може багато разів обирати між a та aa.
Якщо формат можна описати простіше, використовуйте простий шаблон:
^a+$Або винесіть складну логіку з регулярного виразу в звичайний JavaScript-код.
Замість:
^[A-Za-z0-9]+$для поля з відомим максимальним розміром краще використовувати:
^[A-Za-z0-9]{1,64}$Обмеження довжини не усуває всі проблеми backtracking, але зменшує максимальну вартість операції та обмежує вплив шкідливого введення.
Перевірка числового діапазону, складний синтаксичний аналіз або розбір структурованого формату часто надійніші у вигляді послідовних операцій:
function isPort(value) {
if (typeof value !== "string" || !/^\d{1,5}$/.test(value)) {
return false;
}
const port = Number(value);
return port >= 1 && port <= 65535;
}Тут регулярний вираз лише перевіряє форму, а числове правило реалізоване звичайним кодом.
Навіть безпечний регулярний вираз потрібно застосовувати до контрольованого за розміром введення.
const USERNAME_PATTERN = /^[A-Za-z][A-Za-z0-9_]{2,31}$/;
const MAX_USERNAME_LENGTH = 32;
function isValidUsername(value) {
if (typeof value !== "string") {
return false;
}
if (value.length > MAX_USERNAME_LENGTH) {
return false;
}
return USERNAME_PATTERN.test(value);
}
console.log(isValidUsername("developer_01")); // true
console.log(isValidUsername("a")); // false
console.log(isValidUsername("a".repeat(1000))); // falseОбмеження потрібно встановлювати на рівні:
HTTP-сервера;
middleware для розбору тіла запиту;
схеми валідації;
конкретного поля;
регулярного виразу, якщо максимальна довжина відома.
Не варто спочатку передавати необмежене тіло запиту складному валідатору, а вже потім перевіряти його розмір.
Заздалегідь визначте:
максимальну довжину;
дозволений набір символів;
чи підтримується Unicode;
чи допускаються пробіли та переноси рядків;
чи потрібно нормалізувати Unicode;
чи повинна перевірка охоплювати весь рядок.
Це зменшує неоднозначність і спрощує регулярний вираз.
Якорі ^ і $ часто використовують для перевірки всього рядка:
/^[a-z]+$/Проте $ у JavaScript може збігатися перед завершальним символом нового рядка. Якщо потрібна сувора перевірка, що після шаблону немає жодного символу, можна використати перевірку кінця через негативний lookahead:
const onlyLowercase = /^[a-z]+$(?![\s\S])/;
console.log(onlyLowercase.test("abc")); // true
console.log(onlyLowercase.test("abc\n")); // falseІнший практичний підхід — спочатку нормалізувати або відхилити переноси рядків відповідно до правил конкретного поля.
Прапорець m змінює поведінку ^ і $, дозволяючи їм працювати з окремими рядками. Не додавайте m, якщо перевіряєте одне значення цілком.
g і стан lastIndexПрапорець g потрібен для пошуку всіх збігів, але він робить регулярний вираз станомістким. Методи test() і exec() змінюють lastIndex.
const pattern = /^\w+$/g;
console.log(pattern.test("user")); // true
console.log(pattern.test("user")); // false — lastIndex уже змінивсяУ валідації це може спричинити непередбачувану поведінку, особливо якщо один екземпляр регулярного виразу повторно використовується між запитами або викликами функції.
Для перевірки одного значення не використовуйте g без потреби:
const pattern = /^\w+$/;
console.log(pattern.test("user")); // true
console.log(pattern.test("user")); // trueЯкщо g необхідний, перед повторним використанням явно скидайте стан:
pattern.lastIndex = 0;Якщо частина шаблону формується з введення користувача, це вже не лише проблема продуктивності. Користувач може додати метасимволи регулярних виразів:
const query = ".*";
const pattern = new RegExp(`^${query}$`);Такий шаблон не означає буквальний пошук .*. Він означає «будь-яка послідовність символів».
Перед вставленням звичайного тексту в RegExp його потрібно екранувати:
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function createExactPattern(value) {
const escaped = escapeRegExp(value);
return new RegExp(`^${escaped}$`);
}
const pattern = createExactPattern("file.name");
console.log(pattern.test("file.name")); // true
console.log(pattern.test("fileXname")); // falseЕкранування захищає синтаксис регулярного виразу, але не замінює обмеження довжини. Навіть екранований дуже довгий текст може бути невдалим вибором для динамічного шаблону.
Якщо складний регулярний вираз неможливо замінити або довести його безпечність, можна розглянути ізоляцію:
виконання у Worker або окремому процесі;
обмеження часу роботи із завершенням ізольованого виконавця;
обмеження пам’яті;
обмеження довжини введення ще до передавання в ізоляцію.
Важливо: Promise, async/await або setTimeout не переривають уже запущений синхронний виклик регулярного виразу в тому самому потоці.
Наприклад, цей код не створює тайм-аут для test():
const timeout = new Promise((resolve) => {
setTimeout(() => resolve("timeout"), 100);
});
const matching = Promise.resolve(dangerousRegex.test(input));
Promise.race([matching, timeout]).then(console.log);Виклик dangerousRegex.test(input) виконується до створення фактичного очікування результату. Якщо він заблокує потік, таймер також не зможе спрацювати вчасно.
Ізоляція додає складності, тому основним захистом мають залишатися:
безпечна структура шаблону;
обмеження довжини;
тестування найгірших випадків;
відмова від непотрібної складності.
Для кожного регулярного виразу, який обробляє зовнішні дані, корисно мати:
тести на коректні значення;
тести на некоректні значення;
тести на граничні довжини;
тести на довгі майже коректні значення;
вимірювання часу для серії довжин;
перевірку шаблону під час code review.
Окремо перевіряйте регулярні вирази, які:
використовуються до автентифікації;
працюють у публічному HTTP API;
застосовуються до файлів або великих текстів;
формуються через new RegExp();
виконуються багато разів у циклі;
запускаються в основному потоці браузера.
Замість одного складного шаблону розділіть перевірку на прості кроки:
function isValidProductCode(value) {
if (typeof value !== "string") {
return false;
}
if (value.length < 3 || value.length > 20) {
return false;
}
if (!/^[A-Z0-9-]+$/.test(value)) {
return false;
}
if (value.startsWith("-") || value.endsWith("-")) {
return false;
}
return !value.includes("--");
}
console.log(isValidProductCode("AB-123")); // true
console.log(isValidProductCode("-AB123")); // false
console.log(isValidProductCode("AB--123")); // false
console.log(isValidProductCode("ab-123")); // falseКілька простих перевірок часто зрозуміліші, швидші та безпечніші за один великий шаблон із численними lookaround, альтернативами й вкладеними квантифікаторами.
.* без обмеженьШаблон на кшталт:
^.*important.*$може бути надто загальним і змушувати рушій перебирати багато позицій. Якщо відомий формат, краще описати його точніше або виконати пошук методом includes().
+ і *Конструкції на кшталт:
(.+)+
(.*)*
(\s+)+потрібно вважати підозрілими, доки не доведено протилежне.
Вразливість часто проявляється не на рядку, який відповідає шаблону, а на рядку, що відрізняється одним символом у кінці.
asyncАсинхронна функція не робить синхронну операцію регулярного виразу перериваною або неблокувальною.
Навіть лінійний регулярний вираз може стати проблемою, якщо його застосовують до гігантського введення без обмеження розміру.
gСтан lastIndex може призводити до різних результатів для однакових аргументів.
Вставлення рядка користувача в new RegExp() без екранування створює ризик зміни логіки шаблону або появи небезпечної структури.
Під час перевірки регулярного виразу поставте такі запитання:
Чи надходить введення від користувача?
Чи має введення максимальну довжину?
Чи є вкладені необмежені квантифікатори?
Чи можуть дві альтернативи збігатися з однаковим префіксом?
Чи може шаблон відхилити довгий рядок лише наприкінці?
Чи використовується g без необхідності?
Чи формується шаблон динамічно?
Чи можна замінити частину регулярного виразу звичайним кодом?
Чи перевірено найгірший випадок вимірюваннями?
Чи заблокує довгий збіг основний потік застосунку?
Якщо на кілька запитань відповідь викликає сумніви, шаблон потрібно спростити або ізолювати його виконання.
JavaScript використовує рушій регулярних виразів із backtracking.
Вкладені повторення та неоднозначні альтернативи можуть спричинити катастрофічний backtracking.
ReDoS здатен заблокувати event loop у Node.js або основний потік браузера.
Перевіряйте не лише коректні, а й довгі майже коректні рядки.
Обмежуйте довжину введення до виконання регулярного виразу.
Уникайте вкладених необмежених квантифікаторів і зайвих альтернатив.
Не використовуйте g для одноразової валідації.
Екрануйте значення перед передаванням у new RegExp().
async, Promise і таймери не переривають синхронний regex-виклик.
Найкращий захист — простий шаблон, обмежене введення, вимірювання найгіршого випадку та ізоляція складних операцій за потреби.