Skip to content

Commit da4011e

Browse files
committed
feat(policy): add GraphQL L7 inspection
Closes #1022 Adds GraphQL-over-HTTP classification, policy schema fields, OPA enforcement, persisted-query registry handling, tests, and documentation. Signed-off-by: John Myers <[email protected]>
1 parent ebbd9de commit da4011e

20 files changed

Lines changed: 1824 additions & 55 deletions

File tree

‎Cargo.lock‎

Lines changed: 46 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ nix = { version = "0.29", features = ["signal", "process", "user", "fs", "term"]
6969
serde = { version = "1", features = ["derive"] }
7070
serde_json = "1"
7171
serde_yml = "0.0.12"
72+
apollo-parser = "0.8.5"
7273

7374
# HTTP client
7475
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }

‎architecture/sandbox.md‎

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ All paths are relative to `crates/openshell-sandbox/src/`.
2929
| `bypass_monitor.rs` | Background `/dev/kmsg` reader for iptables bypass detection events |
3030
| `sandbox/linux/netns.rs` | Network namespace creation, veth pair setup, bypass detection iptables rules, cleanup on drop |
3131
| `l7/mod.rs` | L7 types (`L7Protocol`, `TlsMode`, `EnforcementMode`, `L7EndpointConfig`), config parsing, validation, access preset expansion, deprecated `tls` value handling |
32+
| `l7/graphql.rs` | GraphQL-over-HTTP request classifier, body buffering, operation/root-field extraction, and persisted-query metadata handling |
3233
| `l7/inference.rs` | Inference API pattern detection (`detect_inference_pattern()`), HTTP request/response parsing and formatting for intercepted inference connections |
3334
| `l7/tls.rs` | Ephemeral CA generation (`SandboxCa`), per-hostname leaf cert cache (`CertCache`), TLS termination/connection helpers, `looks_like_tls()` auto-detection |
3435
| `l7/relay.rs` | Protocol-aware bidirectional relay with per-request OPA evaluation, credential-injection-only passthrough relay |
@@ -1014,22 +1015,22 @@ flowchart LR
10141015

10151016
| Type | Definition | Purpose |
10161017
|------|-----------|---------|
1017-
| `L7Protocol` | `Rest`, `Sql` | Supported application protocols |
1018+
| `L7Protocol` | `Rest`, `Graphql`, `Sql` | Supported application protocols |
10181019
| `TlsMode` | `Auto` (default), `Skip` | TLS handling strategy — `Auto` peeks first bytes and terminates if TLS is detected; `Skip` bypasses detection entirely |
10191020
| `EnforcementMode` | `Audit`, `Enforce` | What to do on L7 deny (log-only vs block) |
1020-
| `L7EndpointConfig` | `{ protocol, tls, enforcement, allow_encoded_slash }` | Per-endpoint L7 configuration |
1021+
| `L7EndpointConfig` | `{ protocol, tls, enforcement, allow_encoded_slash, graphql_max_body_bytes }` | Per-endpoint L7 configuration |
10211022
| `L7Decision` | `{ allowed, reason, matched_rule }` | Result of L7 evaluation |
1022-
| `L7RequestInfo` | `{ action, target, query_params }` | HTTP method, path, and decoded query multimap for policy evaluation |
1023+
| `L7RequestInfo` | `{ action, target, query_params, graphql }` | HTTP method, path, decoded query multimap, and optional GraphQL classification for policy evaluation |
10231024

10241025
### Access presets
10251026

10261027
Policy data supports shorthand `access` presets that expand into explicit `rules` during preprocessing:
10271028

10281029
| Preset | Expands to |
10291030
|--------|-----------|
1030-
| `read-only` | `GET **`, `HEAD **`, `OPTIONS **` |
1031-
| `read-write` | `GET **`, `HEAD **`, `OPTIONS **`, `POST **`, `PUT **`, `PATCH **` |
1032-
| `full` | `* **` (all methods, all paths) |
1031+
| `read-only` | REST: `GET **`, `HEAD **`, `OPTIONS **`; GraphQL: `query` |
1032+
| `read-write` | REST: `GET **`, `HEAD **`, `OPTIONS **`, `POST **`, `PUT **`, `PATCH **`; GraphQL: `query`, `mutation` |
1033+
| `full` | REST: `* **`; GraphQL: `operation_type: "*"` |
10331034

