Skip to main content

Rules and the CRS

The rules field of a WAF configuration is a SecLang program. It is the same language ModSecurity uses, so existing rules and the published CRS documentation apply directly.

The embedded Core Rule Set​

The OWASP CRS v4 is bundled in the extension. Import it as a preset:

@import_preset crs

SecRuleEngine On

@import_preset is an extension of the language provided by this engine — it pulls in the packaged ruleset without you having to ship the CRS files yourself. SecRuleEngine On must come last; it arms the engine after the rules are loaded.

The engine passes 100% of the CRS v4 regression suite, so CRS behaviour matches what its documentation describes.

Rule id ranges​

Useful when reading events, because the id tells you what family fired:

RangeFamily
9000xx–9010xxExclusions (before and after CRS)
9200xxProtocol enforcement and anomalies
9300xxLFI — local file inclusion
9310xxRFI — remote file inclusion
9320xxRCE — remote command execution
9330xxPHP injection
9340xxGeneric code injection
9410xxXSS
9420xxSQL injection
9430xxSession fixation
9440xxJava attacks
9500xxResponse leakage and error disclosure

Your own rules​

Custom rules can stand alone or sit alongside the CRS:

SecRule REQUEST_URI "@contains /admin" \
"id:1001,phase:1,deny,status:403,msg:'Admin access denied'"

SecRule REQUEST_HEADERS:User-Agent "@pm sqlmap nikto nmap" \
"id:1002,phase:1,deny,status:403,t:none,t:lowercase,msg:'Scanner user-agent'"

SecRule ARGS "@rx <script" \
"id:1003,phase:2,deny,status:403,t:none,t:urlDecodeUni,t:lowercase,msg:'Inline script in an argument'"

Keep your own ids outside the CRS ranges — the 1000–99999 band is conventionally free.

Combined with the CRS, order matters: rules meant to run before the CRS go above the import, and exclusions go after it.

SecRule REMOTE_ADDR "@ipMatch 10.0.0.0/8" \
"id:900,phase:1,allow,nolog,msg:'internal traffic bypasses the waf'"

@import_preset crs

SecRuleUpdateTargetById 942100 "!ARGS:search_query"

SecRuleEngine On

Phases​

SecLang splits processing into five phases. Which ones run depends on which plugin evaluates the ruleset:

PhaseSeesRequest pluginGlobal validator
1Request headers, URI, cookies✅✅
2Request body and arguments✅✅ (no body)
3Response headers and status✅—
4Response body✅—
5Logging✅✅

The global validator never reads a body, so phase-2 rules that depend on REQUEST_BODY or ARGS_POST will not fire there.

Paranoia levels and the anomaly score​

The CRS does not deny on a single match. Rules contribute to an anomaly score, and a request is denied once the score crosses a threshold. Two knobs control how aggressive that is:

  • the paranoia level (1–4) — how many rules are active. Level 1 is production-safe; each level up adds broader rules and more false positives.
  • the anomaly threshold — how much accumulated score denies a request. Lower is stricter.

Both are fields on the configuration — you do not write them by hand:

{ "crs": { "paranoia_level": 1, "inbound_anomaly_threshold": 5 } }

They generate exactly the directives you would have written, placed ahead of every ruleset:

SecAction \
"id:40000,\
phase:1,\
nolog,\
pass,\
t:none,\
setvar:'tx.blocking_paranoia_level=1',\
setvar:'tx.inbound_anomaly_score_threshold=5'"

@import_preset crs

SecRuleEngine On

Writing the SecAction yourself still works and still wins if it comes later — the fields are a shortcut for the common case, not a replacement for SecLang.

Start at paranoia level 1

Level 1 with the default threshold is what the CRS is tuned for. Raising the paranoia level before you have tuned level 1 produces a flood of false positives and usually ends with someone disabling the WAF entirely. Go up only after tuning the level below.

Geolocation​

@geoLookup and the GEO collection work once a geolocation database is configured — DB-IP lite by default, free and keyless:

SecRule REMOTE_ADDR "@geoLookup" "id:10001,phase:1,deny,status:403,msg:'request from %{GEO.COUNTRY_CODE}',chain"
SecRule GEO:COUNTRY_CODE "@within KP IR" "t:none"

Without one, @geoLookup never matches and GEO stays empty. SecGeoLookupDb is accepted and ignored: the database is the one the extension downloads. As for any chain, keep both lines in the same element of rules: each element is compiled on its own, and Compile refuses a chain split across two.

DNS blocklists​

@rbl asks a DNS blocklist about the address it targets — normally REMOTE_ADDR:

SecRule REMOTE_ADDR "@rbl bl.spamcop.net" "id:10002,phase:1,deny,status:403,msg:'listed by SpamCop'"

The engine never waits on DNS: the plugin asks the zones the rules name before evaluating, waits at most reputation.rbl.wait-millis for an answer nobody has asked yet, and anything not known in time counts as not listed. See DNS blocklists, and read the part about public resolvers before relying on Spamhaus.

Operators that are parsed but never match​

Six SecLang operators are accepted by the parser and always evaluate to no match. They depend on a host environment the engine does not provide — a filesystem, an XML toolchain. Each one logs at debug level when it is reached.

OperatorWhat it would do
@inspectFileHand an uploaded file to an external program
@validateDTDValidate an XML body against a DTD
@validateSchemaValidate an XML body against an XSD
@verifyCCLuhn check on a candidate credit card number
@verifySSNUS social security number check
@verifyCPFBrazilian CPF check
This is silent

A rule built on one of these never fires — it does not error, and nothing appears in the WAF events because there is no match to report. If your ruleset relies on one of them, it is providing no protection. Turn on integration.log to see them logged as they are reached, and check the issue tracker before depending on any of them.

Everything else the CRS uses — including @rx, @pm, @pmFromFile, @ipMatch, @ipMatchFromFile, @detectSQLi, @detectXSS, @validateByteRange — is implemented, which is why the CRS regression suite passes in full.