The exploit was in the JSON. The URL looked fine.
A guest does not book a room with a query string. The browser sends a POST. Dates, names, extras, a room filter, a rate — they live in the body. If your WAF never reads that body, it is inspecting the envelope and ignoring the letter.
Most public pages still look like HTML. The booking engine API does not. Rooms, offers, availability, stay prices, check-in — they travel as JSON on a handful of POST URLs, often from fetch or XHR, not from a classic form. The path is short and boring. /api/rooms. /api/checkin/. /api/offers. Nothing in the address bar looks like an attack.
The payload is where the application logic lives. It is also where a crafted string can hide: a quote in a guest-name field, a nested object that the backend concatenates into SQL, a filter the booking engine never expected to see as an array.
An edge WAF can see the body, but it sees it without context. It does not know which field is a guest name and which one ends up in a query on the origin. It runs generic rules with body-size limits. And many small properties never proxy through the edge at all — DNS only, TLS on the server.
What we do not do
- Scan every POST on the site. Empty path lists stay off. A blanket JSON scan is how you invent false positives in a CMS save or a cookie banner.
- Treat “we’re behind a CDN” as “the origin is inspected”. Edge for volume. Origin for context. They are not the same layer.
What we do
ONX runs as PHP auto_prepend_file. For listed JSON endpoints it reads the body, walks the fields, and scores the same way it already scores query strings and form posts. A hit is tagged as a JSON-body finding, so it shows up in incidents without guessing.
We inspect the exact bytes the application will parse, the same way a PHP backend does. A WAF that decodes JSON differently from PHP — duplicate keys, \u0027 escapes, deep nesting — is a WAF with a bypass built in. We use PHP’s own json_decode: \u0027 becomes a quote before we score, the last duplicate key wins, and depth stops where PHP stops.
On listed paths we decode regardless of Content-Type (multipart aside — PHP never hands that to the app as raw JSON). An oversized body is a finding, not a pass: blocked in enforce mode, logged in shadow. If the body is not valid JSON, we skip the JSON pass — the application would fail the same parse. That is the one case where we would rather miss a scan than invent a parser the app does not have.
The first wave is the booking-engine JSON surface our clients actually run: rooms, offers, availability, prices, check-in — not the whole internet. If a path is not on the list, we do not open the envelope. Path plus POST is the gate.
POST /api/checkin/
Content-Type: application/json
{"guest":{"name":"x\u0027 OR \u00271\u0027=\u00271"}}
After decode it is the same injection it would be in a form post. In the console it is a JSON-body finding on json_body.guest.name, not a mystery regex on the URL.
Decisions stay on the origin, in the EU. The booking engine keeps talking JSON. The WAF does not become a second application.
Why this belongs in a blog, not a launch tweet
JSON inspection is easy to oversell. “We now parse all APIs” sounds like coverage. It is also how you break a live property. The honest version is narrower: we now look at the bodies that matter, on the clients that asked for it, with a switch that turns the whole thing off from the console.
If the URL looks harmless and the request still got through, look at the body. That is where the work is. That is where the exploit will be too.
Want your endpoints on the list? Ask us.
Back to the blog