Skip to main content

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.

A WAF configuration: enable, block, inspection sections, and the composed rules

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​

FieldDefaultWhat it does
enabledtrueWhen false the plugin does nothing at all — a fast way to disable protection on every route referencing it
blocktruefalse 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_bodytrueWhether 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_limitnullRequest body bytes buffered and inspected. Empty means the 2 MB default, never unlimited
inspect_output_bodytrueWhether the response body is inspected
output_body_limitnullResponse 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_actioninspect_prefixWhat to do with a body longer than the limit: inspect the beginning and let it through, or reject
decompressed_input_body_limitnullWhat a compressed request body may expand to. Empty means 64 MiB, 0 turns it off. See compressed bodies
max_input_compression_rationullHow many times a compressed request body may expand, judged once it is past 1 MiB. Empty means 100, 0 turns it off
undecodable_body_actionrejectA 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.

FieldCRS variableDefaultWhat raising it costs
crs.paranoia_leveltx.blocking_paranoia_level1Each level adds broader rules and more false positives
crs.detection_paranoia_leveltx.detection_paranoia_levelsame as aboveNothing — the extra rules run without affecting the verdict
crs.inbound_anomaly_thresholdtx.inbound_anomaly_score_threshold5Lowering it denies more; raising it tolerates more
crs.outbound_anomaly_thresholdtx.outbound_anomaly_score_threshold4The same, for responses
crs.early_blockingtx.early_blockingoffDenies at the end of phase 1, giving up the rules that need the body

The paranoia level field, with the cost of each level next to it

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.

Nothing is emitted without a CRS import

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.

Detection may not be lower than blocking

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.

Start at paranoia level 1

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:

Response inspection now actually runs

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"]
}
Media types are matched on the type alone

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:

ValueBehaviour
inspect_prefix (default)Inspect the beginning, forward the whole body, and flag the event with truncated: true
rejectRefuse 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:

LimitDefaultRefused with
decompressed_input_body_limit64 MiB413
max_input_compression_ratio100, judged past 1 MiB413

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.

The Core Rule Set refuses compressed requests by default

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.

gzip and deflate on Otoroshi's default server

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"
]
}