Skip to content

Matching ​

A rule fires when all of its match conditions hold, unless all of its not conditions hold too. A rule without match always fires.

Conditions ​

conditionprotocolsholds when
containsallthe payload contains this exact text
regexallthe payload matches this regex
methodhttpthe method matches (a regex over the whole method)
pathhttpthe path and query match
headershttpeach named header is present and matches
headers_opthttpeach named header is missing, or matches
bodyhttpthe body alone matches
statushttpthe response has this status code
qnamednsthe query name (lower-cased) matches
qtypednsthe query type is this, e.g. TXT
larger_thanallthe message is bigger than this size
distinctalla pattern has produced more than over different values on this connection

What "the payload" is depends on the protocol:

protocolpayload for contains and regex
tcpthe whole stream of that direction, matched as it flows by
httpthe whole message: request or status line, headers and body
ftpeach control line
udp, dnseach datagram

On tcp and http, patterns are found wherever they land, even split across packets or megabytes apart. See Streaming and performance.

contains ​

Plain text, matched anywhere in the payload, with no pattern syntax to get wrong:

yaml
- name: reverse shells
  match: { contains: '/bin/sh' }
  action: block

It is the cheapest condition there is — no regex engine, just a substring search — so where plain text will do, prefer it to regex. It takes no {flag}, since that is a pattern; use regex for anything that is not a literal.

Regex syntax ​

Patterns use Rust's regex syntax. The things to know:

  • Matching is case-sensitive. Add (?i) at the start for case-insensitive.
  • A pattern matches anywhere in the value; anchor with ^ and $ when you mean the whole value.
  • There are no backreferences and no lookaround (\1, (?=...), (?!...)). That is what makes streaming matching in constant memory possible. For "does not match", use not.
  • In YAML, single-quoted strings keep backslashes as they are: '\.\./'. In double quotes you have to double them: "\\.\\./".
  • \b, character classes and Unicode all work.

The game's flags ​

{flag} in a pattern stands for the game's flag format, which a game mode knows, or which the config sets as flag. Rules then say what they mean, and work unchanged from one game to the next:

yaml
- name: flag leak outside the notes route
  dir: out
  match: { regex: '{flag}' }
  not: { path: '^/notes/' }

It can go anywhere a regex does: regex, path, body, header values, distinct patterns and the patterns of replace. contains takes plain text, so it cannot use it. A rules file that uses {flag} without a flag format set does not load, and says why.

Used as a replace pattern for encryption, {flag} encrypts the part of the flag that varies, with the characters the game uses, and stores it behind an ENCRYPTED_FLAG_ marker, so what the service holds says what it is and cannot be handed in as a flag.

HTTP conditions ​

path ​

path sees the path and the query string together, as sent: /search?q=test. It is a regex, so anchor it:

yaml
- match: { path: '^/admin' }             # also matches /administrator
- match: { path: '^/admin(/|\?|$)' }     # just /admin and below
- match: { path: '[?&]debug=' }          # a query parameter, wherever it appears

For responses, path is the path of the request being answered, so dir: out rules can be scoped to a route.

headers and headers_opt ​

Header names are case-insensitive; values are regexes. If a header is sent more than once, it is enough for one of its values to match.

headers requires the header to be present. headers_opt checks a header only if it is sent: it holds when the header is missing, or one of its values matches. match has no "or", so this is the way to say "missing, or matching":

yaml
- name: odd content types on the api
  match: { path: '^/api/' }
  not: { headers_opt: { content-type: '^application/json' } }
  set_status: 415

This catches a request with another content type and leaves alone one without any (a GET, say). With headers in its place, it would block every request without the header too.

WebSocket messages have no headers, so a headers_opt condition always holds for them.

body ​

body matches the body alone, without the head. It needs the whole message at once, so the body is buffered up to limits.buffered_body (8 MiB). Scope body rules with a path so that other routes still stream. For patterns that may appear anywhere, contains and regex stream instead.

not ​

not takes the same conditions as match, and the rule is skipped when all of them hold. It is how a rule allows only what it names, such as the routes the service really has:

yaml
- name: unknown routes
  match: { method: 'GET|POST' }
  not: { path: '^/(|login|register|notes(/[0-9a-f-]+)?)(\?.*)?$' }
  set_status: 404

This answers every request for a path that is not on the list with a 404, which shuts out endpoints the service hides from its own frontend.

With several conditions, not is "not all of these": the rule is skipped only when every one of them holds, and fires as soon as one does not. not: { path: '^/a', method: GET } skips GET /a and still fires for POST /a. There is no way to say "skip if any of these holds" within one rule.

not can only use conditions known from the head: method, path, headers, headers_opt, status, qname and qtype. contains, regex, body, larger_than and distinct are rejected, because they are only known once the payload has arrived.

larger_than ​

A size in bytes, with optional K, M or G (powers of 1024): 4096, 64K, 1M.

protocolwhat is counted
httpthe body
tcpeverything one side has sent over the connection
udp, dnseach datagram
ftpeach line
websocketeach message

On http, if the length is known from the head (Content-Length, or a message without a body), the rule is decided before the body is read, and an oversized upload is refused without reading it. Otherwise the body is counted as it streams by.

yaml
- name: request too large
  match: { larger_than: 1M }
  set_status: 413

Put size limits first

A request blocked from its head alone is refused without its body being read. Putting a size limit before any rule that would need the body spares the proxy from reading an oversized upload at all.

distinct ​

distinct counts how many different values a pattern produces over one connection, and holds once that count passes over. It tells a client walking through every record apart from one reading its own:

yaml
- name: mass flag extraction
  dir: out
  match: { distinct: { pattern: '{flag}', over: 3 } }
  set_status: 403
  • The same value seen twice counts once.
  • The count carries across every message on the connection, so once a client is over the limit, the rest of its connection is blocked too.
  • A new connection starts from zero.
  • At most 4096 values are remembered per rule and connection.

A brake, not a wall

distinct only sees what one connection carries. It catches exploit scripts that reuse a connection, such as those built on requests.Session, httpx.Client or aiohttp, and anything that pulls many records in one response. A script that opens a new connection per request, as plain requests.get does, gets around it.

Set over well above what a legitimate client reads in one connection, and run the rule as action: log for a few ticks to check that it does not fire on the checker.