The filter that stayed silently empty
Three public failures in one night: a filter that never matched, search exploding to 500, and a 403 echoing input.
TL;DR
A broken anchored LIKE pattern made the amenity filter always return empty rows, fixed by switching to substring matching with wildcards escaped. An unsanitized search term crashed MySQL's full-text parser via a single asterisk, fixed by stripping operator characters at the boundary. A 403 response echoed user input back to attackers, fixed by replacing it with a static message while logging the raw value.
A website visitor typed a single word into a tourism listing search box and received a 500 page. Not at peak load, not because the database was down: a single asterisk inside the search term was enough to break the full-text parser inside the database. Meanwhile, a neighboring ticket showed a quieter symptom, the amenity filter returned zero rows for every user. No error, no loud log entry. The page simply stayed empty.
These three failures are the same class because all of them sit on the public surface, and none of them shouts. Two fail silently, the third talks too much. The fixes shipped in one night, and each one left a portable lesson.
The filter that never matched, because it was anchored
The amenity search pattern was built as: quote, word, percent. That pattern anchors at the start of the column value, so it only matches when the column begins with the word. The problem: the column does not store a word. It stores a JSON array of amenity names. The array always starts with a bracket, never with the word, so the anchored pattern could never match the canonical data shape, no matter what anyone searched for.
The fix changes the anchor into a substring match, percent on both sides. Wildcard escaping still applies. The MySQL manual gives the basis: "% matches any number of characters, even zero characters. _ matches exactly one character." [9], and when a wildcard itself needs to be found literally, "To test for literal instances of a wildcard character, precede it by the escape character." [9]. User input is still escaped before interpolation, so a percent or underscore typed by a visitor cannot pose as a wildcard.
Substring matching is not free. An amenity named wifi-id-room will also light up when a user wants plain wifi. The trade-off lives in a code comment next to its upgrade path, a normalized amenities table, which is genuinely not cheap on the database version in use. The decision was explicit: a slightly permissive public filter beats a filter that never works.
The test side was rebuilt too. The old test was a characterization test written before the fix: it locked the broken behavior and kept it green. A characterization test built from a bug is not a contract. The new test plants the canonical JSON array into the fixture and asserts one row back for a present amenity, zero rows for an absent one.
The search that exploded via the parser
The second failure was louder. The raw search term from the URL went into the full-text search call untouched. The database's full-text parser has its own language, and characters like the asterisk, plus, or parentheses are not plain text to it. One asterisk produced syntax error 1064, and the server answered with a 500 on a GET endpoint the entire world can call.
Sanitization happens at the trust boundary, in the handler before the query descends. Operator characters are stripped, extra whitespace collapsed, and the term is truncated at 100 characters. An empty result is treated as no filter rather than a failure, so a request consisting only of operators still earns a 200 with the unfiltered list. Why is stripping safe? The search mode is natural language. The MySQL manual states: "A natural language search interprets the search string as a phrase in natural human language (a phrase in free text). There are no special operators, with the exception of double quote (") characters." [8]. A mode that defines no operators cannot miss them, so removing operator characters changes no intended result, it removes syntax the mode never uses. "Full-text searching is performed using MATCH() AGAINST() syntax." [8], and that syntax is where input stops being trusted.
The message that echoed the answer back
The third failure was the quietest. The 403 detail on the export endpoint echoed back the requester-supplied module value. The flow was simple: the service wraps the error with the query value, the handler writes the error as-is, and an attacker receives exact confirmation of the string they sent. For reconnaissance, such feedback is solid currency. OWASP summarizes it as "Error handling is a part of the overall security of an application." [7], because "Unhandled errors can assist an attacker in this initial phase, which is very important for the rest of the attack." [7].
The fix does not silence the message, it relocates the content. The detail in the body becomes static: a description of the condition, no echo. The raw value is still recorded, but in server logs, where the audience is an auditor, not the requester. A new handler test asserts the raw value appears nowhere in the response body.
Three fixes, one principle behind them: guard at the edges. Test the LIKE pattern against the data format actually stored, sanitize public input before it reaches a specialized parser, and word the answer to an attacker as a condition rather than a mirror. Together they close failure channels that individually look trivial and collectively read as a half-dead site.
Sources
- OWASP Cheat Sheet Series, Error Handling (accessed 2026-10-12): "Error handling is a part of the overall security of an application."; "Unhandled errors can assist an attacker in this initial phase, which is very important for the rest of the attack."
- MySQL 5.7 Reference Manual, Full-Text Search Functions (Wayback snapshot, accessed 2026-10-12; live page blocked): "Full-text searching is performed using MATCH() AGAINST() syntax."; "A natural language search interprets the search string as a phrase in natural human language (a phrase in free text). There are no special operators, with the exception of double quote (") characters."
- MySQL 5.7 Reference Manual, String Comparison Functions (Wayback snapshot, accessed 2026-10-12): "% matches any number of characters, even zero characters. _ matches exactly one character."; "To test for literal instances of a wildcard character, precede it by the escape character."