Repository navigation
Feature Guide WAF
Web Application Firewall powered by Coraza with optional OWASP Core Rule Set.
- Overview
- Enable the WAF
- Per-Host Configuration
- WAF Events
- Rule Suppression
- Request Body Limits
- Custom Directives
- CRS Anomaly Scoring
- Troubleshooting
The WAF inspects incoming HTTP requests and applies rule-based detection. When a request matches a rule it is either:
- Blocked — the request is rejected with HTTP 403
- Detected — the request is logged but allowed through
Events are recorded per request and visible in WAF → Events.
- Open WAF in the sidebar.
- Click the Settings tab.
- Toggle Enable WAF globally (blocking).
- Optionally check Load OWASP Core Rule Set (recommended — covers SQLi, XSS, LFI, RCE, and more).
- Click Save WAF settings.
Configuration takes effect immediately on the next request.
Each proxy host can override the global WAF setting:
- Disabled — WAF off for this host, regardless of global state
- Global (default) — inherit global WAF settings
- Custom — enable/disable, OWASP CRS, custom directives, and rule suppressions specific to this host
Hosts using Custom mode merge their settings with the global config (Merge with global) or completely override it (Override global).
To configure:
- Open the proxy host dialog (edit an existing host or create a new one).
- Scroll to the WAF section.
- Select the desired mode and fill in any custom settings.
The Events tab shows a searchable, paginated log of all WAF activity.
Time range filters above the table let you scope which events are shown:
| Filter | Window |
|---|---|
| All | All stored events (default) |
| 24h | Last 24 hours |
| 7d | Last 7 days |
| 30d | Last 30 days |
| Custom | Date/time pickers for start and end |
Above the table, a stats bar shows totals for the selected period — total events, blocked count, detected count, and unique client IPs.
| Column | Description |
|---|---|
| Time (UTC) | When the request arrived |
| Action | Blocked or Detected |
| Severity | CRITICAL, ERROR, HIGH, WARNING, NOTICE, INFO |
| Host | The proxy host that handled the request |
| Client IP | Source IP address, with country code if GeoIP is configured |
| Request | HTTP method and request URI |
| Rule ID | OWASP CRS or custom rule that matched |
Click any row to open the detail drawer on the right. Close it with the close button, the Escape key, or a click outside the drawer.
The top of the drawer shows the time, host, client IP, method, URI, Rule ID and Rule Message, followed by the Suppress Globally and Suppress for {host} buttons (see Rule Suppression).
Below them, Audit Data shows the Coraza audit entry in tabs:
| Tab | Contents |
|---|---|
| Overview | Transaction ID, timestamp, client and server address, and the matched rules with their severity, matched data and tags |
| Request | Method, URI, protocol, headers, query arguments, body if present, and content length |
| Response | Response status, protocol, headers, and body if present |
| Matches (N) | Per matched rule: rule ID, severity, message, log data, rule file and line, reference and tags. Shown only when rules matched |
A collapsible Raw JSON section under the tabs shows the whole audit entry, pretty-printed.
Search filters by host, client IP, URI, or rule message. Results are paginated server-side.
Since v1.13.1, CPM replaces credentials with [redacted] before it stores an event:
- the values of these request and response headers:
Authorization,Proxy-Authorization,Cookie,Set-Cookie,X-API-Key,X-Auth-Token,X-CPM-Forward-Auth-Proof,X-Plex-Token,X-Emby-Token,X-Emby-Authorization,X-MediaBrowser-Token,Private-TokenandX-Vault-Token - cookie and credential-header values that a rule message echoes, such as the CRS's
Matched Data: … found within REQUEST_COOKIES:session: … - since v1.13.2: the values of other headers, query parameters and form fields whose names contain a credential word (
token,key,apikey,secret,password,pwd,pass,auth,session,sid,sig,signature,jwt,code,credential), e.g.X-Access-Tokenorapi_key, in the stored request URI and in rule messages (ARGS:password, or aREQUEST_URIa rule echoes)
Other headers and the request URI, query string included, are stored unchanged. The Request, Response and Matches tabs, the Raw JSON section and the Rule Message field of the event detail, and /api/waf-events all show the redacted copy. Events stored before the upgrade are not scrubbed; they are deleted when the analytics retention expires (30 days by default, set with CLICKHOUSE_RETENTION_DAYS; see Feature Guide Analytics).
Suppressing a rule removes it from the WAF for all matching requests. Use this to fix false positives.
Suppressed rules apply to all hosts using global WAF settings. Rules suppressed globally use SecRuleRemoveById under the hood.
- From an event: open the detail drawer → click Suppress Globally
- From the tab: WAF → Suppressed Rules → enter a rule ID and click Look up → Suppress Globally
Suppressed rules apply only to the specific host where the event triggered.
- From an event: open the detail drawer → click Suppress for {host}
Per-host suppressions are stored in the host's custom WAF config.
Open WAF → Suppressed Rules and click the delete icon next to the rule.
Coraza buffers each request body so rules can inspect it, and rejects anything
larger than its limit. With the OWASP CRS loaded that limit is 12.5 MiB —
which is why large uploads (Nextcloud chunks, Immich assets, object storage
PUTs) start failing with 413 as soon as the WAF is enabled, while the same
host works fine with it off.
Three fields cover it, in WAF → Settings globally and in the WAF section of each proxy host:
| Field | SecLang directive | Notes |
|---|---|---|
| Max body size | SecRequestBodyLimit |
Buffered per request. Coraza's hard maximum is 1024 MiB (1 GiB) |
| Buffered in memory | SecRequestBodyInMemoryLimit |
Anything beyond this spills to disk. Must not exceed the max body size |
| Over-limit action | SecRequestBodyLimitAction |
Reject returns 413; Process partial inspects the buffered part and forwards the rest |
Both sizes are entered in MiB. Leave a field blank to inherit — the global setting for a host, or Coraza's own default when nothing is set.
Sizing: the limit applies to a single request body, not the whole upload.
Clients that chunk their uploads (Nextcloud, Immich) only need a limit above
one chunk. Process partial is the safer choice for hosts serving very large
files: rules still see the leading bytes and nothing is dropped.
Values above 1 GiB are refused when you save. Coraza validates its limits while Caddy loads the config, so an out-of-range value would make Caddy reject the whole configuration and every host would stop picking up changes — not just the one being edited.
SecRequestBodyNoFilesLimit is accepted in custom directives for
compatibility, but Coraza parses and ignores it
(corazawaf/coraza#896) — it
has no effect on uploads.
Advanced users can write raw ModSecurity SecLang directives in the Custom SecLang Directives field (WAF Settings tab, or per-host config).
Directives are applied after the OWASP CRS when both are enabled. For a host set to Merge with global, the host's directives come after the global ones in the same WAF handler.
Not every directive reaches Caddy. See Accepted and dropped directives.
Allow an IP unconditionally:
SecRule REMOTE_ADDR "@ipMatch 1.2.3.4" "id:9000,phase:1,allow,nolog,msg:'Allow IP'"
Skip the OWASP CRS for a path:
SecRule REQUEST_FILENAME "@rx \A/api/(?:[^.]|\.[^.])*\.?\z" "id:9001,phase:1,pass,nolog,ctl:ruleRemoveByTag=OWASP_CRS"
This removes the CRS rules for the rest of the request, including the phase 2
anomaly check (949110) that blocks. It matches the decoded path and skips
nothing for a path containing ..: the v1.13.0/v1.13.1 template
(SecRule REQUEST_URI "@beginsWith /api/" …) matched the raw URI, so
/api/../index.php or /api/%2e%2e/index.php turned the CRS off for a path the
upstream resolves outside /api/. Replace stored copies of it. Keep \A and
\z: Coraza's @rx is multi-line, so ^ and $ would also match at a
decoded %0a. Your own custom rules still apply. You
cannot switch the whole WAF off for a path: ctl:ruleEngine=Off is dropped.
Skip the CRS XSS rules:
SecAction "id:9004,phase:1,pass,nolog,ctl:ruleRemoveByTag=attack-xss"
SecRuleRemoveByTag is dropped. Use a ctl:ruleRemoveByTag action in phase 1
instead, or a SecRule with a condition (as in the previous example) to skip
the rules for only part of the site.
Custom directives come after the CRS, so these ctl: actions take effect after
the CRS's own phase 1 rules have run. To turn one rule off completely, suppress
it by ID (see Rule Suppression).
Block a specific User-Agent:
SecRule REQUEST_HEADERS:User-Agent "@contains badbot" "id:9002,phase:1,deny,status:403,log"
Block access to sensitive files (.env, .git, wp-config.php, etc.):
SecRule REQUEST_URI "@rx (?i)(?:^|/)(?:\.env|\.git|\.htaccess|\.htpasswd|\.bash_history|wp-config\.php|id_rsa|etc/passwd|etc/shadow)(?:/|$|[?#])" "id:9003,phase:1,deny,status:403,msg:'Blocked Restricted File',log"
This uses an anchored regex so it only blocks the exact files. Do not wrap the
argument in square brackets and do not use @pm here: @pm matches
case-insensitive substrings, so .env also blocks innocent paths like
/docs/.envelope, and wrapping it in [...] makes Coraza fail to compile the
rule.
Every rule needs an id: that no other custom rule uses (for a Merge with
global host, the global rules count too). Check your existing directives
before you paste an example.
Click Quick Templates (in the Settings tab, or under the custom directives in the host dialog) to add one of these rules: Allow IP, Skip OWASP CRS for path, Skip OWASP CRS XSS rules and Block User-Agent. They match the examples above, apart from their ids.
Since v1.13.1, every template is a rule CPM accepts. When a
template's default id: is already in the field, it takes the next free one,
so clicking a template twice does not repeat an id. In the host dialog the ids
start at 9100, so a Merge with global host does not reuse the ids of the
global templates. The Disable WAF for path and Remove XSS rules
templates of older releases inserted lines that CPM drops; replace them with
the new ones.
Custom directives go through an allowlist. Only these reach Caddy:
-
SecRule,SecAction,SecMarkerandSecDefaultAction -
SecRequestBodyLimit,SecRequestBodyInMemoryLimitandSecRequestBodyNoFilesLimitwith a byte count from 1024 to 1073741824 (1 GiB), andSecRequestBodyLimitAction RejectorProcessPartial - comments (
#) and blank lines
Even so, CPM drops lines that could read files, run programs or switch the WAF
off, and lines that would make Caddy refuse the whole config. The operator,
setenv, parsing, continuation, id and chain checks are new in the release
after v1.12.0:
| Dropped | Details |
|---|---|
Include and every directive not listed above |
SecRuleEngine, SecRuleRemoveById, SecRuleRemoveByTag, SecRuleRemoveByMsg, SecRuleUpdateActionById, SecRuleUpdateTargetById, SecResponseBodyAccess, … Use Rule Suppression or a ctl:ruleRemoveById / ctl:ruleRemoveByTag action instead |
| Operators that read files or run a program |
@pmFromFile/@pmf, @ipMatchFromFile/@ipMatchF, @inspectFile and @validateSchema, negated or not. See the exception for CRS data files
|
The setenv action |
It changes environment variables of the Caddy process |
ctl:ruleEngine |
It can switch the WAF off per request. Caught with any spacing or quoting |
| Lines Coraza cannot parse | A SecRule, SecAction or SecDefaultAction with a broken structure, such as a SecRule without a quoted operator |
Directives continued with a trailing \
|
Write each directive on one line |
A rule reusing an id:
|
The later rule is dropped, since Coraza refuses duplicate ids. For a Merge with global host, the global directives come first, so a host rule that reuses a global id is dropped |
| The rest of a chain | When one rule of a chain is dropped, the whole chain is dropped with it |
Operator names and other rule content are not checked: a typo such as
@contians still reaches Caddy, which then refuses the whole config. Clashes
with OWASP CRS rule ids are not checked either, so with the CRS loaded, avoid
ids 900000–999999 and 200000–200006 (used by coraza.conf-recommended).
The data-file operators @pmFromFile, @pmf, @ipMatchFromFile and
@ipMatchF are accepted when all of these hold:
- the argument is one data file of the embedded OWASP CRS, written
@owasp_crs/<name>.data -
<name>.datais one of the 21 data files of coraza-coreruleset v4.25.0:ai-critical-artifacts.data,asp-dotnet-errors.data,iis-errors.data,java-classes.data,lfi-os-files.data,php-errors.data,php-function-names-933150.data,php-variables.data,restricted-files.data,restricted-upload.data,ruby-errors.data,scanners-user-agents.data,sql-errors.data,ssrf-no-scheme.data,ssrf.data,unix-shell-aliases.data,unix-shell-builtins.data,unix-shell.data,web-shells-asp.data,web-shells-php.data,windows-powershell-commands.data - the operator is spelt exactly as above. Coraza's operator names are case-sensitive, so
@pmfromfileor@PMFis dropped, and the message names the correct spelling - the OWASP CRS is loaded for the WAF handler the rule ends up in (Load OWASP Core Rule Set, globally or on the host)
SecRule REQUEST_HEADERS:User-Agent "@pmFromFile @owasp_crs/scanners-user-agents.data" "id:9010,phase:1,deny,status:403,log,msg:'Scanner User-Agent'"
Since v1.13.1, a save is rejected only for lines that the
save itself newly drops. This applies to proxy hosts (the host dialog,
POST /api/v1/proxy-hosts and PUT /api/v1/proxy-hosts/{id}) and to the
global settings (WAF → Settings and PUT /api/v1/settings/waf). The error
starts with
waf.custom_directives contains N line(s) that will be dropped and never sent to Caddy:
and lists each line with its reason. An out-of-range body limit gets its own
message (waf.custom_directives has an out-of-range body limit: …). Turning
Load OWASP Core Rule Set off while a rule reads an @owasp_crs/ file
counts as a new drop.
A stored line that a newer release drops, such as a rule saved before these
checks existed, does not block unrelated edits. It stays in the database, is
left out of the generated config, and is logged by the web container
(docker compose logs web | grep '\[waf\]'):
[waf] proxy host "app" (app.example.com): 1 custom directive line(s) are not sent to Caddy and have no effect:
"SecRuleRemoveByTag "attack-xss"" → rule-mutation/engine directives are not allowed (they can disable WAF protections or override rule actions)
The source is global WAF settings, proxy host "<name>" (<domains>), or
proxy host "<name>" (<domains>), from the global WAF settings for a global
line that only that host drops (for example because the host turns the CRS
off). Each set of dropped lines is logged once per start and again when it
changes. A Merge with global host without its own CRS setting (one saved
through the REST API without load_owasp_crs) is not re-checked when the
global CRS is turned off; its @owasp_crs/ rules are then only reported in the
log.
OWASP CRS rules generally carry the block action, but the CRS runs in
anomaly scoring mode by default. This means block does not reject the
request on its own: the rule matches, sets an anomaly-score increment, and an
end-of-request rule (949110 / 949111) only denies when the cumulative
inbound anomaly score crosses a threshold (default 5). A rule marked
severity: CRITICAL contributes the most points, but a single hit below the
threshold still passes.
That is why a rule such as 930130 (Restricted File Access Attempt) can
appear in WAF → Events as CRITICAL while the request still goes through.
It is expected CRS behaviour, not a CPM bug and not an ordering problem.
To force a specific CRS rule to hard-block regardless of the threshold, add a
custom directive with an explicit deny,status:403 action instead of relying
on the rule's block. A self-contained rule that does not depend on the CRS
set looks like this:
SecRule REQUEST_URI "@rx (?i)(?:^|/)(?:\.env|\.git|\.htaccess|\.htpasswd|\.bash_history|wp-config\.php|id_rsa|etc/passwd|etc/shadow)(?:/|$|[?#])" "id:9003,phase:1,deny,status:403,msg:'Blocked Restricted File',log"
The standalone deny bypasses the anomaly threshold. Make sure the host WAF
engine Mode is On (not DetectionOnly): DetectionOnly swallows even an
explicit deny and only logs.
Coraza refuses to compile a rule it cannot parse, so a malformed custom directive makes Caddy reject the entire config and every host stops picking up changes. CPM rejects directives it would drop when you save (see Accepted and dropped directives) and drops rules whose structure Coraza cannot parse, but it does not check operator names or other rule content, so always test a new rule before relying on it.
The WAF's request body limit is almost always the cause. Raise Max body size under Request Body Limits (globally, or on the affected host), or switch the over-limit action to Process partial. See Request Body Limits.
This is usually CRS anomaly scoring, not a misconfiguration: a single rule
match below the inbound anomaly threshold (default 5) only logs. See
CRS Anomaly Scoring.
To hard-block a specific rule, add a standalone deny,status:403 rule as
shown there, and confirm the WAF engine Mode is On rather than
DetectionOnly.
The custom directives contain a line CPM does not send to Caddy. The message lists each line with its reason; rewrite or remove them (see Accepted and dropped directives). If you used the Disable WAF for path or Remove XSS rules template of an older release, replace it with Skip OWASP CRS for path or Skip OWASP CRS XSS rules.
Since v1.13.1, stored rules that CPM now drops stay in the
settings but are left out of the generated config. Look for them in the web
container log with docker compose logs web | grep '\[waf\]' (see
Saving, and rules stored by older releases),
then rewrite them.
- Check the event in WAF → Events and note the Rule ID.
- Use Suppress Globally or Suppress for {host} from the event drawer.
- If many rules are firing for legitimate traffic, suppress them by ID or skip CRS rules by tag for the affected paths (
ctl:ruleRemoveByTag, see Common patterns).
Checks:
- Verify WAF is enabled (WAF → Settings toggle is on).
- Confirm traffic is reaching the proxy host (check Caddy access logs).
- If the WAF was enabled after creating a proxy host, verify the host configuration was re-applied (edit the host and save).
The WAF is WAF-aware for WebSocket upgrade requests. If WebSocket connections fail after enabling the WAF, check the WAF events for blocked upgrade requests and suppress the relevant rules.
- Feature Guide Proxy Hosts
- Feature Guide Geo Blocking
- Environment Variables Reference
- Troubleshooting
Need help? Open an issue with the rule IDs, request details (no sensitive data), and relevant logs.