Repository navigation
GraphQL-aware L7 inspection: operation-type and field-level policy rules #1022
Description
Problem Statement
OpenShell's L7 enforcement matches HTTP method, URL path, and query parameters. That's sufficient for REST APIs where the destructive intent is encoded in the URL (DELETE /repos/.../branches/main). It is not sufficient for GraphQL, JSON-RPC, SOAP, and similar body-encoded operation languages, where the destructive vs. read-only distinction lives in the request body.
Concrete motivating case (publicly reported, tomshardware.com link): an agent with a valid Railway bearer token issued
POST https://backboard.railway.app/graphql/v2
{"query":"mutation { volumeDelete(volumeId: \"...\") }"}
and deleted a production database. The same POST /graphql/v2 URL also serves legitimate read traffic (query { volume(...) }) — they cannot be distinguished without parsing the body. With current L7 rules an operator must choose between (a) blanket-denying POST /graphql/v2 (blocks legitimate writes too), or (b) allowing it and accepting the destructive-call risk.
This gap will surface for any GraphQL or JSON-RPC API where destructive operations are encoded in the request body — which is the common case across GraphQL-shaped systems.
Proposed Design
Proposed Design
Add Graphql as a peer variant to the existing L7Protocol enum (crates/openshell-sandbox/src/l7/mod.rs), alongside Rest and Sql. New module crates/openshell-sandbox/src/l7/graphql.rs implements:
- Body capture — buffer up to a bounded size (proposed 64 KiB default, configurable per-endpoint, number to be benchmarked before locking) of the POST body using existing framing logic (
parse_body_lengthinl7/rest.rs). Bodies exceeding the bound fail closed. - GraphQL parsing — minimal-cost parse of the JSON envelope (
{ query, variables, operationName }) and the GraphQL document to extract: operation type (query/mutation/subscription), top-level operation name, and the set of root fields invoked. - Rule matching — extend
L7AllowandL7DenyRule(proto/sandbox.proto) with three new optional fields scoped to the GraphQL protocol:operation_type(query/mutation/subscription/*)operation_name(glob)fields(set of field-name globs; rule matches if any selected field matches any element)
- Policy composition — same allow/deny ordering as the REST path: deny rules take precedence, allows are additive.
Example policy fragment, authored as a Provider Profile (Discussion #865):
- host: backboard.railway.app
port: 443
protocol: graphql
rules:
- allow:
operation_type: query # all reads
- allow:
operation_type: mutation
fields: ["volumeCreate", "deploymentTrigger"]
deny_rules:
- operation_type: mutation
fields: ["*Delete", "*Destroy", "volumeDelete", "projectDelete"]The OPA Rego rules in crates/openshell-sandbox/data/sandbox-policy.rego extend by analogy with the REST path; no new evaluation engine.
Parser choice: apollo-parser
Phase 1 needs a Rust GraphQL document parser. Three candidates compared, scored on dependency footprint, maintenance health, error-recovery semantics, and AST fit for our use (operation type, operation name, top-level field set). Data from crates.io as of April 2026:
apollo-parser 0.8.5 |
async-graphql-parser 7.2.1 |
graphql-parser 0.4.1 |
|
|---|---|---|---|
| Last stable release | 2026-02-25 | 7.2.1 stable; 8.0.0-rc.5 dated 2026-04-21 | 2024-12-03 |
| Versions published | 41 | 209 | 8 |
| Recent downloads (90d) | ~93k | ~4.2M | ~4.4M |
| License | MIT OR Apache-2.0 | MIT OR Apache-2.0 | MIT OR Apache-2.0 |
| Production deps | 3 (memchr, rowan, thiserror) |
4 (async-graphql-value, pest, serde, serde_json) |
2 (combine, thiserror) |
| Source size | ~209 KB / ~8.1k LoC Rust | server-framework parser internals | ~36 KB / ~3.9k LoC Rust |
| AST shape | CST (rowan, lossless) |
AST | AST |
| Error recovery | yes — "lexing and parsing does not fail or panic"; "always produces a CST" |
partial | none |
| Maintainer | apollographql org | async-graphql server project | graphql-rust org / individual |
Recommendation: apollo-parser.
- Smallest credible production-dep footprint for our use case.
graphql-parseris technically lighter (two deps) but stagnant; the maintenance gap matters more than two crates on a security-critical path. - Maintained by the team that authors the GraphQL Federation spec — the strongest available signal that the parser will track GraphQL spec evolution.
- Active and recent: 41 versions, last release Feb 2026.
graphql-parsershipped 8 versions in 8 years and last released December 2024. - Error-recovery semantics fit the policy path. Producing a CST even on malformed input lets the deny path emit specific diagnostics ("malformed mutation field; rejected by deny rule X") rather than blanket "parse error → block". Useful for the policy advisor / agent inbox flow.
Why not the others:
async-graphql-parseris the parser internals of a server framework, not a standalone library. Its release cycle is bound to the server's (currently in 8.0.0-rc churn — five release candidates visible). Pullspestparser-generator runtime as a production dep.graphql-parseris small and has no parser-generator dep, but maintenance is thin (8 versions across 8 years, last release December 2024).combine-based AST output has no error recovery — malformed input fails wholesale, acceptable for fail-closed posture but loses the diagnostic benefit.
Selection assumes Phase 1 only needs the AST root: operation kind (query / mutation / subscription), operation name, and the set of top-level selected fields. All three crates expose this. If Phase 2+ needs execution-time information (variable resolution, fragment expansion across the full document graph), re-evaluate before that phase lands.
Implementation phases
- Phase 1 —
Graphqlprotocol variant, body capture with bounded buffering, operation-type matching only (query/mutation/subscription). Closes the Railway-class case. - Phase 2 — operation-name and root-field matching.
- Phase 3 — generalize the "body inspector" trait so JSON-RPC, SOAP, and protobuf inspection can be added without new sandbox releases — they become Provider Profile contributions.
Related
- Discussion Provider Enhancements -- Declarative Profiles, Auto-Injected Policy, Multi-Provider Inference #865 — Provider Enhancements (the rule-authoring surface)
- Issue Enhanced Provider Management #896 — tracking issue for Provider Enhancements -- Declarative Profiles, Auto-Injected Policy, Multi-Provider Inference #865
- Issue feat(policy): add deny rules to network policy schema #822 — deny rules in network policy schema
- RFC 0001 — Agent-Driven Policy Management (sets up the inbox where mutation-deny rules would be reviewed and approved)
Alternatives Considered
Alternatives Considered
- Coarse
POSTdeny on/graphql*— works today, blocks the Railway attack, but also blocks all legitimate writes. Operationally a non-starter for any team that needs GraphQL writes at all. - Rely on server-side enforcement only — correct in principle, and the primary control will always live on the side of the protected resource. Does not help when the upstream service ships destructive operations without confirmation gates, which the Railway incident demonstrates is common in practice.
- Externalize to a request-inspecting sidecar — e.g., proxy through an Envoy filter or OPA-with-
http.send. Adds another service on the deployment path, and the sandbox already has the body bytes in flight; in-process parsing is strictly cheaper. - Require GraphQL APIs to expose a typed REST surface — not actionable; many production systems are GraphQL-native by design.
- Body-blind allow with audit-only logging — defense-in-depth value is near zero; agent destructive calls succeed, postmortem is the only artifact.
Agent Investigation
Codebase surveyed by agent prior to filing:
proto/sandbox.proto:L7Allow(method,path,query,command) andL7DenyRule(same shape) define the current rule surface. No body-related field exists.NetworkEndpoint.protocolis a free-form string with comment indicating"rest","sql", or empty.crates/openshell-sandbox/src/l7/mod.rs:L7Protocolenum is{ Rest, Sql }.parse()accepts only those literals — addingGraphqlis a minimal change.crates/openshell-sandbox/src/l7/rest.rs: implements body framing (parse_body_length, Content-Length / chunked) for HTTP correctness. The bytes are already buffered; no body content matching exists.grep -rn "match_body|body_match|graphql|operation_type" crates/openshell-sandbox/src/l7/returns zero hits.crates/openshell-sandbox/data/sandbox-policy.rego: REST and SQL evaluation paths are siblings; adding a GraphQL evaluation path is structurally analogous.- Discussion Provider Enhancements -- Declarative Profiles, Auto-Injected Policy, Multi-Provider Inference #865 (Provider Profiles): the rule syntax above slots cleanly into the existing
endpoints[].rules/endpoints[].deny_rulesshape that profile YAML already supports.
No new dependency is required for Phase 1 beyond serde_json (already in tree) and apollo-parser (see Parser choice section above).
Checklist
- I've reviewed existing issues and the architecture docs
- This is a design proposal, not a "please build this" request
Activity
cgwalters commentedon Apr 29, 2026
Yes, I researched this when working on https://github.com/LobsterTrap/service-gator
One good thing is right now, at least e.g. Github and Gitlab deny graphql
mutationwithGETrequests. I think it'd make sense to suggest all services which expose Graphql to have matching semantics.But it's a huge change to start parsing the content of HTTP requests here. Personally, for stuff like this I think the real fix is for services that expose tokens to at least offer readonly vs write at that level, if not a fully fine-grained model.
johntmyers commentedon Apr 30, 2026
Implementation Plan
Issue type:
featComplexity: High
Confidence: Medium — clear architecture path, but GraphQL body inspection needs careful parser-differential and request-framing tests
Summary
Add a
graphqlL7 protocol that can inspect GraphQL-over-HTTP requests by parsing the JSON envelope and GraphQL document before forwarding the request upstream. The feature should first enforce operation-type policy (query/mutation/subscription), then support operation-name and root-field matching so provider/app profiles can express practical service-specific controls for real GraphQL APIs.Scope
crates/openshell-sandbox/src/l7/: add a GraphQL provider/relay path that buffers bounded request bodies, parses GraphQL documents withapollo-parser, classifies operation metadata, and fails closed on malformed, oversized, compressed, multipart, or unclassifiable enforced requests.proto/sandbox.proto,crates/openshell-policy, andcrates/openshell-sandbox/src/opa.rs: extendL7AllowandL7DenyRulewith GraphQL fields and serialize them into OPA input/data without changing existing REST/SQL policy behavior.crates/openshell-sandbox/data/sandbox-policy.rego: add GraphQL allow/deny matching alongside REST/SQL, with deny rules taking precedence and explicit deny reasons for operation type, operation name, root fields, and persisted-query/hash-only denial.architecture/sandbox.md,architecture/security-policy.md,docs/reference/policy-schema.mdx, anddocs/sandboxes/policies.mdx: document GraphQL inspection, policy examples for real services, and persisted/saved query behavior.Implementation Steps
operation_type,operation_name,fields, and an endpoint-level persisted-query behavior such aspersisted_queries: deny|allow_registeredwith an optional trusted hash registry. Preserve backward compatibility for existing REST policy YAML/proto round trips.POSTJSON bodies, andGETquery-string requests. Extractoperation_type, selectedoperation_name, and root fields; evaluate all batched operations and deny if any operation is denied.query, multiple operations withoutoperationName, malformed documents, bodies over the configured cap, unsupported compression, multipart uploads, subscriptions unless explicitly allowed, and hash-only persisted queries without a trusted registry.L7Protocol::Graphqlthroughparse_l7_config()andrelay_with_inspection(). Reuse REST HTTP/1.1 framing where safe, but ensure GraphQL inspection buffers the exact body bytes needed for policy before forwarding and then forwards the original request body unchanged after allow.request.method/path/query_paramsto include a GraphQL metadata object. Add GraphQL allow/deny rules that match operation type, operation name glob, and root-field globs. For aliases, match the underlying field name, not the alias.backboard.railway.app/graphql/v2; allowquery; allow selected safe mutations such as deployment-trigger operations; deny destructive roots likevolumeDelete,projectDelete,*Delete,*Destroy.api.github.com/graphql; allowquery; optionally allow low-risk roots likeaddReaction/removeReaction; deny branch/repo/org destructive mutations such asdeleteRef,deleteRepository,updateBranchProtectionRule,createRepository, and broaddelete*patterns.gitlab.com/api/graphql; prefer token scopes such asread_apiwhere possible; allowquery; deny mutations by default or allow only reviewed workflow mutations. Document that GitLab rejects mutations over GET, but OpenShell still enforces body-aware policy for POST.*.myshopify.com/admin/api/**/graphql.json; allowquery; require app-specific allowlists for mutations; deny*Delete,bulkOperationRunMutation, and high-impact inventory/order/customer mutations unless explicitly approved.api.monday.com/v2; allow board/item reads; allow tightly scoped create/update mutations only where needed; deny delete/archive roots by default.query; deny record create/update/delete mutations unless the sandbox is intended to modify CRM data and the root fields are explicitly allowlisted.delete*,deleteMany*,unpublish*, and batch mutations unless a publishing workflow requires them.api.atlassian.com/graphql; allow reads by default; treat mutations as product-specific and require explicit root-field allowlists because the gateway spans Jira, Confluence, Bitbucket, and admin surfaces.operation_type: queryas the normal profile and deny subscriptions/mutations unless a specific partner API requires them.queryis absent andextensions.persistedQuery.sha256Hashexists.id,documentId, orqueryIdas untrusted persisted-operation identifiers when noqueryis present.enforcement: enforce, deny hash-only/saved-query requests unless the endpoint policy includes a trusted hash-to-classification registry populated from a provider profile or application manifest.enforcement: audit, log the hash/id, operationName, and classification status without logging variable values.queryand persisted-query metadata are present, classify fromqueryand optionally validate that the hash matches the canonical document before allow.Test Plan
crates/openshell-sandbox/src/l7/graphql.rsfor simple query, mutation, subscription, named operations, aliases, fragments, inline fragments, directives, batched operations, GET envelopes, malformed documents, multiple operations withoutoperationName, oversized bodies, and persisted-query/hash-only shapes.crates/openshell-sandbox/src/opa.rstests to cover GraphQL allow/deny precedence, operation-type allow, operation-name glob matching, root-field allow/deny, missing GraphQL metadata denial, and persisted-query denial reasons.crates/openshell-policytests for YAML-to-proto and proto-to-YAML round trips of GraphQL fields, deny rules, endpoint persisted-query settings, and registry entries if implemented in schema.accesspresets, query-param matching,deny_rules, TLS auto-detection, and credential redaction tests passing.Risks & Open Questions
Documentation Impact
architecture/sandbox.mdandarchitecture/security-policy.mdto describeprotocol: graphql, body-aware inspection, GraphQL OPA input, and fail-closed behavior.docs/reference/policy-schema.mdxanddocs/sandboxes/policies.mdxwith GraphQL rule fields, real service policy examples, and persisted/saved query guidance.docs/security/best-practices.mdxto recommend GraphQL operation-type enforcement for high-impact API tokens and to prefer provider read-only scopes where available.Revision 1 — initial plan with real-service policy shapes and persisted-query handling
feat(policy): add GraphQL L7 inspection
johntmyers commentedon Apr 30, 2026
Implementation Complete
PR: #1083
What was built
Added
protocol: graphqlL7 inspection with GraphQL-over-HTTP classification, operation type/name/root-field policy matching, deny precedence, batched request fail-closed behavior, and trusted registry handling for hash-only/saved queries.Tests
e2e/unchanged)mise run pre-commitpassesDocs updated
architecture/sandbox.mdarchitecture/security-policy.mddocs/reference/policy-schema.mdxdocs/sandboxes/policies.mdxdocs/security/best-practices.mdxThe issue will auto-close when the PR is merged.
feat(policy): add GraphQL L7 inspection