WAF configurations
A WafConfig holds a ruleset plus the settings that decide how much of each request and response
the engine gets to see. Routes reference one by id, and several routes can share the same
configuration.
Threat Protection → WAF configs.

Block requests off is monitoring mode. Below, the rules the config composes to — referenced
rulesets first, then the inline entries — with a Compile button that checks them without saving.
Fields
| Field | Default | What it does |
|---|---|---|
enabled | true | When false the plugin does nothing at all — a fast way to disable protection on every route referencing it |
block | true | false is monitoring mode: rules run and events are emitted, nothing is denied |
rules | [] | The SecLang ruleset, one entry per line or per block. See rules |
inspect_input_body | true | Whether the request body is read and given to the engine. Form, multipart, XML and JSON bodies are split into ARGS, JSON including every +json type (application/vnd.api+json, application/problem+json, ...) |
input_body_limit | null | Request body bytes buffered and inspected. Empty means the 2 MB default, never unlimited |
inspect_output_body | true | Whether the response body is inspected |
output_body_limit | null | Response body bytes buffered and inspected. Empty means the 2 MB default |
output_body_mimetypes | [] | Restricts response inspection to these media types. Empty means all |
oversize_body_action | inspect_prefix | What to do with a body longer than the limit: inspect the beginning and let it through, or reject |
decompressed_input_body_limit | null | What a compressed request body may expand to. Empty means 64 MiB, 0 turns it off. See compressed bodies |
max_input_compression_ratio | null | How many times a compressed request body may expand, judged once it is past 1 MiB. Empty means 100, 0 turns it off |
undecodable_body_action | reject | A request body in an encoding the WAF cannot decode: refuse it with 415, or inspect_raw |
rulesets | [] | Rulesets to run, in order, before the inline rules |
rules | [] | Inline SecLang, applied after every referenced ruleset |
crs | {} | The Core Rule Set dials — paranoia level and anomaly thresholds, as fields |
The Core Rule Set dials
CRS is driven by a paranoia level and an anomaly threshold. Setting either used to mean hand-writing
a SecAction with tx. variables into the rule text — the single most common tuning operation in
the product, expressed as folklore. They are fields now.
| Field | CRS variable | Default | What raising it costs |
|---|---|---|---|
crs.paranoia_level | tx.blocking_paranoia_level | 1 | Each level adds broader rules and more false positives |
crs.detection_paranoia_level | tx.detection_paranoia_level | same as above | Nothing — the extra rules run without affecting the verdict |
crs.inbound_anomaly_threshold | tx.inbound_anomaly_score_threshold | 5 | Lowering it denies more; raising it tolerates more |
crs.outbound_anomaly_threshold | tx.outbound_anomaly_score_threshold | 4 | The same, for responses |
crs.early_blocking | tx.early_blocking | off | Denies at the end of phase 1, giving up the rules that need the body |

