Fewer false positives, more attacks caught: testing a WAF against real hotel traffic
On our test set of hotel booking traffic, we cut regex false positives from 9.5% to 2.9% of legitimate requests, and raised the share of attacks caught from 69% to 100%. The tool that got us there is not a smarter rule. It is a fixed test corpus that every rule change has to pass.
ONXZero protects the websites and booking engines of hotels running on the Ellipse platform. Those forms are the worst case for a signature-based WAF. A guest types a free-text note in Slovak, German or Hungarian, with dashes, quotes, semicolons and the occasional word like update or select. Staff paste HTML from Outlook into reservation notes. One false block there is not a log line: it is a lost booking.
A corpus built from real parameter names
We exported the parameter names our clients actually see over 30 days: every URL, method and field combination, with counts, but no values. That gave us the real shape of the traffic: the booking order form, room search with JSON room lists, the manager login, calendars, ad click IDs.
From it we wrote 63 synthetic but realistic legitimate requests, using those exact field names:
- guest notes in Slovak, English, German and Hungarian, with diacritics, emoji and line breaks
- names with apostrophes (O'Brien), company names with ampersands
- notes that naturally contain SQL-looking words: “Príchod 14:00 -- update termínu”, “; update v systéme”
- HTML e-mail templates and reservation notes pasted from Outlook
- ad click IDs, base64 and PHP-serialized state, passwords full of symbols
Next to them sit the attacks: textbook payloads for every class we cover, plus the exploits we actually see on these sites, such as React2Shell, Next.js Server Actions and Spring4Shell.
A small runner pushes every request through the client's real inspection code, once with the rule set the server distributes and once with the built-in fallback. It reports two numbers, legitimate requests flagged and attacks missed, and fails on anything new.
The first measurement: wrong in both directions
The first run flagged 6 of 63 legitimate requests (9.5%) and caught only 27 of 39 attacks (69%). Production had looked clean, with almost no regex hits on real guests in 30 days. The corpus showed that this was partly luck.
The false positives were all rules matching ordinary words:
- a bare
--treated as an SQL comment, as in “14:00 -- update” or a password containing-- ; updatetreated as a stacked SQL queryjavascript:anddata:anywhere in text, as in “JavaScript: konferencia”sleep(as a time-based SQL injection, as in “sleep(ing) problem”
The misses had one cause. Before matching, the WAF asks whether a value “looks like normal text” so that notes are not over-scanned. That filter only knew SQL, XSS, shell and ../ indicators. Anything else that read like text went straight through: admin'--, ${jndi:…}, php://filter, internal-network URLs, wget …, | sh, XXE entities.
The fixes: narrower rules, wider filter
The principle was simple: a rule should match the syntax of an attack, not a word that happens to appear in one.
- SQL comment only after a quote or bracket (
x'--,x') -- -), not prose dashes. - Time-based SQLi needs a number:
SLEEP(5), notsleep(ing). - Stacked queries need real SQL after the semicolon:
; DROP TABLE,; UPDATE users SET, not “; update v systéme”. javascript:anddata:only in URI form (javascript:alert,data:text/html;…).- New rules for PHP stream wrappers (
php://,phar://) and Twig template injection; command lists now includenc,python,perl.
The “normal text” filter went the other way. It now recognises structural exploit syntax that no guest ever types (a quote followed by a comment, ${jndi:, stream wrappers, XML entities, template expressions, internal URLs, backticks around commands), and it looks at one URL-decoded layer as well.
The server-side rule changes went out as a database migration, and it only touched rules still at their shipped default. Rules an administrator had edited were left alone.
Real attacks caught our own regression
Textbook payloads are a start, but attackers do not read textbooks. The next day we added 16 real payloads from 30 days of incidents, with no IPs and no guest data. Two of them failed.
The first was a scan of the booking currency parameter: -1 OR 5*5=25 -- and -1" OR 5*5=25 --. Only the bare -- rule had ever caught it, the same rule we had just narrowed. Our fix for false positives had opened a gap. A dedicated rule for numeric boolean conditions closed it the same day, before any scanner came back.
The second was a React2Shell payload split across fields, where one field held only the _prefix chunk with child_process. It read like text to the filter, and older clients had labelled its siblings as XSS. It now has its own rule, and the server relabels such incidents correctly.
The corpus also took in one case from the other side: a reservation note saved by staff in the manager, flagged as XSS because it contained the word data: in a long HTML note. The site was in log-only mode, so nobody was blocked. The rewritten data: rule already passes it, and the note now stays in the corpus as a permanent test.
Results
| Stage | Legitimate flagged | Attacks caught |
|---|---|---|
| First measurement | 6 of 63 (9.5%) | 27 of 39 (69%) |
| After the rule fixes | 2 of 63 (3.2%) | 40 of 42 (95%) |
| With real attack samples and the regression fix | 2 of 64 (3.1%) | 56 of 58 (97%) |
| After the CRS comparison (client 2.18.10) | 2 of 69 (2.9%) | 62 of 62 (100%) |
The two remaining false positives are deliberately awkward notes: ../ written in prose, and a guest typing onload= into a note. Fixing either with a regex would weaken detection, so they stay as known gaps until per-field context can decide. We also had two known misses; the comparison below is how we closed them.
Compared with OWASP CRS
Out of the box, OWASP Core Rule Set 4.20 at its default paranoia level flagged 9 of the same 69 legitimate requests and caught 49 of the 62 attacks. We ran it on the Coraza 3.3.3 engine with the standard inbound anomaly threshold of 5, no rule exclusions, and the same requests with ordinary browser headers.
| Rule set | Legitimate flagged | Attacks caught |
|---|---|---|
| ONXZero 2.18.10 | 2 of 69 (2.9%) | 62 of 62 (100%) |
| CRS 4.20, paranoia level 1 (default) | 9 of 69 (13%) | 49 of 62 (79%) |
| CRS 4.20, paranoia level 2 | 23 of 69 (33%) | 55 of 62 (89%) |
| CRS 4.20, paranoia level 3 | 43 of 69 (62%) | 61 of 62 (98%) |
| CRS 4.20, paranoia level 4 | 59 of 69 (86%) | 62 of 62 (100%) |
This table needs a careful reading:
- We had home advantage. ONXZero's rules were tuned on this very corpus, so its numbers are in-sample. The last 9 requests were even added after the first CRS run, to cover what it found. CRS saw it cold.
- CRS is meant to be tuned. Production CRS deployments add rule exclusions for fields like rich-text editors, so an untuned run overstates its false positives. At level 1 they came almost entirely from HTML in staff editor fields and from notes like “JavaScript: konferencia”, “sleep(ing)” and “Char (znak)”. From level 3 it flags nearly every note with Slovak diacritics.
- CRS missed the React2Shell payloads at levels 1 and 2. It is a Node.js exploit sprayed at every site, and generic rules do not target it. Before we wrote a dedicated signature, we caught it only by accident, through a generic command-injection rule.
- CRS caught both of our known misses, at every level. At levels 3 and 4 it also catches more attacks than we do overall, and pays for that with most legitimate notes. CRS scans every field, while we skipped token-like field names such as
hash, and it reads a remote URL in a file parameter as file inclusion. Client 2.18.10 does both: token-named fields are skipped only when the value really looks like a token, and an absolute URL in a parameter likepageortemplateis treated as an inclusion attempt.
So the comparison says less about which rule set is better than about where generic rules hurt: the free-text fields of a booking form.
Limits of this test
The corpus is small: 69 legitimate requests and 62 attacks, chosen to cover the field types and attack classes we see. The percentages describe this traffic, not web applications in general. One request more or less moves a row by 1.4 to 1.6 points, and 100% on a set we tuned against means only that we have no known misses left, not that nothing gets through. We are not publishing the corpus, because it is derived from our clients' real traffic.
What changes from here
What changed most is the process, not the rules. Every rule change now runs against the corpus before it ships, in both directions at once. Real incident samples keep being added, and every false positive we find in production becomes a permanent test. The next step is the one regexes cannot take: a positive model of what each form field normally contains, so that ../ in a guest note and ../ in a file parameter stop looking the same.