Rule format
A rule is a JSON object. It fires when all of its conditions match, then runs its action. Add it to the plugin config under rules_request (request phase) or rules_response (response phase).
{
"id": "1234",
"phase": "access",
"conditions": [
{
"op": "rx",
"transform": ["urlDecodeUni"],
"value": "['\"`]+.*['\"`;&|]+",
"variables": ["request.arg.value"],
"multi_match": false
}
],
"action": { "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" } },
"log": true,
"message": "Example injection rule",
"tags": ["injection", "virtual-patching"]
}| Field | Type | Description |
|---|---|---|
id | string | Unique identifier. Used in logs and by rule controls / overrides. |
phase | string | access, header_filter, body_filter, or mcp_event. See Phases. |
conditions | array | One or more condition objects, AND-ed together. See Conditions. |
action | object | What to do when the rule fires. See Actions. |
message | string | Human-readable description, written to the audit log. |
tags | array | Labels used by overrides and rule controls (e.g. attack-sqli). |
log | boolean | Whether a match is written to the audit log. |
rule_control | array | Optional self-modifications applied to this or other rules. See Rule controls. |
Conditions
Each condition applies its operator to one or more variables, optionally after a transformation chain. A condition matches if the operator matches on any of its resolved values.
| Field | Type | Description |
|---|---|---|
variables | array | What to inspect (e.g. request.arg.value, request.header.value:host). See Variables. |
op | string | The operator (e.g. rx, eq, libinjection_sqli). See Operators. |
value | string | The operator argument (pattern, number, token list…). Use "" for operators that take none (isSet, libinjection_*). |
transform | array | Transformations applied to each value before the operator runs, in order. Omit or [] for none. See Transformations. |
negated | boolean | Invert the match. See Negation. |
multi_match | boolean | When true, the operator is also tested against each intermediate transform result, not only the final one. |
matched.value and the regex capture groups group:0, group:1, …Phases
| Phase | Runs | Can inspect |
|---|---|---|
access | Before the request reaches your app | Method, path, query, headers, cookies, and the parsed body. Can block, sanitize, or modify the request. Most rules live here. |
header_filter | After the app responds, before headers go to the client | The request plus the upstream response status and headers. |
body_filter | While streaming the response body | Response body chunks. Used internally for MCP SSE reassembly. |
mcp_event | Per reassembled SSE event (Karna-native) | A single Model Context Protocol event. Rules can drop / replace / terminate / inject events. Non-MCP requests skip this phase. |
Variables
Variables name what a condition inspects. Many are arrays (e.g. all header values). Append :<selector> to target a specific named element, for example request.header.value:host or request.arg.value:username.
| Variable | Resolves to |
|---|---|
request.arg.value | Values from query string + parsed body (ModSec ARGS). Canonical for "any argument value". |
request.arg.name | Argument names from query string + parsed body. |
request.query.value / .name | Values / names from the query string only. |
request.body.urlencode.value:<name> | A specific urlencoded form field. |
request.body.json.value:<path> | A value at a JSON path in the parsed body. |
request.body | Raw body (for urlencoded / text bodies). |
request.header.value / .name | Request header values / names. Target one with :<header>. |
request.header_no_fp.value | Header values excluding the most FP-prone ones (User-Agent, Referer, …). |
request.cookie.value / .name | Cookie values / names. |
request.raw_path | URL path, not normalized, no query string. |
request.basename | Last segment of the path (e.g. index.php). |
request.method | HTTP method. |
request.file | Uploaded file names / multipart param names. |
request.body.multipart.filename | Multipart filenames. |
request.body.multipart.header.value | Multipart part header values. |
request.header.referer.{path,query,scheme,host} | Components of the Referer URL. |
response.set_cookie.value / .name | Values / names from Set-Cookie (response phases). |
response.header.name:<name> | A specific response header (response phases). |
matched.value | The value matched by an earlier condition in the chain. |
group:<n> | Capture group n from an earlier rx match (group:0 = full match). |
tx:<name> / var:<name> | Transaction variables (CRS TX:), e.g. var:paranoia_level. |
redis.<key> | Inspect a Redis key (read-only). Everything after redis. is the key name, with macros allowed (redis.ban:%{remote_addr}). The operator picks the command — see Redis inspection. Needs redis_inspect_enabled. |
geoip.* / asn.* | Enrichment from a sibling plugin (geoip.country_code, asn.org, …), when present. |
mcp.* | MCP fields (mcp.method, mcp.tool.name, …) when mcp_enabled. |
request.arg.value:<name> (covers query + urlencoded body).Operators
The op field names one operator. An operator not in this set never matches. CRS operators (@detectSQLi, @streq, @detectXSS, …) are translated to these names when SecLang is parsed.
| op | Negatable | Description |
|---|---|---|
rx | yes | Regex match against the value. With the RE2 engine this is linear-time and ReDoS-safe. |
eq | yes | Exact equality (string or number). |
ge / gt / lt / le | yes | Numeric ordering. Non-numeric input fails closed. |
beginsWith / endsWith | yes | String prefix / suffix match. |
contains | yes | Literal substring (case-sensitive). |
within | yes | Value is one of the whitespace-separated tokens in value. |
isSet | yes | Whether the variable resolves to anything. With negated: true, fires on absence. |
pm / pmFromFile | yes | Phrase match: any token in value (or a file) appears in the variable. |
ipMatch | yes | IPv4/IPv6/CIDR match against a comma- or space-separated list. |
libinjection_sqli / libinjection_xss | yes | SQLi / XSS detection via libinjection. |
validateUrlEncoding | yes | Matches on malformed %XX sequences. |
validateUtf8Encoding | yes | Matches when input is not valid UTF-8. |
validateByteRange | yes | Matches when any byte falls outside the ranges in value (e.g. "32-126,9,10,13"). |
unconditionalMatch | n/a | Always true. Used as the predicate of chains gated by other conditions' side-effects. |
mcp_method_in | n/a | The JSON-RPC method is in value (MCP). |
mcp_jsonrpc_valid | n/a | The body is a valid JSON-RPC 2.0 envelope (MCP). |
redis_sismember | yes | The value is a member of the Redis SET named by the redis.<key> variable. Negated = not a member (allowlist). Needs redis_inspect_enabled. |
redis_hexists | yes | The Redis HASH named by the redis.<key> variable has a field equal to value. Negated = field absent. Needs redis_inspect_enabled. |
@ipMatchFromFile, @verifyCC, @verifySSN, @geoLookup, @inspectFile. Rules that need them are skipped at parse time with a WARN line in the Kong error log.Negation
Every binary operator can be negated. The canonical form is a separate boolean field, not a ! prefix:
{
"op": "isSet",
"negated": true,
"value": "",
"variables": ["request.header.value:content-type"]
}Semantics: a negated condition fires when the positive match fails and the value is present. A missing variable does not satisfy a negated condition. The one exception is isSet with negated: true, which is the way to spell "variable is absent" and so fires on a missing variable.
For back-compat the engine still accepts "op": "!rx", but new rules should use negated.
Transformations
Listed in transform, applied in order to each value before the operator runs. There are no implicit transforms — what you list is what runs. Results are cached per request.
| Transform | Effect |
|---|---|
lowercase | Lowercase ASCII. |
urlDecodeUni | URL-decode, including %uHHHH sequences (alias: urlDecode). |
hexSequenceDecode | Decode %HH sequences (one pass). |
htmlEntityDecode | Decode HTML entities (&#xHH;, ", …). |
jsDecode | Decode JavaScript \uHHHH / fullwidth escapes. |
cssDecode | Decode CSS escapes. |
escapeSeqDecode | Decode ANSI-C escapes (\n, \xHH, …). |
base64Decode | Base64-decode (alias: base64decode). |
removeNulls | Strip NUL bytes. |
removeWhitespace | Strip all whitespace. |
compressWhitespace | Collapse runs of whitespace to a single space. |
replaceComments | Replace /* */ and // with a space. |
removeCommentsChar | Strip comment characters (/*, */, //, #). |
normalisePath | Normalize path slashes and . / .. segments (alias: normalizePath). |
normalizePathWin | Like above, treating \ as a separator. |
cmdLine | Command-line normalization (strip quoting, collapse spaces, lowercase). |
utf8toUnicode | Convert UTF-8 to %uHHHH form. |
length | Replace the value with its length (a number). |
sha1 | SHA-1 digest (raw bytes). |
hexEncode | Hex-encode bytes. |
Actions
The action object decides what happens on a match. Side-effect actions (set_variable, set_log_fields, redis_incr_key, redis_set, redis_sadd, redis_del) fire whether or not the request is blocked. Terminal actions only block when engine_blocking_mode is on.
| Action | What it does |
|---|---|
fixed_response | Terminate with a fixed status / headers / body (the standard block). |
fix_matched_parts | Sanitize the matched targets in place and let the request through. Takes precedence over fixed_response if both are present. |
rate_limit | Redis fixed-window counter; returns 429 when the limit is exceeded. |
redis_incr_key | Increment a Redis key with a TTL (no terminal effect). |
redis_set / redis_sadd / redis_del | Write cluster-wide state on a match: set a key (with optional TTL), add a member to a set, or delete a key. The auto-ban primitive. |
set_variable | Write a value into kong.ctx.shared or kong.ctx.plugin for sibling plugins / later phases. |
set_log_fields | Add custom fields to the audit log entry. |
fixed_response
"action": { "fixed_response": { "status_code": 403, "headers": { "content-type": "text/plain" }, "body": "Forbidden\r\n" } }
fix_matched_parts
Strips remove_chars_pattern from every matched target and forwards upstream. The audit log marks the entry action: "sanitized".
"action": { "fix_matched_parts": { "remove_chars_pattern": "[\"';&|`]*" } }
rate_limit
| Field | Default | Purpose |
|---|---|---|
key | %{remote_addr} | Counter cardinality. Macros: %{remote_addr}, %{request.method}, %{request.host}, %{request.scheme}, %{request.path}. |
limit | 0 | Max requests in the window. |
window_seconds | 60 | Window length / counter TTL. |
response | 429 | Optional status_code / body / headers. Retry-After is set automatically. |
redis_set / redis_sadd / redis_del
Write cluster-wide state on a match. These are fire-and-forget side effects: synchronous in the access phase, deferred to a timer in later phases, and they never block the request themselves. Keys, values, and members are macro-resolved (%{remote_addr}, %{request.method|host|scheme|path}, %{request_headers.X}). Pair them with a redis.<key> inspection rule to close an auto-ban loop — see the auto-ban example.
| Action | Fields | Redis command |
|---|---|---|
redis_set | key, value (default "1"), expire | SET key value, plus EX expire when expire is set. |
redis_sadd | key, member, expire | SADD key member, plus EXPIRE key expire when expire is set. |
redis_del | key | DEL key (manual unban / clear). |
"action": { "redis_set": { "key": "ban:%{remote_addr}", "value": "1", "expire": 600 } }
set_variable
type is required (shared → kong.ctx.shared, plugin → kong.ctx.plugin). String values support %{var} macros resolved against the request.
"action": { "set_variable": { "name": "skip_js_challenge", "value": true, "type": "shared" } }
set_log_fields
"action": { "set_log_fields": [ { "name": "username", "value": "%{request.body.urlencode.value:username}" } ] }
Redis inspection
A redis.<key> variable reads shared state from Redis at request time. The variable names the key (everything after redis., macros allowed), and the operator is the test — it decides which read command runs. Reads are off unless redis_inspect_enabled is on, and the client is locked to a read-only command whitelist.
| Operator | Redis command | Meaning |
|---|---|---|
isSet | EXISTS | Key exists. negated: true fires on absence (allowlist). The canonical ban / ACL-TTL check. |
eq / rx / contains / beginsWith | GET | Read the value, then compare with the operator. An absent key never matches. |
gt / lt / ge / le | GET | Read the value and compare numerically (counters / scores stored as strings). |
redis_sismember | SISMEMBER | value is a member of the set. Negatable. |
redis_hexists | HEXISTS | The hash has a field named value. Negatable. |
The key macros are %{remote_addr}, %{request.method|host|scheme|path}, and %{request_headers.X}. The value needle resolves %{remote_addr} and %{request.*}. When Redis is unreachable, redis_on_error decides the outcome (default skip — the condition does not match and traffic flows).
{
"op": "isSet",
"value": "",
"variables": ["redis.ban:%{remote_addr}"]
}{
"op": "redis_sismember",
"value": "%{request_headers.authorization}",
"variables": ["redis.revoked_tokens"]
}Rule controls
A rule's rule_control array modifies rules (itself or others by id / tag). Useful for carving out false positives and patching CRS rules without editing the pack.
| Control | Effect |
|---|---|
remove_rule | Skip a rule entirely ({ "rule_id": "1234" }). |
remove_rules_by_tag | Skip all rules with a tag. |
remove_variable_from_rule_conditions | Drop a variable from every condition of a rule. |
remove_variable_rx | Drop variables whose key matches a regex (great for libinjection header FPs). |
remove_target_rule_by_pattern | Drop matched-key targets from a rule by Lua pattern. |
remove_target_tag_by_pattern | Same, for all rules with a tag. |
change_rule_action | Replace a rule's action. |
change_condition_tfunc | Replace a condition's transform chain (condition_number is 1-based). |
change_condition_value | Replace a condition's operator value. |
replace_condition / remove_condition / add_condition | Replace, delete, or append a condition. |
"rule_control": [ { "remove_variable_rx": { "name": "request.header.value", "rx": ".*(?:[Uu]ser\\-[Aa]gent|[Rr]eferer|[Aa]uthorization).*" } } ]
CRS overrides
To change what existing CRS rules do without rewriting them, use the config-level rule_action_overrides and rule_response_overrides arrays (see Configuration). Each entry has a selector and a payload; the first matching entry wins.
| Selector | Behaviour |
|---|---|
ids | OR-match against rule.id. |
id_ranges | Numeric range, inclusive (e.g. "941000-941999"). |
tags | Any tag in the list intersects rule.tags. |
except_ids / except_tags | Exclude a rule even on a positive match. |
any | true matches every rule (use with except_*). |
{
"selector": { "tags": ["attack-xss"] },
"action": { "type": "fix", "remove_chars_pattern": "[<>\"'&;]" }
}SecLang rules
If you prefer ModSecurity syntax, put raw SecRule strings in custom_secrules. They are parsed at worker start and added to the global pool alongside the CRS. Only the canonical SecRule <vars> "<op>" "<actions>" form is parsed; SecRule* derivatives are skipped.
"custom_secrules": [ "SecRule ARGS:debug \"@streq 1\" \"id:9001,phase:1,deny,status:403,msg:'debug arg blocked'\"" ]
Example: detect & block
Block header-borne SQL injection, with a carve-out for FP-prone headers.
{
"id": "2201",
"phase": "access",
"conditions": [
{
"op": "libinjection_sqli",
"transform": ["urlDecodeUni"],
"value": "",
"variables": ["request.header.value"]
}
],
"action": { "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" } },
"message": "SQL Injection: header-borne",
"rule_control": [
{
"remove_variable_rx": {
"name": "request.header.value",
"rx": ".*(?:[Uu]ser\\-[Aa]gent|[Rr]eferer|[Aa]uthorization).*"
}
}
],
"tags": ["injection", "attack-sqli"]
}Example: sanitize, don't block
Strip XSS-shape characters from the name argument instead of blocking. GET /signup?name=O'Brien reaches the app as name=OBrien.
{
"id": "sanitize-name-field",
"phase": "access",
"log": true,
"conditions": [
{
"op": "rx",
"transform": [],
"value": "[<>\"'&;]",
"variables": ["request.arg.value:name"]
}
],
"action": { "fix_matched_parts": { "remove_chars_pattern": "[<>\"'&;]" } },
"tags": ["sanitize"],
"message": "neutralize XSS-shape chars in name"
}Example: rate limit
Cap /api/login to 5 attempts per minute per source IP.
{
"id": "rl-login-per-ip",
"phase": "access",
"conditions": [
{
"op": "beginsWith",
"transform": [],
"value": "/api/login",
"variables": ["request.raw_path"]
}
],
"action": {
"rate_limit": {
"key": "%{remote_addr}",
"limit": 5,
"window_seconds": 60,
"response": { "status_code": 429, "body": "Too many login attempts.\r\n" }
}
},
"message": "login rate limit",
"tags": ["ratelimit", "auth"]
}Example: counter + threshold
Two rules: the first increments a Redis counter on a failed login (no session cookie set on a login POST); the second blocks once the counter is high. The read needs redis_inspect_enabled, and both rules key on the same Redis key so the write and read line up.
{
"id": "count-failed-login",
"phase": "header_filter",
"conditions": [
{
"op": "beginsWith",
"value": "/login",
"variables": ["request.raw_path"]
},
{
"op": "eq",
"value": "POST",
"variables": ["request.method"]
},
{
"op": "isSet",
"negated": true,
"value": "",
"variables": ["response.set_cookie.name:session"]
}
],
"action": {
"redis_incr_key": { "key": "failed_login:%{remote_addr}", "expire": 300 }
},
"log": false
}{
"id": "block-failed-login",
"phase": "access",
"conditions": [
{
"op": "ge",
"value": "5",
"variables": ["redis.failed_login:%{remote_addr}"]
}
],
"action": { "fixed_response": { "status_code": 403, "body": "Too many login attempts.\r\n" } },
"log": false
}Example: distributed auto-ban
Two rules close the loop across every Kong node. The first writes a ban with a TTL when it catches an attack; the second blocks any request from a banned IP. Needs redis_inspect_enabled for the read and a shared Redis so all nodes see the same ban.
{
"id": "ban-on-sqli",
"phase": "access",
"conditions": [
{
"op": "libinjection_sqli",
"transform": ["urlDecodeUni"],
"value": "",
"variables": ["request.arg.value"]
}
],
"action": {
"redis_set": {
"key": "ban:%{remote_addr}",
"value": "1",
"expire": 600
},
"fixed_response": { "status_code": 403, "body": "Forbidden\r\n" }
},
"message": "SQLi — ban source for 10 min",
"tags": ["attack-sqli"]
}{
"id": "block-banned",
"phase": "access",
"conditions": [
{
"op": "isSet",
"value": "",
"variables": ["redis.ban:%{remote_addr}"]
}
],
"action": { "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" } },
"message": "source is banned",
"tags": ["banlist"]
}Example: chained rule
Conditions are AND-ed. This rule fires only on a POST to /upload carrying a script-like filename.
{
"id": "block-script-upload",
"phase": "access",
"conditions": [
{
"op": "eq",
"value": "POST",
"variables": ["request.method"]
},
{
"op": "beginsWith",
"value": "/upload",
"variables": ["request.raw_path"]
},
{
"op": "rx",
"transform": ["lowercase"],
"value": "\\.(php|phtml|jsp|asp)$",
"variables": ["request.file"]
}
],
"action": { "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" } },
"message": "script upload blocked",
"tags": ["upload"]
}