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

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

SyntaxMeans
and, &&Both. Binds tighter than or.
or, ||Either.
not, !Negation. Binds tightest of the three.
( … )Grouping, to override that precedence.
alwaysMatches every request. What a maintenance block is, so it is spellable.
neverMatches nothing.
exists fThe 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.
$nameA 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

FieldTypeWhat it is
ip.srcip addressThe 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.countrystringISO 3166-1 alpha-2, from the GeoIP database.
ip.geoip.continentstringTwo-letter continent code.
ip.geoip.asnumintegerAutonomous system number.
ip.geoip.as_organizationstringThe name that owns the AS.
ip.reputation.is_threatbooleanTrue for addresses in our own list of known-bad ranges.

TLS

FieldTypeWhat it is
sslbooleanWhether the request arrived over TLS at all.
tls.versionstringNegotiated version, e.g. TLSv1.3.
tls.cipherstringNegotiated cipher suite.
tls.ja4stringJA4 fingerprint of the client hello — the most useful bot signal that survives a User-Agent change.
tls.client_cert.verifiedbooleanA client certificate was presented and verified.
tls.client_cert.subjectstringThat certificate's subject.

The request

FieldTypeWhat it is
http.request.versionstringRendered as HTTP/1.1, HTTP/2.0.
http.request.methodstringAs sent. Comparisons fold case anyway.
http.hoststringHost header, lowercased and port-stripped; the URI authority if there is no header.
http.request.uri.pathstringPercent-decoded exactly once. No query string.
http.request.uri.querystringRaw, without the leading ?. An empty string when there is no query — present, not absent.
http.request.uristringPath and query together, the form a log line shows. Not decoded.
http.request.uri.path.extensionstringLowercased, no dot. Absent when empty or longer than 16 characters.
http.request.headers.namesset of stringsEvery header name present, for "does this request carry X at all".
http.user_agentstringShorthand for the header.
http.refererstringShorthand for the header.
http.request.body.sizeintegerFrom Content-Length. Zero when there is none, not absent.
http.request.content_typestringContent-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:

FormTypeWhat it is
http.request.headers["x-api-key"]stringOne 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"]stringOne cookie. The name is NOT folded: cookie names are case-sensitive per RFC 6265.

Signals we derive

FieldTypeWhat it is
guardyn.bot_scoreinteger1–99. Low is more likely automated. Absent — not zero — when scoring did not run.
guardyn.verified_botstringA crawler we recognise and have verified: googlebot, bingbot. Absent otherwise.
guardyn.rateintegerRequests from this address in the current sliding window.
guardyn.internalbooleanTrue 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

FieldTypeWhat it is
guardyn.cache_statusstringOne of HIT, MISS, MISS-STORED, STALE, STALE-ERROR, REVALIDATED, BYPASS, DYNAMIC, COLLAPSED.
guardyn.origin.namestringThe origin the request was routed to.
guardyn.origin.statusintegerThe 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

OperatorApplies toBehaviour
eq, ==any typeEqual. 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 typeNot equal — but false when the field is absent. See the warning below.
lt, le, gt, geintegers onlyAlso spellable <, <=, >, >=. The type checker refuses them on anything but an integer field.
containstextSubstring, case-insensitive. Use matches if you need case to matter.
starts_withtextPrefix, case-insensitive.
ends_withtextSuffix, case-insensitive.
matches, ~textRegular expression, case-sensitive unless the pattern begins (?i). The flag is hoisted out of the pattern at parse time.
in {…}any typeMembership in an inline set. On an address field, containment in any of the ranges.
in $nameany typeMembership in a named list.
existsany typeThe 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.country with 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.
degradations, as they appear in a 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.

PhaseWhat it is for
ip_accessAddress, ASN and country lists. Cheapest, so first.
managedOur maintained signatures.
customYour own firewall rules.
rate_limitRate limiting. After the firewall, so a blocked request does not consume a budget an attacker could use to exhaust a real visitor’s.
botsBot scoring and challenges.
redirectRedirects, before any origin work is considered.
request_transformRewrites applied to what the origin will see.
cache_rulesCache key construction and TTL overrides.
response_transformRewrites 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.

ActionPhases it may fire in
logEvery phase. Counts the match and carries on — the only safe way to deploy a new rule.
skipEvery phase. Ends the phase, and any phases it names.
blockip_access, managed, custom, bots
challengeip_access, managed, custom, bots
route_toip_access, managed, custom, bots
redirectredirect
modify_headersrequest_transform, response_transform
rewriterequest_transform, response_transform
set_cachecache_rules
rate_limitrate_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.

  1. The expression is type-checked, so guardyn.bot_score contains "x" and ip.src gt 5 never reach a node.
  2. Patterns are compiled with a 65,536-byte ceiling on the compiled program and its DFA cache.
  3. Sets are folded, sorted and deduplicated; string sets become tree lookups.
  4. 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.
  5. 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.

a valid expression
$ 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:

a bad one
{
  "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.