How to adjust OWASP CRS behaviour with rule overrides, target exclusions, and custom rules.
Guard Proxy does not only deploy the OWASP Core Rule Set (CRS) out of the box — it also lets you fine-tune it. Every virtual host can be bound to a policy, and every policy can carry three kinds of tuning entries:
- Rule overrides — turn an entire CRS rule on or off.
- Rule exclusions — stop a CRS rule from inspecting one specific argument, header, or URI.
- Custom rules — write your own security rules on top of CRS.
This guide explains what each entry does, what fields you must fill in, and what Guard Proxy writes into the generated Coraza configuration.
- Quick glossary
- Rule overrides
- Rule exclusions
- From log to exclusion — a worked example
- Custom rules
- API cheat-sheet
- What the generated config looks like
- Further reading
| Term | Meaning |
|---|---|
| CRS | OWASP Core Rule Set — a large collection of ready-made WAF rules (SQL-injection detection, XSS detection, etc.). |
| Rule | A single numbered check inside CRS, e.g. 942100 looks for SQL-injection patterns. |
| Target | The specific place a rule inspects: a query argument, a request header, the request URI, etc. |
| Phase | The moment in the HTTP life-cycle when a rule runs. Guard Proxy currently supports custom rules in Phase 1 = request headers and Phase 2 = request body. |
| Action | What Coraza does when a rule matches, e.g. deny, pass, log, skipAfter. |
| SecRule | The ModSecurity/Coraza directive that defines a rule. |
A rule override is the simplest form of tuning: you completely enable or completely disable a CRS rule for a given policy.
When to use it
- A CRS rule is constantly blocking legitimate traffic on your application and you have no time to investigate a narrower fix → disable it.
- You previously disabled a rule and now want it back → enable it.
When not to use it
- The rule only misbehaves on one endpoint or for one request argument. In that case use a rule exclusion (see below) so you do not lose protection everywhere else.
| Field | Required | Description |
|---|---|---|
rule_id |
Yes | CRS rule number, e.g. 941100 (XSS), 942100 (SQLi). |
action |
Yes | enable or disable. |
comment |
No | A free-text note for your team, e.g. "Disabled because of false positives on the legacy search endpoint". |
Disable rule 942100 inside policy 3:
POST /policies/3/rules
{
"rule_id": 942100,
"action": "disable",
"comment": "False positives on search form"
}Instead of turning a whole rule off, you tell the WAF: "Rule X should stop looking at this one specific thing". In ModSecurity/Coraza terminology this is called removing a target from a rule.
When to use it
- Rule
942100(SQLi) fires on a harmlesstokenparameter sent by your mobile app to/api/login. - Rule
941100(XSS) fires on a rich-text fielddescriptionin your admin panel. - A third-party webhook sends a header
X-Signaturethat looks suspicious to CRS but is actually expected.
| Field | Required | Description |
|---|---|---|
rule_id |
Yes | CRS rule number you want to narrow down, e.g. 942100. |
target_type |
Yes | What kind of target you are excluding. See the table below. |
target_value |
Yes | The concrete name of the target, e.g. "token", "X-Signature". |
scope_path |
No | A URL path prefix (e.g. /api/login) where the exclusion is valid. If omitted the exclusion is global — it applies to every request handled by the policy. |
comment |
No | A note explaining why the exclusion exists. |
target_type value |
What it means | Example target_value |
|---|---|---|
args |
A query-string or body parameter | "token", "search" |
args_names |
The name of a parameter (rarely needed) | "old_name" |
request_headers |
A request header | "X-Signature", "User-Agent" |
request_uri |
The full request URI | Leave empty / same as target type (excludes the whole URI from inspection for that rule) |
Global exclusion — stop rule 942100 from checking the token argument on every endpoint:
POST /policies/3/exclusions
{
"rule_id": 942100,
"target_type": "args",
"target_value": "token",
"comment": "Mobile app sends long opaque tokens"
}Path-scoped exclusion — same as above, but only for /api/login:
POST /policies/3/exclusions
{
"rule_id": 942100,
"target_type": "args",
"target_value": "token",
"scope_path": "/api/login",
"comment": "Login endpoint only"
}Header exclusion — stop rule 920274 from inspecting the X-Custom-Header header:
POST /policies/3/exclusions
{
"rule_id": 920274,
"target_type": "request_headers",
"target_value": "X-Custom-Header",
"comment": "Third-party integration header"
}The hardest part of tuning a WAF is knowing whether a blocked request deserves an exclusion. Below are two step-by-step examples that show how to read a log and make the right decision.
Your team reports: "When our editors save blog posts that contain HTML, the WAF blocks them with 403."
You look at the log and see:
| Field | Value |
|---|---|
| Method | POST |
| Request URI | /admin/posts |
| Rule ID | 941100 |
| Rule message | XSS Attack Detected via libinjection |
Step 1 — What does the rule do?
Rule 941100 detects cross-site scripting (XSS) patterns. It inspects request arguments and the request body.
Step 2 — Is this a false positive?
Yes. An admin panel that intentionally accepts HTML from trusted editors is expected to contain <script>-like strings. The rule is doing its job, but the context (a trusted admin saving a post) makes it a false positive.
Step 3 — What exactly should be excluded?
The rule fired on the content argument inside the POST body. You do not want to disable the whole rule — you only want to stop it from checking the content field on the admin endpoint.
Step 4 — Build the exclusion
| Exclusion field | Value | Reason |
|---|---|---|
rule_id |
941100 |
The rule that fired. |
target_type |
args |
The rule inspected a request argument. |
target_value |
"content" |
The argument name that contained the HTML. |
scope_path |
/admin/posts |
Only the post-editing endpoint needs this. |
comment |
Editors intentionally save HTML in posts |
So the next admin knows why this exists. |
JSON to send:
POST /policies/3/exclusions
{
"rule_id": 941100,
"target_type": "args",
"target_value": "content",
"scope_path": "/admin/posts",
"comment": "Editors intentionally save HTML in posts"
}Then run POST /config/apply to regenerate the configuration.
Your mobile app users cannot log in. The logs show:
| Field | Value |
|---|---|
| Method | POST |
| Request URI | /api/login |
| Rule ID | 942100 |
| Rule message | SQL Injection Attack Detected via libinjection |
The token parameter contains a long opaque JWT string that happens to contain a character sequence the SQLi rule treats as suspicious.
Step 1 — What does the rule do?
Rule 942100 detects SQL-injection patterns in request arguments.
Step 2 — Is this a false positive?
Yes. The token field is generated by your own authentication service. It is not user-supplied SQL.
Step 3 — What exactly should be excluded?
The token argument on the login endpoint only.
Step 4 — Build the exclusion
| Exclusion field | Value | Reason |
|---|---|---|
rule_id |
942100 |
The rule that fired. |
target_type |
args |
The rule inspected a request argument. |
target_value |
"token" |
The argument name that triggered the rule. |
scope_path |
/api/login |
Only the login endpoint needs this. |
comment |
Mobile app JWT token contains SQL-like sequences |
Explains the business context. |
JSON to send:
POST /policies/3/exclusions
{
"rule_id": 942100,
"target_type": "args",
"target_value": "token",
"scope_path": "/api/login",
"comment": "Mobile app JWT token contains SQL-like sequences"
}- Find the Rule ID and read its message. What is the rule trying to protect against?
- Look at the Request URI, Method, and any arguments/headers. Is this a normal, legitimate use of your application?
- If it is an attack → do nothing. Let the rule block.
- If it is a false positive → identify the smallest possible target (one argument, one header) and create a rule exclusion scoped to the specific path.
- Only as a last resort — if the rule is completely incompatible with your application and you cannot narrow it down — use a rule override to disable the entire rule.
Custom rules let you write your own security checks that do not exist in CRS. They are standard Coraza SecRule directives created through the admin panel instead of being hand-edited into a .conf file.
When to use it
- Block requests from a specific bot user-agent that CRS does not catch.
- Reject requests that do not carry a mandatory internal header.
- Add geo-IP or time-based restrictions tailored to your organisation.
| Field | Required | Description |
|---|---|---|
rule_id |
Yes | A number you choose between 9000000 and 9099999. This range is reserved for administrator-authored rules so it never collides with CRS rule IDs. |
phase |
Yes | When the rule runs. Allowed values: request_headers, request_body. Response and logging phases are not available because Guard Proxy's current SPOA integration inspects requests only. |
variables |
Yes | What Coraza should inspect, e.g. REQUEST_HEADERS:User-Agent, ARGS, ARGS|REQUEST_BODY. |
operator |
Yes | How to compare the variable against your pattern. See the operator table below. |
operator_argument |
Yes | The pattern or value for the operator, e.g. a regex or a literal string. |
actions |
Yes | Comma-separated Coraza actions, e.g. deny,status:403,log or pass,skipAfter:END. |
comment |
No | A human-readable note. |
is_active |
No (default true) |
Inactive rules are kept in the database but are not written into the generated config. |
operator value |
Coraza token | Meaning | Typical operator_argument |
|---|---|---|---|
rx |
@rx |
Regular expression match | (?i)badbot |
streq |
@streq |
Exact string match | admin |
contains |
@contains |
Substring match | internal |
begins_with |
@beginsWith |
Starts with | /api/v1 |
ends_with |
@endsWith |
Ends with | .pdf |
eq |
@eq |
Numeric equal | 0 |
ge |
@ge |
Greater than or equal | 18 |
gt |
@gt |
Greater than | 100 |
le |
@le |
Less than or equal | 5 |
lt |
@lt |
Less than | 10 |
pm |
@pm |
Phrase match (space-separated list) | select drop delete |
within |
@within |
Value must be in a list | GET POST HEAD |
ip_match |
@ipMatch |
IP or CIDR match | 192.168.1.0/24 10.0.0.5 |
phase value |
Coraza phase number | When it runs |
|---|---|---|
request_headers |
1 | After the request headers are received |
request_body |
2 | After the request body is received |
Coraza itself also has response and logging phases, but Guard Proxy does not run
them today. HAProxy sends request metadata and body data to Coraza through SPOE,
and the Coraza SPOA application is configured with response_check: false.
If you need response inspection later, the proxy/SPOA data flow must be extended
first; adding a phase 3, 4, or 5 custom rule in the current stack would not
execute as an operator expects.
Block every request whose User-Agent contains curl:
POST /policies/3/custom-rules
{
"rule_id": 9000001,
"phase": "request_headers",
"variables": "REQUEST_HEADERS:User-Agent",
"operator": "rx",
"operator_argument": "(?i)curl",
"actions": "deny,status:403,log",
"comment": "No curl-based scraping"
}Require an internal header X-Internal-Auth on admin paths:
POST /policies/3/custom-rules
{
"rule_id": 9000002,
"phase": "request_headers",
"variables": "REQUEST_HEADERS:X-Internal-Auth",
"operator": "rx",
"operator_argument": "^$",
"actions": "deny,status:403,msg:'Missing internal auth header'",
"comment": "All admin requests must carry the internal auth header"
}Allow only specific HTTP methods:
POST /policies/3/custom-rules
{
"rule_id": 9000003,
"phase": "request_headers",
"variables": "REQUEST_METHOD",
"operator": "within",
"operator_argument": "GET POST HEAD OPTIONS",
"actions": "deny,status:405,log",
"comment": "Reject unexpected HTTP verbs"
}All endpoints below require an Authorization: Bearer <token> header. POST, PATCH, and DELETE are restricted to admin users.
| Action | Endpoint | Method |
|---|---|---|
| Rule overrides | ||
| Create | /policies/{id}/rules |
POST |
| List | /policies/{id}/rules |
GET |
| Get one | /policies/{id}/rules/{rule_override_id} |
GET |
| Update | /policies/{id}/rules/{rule_override_id} |
PATCH |
| Delete | /policies/{id}/rules/{rule_override_id} |
DELETE |
| Rule exclusions | ||
| Create | /policies/{id}/exclusions |
POST |
| List | /policies/{id}/exclusions |
GET |
| Get one | /policies/{id}/exclusions/{rule_exclusion_id} |
GET |
| Update | /policies/{id}/exclusions/{rule_exclusion_id} |
PATCH |
| Delete | /policies/{id}/exclusions/{rule_exclusion_id} |
DELETE |
| Custom rules | ||
| Create | /policies/{id}/custom-rules |
POST |
| List | /policies/{id}/custom-rules |
GET |
| Get one | /policies/{id}/custom-rules/{custom_rule_id} |
GET |
| Update | /policies/{id}/custom-rules/{custom_rule_id} |
PATCH |
| Delete | /policies/{id}/custom-rules/{custom_rule_id} |
DELETE |
| Deploy config | ||
| Generate & apply | /config/apply |
POST |
After you create, update, or delete any tuning entry you must call POST /config/apply to regenerate the WAF configuration and reload HAProxy + Coraza.
Guard Proxy renders three files when you hit POST /config/apply. The tuning entries end up in rule_overrides.conf.
If you disabled rule 942100:
SecRuleRemoveById 942100A global exclusion (no scope_path) is emitted as:
SecRuleRemoveTargetById 942100 ARGS:tokenA path-scoped exclusion (e.g. /api/login) becomes a small control rule:
SecRule REQUEST_URI "@beginsWith /api/login" \
"id:9990001,phase:1,pass,nolog,\
ctl:ruleRemoveTargetById=942100;ARGS:token"The control rule matches the request URI and then tells Coraza to remove the target for that single request only.
A custom rule is emitted exactly as a SecRule directive:
SecRule REQUEST_HEADERS:User-Agent "@rx (?i)curl" \
"id:9000001,phase:1,deny,status:403,log"Inactive rules (is_active: false) are skipped entirely — they do not appear in the generated file.
- OWASP CRS Documentation — how the rule set works, paranoia levels, anomaly scoring, and rule numbering.
- Coraza Reference — SecRule syntax, variables, operators, actions, and phases.
- ModSecurity Handbook (book) — deep dive into the engine that Coraza is compatible with.
- Guard Proxy architecture.md — how the generated config reaches HAProxy and Coraza.