Skip to content
github-actions[bot] edited this page Oct 28, 2026 · 8 revisions

WAF (Web Application Firewall)

🌐 English · Français

Arenet embeds Coraza v3.7 — a Go-native WAF engine compatible with the ModSecurity rule format — pre-loaded with OWASP CRS v4.25 (Core Rule Set). Every route can opt into WAF protection at one of three levels :

Mode Behaviour
off WAF handler not in the chain. Zero overhead.
detect WAF evaluates every request, emits a waf_event to /security for each match, but lets the request continue to the upstream. Use for observation / triage.
block WAF evaluates, emits the event, and rejects the request with 403 Forbidden when the anomaly score exceeds the blocking threshold.

Quick start : enable WAF on a route

  1. Sidebar → Routes
  2. Click your route → Edit
  3. WAF mode → pick detect (recommended for first deployment)
  4. Save

Within 5 seconds the WAF is active. Send a probe to test :

curl "https://your-route.example.com/?id=1' OR '1'='1"
# In detect mode : the request reaches your backend AND a SQLi event lands in /security
# In block mode : 403 Forbidden, request doesn't reach backend

Open /security in the UI : you'll see the event listed with category SQLi, the matching CRS rule ID (942100 for libinjection), source IP + GeoIP, the matched payload sample.


Anatomy of a WAF event

Each match emits one row in the SQLite waf_event table with :

  • ts — timestamp
  • route_id — which route triggered
  • rule_id — CRS rule ID (e.g. 942100, 920170)
  • category — Arenet classification (SQLi / XSS / RCE / LFI / RFI / PROTOCOL / METHOD / SCANNER / SESSION / DATA_LEAK / ...) — derived from rule ID range, see internal/waf/category.go
  • severity — CRITICAL / ERROR / WARNING / NOTICE
  • src_ip — remote IP
  • request_path — {method} {uri}
  • payload_sample — first 256 chars of the matched data
  • action — BLOCK (block mode + score ≥ threshold) or DETECT (detect mode OR block mode + score < threshold)
  • status_code — 403 for blocked, 200 for detect/observed
  • matched_var (v2.36+) — the field that triggered the rule, as VARIABLE:name (e.g. ARGS:content, REQUEST_HEADERS:referer). Empty for events recorded before v2.36 and for rules that look at the URL itself.

The Logs page and the /security/<routeId> drilldown list these events. For admins, each excludable event carries an Exclude… button (see Targeted exclusion below).

Repeats of the same route + source IP + rule within 60 s are recorded once (the dashboard counters still count every hit).


Handling false positives

When the WAF blocks a legitimate request (a known false positive), you have four escape hatches to apply per-route, from the narrowest to the widest :

Option (0) — Targeted exclusion from an event (recommended)

