Edge and WAF

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.

Updated Houssam Hammoudi, CTOTested with OWASP CRS 4.14.0 in coraza-proxy-wasm 0.6.0, Envoy 1.39.1

On this page
  1. What goes wrong
  2. What the docs say
  3. The secure configuration
  4. Prove it
  5. Mistakes people make
  6. Checklist

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:

conf
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/*.conf

Step 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.

conf
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/*.conf

In 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.

text
== 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 942480

Read 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 free

The 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