10341035
Expansion happens in `expand_access_presets()` before the Rego engine loads the data. The `rules` and `access` fields are mutually exclusive (validated at startup).
10351036

@@ -1041,14 +1042,17 @@ Expansion happens in `expand_access_presets()` before the Rego engine loads the
10411042

10421043
- `rules` and `access` both specified on same endpoint
10431044
- `protocol` specified without `rules` or `access`
1045+
- unknown `protocol`
10441046
- `protocol: sql` with `enforcement: enforce` (SQL parsing not available in v1)
10451047
- Empty `rules` array (would deny all traffic)
1048+
- invalid GraphQL operation types, persisted-query mode, body limit, or rule shape
10461049

10471050
**Warnings** (logged):
10481051

10491052
- `tls: terminate` or `tls: passthrough` on any endpoint (deprecated — TLS termination is now automatic; use `tls: skip` to disable)
10501053
- `tls: skip` with L7 rules on port 443 (L7 inspection cannot work on encrypted traffic)
10511054
- Unknown HTTP method in rules
1055+
- GraphQL-specific fields on non-GraphQL endpoints
10521056

10531057
### TLS termination (auto-detect)
10541058

@@ -1223,13 +1227,21 @@ Implements `L7Provider` for HTTP/1.1:
12231227

12241228
- **`looks_like_http()`**: Protocol detection via first-byte peek -- checks for standard HTTP method prefixes (GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS, CONNECT, TRACE).
12251229

1230+
### GraphQL protocol classifier
1231+
1232+
**File:** `crates/openshell-sandbox/src/l7/graphql.rs`
1233+
1234+
GraphQL inspection reuses the HTTP parser, then buffers the request body up to `graphql_max_body_bytes` for classification. It supports `GET` and `POST` GraphQL-over-HTTP envelopes, JSON batches, named operations, root fragment expansion, Apollo persisted-query hashes, and saved-query IDs (`id`, `documentId`, `queryId`). The classifier emits `GraphqlRequestInfo` with operation type, optional operation name, root fields, and persisted-query identifiers.
1235+
1236+
Hash-only or saved-query-only requests cannot be parsed into operation fields. They are denied unless the endpoint sets `persisted_queries: allow_registered` and provides a trusted `graphql_persisted_queries` entry for the hash or ID. Batch requests are fail-closed: any malformed, denied, or unregistered operation denies the whole HTTP request.
1237+
12261238
### Per-request L7 evaluation
12271239

12281240
`relay_with_inspection()` in `crates/openshell-sandbox/src/l7/relay.rs` is the main relay loop:
12291241

12301242
1. Parse one HTTP request from client via the provider. Parser and path-canonicalization failures close the connection and emit a denied OCSF network event with the rejection reason in `status_detail`.
12311243
2. Resolve credential placeholders in the request target via `rewrite_target_for_eval()`. OPA receives the redacted path (`[CREDENTIAL]` markers); the resolved path goes only to upstream. If resolution fails, return HTTP 500 and close the connection.
1232-
3. Build L7 input JSON with `request.method`, the **redacted** `request.path`, `request.query_params`, plus the CONNECT-level context (host, port, binary, ancestors, cmdline)
1244+
3. Build L7 input JSON with `request.method`, the **redacted** `request.path`, `request.query_params`, optional `request.graphql`, plus the CONNECT-level context (host, port, binary, ancestors, cmdline)
12331245
4. Evaluate `data.openshell.sandbox.allow_request` and `data.openshell.sandbox.request_deny_reason`
12341246
5. Log the L7 decision (tagged `L7_REQUEST`) using the redacted target — real credential values never appear in logs
12351247
6. If allowed (or audit mode): relay request to upstream via `relay_http_request_with_resolver()` (which rewrites all remaining credential placeholders in headers, query parameters, path segments, and Basic auth tokens) and relay the response back to client, then loop

‎architecture/security-policy.md‎

