Skip to content

Rules ​

The WAF evaluates a set of rules defined in one or more JSON files. Each file is a JSON array of rule objects. Files are loaded by the rule_file directive (which may be repeated to load several files).

The schema below mirrors the Rule struct in types.go and the validation in rules.go (validateRule).


Schema ​

json
{
  "id":          "unique-rule-id",
  "phase":       1,
  "pattern":     "(?i)example",
  "targets":     ["URI", "ARGS"],
  "severity":    "HIGH",
  "score":       8,
  "action":      "block",
  "description": "Human-readable description",
  "priority":    10
}
FieldJSON keyTypeRequiredNotes
IDidstringyesMust be unique across all loaded files. Duplicate IDs are dropped with a warning.
PhasephaseintyesOne of 1, 2, 3, 4. See Phases.
PatternpatternstringyesA Go regexp pattern (RE2 syntax). Compiled at load time and cached per rule ID. Invalid patterns drop the rule with a warning.
Targetstargetsarray of stringyesOne or more target identifiers. See Targets.
Transformationstransformationsarray of stringnoOptional per-rule input transformation pipeline (ModSecurity/CRS names, e.g. ["urlDecodeUni","removeNulls"]). See Input normalization. Absent = the per-target default chain; explicit [] = no transformation.
SeverityseveritystringnoFree-form label used only for logging (e.g. LOW, MEDIUM, HIGH, CRITICAL). It does not affect blocking decisions.
Scorescoreintyes (validated ≥ 0)Added to the request's anomaly score on match.
Actionactionstringno"block" (block immediately on match) or "log" (log and continue). Empty / missing means: rely on the anomaly threshold only. mode is accepted as an alias for files written against older documentation — see Field name caveat.
DescriptiondescriptionstringnoHuman-readable description, written to log records.
PrioritypriorityintnoHigher priority is evaluated first within a phase. Defaults to 0.

Validation rules (from validateRule in rules.go) ​

A rule is rejected (and dropped from the runtime ruleset, with a warning logged) if any of the following holds:

  • id is empty
  • pattern is empty
  • targets is empty
  • phase is outside [1, 4]
  • score is negative
  • action is non-empty and not equal to "block" or "log"

Loading a file is aborted only when the file cannot be read or its contents cannot be parsed as a JSON array of rules. Individual invalid rules do not abort the load; they are reported in the Validation errors in rules log entry.

Field name caveat ​

The JSON key for the action is action, matching the Go field Action (types.go). Every bundled rule file uses it.

Earlier versions of this page documented the key as mode because the Go struct was tagged json:"mode" while the bundled files used "action" — so, in practice, no shipped rule ever had an explicit action and all blocking came from the anomaly threshold. That tag is fixed. To keep rule files written against the old documentation working, the loader still accepts mode as an alias; when both keys are present, action wins. When authoring new rules, use "action".


Phases ​

PhaseInspected atAvailable targets
1After pre-request checks (IP / DNS blacklist, rate limit, GeoIP, ASN), before the upstream handler.Request method, URL, headers, cookies, query parameters, JSON body paths.
2After Phase 1, before the upstream handler. Same time window as Phase 1; useful for separating header-only checks from body-aware checks.Request body (BODY, JSON_PATH:), plus all Phase 1 targets.
3After the upstream handler returns, before the response leaves the proxy.RESPONSE_HEADERS, RESPONSE_HEADERS:<name>.
4After Phase 3.RESPONSE_BODY.

Within a phase, rules are sorted by descending priority, then evaluated in order. The first rule that triggers a block stops the phase.


Targets ​

Defined in request.go. Names are matched case-insensitively unless noted otherwise.

ModSecurity/CRS target names are accepted as aliases: REQUEST_HEADERS→HEADERS, REQUEST_COOKIES→COOKIES, QUERY_STRING→ARGS, REQUEST_URI→URI, REQUEST_BODY→BODY, REQUEST_FILENAME→PATH.

