Concepts
Firewall rules
The expression language, every field it knows, and the phase a rule has to be in to fire.
A rule is four things: a condition written in the expression language, an action, the phase it runs in, and a position within that phase. None of it is code. There is no path where your rule becomes a program, which is what lets the same rule be evaluated by the edge, rendered by the dashboard and diffed by gdn plan without three implementations drifting apart. The evaluator reads a request through one interface with a single method — “what is the value of this field” — so the thing that answers can be a live request or a map in a test, and neither is a model of the other.
The shape of an expression
ip.geoip.country in {"ru" "cn"} and not ip.src in $office_ips
http.request.uri.path matches "^/wp-(admin|login)" and guardyn.bot_score lt 30
http.host eq "api.example.com" and http.request.method in {"POST" "PUT"}
# comments run to the end of the line
exists http.referer and http.request.headers["x-api-key"] ne "rotated-key"Tokens are separated by whitespace. That is not a stylistic preference: a bare word runs until whitespace, a bracket or a quote, so http.host=="a" is read as a field called http.host== and rejected as an unknown field. Put spaces around every operator.
| Syntax | Means |
|---|---|
| and, && | Both. Binds tighter than or. |
| or, || | Either. |
| not, ! | Negation. Binds tightest of the three. |
| ( … ) | Grouping, to override that precedence. |
| always | Matches every request. What a maintenance block is, so it is spellable. |
| never | Matches nothing. |
| exists f | The field has a value. Brackets optional: exists(http.referer) is the same. |
| {"a" "b"} | An inline set. All strings, all numbers, or all addresses — never mixed. |
| $name | A named list, managed outside the rule. |
| # … | A comment, to the end of the line. |
Strings take either quote and a deliberately small set of escapes: \", \', \\, \n, \t, \r. Anything else is an error rather than a silently literal backslash, because \d inside a pattern is extremely common and must be written \\d. Getting that wrong the other way — treating \d as a literal d — would break every pattern that used it, quietly.
Fields
Thirty-one named fields, plus two indexed forms. The list is served as JSON by GET /v1/reference, from the same build that parses expressions, so a reference cannot describe a field the parser rejects. Field names match Cloudflare's where an equivalent exists, so a migrated ruleset reads the same.
Connection
| Field | Type | What it is |
|---|---|---|
| ip.src | ip address | The visitor's address. Today that is the peer address of the connection: no trusted-proxy unwrapping is implemented, so behind another proxy this is the proxy. |
| ip.geoip.country | string | ISO 3166-1 alpha-2, from the GeoIP database. |
| ip.geoip.continent | string | Two-letter continent code. |
| ip.geoip.asnum | integer | Autonomous system number. |
| ip.geoip.as_organization | string | The name that owns the AS. |
| ip.reputation.is_threat | boolean | True for addresses in our own list of known-bad ranges. |
TLS
| Field | Type | What it is |
|---|---|---|
| ssl | boolean | Whether the request arrived over TLS at all. |
| tls.version | string | Negotiated version, e.g. TLSv1.3. |
| tls.cipher | string | Negotiated cipher suite. |
| tls.ja4 | string | JA4 fingerprint of the client hello — the most useful bot signal that survives a User-Agent change. |
| tls.client_cert.verified | boolean | A client certificate was presented and verified. |
| tls.client_cert.subject | string | That certificate's subject. |
The request
| Field | Type | What it is |
|---|---|---|
| http.request.version | string | Rendered as HTTP/1.1, HTTP/2.0. |
| http.request.method | string | As sent. Comparisons fold case anyway. |
| http.host | string | Host header, lowercased and port-stripped; the URI authority if there is no header. |
| http.request.uri.path | string | Percent-decoded exactly once. No query string. |
| http.request.uri.query | string | Raw, without the leading ?. An empty string when there is no query — present, not absent. |
| http.request.uri | string | Path and query together, the form a log line shows. Not decoded. |
| http.request.uri.path.extension | string | Lowercased, no dot. Absent when empty or longer than 16 characters. |
| http.request.headers.names | set of strings | Every header name present, for "does this request carry X at all". |
| http.user_agent | string | Shorthand for the header. |
| http.referer | string | Shorthand for the header. |
| http.request.body.size | integer | From Content-Length. Zero when there is none, not absent. |
| http.request.content_type | string | Content-Type, lowercased, parameters stripped. |
Two fields are parameterised by a name, so there is no finite list of them and they are not in the catalogue — an editor offers them as templates:
| Form | Type | What it is |
|---|---|---|
| http.request.headers["x-api-key"] | string | One header. The name is folded to lowercase, because HTTP header names are case-insensitive. Repeated headers are joined with ", " so a rule sees what the origin would. |
| http.cookie["SessionID"] | string | One cookie. The name is NOT folded: cookie names are case-sensitive per RFC 6265. |
Signals we derive
| Field | Type | What it is |
|---|---|---|
| guardyn.bot_score | integer | 1–99. Low is more likely automated. Absent — not zero — when scoring did not run. |
| guardyn.verified_bot | string | A crawler we recognise and have verified: googlebot, bingbot. Absent otherwise. |
| guardyn.rate | integer | Requests from this address in the current sliding window. |
| guardyn.internal | boolean | True for a cache-warming or health-check subrequest we issued ourselves. Exclude these, or a rate limit eventually blocks our own health checks. |
Known only after the origin answers
| Field | Type | What it is |
|---|---|---|
| guardyn.cache_status | string | One of HIT, MISS, MISS-STORED, STALE, STALE-ERROR, REVALIDATED, BYPASS, DYNAMIC, COLLAPSED. |
| guardyn.origin.name | string | The origin the request was routed to. |
| guardyn.origin.status | integer | The status the origin returned. |
These three exist only in the response_transform phase. A rule in any earlier phase that reads one is refused when the snapshot is built, naming the rule and the phase — rather than answered with an empty value on every request, which is the same rule quietly never firing.
Operators
| Operator | Applies to | Behaviour |
|---|---|---|
| eq, == | any type | Equal. Text compares case-insensitively. Against a CIDR it is a containment test, because writing ip.src eq 10.0.0.0/8 and getting false is a trap nobody recovers from without reading the source. |
| ne, != | any type | Not equal — but false when the field is absent. See the warning below. |
| lt, le, gt, ge | integers only | Also spellable <, <=, >, >=. The type checker refuses them on anything but an integer field. |
| contains | text | Substring, case-insensitive. Use matches if you need case to matter. |
| starts_with | text | Prefix, case-insensitive. |
| ends_with | text | Suffix, case-insensitive. |
| matches, ~ | text | Regular expression, case-sensitive unless the pattern begins (?i). The flag is hoisted out of the pattern at parse time. |
| in {…} | any type | Membership in an inline set. On an address field, containment in any of the ranges. |
| in $name | any type | Membership in a named list. |
| exists | any type | The field has a value. An absent cookie is a different question from an empty one. |
Case folding is the default because every author expects it to be: the literal is lowercased when the snapshot is built and the request value is folded at comparison time, so neither side allocates more than it has to. On a set field like http.request.headers.names, a comparison holds when any element satisfies it — comparing against a joined string instead would make http.request.headers.names eq "host" false for every real request.
Missing is not unknown
This is the distinction the whole engine is arranged around, and it is worth understanding before you write a rule you intend to rely on.
http.cookie["session"]on a request with no cookies is missing. The answer is “there is no such cookie”, and a rule testing for it evaluates to false with confidence.ip.geoip.countrywith no GeoIP database loaded is unknown. The answer is “we cannot tell”. The condition still evaluates to false — but the evaluation records a degradation naming the field, and so does the trace.
custom/1: ip.geoip.country could not be determined, so this condition was treated as not matching custom/2: the list `office_ips` could not be resolved, so this condition was treated as not matching
The same logic applies to a named list. A list that failed to resolve returns “I do not know”, which is recorded, rather than “not in the list”, which reads as a clean allow. See Traces for where these surface per request.
Named lists
ip.src in $office_ips tests membership in a list managed outside the rule. An IP blocklist with fifty thousand entries does not belong inline in an expression, and the point of a list is that one office allowlist is referenced by every zone — so lists are scoped to the workspace, not the site. A list name must match ^[a-z][a-z0-9_]{0,62}$, and the kinds are ip, asn, country, hostname and string.
Values are lowercased, sorted and deduplicated when the snapshot is built, so membership is a binary search rather than a scan: a list can hold a hundred thousand entries and is consulted on every request. An IP list is looked up by exact address first; CIDR entries in it are scanned afterwards, which is acceptable because a list of ranges of any real size should be an inline in {…} set instead, which is compiled. A rule that references a list nobody defined fails the snapshot build with the rule named, because every request would otherwise record a degradation.
Phases, and which actions can fire in which
Rules are grouped into rulesets, and each ruleset belongs to one phase. Phases run in a fixed order — the order of the enum, so a phase cannot be reordered without moving it in the source — and each phase evaluates its rules by position, ascending. Positions are dense 1-based integers with a unique index behind them: two rules sharing a position would make evaluation order depend on a query's ORDER BY, which is to say undefined.
| Phase | What it is for |
|---|---|
| ip_access | Address, ASN and country lists. Cheapest, so first. |
| managed | Our maintained signatures. |
| custom | Your own firewall rules. |
| rate_limit | Rate limiting. After the firewall, so a blocked request does not consume a budget an attacker could use to exhaust a real visitor’s. |
| bots | Bot scoring and challenges. |
| redirect | Redirects, before any origin work is considered. |
| request_transform | Rewrites applied to what the origin will see. |
| cache_rules | Cache key construction and TTL overrides. |
| response_transform | Rewrites applied to what the visitor will see. |
An action that cannot fire in its phase is refused, because the mismatches are all confusing-in-production rather than obviously wrong on the page. The snapshot build checks every combination in the table below. The store checks the three worst — set_cache, rate_limit and challenge — on the way in, so those errors arrive while somebody is still looking at the form rather than as a failed publish an hour later.
| Action | Phases it may fire in |
|---|---|
| log | Every phase. Counts the match and carries on — the only safe way to deploy a new rule. |
| skip | Every phase. Ends the phase, and any phases it names. |
| block | ip_access, managed, custom, bots |
| challenge | ip_access, managed, custom, bots |
| route_to | ip_access, managed, custom, bots |
| redirect | redirect |
| modify_headers | request_transform, response_transform |
| rewrite | request_transform, response_transform |
| set_cache | cache_rules |
| rate_limit | rate_limit |
block, challenge and redirect end the request; the phase stops and nothing after it runs. skip ends the phase too, which is what makes an office-IP allowlist at position 1 impossible for a later managed rule to undo. log and the transforms accumulate instead: two rules each adding a header both apply. A block must carry a 4xx or 5xx status — blocking with 200 would look like success to a client — and a redirect must be 301, 302, 303, 307 or 308.
Managed rules
The signatures we maintain are written in the language above, parsed by the same parser and evaluated by the same evaluator. There is no privileged internal form, which means you can read exactly what a managed rule does — and we cannot ship a rule we could not have written in the product. Each carries a stable id that appears in traces (traversal_encoded, sqli_union_select, log4shell, wordpress_probe), a false-positive risk, and a default action that follows from it: block only where a legitimate request essentially cannot match, log everywhere a real one plausibly could.
The honest statement of the gap: this is not the OWASP Core Rule Set. There is no request-body inspection, no anomaly scoring across rules and no protocol-level normalisation chain. It is a filter for the overwhelming volume of untargeted automation, and a determined attacker who knows the list can work around it.
Compilation happens when the snapshot is built
Nothing in the request path compiles a regex, sorts a set or parses a CIDR. All of that happens once, when the configuration snapshot is built, which is what turns a whole class of outage into a failed gdn apply. A pattern whose automaton is enormous would not merely be slow — it would arrive as latency on the zone that deployed it and every other zone sharing the process.
- The expression is type-checked, so
guardyn.bot_score contains "x"andip.src gt 5never reach a node. - Patterns are compiled with a 65,536-byte ceiling on the compiled program and its DFA cache.
- Sets are folded, sorted and deduplicated; string sets become tree lookups.
- The phase is checked as a whole: at most 512 leaf conditions across all its rulesets, dense unique positions, phase-appropriate actions, no response-phase field in a request phase, and no reference to a list that does not exist.
- Every problem is reported, not just the first — fixing rules one error per deploy is a bad afternoon.
Several things a parser would happily accept are refused because they are almost certainly not what the author meant: an empty pattern (write always), an empty set, an and with no conditions (vacuously true) and an or with none (vacuously false). See Configuration for what a failed build looks like.
Checking an expression before you save it
POST /v1/rules/check parses and type-checks an expression and stores nothing. It needs a session and no role beyond that, because it touches no data. The rule editor calls it 250 ms after you stop typing — against the real parser, the same one that will compile the expression into the snapshot, so it cannot disagree with the eventual save.
$ curl -sS http://127.0.0.1:8787/v1/rules/check \
-H 'content-type: application/json' -b session.txt \
-d '{"expression": "ip.geoip.country in {\"RU\" \"CN\"} and not ip.src in $office_ips"}'
{
"ok": true,
"normalised": "ip.geoip.country in {\"cn\" \"ru\"} and not ip.src in $office_ips",
"fields": ["ip.geoip.country", "ip.src"],
"response_phase_only_fields": []
}normalised is the expression printed back from the parsed form. Showing it is how somebody learns the language's own idiom for what they wrote — here, that a string set is folded and sorted. It is also why gdn plan can diff a rule as one changed line instead of a wall of JSON. response_phase_only_fields is how an editor can warn that a custom-phase rule reads guardyn.origin.status before the save fails.
A bad expression is not an HTTP error — the endpoint answers 200 with ok: false, because “you are mid-sentence” is not a failure. Both the flat message and a caret-annotated copy are returned. Every parse error carries a character offset, counted in characters rather than bytes so a multi-byte value cannot shift the caret:
{
"ok": false,
"message": "`equals` is not an operator. Use one of eq, ne, lt, le, gt, ge, contains, starts_with, ends_with, matches, in. (at character 10)",
"annotated": "http.host equals \"example.com\"\n ^ `equals` is not an operator. Use one of eq, ne, lt, le, gt, ge, contains, starts_with, ends_with, matches, in."
}Rendered, the annotation is the two lines anyone actually reads:
http.host equals "example.com"
^ `equals` is not an operator. Use one of eq, ne, lt, le, gt, ge,
contains, starts_with, ends_with, matches, in.The same treatment applies to an unknown field: the message names what you typed and what a field looks like, and the save is refused. That refusal is the point. A WAF that accepts an unrecognised field name and evaluates it as empty gives you a rule that never matches and never says so, which is the most common and most dangerous failure this kind of product has.