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
| condition | protocols | holds when |
|---|---|---|
contains | all | the payload contains this exact text |
regex | all | the payload matches this regex |
method | http | the method matches (a regex over the whole method) |
path | http | the path and query match |
headers | http | each named header is present and matches |
headers_opt | http | each named header is missing, or matches |
body | http | the body alone matches |
status | http | the response has this status code |
qname | dns | the query name (lower-cased) matches |
qtype | dns | the query type is this, e.g. TXT |
larger_than | all | the message is bigger than this size |
distinct | all | a pattern has produced more than over different values on this connection |
What "the payload" is depends on the protocol:
| protocol | payload for contains and regex |
|---|---|
| tcp | the whole stream of that direction, matched as it flows by |
| http | the whole message: request or status line, headers and body |
| ftp | each control line |
| udp, dns | each 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:
- name: reverse shells
match: { contains: '/bin/sh' }
action: blockIt 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", usenot. - 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:
- 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:
- match: { path: '^/admin' } # also matches /administrator
- match: { path: '^/admin(/|\?|$)' } # just /admin and below
- match: { path: '[?&]debug=' } # a query parameter, wherever it appearsFor 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":
- name: odd content types on the api
match: { path: '^/api/' }
not: { headers_opt: { content-type: '^application/json' } }
set_status: 415This 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:
- name: unknown routes
match: { method: 'GET|POST' }
not: { path: '^/(|login|register|notes(/[0-9a-f-]+)?)(\?.*)?$' }
set_status: 404This 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.
| protocol | what is counted |
|---|---|
| http | the body |
| tcp | everything one side has sent over the connection |
| udp, dns | each datagram |
| ftp | each line |
| websocket | each 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.
- name: request too large
match: { larger_than: 1M }
set_status: 413Put 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:
- 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.