Static targets ​

TargetSource
METHODr.Method
REMOTE_IPr.RemoteAddr (host:port form)
PROTOCOLr.Proto
HOSTr.Host
URIr.URL.RequestURI()
URLr.URL.String()
PATHr.URL.Path
ARGSr.URL.RawQuery
USER_AGENTr.UserAgent()
CONTENT_TYPEr.Header.Get("Content-Type")
BODYRequest body, read through io.LimitReader(MaxRequestBodySize) and re-attached so downstream handlers still see the full body.
HEADERSAll request headers serialised as Name: v1,v2; Name: v….
COOKIESAll request cookies serialised as name=value; name=value.
FILE_NAMEFirst file name from r.MultipartForm.
FILE_MIME_TYPEFirst file Content-Type from r.MultipartForm.
RESPONSE_HEADERSAll response headers (Phase 3 only).
RESPONSE_BODYResponse body captured by the response recorder (Phase 4 only).

Dynamic targets ​

These accept an argument after the colon. Parameter and header names are case-sensitive in the value passed to lookup, but the prefix is matched case-insensitively.

TargetSource
HEADERS:<name>r.Header.Get("<name>")
COOKIES:<name>r.Cookie("<name>")
URL_PARAM:<name>r.URL.Query().Get("<name>")
JSON_PATH:<dotted.path>Reads the body, parses as JSON, and walks the dotted path (numeric segments are array indices).
RESPONSE_HEADERS:<name>w.Header().Get("<name>") (Phase 3 only).

Multiple targets in one rule ​

A single targets entry may itself be a comma-separated list. The extractor will try each value in turn, joining successful extractions with commas. Failures on individual sub-targets are tolerated — only the successful extractions are passed to the regex engine.

json
"targets": ["URI,HEADERS:User-Agent,COOKIES:sessionid"]

Input normalization ​

Rule patterns are matched against the request the way the application behind the WAF will consume it, not only the raw bytes on the wire. This closes encoding evasion: a payload like %75nion%20select (or a fully percent-encoded query, or a percent-encoded form body) reaches a SQL engine as union select, so the WAF must see it that way too.

How matching works ​

Each target value is matched twice: once as the raw extracted value, and once as a transformed copy. The rule fires if either matches. Because the raw value is always tested first, a rule that matches today can never stop matching — transformations only add coverage. Unencoded traffic pays no extra regex (the second pass runs only when normalization actually changed the value).

Default chain ​

When a rule does not set transformations, the raw request targets ARGS, URI, URL and BODY get a conservative default chain:

  1. urlDecode — single-pass percent-decode. + becomes a space in query/body context; in the path portion of URI/URL it stays literal.
  2. removeNulls — strip NUL bytes used to split keywords (un%00ion).
  3. compressWhitespace — fold runs of whitespace to one space (defeats tab/newline substitution).

PATH and URL_PARAM:<name> are already decoded by Go and are left untouched by the default. All other targets get no default transformation.

Per-rule transformations ​

A rule may override the default with its own pipeline, applied in order:

json
{
  "id": "941-xss-entity",
  "phase": 2,
  "pattern": "(?i)<script",
  "targets": ["ARGS", "BODY"],
  "transformations": ["urlDecode", "htmlEntityDecode"]
}
NameEffect
urlDecode / urlDecodeUniSingle-pass percent-decode (context-aware +).
removeNullsRemove NUL bytes.
compressWhitespaceCollapse whitespace runs to one space.
replaceCommentsReplace /* … */ with a space (defeats union/**/select).
htmlEntityDecodeDecode common HTML entities (&lt;, &#39;, …).
lowercaseLowercase (patterns already use (?i), so rarely needed).
noneNo-op.

Names are case-insensitive and an optional t: prefix is accepted, so both t:urlDecodeUni and urldecodeuni resolve. An unknown name fails at load time rather than silently doing nothing. An explicit empty array means "match the raw value only".

What this does NOT close ​

Decoding is single-pass by design: %2555 decodes to the literal %55, not to U. This is correct — an application that decodes once also sees %55 — so double-encoding is not "caught" because it is not an attack against a single-decoding backend. %uXXXX (IIS/.NET Unicode) and overlong UTF-8 are not decoded by the current pipeline. Cookie and header targets are not normalized by default; add transformations to a rule that needs it.

How a match becomes a block ​

For each rule that matches:

  1. The hit counter for the rule (a *atomic.Int64 in Middleware.ruleHits) is incremented.
  2. The phase counter (Middleware.ruleHitsByPhase[phase]) is incremented.
  3. state.TotalScore += rule.score.
  4. The request is blocked with 403 Forbidden if either:
    • state.TotalScore >= anomaly_threshold, or
    • rule.action == "block".
  5. When blocked, the configured custom response for 403 (if any) is written; otherwise the default plain-text body is sent.

Rules with action == "log" log the match at INFO level and let evaluation continue.


Examples ​

json
[
  {
    "id": "block-scanners",
    "phase": 1,
    "pattern": "(?i)(nikto|sqlmap|nmap|acunetix|nessus|wpscan|burpsuite|metasploit|nuclei)",
    "targets": ["HEADERS:User-Agent"],
    "severity": "CRITICAL",
    "score": 10,
    "action": "block",
    "priority": 100,
    "description": "Block well-known vulnerability scanners by User-Agent."
  },
  {
    "id": "log4j-jndi",
    "phase": 2,
    "pattern": "(?i)\\$\\{jndi:(ldap|rmi|dns):\\/\\/[^}]*\\}",
    "targets": ["BODY", "ARGS", "URI", "HEADERS"],
    "severity": "CRITICAL",
    "score": 10,
    "action": "block",
    "description": "Detect Log4Shell (CVE-2021-44228) JNDI injection attempts."
  },
  {
    "id": "low-score-log",
    "phase": 2,
    "pattern": "(?i)suspicious-keyword",
    "targets": ["BODY"],
    "severity": "LOW",
    "score": 1,
    "action": "log",
    "description": "Record suspicious keyword without blocking."
  },
  {
    "id": "json-admin-flag",
    "phase": 2,
    "pattern": "^true$",
    "targets": ["JSON_PATH:user.is_admin"],
    "severity": "HIGH",
    "score": 8,
    "action": "block",
    "description": "Block requests attempting to set is_admin via mass assignment."
  },
  {
    "id": "leaky-server-header",
    "phase": 3,
    "pattern": "(?i)apache|nginx/\\d|iis",
    "targets": ["RESPONSE_HEADERS:Server"],
    "severity": "MEDIUM",
    "score": 2,
    "action": "log",
    "description": "Log responses leaking the server software identity."
  }
]

Notes on regex performance ​

  • Patterns are compiled by Go's regexp (RE2). RE2 guarantees linear-time execution; ReDoS attacks against the matcher are not possible.
  • Compiled patterns are cached by rule ID in the per-middleware RuleCache. Reloading rules reuses cached compilations when the rule ID has not changed; new IDs trigger compilation.
  • Use (?i) at the start of the pattern for case-insensitive matching. RE2 also supports (?s) (. matches newlines) and other flags as documented in the Go regexp syntax reference.
  • Avoid expensive constructs (large [abc]{1,1000} ranges, deep alternations of long literals) — RE2 is linear in input size, but constants matter.

Authoring tips ​

  • Prefer modular files under rules/ over a single monolithic rules.json. Multiple rule_file directives load them all.
  • Always test new rules against the bundled offensive payloads in test.py before deploying.
  • Set priority on rules that should evaluate before others within the same phase.
  • Use action: "log" while tuning thresholds; switch to action: "block" once false-positive rates are acceptable.