Use when : a rule fires on one legitimate field — a rich-text editor's content parameter tripping the SQLi rule 942100, a long Referer header tripping an XSS rule…

  1. Open Logs (or the route's Security page) and find the event.
  2. Click Exclude…. The dialog says in plain words what will happen: "Rule 942100 will stop inspecting the parameter “content”. It keeps protecting every other field of the route."
  3. Only on this path is ticked by default, with the event's path (e.g. /api/save). Edit it if needed, tick and everything below it for a whole sub-tree (/api covers /api and /api/…, not /apix), or untick it to cover every path of the route.
  4. Exclude. Caddy reloads; the next identical request passes, while the same payload in any other field, or on another path, is still inspected.

This is the OWASP-recommended way: the rule stays active, only the one field is skipped (ctl:ruleRemoveTargetById).

Notes :

  • When the event doesn't name a field (events recorded before v2.36, or a rule that inspects the URL itself), the dialog warns you and excludes the rule on the whole route instead (same as option (c)).
  • CRS blocking-evaluation rules (949xxx, 959xxx), initialization (901xxx) and correlation (980xxx) have no button: excluding them would disable the blocking itself. Exclude the rule listed next to them (e.g. 942100) instead. Arenet's own rules (1xxxxx) have no button either.
  • The route form's WAF section lists the targeted exclusions (Targeted exclusions), one readable line each, with Remove and a small form to add one by hand (rule ID, field type, field name, optional path).
  • Admins only. Each addition is recorded in the audit log as a route update.

Option (a) — Disable CRS entirely on the route

Use when : the route serves trusted internal traffic (admin tool on LAN), or the backend is so noisy that CRS produces 90% false positives.

  1. Edit route → WAF section → Disable OWASP CRS on this route ✅
  2. A confirmation dialog (ADR D4) warns about the security implications
  3. Save

The WAF handler stays in the chain (the dashboard still counts events as 0), but the CRS includes are stripped. Zero CRS evaluation cost.

Option (c) — Exclude specific CRS rule IDs

Use when : a precise rule (e.g. 942100 SQLi libinjection) fires on a legitimate payload in many different fields — otherwise prefer option (0).

  1. Edit route → WAF exclusions → Excluded rule IDs
  2. Comma-separated list : 942100, 920170, 911100
  3. Save

The rule is removed from this route's CRS evaluation set. Other rules in the same family still apply.

Option (e) — Exclude entire CRS tag families

Use when : a whole rule family is noisy on this route. Example : attack-protocol (rule range 911-921) trips on legitimate WebDAV / non-standard HTTP method usage.

  1. Edit route → WAF exclusions → Excluded tags
  2. Pick from the autocomplete dropdown : attack-protocol, attack-sqli, attack-rce, language-php, paranoia-level/3, ...
  3. Save

The whole tag family is excluded. Rules with that tag are removed from this route's CRS evaluation set. Cheaper to maintain than listing 15 rule IDs.


Custom rules (guided)

Block requests that match your own criteria, without writing SecLang. Route form → WAF section → Custom rules.

A rule has a name and 1 to 8 conditions that must all match. Each condition accepts several values (one per line — any of them matches):

Criterion Operators Example
Path begins with · is · contains begins with /wp-admin
Method is · is not is not GET / HEAD / POST
User-Agent contains · is · is absent or empty contains sqlmap / nikto (case-insensitive)
Header (by name) is absent · is present · contains · is X-Admin-Token is absent

The editor shows the rule as a sentence while you type ("Block when the method is POST and the path begins with /login and the User-Agent is absent or empty"). Three presets prefill it: Sensitive files (/.env, /.git…), Known scanners (User-Agents), Limited methods.

Behaviour :

  • A rule follows the route's WAF mode: Block → 403, Detect → the request passes and the match is logged. Rules do nothing when the WAF is off (they are kept).
  • Custom rules run before the OWASP CRS and still run when the CRS is disabled on the route.
  • Each rule can be switched off without deleting it. Rules are saved with the route (Save).
  • In the WAF history (Logs, route Security page) the events show the CUSTOM category and the rule's name. Rule IDs are 120000–129999 and stay stable when you edit a rule.
  • Source IP / network is not a criterion: use the per-path IP filter of path rules for that.

SecLang (advanced)

For needs the guided rules can't express, each route has a SecLang editor (route form → WAF → SecLang (advanced), collapsed until used). It is real Coraza / ModSecurity syntax, with guard rails and help:

  • Editor: syntax highlighting, completion (directives, variables, @operators, actions, ctl: and t: values — only what is allowed), live check with the problem shown on its line.
  • Templates, commented line by line in your language, inserted with the next free rule ID: JSON-only API, allowed methods, /admin from the LAN only, require an API-key header, block bots by User-Agent, limit parameter length, exception for one field.
  • → SecLang on a guided rule: replaces it by the SecLang it compiles to (a good way to learn, then go further).
  • Test a request (saved routes): method, path, headers, body → "Blocked (405) by rule 130000 Method not allowed" or "Accepted", with every rule that matched (OWASP CRS included). It uses the unsaved editor content and sends nothing to the backend; nothing is logged.

What is allowed (checked when you save; a refusal names the line):

  • Directives SecRule, SecAction, SecMarker only — no Include, no engine settings (SecRuleEngine, SecRequestBodyLimit…), no audit log, no data files.
  • Rule IDs 130000–139999, one per rule (chained links have none), unique.
  • Operators: @rx @streq @beginsWith @endsWith @contains @pm @within @eq @ge @gt @le @lt @ipMatch @detectSQLi @detectXSS @validateByteRange @validateUrlEncoding @validateUtf8Encoding @strmatch @unconditionalMatch @noMatch. Refused among others: @inspectFile (runs a program), *FromFile (reads host files), @rbl (network), @geoLookup, @validateSchema.
  • Actions: id phase chain deny block drop pass allow status redirect log nolog auditlog noauditlog msg logdata tag severity rev ver maturity capture multiMatch setvar expirevar skip skipAfter t ctl. Refused: exec, setenv, initcol. ctl: limited to the ruleRemove… family, requestBodyProcessor and forceRequestBodyVariable (no ctl:ruleEngine, no body-limit changes).
  • The ENV collection and %{ENV.…} macros are refused (the process environment may hold secrets).
  • At most 64 KiB and 200 rules.

SecLang runs after the targeted exclusions and guided rules and before the CRS, follows the route's mode (the SecRuleEngine line stays Arenet's), and still runs when the CRS is disabled. Events show the CUSTOM category and the rule's msg as its name. Prefer the guided rules when they are enough: they can't be written wrong.


