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 — every custom rule goes there whatever its phase, and the engine runs it in the phase named by its phase field.
{
"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 | Master switch for writing this rule to the audit log. Set it explicitly on a local rule. A rule in rules_request or custom_secrules is not logged unless log is true — only a global rules pack defaults it to true when absent, and only CRS rules are forced to true at load. Rate-limit rules additionally suppress admitted matches unless rate_limit.log_all_matches is true. |
rule_control | array | Optional self-modifications applied to this or other rules. See Rule controls. |
JSON Schema
The rule format is published as a JSON Schema (draft 2020-12), so an editor can complete and check rules as you type them and CI can reject a broken pack before it reaches a gateway. Two documents, both served from this site and both resolvable offline from docs/schema/ in the repository:
| Schema | Validates |
|---|---|
karna-rule.schema.json | One rule object — a single rules_request entry. |
karna-rules.schema.json | An array of rules — a global rules pack file, or the json payload published to Redis. |
In your editor
Point the schema at your rule files and you get completion on operators, transformations and action names, plus an immediate error on a field Karna would have ignored in silence.
{
"json.schemas": [
{
"fileMatch": ["global-rules.d/*.json", "karna-rules*.json"],
"url": "https://karna.sicuranext.com/docs/schema/karna-rules.schema.json"
},
{
"fileMatch": ["karna-rule-*.json"],
"url": "https://karna.sicuranext.com/docs/schema/karna-rule.schema.json"
}
]
}A single-rule file can also carry the reference itself, which works in any editor with no per-project configuration:
{
"$schema": "https://karna.sicuranext.com/docs/schema/karna-rule.schema.json",
"id": "100001",
"phase": "access",
"conditions": [
{ "op": "libinjection_sqli", "value": "", "variables": ["request.arg.value"] }
],
"action": { "fixed_response": { "status_code": 403 } },
"message": "SQLi in a request argument"
}$schema is the only non-rule key the schema accepts, and Karna ignores it at load, so a file annotated this way stays deployable as is.
On the command line
The repository ships a validator that resolves the schemas locally, so it needs no network. It takes a single rule, a pack, or a JSON Lines file of rules, and exits non-zero on the first bad file.
pip install jsonschema python3 scripts/validate-rules.py global-rules.d/*.json # ok global-rules.d/00-exclusions.json (4 rule(s)) # FAIL global-rules.d/10-blocking.json (2 rule(s)) # conditions[0].variables[0]: 'ARGS' does not match ... # action: Additional properties are not allowed ('block' was unexpected)
Any standard validator works just as well, which is the point of publishing a schema rather than a linter:
# python check-jsonschema --schemafile docs/schema/karna-rules.schema.json global-rules.d/*.json # node npx ajv-cli validate --spec=draft2020 \ -r docs/schema/karna-rule.schema.json \ -s docs/schema/karna-rules.schema.json \ -d "global-rules.d/*.json"
Wire whichever one you use into CI on the repository that holds your pack. A global rules pack reaches every service at once, so a rule dropped at load for a typo is a gap you find in the logs rather than in review.
What it catches, and what it cannot
The schema is strict where being strict is safe. Unknown fields are rejected everywhere, because Karna ignores them: a misspelled tags or transform costs you the behaviour you thought you had written, with nothing in the logs to say so. It also enforces the operator and transformation vocabularies, requires an argument for the operators that take one, and requires type on set_variable, where a missing scope means the write is silently dropped.
Variables are checked on their namespace only (request., response., tx:, redis., …), which is enough to reject a ModSecurity name such as ARGS or a typo in the prefix, but not enough to prove a full name resolves. That distinction matters: a variable that resolves to nothing quietly disables a rule, and paired with isSet and negated it makes the rule fire on every request. Beyond the shape, the schema says nothing about whether a rule is correct — a valid rule can still have a regex that never matches. Test in detection-only mode first.
id to be a JSON string. Rule controls and overrides address rules by string id — a numeric range such as "920100-920199" expands to string keys — so a rule written with a numeric id cannot be targeted by remove_rule later.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. |
kong.ctx.shared.response_from_cache, Kong skips Karna’s access phase but still runs header_filter. Local header_filter rules are evaluated there anyway, cached response or not, on a request context built for the purpose: request headers, cookies, tls.* and %{remote_addr} resolve; the body namespaces resolve empty (the body is never read outside access); Redis actions run through a timer, so a cached response gains no synchronous round trip. Everything else — the Karna headers, CRS exclusion plugins, custom_secrules, the global pack, the 5xx mask — still skips a cached response. This is the phase to use for a request-side counter that must see every asset a client fetches. Requires 1.7.0 or later; before that, a cache hit skipped every rule.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.
%{…} macros in an action or a log field. A name with no resolver resolves to nothing, which disables the rule — and paired with isSet + negated it makes the rule fire on every request. Karna warns about such a name at rule load and scripts/validate-rules.py rejects it (1.7.1 and later). Check which of the two tables a name is in before you write the rule.Condition variables
| 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. Target one with request.query.value:<name>. |
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.xml.<path> | A value at a path in the parsed XML body, flattened by the SAX parser the same way JSON is. |
request.body | Raw body (for urlencoded / text bodies). |
request.body.length | Body size in bytes, as a string. Use with the numeric operators (gt, ge, …). |
request.body.processor | Which parser the body was routed to (JSON, XML, URLENCODED, MULTIPART), derived from Content-Type. The ModSec REQBODY_PROCESSOR equivalent. Resolves even when the request carries no body. |
request.header.value | Request header values. Target one with request.header.value:<header> (lowercase name). |
request.cookie.value | Cookie values. The resolved map also carries the cookie names under request.cookie.name:<n>, so an operator on this variable scans names and values together (ModSec REQUEST_COOKIES + REQUEST_COOKIES_NAMES). |
request.cookie.value:<name> | One cookie’s value (ModSec REQUEST_COOKIES:<name>). The name is case-insensitive. A JSON cookie resolves to its flattened keys (request.cookie.json.<name>.*), so it counts as present too. isSet with negated: true is “this cookie is missing”. Requires Karna 1.7.1 or later: before that the selector resolved to nothing in a condition, so a negated isSet on it matched every request and every other operator never matched. |
request.cookie.name / request.cookie.name:<name> | Cookie names only, as the client spelled them (ModSec REQUEST_COOKIES_NAMES), or one of them by case-insensitive name. Requires Karna 1.7.1 or later. |
request.raw_path | URL path exactly as it came off the wire: not normalized, percent-encoding intact, no query string. The one to match for traversal / encoding evasion. |
request.path | URL path after nginx normalization: dot segments resolved, percent-encoding decoded (%2F excepted — nginx never decodes an encoded slash). The same view the upstream routes on: /x/../y resolves to /y here and stays /x/../y in request.raw_path. |
request.path_with_query | Verbatim path plus the query string (CRS REQUEST_URI). |
request.raw_query | The query string alone, verbatim, undecoded and unparsed. |
request.line | The whole request line, <method> <path?query> HTTP/<version> (ModSec REQUEST_LINE). |
request.http_version | Protocol version as HTTP/1.1, HTTP/2, … |
request.basename | Last segment of the path (e.g. index.php). |
request.method | HTTP method. |
request.remote_addr | Client IP as seen on the transport — the ModSecurity REMOTE_ADDR equivalent, and the same value the %{remote_addr} macro resolves. Pair with ipMatch. |
request.forwarded_addr | Client IP as Kong resolves it: X-Forwarded-For walked back through Kong's trusted_ips, falling back to the peer address when the header is absent or the peer is not trusted. Behind a CDN or load balancer this is the one an IP allow/deny list needs. |
request.file | Uploaded file names / multipart param names. |
request.body.multipart.filename | Every multipart part's filename= (ModSec FILES). |
request.body.multipart.name | Every multipart part's name= (ModSec FILES_NAMES). |
request.body.multipart.part.<name> | Everything belonging to one multipart part — its value and its own sub-keys — collected under that part's name. |
request.body.multipart.header.value | Multipart part header values. Target one with :<header>. |
response.status | The status the client receives, as a string (header_filter phase). For a proxied request this is the upstream’s status. When a plugin generated the response it is that plugin’s status, so a rule can count the 429s Karna’s own rate_limit produced or react to its own blocks. Requires Karna 1.5.9 or later: before that the variable carried the upstream status only, and on a response Karna produced itself it resolved to nothing. |
response.header.value:<name> / .name:<name> | A specific response header, by lowercase name (response phases). |
response.set_cookie.value / .name | Values / names from Set-Cookie (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> | One transaction variable set earlier in the same request by a setvar action (CRS TX:). |
group_rx:<regex> | Every transaction variable whose name matches the regex, as a set of values (CRS TX:/pattern/). |
count:<variable> | How many values the named variable resolves to, as a number (ModSec &VAR). Use with the numeric operators: count:request.arg.value with gt counts arguments, and count:request.header.value:x-api-key with eq 0 is "header missing". The count works on request.arg.value, request.arg.name, request.header.value[:<n>], request.cookie.value[:<n>], request.cookie.name[:<n>], request.body and request.body.multipart*; on anything else it is always 0. |
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. |
mcp.* | MCP fields (mcp.method, mcp.tool.name, …) when mcp_enabled. |
connection.id | Pseudonymous connection identifier (kc1_<32 hex>): identical for every request on the same TCP connection (HTTP/1.1 keep-alive, HTTP/2 streams), different across connections, not reversible. Also a request-context macro (%{connection.id}) that resolves in every phase, e.g. as a rate_limit key for a per-connection rather than per-IP counter. |
tls.* | Negotiated TLS telemetry, read-only. tls.enabled (true/false) and tls.capture_status (not_tls/complete/partial/error) always resolve; on a TLS request also tls.protocol, tls.cipher, tls.curve, tls.alpn, tls.sni, tls.session_reused, tls.early_data (booleans as true/false), tls.client_ciphers and tls.client_curves (the lists the client offered, colon-separated, client order). Absent on plain HTTP, so isSet on them is false; an empty list resolves to "", which is distinct from absent. All usable as %{tls.…} macros. tls.fingerprint (64 hex) and tls.fingerprint_version carry the karna-tls-v1 client fingerprint, only when tls.capture_status is complete. |
request.arg.value:<name> (covers query + urlencoded body).isSet on it matches every request, any other operator never matches. Karna logs a warning at rule load for every such name (uses condition variable '…', which the engine does not resolve), and scripts/validate-rules.py rejects it. Check both after writing a rule.Macro-only names
These are not condition variables. They live in the request’s inspection table, which Karna builds in header_filter, so they resolve as %{…} macros in set_variable and redis_incr_key from header_filter onward, and in set_log_fields at log time. Used in a condition they resolve to nothing and the rule never fires.
| Name | Resolves to |
|---|---|
geoip.country_code / country_name / continent_code / continent_name | Geo enrichment published by a sibling plugin on kong.ctx.shared, when present. Also emitted in the enrichment block of audit log v2. |
asn.id / asn.org | ASN enrichment from a sibling plugin, same contract as the geo keys. |
request.header.name:<name> | A request header’s name as the client spelled it, keyed by its lowercase form. |
request.header_no_fp.value:<name> | Header values with the most false-positive-prone headers (User-Agent, Referer, …) left out. |
request.header.referer.{path,query,scheme,host} | Components of the Referer URL, parsed. |
request.forwarded_scheme / _host / _port / _path / _prefix | Kong’s forwarded view of the request, from the X-Forwarded-* headers it trusts. The address counterpart, request.forwarded_addr, is a condition variable. |
var:<name> | Legacy alias surface for transaction variables. Use tx:<name> in conditions. var:paranoia_level in particular no longer resolves anywhere: the paranoia gate is the rule’s own paranoia_level field, compared against the plugin config before the rule runs. |
plugin.<plugin>.<key> | Free-form values published by a sibling plugin under its own namespace on kong.ctx.shared. |
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.
Three things count as "not absent", so a negated isSet does not fire on them: a target removed by a ctl:ruleRemoveTargetById / ruleRemoveTargetByTag exclusion (an excluded target is not a missing variable), an unreadable Redis key, and a Redis read with redis_inspect_enabled off. See Redis variables for how redis_on_error resolves the last two.
Two things do count as absent, and both block every request when you did not mean them to: a variable name no resolver recognises (a typo; Karna warns about it at rule load, see above), and a response.* variable used by an access-phase rule. Both show up on the first request after deploy.
A rule that carries rule_control cannot also use a negated isSet: it is refused at load with an error naming the rule id. Such a rule applies its ctl:* side effects when it matches, so it can switch detection rules off — and keyed on absence it would fire on ordinary traffic, disabling detection for every request. Key the exclusion on something the request actually carries.
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). The TTL is set by the increment that creates the key and never refreshed, so the window is fixed: it starts at the first hit. Read the counter back with a value operator (gt, ge, …) on redis.<same key>; the comparison is numeric. |
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. |
log_only | true — record this non-terminal match in the audit log as a real match (the ModSecurity pass,log shape). Nothing is blocked or rewritten. See log_only. |
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
Redis fixed-window counter on karna:rl:<rule_id>:<resolved key>. The increment and the window TTL run in one server-side script, so they cannot interleave and the counter can never be left without an expiry. The window is fixed, not sliding: increments inside it do not push the expiry out.
| Field | Default | Purpose |
|---|---|---|
key | %{remote_addr} | Counter cardinality. Accepts the request-context macros; anything else stays literal. |
limit | 0 | Max requests in the window. |
window_seconds | 60 | Window length / counter TTL. 0 or less falls back to 60: an unbounded counter would never reset and would keep returning the terminal 429 forever. |
log_all_matches | false | Opt in to auditing admitted, under-limit matches. By default only limit exceedances and enforced bans are audit-eligible. The rule-level log flag remains the master switch. |
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 accept the request-context macros. 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, nx (default false) | SET key value, plus EX expire when expire is set. With nx: true it is SET … NX: only the write that creates the key lands, later ones leave the value and the TTL alone, so the key records the first time it was 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 — which names resolve depends on the phase, see Macros in actions.
"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}" } ] }
Macros in actions
Action fields that accept %{...} templates do not all draw on the same pool of names, and one group depends on the phase the rule runs in.
Request-context macros resolve in every phase — they are read straight off the request: %{remote_addr}, %{request.method}, %{request.host}, %{request.scheme}, %{request.path}, %{request_headers.<name>} and %{connection.id}.
Per-client key macros pick one cookie out of the request or the response, or one request argument, and can hash any key macro. They are meant for Redis keys that mark a client (or an object the client works on) in one request and check it in a later one.
| Macro | Value |
|---|---|
%{request_cookies.<name>} | The value of one request cookie, read off the Cookie header, so it resolves in access too. The name is matched case-insensitively; with a duplicate cookie the first one wins. The value is used raw (no %HH decoding), trimmed, and cut to 256 bytes. |
%{response_set_cookie.<name>} | The value of the response Set-Cookie for that cookie name: the text after = up to the first ;, attributes dropped. With several Set-Cookie headers for the same name the last one wins, which is the one the browser keeps. It has a value only from header_filter on; in access it is absent. Cut to 256 bytes. |
%{request.query.value:<name>} | The value of one query-string argument, and only the query string. The name is matched case-insensitively; with a repeated argument the first one wins. The value is the decoded one the rule variable of the same name sees, cut to 256 bytes. |
%{request.body.urlencode.value:<name>} | The value of one application/x-www-form-urlencoded body argument, and only the body. Same name, duplicate and length rules as the query macro. It reads the body Karna already parsed for the rules, in access; a body of another type (JSON, multipart, XML), a request without a body, or a body switched off by ctl:requestBodyAccess=Off makes it absent. |
%{sha256:<macro>} | The lowercase hex SHA-256 of another key macro, for example %{sha256:request_cookies.sid} or %{sha256:request_headers.authorization}. The whole value is hashed (no 256-byte cut). Use it so a session id never sits in Redis in clear. SHA-256 comes from resty.sha256, which ships with OpenResty and calls OpenSSL, so it adds no dependency and costs about a microsecond per key. |
Each argument macro names one source on purpose. There is no macro that takes the argument from “the query or the body”: the client would choose where to send it and so which key it lands on. If the same argument may arrive either way, write one rule per source.
An absent value never builds a key. When the cookie or the argument is missing, has an empty value (sid=, line_id=, or a deletion Set-Cookie), or a hashed macro has nothing to hash, the key would be the same for every such client. So the write actions (redis_set, redis_sadd, redis_del, redis_incr_key) skip the write, a rate_limit keyed on it counts nothing and bans nothing, and the redis.<key> read is unknown: neither isSet nor its negated form matches, and Redis is not queried, whatever redis_on_error says. Skips are logged at debug level. %{request_headers.<name>} keeps its older behaviour, a missing header resolves to an empty string, for compatibility with existing rules; use %{sha256:request_headers.<name>} when a missing header must not share a key.
[
{
"id": "cart-mark", "phase": "header_filter",
"conditions": [ { "variables": ["response.status"], "op": "eq", "value": "200" } ],
"action": { "redis_set": { "key": "cart:%{sha256:request_cookies.sid}:t", "expire": 900 } }
},
{
"id": "checkout-needs-cart", "phase": "access",
"conditions": [
{ "variables": ["request.path"], "op": "beginsWith", "value": "/checkout" },
{ "variables": ["redis.cart:%{sha256:request_cookies.sid}:t"], "op": "isSet", "negated": true }
],
"action": { "fixed_response": { "status_code": 403 } }
}
]A request to /checkout with no sid cookie at all does not match the second rule (unknown, not absent); add a separate condition on request.cookie.value:sid if such requests must be blocked too.
[
{
"id": "line-edit-count", "phase": "access",
"conditions": [ { "variables": ["request.path"], "op": "eq", "value": "/cart/edit" } ],
"action": {
"redis_incr_key": { "key": "line:n:%{sha256:request.body.urlencode.value:line_id}", "expire": 1800 },
"redis_set": { "key": "line:young:%{sha256:request.body.urlencode.value:line_id}", "expire": 60, "nx": true }
}
},
{
"id": "line-edited-too-often", "phase": "access",
"conditions": [
{ "variables": ["request.path"], "op": "eq", "value": "/cart/edit" },
{ "variables": ["redis.line:n:%{sha256:request.body.urlencode.value:line_id}"], "op": "gt", "value": "10" },
{ "variables": ["redis.line:young:%{sha256:request.body.urlencode.value:line_id}"], "op": "isSet", "negated": true }
],
"action": { "log_only": true }
}
]The first rule counts the edits of each line over a fixed 30-minute window and marks the line “young” for 60 seconds after its first edit (nx: later edits do not push the mark forward). The second one logs a line edited more than ten times and still edited after its first minute. A request without line_id is not counted and matches neither rule.
Two consequences to keep in mind. The client controls its cookies and its arguments, so a rate_limit keyed only on a cookie is skipped by a client that drops the cookie, and reset by one that changes it; pair it with a limit on %{remote_addr} when the limit is a defence rather than a fairness rule. And sha256: is unsalted: it keeps a random session id out of Redis, but a low-entropy value (an IP address, a short user id) can be recovered from its hash by trying every candidate.
Every other rule variable (%{request.header.value:host}, %{request.body.json.value:username}, %{response.status}, …) comes from the request’s inspection table, which Karna builds in header_filter. Used by a rule in the access phase, such a macro stays literal — the value does not exist yet.
| Field | Request context | Inspection table |
|---|---|---|
rate_limit.key / rate_limit.ban.key | yes, except %{request_headers.X} | no — stays literal |
redis_incr_key.key | yes | yes, from header_filter on |
redis_set / redis_sadd / redis_del | yes | no — stays literal |
redis.<key> variable (read side) | yes | no — stays literal |
set_variable.value | yes | yes, from header_filter on |
set_log_fields.value | only names the table also carries | yes — resolved at log time |
set_log_fields is the one field resolved purely from the inspection table: it runs at log time, when the table is complete, and its value must be a single %{…} macro rather than a template with text around it. Most request-context names are inspection-table keys too, so they work there; %{request_headers.<name>} is not one and does not.
An unresolvable macro is left literal rather than raising an error, so a typo or an early-phase lookup shows up as a %{…} in the key instead of a failed request. Practical consequence: a Redis key written by one rule and read back by another should use only request-context macros, so both sides derive the same string in any phase.
log_only
A rule whose job is to notice something and record it, without touching the request. The ModSecurity pass,log shape. The match lands in the audit log as a real entry — id, message, tags, matched value — labelled action: "log", in both formats (matches[] in v2, messages[] in v1) and in both the access and header_filter phases.
"action": { "log_only": true, "set_log_fields": [ { "name": "app_verdict", "value": "%{response.header.value:x-app-verdict}" } ] }
Because the match is real, it satisfies auditlog_only_on_match. Without log_only a non-terminal rule is invisible under that setting: it fires, but nothing is written, so the only way to see it was to log every request. Several log_only rules can fire on one request and all are recorded — nothing stops the rule loop.
pass helper rules (setvar counters, ctl: gates, chain scaffolding), and those cannot be filtered out by log because every CRS rule carries log = true. One flag per rule keeps the audit log the operator's decision. Note also that log_only decides whether the match is collected and log whether it is written — log: false still suppresses it — and that rule_action_overrides do not reach a log_only rule, so promoting one to blocking means editing the rule.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. An unreadable Redis is neither present nor absent: the condition then matches only when redis_on_error is fail_closed, whichever way the rule is written, so an allowlist rule does not turn a Redis outage into a total outage. redis_inspect_enabled: false behaves the same as an unreachable Redis. |
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. |
Keys accept the request-context macros and the per-client key macros (one cookie, one Set-Cookie value, one query or urlencoded body argument, sha256:); the value needle accepts the same set minus %{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. When a rule carrying controls matches, they apply to every rule evaluated after it in the same request — this is the same surface SecLang exposes as ctl:*, and what an exclusion rule in a global pack or in rules_request uses.
| Control | Effect |
|---|---|
remove_rule | Skip a rule entirely ({ "rule_id": "1234" }); a hyphen range works too ("920100-920199"). ← ctl:ruleRemoveById |
remove_rules_by_tag | Skip every rule carrying a tag ({ "tag": "attack-sqli" }). ← ctl:ruleRemoveByTag |
remove_target_from_rule_by_id | Drop a variable target from one rule ({ "rule_id": "942100", "target": "request.arg.value:pwd" }). The target is either one field (request.arg.value:pwd = ARGS:pwd, request.cookie.value:session = REQUEST_COOKIES:session; matched by name, lowercase, and every key the engine derived from that field goes too: the JSON expansion request.cookie.json.session.* / request.query.json:pwd.*, the base64 variant …_ka_b64_decoded, indexed duplicates …:pwd:2) or a whole collection written as a bare namespace (request.cookie.value = REQUEST_COOKIES, request.cookie.name = REQUEST_COOKIES_NAMES, request.header.value = REQUEST_HEADERS, request.arg.value = ARGS, request.query.value = ARGS_GET): the rule stops inspecting that collection; on a merged variable only the folded part is removed (request.query.value on an ARGS rule drops the query half and keeps the body args). Both forms are namespace-gated: a cookie target never touches a header or argument of the same name. ← ctl:ruleRemoveTargetById |
remove_target_rule_by_tag | Same, for every rule carrying a tag ({ "tag": "attack-sqli", "name": "request.header.value:user-agent" }); name takes the same two forms. tag: "OWASP_CRS" is special-cased to mean all rules, custom ones included. ← ctl:ruleRemoveTargetByTag |
engine_off | true — skip all remaining rule evaluation for this request. ← ctl:ruleEngine=Off |
detection_only | true — rules keep matching, logging and running side effects, but every terminal action is suppressed: fixed_response, the rate_limit 429, and fix_matched_parts sanitising. The audit log reports engine.mode: "detection" and action: "detect". ← ctl:ruleEngine=DetectionOnly |
engine_on | true — force blocking for the rest of the request, even on a service running engine_blocking_mode = false or after an earlier detection_only: every terminal action is enforced from here on (fixed_response, the rate_limit 429, fix_matched_parts). Applied before the carrying rule's own action, so engine_on + fixed_response on one rule is a virtual patch that blocks on a detection-only service; in SecLang put the ctl on the last link of the chain. engine_off still wins; between detection_only and engine_on the last applied wins. The audit log reports action: "block", engine.mode: "blocking" and engine.forced_by_rule (v2), an extra tag karna/engine-forced-on/<id> on the message (v1). ← ctl:ruleEngine=On |
body_access_off | true — stop inspecting the request body: request.body, the parsed body namespaces (json / xml / urlencode / multipart / files) and the body half of request.arg.value all resolve empty. Query args, path, headers and cookies are unaffected, and request.body.processor stays set (it derives from Content-Type). Useful on file-upload endpoints. ← ctl:requestBodyAccess=Off When the carrying rule is control-only and reads no body variable (path, headers, cookies, query — request.arg.* counts as body), it runs in the pre-body controls pass, before the body gates: the body is never read, parsed or counted for that request, so a 20 000-field form on that path passes without raising limit_arg_num. |
audit_request_body | true — attach the raw request body to this request's audit record, if one is written. Enrichment, not detection: a rule carrying only this control is not recorded as a match and does not force a write (auditlog_only_on_match is not satisfied by it). v2: request.body_raw + body_encoding (utf-8 / base64) / body_truncated / body_length; v1: transaction.request.body, ModSecurity's part C slot, plus the same siblings. The body as the client sent it, clipped at auditlog_request_body_max_bytes (16384), base64 when not valid UTF-8, never attached under body_access_off, never reflected into a response. It logs whatever the client sent — on a login form, credentials. ← ctl:auditLogParts=+C |
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. |
engine_off, not detection_only, not engine_on, not body_access_off — can switch them off or on. The three body gates (request_content_type_enforce, the body parser, limit_arg_num) run after the pre-body controls pass: control-only access rules whose conditions never read the body (path, headers, cookies, query string — ModSecurity phase 1) are evaluated first, and a body_access_off they apply takes the body out of those three gates; the body is then never parsed for that request. That is the one control a gate honours. Everything else is loosened through the plugin schema (request_content_type_enforce, limit_arg_num, request_methods_allowed, …)."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. ctl:* directives map to the rule controls above (ruleRemoveById, ruleRemoveByTag, ruleRemoveTargetById, ruleRemoveTargetByTag, ruleEngine=Off|DetectionOnly|On, requestBodyAccess=Off, auditLogParts=+C) and are read from every link of a chain, so ctl:ruleEngine=On on the last link fires only when the whole chain matched. Anything else after ctl: is ignored.
"custom_secrules": [ "SecRule ARGS:debug \"@streq 1\" \"id:9001,phase:1,deny,status:403,msg:'debug arg blocked'\"" ]
Global rules
Karna is attached per-service, so Kong has no native way to ship one rule pack to every service — a global plugin instance would be shadowed by the per-service ones. Global rules close that gap: one pack, evaluated on every service Karna is attached to, before local rules and the CRS.
There are two sources, independent and individually optional:
| Source | Enabled by | Shape | Update |
|---|---|---|---|
| Disk | KARNA_GLOBAL_RULES_PATH | A .json file, or a directory of *.json files | kong reload / restart |
| Redis | KARNA_REDIS_URL | A hash with a JSON and a SecLang payload, HMAC-signed | Hot, within the poll interval |
Pick disk when the rules belong in version control and ride your normal deploy (GitOps, DB-less, air-gapped, no Redis to run). Pick Redis when you need to change the pack on a running fleet without touching the nodes. Run both and the disk pack is the authoritative baseline the hot channel cannot overwrite — see Two sources, one pack.
From disk
Point KARNA_GLOBAL_RULES_PATH at a single .json file or at a directory, in which case every *.json inside is loaded in filename order — prefix the files (00-, 10-) and that prefix is the evaluation order. Each file is a bare JSON array of rules in the Karna rule format: the same objects you would put in rules_request, and the same array you would publish to Redis as the json payload, so a pack moves between the two channels verbatim. SecLang is not accepted on this channel.
docker run ... \
-e KARNA_GLOBAL_RULES_PATH=/etc/kong/karna/global-rules.d \
-v ./global-rules.d:/etc/kong/karna/global-rules.d:ro \
karna:latest
# ./global-rules.d/00-exclusions.json -> evaluated first
# ./global-rules.d/10-blocking.jsonThe pack is read once per worker at init_worker, synchronously, so it is live before the first request — there is no cold-start window and no filesystem watcher. Editing a file takes effect on kong reload (which respawns the workers) or a container restart. On disk the filesystem is the trust anchor, exactly as for the CRS rules themselves, so the pack carries no signature.
KARNA_GLOBAL_RULES_PATH must be declared in the main context with env KARNA_GLOBAL_RULES_PATH;. The published image already does this — see Configuration → Environment variables.At startup each worker logs one summary line at notice level (Kong's default): the path, how many files it read, how many rules ended up in each list, how many were dropped, and a sha256 fingerprint of the exact bytes the pack was built from. The fingerprint is how you check that every node in a fleet is running the same global rules; unlike a hand-written version field, it cannot be forgotten on an edit.
[karna] global rules: loaded file pack sha256:f2cc8ce2a430 from /etc/kong/karna/global-rules.d (2 file(s), 1 controls + 3 detection, worker 0)
From Redis — storage layout
One Redis hash, karna:global_rules, written atomically by a single HSET:
| Field | Content |
|---|---|
json | A JSON array of rules in the Karna rule format — the same objects you would put in rules_request. Author order is preserved. |
seclang | Raw SecLang text, the same dialect as custom_secrules (deny and block both map to a 403 fixed_response; an explicit status:NNN is honoured). Rules are ordered by id. |
version | Monotonically increasing integer, bumped on every publish. Workers poll this field only and re-fetch the pack when it changes. |
sig | Hex HMAC-SHA256 over version + "\n" + sha256(json) + "\n" + sha256(seclang). |
JSON rules are evaluated first (in author order), then SecLang rules (sorted by id). Rules default to "log": true.
Publishing to Redis
Use scripts/karna-rules.py — it validates, previews, signs, and publishes the pack in one atomic write. Set up once:
# 1. generate the signing key openssl rand -hex 32 # 2. on every Kong node (see Configuration → Environment): # KARNA_REDIS_URL=redis://:pass@redis:6379/0 # KARNA_GLOBAL_RULES_HMAC_KEY=<the key> # 3. restart Kong once to pick up the env
# publish (the key comes from the env, never from argv) export KARNA_GLOBAL_RULES_HMAC_KEY=... ./scripts/karna-rules.py --type global-rules \ --redis redis://localhost:6379/0 \ --json global_rules.json --seclang global_rules.conf # dry-run first, if you like: add --dry-run # inspect what is live: --show (version, counts, signature status) # recover your authoring files from Redis: --pull
Publishing only --json preserves the already-published SecLang payload (and vice versa) — pushing one format never wipes the other. Deleting the hash (DEL karna:global_rules) is the explicit off switch: workers clear the pack at the next poll.
Trust model
Redis is a transport, not a trust anchor. With KARNA_GLOBAL_RULES_HMAC_KEY set, a pack with a missing or invalid signature is rejected and the last known good pack stays active — write access to Redis alone is not enough to inject or weaken rules; the signing key is. Workers also refuse non-increasing versions, so an old signed pack cannot be replayed while they run (after a full Kong restart the counter resets; rotate the key if that residual matters to you). Without the key, packs are accepted unsigned and a loud warning is logged at startup — fine for a lab, not for production.
Two sources, one pack
When both are configured, the two packs are merged into the one the request path reads:
- Order — disk before Redis. Inside a source, author order (files in filename order, rules in array order).
- Duplicate ids — first wins. A rule id that already came from disk is discarded when it arrives again from Redis, with a warning naming both sides. So the hot channel can add rules but cannot silently replace a rule you deployed on disk with a weaker one carrying the same id. The same rule applies between two files in a directory: the earlier filename wins.
Blocking rules and exclusions in the same pack
A global pack can carry both, and their order in the file does not matter. At load time each pack is split in two lists:
- controls — rules with a
rule_controland noaction: CRS-style exclusions. They run first, on the multi-match path, so every matching exclusion contributes itsctl:*side effects, and they land in time to affect the global detection rules, the local rules and the CRS. - detection — everything else, on the standard first-terminal-wins path with the full action dispatch (
fixed_response,fix_matched_parts,rate_limit,set_variable, …). A rule that carries arule_controlnext to a real action stays here, so it keeps that dispatch.
Without the split, a blocking rule matching early in the list would end the pass and quietly swallow an exclusion declared after it — visible only in detection mode, where the request continues to the CRS with fewer exclusions applied than the file says.
Evaluation order and failure posture
Global rules run after rule controls and CRS exclusion plugins (so per-service ctl:* exclusions and overrides can tame a global rule on one service without touching the pack) and before local rules and the CRS. Blocking behaviour follows each service's engine_blocking_mode — on a detection-only service a global rule logs instead of blocking. There is deliberately no per-service opt-out; tag your global rules (e.g. "tags": ["global-pack"]) so overrides can select them where exceptions are needed.
Supported phases match local rules: access, header_filter and mcp_event. @pmFromFile is not supported on this channel — a global pack ships no data files and must not reach into the engine's shared data-file store.
Failures are fail-open, granular and loud, because a broken global pack must never take a node down:
- A rule the engine could not evaluate — missing
id/phase/conditions, an unsupported@pmFromFile, a phase that is never evaluated — is dropped with the id and the reason in the log. The rest of the pack stays active. Such a rule is not kept: a condition that can never match would look like coverage without being any. - A file that cannot be read or does not decode is skipped with an error; the other files in the directory still load. A single-file pack that does not decode means no rules from disk, and the node keeps serving with the CRS, the local rules and any Redis pack.
- On a Redis outage or a rejected pack, workers keep the last known good Redis pack and log an error per failed poll. A worker that starts while Redis is down runs without the Redis pack until the first successful poll; the disk pack, the CRS and the local rules are unaffected.
TLS fingerprint: karna-tls-v1
Every TLS request carries a client fingerprint in the audit log (tls.fingerprint, both formats) and in the rule variables tls.fingerprint / tls.fingerprint_version. It is a JA3-like fingerprint reduced to the cipher suites the client offered: the one piece of ClientHello material nginx exposes in the HTTP phases for new and resumed sessions alike ($ssl_ciphers). It is not JA3 and not JA4; do not compare it with databases of either. Nothing negotiated enters it, so it is a property of the client stack, not of the server configuration.
The version string karna-tls-v1 names every detail below. Any change to any of them is a new version; v1 never changes.
| Step | Rule |
|---|---|
| Input | $ssl_ciphers as nginx renders it: OpenSSL names for known suites, 0xNNNN (lowercase hex) for the rest, colon-separated, in the client's order. |
| GREASE | Tokens 0x0a0a, 0x1a1a, … 0xfafa (RFC 8701: both bytes equal, low nibble a) are removed, matched case-insensitively. Every other 0xNNNN token is kept: a suite the server does not know is information, not noise. |
| SCSV | TLS_EMPTY_RENEGOTIATION_INFO_SCSV (0x00ff) is kept when present, as JA3 does. |
| Order | Preserved. Two stacks offering the same set in a different order are different clients. |
| Curves | Not included, by design: $ssl_curves is empty on a resumed TLS 1.2 session, so the same client would carry two identities. They stay available as tls.client_curves. |
| Canonical string | karna-tls-v1|<c1>,<c2>,…, or karna-tls-v1|- when nothing is left. Never logged. |
| Hash | SHA-256 of the canonical string, lowercase hex, 64 characters. |
| When | Only when tls.capture_status is complete. Absent on partial captures and on plain HTTP. Computed once per distinct list per worker. |
# $ssl_ciphers as received (GREASE first, then Chrome's 15 suites) 0x9a9a:TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-RSA-AES128-SHA:ECDHE-RSA-AES256-SHA:AES128-GCM-SHA256:AES256-GCM-SHA384:AES128-SHA:AES256-SHA # canonical string (GREASE dropped, order kept) karna-tls-v1|TLS_AES_128_GCM_SHA256,TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,ECDHE-ECDSA-AES128-GCM-SHA256,ECDHE-RSA-AES128-GCM-SHA256,ECDHE-ECDSA-AES256-GCM-SHA384,ECDHE-RSA-AES256-GCM-SHA384,ECDHE-ECDSA-CHACHA20-POLY1305,ECDHE-RSA-CHACHA20-POLY1305,ECDHE-RSA-AES128-SHA,ECDHE-RSA-AES256-SHA,AES128-GCM-SHA256,AES256-GCM-SHA384,AES128-SHA,AES256-SHA # tls.fingerprint = sha256(canonical), lowercase hex 32aad363139a0f2f7d9289b1a380ca95c8d099d8b3da6ceb747ebdb30557d489
karna-tls-v2 will add the client's signature algorithms once Kong ships an nginx with $ssl_sigalgs (1.31.2+). Chrome and Firefox change their cipher list rarely, but they do; treat allow-lists as living data. The fingerprint says nothing about what the request contains: use it to add context to a decision, not as the only condition that blocks.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",
"log": true,
"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: rate limit, then ban
A rate limit answers “how much traffic do I tolerate”. It does not answer “what do I do with a client that keeps hitting the limit”. Stretching window_seconds to express the second one also caps a legitimate client for the whole stretch: the rate you tolerate and how long you punish exceeding it are separate numbers, and they want separate counters.
One rule expresses both numbers: 5 requests per 10 seconds; after 20 refusals, banned for 2 hours.
{
"id": "rl-login",
"phase": "access",
"conditions": [
{ "op": "beginsWith", "value": "/login", "variables": ["request.raw_path"] }
],
"action": {
"rate_limit": {
"key": "%{remote_addr}",
"limit": 5,
"window_seconds": 10,
"ban": {
"key": "%{remote_addr}",
"after_exceedances": 20,
"duration_seconds": 7200
}
}
},
"log": true,
"message": "login rate limit with temporary ban",
"tags": ["ratelimit"]
}Requests over the ordinary limit receive 429. The twentieth refusal creates the ban atomically and receives 403; later requests from the same identity receive 403 until the two hours expire. The counter is reset when the ban starts, so expiry opens a fresh rate-limit window.
With log: true, the audit log records the refusals and every enforced ban. Admitted requests stay quiet unless the rate-limit action sets log_all_matches: true.
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"]
}Example: TLS fingerprint & connection
Five patterns over tls.* and connection.id. They are documentation, not defaults: none ships enabled, and the fingerprints below are placeholders to replace with values read from your own audit log. Rule 5 shows the guard every fingerprint rule should carry.
{ "id": "tls-deny-fp", "phase": "access",
"conditions": [ { "op": "within", "transform": [],
"value": "<fp-of-scanner-A> <fp-of-scanner-B>",
"variables": ["tls.fingerprint"] } ],
"action": { "fixed_response": { "status_code": 403, "body": "Forbidden\r\n" } },
"message": "TLS fingerprint on deny list", "tags": ["tls-fingerprint"] }{ "id": "tls-sni-host", "phase": "access", "log": true,
"conditions": [
{ "op": "eq", "value": "true", "transform": [], "variables": ["tls.enabled"] },
{ "op": "rx", "value": "^(.+)$", "transform": ["lowercase"], "variables": ["tls.sni"] },
{ "op": "eq", "value": "%{group:1}", "negated": true, "transform": ["lowercase"], "variables": ["request.host"] }
],
"action": { "log_only": true },
"message": "SNI does not match Host", "tags": ["tls-sni"] }request.host may carry a port; strip it with a first rx capture if your clients send one. Start with log_only: CDNs and some corporate proxies legitimately differ here.{ "id": "tls-ua-mismatch", "phase": "access", "log": true,
"conditions": [
{ "op": "eq", "value": "complete", "transform": [], "variables": ["tls.capture_status"] },
{ "op": "rx", "value": "Chrome/\\d+", "transform": [], "variables": ["request.header.value:user-agent"] },
{ "op": "within", "negated": true, "transform": [],
"value": "<fp-chrome-desktop> <fp-chrome-android> <fp-chrome-ios>", "variables": ["tls.fingerprint"] }
],
"action": { "set_variable": { "type": "shared", "name": "karna_tls_ua_mismatch", "value": "%{tls.fingerprint}" } },
"message": "Chrome UA with a non-Chrome TLS stack", "tags": ["tls-fingerprint", "bot"] }set_variable into kong.ctx.shared) rather than blocking on it alone.{ "id": "tls-legacy-version", "phase": "access",
"conditions": [ { "op": "within", "value": "TLSv1 TLSv1.1", "transform": [], "variables": ["tls.protocol"] } ],
"action": { "fixed_response": { "status_code": 426, "body": "Upgrade Required\r\n" } },
"message": "Legacy TLS version", "tags": ["tls-policy"] }{ "op": "eq", "value": "http/1.1", "variables": ["tls.alpn"] } to single out clients that refuse HTTP/2 on an API where every real client negotiates h2. Kong's ssl_protocols already rejects TLS 1.0/1.1 by default; rule 4 is for deployments that had to re-enable them.{ "id": "tls-fp-guarded", "phase": "access",
"conditions": [
{ "op": "eq", "value": "complete", "transform": [], "variables": ["tls.capture_status"] },
{ "op": "within", "negated": true, "transform": [],
"value": "<fp-allowed-app-1> <fp-allowed-app-2>", "variables": ["tls.fingerprint"] }
],
"action": { "rate_limit": { "key": "%{connection.id}", "limit": 30, "window_seconds": 60 } },
"message": "Unknown TLS client: throttled per connection", "tags": ["tls-fingerprint"] }partial capture tls.fingerprint is absent, and a negated within on an absent variable does not fire (see Negation), so rule 5 is safe even without the first condition. The explicit tls.capture_status check documents the intent and protects a rule whose second condition is later changed to a positive match. On plain HTTP every tls.* except tls.enabled and tls.capture_status is absent, so none of these rules fires there. %{connection.id} as a rate_limit key throttles the connection instead of the address, which matters behind NAT and CGNAT.