Хрю, інженерна громадо! Пігмен біля клавіатури. Сьогодні розбираємо охоронця, який так старанно захищає магазин, що покупець не може оновити кошик. Чат теж ліг. Зате відповідь 403 працює бездоганно. Знайомся: хибні спрацювання WAF на прикладі OWASP Coraza та Juice Shop.

Одразу розставлю копита: цей стенд зібрав автор лабораторного розбору, а не я. Він узяв Coraza з OWASP CRS, Caddy та навмисно вразливий Juice Shop. Я проведу тебе через п’ять спрацювань: де політика розминулася з логікою застосунку, як звужували винятки й чому працездатний кошик ще не означає безпечний сайт.

Покупець прийшов по товар. Отримав підозру

План експерименту був скромний: Python-скрипт із Playwright входить у магазин, шукає товар, додає його до кошика й доходить до оформлення замовлення. На конфігурації без кастомних винятків частину цих дій зупинив WAF. До оплати ще далеко, а пригоди вже почалися.

Підступність SPA в тому, що ти можеш бачити цілком пристойний інтерфейс. Сторінка намалювалася, кнопочки на місці, дизайнер спить спокійно. А фоновий запит отримав 403 — і потрібна дія не виконується. Тому я б дивився на проходження сценарію цілком. Факт «головна відкрилася» тут доводить лише те, що головна відкрилася.

CRS шукає ознаки підозрілих запитів, але не читає думки розробника твого магазину. Для API метод PUT може бути звичайним оновленням даних. Для налаштованої політики — методом за межами дозволеного списку. Обидві сторони виконують свою роботу. Пояснювати їх одна одній доведеться інженеру.

949110 — це вирок. Діставай попередні рядки

Відкриваємо audit.log. Наприкінці ланцюжка часто стоїть 949110, або Inbound Anomaly Score Exceeded. Рука вже тягнеться вимкнути винного? Притримай копито. Це правило повідомляє про набраний бал, а конкретні причини могли знайти інші перевірки вище.

У наведеній конфігурації з Paranoia Level 1 поріг становив 5 балів. Спрацювання CRITICAL додавало 5, WARNING — 3. Бали складаються: прибрав одну причину, але залишив інші — запит усе ще може набирати достатньо для блокування. Арифметика невблаганна, навіть коли дуже хочеться закрити тикет.

Автор дивився на id, повідомлення msg та is_interrupted: true. Тут мене цікавить увесь маршрут: яка ознака спрацювала, скільки балів принесла й що в результаті перервало запит. Так ми шукаємо причину збою, а не просто навчаємо журнал мовчати.

П’ять підозрюваних у справі зниклого кошика

Перший — Socket.IO з text/plain. Правило 920420 не прийняло тип вмісту службових HTTP-запитів. У стенді вони приходили з Content-Type: text/plain. Мова про handshake або polling, а не перевірку кадрів уже встановленого WebSocket-з’єднання. Тут я навмисно пригальмую: якщо переплутати ці речі, пояснюватимеш збій на зовсім іншому рівні.

Другий — кошик, якому знадобився PUT. Запит до /api/BasketItems/1 уперся в правило 911100. Конфігурація дозволяла GET, POST, HEAD та OPTIONS. PUT у цей клуб не взяли. Застосунок просить оновити дані, політика відмовляє — і покупець залишається з нездійсненими споживчими планами.

Третій — профіль із назвою whoami. Правило 932260 впізнало в /rest/user/whoami слово, схоже на Unix-команду. Але тут воно називає REST-ендпоінт. Команду ніхто не запускає. Перевірка знайшла знайому ознаку, а контекст застосунку до неї в комплекті не завезли.

Четвертий — пошук apple'--. Апостроф і два дефіси зачепили 942100 із LibInjection. У лабораторному сценарії це було тестове значення пошуку. Для SQLi-детектора воно виглядало підозріло. Ось тут, хряче, починається найцікавіше: зробити так, щоб пошук працював, легко. Зберегти при цьому потрібну перевірку — окрема робота.

П’ятий — IP-адреса в Host. Правило 920350 додавало 3 бали за числовий Host. Саме по собі воно не дотягувало до блокування в цьому прикладі, але могло скластися з іншими попередженнями. Тож я не призначав би одного винного лише тому, що його ім’я останнім потрапило в поле зору.

Кошик ремонтуємо. Паркан цілком не зносимо

Автор зібрав винятки в coraza-rules/exclusions.conf. Замість глобального SecRuleRemoveById використовував точкове виключення цілей через ctl:ruleRemoveTargetById. Правило продовжувало працювати для решти запитів. Межі винятку все одно треба перевіряти: слово «точковий» не видає налаштуванню автоматичного сертифіката безпеки.

Із пошуком перша спроба виявилася надто широкою. Параметр q для /rest/products/search виключили з перевірки 942100. Хибне спрацювання зникло, але саме ця перевірка більше не аналізувала виключений параметр. Мій улюблений різновид зеленого тесту: перестали перевіряти — перестало сваритися. От тільки це ще не ремонт.

Тоді автор додав умову через chain: виняток діє, якщо значення не містить визначених SQL-шаблонів, зокрема union, select або or 1=1. Тут поставлю великий редакторський п’ятачок: джерело прямо називає це спрощеним навчальним прикладом. Готовим правилом для production він від цього не стає.

Обфускація, кодування й SQL-коментарі можуть обійти такий регулярний вираз. У розборі окремо згадані нормалізація та трансформації t:urlDecodeUni, t:lowercase. Тому тягнути цю регулярку у свій конфіг без розбору не раджу. Перевіряти доведеться і межі винятку, і те, які запити він тепер пропускає.

Покупця пустили. Тепер час для непроханих гостей

Після змін Playwright дійшов до кнопки оформлення замовлення. Журнал більше не показував блокувань цього сценарію за п’ятьма розглянутими правилами. Добре: функціональність на стенді відновили. Але я б поки не відкладав клавіатуру й не наливав святкову каву.

Автор окремо відправив тестові запити із сигнатурами SQLi, RCE, XSS, Path Traversal та забороненим методом DELETE. Вони продовжили отримувати 403. Це результат конкретних перевірок у конкретному стенді, а не обіцянка заблокувати будь-яку атаку після подібних змін.

У стенді використовували Coraza з CRS 4.25.2 та Caddy. Для production автор додає версіонування винятків, рев’ю, моніторинг і автоматизовану регресію; область винятку бажано звужувати до методу, URL і параметра. Отакий фінал у Пігмена: кошик працює, тестові атаки блокуються, а наступний інженер може зрозуміти конфіг без виклику духів із закритих тикетів.