The point of the field is not that it is shorter than the SecAction — it is that the cost of each
level is written next to the level.
{
"crs": {
"paranoia_level": 1,
"detection_paranoia_level": 2, // see what level 2 would cost, without paying it
"inbound_anomaly_threshold": 10 // more tolerant than the CRS default of 5
}
}
They compile to one SecAction written ahead of every ruleset and inline rule. That order is
the whole mechanism, not a preference: CRS applies its own defaults in
REQUEST-901-INITIALIZATION.conf guarded by &TX:name "@eq 0" — only if nobody has said
otherwise — so whichever setvar runs first wins. Emitted after the preset, they would do nothing.
An empty crs block emits nothing, so a configuration that says nothing runs exactly what it ran
before. These numbers decide what a WAF blocks, and an upgrade must not move them.
The dials are read by CRS and by nothing else. A configuration that sets them but never imports CRS —
no @import_preset crs in its rules or in any enabled ruleset it references — gets no generated
rule at all, because one that sets variables nothing reads is a setting that silently does nothing.
The mismatch is reported rather than swallowed: Compile says so, and the gateway logs a warning when the configuration is loaded.
Rules above the detection level never run at all, so the blocking level would have nothing to act on. A configuration that says otherwise is refused on write rather than corrected — a paranoia level of 7 is a mistake with a number in it, and storing 1 instead would leave you certain you had raised it.
Level 1 with the default threshold is what CRS is tuned for. Raising the level before you have tuned
level 1 produces a flood of false positives and usually ends with someone switching the WAF off. Use
detection_paranoia_level to measure the next level first, then
learning mode to decide whether to move.
Body inspection
Body inspection is where the WAF earns most of its detections and spends most of its budget. Two things are worth understanding before you tune it.
The engine only sees what you give it. With input_body_limit set to 65536, a rule cannot
match on byte 65537. An attacker who knows the limit can pad past it, so the limit is a cost
control, not a security boundary — pair it with a hard body-size limit on the route
(Otoroshi ships a Request body length limiter plugin for that).
The limit is also a memory bound. Nothing beyond it is ever held: the body is read up to the limit, inspected, and then forwarded as the buffered head followed by the rest of the stream, which is never buffered at all. A 500 MB upload against a 2 MB limit costs 2 MB of heap, not 500 MB.
Response inspection is opt-in territory. It is the right place for leakage rules (stack traces,
error disclosure, data patterns), but it means buffering responses. Restrict it with
output_body_mimetypes so you are not inspecting images and downloads:
Until this release two defects meant it usually did not. Response bodies were only inspected when
the request carried a body — false for every plain GET — and the media type filter compared
the raw Content-Type, so a configured text/html never matched text/html; charset=utf-8.
Both are fixed, which means a configuration that has had inspect_output_body: true sitting there
harmlessly will start inspecting responses on upgrade. If your ruleset carries phase 3 and 4 rules
you have never seen fire, set block: false and read the events for a few days first — the same
discipline as arming the request path. See tuning.
{
"inspect_output_body": true,
"output_body_limit": 262144,
"output_body_mimetypes": ["application/json", "text/html"]
}
output_body_mimetypes is compared against the media type of the response Content-Type, with its
parameters ignored — so an entry of text/html matches a response served as
text/html; charset=utf-8, which is what backends actually send.
A subtype wildcard works too: text/* matches every textual response. A response with no
Content-Type at all is evaluated rather than skipped.
Bodies longer than the limit
A body past the limit was only inspected up to the cut, so a clean verdict on it proves less than a
clean verdict on a whole one. oversize_body_action says what to do about that:
| Value | Behaviour |
|---|---|
inspect_prefix (default) | Inspect the beginning, forward the whole body, and flag the event with truncated: true |
reject | Refuse the request with 413 — a body nothing could inspect does not go through |
reject is the strict reading, and the right one for an API whose payloads are all small. On a
route that legitimately carries large uploads it will refuse them, so raise the limit instead, or
scope the WAF plugin away from the upload path.
Either way the truncation is visible: CloudApimWafTrailEvent carries truncated and
oversize_rejected, so "we did not see all of it" is never silent.
Compressed bodies
A compressed body is read the way its recipient will read it. A request body sent with
Content-Encoding: gzip, deflate or br is decompressed for the rules and forwarded as it
came, and so is a response body. The input and output limits apply to the decompressed bytes.
What a compressed request body expands to is held to two limits, so that a few kilobytes cannot stand for gigabytes once the backend decompresses them:
| Limit | Default | Refused with |
|---|---|---|
decompressed_input_body_limit | 64 MiB | 413 |
max_input_compression_ratio | 100, judged past 1 MiB | 413 |
They are checked on the inspected head first, so a decompression bomb is refused before anything is forwarded. Past the head, the body is checked on its way to the backend and cut the moment a limit is crossed: the backend sees a broken upload rather than decompressing the rest.
A body that cannot be read is refused rather than let through unseen: an encoding the WAF does not
decode (zstd, a chain of codings) with 415, a body that does not decode as what it claims with
400. undecodable_body_action: "inspect_raw" inspects such a body as it is instead. In monitoring
mode (block: false) none of these refuse anything: the event is emitted and the body goes on.
The trail event says why a body was refused, in rejected_reason: decompressed_size,
compression_ratio, undecodable_encoding or corrupt_body.
CRS lists Content-Encoding among its restricted request headers (tx.restricted_headers_basic),
so rule 920450 refuses any request that carries one, at every paranoia level. With CRS imported, a
compressed request body only reaches the decoding above once that header is allowed: take
/content-encoding/ out of tx.restricted_headers_basic, or exclude 920450 for it.
The HTTP server Otoroshi runs on by default (Play) decompresses gzip and deflate request bodies
itself, before any plugin, and without a limit. The rules still read them in clear, but by then
nothing says the body was compressed, so the two limits above cannot apply to them: they hold
br bodies, and gzip and deflate ones behind a server that leaves them encoded.
Compile and test
Two tools sit on the configuration form.
Compile parses and compiles the ruleset and reports the first error, with no traffic involved. Run it before every save.
Test evaluates the ruleset against a request you describe as JSON, and returns the full engine result — which rules matched, in which phase, and the resulting disposition:
{
"method": "POST",
"uri": "/post",
"headers": {
"host": "www.foo.bar",
"content-type": "application/x-www-form-urlencoded",
"user-agent": "curl/8.1.2"
},
"body": "q=1' OR '1'='1",
"protocol": "HTTP/1.1"
}
This is the fastest loop for writing a rule, and the only way to check a rule fires before it reaches production traffic.
Example
{
"id": "waf-config_1f3a...",
"name": "Public API",
"description": "CRS with a couple of local exclusions",
"enabled": true,
"block": true,
"inspect_input_body": true,
"input_body_limit": 1048576,
"inspect_output_body": false,
"output_body_limit": null,
"output_body_mimetypes": [],
"rules": [
"@import_preset crs",
"SecRuleUpdateTargetById 942100 \"!ARGS:search_query\"",
"SecRuleEngine On"
]
}