Skip to content

Feature Guide WAF

fuomag9 edited this page Sep 27, 2026 · 8 revisions

Feature Guide: WAF

Web Application Firewall powered by Coraza with optional OWASP Core Rule Set.

Table of Contents

  1. Overview
  2. Enable the WAF
  3. Per-Host Configuration
  4. WAF Events
  5. Rule Suppression
  6. Request Body Limits
  7. Custom Directives
  8. CRS Anomaly Scoring
  9. Troubleshooting

Overview

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.


Enable the WAF

  1. Open WAF in the sidebar.
  2. Click the Settings tab.
  3. Toggle Enable WAF globally (blocking).
  4. Optionally check Load OWASP Core Rule Set (recommended — covers SQLi, XSS, LFI, RCE, and more).
  5. Click Save WAF settings.

Configuration takes effect immediately on the next request.


Per-Host Configuration

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:

  1. Open the proxy host dialog (edit an existing host or create a new one).
  2. Scroll to the WAF section.
  3. Select the desired mode and fill in any custom settings.

WAF Events

The Events tab shows a searchable, paginated log of all WAF activity.

Period filters

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

Stats bar

Above the table, a stats bar shows totals for the selected period — total events, blocked count, detected count, and unique client IPs.

Columns

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

Event Detail

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

Search filters by host, client IP, URI, or rule message. Results are paginated server-side.

Credential redaction

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-Token and X-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-Token or api_key, in the stored request URI and in rule messages (ARGS:password, or a REQUEST_URI a 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).


Rule Suppression

Suppressing a rule removes it from the WAF for all matching requests. Use this to fix false positives.

Global suppression

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

Per-host suppression

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.

Remove a suppression

Open WAF → Suppressed Rules and click the delete icon next to the rule.


Request Body Limits

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.


Custom Directives

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.

Common patterns

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.

Quick Templates

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.

Accepted and dropped directives

Custom directives go through an allowlist. Only these reach Caddy:

  • SecRule, SecAction, SecMarker and SecDefaultAction
  • SecRequestBodyLimit, SecRequestBodyInMemoryLimit and SecRequestBodyNoFilesLimit with a byte count from 1024 to 1073741824 (1 GiB), and SecRequestBodyLimitAction Reject or ProcessPartial
  • 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).

Embedded CRS data files

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>.data is 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 @pmfromfile or @PMF is 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'"

Saving, and rules stored by older releases

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.


CRS Anomaly Scoring: Why a Rule Can Log CRITICAL but Not Block

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.


Troubleshooting

Large uploads fail with 413

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.

A rule logs CRITICAL but the request still goes through

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.

Saving fails with "line(s) that will be dropped"

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.

A custom rule has no effect after upgrading

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.

Requests blocked that shouldn't be

  1. Check the event in WAF → Events and note the Rule ID.
  2. Use Suppress Globally or Suppress for {host} from the event drawer.
  3. 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).

No events appearing

Checks:

  1. Verify WAF is enabled (WAF → Settings toggle is on).
  2. Confirm traffic is reaching the proxy host (check Caddy access logs).
  3. If the WAF was enabled after creating a proxy host, verify the host configuration was re-applied (edit the host and save).

WebSocket connections failing

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.


Related Documentation


Need help? Open an issue with the rule IDs, request details (no sensitive data), and relevant logs.

Clone this wiki locally