Lines changed: 27 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,9 @@ Each endpoint defines a network destination and, optionally, L7 inspection behav
476476
| `rules` | `L7Rule[]` | `[]` | Explicit L7 allow rules. Mutually exclusive with `access`. |
477477
| `allowed_ips` | `string[]` | `[]` | IP allowlist for SSRF override. Entries overlapping always-blocked ranges (loopback, link-local, unspecified) are rejected at load time. See [Private IP Access via `allowed_ips`](#private-ip-access-via-allowed_ips). |
478478
| `allow_encoded_slash` | `bool` | `false` | Preserves `%2F` inside L7 request path segments instead of rejecting the request. Required for endpoints such as npm scoped packages. |
479+
| `persisted_queries` | `string` | `"deny"` | GraphQL hash-only/saved-query behavior. Use `"allow_registered"` only with `graphql_persisted_queries`. |
480+
| `graphql_persisted_queries` | `map` | `{}` | Trusted GraphQL persisted-query registry keyed by hash or service-specific ID. Values contain `operation_type`, optional `operation_name`, and optional root `fields`. |
481+
| `graphql_max_body_bytes` | `integer` | `65536` | Maximum GraphQL request body size buffered for inspection. Larger GraphQL bodies are rejected before policy evaluation. |
479482

480483
#### `NetworkBinary`
481484

@@ -507,6 +510,13 @@ rules:
507510
query:
508511
labels:
509512
any: ["bug*", "p1*"]
513+
- allow:
514+
operation_type: query
515+
fields: [viewer, repository]
516+
- allow:
517+
operation_type: mutation
518+
operation_name: Issue*
519+
fields: [createIssue]
510520
```
511521

512522
#### `L7Allow`
@@ -517,18 +527,23 @@ rules:
517527
| `path` | `string` | URL path glob pattern: `**` matches everything, otherwise `glob.match` with `/` delimiter. |
518528
| `command` | `string` | SQL command: `SELECT`, `INSERT`, `UPDATE`, `DELETE`, or `*` (any). Case-insensitive matching. For `protocol: sql` endpoints. |
519529
| `query` | `map` | Optional REST query rules keyed by decoded query param name. Value is either a glob string (for example, `tag: "foo-*"`) or `{ any: ["foo-*", "bar-*"] }`. |
530+
| `operation_type` | `string` | GraphQL operation type: `query`, `mutation`, `subscription`, or `*`. Required for `protocol: graphql` allow rules. |
531+
| `operation_name` | `string` | Optional GraphQL operation-name glob. Omit to match any operation name. |
532+
| `fields` | `string[]` | Optional GraphQL root-field globs. For allow rules, every selected root field must match one configured glob. For deny rules, any matching root field blocks the request. |
533+
534+
Method, command, and GraphQL operation type fields use `*` as wildcard for "any". Path patterns use `**` for "match everything" and standard glob patterns with `/` as a delimiter otherwise. Query matching is case-sensitive and evaluates decoded values; when duplicate keys are present in the request, every value for that key must match the configured matcher. GraphQL field and operation-name matching also uses glob patterns. See `sandbox-policy.rego` -- `method_matches()`, `path_matches()`, `command_matches()`, `query_params_match()`, and `graphql_*`.
520535

521-
Method and command fields use `*` as wildcard for "any". Path patterns use `**` for "match everything" and standard glob patterns with `/` as a delimiter otherwise. Query matching is case-sensitive and evaluates decoded values; when duplicate keys are present in the request, every value for that key must match the configured matcher. See `sandbox-policy.rego` -- `method_matches()`, `path_matches()`, `command_matches()`, `query_params_match()`.
536+
GraphQL inspection supports `GET` and `POST` GraphQL-over-HTTP envelopes, JSON batches, named-operation selection, fragments at the operation root, Apollo persisted-query hashes, and service-specific saved-query IDs (`id`, `documentId`, or `queryId`). Hash-only or saved-query-only requests have no parseable document, so they are denied unless `persisted_queries: allow_registered` is set and the hash or ID appears in `graphql_persisted_queries`. If a batch contains any denied, malformed, or unregistered operation, the whole request is denied.
522537

523538
#### Access Presets
524539

525540
The `access` field provides shorthand for common rule sets. During preprocessing, presets are expanded into explicit `rules` arrays before Rego evaluation.
526541

527-
| Preset | Expands To | Description |
528-
| ------------ | ------------------------------------------------------------------ | ---------------------------------------- |
529-
| `read-only` | `GET/**`, `HEAD/**`, `OPTIONS/**` | Safe read-only HTTP methods on all paths |
530-
| `read-write` | `GET/**`, `HEAD/**`, `OPTIONS/**`, `POST/**`, `PUT/**`, `PATCH/**` | Read and write but not delete |
531-
| `full` | `*/**` | All methods, all paths |
542+
| Preset | REST expansion | GraphQL expansion | Description |
543+
| ------------ | ------------------------------------------------------------------ | ------------------------------ | ---------------------------------------- |
544+
| `read-only` | `GET/**`, `HEAD/**`, `OPTIONS/**` | `operation_type: query` | Safe read-only access |
545+
| `read-write` | `GET/**`, `HEAD/**`, `OPTIONS/**`, `POST/**`, `PUT/**`, `PATCH/**` | `query`, `mutation` | Read and write but not delete for REST |
546+
| `full` | `*/**` | `operation_type: "*"` | All supported actions |
532547
533548
See `crates/openshell-sandbox/src/l7/mod.rs` -- `expand_access_presets()`.
534549

@@ -719,9 +734,10 @@ flowchart LR
719734
| -------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
720735
| `protocol` absent or empty | **L4 (transport)** | The proxy performs a raw `copy_bidirectional` after the CONNECT handshake. No application-layer inspection occurs. Only the host:port and binary identity are checked. |
721736
| `protocol: rest` | **L7 (application)** | The proxy parses each HTTP/1.1 request within the tunnel, evaluates method+path against the endpoint's `rules`, and either forwards or denies each request individually. |
737+
| `protocol: graphql` | **L7 (application)** | The proxy parses GraphQL-over-HTTP requests, classifies operation type, operation name, root fields, and persisted-query identifiers, then evaluates GraphQL allow and deny rules. |
722738
| `protocol: sql` | **L7 (application, audit-only)** | Reserved for SQL protocol inspection. Currently falls through to passthrough with a warning. `enforcement: enforce` is rejected at validation time for SQL endpoints. |
723739

724-
This is the single most important behavioral trigger in the policy language. An endpoint with no `protocol` field passes traffic opaquely after the L4 (CONNECT) check. Adding `protocol: rest` activates per-request HTTP parsing and policy evaluation inside the proxy.
740+
This is the single most important behavioral trigger in the policy language. An endpoint with no `protocol` field passes traffic opaquely after the L4 (CONNECT) check. Adding `protocol: rest` or `protocol: graphql` activates per-request HTTP parsing and policy evaluation inside the proxy.
725741

726742
**Implementation path**: After L4 CONNECT is allowed, the proxy calls `query_l7_config()` which evaluates the Rego rule `data.openshell.sandbox.matched_endpoint_config`. This rule only matches endpoints that have a `protocol` field set (see `sandbox-policy.rego` line `ep.protocol`). If a config is returned, the proxy enters `relay_with_inspection()` instead of `copy_bidirectional()`. See `crates/openshell-sandbox/src/proxy.rs` -- `handle_tcp_connection()`.
727743

@@ -1467,10 +1483,10 @@ Evaluated on every CONNECT request and every forward proxy request. The same OPA
14671483

14681484
### L7 Rules (per-request within tunnel)
14691485

1470-
| Rule | Signature | Returns |
1471-
| --------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------- |
1472-
| `allow_request` | `input.network.*`, `input.exec.*`, `input.request.method`, `input.request.path` | `true` if the request matches any rule in the matched endpoint |
1473-
| `request_deny_reason` | Same input | Human-readable deny message |
1486+
| Rule | Signature | Returns |
1487+
| --------------------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- |
1488+
| `allow_request` | `input.network.*`, `input.exec.*`, `input.request.method`, `input.request.path`, optional `request.graphql` | `true` if the request matches the matched endpoint's L7 rules |
1489+
| `request_deny_reason` | Same input | Human-readable deny message |
14741490

14751491
See `sandbox-policy.rego` for the full Rego implementation.
14761492

‎crates/openshell-cli/src/policy_update.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,9 @@ fn group_allow_rules(specs: &[String]) -> Result<BTreeMap<(String, u32), Vec<L7R
168168
path: parsed.path,
169169
command: String::new(),
170170
query: HashMap::default(),
171+
operation_type: String::new(),
172+
operation_name: String::new(),
173+
fields: Vec::new(),
171174
}),
172175
});
173176
}
@@ -186,6 +189,9 @@ fn group_deny_rules(specs: &[String]) -> Result<BTreeMap<(String, u32), Vec<L7De
186189
path: parsed.path,
187190
command: String::new(),
188191
query: HashMap::default(),
192+
operation_type: String::new(),
193+
operation_name: String::new(),
194+
fields: Vec::new(),
189195
});
190196
}
191197
Ok(grouped)

0 commit comments

Comments
 (0)