WAF Returns 406 for Thai Text in POST: Fixing It Without Root
An ordinary Thai message triggers a 406 from the WAF when saved, and I cannot edit the rules because it is not my server — the remaining option is to keep the WAF from seeing that text in the first place, then reconstruct it in the app.
A Laravel site I maintain on shared hosting (DirectAdmin + nginx + the server owner's ModSecurity) suddenly stopped saving certain records. Clicking Save brought up a 406 out of nowhere, even though everything else in the fields was the same. After tracing it, I found that the culprit was certain patterns of Thai text in the POST body that happened to match a WAF rule.
This article is not about changing rules — I have written several posts about ModSecurity on this site, but all of them were from the perspective of someone with root access, who could write exclusions, reload nginx, and read modsec_audit.log. This time I am wearing a different hat: a hosting tenant who can log in only to my own space and cannot change anything outside it. And the work needs to move forward today, without waiting three days for a support ticket.
All domains, usernames, and sample text in this article are fictional. The code structure is what I actually use.
Symptoms: saving certain Thai sentences simply returns 406
The form has a field for a long block of Thai text. Enter one sentence and it saves; enter another and it immediately returns 406. The most misleading part when troubleshooting is that it does not happen with every sentence, making the system look "temperamental" when it is actually very consistent. The same sentence gets blocked every time.
What makes this harder to trace than usual is that the app knows nothing about it — laravel.log is empty. No exception, no validation error, not even a line showing an incoming request, because that request never reaches PHP.
First, confirm who is returning 406
Before blaming my own code, I need to identify which layer the 406 comes from. Look directly at the response headers:
curl -i -s -X POST https://app.example.com/records \
--data-urlencode 'note=1 เม็ด วันละ 3 ครั้ง หลังอาหาร' \
-H 'X-Requested-With: XMLHttpRequest' | head -12
HTTP/2 406
server: nginx
content-type: text/html
content-length: 226
The clues are that the response is very short, has none of the headers Laravel adds (no new session cookie, no X-RateLimit-*), and arrives unusually quickly because the framework never bootstraps. These are the signs of a request being blocked at the web server layer, rather than in the app.
I would have liked to read modsec_audit.log to find out which rule id fired, but that file is outside my space, and tenants cannot open it. DirectAdmin only shows the error log for my own domain, which is completely silent because Apache/PHP did not produce the response. That leaves black-box diagnosis: send requests and see which ones pass and which ones fail.
Isolate the part of the text that gets blocked
The quickest way is to create a test endpoint that does nothing except return 200, then send the text in chunks, repeatedly cutting it in half.
// routes/web.php — ชั่วคราว ลบทิ้งเมื่อ isolate เสร็จ
Route::post('/waf-probe', fn () => response('ok', 200))
->withoutMiddleware([\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class]);
URL=https://app.example.com/waf-probe
for t in "1 เม็ด วันละ 3 ครั้ง หลังอาหาร" \
"1 เม็ด วันละ 3 ครั้ง" \
"วันละ 3 ครั้ง หลังอาหาร" \
"ครั้งละ 1 เม็ด" \
"1 เม็ด" ; do
code=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$URL" \
--data-urlencode "probe=$t")
printf '%s <- %s\n' "$code" "$t"
done
406 <- 1 เม็ด วันละ 3 ครั้ง หลังอาหาร
406 <- 1 เม็ด วันละ 3 ครั้ง
200 <- วันละ 3 ครั้ง หลังอาหาร
200 <- ครั้งละ 1 เม็ด
200 <- 1 เม็ด
The result was clear — none of the individual words caused a problem, but arranging them as "number + Thai word + number + Thai word" in a particular order matched a CRS SQL injection detection rule (the 942xxx group that uses libinjection). It judges "token patterns," rather than meaning. When the tokenizer encounters a string with numbers alternating with separators in a familiar sequence, it flags it as a payload.
First, confirmation that this is not a bug in my app, and second, sample text that reproduces the issue exactly — these are the samples to attach to a ticket asking the server owner to whitelist it. Without a reproducible example, the ticket will come back with "tested and working normally" every time.
The options a hosting tenant actually has
Fix the cause
Write an exclusion file for this domain, disable only the rule id that fires, then reload nginx. Done in 5 minutes, without touching the app code.
Give the WAF nothing to match
Change the format of the payload sent from the browser so it no longer contains the pattern the rule matches, then reconstruct it in the app before the controller runs.
I weighed three options before deciding. The first was to wait for the host to adjust it — I would pursue that, but customers could not use the site while waiting. The second was to change the form to send a JSON body through fetch instead of form-urlencoded — this helps in some cases, but not all, because ModSecurity can read both JSON and urlencoded request bodies, depending on which body processors are enabled. I could not guess how that server was configured. The third was to encode the payload so the WAF would no longer see raw Thai text.
I chose the third option because it was the only one entirely under my control and did not depend on configuration I could not see. I also opened a ticket in parallel. If the server owner eventually whitelists it, I can remove the workaround.
Frontend: intercept submit and encode before sending
The idea is to intercept the submit event for every POST form on the site, except those explicitly excluded, collect all field values into a query string, base64-encode it, and put it in a single field named __waf, while removing the name attribute from the original fields. Otherwise, two copies of the data will be sent, and the WAF will still see the raw version.
// resources/views/partials/waf-safe.blade.php — ส่วน <script> (include ใน layouts/app)
(function () {
const SKIP = ['_token', '_method', 'photo', 'avatar', 'attachment'];
const MAXLEN = 2000;
// btoa รับได้เฉพาะ byte เดียว ต้องแปลงเป็น UTF-8 ก่อน ไม่งั้นภาษาไทยพังทันที
const b64 = (s) => btoa(String.fromCharCode(...new TextEncoder().encode(s)));
document.addEventListener('submit', function (e) {
const form = e.target;
if (!form || (form.method || '').toLowerCase() !== 'post') return;
if (form.hasAttribute('data-waf-skip')) return;
if (form.querySelector('input[type=file]')) return; // multipart ไม่ยุ่งด้วย
const pairs = [];
form.querySelectorAll('input, textarea, select').forEach(function (el) {
if (!el.name || el.disabled) return;
if (SKIP.includes(el.name)) return;
if ((el.type === 'checkbox' || el.type === 'radio') && !el.checked) return;
if (String(el.value).length > MAXLEN) return; // ยาวเกิน ปล่อยส่งปกติ
pairs.push(encodeURIComponent(el.name) + '=' + encodeURIComponent(el.value));
el.removeAttribute('name'); // กันส่งซ้ำ + กัน WAF เห็นตัวดิบ
});
if (!pairs.length) return;
const box = document.createElement('input');
box.type = 'hidden';
box.name = '__waf';
box.value = b64(pairs.join('&'));
form.appendChild(box);
}, true);
})();
I also use encodeURIComponent on field names because the actual forms are full of names like items[0][qty]. Without encoding everything, decoding with parse_str will produce the wrong structure.
Laravel: middleware to decode it
On the server, the data must be decoded before everything else, so the controller and FormRequest can work as though nothing happened. Empty values need special care — Laravel already has ConvertEmptyStringsToNull in the middleware group, but it operates on the values present when it runs. My values are merged in afterward, so I have to reproduce that behavior manually.
<?php
// app/Http/Middleware/DecodeWafSafe.php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
class DecodeWafSafe
{
public function handle(Request $request, Closure $next)
{
$blob = $request->input('__waf');
if (is_string($blob) && $blob !== '') {
$raw = base64_decode($blob, true); // strict: ขยะไม่ผ่าน
if ($raw !== false) {
parse_str($raw, $fields);
$request->request->remove('__waf');
$request->merge($this->emptyToNull($fields));
}
}
return $next($request);
}
/** เลียนแบบ ConvertEmptyStringsToNull เพราะเรา merge หลังตัวนั้นรันไปแล้ว */
private function emptyToNull(array $in): array
{
foreach ($in as $k => $v) {
$in[$k] = is_array($v) ? $this->emptyToNull($v) : ($v === '' ? null : $v);
}
return $in;
}
}
// bootstrap/app.php — ต้อง prepend ให้รันก่อนตัวอื่นใน group web
->withMiddleware(function (Middleware $middleware) {
$middleware->web(prepend: [
\App\Http\Middleware\DecodeWafSafe::class,
]);
})
Empty values are not a minor detail. Without converting '' to null, a date field the user leaves blank becomes an empty string sent to MariaDB with strict mode enabled, resulting in a 500 instead of a 406 — all that does is move the failure somewhere else. I ran into this on the first deployment.
Fields to exclude, and the reason for each
- _token — Laravel reads the CSRF token from the request before it reaches my middleware. Hiding it in __waf as well will cause a 419 every time.
- _method — The field specifying the overridden verb (PUT/PATCH/DELETE) is read at the routing layer and must always be sent raw.
- File upload fields — Skip the entire form if it has input[type=file], because rebuilding multipart with JS is not worth the effort, and files are not the text the rule is matching anyway.
- Fields longer than 2000 characters — base64 adds roughly a third to the length. Packing several fields together could hit nginx's body size limit, so it is better to send long values normally.
- Forms marked data-waf-skip — An escape hatch for forms with special behavior, such as forms where other JS continues to manipulate the fields.
Traps I encountered after deployment
The first and most painful was forms submitted with form.submit() from JavaScript — that method does not fire the submit event, so my interceptor never runs. The data goes to the WAF raw as before, and gets the same 406. The fix is to call form.requestSubmit() instead (which does fire the event), or make the button a regular submit button.
This is a workaround, not a fix — the WAF is still misconfigured; I am just handing it something it cannot match. Do not treat this as an additional security measure, because the actual effect is less inspection. Input validation and prepared statements in the app must be more rigorous than before, not less. As soon as the server owner agrees to whitelist it, remove this immediately.
The second issue involves other people on the team. This code runs quietly across the entire site. Someone who comes along later, opens DevTools, and sees a request containing only _token and __waf will wonder what the form is sending. So I wrote a long comment at the top of the partial file explaining what it is, why it is enabled, and under what conditions it should be removed.
I do not particularly like workarounds like this. They add something I have to explain to the next person maintaining the system. But weighed against leaving customers unable to save their work for days while waiting on a ticket, this is the option I have to take for now. If you encounter the same problem and have root access, do not follow this article. Writing an exclusion for the specific rule id is a much more direct solution. If I have left anything out or made a typo, I will come back and correct it later.
Want us to set this up?
Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.
Hit something like this? Stuck somewhere?
Come trade notes on the LinuxThai board



Comments 0
No comments yet — be the first