Docs / Writing rules
Reference

Writing rules

How to author detection, sanitization, rate-limiting, and exception rules. Rules are JSON objects you put in rules_request / rules_response, or SecLang strings in custom_secrules. This page is the full technical reference.

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).

a complete rulejson
{
  "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"]
}
FieldTypeDescription
idstringUnique identifier. Used in logs and by rule controls / overrides.
phasestringaccess, header_filter, body_filter, or mcp_event. See Phases.
conditionsarrayOne or more condition objects, AND-ed together. See Conditions.
actionobjectWhat to do when the rule fires. See Actions.
messagestringHuman-readable description, written to the audit log.
tagsarrayLabels used by overrides and rule controls (e.g. attack-sqli).
logbooleanWhether a match is written to the audit log.
rule_controlarrayOptional 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.

FieldTypeDescription
variablesarrayWhat to inspect (e.g. request.arg.value, request.header.value:host). See Variables.
opstringThe operator (e.g. rx, eq, libinjection_sqli). See Operators.
valuestringThe operator argument (pattern, number, token list…). Use "" for operators that take none (isSet, libinjection_*).
transformarrayTransformations applied to each value before the operator runs, in order. Omit or [] for none. See Transformations.
negatedbooleanInvert the match. See Negation.
multi_matchbooleanWhen true, the operator is also tested against each intermediate transform result, not only the final one.
ChainingMultiple conditions form a chain (logical AND). A later condition can inspect an earlier one's match via matched.value and the regex capture groups group:0, group:1, …

Phases

PhaseRunsCan inspect
accessBefore the request reaches your appMethod, path, query, headers, cookies, and the parsed body. Can block, sanitize, or modify the request. Most rules live here.
header_filterAfter the app responds, before headers go to the clientThe request plus the upstream response status and headers.
body_filterWhile streaming the response bodyResponse body chunks. Used internally for MCP SSE reassembly.
mcp_eventPer 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.

VariableResolves to
request.arg.valueValues from query string + parsed body (ModSec ARGS). Canonical for "any argument value".
request.arg.nameArgument names from query string + parsed body.
request.query.value / .nameValues / 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.bodyRaw body (for urlencoded / text bodies).
request.header.value / .nameRequest header values / names. Target one with :<header>.
request.header_no_fp.valueHeader values excluding the most FP-prone ones (User-Agent, Referer, …).
request.cookie.value / .nameCookie values / names.
request.raw_pathURL 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.pathURL 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_queryVerbatim path plus the query string (CRS REQUEST_URI).
request.basenameLast segment of the path (e.g. index.php).
request.methodHTTP method.
request.remote_addrClient 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_addrClient 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.fileUploaded file names / multipart param names.
request.body.multipart.filenameMultipart filenames.
request.body.multipart.header.valueMultipart part header values.
request.header.referer.{path,query,scheme,host}Components of the Referer URL.
response.set_cookie.value / .nameValues / names from Set-Cookie (response phases).
response.header.name:<name>A specific response header (response phases).
matched.valueThe 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.idPseudonymous 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.
Targeting a named argTo match a specific argument by name, prefer 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.

opNegatableDescription
rxyesRegex match against the value. With the RE2 engine this is linear-time and ReDoS-safe.
eqyesExact equality (string or number).
ge / gt / lt / leyesNumeric ordering. Non-numeric input fails closed.
beginsWith / endsWithyesString prefix / suffix match.
containsyesLiteral substring (case-sensitive).
withinyesValue is one of the whitespace-separated tokens in value.
isSetyesWhether the variable resolves to anything. With negated: true, fires on absence.
pm / pmFromFileyesPhrase match: any token in value (or a file) appears in the variable.
ipMatchyesIPv4/IPv6/CIDR match against a comma- or space-separated list.
libinjection_sqli / libinjection_xssyesSQLi / XSS detection via libinjection.
validateUrlEncodingyesMatches on malformed %XX sequences.
validateUtf8EncodingyesMatches when input is not valid UTF-8.
validateByteRangeyesMatches when any byte falls outside the ranges in value (e.g. "32-126,9,10,13").
unconditionalMatchn/aAlways true. Used as the predicate of chains gated by other conditions' side-effects.
mcp_method_inn/aThe JSON-RPC method is in value (MCP).
mcp_jsonrpc_validn/aThe body is a valid JSON-RPC 2.0 envelope (MCP).
redis_sismemberyesThe value is a member of the Redis SET named by the redis.<key> variable. Negated = not a member (allowlist). Needs redis_inspect_enabled.
redis_hexistsyesThe Redis HASH named by the redis.<key> variable has a field equal to value. Negated = field absent. Needs redis_inspect_enabled.
Not implementedSome CRS operators have no equivalent: @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:

negated conditionjson
{
  "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.

TransformEffect
lowercaseLowercase ASCII.
urlDecodeUniURL-decode, including %uHHHH sequences (alias: urlDecode).
hexSequenceDecodeDecode %HH sequences (one pass).
htmlEntityDecodeDecode HTML entities (&#xHH;, &quot;, …).
jsDecodeDecode JavaScript \uHHHH / fullwidth escapes.
cssDecodeDecode CSS escapes.
escapeSeqDecodeDecode ANSI-C escapes (\n, \xHH, …).
base64DecodeBase64-decode (alias: base64decode).
removeNullsStrip NUL bytes.
removeWhitespaceStrip all whitespace.
compressWhitespaceCollapse runs of whitespace to a single space.
replaceCommentsReplace /* */ and // with a space.
removeCommentsCharStrip comment characters (/*, */, //, #).
normalisePathNormalize path slashes and . / .. segments (alias: normalizePath).
normalizePathWinLike above, treating \ as a separator.
cmdLineCommand-line normalization (strip quoting, collapse spaces, lowercase).
utf8toUnicodeConvert UTF-8 to %uHHHH form.
lengthReplace the value with its length (a number).
sha1SHA-1 digest (raw bytes).
hexEncodeHex-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.

ActionWhat it does
fixed_responseTerminate with a fixed status / headers / body (the standard block).
fix_matched_partsSanitize the matched targets in place and let the request through. Takes precedence over fixed_response if both are present.
rate_limitRedis fixed-window counter; returns 429 when the limit is exceeded.
redis_incr_keyIncrement a Redis key with a TTL (no terminal effect).
redis_set / redis_sadd / redis_delWrite 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_variableWrite a value into kong.ctx.shared or kong.ctx.plugin for sibling plugins / later phases.
set_log_fieldsAdd custom fields to the audit log entry.
log_onlytrue — 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

actionjson
"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".

actionjson
"action": {
  "fix_matched_parts": { "remove_chars_pattern": "[\"';&|`]*" }
}

rate_limit

FieldDefaultPurpose
key%{remote_addr}Counter cardinality. Macros: %{remote_addr}, %{request.method}, %{request.host}, %{request.scheme}, %{request.path}.
limit0Max requests in the window.
window_seconds60Window length / counter TTL.
response429Optional 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.

ActionFieldsRedis command
redis_setkey, value (default "1"), expireSET key value, plus EX expire when expire is set.
redis_saddkey, member, expireSADD key member, plus EXPIRE key expire when expire is set.
redis_delkeyDEL key (manual unban / clear).
ban the client IP for 10 minutesjson
"action": {
  "redis_set": {
    "key": "ban:%{remote_addr}",
    "value": "1",
    "expire": 600
  }
}

set_variable

type is required (sharedkong.ctx.shared, pluginkong.ctx.plugin). String values support %{var} macros resolved against the request.

actionjson
"action": {
  "set_variable": {
    "name": "skip_js_challenge",
    "value": true,
    "type": "shared"
  }
}

set_log_fields

actionjson
"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.

actionjson
"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.

Why opt-inCollecting every non-terminal match would pull in the CRS 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 writtenlog: 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.

OperatorRedis commandMeaning
isSetEXISTSKey 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 / beginsWithGETRead the value, then compare with the operator. An absent key never matches.
gt / lt / ge / leGETRead the value and compare numerically (counters / scores stored as strings).
redis_sismemberSISMEMBERvalue is a member of the set. Negatable.
redis_hexistsHEXISTSThe 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).

block IPs present in a banlist keyjson
{
  "op": "isSet",
  "value": "",
  "variables": ["redis.ban:%{remote_addr}"]
}
reject a token found in a revoked-tokens setjson
{
  "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.

ControlEffect
remove_ruleSkip a rule entirely ({ "rule_id": "1234" }); a hyphen range works too ("920100-920199"). ← ctl:ruleRemoveById
remove_rules_by_tagSkip every rule carrying a tag ({ "tag": "attack-sqli" }). ← ctl:ruleRemoveByTag
remove_target_from_rule_by_idDrop one variable target from one rule ({ "rule_id": "942100", "target": "request.arg.value:pwd" }). ← ctl:ruleRemoveTargetById
remove_target_rule_by_tagDrop 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_offtrue — skip all remaining rule evaluation for this request. ← ctl:ruleEngine=Off
detection_onlytrue — 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_offtrue — 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_conditionsDrop a variable from every condition of a rule.
remove_variable_rxDrop variables whose key matches a regex (great for libinjection header FPs).
remove_target_rule_by_patternDrop matched-key targets from a rule by Lua pattern.
remove_target_tag_by_patternSame, for all rules with a tag.
change_rule_actionReplace a rule's action.
change_condition_tfuncReplace a condition's transform chain (condition_number is 1-based).
change_condition_valueReplace a condition's operator value.
replace_condition / remove_condition / add_conditionReplace, delete, or append a condition.
Not reachable from a rule controlThe always-on validation gates (method, path, denied headers, content-type / charset, body parser, argument count) run before any rule has been evaluated, so no control — not 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, …).
carve out libinjection header false positivesjson
"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.

SelectorBehaviour
idsOR-match against rule.id.
id_rangesNumeric range, inclusive (e.g. "941000-941999").
tagsAny tag in the list intersects rule.tags.
except_ids / except_tagsExclude a rule even on a positive match.
anytrue matches every rule (use with except_*).
switch the XSS family to sanitizejson
{
  "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.

configjson
"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:

SourceEnabled byShapeUpdate
DiskKARNA_GLOBAL_RULES_PATHA .json file, or a directory of *.json fileskong reload / restart
RedisKARNA_REDIS_URLA hash with a JSON and a SecLang payload, HMAC-signedHot, 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 runbash
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.json

The 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.

env vars and nginxnginx wipes the environment of its worker processes, so 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.

startup logtext
[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:

FieldContent
jsonA JSON array of rules in the Karna rule format — the same objects you would put in rules_request. Author order is preserved.
seclangRaw 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.
versionMonotonically increasing integer, bumped on every publish. Workers poll this field only and re-fetch the pack when it changes.
sigHex 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:

one-time setupbash
# 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 / inspect / recoverbash
# 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_control and no action: CRS-style exclusions. They run first, on the multi-match path, so every matching exclusion contributes its ctl:* 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 a rule_control next 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.

StepRule
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.
GREASETokens 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.
SCSVTLS_EMPTY_RENEGOTIATION_INFO_SCSV (0x00ff) is kept when present, as JA3 does.
OrderPreserved. Two stacks offering the same set in a different order are different clients.
CurvesNot 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 stringkarna-tls-v1|<c1>,<c2>,…, or karna-tls-v1|- when nothing is left. Never logged.
HashSHA-256 of the canonical string, lowercase hex, 64 characters.
WhenOnly when tls.capture_status is complete. Absent on partial captures and on plain HTTP. Computed once per distinct list per worker.
worked example: Chrome on Kong 3.9 (OpenSSL 3.2)text
# $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
LimitsCipher order alone separates browser families from curl, Python, Go and Java stacks well, but it has less entropy than JA3/JA4: two clients with the same cipher list but different extensions collide. A future 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.

rules_request entryjson
{
  "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.

rules_request entryjson
{
  "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.

rules_request entryjson
{
  "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.

1 — count failed loginsjson
{
  "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
}
2 — block over thresholdjson
{
  "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.

1 — ban on SQLi (any node)json
{
  "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"]
}
2 — block banned IPs (every node)json
{
  "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.

rules_request entryjson
{
  "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.

1. deny a known-bad fingerprint (a scanner's cipher list)json
{ "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"] }
2. SNI and Host disagree (domain fronting, misrouted scanners)json
{ "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"] }
Portsrequest.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.
3. User-Agent claims a browser, TLS stack does notjson
{ "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"] }
Feed, don't blockHeadless browsers and HTTP libraries impersonating Chrome are the target; the false positives are Chrome behind a TLS-inspecting proxy, whose fingerprint is the proxy's. Hand the signal to a bot-management or scoring step (set_variable into kong.ctx.shared) rather than blocking on it alone.
4. legacy protocol or ALPN policyjson
{ "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"] }
ALPN variantSwap the condition for { "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.
5. the guard: only act on a complete capturejson
{ "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"] }
Why the guardOn a 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.