Parses GraphQL request bodies and enforces limits on depth, cost, aliases, batch size and introspection, at the request-body stage.
Registry · Docs · All repositories
For teams running a GraphQL API behind Zentinel who want abusive queries stopped before they reach the resolver. The agent parses each query with apollo-parser (no schema needed), runs the enabled analyzers, and on any violation answers HTTP 200 with a GraphQL errors body instead of forwarding the request. settings.fail_action: allow turns it into log-only mode. If the agent is unreachable, the failure-mode of the Zentinel filter decides.
zentinel bundle install graphql-security
zentinel-graphql-security-agent -s /tmp/zentinel-graphql-security.sockWithout a config file the agent runs with the defaults below (introspection blocked, depth 10, cost 1000).
system {
worker-threads 0
}
listeners {
listener "http" {
address "0.0.0.0:8080"
protocol "http"
}
}
upstreams {
upstream "graphql-backend" {
target "127.0.0.1:4000"
}
}
// Request bodies reach agents only with body inspection switched on.
waf {
mode "off"
body-inspection {
inspect-request-body #true
max-inspection-bytes 1048576
content-types "application/json"
}
}
agents {
agent "graphql-security" type="custom" {
unix-socket "/tmp/zentinel-graphql-security.sock"
events "request_headers" "request_body"
request-body-mode "buffer"
timeout-ms 200
}
}
filters {
filter "graphql-security" {
type "agent"
agent "graphql-security"
failure-mode "closed"
}
}
routes {
route "graphql" {
matches { path-prefix "/graphql" }
upstream "graphql-backend"
filters "graphql-security"
}
}curl -s -H 'content-type: application/json' \
-d '{"query":"{ __schema { types { name } } }"}' http://localhost:8080/graphql
# {"errors":[{"message":"...","extensions":{"code":"INTROSPECTION_BLOCKED","zentinel":true}}]}zentinel bundle install graphql-securityDownloads the release binary pinned in Zentinel's bundle lock and installs it as zentinel-graphql-security-agent.
Warning
The published 0.4.0 release, which Zentinel 26.10_7's bundle lock pins,
was not built from its tag (it reports zentinel-agent-graphql-security 0.2.0): it speaks the removed v1 framing on the Unix socket, so the proxy
never completes a handshake and, under failure-mode "closed", refuses every
request (503). Use 0.5.0 or later, the first release built and
smoke-tested by the shared release pipeline (below).
Tarballs for linux-x86_64, linux-aarch64 and darwin-aarch64 are attached to each release.
cargo install --git https://github.com/zentinelproxy/zentinel-agent-graphql-security # needs protocInstalls zentinel-graphql-security-agent.
The agent reads a YAML file (-c, default config.yaml); if the file does not exist it starts with the defaults. An agent { config { ... } } block in the Zentinel configuration can override file settings; see Configuration from Zentinel.
| Option | Default | Meaning |
|---|---|---|
-c, --config |
config.yaml |
Path to the YAML configuration file. |
-s, --socket |
/tmp/zentinel-graphql-security.sock |
Unix socket to listen on. |
--grpc-address |
none | Listen for gRPC on this address instead of the socket, e.g. 0.0.0.0:50051. |
-l, --log-level |
info |
trace, debug, info, warn or error. RUST_LOG is not read. |
-V, --version |
Print zentinel-graphql-security-agent <version> and exit. |
Configuration file keys. Every section and key is optional. An unknown key is an error, in the file (the agent does not start) and in a config { } block (the block is rejected).
| Option | Default | Meaning |
|---|---|---|
settings.max_body_size |
1048576 |
Larger bodies are rejected with INVALID_REQUEST. |
settings.debug_headers |
false |
Add X-GraphQL-Depth, -Complexity, -Aliases, -Operations and -Fields to the response. Zentinel 0.6.50 drops headers sent from the body phase, so these do not arrive. |
settings.fail_action |
block |
block answers with the errors body; allow only logs violations. |
depth.enabled |
true |
Enable depth limiting. |
depth.max_depth |
10 |
Maximum selection nesting, following fragments (cycles are cut). |
depth.ignore_introspection |
true |
Do not count __schema / __type subtrees. |
complexity.enabled |
true |
Enable cost analysis. |
complexity.max_complexity |
1000 |
Maximum estimated cost. |
complexity.default_field_cost |
1 |
Cost of a field without an override. |
complexity.default_list_multiplier |
10 |
Multiplier for a field's children when it has no list-size argument. |
complexity.list_size_arguments |
first, last, limit, pageSize |
Arguments whose integer value is used as the multiplier. |
complexity.field_costs |
empty | Per-field cost, e.g. Query.users: 10. |
complexity.type_costs |
empty | Per-type cost, applied where the parent type is known (root fields, fragments, inline fragments). |
aliases.enabled |
true |
Enable alias limiting. |
aliases.max_aliases |
10 |
Maximum aliases in one query. |
aliases.max_duplicate_aliases |
3 |
Maximum aliases of the same field. |
batch.enabled |
true |
Enable batch limiting. |
batch.max_queries |
5 |
Maximum operations in a JSON-array batch. |
introspection.enabled |
true |
Enable introspection control. |
introspection.allow |
false |
Allow introspection for everyone. |
introspection.allow_typename |
true |
Allow __typename. |
introspection.allowed_clients |
empty | Who may introspect: client IPs and CIDR ranges (10.0.0.0/8, 2001:db8::/32), matched against the client address Zentinel reports, and any other strings, matched exactly against the allowed_clients_header value. An IP or range entry is never accepted as a header value. |
introspection.allowed_clients_header |
none | Header whose value is compared with allowed_clients. |
field_auth.enabled |
false |
Enable field rules. A caller gets a field a rule matches if it has any of the rule's roles or scopes; fields no rule matches are open. |
field_auth.rules[] |
empty | fields (glob patterns), require_roles / require_scopes (any of), roles_header / scopes_header (defaults x-user-roles, x-user-scopes). |
persisted_queries.enabled |
false |
Enable the allowlist. |
persisted_queries.mode |
allowlist |
allowlist permits only listed queries; cache permits every query and remembers it, so later hash-only requests for it can be analyzed. |
persisted_queries.allowlist_file |
none | JSON file {"queries":[{"query":"<text>","name":"..."}]}, loaded at start. An entry may give hash (SHA-256 hex) instead of, or as well as, query; with both, they must agree or the agent refuses to start. A missing file means an empty list. |
persisted_queries.require_hash |
false |
Reject requests without extensions.persistedQuery.sha256Hash. |
The hash of a query is the SHA-256 of its text with surrounding whitespace trimmed (printf '%s' '{ users { id } }' | shasum -a 256), compared case-insensitively.
The agent always hashes the query text it received itself; the client's extensions.persistedQuery.sha256Hash is never taken on trust. A request whose hash does not match its query is rejected with QUERY_NOT_ALLOWED. A hash-only request (no query) is analyzed with the text the agent has for that hash: the allowlist entry's query in allowlist mode, or a query seen earlier with that hash in cache mode (up to 10,000 are kept, in memory). If the agent has no text for the hash it answers PERSISTED_QUERY_NOT_FOUND (message PersistedQueryNotFound, which Apollo-style clients answer by resending with the full query); a hash that is not on the allowlist at all gets QUERY_NOT_ALLOWED. The upstream still receives the hash-only body and must resolve it itself.
Zentinel releases that include zentinelproxy/zentinel#603 send the agent's config { } block on every connection: once for each pooled connection at startup, and again on every reconnect. The block uses the same keys as the file (snake_case, nested by section) and is applied by one rule:
- Merge, don't replace. The block is laid over the file (or the defaults when there is no file). Sections and keys it leaves out keep their file value; a map such as
complexity.field_costsgains or changes the entries the block names. A list (list_size_arguments,allowed_clients,field_auth.rules) is replaced as a whole. The same block always yields the same settings. - Idempotent. A block identical to the one already applied (compared by a hash of its canonical JSON), or one that changes nothing, keeps the running analyzers. When it changes settings, the persisted-query analyzer and its loaded allowlist are kept unless
persisted_queriesitself changed; a changedpersisted_queriesreloads the allowlist file. - Strict. An unknown key, a value of the wrong type, a blank list entry or an allowlist file that cannot be read rejects the whole block: the agent answers the Configure with failure, keeps its current settings and logs the reason. A single value where a list is expected is a one-item list (that is how Zentinel encodes a KDL node with one argument).
- No secrets in logs. The agent logs the block's hash and the dotted names of the settings that changed, never the block itself.
agent "graphql-security" type="custom" {
unix-socket "/tmp/zentinel-graphql-security.sock"
config {
depth { max_depth 8 }
introspection { allowed_clients "10.0.0.5" }
}
}Full reference: https://registry.zentinelproxy.io/graphql-security/
- Body only. The headers event is always answered with "allow, send the body"; analysis runs on the final body chunk, with the headers and client address the agent kept from the headers event (the client address is the one Zentinel reports, not a forwarding header). Subscribe the agent to
request_headersas well asrequest_body; a body whose headers event the agent did not see is analyzed with no headers, so allowlists and field rules deny.GETrequests with the query in the URL are not inspected, and the proxy only sends bodies whenwaf { body-inspection { inspect-request-body #true } }is set and the content type matches. Userequest-body-mode "buffer"and amax-inspection-bytesat least as large assettings.max_body_size: the agent analyzes only the last chunk it receives, and a body cut off at the inspection limit is allowed without analysis. - Analyzers: depth, complexity, aliases, batch (JSON array of operations), introspection, field rules and persisted queries. Every operation in a batch is analyzed, and all violations are reported together.
- Block response: status 200 and
{"errors":[{"message", "extensions":{"code","zentinel":true, "actual", "max", "field"}}]}. Codes:DEPTH_EXCEEDED,COMPLEXITY_EXCEEDED,TOO_MANY_ALIASES,TOO_MANY_BATCH_QUERIES,INTROSPECTION_BLOCKED,FIELD_UNAUTHORIZED,QUERY_NOT_ALLOWED,PERSISTED_QUERY_NOT_FOUND,PARSE_ERROR,INVALID_REQUEST(not JSON, noqueryand no persisted-query hash, or too large). The agent asks forContent-Type: application/jsonas a response-header operation, which Zentinel 0.6.50 does not apply to a block decided on the body. - Complexity is an estimate without a schema: each field costs its configured cost, and the fields under it are multiplied by its list-size argument or
default_list_multiplier. Multipliers apply one level down and do not compound. - Metrics over the agent protocol:
graphql_security_requests_total,graphql_security_requests_blocked_total,graphql_security_block_rate_percent.
Known limitations in the current code:
- Nested field patterns. Field-rule patterns such as
User.emailonly match through fragments, because nested field types are unknown without a schema.
cargo build
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --checkNeeds stable Rust (CI uses dtolnay/rust-toolchain@stable) and protoc (CI installs protobuf-compiler).
Releases go through the shared agent pipeline
(zentinelproxy/.github):
pushing a tag vX.Y.Z that matches Cargo.toml builds zentinel-graphql-security-agent with
--locked for linux-x86_64, linux-aarch64 and darwin-aarch64, checks
that zentinel-graphql-security-agent --version prints zentinel-graphql-security-agent X.Y.Z, runs
scripts/smoke-test.sh behind the latest Zentinel
release on every platform, and only then publishes the tarballs
zentinel bundle install downloads. CI runs the same pipeline on
linux-x86_64 for every push and pull request.
The smoke test runs the release binary behind a real Zentinel with the agent,
filter and route all failure-mode "closed", over the Unix socket (the
transport zentinel bundle install uses) and gRPC, subscribed to request headers and bodies, with the default
configuration. It checks that a shallow query is forwarded, that a query 12
levels deep, an introspection query, a query with 12 aliases and a batch of 6
queries are answered by the agent the GraphQL way (200 with an errors
body carrying DEPTH_EXCEEDED, INTROSPECTION_BLOCKED, TOO_MANY_ALIASES,
TOO_MANY_BATCH_QUERIES) instead of by the upstream, and that the request is
refused once the agent is stopped (so the agent really is on the request
path). The blocked queries are checked by
scripts/smoke-blocked-queries.py: the
proxy sends a request's headers upstream before the body has been inspected,
so the upstream sees the start of a blocked request either way.
Important
Zentinel releases up to 26.10_7 send a request body to an agent only when
the configuration also has a waf { } block; without one this agent sees
headers only and blocks nothing. The smoke test reports that as a known
limitation for those releases and requires the blocks against anything
newer (Zentinel main passes).
Run it locally:
gh release download 26.10_7 --repo zentinelproxy/zentinel --pattern 'zentinel-26.10_7-darwin-arm64.tar.gz*'
shasum -a 256 -c zentinel-26.10_7-darwin-arm64.tar.gz.sha256 && tar xzf zentinel-26.10_7-darwin-arm64.tar.gz
cargo build --release --locked
AGENT_SMOKE=../.github/agent-smoke/smoke.py scripts/smoke-test.sh target/release/zentinel-graphql-security-agent ./zentinelApache-2.0, see LICENSE.
Part of Zentinel, the security-first reverse proxy. The shisa guards the gate.