Virtual-patching upstream CVEs (worked example)

Arenet is a single Go binary — it does not run PHP or Node, so interpreter CVEs like these never touch Arenet itself. But as the reverse proxy in front of your apps it can virtually patch some of them for the backends behind it, buying time until you update the backend. This is defence-in-depth, not a replacement for patching the vulnerable app.

Two real cases from the October 2026 "Black October" wave. Paste the rule into a route's SecLang (advanced) editor, start the route in detect, confirm with Test a request, then switch to block. The rules are per-route, so scope them to the routes that actually front the affected app.

Node.js "Node2Shell" — CVE-2026-111497 (effective)

An out-of-bounds write in Node's bundled Brotli decoder: a single Brotli-encoded request body reaching the decoder (Express 5 / body-parser 2.x decode Content-Encoding: br by default, so express.json() alone is enough) yields RCE. Clients almost never Brotli-encode request bodies, so rejecting them at the edge removes the inbound attack surface with negligible collateral:

SecRule REQUEST_HEADERS:Content-Encoding "@rx (?i)(^|[,\s])br([,;\s]|$)" "id:130201,phase:1,deny,status:415,log,msg:'Block Brotli-encoded request body (Node2Shell CVE-2026-111497)',tag:'cve-2026-111497'"

Rejects Content-Encoding: br (and gzip, br) at phase 1, before the body is read, with 415. Caveat: covers inbound request bodies only — it cannot protect content your app itself fetches and Brotli-decodes from an external server (fetch() / undici). Patch Node to 22.23.4 / 24.22.1 / 26.12.1.

PHP "PHP2Shell" — CVE-2026-109618 / 109619 (partial — speed-bump only)

