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 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.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 | 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. |
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 macro (%{connection.id}), e.g. as a rate_limit key. |
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).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 does not validate variable names), 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). |
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
| 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}" } ] }
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. |
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. 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 one variable target from one rule ({ "rule_id": "942100", "target": "request.arg.value:pwd" }). ← ctl:ruleRemoveTargetById |
remove_target_rule_by_tag | Drop one variable target from every rule carrying a tag ({ "tag": "attack-sqli", "name": "request.header.value:user-agent" }). 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 |
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 |
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 body_access_off — can switch them off. Loosen those through the plugin schema instead (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.
"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",
"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"]
}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.