OWASP CRS paranoia levels and false positives, the secure way
You raised the paranoia level to 2 because the audit said "stricter", and by lunch the support queue had users who cannot post a comment with two dashes in it. The WAF is right about the dashes. It is wrong about your users.
The short answer
Keep blocking at paranoia level 1 and set the detection paranoia level to 2, so PL 2 rules log without blocking. Collect the matches for a week, then write exclusions that remove one rule from one parameter on one path with ctl:ruleRemoveTargetById. Raise the blocking level only when the PL 2 logs show attacks, not users.
On this page
What goes wrong
The OWASP Core Rule Set sorts its rules into four paranoia levels (PL). PL 1 catches common attacks with very few false alarms. Each level above adds rules that catch more, and flag more normal traffic too. Every match adds to an anomaly score, and a request is blocked when the score reaches the threshold (5 by default).
Teams raise the level in one step because a report asked for it. Real users
start getting 403s for things like a comment with --, a profile that says
<3, or a search for a band name. The pressure to fix it fast leads to the
second mistake: turning off whole rules, or whole rule groups, for the whole
site.
Both mistakes have the same root: the change is made blind. CRS has a built-in way to see what a higher level would block, without blocking it.
What the docs say
Paranoia level 1 (PL 1) provides a set of rules that hardly ever trigger a false alarm (ideally never, but it can happen, depending on the local setup).
Source: CRS docs, Paranoia Levels
then the additional rules from the higher paranoia level are executed but will never count towards the anomaly score threshold used to make the blocking decision.
Source: CRS docs, Paranoia Levels (executing paranoia level)
for transactions to the location ‘web_app_2/function.php’, exclude the query string parameter ‘user_id’ from rule 920280
Source: CRS docs, False Positives and Tuning
The docs call it the "executing paranoia level". In CRS 4 the variable is
tx.detection_paranoia_level; the docs page does not show the variable name,
the crs-setup.conf.example file does.
The secure configuration
Step 1: block at PL 1, detect at PL 2. These lines go after the CRS setup include and before the rules:
Include @recommended-conf
SecRuleEngine On
Include @crs-setup-conf
# Rules at PL 1 block (anomaly score counts).
SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=1"
# Rules at PL 2 run and log, but never count towards blocking.
SecAction "id:900001,phase:1,pass,t:none,nolog,setvar:tx.detection_paranoia_level=2"
Include @owasp_crs/*.confStep 2: read a week of logs and list, for each PL 2 match, the rule ID, the path and the parameter. Decide for each one: attack, or user?
Step 3: for every "user" finding, remove one rule from one parameter on one path. Nothing wider. Then raise the blocking level.
SecAction "id:900000,phase:1,pass,t:none,nolog,setvar:tx.blocking_paranoia_level=2"
SecAction "id:900001,phase:1,pass,t:none,nolog,setvar:tx.detection_paranoia_level=2"
# /search?q=: free text; rule 942440 (SQL comment sequence) fires on "--".
SecRule REQUEST_FILENAME "@streq /search" \
"id:10100,phase:1,pass,t:none,nolog,ctl:ruleRemoveTargetById=942440;ARGS:q"
# /profile?bio=: free text; rule 942131 fires on "<3 ... &".
SecRule REQUEST_FILENAME "@streq /profile" \
"id:10110,phase:1,pass,t:none,nolog,ctl:ruleRemoveTargetById=942131;ARGS:bio"
# /comment text on a technical blog: users paste shell commands on purpose.
# Remove the RCE rules and two SQL rules from this one field only.
SecRule REQUEST_FILENAME "@streq /comment" \
"id:10120,phase:1,pass,t:none,nolog,ctl:ruleRemoveTargetByTag=attack-rce;ARGS:text,ctl:ruleRemoveTargetById=942440;ARGS:text,ctl:ruleRemoveTargetById=942510;ARGS:text"
Include @owasp_crs/*.confIn coraza-proxy-wasm each line is one entry in directives_map, written on a
single line without the backslashes.
Prove it
The test in secure-tests/owasp-crs-paranoia-levels-false-positives/run.sh
sends the same five requests through four configurations: three benign
(a band name with --, a bio with <3 ... &, a comment that mentions
`ls -la`), and two attacks on the same parameters. Under each block,
the rule IDs that matched per request, from the Envoy log.
== 1. Blocking PL 1
GET /search?q=rock%20and%20roll%20--%20best%20of%201975 -> 200
GET /profile?bio=I%20%3C3%20cats%20%26%20dogs -> 200
POST /comment -> 403
GET /search?q=1%27%20UNION%20SELECT%20password%20FROM%20users-- -> 403
GET /profile?bio=%3Cscript%3Ealert(1)%3C/script%3E -> 403
rules that matched:
/comment: 932125 932230 932250
/profile?bio=%3Cscript%3Ealert(1)%3C/script%3E: 941100 941110 941160 941390
/search?q=1%27%20UNION%20SELECT%20password%20FROM%20users--: 942100 942190 942270 942360
== 2. Blocking PL 2
GET /search?q=rock%20and%20roll%20--%20best%20of%201975 -> 403
GET /profile?bio=I%20%3C3%20cats%20%26%20dogs -> 403
POST /comment -> 403
GET /search?q=1%27%20UNION%20SELECT%20password%20FROM%20users-- -> 403
GET /profile?bio=%3Cscript%3Ealert(1)%3C/script%3E -> 403
rules that matched:
/comment: 932125 932230 932236 932250 942510
/profile?bio=%3Cscript%3Ealert(1)%3C/script%3E: 941100 941110 941160 941320 941390 942131
/profile?bio=I%20%3C3%20cats%20%26%20dogs: 942131
/search?q=1%27%20UNION%20SELECT%20password%20FROM%20users--: 942100 942190 942200 942260 942270 942360 942361 942362 942480
/search?q=rock%20and%20roll%20--%20best%20of%201975: 942440
== 3. Blocking PL 1, detection PL 2 (PL 2 rules log, only PL 1 rules block)
GET /search?q=rock%20and%20roll%20--%20best%20of%201975 -> 200
GET /profile?bio=I%20%3C3%20cats%20%26%20dogs -> 200
POST /comment -> 403
GET /search?q=1%27%20UNION%20SELECT%20password%20FROM%20users-- -> 403
GET /profile?bio=%3Cscript%3Ealert(1)%3C/script%3E -> 403
rules that matched:
/comment: 932125 932230 932236 932250 942510
/profile?bio=%3Cscript%3Ealert(1)%3C/script%3E: 941100 941110 941160 941320 941390 942131
/profile?bio=I%20%3C3%20cats%20%26%20dogs: 942131
/search?q=1%27%20UNION%20SELECT%20password%20FROM%20users--: 942100 942190 942200 942260 942270 942360 942361 942362 942480
/search?q=rock%20and%20roll%20--%20best%20of%201975: 942440
== 4. Blocking PL 2 with narrow exclusions (one rule, one parameter, one path)
GET /search?q=rock%20and%20roll%20--%20best%20of%201975 -> 200
GET /profile?bio=I%20%3C3%20cats%20%26%20dogs -> 200
POST /comment -> 200
GET /search?q=1%27%20UNION%20SELECT%20password%20FROM%20users-- -> 403
GET /profile?bio=%3Cscript%3Ealert(1)%3C/script%3E -> 403
rules that matched:
/profile?bio=%3Cscript%3Ealert(1)%3C/script%3E: 941100 941110 941160 941320 941390
/search?q=1%27%20UNION%20SELECT%20password%20FROM%20users--: 942100 942190 942200 942260 942270 942360 942361 942362 942480Read it top to bottom. Block 1: PL 1 already has one false positive (the comment with a shell command). Block 2: PL 2 blocks all three benign requests. Block 3: the same PL 2 rule IDs appear in the log (942440, 942131, 942510, 932236), but only PL 1 decides, so nothing new is blocked; that is your tuning list. Block 4: PL 2 blocks both attacks and none of the users, and the attacks still trip many other rules on the same parameters.
Mistakes people make
Jumping to PL 2 in one change
Every PL 2 false positive becomes a user-facing 403 on the same day. Use the detection level for a week first; it costs a little CPU and no users.
Excluding a rule for the whole site
SecRuleRemoveById 942440 fixes the search box and removes that check from
every parameter on every path. Scope by path and parameter with
ctl:ruleRemoveTargetById.
Raising the anomaly threshold instead of tuning
A threshold of 20 hides false positives and most real attacks too. Keep the default threshold and fix the specific matches.
Writing exclusions after the rules
Runtime exclusions (ctl: in a SecRule) must run before the rules they
change, so they go before Include @owasp_crs/*.conf.
Tuning on test traffic
Your test suite does not type band names or paste shell commands. Tune on real traffic, in detection, and keep the log review as a recurring task.
Checklist
- Start with blocking PL 1 and detection PL 2.
- Collect PL 2 matches with rule ID, path and parameter for at least a week.
- Classify each match as attack or user.
- Write one exclusion per rule, parameter and path; no site-wide removals.
- Keep the anomaly threshold at the CRS default.
- Put exclusions before the CRS rules include.
- Raise the blocking level, and keep detection one level above it.
Paranoia is healthy in a WAF. Just let it rehearse before it meets the customers.
H2-CPQE
Learn it on a live range
WAF, rate limiting and DDoS, in Edge and Post-Quantum Networking: a real host in your browser, and every objective checked on the machine.
Start freeThe Dome
Want it run for you?
The Dome puts post-quantum TLS, a WAF that blocks, signed DNS and a zero-trust mesh in front of your application. Tell us what you run.
See the Dome