Memory-corruption bugs in the PHP interpreter core (a use-after-free reached through PHP's session.upload_progress, plus an out-of-memory path), both unauthenticated RCE. The bug lives below userland, so a WAF signature cannot be a reliable fix — it only raises the bar against the known public exploitation path, which rides a multipart field named PHP_SESSION_UPLOAD_PROGRESS:

SecRule ARGS_NAMES "@rx (?i)PHP_SESSION_UPLOAD_PROGRESS" "id:130202,phase:2,deny,status:403,log,msg:'Block PHP session.upload_progress abuse (PHP2Shell CVE-2026-109619)',tag:'cve-2026-109619'"

Read before deploying:

  • It blocks one exploitation technique, not the vulnerability. CVE-2026-109618 (OOM path) has no reliable request signature; the only blunt lever there is a request-body size limit on the upload endpoint.
  • PHP_SESSION_UPLOAD_PROGRESS is a legitimate PHP feature. If an app behind the route genuinely uses upload progress, this rule breaks those uploads — hence per-route scoping and detect first.
  • Nothing here replaces updating PHP to the fixed release.

Both rules were validated against Arenet's own SecLang allowlist and Coraza engine: they pass the editor's check, block the attack shape, and leave a normal request untouched. Run Test a request on your route to see the same before switching to block.


CRS paranoia levels

OWASP CRS supports four paranoia levels (PL1–PL4) controlling the strictness :

  • PL1 (default) : rules with low false-positive rate, suitable for most public web apps
  • PL2 : adds rules that may trip on legitimate edge cases
  • PL3 : aggressive, expect FPs on rich-content apps
  • PL4 : maximum, for hardened endpoints only

Arenet currently emits PL1 by default for every route. Per-route paranoia level configuration is not exposed in the UI as of v2.9.3 ; if you need PL2+ on a specific route, the workaround is custom Coraza directives via the route's WAF config (advanced, not UI-exposed).


Performance considerations

  • The WAF handler runs before the reverse-proxy handler so a block-mode rejection never reaches the upstream
  • WAF instances are pooled by their directives-string SHA — N routes with identical WAF config share one Coraza WAF instance in memory (see internal/waf/module.go's pool key)
  • Body inspection (multipart, XML, JSON) is the heaviest path. For routes that handle large uploads (file servers, Docker registry), enable Upload streaming mode on the route which skips body inspection and asks Caddy to flush bytes through without RAM buffering
  • The Coraza engine itself is Go-native (not cgo), so no thread-overhead per request

Common false-positive patterns + fixes

Symptom Likely cause Fix
GET with body returns 403 on REST API 920170 (attack-protocol) Exclude rule 920170 or tag attack-protocol
PUT/DELETE returns 403 on admin API 911100 (METHOD-ENFORCEMENT) Exclude rule 911100 or tag attack-protocol
Anything containing -- in URL returns 403 942100 (SQLi libinjection FP on UUIDs / version strings) Exclude rule 942100 or, narrower, exclude only on the specific route
Multipart upload returns 403 922xxx (multipart attacks) Exclude tag attack-multipart or enable Upload Streaming Mode
Authentik / Keycloak /api/v1/auth/oidc/callback returns 403 Multiple CRS rules trip on OAuth state/code params Exclude tags attack-protocol + attack-sqli on the IdP route, OR set the IdP route's WAF to off

Monitoring : staying ahead of false positives

The recommended workflow when adding a new route :

  1. Set WAF mode to detect first
  2. Use the route normally for 24-48h (your real traffic exercises the CRS rule set)
  3. Open /security/<routeId> → review the events generated
  4. Identify FPs : events where the matched payload is legitimate user data, not an attack
  5. Add exclusions (rule IDs or tags) for the FPs
  6. Switch WAF mode to block
  7. Wire an alert (Alerting) on waf_event_rate > 10 / 5min so you catch unexpected spikes

Cross-route observability

The dashboard /dashboard shows the WAF detect/block rates per top-N routes. The /security page aggregates events across all routes with filters. The unified /logs shows WAF events alongside auth + rate-limit + cert events for correlation ("did this IP try N WAF probes AND fail auth N times AND trip rate-limit ?").


See also

  • Routes — where to find the WAF settings in the UI
  • CrowdSec — bans the most egregious WAF-triggering IPs at the network layer (before they reach the WAF)
  • Alerting — alert on waf_event_rate spikes
  • Custom Error Pages — brand the 403 page WAF returns
  • OWASP CRS documentation — rule reference + tag taxonomy
  • Coraza documentation — WAF engine internals
  • internal/waf/category.go — Arenet rule-ID → category mapping
  • internal/caddymgr/manager.go (lines ~1400-2550) — WAF handler emit + per-route SecAction shape

Clone this wiki locally