Skip to content

About

GraphQL security agent for Zentinel proxy - query depth, complexity, and introspection control

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

29 Commits

Folders and files

Repository files navigation

Zentinel

GraphQL security agent

Parses GraphQL request bodies and enforces limits on depth, cost, aliases, batch size and introspection, at the request-body stage.

CI License: Apache-2.0 Agent protocol v2

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.

Quick start

zentinel bundle install graphql-security
zentinel-graphql-security-agent -s /tmp/zentinel-graphql-security.sock

Without 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}}]}

Install

Bundle (recommended)

zentinel bundle install graphql-security

Downloads 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).

Release binaries

Tarballs for linux-x86_64, linux-aarch64 and darwin-aarch64 are attached to each release.

From source

cargo install --git https://github.com/zentinelproxy/zentinel-agent-graphql-security   # needs protoc

Installs zentinel-graphql-security-agent.

Configuration

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.

Configuration from Zentinel

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_costs gains 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_queries itself changed; a changed persisted_queries reloads 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/

How it works

  • 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_headers as well as request_body; a body whose headers event the agent did not see is analyzed with no headers, so allowlists and field rules deny. GET requests with the query in the URL are not inspected, and the proxy only sends bodies when waf { body-inspection { inspect-request-body #true } } is set and the content type matches. Use request-body-mode "buffer" and a max-inspection-bytes at least as large as settings.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, no query and no persisted-query hash, or too large). The agent asks for Content-Type: application/json as 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.email only match through fragments, because nested field types are unknown without a schema.

Development

cargo build
cargo test --workspace
cargo clippy --workspace --all-targets -- -D warnings
cargo fmt --all --check

Needs stable Rust (CI uses dtolnay/rust-toolchain@stable) and protoc (CI installs protobuf-compiler).

Releases and smoke test

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 ./zentinel

Related

License

Apache-2.0, see LICENSE.


Part of Zentinel, the security-first reverse proxy. The shisa guards the gate.

About

GraphQL security agent for Zentinel proxy - query depth, complexity, and introspection control

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages