diff --git a/AGENTS.md b/AGENTS.md index 53b4e80496..f8c60eeb4d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -41,7 +41,15 @@ These pipelines connect skills into end-to-end workflows. Individual skill files | `crates/openshell-tui/` | Terminal UI | Ratatui-based dashboard for monitoring | | `crates/openshell-driver-kubernetes/` | Kubernetes compute driver | In-process `ComputeDriver` backend for K8s sandbox pods | | `crates/openshell-driver-docker/` | Docker compute driver | In-process `ComputeDriver` backend for local Docker sandbox containers | +| `crates/openshell-driver-podman/` | Podman compute driver | In-process `ComputeDriver` backend for local Podman sandbox containers | | `crates/openshell-driver-vm/` | VM compute driver | Standalone libkrun-backed `ComputeDriver` subprocess (embeds its own rootfs + runtime) | +| `crates/openshell-prover/` | Policy prover | Policy verification and proof generation | +| `crates/openshell-server-macros/` | Server macros | Compile-time helpers for gateway RPC authorization | +| `crates/openshell-supervisor-middleware/` | Middleware runtime | Generic middleware registry, remote service integration, and chain execution | +| `crates/openshell-supervisor-middleware-builtins/` | Built-in middleware | First-party in-process middleware implementations | +| `crates/openshell-supervisor-network/` | Network supervisor | Proxying, L7 enforcement, policy evaluation, and inference routing | +| `crates/openshell-supervisor-process/` | Process supervisor | Process lifecycle, namespace, and bypass monitoring | +| `crates/openshell-vfio/` | VFIO support | PCI and GPU passthrough preparation and lifecycle | | `python/openshell/` | Python SDK | Python bindings and CLI packaging | | `proto/` | Protobuf definitions | gRPC service contracts | | `deploy/` | Docker, Helm, K8s | Dockerfiles, Helm chart, manifests | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b78f36522f..8ecf52ff81 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -75,6 +75,7 @@ Skills live in `.agents/skills/`. Your agent's harness can discover and load the | Contributing | `create-github-pr` | Create pull requests with proper conventions | | Reviewing | `review-github-pr` | Summarize PR diffs and key design decisions | | Reviewing | `review-security-issue` | Assess security issues for severity and remediation | +| Reviewing | `fix-security-issue` | Implement an approved security remediation plan | | Reviewing | `watch-github-actions` | Monitor CI pipeline status and logs | | Reviewing | `test-release-canary` | Dispatch and iterate on the Release Canary workflow that smoke-tests published artifacts | | Triage | `triage-issue` | Assess, classify, and route community-filed issues | diff --git a/Cargo.lock b/Cargo.lock index 76dedf0625..f9105b9049 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3630,6 +3630,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "glob", "ipnet", "miette", "prost", @@ -3771,6 +3772,7 @@ version = "0.0.0" dependencies = [ "miette", "openshell-core", + "prost-types", "serde", "serde_json", "serde_yml", @@ -3831,9 +3833,12 @@ dependencies = [ "openshell-core", "openshell-ocsf", "openshell-policy", + "openshell-supervisor-middleware", + "openshell-supervisor-middleware-builtins", "openshell-supervisor-network", "openshell-supervisor-process", "prost", + "prost-types", "rustls", "serde", "serde_json", @@ -3887,6 +3892,8 @@ dependencies = [ "openshell-providers", "openshell-router", "openshell-server-macros", + "openshell-supervisor-middleware", + "openshell-supervisor-middleware-builtins", "petname", "pin-project-lite", "prost", @@ -3929,6 +3936,33 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "openshell-supervisor-middleware" +version = "0.0.0" +dependencies = [ + "miette", + "openshell-core", + "openshell-supervisor-middleware-builtins", + "prost-types", + "tokio", + "tokio-stream", + "tonic", +] + +[[package]] +name = "openshell-supervisor-middleware-builtins" +version = "0.0.0" +dependencies = [ + "miette", + "openshell-core", + "prost-types", + "regex", + "serde", + "serde_json", + "tokio", + "tonic", +] + [[package]] name = "openshell-supervisor-network" version = "0.0.0" @@ -3951,6 +3985,9 @@ dependencies = [ "openshell-ocsf", "openshell-policy", "openshell-router", + "openshell-supervisor-middleware", + "openshell-supervisor-middleware-builtins", + "prost-types", "rcgen", "regorus", "reqwest 0.12.28", @@ -3968,8 +4005,10 @@ dependencies = [ "tokio", "tokio-rustls", "tokio-tungstenite 0.26.2", + "tonic", "tower-mcp-types", "tracing", + "tracing-subscriber", "uuid", "webpki-roots 1.0.7", ] diff --git a/Cargo.toml b/Cargo.toml index f450cd5c8c..fd3641d681 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,7 @@ serde_yml = "0.0.12" toml = "0.8" apollo-parser = "0.8.5" tower-mcp-types = "0.12.0" +regex = "1" # HTTP client reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls-native-roots"] } diff --git a/architecture/gateway.md b/architecture/gateway.md index d873b2a105..ba8437b7f3 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -271,6 +271,14 @@ config path. A gateway-global policy can override sandbox-scoped policy. The sandbox supervisor polls for config revisions and hot-reloads dynamic policy when the policy engine accepts the update. +External supervisor middleware registration is operator-owned gateway +configuration. At startup the gateway connects to each service, validates its +described bindings and operator body limit, and rejects duplicate binding IDs. +Before persisting a policy, the gateway asks each selected implementation to +validate its config. The effective sandbox config contains only the registered +services required by that policy; supervisors invoke those services directly on +the request path. + Provider credential expiry is enforced during gateway-to-sandbox credential resolution and again by the sandbox placeholder resolver. This keeps expired credentials from resolving even when a running sandbox still has retained diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 30d1bf4783..f5c760aba7 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -66,6 +66,51 @@ matchers; generic JSON-RPC rules match only the method. JSON-RPC responses and server-to-client MCP messages on response or SSE streams are relayed but are not currently parsed for policy enforcement. +For admitted HTTP requests, the proxy can run an ordered supervisor middleware +chain before credential injection. Host selectors choose the chain independently +of the network rule that admitted the request. Policy entries use integer order +values with stable name tie-breaking, and the gRPC contract represents operations +and phases as enums. Built-ins run in-process; +operator-registered services are called directly from the supervisor +over the common middleware gRPC contract. The gateway validates external +service capabilities and implementation-owned config before delivery. +For a gateway policy snapshot, the supervisor prepares replacements off to the +side and installs them as one runtime generation. A policy-only change swaps +the policy engine alone and reuses the connected registry, so middleware +reachability cannot block it. The registry is rebuilt, reconnecting every +delivered service, only when the delivered service set changes or the installed +registry is degraded; that rebuild commits together with the policy engine. +A failure preserves the complete last-known-good runtime and leaves its applied +hash and registrations unchanged so the snapshot is retried. The generic +registry and chain runner live in +`openshell-supervisor-middleware`; +first-party implementations and their config schemas live in +`openshell-supervisor-middleware-builtins`. The gateway and supervisor inject +those services explicitly and discover their binding IDs through the same +`Describe` contract used by external services. Reusable compiled DNS host +patterns and selectors remain in `openshell-core::host_pattern`. + +`openshell-policy` validates policy-owned middleware structure without knowing +which implementations are installed. The active middleware registry validates +implementation-owned config before gateway admission. The supervisor's +local-file path supplies its built-in catalog to the same JSON projection so it +retains early config validation. Rego exposes the middleware list as policy +data, but Rust performs selector validation, overlap detection, matching, chain +ordering, implementation discovery, and config validation. + +The Rust HTTP pipeline makes transformed-body handling explicit for every +middleware invocation: body-independent protocols select a no-recheck mode, +while GraphQL, JSON-RPC, and MCP carry a policy evaluator bound to the captured +policy generation. Each replacement is reclassified and evaluated before the +next stage runs. Hard-deny classification is shared by CONNECT relays, +route-selected relays, and forward proxying; evaluator failures become +structured fail-closed outcomes instead of escaping the middleware pipeline. +Each middleware stage can also return ordered header write and remove +operations. Rust validates and applies a stage atomically to the logical header +state before the next stage runs, then replays the validated operations against +the raw request before credential injection. Credential, routing, framing, and +hop-by-hop headers remain supervisor-owned and cannot be mutated. + `https://inference.local` is special. It bypasses OPA network policy and is handled by the inference interception path: @@ -206,8 +251,10 @@ engine with a gateway policy revision. ## Failure Behavior - If gateway config polling fails, the sandbox keeps its last-known-good policy. -- If a live policy update is invalid, the supervisor rejects it and keeps the - current policy. +- If a live policy or middleware-registry update is invalid, the supervisor + rejects the combined update and keeps the current runtime pair. +- If an operator-run middleware call fails, the selected config's `on_error` + behavior decides whether to deny the request or continue without that stage. - Existing raw byte streams are connection scoped. Dynamic policy changes apply to new connections or the next parsed HTTP request where the proxy can safely re-evaluate. diff --git a/crates/openshell-cli/src/run.rs b/crates/openshell-cli/src/run.rs index 0182e1b668..4bc544cf11 100644 --- a/crates/openshell-cli/src/run.rs +++ b/crates/openshell-cli/src/run.rs @@ -1648,39 +1648,9 @@ fn parse_driver_config_json(value: &str) -> Result { )); }; - Ok(prost_types::Struct { - fields: fields - .into_iter() - .map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value))) - .collect::>()?, - }) -} - -fn json_to_protobuf_value(value: serde_json::Value) -> Result { - use prost_types::{ListValue, Struct, Value, value::Kind}; - - let kind = match value { - serde_json::Value::Null => Kind::NullValue(0), - serde_json::Value::Bool(value) => Kind::BoolValue(value), - serde_json::Value::Number(value) => Kind::NumberValue(value.as_f64().ok_or_else(|| { - miette!("--driver-config-json contains a number that cannot be represented") - })?), - serde_json::Value::String(value) => Kind::StringValue(value), - serde_json::Value::Array(values) => Kind::ListValue(ListValue { - values: values - .into_iter() - .map(json_to_protobuf_value) - .collect::>()?, - }), - serde_json::Value::Object(fields) => Kind::StructValue(Struct { - fields: fields - .into_iter() - .map(|(key, value)| json_to_protobuf_value(value).map(|value| (key, value))) - .collect::>()?, - }), - }; - - Ok(Value { kind: Some(kind) }) + openshell_core::proto_struct::json_object_to_struct(fields) + .into_diagnostic() + .wrap_err("--driver-config-json contains a value that cannot be represented") } fn validate_cpu_quantity(value: &str) -> Result { diff --git a/crates/openshell-core/Cargo.toml b/crates/openshell-core/Cargo.toml index 0ff6d06d6c..53735a6bb8 100644 --- a/crates/openshell-core/Cargo.toml +++ b/crates/openshell-core/Cargo.toml @@ -11,6 +11,7 @@ license.workspace = true repository.workspace = true [dependencies] +glob = { workspace = true } prost = { workspace = true } prost-types = { workspace = true } tonic = { workspace = true, features = ["channel", "tls-native-roots"] } diff --git a/crates/openshell-core/README.md b/crates/openshell-core/README.md index 51da847b82..b4cdb05f5d 100644 --- a/crates/openshell-core/README.md +++ b/crates/openshell-core/README.md @@ -50,3 +50,17 @@ router agree on provider defaults. Profiles define: Do not duplicate provider-specific inference behavior in callers. Add shared behavior here, then consume it from the gateway, sandbox, and router. + +## Middleware Contracts + +Built-in supervisor middleware identifiers, host-selector matching, and pure +configuration validation live in `openshell_core::middleware`. Policy admission +and the supervisor runtime consume the same contract without introducing a +dependency from the policy crate to the supervisor implementation. + +## Protobuf Struct Conversion + +Use `openshell_core::proto_struct` when crossing between `serde_json` values and +`prost_types::{Struct, Value}`. Both conversion directions live in this module; +JSON-to-protobuf conversion is fallible so callers cannot silently replace an +unrepresentable number. diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 92eab00408..df559f2754 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -582,6 +582,8 @@ pub async fn fetch_policy(endpoint: &str, sandbox_id: &str) -> Result, } fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult { @@ -762,6 +765,7 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin settings: inner.settings, global_policy_version: inner.global_policy_version, provider_env_revision: inner.provider_env_revision, + supervisor_middleware_services: inner.supervisor_middleware_services, } } diff --git a/crates/openshell-core/src/host_pattern.rs b/crates/openshell-core/src/host_pattern.rs new file mode 100644 index 0000000000..f5d9a099a0 --- /dev/null +++ b/crates/openshell-core/src/host_pattern.rs @@ -0,0 +1,348 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! DNS-label-aware host patterns and selectors. + +use std::collections::{HashSet, VecDeque}; + +/// A validated, compiled DNS host pattern. +/// +/// Matching is case-insensitive. A `*` wildcard stays within one DNS label, +/// while a label consisting only of `**` consumes one or more labels. These +/// semantics mirror the Rego endpoint glob matching used for network policy +/// admission (`glob.match` with a `.` delimiter), so a pattern copied from a +/// network endpoint selects exactly the hosts that endpoint admits. +#[derive(Clone)] +pub struct HostPattern { + source: String, + labels: Vec, +} + +#[derive(Clone)] +enum HostLabelPattern { + Recursive, + Label { + source: String, + pattern: glob::Pattern, + literal: bool, + }, +} + +/// A validated destination host selector. +/// +/// Includes are evaluated first and exclusions take precedence. Constructing +/// the selector compiles every pattern once, so request-time selection cannot +/// fail due to malformed input. +#[derive(Clone)] +pub struct HostSelector { + includes: Vec, + excludes: Vec, +} + +impl HostSelector { + pub fn new(include: &[String], exclude: &[String]) -> Result { + if include.is_empty() { + return Err("endpoint selector must include at least one host pattern".to_string()); + } + Ok(Self { + includes: include + .iter() + .map(|pattern| HostPattern::new(pattern)) + .collect::>()?, + excludes: exclude + .iter() + .map(|pattern| HostPattern::new(pattern)) + .collect::>()?, + }) + } + + #[must_use] + pub fn matches(&self, host: &str) -> bool { + self.includes.iter().any(|pattern| pattern.matches(host)) + && !self.excludes.iter().any(|pattern| pattern.matches(host)) + } + + /// Conservatively determine whether this selector can match a concrete + /// host admitted by `candidate`. + #[must_use] + pub fn may_match_pattern(&self, candidate: &HostPattern) -> bool { + self.includes.iter().any(|include| { + if !include.overlaps(candidate) { + return false; + } + + let concrete_intersection = candidate.literal().or_else(|| include.literal()); + let excluded = concrete_intersection.map_or_else( + || { + self.excludes.iter().any(|excluded| { + excluded.is_universal() + || excluded.source == include.source + || excluded.source == candidate.source + }) + }, + |host| self.excludes.iter().any(|excluded| excluded.matches(host)), + ); + !excluded + }) + } +} + +impl HostPattern { + pub fn new(pattern: &str) -> Result { + if pattern.is_empty() { + return Err("host pattern must not be empty".to_string()); + } + if pattern.chars().any(char::is_whitespace) { + return Err("host pattern must not contain whitespace".to_string()); + } + // The glob crate treats braces as literal characters while endpoint + // glob matching expands them, so a brace pattern would silently never + // match a real host. Fail loud instead. + if pattern.chars().any(|ch| matches!(ch, '{' | '}')) { + return Err( + "host pattern must not contain brace alternates; list each host pattern separately" + .to_string(), + ); + } + + let source = pattern.to_ascii_lowercase(); + if source.split('.').any(str::is_empty) { + return Err("host pattern must not contain empty DNS labels".to_string()); + } + let labels = source + .split('.') + .map(|label| { + if label == "**" { + return Ok(HostLabelPattern::Recursive); + } + let pattern = glob::Pattern::new(label) + .map_err(|error| format!("invalid host pattern: {error}"))?; + Ok(HostLabelPattern::Label { + source: label.to_string(), + pattern, + literal: !label.chars().any(|ch| matches!(ch, '*' | '?' | '[')), + }) + }) + .collect::, String>>()?; + Ok(Self { source, labels }) + } + + #[must_use] + pub fn matches(&self, host: &str) -> bool { + let host = host.to_ascii_lowercase(); + if host.split('.').any(str::is_empty) { + return false; + } + self.matches_labels(&host.split('.').collect::>()) + } + + fn matches_labels(&self, host: &[&str]) -> bool { + let mut pending = vec![(0, 0)]; + let mut visited = HashSet::new(); + while let Some((pattern_idx, host_idx)) = pending.pop() { + if !visited.insert((pattern_idx, host_idx)) { + continue; + } + if pattern_idx == self.labels.len() && host_idx == host.len() { + return true; + } + match self.labels.get(pattern_idx) { + Some(HostLabelPattern::Recursive) if host_idx < host.len() => { + pending.push((pattern_idx + 1, host_idx + 1)); + pending.push((pattern_idx, host_idx + 1)); + } + Some(HostLabelPattern::Label { pattern, .. }) if host_idx < host.len() => { + if pattern.matches(host[host_idx]) { + pending.push((pattern_idx + 1, host_idx + 1)); + } + } + Some(_) | None => {} + } + } + false + } + + fn literal(&self) -> Option<&str> { + self.labels + .iter() + .all(|label| matches!(label, HostLabelPattern::Label { literal: true, .. })) + .then_some(self.source.as_str()) + } + + fn is_universal(&self) -> bool { + matches!(self.labels.as_slice(), [HostLabelPattern::Recursive]) + } + + fn overlaps(&self, other: &Self) -> bool { + let mut pending = VecDeque::from([(0, 0)]); + let mut visited = HashSet::new(); + while let Some((left_idx, right_idx)) = pending.pop_front() { + if !visited.insert((left_idx, right_idx)) { + continue; + } + if left_idx == self.labels.len() && right_idx == other.labels.len() { + return true; + } + + let (Some(left), Some(right)) = + (self.labels.get(left_idx), other.labels.get(right_idx)) + else { + continue; + }; + + // Every transition consumes exactly one shared host label, so a + // `**` on either side must consume at least one label before it + // can complete — the same minimum the matcher enforces. + match (left, right) { + (HostLabelPattern::Recursive, HostLabelPattern::Recursive) => { + pending.push_back((left_idx + 1, right_idx + 1)); + pending.push_back((left_idx, right_idx + 1)); + pending.push_back((left_idx + 1, right_idx)); + } + (HostLabelPattern::Recursive, HostLabelPattern::Label { .. }) => { + pending.push_back((left_idx + 1, right_idx + 1)); + pending.push_back((left_idx, right_idx + 1)); + } + (HostLabelPattern::Label { .. }, HostLabelPattern::Recursive) => { + pending.push_back((left_idx + 1, right_idx + 1)); + pending.push_back((left_idx + 1, right_idx)); + } + (left, right) if label_patterns_may_overlap(left, right) => { + pending.push_back((left_idx + 1, right_idx + 1)); + } + _ => {} + } + } + false + } +} + +/// Match a host using DNS-label-aware glob semantics. +/// +/// Matching is case-insensitive. `*` cannot cross a `.` label boundary, while +/// a label consisting of `**` consumes one or more labels. Invalid or empty +/// patterns return an error instead of silently becoming a non-match. +pub fn host_matches(pattern: &str, host: &str) -> Result { + Ok(HostPattern::new(pattern)?.matches(host)) +} + +/// Conservatively determine whether two DNS host patterns can match the same +/// concrete host. +pub fn host_patterns_overlap(left: &str, right: &str) -> Result { + let left = HostPattern::new(left)?; + let right = HostPattern::new(right)?; + Ok(left.overlaps(&right)) +} + +/// Determine whether a selector can match any concrete host admitted by a host +/// pattern. +/// +/// This is conservative for intersections between two non-literal globs: +/// exclusions suppress a conflict only when they cover a known concrete +/// intersection or exactly cover one of the intersecting patterns. +pub fn selector_may_match_pattern( + include: &[String], + exclude: &[String], + candidate: &str, +) -> Result { + let selector = HostSelector::new(include, exclude)?; + let candidate = HostPattern::new(candidate)?; + Ok(selector.may_match_pattern(&candidate)) +} + +fn label_patterns_may_overlap(left: &HostLabelPattern, right: &HostLabelPattern) -> bool { + let ( + HostLabelPattern::Label { + source: left_source, + pattern: left_pattern, + literal: left_literal, + }, + HostLabelPattern::Label { + source: right_source, + pattern: right_pattern, + literal: right_literal, + }, + ) = (left, right) + else { + return true; + }; + + match (*left_literal, *right_literal) { + (true, true) => left_source == right_source, + (true, false) => right_pattern.matches(left_source), + (false, true) => left_pattern.matches(right_source), + (false, false) => true, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_matching_is_case_insensitive() { + assert!(host_matches("*.Example.COM", "API.example.com").unwrap()); + assert!(!host_matches("*.example.com", "example.com").unwrap()); + assert!(!host_matches("*.example.com", "deep.api.example.com").unwrap()); + assert!(host_matches("**.example.com", "deep.api.example.com").unwrap()); + assert!(host_matches("*-api.example.com", "tenant-api.example.com").unwrap()); + assert!(!host_matches("*", "deep.api.example.com").unwrap()); + } + + #[test] + fn host_matching_rejects_invalid_patterns() { + assert!(host_matches("", "api.example.com").is_err()); + assert!(host_matches("api .example.com", "api.example.com").is_err()); + assert!(host_matches("api[.example.com", "api.example.com").is_err()); + assert!(host_matches("*.{prod,staging}.example.com", "api.prod.example.com").is_err()); + assert!(host_matches("api**.example.com", "apix.example.com").is_err()); + } + + #[test] + fn recursive_wildcard_requires_at_least_one_label() { + assert!(host_matches("**.example.com", "api.example.com").unwrap()); + assert!(!host_matches("**.example.com", "example.com").unwrap()); + assert!(host_matches("api.**.com", "api.x.y.com").unwrap()); + assert!(!host_matches("api.**.com", "api.com").unwrap()); + } + + #[test] + fn universal_wildcard_matches_any_host() { + for host in [ + "com", + "localhost", + "example.com", + "deep.api.example.com", + "xn--bcher-kva.example", + "tenant-api.internal", + "192.168.1.1", + "[::1]", + ] { + assert!(host_matches("**", host).unwrap(), "** must match {host}"); + } + + // Hosts with empty labels are invalid and never match, even for `**`. + assert!(!host_matches("**", "").unwrap()); + assert!(!host_matches("**", "api.example.com.").unwrap()); + assert!(!host_matches("**", "api..example.com").unwrap()); + } + + #[test] + fn host_pattern_overlap_handles_concrete_and_wildcard_hosts() { + assert!(host_patterns_overlap("*.example.com", "api.example.com").unwrap()); + assert!(host_patterns_overlap("**.example.com", "deep.api.example.com").unwrap()); + assert!(!host_patterns_overlap("*.example.com", "deep.api.example.com").unwrap()); + assert!(!host_patterns_overlap("*.example.com", "*.other.com").unwrap()); + assert!(host_patterns_overlap("**.example.com", "*.api.example.com").unwrap()); + assert!(!host_patterns_overlap("**.example.com", "example.com").unwrap()); + } + + #[test] + fn selector_pattern_overlap_honors_concrete_exclusions() { + let include = vec!["*.example.com".to_string()]; + let exclude = vec!["api.example.com".to_string()]; + + assert!(!selector_may_match_pattern(&include, &exclude, "api.example.com").unwrap()); + assert!(selector_may_match_pattern(&include, &exclude, "*.example.com").unwrap()); + } +} diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 3212963691..a2be6daa7d 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod forward; pub mod google_cloud; pub mod gpu; pub mod grpc_client; +pub mod host_pattern; pub mod image; pub mod inference; pub mod metadata; diff --git a/crates/openshell-core/src/proto/mod.rs b/crates/openshell-core/src/proto/mod.rs index 08b062d2e5..1ac6fc94c4 100644 --- a/crates/openshell-core/src/proto/mod.rs +++ b/crates/openshell-core/src/proto/mod.rs @@ -79,8 +79,22 @@ pub mod inference { } } +#[allow( + clippy::all, + clippy::pedantic, + clippy::nursery, + unused_qualifications, + rust_2018_idioms +)] +pub mod middleware { + pub mod v1 { + include!(concat!(env!("OUT_DIR"), "/openshell.middleware.v1.rs")); + } +} + pub use datamodel::v1::*; pub use inference::v1::*; +pub use middleware::v1::*; pub use openshell::*; pub use sandbox::v1::*; pub use test::ObjectForTest; diff --git a/crates/openshell-core/src/proto_struct.rs b/crates/openshell-core/src/proto_struct.rs index 8871d6f6a7..ce95c65f0c 100644 --- a/crates/openshell-core/src/proto_struct.rs +++ b/crates/openshell-core/src/proto_struct.rs @@ -1,10 +1,74 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Helpers for decoding `google.protobuf.Struct` values. +//! Helpers for converting `google.protobuf.Struct` values to and from JSON. use serde::{Deserialize, Deserializer, de::Error as _}; +/// Errors converting JSON values into protobuf well-known types. +#[derive(Debug, thiserror::Error)] +pub enum ProtoStructError { + /// A JSON number cannot be represented exactly by protobuf's double value. + #[error("JSON number {0} cannot be represented exactly as a protobuf double")] + UnrepresentableNumber(serde_json::Number), +} + +/// Convert a JSON object into a protobuf Struct. +pub fn json_object_to_struct( + object: serde_json::Map, +) -> Result { + Ok(prost_types::Struct { + fields: object + .into_iter() + .map(|(key, value)| json_value_to_proto(value).map(|value| (key, value))) + .collect::>()?, + }) +} + +/// Convert a JSON value into a protobuf Value. +pub fn json_value_to_proto( + value: serde_json::Value, +) -> Result { + use prost_types::{ListValue, Value, value::Kind}; + + let kind = match value { + serde_json::Value::Null => Kind::NullValue(0), + serde_json::Value::Bool(value) => Kind::BoolValue(value), + serde_json::Value::Number(value) => Kind::NumberValue(number_to_f64_exact(&value)?), + serde_json::Value::String(value) => Kind::StringValue(value), + serde_json::Value::Array(values) => Kind::ListValue(ListValue { + values: values + .into_iter() + .map(json_value_to_proto) + .collect::>()?, + }), + serde_json::Value::Object(object) => Kind::StructValue(json_object_to_struct(object)?), + }; + + Ok(Value { kind: Some(kind) }) +} + +fn number_to_f64_exact(value: &serde_json::Number) -> Result { + let number = value + .as_f64() + .ok_or_else(|| ProtoStructError::UnrepresentableNumber(value.clone()))?; + + let exact = value.as_i64().map_or_else( + || value.as_u64().is_none_or(integer_is_exact_in_f64), + |integer| integer_is_exact_in_f64(integer.unsigned_abs()), + ); + + exact + .then_some(number) + .ok_or_else(|| ProtoStructError::UnrepresentableNumber(value.clone())) +} + +fn integer_is_exact_in_f64(integer: u64) -> bool { + integer == 0 + || (u64::BITS - integer.leading_zeros()).saturating_sub(integer.trailing_zeros()) + <= f64::MANTISSA_DIGITS +} + /// Convert a protobuf Struct into a JSON object for typed serde decoding. #[must_use] pub fn struct_to_json_object( @@ -72,6 +136,47 @@ where mod tests { use super::*; + #[test] + fn json_and_proto_values_round_trip() { + let json = serde_json::json!({ + "null": null, + "bool": true, + "number": 42.5, + "string": "value", + "list": [1.0, {"nested": "value"}], + }); + let serde_json::Value::Object(object) = json.clone() else { + unreachable!(); + }; + + let proto = json_object_to_struct(object).unwrap(); + + assert_eq!(struct_to_json_value(&proto), json); + } + + #[test] + fn rejects_integer_that_cannot_round_trip_through_protobuf_double() { + let value = serde_json::json!(9_007_199_254_740_993_u64); + + let err = json_value_to_proto(value).expect_err("lossy integer must be rejected"); + + assert!(err.to_string().contains("9007199254740993")); + assert!(err.to_string().contains("exactly")); + } + + #[test] + fn accepts_integer_that_round_trips_through_protobuf_double() { + let value = serde_json::json!(9_007_199_254_740_992_u64); + + let proto = json_value_to_proto(value.clone()).expect("integer should be exact"); + + assert_eq!( + value_to_json(&proto).as_f64(), + value.as_f64(), + "protobuf Struct stores all numbers as doubles" + ); + } + #[derive(Debug, Default, Deserialize)] #[serde(default)] struct TestConfig { diff --git a/crates/openshell-driver-docker/src/tests.rs b/crates/openshell-driver-docker/src/tests.rs index 594308bce5..7ca3405251 100644 --- a/crates/openshell-driver-docker/src/tests.rs +++ b/crates/openshell-driver-docker/src/tests.rs @@ -118,40 +118,11 @@ fn runtime_config() -> DockerDriverRuntimeConfig { } fn json_struct(value: serde_json::Value) -> prost_types::Struct { - match json_value(value).kind { - Some(prost_types::value::Kind::StructValue(value)) => value, - _ => panic!("expected JSON object"), - } -} - -fn json_value(value: serde_json::Value) -> prost_types::Value { - match value { - serde_json::Value::Null => prost_types::Value { kind: None }, - serde_json::Value::Bool(value) => prost_types::Value { - kind: Some(prost_types::value::Kind::BoolValue(value)), - }, - serde_json::Value::Number(value) => prost_types::Value { - kind: value.as_f64().map(prost_types::value::Kind::NumberValue), - }, - serde_json::Value::String(value) => prost_types::Value { - kind: Some(prost_types::value::Kind::StringValue(value)), - }, - serde_json::Value::Array(values) => prost_types::Value { - kind: Some(prost_types::value::Kind::ListValue( - prost_types::ListValue { - values: values.into_iter().map(json_value).collect(), - }, - )), - }, - serde_json::Value::Object(values) => prost_types::Value { - kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct { - fields: values - .into_iter() - .map(|(key, value)| (key, json_value(value))) - .collect(), - })), - }, - } + let serde_json::Value::Object(object) = value else { + panic!("expected JSON object"); + }; + openshell_core::proto_struct::json_object_to_struct(object) + .expect("test JSON must convert to a protobuf Struct") } fn inspected_volume(driver: &str, options: HashMap) -> bollard::models::Volume { diff --git a/crates/openshell-driver-kubernetes/src/driver.rs b/crates/openshell-driver-kubernetes/src/driver.rs index 5315d30409..063bdfd45d 100644 --- a/crates/openshell-driver-kubernetes/src/driver.rs +++ b/crates/openshell-driver-kubernetes/src/driver.rs @@ -3010,38 +3010,11 @@ mod tests { std::sync::LazyLock::new(|| std::sync::Mutex::new(())); fn json_struct(value: serde_json::Value) -> Struct { - match json_value(value).kind { - Some(Kind::StructValue(value)) => value, - _ => panic!("expected JSON object"), - } - } - - fn json_value(value: serde_json::Value) -> Value { - match value { - serde_json::Value::Null => Value { kind: None }, - serde_json::Value::Bool(value) => Value { - kind: Some(Kind::BoolValue(value)), - }, - serde_json::Value::Number(value) => Value { - kind: value.as_f64().map(Kind::NumberValue), - }, - serde_json::Value::String(value) => Value { - kind: Some(Kind::StringValue(value)), - }, - serde_json::Value::Array(values) => Value { - kind: Some(Kind::ListValue(prost_types::ListValue { - values: values.into_iter().map(json_value).collect(), - })), - }, - serde_json::Value::Object(values) => Value { - kind: Some(Kind::StructValue(Struct { - fields: values - .into_iter() - .map(|(key, value)| (key, json_value(value))) - .collect(), - })), - }, - } + let serde_json::Value::Object(object) = value else { + panic!("expected JSON object"); + }; + openshell_core::proto_struct::json_object_to_struct(object) + .expect("test JSON must convert to a protobuf Struct") } fn sandbox_to_k8s_spec_for_test( diff --git a/crates/openshell-driver-podman/src/container.rs b/crates/openshell-driver-podman/src/container.rs index e4a0b79189..9ee26c0735 100644 --- a/crates/openshell-driver-podman/src/container.rs +++ b/crates/openshell-driver-podman/src/container.rs @@ -1155,40 +1155,11 @@ mod tests { std::sync::LazyLock::new(|| std::sync::Mutex::new(())); fn json_struct(value: Value) -> prost_types::Struct { - match json_value(value).kind { - Some(prost_types::value::Kind::StructValue(value)) => value, - _ => panic!("expected JSON object"), - } - } - - fn json_value(value: Value) -> prost_types::Value { - match value { - Value::Null => prost_types::Value { kind: None }, - Value::Bool(value) => prost_types::Value { - kind: Some(prost_types::value::Kind::BoolValue(value)), - }, - Value::Number(value) => prost_types::Value { - kind: value.as_f64().map(prost_types::value::Kind::NumberValue), - }, - Value::String(value) => prost_types::Value { - kind: Some(prost_types::value::Kind::StringValue(value)), - }, - Value::Array(values) => prost_types::Value { - kind: Some(prost_types::value::Kind::ListValue( - prost_types::ListValue { - values: values.into_iter().map(json_value).collect(), - }, - )), - }, - Value::Object(values) => prost_types::Value { - kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct { - fields: values - .into_iter() - .map(|(key, value)| (key, json_value(value))) - .collect(), - })), - }, - } + let Value::Object(object) = value else { + panic!("expected JSON object"); + }; + proto_struct::json_object_to_struct(object) + .expect("test JSON must convert to a protobuf Struct") } fn gpu_resources(count: Option) -> ResourceRequirements { diff --git a/crates/openshell-driver-podman/src/driver.rs b/crates/openshell-driver-podman/src/driver.rs index cf639fa265..a86d58dee0 100644 --- a/crates/openshell-driver-podman/src/driver.rs +++ b/crates/openshell-driver-podman/src/driver.rs @@ -1256,41 +1256,12 @@ mod tests { PodmanComputeDriver::for_tests(config) } - fn json_value(value: serde_json::Value) -> prost_types::Value { - match value { - serde_json::Value::Null => prost_types::Value { kind: None }, - serde_json::Value::Bool(value) => prost_types::Value { - kind: Some(prost_types::value::Kind::BoolValue(value)), - }, - serde_json::Value::Number(value) => prost_types::Value { - kind: value.as_f64().map(prost_types::value::Kind::NumberValue), - }, - serde_json::Value::String(value) => prost_types::Value { - kind: Some(prost_types::value::Kind::StringValue(value)), - }, - serde_json::Value::Array(values) => prost_types::Value { - kind: Some(prost_types::value::Kind::ListValue( - prost_types::ListValue { - values: values.into_iter().map(json_value).collect(), - }, - )), - }, - serde_json::Value::Object(values) => prost_types::Value { - kind: Some(prost_types::value::Kind::StructValue(prost_types::Struct { - fields: values - .into_iter() - .map(|(key, value)| (key, json_value(value))) - .collect(), - })), - }, - } - } - fn json_struct(value: serde_json::Value) -> prost_types::Struct { - match json_value(value).kind { - Some(prost_types::value::Kind::StructValue(value)) => value, - _ => panic!("expected JSON object"), - } + let serde_json::Value::Object(object) = value else { + panic!("expected JSON object"); + }; + openshell_core::proto_struct::json_object_to_struct(object) + .expect("test JSON must convert to a protobuf Struct") } fn sandbox_with_volume_mount(volume: &str) -> DriverSandbox { diff --git a/crates/openshell-ocsf/src/builders/finding.rs b/crates/openshell-ocsf/src/builders/finding.rs index 177c9159df..fecb1e9275 100644 --- a/crates/openshell-ocsf/src/builders/finding.rs +++ b/crates/openshell-ocsf/src/builders/finding.rs @@ -25,6 +25,7 @@ pub struct DetectionFindingBuilder<'a> { risk_level: Option, message: Option, log_source: Option, + unmapped: serde_json::Map, } impl<'a> DetectionFindingBuilder<'a> { @@ -45,6 +46,7 @@ impl<'a> DetectionFindingBuilder<'a> { risk_level: None, message: None, log_source: None, + unmapped: serde_json::Map::new(), } } @@ -74,6 +76,13 @@ impl<'a> DetectionFindingBuilder<'a> { self } + /// Add a source-specific attribute that is not defined by the OCSF class. + #[must_use] + pub fn unmapped(mut self, key: &str, value: impl Into) -> Self { + self.unmapped.insert(key.to_string(), value.into()); + self + } + /// Add a remediation description. #[must_use] pub fn remediation(mut self, desc: &str) -> Self { @@ -122,6 +131,9 @@ impl<'a> DetectionFindingBuilder<'a> { self.severity, metadata, ); + if !self.unmapped.is_empty() { + base.unmapped = Some(serde_json::Value::Object(self.unmapped)); + } self.ctx.apply_common_fields(&mut base, None, self.message); OcsfEvent::DetectionFinding(DetectionFindingEvent { @@ -169,6 +181,7 @@ mod tests { .is_alert(true) .confidence(ConfidenceId::High) .risk_level(RiskLevelId::High) + .unmapped("source_rule", "nonce_replay") .finding_info( FindingInfo::new("nssh1-replay-abc", "NSSH1 Nonce Replay Attack") .with_desc("A nonce was replayed."), @@ -188,5 +201,6 @@ mod tests { assert_eq!(json["finding_info"]["title"], "NSSH1 Nonce Replay Attack"); assert_eq!(json["is_alert"], true); assert_eq!(json["confidence"], "High"); + assert_eq!(json["unmapped"]["source_rule"], "nonce_replay"); } } diff --git a/crates/openshell-ocsf/src/builders/http.rs b/crates/openshell-ocsf/src/builders/http.rs index 4fa33eb1ca..ae91cf7c41 100644 --- a/crates/openshell-ocsf/src/builders/http.rs +++ b/crates/openshell-ocsf/src/builders/http.rs @@ -25,6 +25,7 @@ pub struct HttpActivityBuilder<'a> { firewall_rule: Option, message: Option, status_detail: Option, + unmapped: serde_json::Map, } impl<'a> HttpActivityBuilder<'a> { @@ -45,6 +46,7 @@ impl<'a> HttpActivityBuilder<'a> { firewall_rule: None, message: None, status_detail: None, + unmapped: serde_json::Map::new(), } } @@ -69,6 +71,13 @@ impl<'a> HttpActivityBuilder<'a> { self } + /// Add a source-specific attribute that is not defined by the OCSF class. + #[must_use] + pub fn unmapped(mut self, key: &str, value: impl Into) -> Self { + self.unmapped.insert(key.to_string(), value.into()); + self + } + #[must_use] pub fn build(self) -> OcsfEvent { let activity_name = self.activity.http_label().to_string(); @@ -86,6 +95,9 @@ impl<'a> HttpActivityBuilder<'a> { if let Some(detail) = self.status_detail { base.set_status_detail(detail); } + if !self.unmapped.is_empty() { + base.unmapped = Some(serde_json::Value::Object(self.unmapped)); + } self.ctx .apply_common_fields(&mut base, self.status, self.message); @@ -157,11 +169,15 @@ mod tests { .firewall_rule("aws_iam", "ssrf") .message("FORWARD blocked: allowed_ips check failed") .status_detail("resolves to always-blocked address") + .unmapped("attempt", 2) + .unmapped("cached", true) .build(); let json = event.to_json().unwrap(); assert_eq!(json["class_uid"], 4002); assert_eq!(json["status_detail"], "resolves to always-blocked address"); + assert_eq!(json["unmapped"]["attempt"], 2); + assert_eq!(json["unmapped"]["cached"], true); assert_eq!(json["action_id"], 2); // Denied } } diff --git a/crates/openshell-ocsf/src/format/shorthand.rs b/crates/openshell-ocsf/src/format/shorthand.rs index 0cb5d906c3..4dbc4eb321 100644 --- a/crates/openshell-ocsf/src/format/shorthand.rs +++ b/crates/openshell-ocsf/src/format/shorthand.rs @@ -82,22 +82,57 @@ fn truncate_with_ellipsis(text: &str, max: usize) -> String { format!("{}...", &text[..end]) } -/// Format a `[reason:...]` tag from `status_detail` (or `message` fallback) -/// for denied events. Returns an empty string if neither field is set. -fn reason_tag(base: &BaseEventData) -> String { - let text = base - .status_detail - .as_deref() - .or(base.message.as_deref()) - .unwrap_or(""); +fn reason_text(text: Option<&str>) -> Option { + let text = text?; if text.is_empty() { - return String::new(); + return None; } let text = text.replace(['\n', '\r'], " "); - format!( - " [reason:{}]", - truncate_with_ellipsis(&text, MAX_REASON_LEN) - ) + Some(truncate_with_ellipsis(&text, MAX_REASON_LEN)) +} + +/// Format a `[reason:...]` tag from `status_detail` (or `message` fallback) +/// for denied events. Returns an empty string if neither field is set. +fn reason_tag(base: &BaseEventData) -> String { + reason_text(base.status_detail.as_deref().or(base.message.as_deref())) + .map_or_else(String::new, |text| format!(" [reason:{text}]")) +} + +fn unmapped_fields(base: &BaseEventData) -> Vec { + base.unmapped + .as_ref() + .and_then(serde_json::Value::as_object) + .into_iter() + .flatten() + .filter_map(|(key, value)| { + let value = match value { + serde_json::Value::Bool(value) => value.to_string(), + serde_json::Value::Number(value) => value.to_string(), + serde_json::Value::String(value) => { + let value = value.replace(['\n', '\r'], " "); + truncate_with_ellipsis(&value, MAX_REASON_LEN) + } + _ => return None, + }; + Some(format!("{key}:{value}")) + }) + .collect() +} + +fn unmapped_context(base: &BaseEventData, include_reason: bool) -> String { + let mut fields = unmapped_fields(base); + + if include_reason + && let Some(reason) = reason_text(base.status_detail.as_deref().or(base.message.as_deref())) + { + fields.push(format!("reason:{reason}")); + } + + if fields.is_empty() { + String::new() + } else { + format!(" [{}]", fields.join(" ")) + } } fn message_tag(base: &BaseEventData) -> String { @@ -200,12 +235,7 @@ impl OcsfEvent { .as_ref() .map(|r| format!(" [policy:{} engine:{}]", r.name, r.rule_type)) .unwrap_or_default(); - // For denied events, surface the reason from status_detail - let reason_ctx = if action == "DENIED" { - reason_tag(&e.base) - } else { - String::new() - }; + let outcome_ctx = unmapped_context(&e.base, action == "DENIED"); let arrow = if actor_str.is_empty() { format!(" {method} {url_str}") } else { @@ -218,7 +248,7 @@ impl OcsfEvent { (false, true) => format!(" {action}"), (false, false) => format!(" {action}{arrow}"), }; - // Denied HTTP events surface their message through reason_ctx. + // Denied HTTP events surface their message through outcome_ctx. // Allowed MCP decisions also need the JSON-RPC message so the // selected tool name is visible in the shorthand log stream. let message_ctx = if action != "DENIED" @@ -230,7 +260,7 @@ impl OcsfEvent { } else { String::new() }; - format!("HTTP:{method} {sev}{detail}{rule_ctx}{reason_ctx}{message_ctx}") + format!("HTTP:{method} {sev}{detail}{rule_ctx}{outcome_ctx}{message_ctx}") } Self::SshActivity(e) => { @@ -292,16 +322,19 @@ impl OcsfEvent { } Self::DetectionFinding(e) => { - let disposition = e - .disposition - .map_or_else(|| "UNKNOWN".to_string(), |d| d.label().to_uppercase()); + let disposition = e.disposition.map_or_else( + || e.base.activity_name.to_uppercase(), + |d| d.label().to_uppercase(), + ); let title = &e.finding_info.title; - let confidence_ctx = e - .confidence - .map(|c| format!(" [confidence:{}]", c.label().to_lowercase())) - .unwrap_or_default(); - - format!("FINDING:{disposition} {sev} \"{title}\"{confidence_ctx}") + let mut context = vec![format!("type:{}", e.finding_info.uid)]; + context.extend(unmapped_fields(&e.base)); + if let Some(confidence) = e.confidence { + context.push(format!("confidence:{}", confidence.label().to_lowercase())); + } + let context = format!(" [{}]", context.join(" ")); + + format!("FINDING:{disposition} {sev} \"{title}\"{context}") } Self::ApplicationLifecycle(e) => { @@ -546,6 +579,36 @@ mod tests { ); } + #[test] + fn test_http_activity_shorthand_includes_unmapped_attributes() { + let mut base = base(4002, "HTTP Activity", 4, "Network Activity", 99, "Other"); + base.add_unmapped("attempt", serde_json::json!(2)); + base.add_unmapped("cached", serde_json::json!(true)); + let event = OcsfEvent::HttpActivity(HttpActivityEvent { + base, + http_request: Some(HttpRequest::new( + "POST", + Url::new("http", "httpbin.org", "/anything", 443), + )), + http_response: None, + src_endpoint: None, + dst_endpoint: None, + proxy_endpoint: None, + actor: None, + firewall_rule: Some(FirewallRule::new("httpbin", "extension")), + action: Some(ActionId::Allowed), + disposition: Some(DispositionId::Allowed), + observation_point_id: None, + is_src_dst_assignment_known: None, + }); + + let shorthand = event.format_shorthand(); + assert_eq!( + shorthand, + "HTTP:POST [INFO] ALLOWED POST http://httpbin.org:443/anything [policy:httpbin engine:extension] [attempt:2 cached:true]" + ); + } + #[test] fn test_http_activity_shorthand_mcp_shows_tool_for_allow_and_deny() { let mut event_base = base(4002, "HTTP Activity", 4, "Network Activity", 0, "Other"); @@ -931,8 +994,37 @@ mod tests { let shorthand = event.format_shorthand(); assert_eq!( shorthand, - "FINDING:BLOCKED [HIGH] \"NSSH1 Nonce Replay Attack\" [confidence:high]" + "FINDING:BLOCKED [HIGH] \"NSSH1 Nonce Replay Attack\" [type:nssh1-replay-abc confidence:high]" + ); + } + + #[test] + fn test_detection_finding_shorthand_uses_activity_and_safe_unmapped_attributes() { + let mut base = base(2004, "Detection Finding", 2, "Findings", 1, "Create"); + base.add_unmapped("count", serde_json::json!(1)); + base.add_unmapped("source", serde_json::json!("content_guard")); + let event = OcsfEvent::DetectionFinding(DetectionFindingEvent { + base, + finding_info: FindingInfo::new("content_guard.match", "configured content matched"), + evidences: Some(vec![Evidence::from_pairs(&[( + "matched_content", + "must-not-appear", + )])]), + attacks: None, + remediation: None, + is_alert: None, + confidence: None, + risk_level: None, + action: None, + disposition: None, + }); + + let shorthand = event.format_shorthand(); + assert_eq!( + shorthand, + "FINDING:CREATE [INFO] \"configured content matched\" [type:content_guard.match count:1 source:content_guard]" ); + assert!(!shorthand.contains("must-not-appear")); } #[test] diff --git a/crates/openshell-policy/Cargo.toml b/crates/openshell-policy/Cargo.toml index 16719de13d..b69da8d2b5 100644 --- a/crates/openshell-policy/Cargo.toml +++ b/crates/openshell-policy/Cargo.toml @@ -16,6 +16,7 @@ serde = { workspace = true } serde_json = { workspace = true } serde_yml = { workspace = true } miette = { workspace = true } +prost-types = { workspace = true } [lints] workspace = true diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index f1721146ed..29c7c7a0aa 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -11,6 +11,7 @@ mod compose; mod merge; +mod middleware; use std::collections::{BTreeMap, HashMap}; use std::fmt; @@ -32,6 +33,9 @@ pub use merge::{ PolicyMergeError, PolicyMergeOp, PolicyMergeResult, PolicyMergeWarning, generated_rule_name, merge_policy, policy_covers_rule, }; +pub use middleware::middleware_host_matches; +pub use middleware::validate_json as validate_network_middleware_json; +pub use middleware::validate_json_with_config as validate_network_middleware_json_with_config; // --------------------------------------------------------------------------- // YAML serde types (canonical — used for both parsing and serialization) @@ -49,6 +53,8 @@ struct PolicyFile { process: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] network_policies: BTreeMap, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + network_middlewares: Vec, } #[derive(Debug, Serialize, Deserialize)] @@ -671,7 +677,11 @@ fn yaml_mcp_method( method.to_string() } -fn to_proto(raw: PolicyFile) -> SandboxPolicy { +fn to_proto(raw: PolicyFile) -> Result { + let network_middlewares = middleware::into_proto(raw.network_middlewares) + .into_diagnostic() + .wrap_err("failed to convert network middleware config")?; + let network_policies = raw .network_policies .into_iter() @@ -761,7 +771,7 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { }) .collect(); - SandboxPolicy { + Ok(SandboxPolicy { version: raw.version, filesystem: raw.filesystem_policy.map(|fs| FilesystemPolicy { include_workdir: fs.include_workdir, @@ -776,7 +786,8 @@ fn to_proto(raw: PolicyFile) -> SandboxPolicy { run_as_group: p.run_as_group, }), network_policies, - } + network_middlewares, + }) } // --------------------------------------------------------------------------- @@ -908,12 +919,15 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { }) .collect(); + let network_middlewares = middleware::from_proto(&policy.network_middlewares); + PolicyFile { version: policy.version, filesystem_policy, landlock, process, network_policies, + network_middlewares, } } @@ -961,7 +975,7 @@ pub fn parse_sandbox_policy(yaml: &str) -> Result { let raw: PolicyFile = serde_yml::from_str(yaml) .into_diagnostic() .wrap_err("failed to parse sandbox policy YAML")?; - Ok(to_proto(raw)) + to_proto(raw) } /// Serialize a proto sandbox policy to a YAML string. @@ -1064,6 +1078,7 @@ pub fn restrictive_default_policy() -> SandboxPolicy { run_as_group: "sandbox".into(), }), network_policies: HashMap::new(), + network_middlewares: vec![], } } @@ -1119,6 +1134,16 @@ pub enum PolicyViolation { }, /// `credential_signing` and `request_body_credential_rewrite` are both set. CredentialSigningWithBodyRewrite { policy_name: String, host: String }, + /// A middleware configuration is structurally invalid. + InvalidMiddlewareConfig { name: String, reason: String }, + /// Middleware configuration names must be unique. + DuplicateMiddlewareConfigName { name: String }, + /// A middleware selector conflicts with an endpoint that skips TLS inspection. + MiddlewareTlsSkipConflict { + middleware_name: String, + policy_name: String, + host: String, + }, } impl fmt::Display for PolicyViolation { @@ -1183,6 +1208,23 @@ impl fmt::Display for PolicyViolation { and request_body_credential_rewrite set; these options are mutually exclusive" ) } + Self::InvalidMiddlewareConfig { name, reason } => { + write!(f, "middleware config '{name}' is invalid: {reason}") + } + Self::DuplicateMiddlewareConfigName { name } => { + write!(f, "duplicate middleware config '{name}'") + } + Self::MiddlewareTlsSkipConflict { + middleware_name, + policy_name, + host, + } => { + write!( + f, + "middleware config '{middleware_name}' selects network policy \ + '{policy_name}' tls: skip endpoint '{host}'" + ) + } } } } @@ -1201,6 +1243,9 @@ impl fmt::Display for PolicyViolation { /// - Individual path lengths must not exceed [`MAX_PATH_LENGTH`] /// - Total path count must not exceed [`MAX_FILESYSTEM_PATHS`] /// - Network endpoint hosts must not use TLD wildcards (e.g. `*.com`) +/// - Middleware names, implementations, failure modes, selectors, and built-in +/// configurations must be valid +/// - Middleware selectors must not match endpoints that skip TLS inspection pub fn validate_sandbox_policy( policy: &SandboxPolicy, ) -> std::result::Result<(), Vec> { @@ -1315,6 +1360,8 @@ pub fn validate_sandbox_policy( } } + violations.extend(middleware::validate(policy)); + if violations.is_empty() { Ok(()) } else { @@ -1438,6 +1485,72 @@ network_policies: assert_eq!(proto2.network_policies["my_api"].name, "my-custom-api-name"); } + #[test] + fn round_trip_preserves_network_middlewares() { + let yaml = r#" +version: 1 +network_middlewares: + - name: global-redactor + middleware: openshell/secrets + order: 20 + on_error: fail_open + endpoints: + include: ["api.example.com", "*.service.test"] + exclude: ["internal.example.com"] + config: + secrets: ["api_key", "authorization"] + service: + mode: redact + max_matches: 2 + - name: secondary-redactor + middleware: openshell/secrets + endpoints: + include: ["api.example.com"] +network_policies: + api: + name: api + endpoints: + - host: api.example.com + port: 443 + protocol: rest + binaries: + - path: /usr/bin/curl +"#; + let proto = parse_sandbox_policy(yaml).expect("parse failed"); + assert_eq!(proto.network_middlewares.len(), 2); + assert_eq!(proto.network_middlewares[0].name, "global-redactor"); + assert_eq!(proto.network_middlewares[0].middleware, "openshell/secrets"); + assert_eq!(proto.network_middlewares[0].order, 20); + assert_eq!(proto.network_middlewares[0].on_error, "fail_open"); + assert_eq!( + proto.network_middlewares[0] + .endpoints + .as_ref() + .expect("selector") + .include, + vec!["api.example.com", "*.service.test"] + ); + assert_eq!( + proto.network_middlewares[0] + .endpoints + .as_ref() + .expect("selector") + .exclude, + vec!["internal.example.com"] + ); + assert!( + proto.network_middlewares[0] + .config + .as_ref() + .expect("config") + .fields + .contains_key("service") + ); + let yaml_out = serialize_sandbox_policy(&proto).expect("serialize failed"); + let reparsed = parse_sandbox_policy(&yaml_out).expect("re-parse failed"); + assert_eq!(reparsed.network_middlewares, proto.network_middlewares); + } + #[test] fn restrictive_default_has_no_network_policies() { let policy = restrictive_default_policy(); @@ -1568,6 +1681,31 @@ network_policies: assert!(parse_sandbox_policy(yaml).is_err()); } + #[test] + fn parse_rejects_middleware_attachments_on_network_policies_and_endpoints() { + let policy_attachment = r" +version: 1 +network_policies: + api: + middleware: [redact] + endpoints: + - host: api.example.com + port: 443 +"; + assert!(parse_sandbox_policy(policy_attachment).is_err()); + + let endpoint_attachment = r" +version: 1 +network_policies: + api: + endpoints: + - host: api.example.com + port: 443 + middleware: [redact] +"; + assert!(parse_sandbox_policy(endpoint_attachment).is_err()); + } + #[test] fn l7_config_stanza_runtime_fields_use_canonical_schema() { let fields = l7_config_alias_runtime_fields( @@ -1641,6 +1779,62 @@ network_policies: // ---- Policy validation tests ---- + fn middleware_config( + name: &str, + implementation: &str, + ) -> openshell_core::proto::NetworkMiddlewareConfig { + openshell_core::proto::NetworkMiddlewareConfig { + name: name.into(), + middleware: implementation.into(), + order: 0, + config: None, + on_error: String::new(), + endpoints: Some(openshell_core::proto::MiddlewareEndpointSelector { + include: vec!["api.example.com".into()], + exclude: Vec::new(), + }), + } + } + + #[test] + fn structural_validation_defers_implementation_owned_config() { + let mut policy = restrictive_default_policy(); + let mut middleware = middleware_config("future", "openshell/future"); + middleware.config = Some( + openshell_core::proto_struct::json_object_to_struct( + std::iter::once(("implementation_field".into(), serde_json::json!(42))).collect(), + ) + .unwrap(), + ); + policy.network_middlewares.push(middleware); + + validate_sandbox_policy(&policy) + .expect("generic policy validation must not select installed implementations"); + } + + #[test] + fn json_validation_delegates_implementation_owned_config() { + let data = serde_json::json!({ + "network_middlewares": [{ + "name": "future", + "middleware": "openshell/future", + "config": {"implementation_field": 42}, + "endpoints": {"include": ["api.example.com"]} + }] + }); + + let violations = + validate_network_middleware_json_with_config(&data, |implementation, _config| { + Err(format!("{implementation} is not installed")) + }) + .expect("parse middleware policy"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::InvalidMiddlewareConfig { name, reason } + if name == "future" && reason.contains("not installed") + ))); + } + #[test] fn validate_rejects_root_run_as_user() { let mut policy = restrictive_default_policy(); @@ -1669,6 +1863,163 @@ network_policies: assert_eq!(violations.len(), 2); } + #[test] + fn validate_rejects_invalid_middleware_control_fields() { + let cases = [ + ( + middleware_config("", "openshell/secrets"), + "name must not be empty", + ), + ( + middleware_config("redactor", ""), + "implementation must not be empty", + ), + ( + { + let mut middleware = middleware_config("redactor", "openshell/secrets"); + middleware.on_error = "maybe".into(); + middleware + }, + "invalid on_error", + ), + ( + { + let mut middleware = middleware_config("redactor", "openshell/secrets"); + middleware.endpoints = None; + middleware + }, + "endpoint selector is required", + ), + ( + { + let mut middleware = middleware_config("redactor", "openshell/secrets"); + middleware.endpoints.as_mut().unwrap().include.clear(); + middleware + }, + "must include at least one host pattern", + ), + ]; + + for (middleware, expected) in cases { + let mut policy = restrictive_default_policy(); + policy.network_middlewares.push(middleware); + let errors = validate_sandbox_policy(&policy) + .expect_err("invalid middleware must be rejected") + .into_iter() + .map(|violation| violation.to_string()) + .collect::>() + .join("; "); + assert!( + errors.contains(expected), + "expected {expected:?} in {errors:?}" + ); + } + } + + #[test] + fn validate_rejects_duplicate_middleware_config_names() { + let mut policy = restrictive_default_policy(); + policy + .network_middlewares + .push(middleware_config("redactor", "openshell/secrets")); + policy + .network_middlewares + .push(middleware_config("redactor", "openshell/secrets")); + + let violations = validate_sandbox_policy(&policy).expect_err("duplicate name"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::DuplicateMiddlewareConfigName { name } if name == "redactor" + ))); + } + + #[test] + fn validate_rejects_malformed_middleware_selector_patterns() { + let mut policy = restrictive_default_policy(); + let mut middleware = middleware_config("redactor", "openshell/secrets"); + middleware.endpoints.as_mut().unwrap().include = vec!["api[.example.com".into()]; + policy.network_middlewares.push(middleware); + + let errors = validate_sandbox_policy(&policy) + .expect_err("malformed selector") + .into_iter() + .map(|violation| violation.to_string()) + .collect::>() + .join("; "); + assert!(errors.contains("invalid host pattern"), "{errors}"); + } + + #[test] + fn middleware_host_selector_matching_is_case_insensitive() { + assert!(middleware_host_matches("*.Example.COM", "API.example.com").unwrap()); + assert!(!middleware_host_matches("*.example.com", "example.com").unwrap()); + assert!(!middleware_host_matches("*.example.com", "deep.api.example.com").unwrap()); + assert!(middleware_host_matches("**.example.com", "deep.api.example.com").unwrap()); + assert!(!middleware_host_matches("**.example.com", "example.com").unwrap()); + } + + #[test] + fn validate_rejects_middleware_selector_matching_tls_skip_endpoint() { + let mut policy = restrictive_default_policy(); + policy + .network_middlewares + .push(middleware_config("redactor", "openshell/secrets")); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "api.example.com".into(), + port: 443, + tls: "skip".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy).expect_err("tls skip conflict"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MiddlewareTlsSkipConflict { + middleware_name, + policy_name, + host, + } if middleware_name == "redactor" && policy_name == "api" && host == "api.example.com" + ))); + } + + #[test] + fn validate_rejects_concrete_selector_overlapping_tls_skip_wildcard() { + let mut policy = restrictive_default_policy(); + let mut middleware = middleware_config("redactor", "openshell/secrets"); + middleware.endpoints.as_mut().unwrap().include = vec!["api.example.com".into()]; + policy.network_middlewares.push(middleware); + policy.network_policies.insert( + "api".into(), + NetworkPolicyRule { + name: "api".into(), + endpoints: vec![NetworkEndpoint { + host: "*.example.com".into(), + port: 443, + tls: "skip".into(), + ..Default::default() + }], + binaries: Vec::new(), + }, + ); + + let violations = validate_sandbox_policy(&policy).expect_err("tls skip conflict"); + assert!(violations.iter().any(|violation| matches!( + violation, + PolicyViolation::MiddlewareTlsSkipConflict { + middleware_name, + policy_name, + host, + } if middleware_name == "redactor" && policy_name == "api" && host == "*.example.com" + ))); + } + #[test] fn validate_rejects_non_sandbox_user() { let mut policy = restrictive_default_policy(); @@ -1753,6 +2104,7 @@ network_policies: filesystem: None, landlock: None, network_policies: HashMap::new(), + network_middlewares: Vec::new(), }; assert!(validate_sandbox_policy(&policy).is_ok()); } @@ -2134,6 +2486,7 @@ network_policies: filesystem: None, landlock: None, network_policies: HashMap::new(), + network_middlewares: Vec::new(), }; assert!(validate_sandbox_policy(&policy).is_ok()); } @@ -2149,6 +2502,7 @@ network_policies: filesystem: None, landlock: None, network_policies: HashMap::new(), + network_middlewares: Vec::new(), }; assert!(validate_sandbox_policy(&policy).is_ok()); } @@ -2227,6 +2581,7 @@ network_policies: filesystem: None, landlock: None, network_policies: HashMap::new(), + network_middlewares: Vec::new(), }; assert!(validate_sandbox_policy(&policy).is_ok()); } diff --git a/crates/openshell-policy/src/middleware.rs b/crates/openshell-policy/src/middleware.rs new file mode 100644 index 0000000000..72a27e0603 --- /dev/null +++ b/crates/openshell-policy/src/middleware.rs @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! YAML schema and protobuf conversion for supervisor middleware policies. + +use std::collections::{BTreeMap, HashSet}; + +use openshell_core::proto::{ + MiddlewareEndpointSelector, NetworkEndpoint, NetworkMiddlewareConfig, NetworkPolicyRule, + SandboxPolicy, +}; +use openshell_core::proto_struct::{ + ProtoStructError, json_object_to_struct, struct_to_json_object, +}; +use serde::{Deserialize, Serialize}; + +use super::PolicyViolation; + +pub use openshell_core::host_pattern::host_matches as middleware_host_matches; +use openshell_core::host_pattern::{HostPattern, HostSelector}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct NetworkMiddlewareConfigDef { + name: String, + middleware: String, + #[serde(default, skip_serializing_if = "is_default")] + order: i32, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + config: BTreeMap, + #[serde(default, skip_serializing_if = "String::is_empty")] + on_error: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + endpoints: Option, +} + +fn is_default(value: &T) -> bool { + value == &T::default() +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct MiddlewareEndpointSelectorDef { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + include: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + exclude: Vec, +} + +/// Middleware-relevant projection of the runtime policy JSON accepted by the +/// supervisor's local-file mode. Unrelated network and L7 fields are ignored; +/// middleware entries retain their strict canonical schema. +#[derive(Debug, Default, Deserialize)] +struct MiddlewareValidationPolicyDef { + #[serde(default)] + network_middlewares: Vec, + #[serde(default)] + network_policies: BTreeMap, +} + +#[derive(Debug, Default, Deserialize)] +struct MiddlewareValidationNetworkPolicyDef { + #[serde(default)] + name: String, + #[serde(default)] + endpoints: Vec, +} + +#[derive(Debug, Default, Deserialize)] +struct MiddlewareValidationEndpointDef { + #[serde(default)] + host: String, + #[serde(default)] + tls: String, +} + +pub fn into_proto( + definitions: Vec, +) -> Result, ProtoStructError> { + definitions + .into_iter() + .map(|definition| { + Ok(NetworkMiddlewareConfig { + name: definition.name, + middleware: definition.middleware, + order: definition.order, + config: Some(json_object_to_struct( + definition.config.into_iter().collect(), + )?), + on_error: definition.on_error, + endpoints: definition + .endpoints + .map(|selector| MiddlewareEndpointSelector { + include: selector.include, + exclude: selector.exclude, + }), + }) + }) + .collect() +} + +pub fn from_proto(middlewares: &[NetworkMiddlewareConfig]) -> Vec { + middlewares + .iter() + .map(|middleware| NetworkMiddlewareConfigDef { + name: middleware.name.clone(), + middleware: middleware.middleware.clone(), + order: middleware.order, + config: middleware + .config + .as_ref() + .map(struct_to_json_object) + .unwrap_or_default() + .into_iter() + .collect(), + on_error: middleware.on_error.clone(), + endpoints: middleware.endpoints.as_ref().map(|selector| { + MiddlewareEndpointSelectorDef { + include: selector.include.clone(), + exclude: selector.exclude.clone(), + } + }), + }) + .collect() +} + +/// Validate middleware configuration from the supervisor's runtime policy +/// JSON through the same typed validator used for protobuf policies. +pub fn validate_json(data: &serde_json::Value) -> Result, String> { + validate_json_with_config(data, |_implementation, _config| Ok(())) +} + +/// Validate middleware policy structure and delegate implementation-owned +/// configuration to the supplied registry or catalog. +pub fn validate_json_with_config( + data: &serde_json::Value, + validate_config: F, +) -> Result, String> +where + F: Fn(&str, &prost_types::Struct) -> Result<(), String>, +{ + let definition: MiddlewareValidationPolicyDef = serde_json::from_value(data.clone()) + .map_err(|error| format!("failed to parse network middleware policy: {error}"))?; + let network_middlewares = into_proto(definition.network_middlewares) + .map_err(|error| format!("failed to convert network middleware config: {error}"))?; + let network_policies = definition + .network_policies + .into_iter() + .map(|(key, rule)| { + let rule = NetworkPolicyRule { + name: rule.name, + endpoints: rule + .endpoints + .into_iter() + .map(|endpoint| NetworkEndpoint { + host: endpoint.host, + tls: endpoint.tls, + ..Default::default() + }) + .collect(), + ..Default::default() + }; + (key, rule) + }) + .collect(); + let policy = SandboxPolicy { + network_middlewares, + network_policies, + ..Default::default() + }; + let mut violations = validate(&policy); + for middleware in &policy.network_middlewares { + let config = middleware.config.clone().unwrap_or_default(); + if let Err(reason) = validate_config(&middleware.middleware, &config) { + violations.push(PolicyViolation::InvalidMiddlewareConfig { + name: middleware.name.clone(), + reason, + }); + } + } + Ok(violations) +} + +pub fn validate(policy: &SandboxPolicy) -> Vec { + let mut violations = Vec::new(); + let mut names = HashSet::new(); + + for middleware in &policy.network_middlewares { + if middleware.name.is_empty() { + violations.push(PolicyViolation::InvalidMiddlewareConfig { + name: middleware.name.clone(), + reason: "name must not be empty".to_string(), + }); + } else if !names.insert(middleware.name.clone()) { + violations.push(PolicyViolation::DuplicateMiddlewareConfigName { + name: middleware.name.clone(), + }); + } + + if middleware.middleware.is_empty() { + violations.push(PolicyViolation::InvalidMiddlewareConfig { + name: middleware.name.clone(), + reason: "implementation must not be empty".to_string(), + }); + } + + if !matches!( + middleware.on_error.as_str(), + "" | "fail_closed" | "fail_open" + ) { + violations.push(PolicyViolation::InvalidMiddlewareConfig { + name: middleware.name.clone(), + reason: format!("invalid on_error '{}'", middleware.on_error), + }); + } + + let Some(selector) = &middleware.endpoints else { + violations.push(PolicyViolation::InvalidMiddlewareConfig { + name: middleware.name.clone(), + reason: "endpoint selector is required".to_string(), + }); + continue; + }; + if selector.include.is_empty() { + violations.push(PolicyViolation::InvalidMiddlewareConfig { + name: middleware.name.clone(), + reason: "endpoint selector must include at least one host pattern".to_string(), + }); + } + let mut selector_valid = !selector.include.is_empty(); + for pattern in selector.include.iter().chain(&selector.exclude) { + if let Err(reason) = HostPattern::new(pattern) { + selector_valid = false; + violations.push(PolicyViolation::InvalidMiddlewareConfig { + name: middleware.name.clone(), + reason: format!("endpoint selector pattern '{pattern}' is invalid: {reason}"), + }); + } + } + let compiled_selector = if selector_valid { + HostSelector::new(&selector.include, &selector.exclude).ok() + } else { + None + }; + + for (key, rule) in &policy.network_policies { + let policy_name = if rule.name.is_empty() { + key + } else { + &rule.name + }; + for endpoint in &rule.endpoints { + let overlaps_tls_skip = endpoint.tls == "skip" + && compiled_selector.as_ref().is_some_and(|selector| { + HostPattern::new(&endpoint.host) + .is_ok_and(|endpoint| selector.may_match_pattern(&endpoint)) + }); + if overlaps_tls_skip { + violations.push(PolicyViolation::MiddlewareTlsSkipConflict { + middleware_name: middleware.name.clone(), + policy_name: policy_name.clone(), + host: endpoint.host.clone(), + }); + } + } + } + } + + violations +} diff --git a/crates/openshell-sandbox/Cargo.toml b/crates/openshell-sandbox/Cargo.toml index 0d7ff33925..f9c23cf60f 100644 --- a/crates/openshell-sandbox/Cargo.toml +++ b/crates/openshell-sandbox/Cargo.toml @@ -19,6 +19,8 @@ openshell-core = { path = "../openshell-core", default-features = false } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-supervisor-network = { path = "../openshell-supervisor-network" } +openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } +openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } openshell-supervisor-process = { path = "../openshell-supervisor-process" } # Async runtime @@ -26,6 +28,7 @@ tokio = { workspace = true } # gRPC (tonic::Status downcast in error mapping) tonic = { workspace = true, features = ["channel", "tls-native-roots"] } +prost-types = { workspace = true } # CLI clap = { workspace = true } diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 7a1085f1bf..a37c5d17f6 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -160,9 +160,17 @@ pub async fn run_sandbox( // Load policy and initialize OPA engine let openshell_endpoint_for_proxy = openshell_endpoint.clone(); let sandbox_name_for_agg = sandbox.clone(); - let (mut policy, opa_engine, retained_proto, loaded_policy_origin) = + let (mut policy, opa_engine, retained_proto, middleware_registry_status, loaded_policy_origin) = if let Some(bootstrap) = sidecar_bootstrap.as_ref() { - load_policy_from_sidecar_bootstrap(bootstrap)? + let (policy, opa_engine, retained_proto, loaded_policy_origin) = + load_policy_from_sidecar_bootstrap(bootstrap)?; + ( + policy, + opa_engine, + retained_proto, + MiddlewareRegistryStatus::Synchronized, + loaded_policy_origin, + ) } else { load_policy( sandbox_id.clone(), @@ -555,6 +563,7 @@ pub async fn run_sandbox( ocsf_enabled: poll_ocsf_enabled, provider_credentials: poll_provider_credentials, policy_local_ctx: poll_policy_local, + middleware_registry_status, sidecar_control_publisher: sidecar_control_publisher.clone(), }; @@ -1787,6 +1796,7 @@ async fn load_policy( SandboxPolicy, Option>, Option, + MiddlewareRegistryStatus, LoadedPolicyOrigin, )> { // File mode: load OPA engine from rego rules + YAML data (dev override) @@ -1801,10 +1811,22 @@ async fn load_policy( "Loading OPA policy engine from local files [rules:{policy_file} data:{data_file}]" )) .build()); - let engine = OpaEngine::from_files( + let validate_middleware_config = |implementation: &str, config: &prost_types::Struct| { + openshell_supervisor_middleware_builtins::validate_config(implementation, config) + .map_err(|error| error.to_string()) + }; + let engine = OpaEngine::from_files_with_middleware_config( std::path::Path::new(policy_file), std::path::Path::new(data_file), + Some(&validate_middleware_config), )?; + let middleware_registry = + openshell_supervisor_middleware::MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await?; + engine.replace_middleware_registry(middleware_registry)?; let config = engine.query_sandbox_config()?; let mut policy = SandboxPolicy { version: 1, @@ -1817,10 +1839,12 @@ async fn load_policy( process: config.process, }; enrich_sandbox_baseline_paths(&mut policy); + // File mode has no operator-registered middleware to connect. return Ok(( policy, Some(Arc::new(engine)), None, + MiddlewareRegistryStatus::Synchronized, LoadedPolicyOrigin::LocalOverride, )); } @@ -1934,8 +1958,8 @@ async fn load_policy( // container hasn't started yet. After the entrypoint spawns, the // engine is rebuilt with the real PID for symlink resolution. info!("Creating OPA engine from proto policy data"); - let opa_engine = match OpaEngine::from_proto(&proto_policy) { - Ok(engine) => Some(Arc::new(engine)), + let engine = match OpaEngine::from_proto(&proto_policy) { + Ok(engine) => engine, Err(e) => { report_initial_policy_failure(endpoint, id, loaded_policy_revision.as_ref(), &e) .await; @@ -1943,6 +1967,41 @@ async fn load_policy( } }; + // Connect operator-registered middleware services. A connect/describe + // failure keeps the built-in registry active so each request's + // `on_error` policy governs matched traffic. The policy poll loop + // retries the install without waiting for a config change. + let middleware_services = snapshot.supervisor_middleware_services.clone(); + let middleware_registry_status = match grpc_retry("Middleware connect", || { + openshell_supervisor_middleware::MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + middleware_services.clone(), + ) + }) + .await + .and_then(|registry| engine.replace_middleware_registry(registry)) + { + Ok(()) => MiddlewareRegistryStatus::Synchronized, + Err(error) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .state(StateId::Other, "degraded") + .unmapped( + "supervisor_middleware_service_count", + serde_json::json!(middleware_services.len()) + ) + .message(format!( + "Supervisor middleware connect failed at startup; continuing with built-in middleware only, per-request on_error governs matched requests [error:{error}]" + )) + .build() + ); + MiddlewareRegistryStatus::NeedsReconciliation + } + }; + let opa_engine = Some(Arc::new(engine)); + let policy = match SandboxPolicy::try_from(proto_policy.clone()) { Ok(policy) => policy, Err(e) => { @@ -1955,6 +2014,7 @@ async fn load_policy( policy, opa_engine, Some(proto_policy), + middleware_registry_status, LoadedPolicyOrigin::Gateway { revision: loaded_policy_revision, }, @@ -2077,6 +2137,45 @@ fn discover_policy_from_path(path: &std::path::Path) -> openshell_core::proto::S } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum MiddlewareRegistryStatus { + Synchronized, + NeedsReconciliation, +} + +/// True when the installed middleware registry no longer matches the desired +/// service set and must be rebuilt (reconnecting every delivered service). +/// +/// A policy-only change never requires a rebuild: middleware configs were +/// validated at gateway admission and the installed registry's manifests +/// already cover the unchanged service set, so requiring the services to be +/// reachable would only let a middleware outage block the policy update. +fn middleware_registry_needs_rebuild( + registry_status: MiddlewareRegistryStatus, + current_services: &[openshell_core::proto::SupervisorMiddlewareService], + desired_services: &[openshell_core::proto::SupervisorMiddlewareService], +) -> bool { + registry_status == MiddlewareRegistryStatus::NeedsReconciliation + || current_services != desired_services +} + +fn gateway_policy_runtime_needs_reconciliation( + reloads_gateway_policy: bool, + current_policy_hash: &str, + desired_policy_hash: &str, + current_services: &[openshell_core::proto::SupervisorMiddlewareService], + desired_services: &[openshell_core::proto::SupervisorMiddlewareService], + registry_status: MiddlewareRegistryStatus, +) -> bool { + reloads_gateway_policy + && (current_policy_hash != desired_policy_hash + || middleware_registry_needs_rebuild( + registry_status, + current_services, + desired_services, + )) +} + /// Identity returned with the exact policy snapshot used to construct OPA. #[derive(Clone, Debug, PartialEq, Eq)] struct LoadedPolicyRevision { @@ -2363,9 +2462,76 @@ struct PolicyPollLoopContext { ocsf_enabled: Arc, provider_credentials: ProviderCredentialState, policy_local_ctx: Option>, + middleware_registry_status: MiddlewareRegistryStatus, sidecar_control_publisher: Option, } +async fn connect_middleware_registry( + services: &[openshell_core::proto::SupervisorMiddlewareService], +) -> Result { + openshell_supervisor_middleware::MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + services.to_vec(), + ) + .await +} + +async fn reconcile_middleware_registry( + opa_engine: &OpaEngine, + desired_services: &[openshell_core::proto::SupervisorMiddlewareService], + current_services: &mut Vec, + status: &mut MiddlewareRegistryStatus, +) { + if *status == MiddlewareRegistryStatus::Synchronized + && desired_services == current_services.as_slice() + { + return; + } + + match connect_middleware_registry(desired_services) + .await + .and_then(|registry| opa_engine.replace_middleware_registry(registry)) + { + Ok(()) => { + current_services.clear(); + current_services.extend_from_slice(desired_services); + *status = MiddlewareRegistryStatus::Synchronized; + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped( + "supervisor_middleware_service_count", + serde_json::json!(current_services.len()) + ) + .message(format!( + "Supervisor middleware registry reloaded [service_count:{}]", + current_services.len() + )) + .build() + ); + } + Err(error) => { + // Emit only on the transition into the failed state to avoid + // repeating the same finding on every poll during an outage. + if *status == MiddlewareRegistryStatus::Synchronized { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .state(StateId::Other, "failed") + .message(format!( + "Supervisor middleware registry reload failed, keeping last-known-good registry [error:{error}]" + )) + .build() + ); + } + *status = MiddlewareRegistryStatus::NeedsReconciliation; + } + } +} + async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { use openshell_core::grpc_client::CachedOpenShellClient; use openshell_core::proto::PolicySource; @@ -2382,10 +2548,14 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { let mut current_config_revision: u64 = 0; let mut current_provider_env_revision: u64 = ctx.provider_credentials.snapshot().revision; let mut current_policy_hash = String::new(); + let mut current_middleware_services = Vec::new(); + let mut middleware_registry_status = ctx.middleware_registry_status; let mut current_settings: std::collections::HashMap< String, openshell_core::proto::EffectiveSetting, > = std::collections::HashMap::new(); + let reloads_gateway_policy = ctx.loaded_policy_origin.allows_gateway_policy_reload(); + let mut last_failed_runtime_revision: Option<(u64, String)> = None; // A first poll that does not match the policy already loaded into OPA must // pass through the normal reconciliation path immediately. It must never @@ -2401,6 +2571,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); current_config_revision = candidate.config_revision; current_policy_hash.clone_from(&candidate.policy_hash); + current_middleware_services = result.supervisor_middleware_services; current_settings = result.settings; enqueue_policy_status( &status_sender, @@ -2416,6 +2587,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { apply_ocsf_json_setting(&ctx.ocsf_enabled, &result.settings); current_config_revision = result.config_revision; current_policy_hash = result.policy_hash.clone(); + current_middleware_services = result.supervisor_middleware_services; current_settings = result.settings; debug!( config_revision = current_config_revision, @@ -2443,29 +2615,59 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } }; + let config_changed = result.config_revision != current_config_revision; let provider_env_changed = result.provider_env_revision != current_provider_env_revision; - if result.config_revision == current_config_revision && !provider_env_changed { - continue; + let policy_changed = result.policy_hash != current_policy_hash; + let middleware_registry_changed = middleware_registry_needs_rebuild( + middleware_registry_status, + ¤t_middleware_services, + &result.supervisor_middleware_services, + ); + let policy_runtime_changed = gateway_policy_runtime_needs_reconciliation( + reloads_gateway_policy, + ¤t_policy_hash, + &result.policy_hash, + ¤t_middleware_services, + &result.supervisor_middleware_services, + middleware_registry_status, + ); + + // A local policy override is not coupled to the gateway policy + // snapshot, so its service registry can still be reconciled alone. + // Gateway policy snapshots, however, must install policy and registry + // as one generation below. + if !reloads_gateway_policy { + reconcile_middleware_registry( + &ctx.opa_engine, + &result.supervisor_middleware_services, + &mut current_middleware_services, + &mut middleware_registry_status, + ) + .await; } - let policy_changed = result.policy_hash != current_policy_hash; + if !config_changed && !provider_env_changed && !policy_runtime_changed { + continue; + } - // Log which settings changed. - log_setting_changes(¤t_settings, &result.settings); + if config_changed || provider_env_changed { + // Log which settings changed. + log_setting_changes(¤t_settings, &result.settings); - ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Other, "detected") - .unmapped("old_config_revision", serde_json::json!(current_config_revision)) - .unmapped("new_config_revision", serde_json::json!(result.config_revision)) - .unmapped("policy_changed", serde_json::json!(policy_changed)) - .unmapped("provider_env_changed", serde_json::json!(provider_env_changed)) - .message(format!( - "Settings poll: config change detected [old_revision:{current_config_revision} new_revision:{} policy_changed:{policy_changed} provider_env_changed:{provider_env_changed}]", - result.config_revision - )) - .build()); + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Other, "detected") + .unmapped("old_config_revision", serde_json::json!(current_config_revision)) + .unmapped("new_config_revision", serde_json::json!(result.config_revision)) + .unmapped("policy_changed", serde_json::json!(policy_changed)) + .unmapped("provider_env_changed", serde_json::json!(provider_env_changed)) + .message(format!( + "Settings poll: config change detected [old_revision:{current_config_revision} new_revision:{} policy_changed:{policy_changed} provider_env_changed:{provider_env_changed}]", + result.config_revision + )) + .build()); + } if provider_env_changed { match openshell_core::grpc_client::fetch_provider_environment( @@ -2516,86 +2718,131 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } } - // Only reload OPA when the policy payload actually changed. - if policy_changed && ctx.loaded_policy_origin.allows_gateway_policy_reload() { - let Some(policy) = result.policy.as_ref() else { - ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .state(StateId::Other, "skipped") - .message("Settings poll: policy hash changed but no policy payload present; skipping reload") - .build()); - current_config_revision = result.config_revision; - current_policy_hash = result.policy_hash; - current_settings = result.settings; - continue; + if policy_runtime_changed { + let pid = ctx.entrypoint_pid.load(Ordering::Acquire); + let runtime_result = match result.policy.as_ref() { + Some(policy) if middleware_registry_changed => { + match connect_middleware_registry(&result.supervisor_middleware_services).await + { + Ok(registry) => ctx + .opa_engine + .reload_policy_and_middleware_from_proto_with_pid( + policy, pid, registry, + ), + Err(error) => Err(error), + } + } + // Policy-only change: the installed registry already matches + // the delivered service set, so swap the engine alone. This + // must not require middleware reachability. + Some(policy) => ctx.opa_engine.reload_from_proto_with_pid(policy, pid), + None => Err(miette::miette!( + "runtime reload requires a policy payload but none was returned" + )), }; - let pid = ctx.entrypoint_pid.load(Ordering::Acquire); - match ctx.opa_engine.reload_from_proto_with_pid(policy, pid) { + match runtime_result { Ok(()) => { - if let Some(policy_local_ctx) = ctx.policy_local_ctx.as_ref() { - policy_local_ctx.set_current_policy(policy.clone()).await; - } - if let Some(publisher) = ctx.sidecar_control_publisher.as_ref() { - publisher.publish_policy( - policy.clone(), - result.policy_hash.clone(), - result.config_revision, - ); + let policy = result + .policy + .as_ref() + .expect("successful runtime reload requires a policy payload"); + if policy_changed { + if let Some(policy_local_ctx) = ctx.policy_local_ctx.as_ref() { + policy_local_ctx.set_current_policy(policy.clone()).await; + } + if let Some(publisher) = ctx.sidecar_control_publisher.as_ref() { + publisher.publish_policy( + policy.clone(), + result.policy_hash.clone(), + result.config_revision, + ); + } + if result.global_policy_version > 0 { + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) + .unmapped("global_version", serde_json::json!(result.global_policy_version)) + .message(format!( + "Policy reloaded successfully (global) [policy_hash:{} global_version:{}]", + result.policy_hash, + result.global_policy_version + )) + .build()); + } else { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) + .message(format!( + "Policy reloaded successfully [policy_hash:{}]", + result.policy_hash + )) + .build() + ); + } + if result.version > 0 && result.policy_source == PolicySource::Sandbox { + enqueue_policy_status( + &status_sender, + PolicyStatusUpdate::loaded(result.version), + ); + } } - if result.global_policy_version > 0 { + + if middleware_registry_changed { ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) .severity(SeverityId::Informational) .status(StatusId::Success) .state(StateId::Enabled, "loaded") - .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) - .unmapped("global_version", serde_json::json!(result.global_policy_version)) + .unmapped( + "supervisor_middleware_service_count", + serde_json::json!(result.supervisor_middleware_services.len()) + ) .message(format!( - "Policy reloaded successfully (global) [policy_hash:{} global_version:{}]", - result.policy_hash, - result.global_policy_version + "Supervisor policy runtime reloaded atomically [service_count:{}]", + result.supervisor_middleware_services.len() )) .build()); - } else { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .unmapped("policy_hash", serde_json::json!(&result.policy_hash)) - .message(format!( - "Policy reloaded successfully [policy_hash:{}]", - result.policy_hash - )) - .build() - ); - } - if result.version > 0 && result.policy_source == PolicySource::Sandbox { - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::loaded(result.version), - ); } + + current_policy_hash.clone_from(&result.policy_hash); + current_middleware_services.clone_from(&result.supervisor_middleware_services); + middleware_registry_status = MiddlewareRegistryStatus::Synchronized; + last_failed_runtime_revision = None; } Err(e) => { - ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .state(StateId::Other, "failed") - .unmapped("version", serde_json::json!(result.version)) - .unmapped("error", serde_json::json!(e.to_string())) - .message(format!( - "Policy reload failed, keeping last-known-good policy [version:{} error:{e}]", - result.version - )) - .build()); - if result.version > 0 && result.policy_source == PolicySource::Sandbox { - enqueue_policy_status( - &status_sender, - PolicyStatusUpdate::failed(result.version, e.to_string()), - ); + let failed_revision = (result.config_revision, result.policy_hash.clone()); + if last_failed_runtime_revision.as_ref() != Some(&failed_revision) { + ocsf_emit!(ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .state(StateId::Other, "failed") + .unmapped("version", serde_json::json!(result.version)) + .unmapped("error", serde_json::json!(e.to_string())) + .message(format!( + "Policy and middleware runtime reload failed, keeping last-known-good runtime [version:{} error:{e}]", + result.version + )) + .build()); + if policy_changed + && result.version > 0 + && result.policy_source == PolicySource::Sandbox + { + enqueue_policy_status( + &status_sender, + PolicyStatusUpdate::failed(result.version, e.to_string()), + ); + } } + last_failed_runtime_revision = Some(failed_revision); + // Nothing was installed, so the registry status still + // describes the live registry. The retry is driven by the + // persisting hash/service-set mismatch (or an existing + // NeedsReconciliation), not by degrading the status here. } } } @@ -2638,7 +2885,9 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { } current_config_revision = result.config_revision; - current_policy_hash = result.policy_hash; + if !reloads_gateway_policy { + current_policy_hash = result.policy_hash; + } current_settings = result.settings; } } @@ -3008,9 +3257,115 @@ filesystem_policy: settings: std::collections::HashMap::new(), global_policy_version: 0, provider_env_revision: 0, + supervisor_middleware_services: Vec::new(), } } + #[test] + fn failed_gateway_runtime_snapshot_is_retried_without_revision_change() { + let services = Vec::new(); + + assert!(gateway_policy_runtime_needs_reconciliation( + true, + "hash-v1", + "hash-v1", + &services, + &services, + MiddlewareRegistryStatus::NeedsReconciliation, + )); + assert!(!gateway_policy_runtime_needs_reconciliation( + true, + "hash-v1", + "hash-v1", + &services, + &services, + MiddlewareRegistryStatus::Synchronized, + )); + } + + #[test] + fn gateway_runtime_reconciliation_tracks_policy_and_service_changes() { + let no_services = Vec::new(); + let desired_services = vec![openshell_core::proto::SupervisorMiddlewareService { + name: "guard".into(), + ..Default::default() + }]; + + assert!(gateway_policy_runtime_needs_reconciliation( + true, + "hash-v1", + "hash-v2", + &no_services, + &no_services, + MiddlewareRegistryStatus::Synchronized, + )); + assert!(gateway_policy_runtime_needs_reconciliation( + true, + "hash-v1", + "hash-v1", + &no_services, + &desired_services, + MiddlewareRegistryStatus::Synchronized, + )); + assert!(!gateway_policy_runtime_needs_reconciliation( + false, + "local-policy", + "hash-v2", + &no_services, + &desired_services, + MiddlewareRegistryStatus::NeedsReconciliation, + )); + } + + #[test] + fn policy_only_change_does_not_rebuild_middleware_registry() { + let services = vec![openshell_core::proto::SupervisorMiddlewareService { + name: "guard".into(), + ..Default::default() + }]; + + // The runtime must reconcile, but the registry (and therefore + // middleware reachability) is not part of that reconciliation. + assert!(gateway_policy_runtime_needs_reconciliation( + true, + "hash-v1", + "hash-v2", + &services, + &services, + MiddlewareRegistryStatus::Synchronized, + )); + assert!(!middleware_registry_needs_rebuild( + MiddlewareRegistryStatus::Synchronized, + &services, + &services, + )); + } + + #[test] + fn registry_rebuild_requires_service_set_change_or_degraded_registry() { + let no_services = Vec::new(); + let desired_services = vec![openshell_core::proto::SupervisorMiddlewareService { + name: "guard".into(), + ..Default::default() + }]; + + assert!(middleware_registry_needs_rebuild( + MiddlewareRegistryStatus::Synchronized, + &no_services, + &desired_services, + )); + assert!(middleware_registry_needs_rebuild( + MiddlewareRegistryStatus::NeedsReconciliation, + &desired_services, + &desired_services, + )); + assert!(!middleware_registry_needs_rebuild( + MiddlewareRegistryStatus::Synchronized, + &desired_services, + &desired_services, + )); + } + #[test] fn initial_ack_candidate_matches_sandbox_revision() { let canonical = settings_poll_result( diff --git a/crates/openshell-server/Cargo.toml b/crates/openshell-server/Cargo.toml index b5c9b34d71..6192b4224a 100644 --- a/crates/openshell-server/Cargo.toml +++ b/crates/openshell-server/Cargo.toml @@ -26,6 +26,8 @@ openshell-prover = { path = "../openshell-prover" } openshell-providers = { path = "../openshell-providers" } openshell-router = { path = "../openshell-router" } openshell-server-macros = { path = "../openshell-server-macros" } +openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } +openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } # Kubernetes client (used by the `generate-certs` subcommand) kube = { workspace = true } diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index b65b5f3b09..7306c80e7e 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -25,6 +25,7 @@ use std::net::SocketAddr; use std::path::{Path, PathBuf}; use openshell_core::config::ComputeDriverKind; +use openshell_core::proto::SupervisorMiddlewareService; use openshell_core::{GatewayAuthConfig, GatewayJwtConfig, MtlsAuthConfig, OidcConfig, TlsConfig}; use serde::{Deserialize, Serialize}; @@ -151,6 +152,12 @@ pub struct GatewayFileSection { #[serde(default)] pub gateway_jwt: Option, + // ── Supervisor middleware ───────────────────────────────────────────── + /// Statically registered supervisor middleware services. Registration is + /// operator-owned and changes require a gateway restart. + #[serde(default)] + pub middleware: Vec, + // ── Disallowed-in-file fields ──────────────────────────────────────── // // Captured so we can produce a friendly "set this via env/CLI instead" @@ -160,6 +167,28 @@ pub struct GatewayFileSection { pub database_url: Option, } +/// One `[[openshell.gateway.middleware]]` supervisor middleware registration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MiddlewareServiceFileConfig { + /// Operator-facing name used for diagnostics. + pub name: String, + /// Plaintext gRPC endpoint reachable by the gateway and supervisors. + pub grpc_endpoint: String, + /// Operator-owned body limit for every binding exposed by this service. + pub max_body_bytes: u64, +} + +impl From<&MiddlewareServiceFileConfig> for SupervisorMiddlewareService { + fn from(config: &MiddlewareServiceFileConfig) -> Self { + Self { + name: config.name.clone(), + grpc_endpoint: config.grpc_endpoint.clone(), + max_body_bytes: config.max_body_bytes, + } + } +} + #[derive(Debug, thiserror::Error)] pub enum ConfigFileError { #[error("failed to read gateway config file '{}': {source}", path.display())] @@ -401,6 +430,26 @@ allow_unauthenticated_users = true assert!(auth.allow_unauthenticated_users); } + #[test] + fn parses_supervisor_middleware_registration() { + let toml = r#" +[[openshell.gateway.middleware]] +name = "local-guard" +grpc_endpoint = "http://127.0.0.1:50051" +max_body_bytes = 262144 +"#; + let tmp = write_tmp(toml); + let file = load(tmp.path()).expect("valid middleware registration parses"); + assert_eq!( + file.openshell.gateway.middleware, + vec![MiddlewareServiceFileConfig { + name: "local-guard".into(), + grpc_endpoint: "http://127.0.0.1:50051".into(), + max_body_bytes: 262_144, + }] + ); + } + #[test] fn rejects_database_url_in_file() { let toml = r#" diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index cc8ff0d2e2..21d7e02540 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1218,8 +1218,26 @@ pub(super) async fn handle_get_sandbox_config( } } + if let Some(policy) = policy.as_ref() { + state + .middleware_registry + .ensure_policy_bindings_registered(policy) + .map_err(|error| { + Status::failed_precondition(format!( + "effective policy middleware registration is invalid: {error}" + )) + })?; + } + let settings = merge_effective_settings(&global_settings, &sandbox_settings)?; - let config_revision = compute_config_revision(policy.as_ref(), &settings, policy_source); + let supervisor_middleware_services = + state.middleware_registry.required_services(policy.as_ref()); + let config_revision = compute_config_revision( + policy.as_ref(), + &settings, + policy_source, + &supervisor_middleware_services, + ); let provider_env_revision = compute_provider_env_revision(state.store.as_ref(), &sandbox_provider_names).await?; @@ -1232,6 +1250,7 @@ pub(super) async fn handle_get_sandbox_config( policy_source: policy_source.into(), global_policy_version, provider_env_revision, + supervisor_middleware_services, })) } @@ -1510,6 +1529,8 @@ async fn handle_update_config_inner( openshell_policy::ensure_sandbox_process_identity(&mut new_policy); validate_no_reserved_provider_policy_keys(&new_policy)?; validate_policy_safety(&new_policy)?; + crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy) + .await?; let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); @@ -1829,8 +1850,12 @@ async fn handle_update_config_inner( if let Some(baseline_policy) = spec.policy.as_ref() { validate_static_fields_unchanged(baseline_policy, &new_policy)?; - validate_policy_safety(&new_policy)?; - } else { + } + + validate_policy_safety(&new_policy)?; + crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy).await?; + + if spec.policy.is_none() { // Backfill spec.policy using CAS (first-time policy discovery) let _sandbox_sync_guard = state.compute.sandbox_sync_guard().await; let sandbox_id = sandbox.object_id().to_string(); @@ -3112,6 +3137,18 @@ fn deterministic_policy_hash(policy: &ProtoSandboxPolicy) -> String { hasher.update(key.as_bytes()); hasher.update(value.encode_to_vec()); } + if !policy.network_middlewares.is_empty() { + hasher.update(b"network_middlewares"); + for middleware in &policy.network_middlewares { + let encoded = middleware.encode_to_vec(); + hasher.update( + u64::try_from(encoded.len()) + .expect("protobuf payload length fits in u64") + .to_le_bytes(), + ); + hasher.update(encoded); + } + } hex::encode(hasher.finalize()) } @@ -3120,6 +3157,7 @@ fn compute_config_revision( policy: Option<&ProtoSandboxPolicy>, settings: &HashMap, policy_source: PolicySource, + supervisor_middleware_services: &[openshell_core::proto::SupervisorMiddlewareService], ) -> u64 { let mut hasher = Sha256::new(); hasher.update((policy_source as i32).to_le_bytes()); @@ -3152,6 +3190,11 @@ fn compute_config_revision( } } } + let mut middleware = supervisor_middleware_services.iter().collect::>(); + middleware.sort_by(|left, right| left.name.cmp(&right.name)); + for service in middleware { + hasher.update(service.encode_to_vec()); + } let digest = hasher.finalize(); let mut bytes = [0_u8; 8]; @@ -8773,7 +8816,7 @@ mod tests { allowed_ips: vec!["127.0.0.1".to_string()], ..Default::default() }], - binaries: vec![], + ..Default::default() }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_err()); @@ -8794,7 +8837,7 @@ mod tests { allowed_ips: vec!["169.254.169.254".to_string()], ..Default::default() }], - binaries: vec![], + ..Default::default() }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_err()); @@ -8812,7 +8855,7 @@ mod tests { port: 80, ..Default::default() }], - binaries: vec![], + ..Default::default() }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_err()); @@ -8830,7 +8873,7 @@ mod tests { port: 8080, ..Default::default() }], - binaries: vec![], + ..Default::default() }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_err()); @@ -8848,7 +8891,7 @@ mod tests { port: 80, ..Default::default() }], - binaries: vec![], + ..Default::default() }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_err()); @@ -8896,7 +8939,7 @@ mod tests { allowed_ips: vec!["10.0.5.0/24".to_string()], ..Default::default() }], - binaries: vec![], + ..Default::default() }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_ok()); @@ -8913,7 +8956,7 @@ mod tests { port: 443, ..Default::default() }], - binaries: vec![], + ..Default::default() }; let result = validate_rule_not_always_blocked(&rule); assert!(result.is_ok()); @@ -8989,7 +9032,7 @@ mod tests { }, ); - let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox); + let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]); settings.insert( "mode".to_string(), EffectiveSetting { @@ -8999,7 +9042,7 @@ mod tests { scope: SettingScope::Sandbox.into(), }, ); - let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox); + let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]); assert_ne!(rev_a, rev_b); } @@ -9264,8 +9307,8 @@ mod tests { }, ); - let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox); - let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox); + let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]); + let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]); assert_eq!(rev_a, rev_b); } @@ -9281,21 +9324,57 @@ mod tests { }; let settings = HashMap::new(); - let rev_a = compute_config_revision(Some(&policy_a), &settings, PolicySource::Sandbox); - let rev_b = compute_config_revision(Some(&policy_b), &settings, PolicySource::Sandbox); + let rev_a = compute_config_revision(Some(&policy_a), &settings, PolicySource::Sandbox, &[]); + let rev_b = compute_config_revision(Some(&policy_b), &settings, PolicySource::Sandbox, &[]); assert_ne!(rev_a, rev_b); } + #[test] + fn policy_hash_changes_when_network_middlewares_change() { + let policy_a = ProtoSandboxPolicy::default(); + let policy_b = ProtoSandboxPolicy { + network_middlewares: vec![openshell_core::proto::NetworkMiddlewareConfig { + name: "redact-secrets".into(), + middleware: "openshell/secrets".into(), + on_error: "fail_closed".into(), + ..Default::default() + }], + ..Default::default() + }; + + assert_ne!( + deterministic_policy_hash(&policy_a), + deterministic_policy_hash(&policy_b), + "middleware-only policy changes must produce a new policy hash" + ); + } + #[test] fn config_revision_changes_when_policy_source_changes() { let policy = ProtoSandboxPolicy::default(); let settings = HashMap::new(); - let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox); - let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Global); + let rev_a = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]); + let rev_b = compute_config_revision(Some(&policy), &settings, PolicySource::Global, &[]); assert_ne!(rev_a, rev_b); } + #[test] + fn config_revision_changes_when_supervisor_middleware_services_change() { + let policy = ProtoSandboxPolicy::default(); + let settings = HashMap::new(); + let service = openshell_core::proto::SupervisorMiddlewareService { + name: "local-guard".into(), + grpc_endpoint: "http://127.0.0.1:50051".into(), + max_body_bytes: 1024, + }; + + let without = compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[]); + let with = + compute_config_revision(Some(&policy), &settings, PolicySource::Sandbox, &[service]); + assert_ne!(without, with); + } + #[test] fn config_revision_without_policy_still_hashes_settings() { let mut settings = HashMap::new(); @@ -9309,7 +9388,7 @@ mod tests { }, ); - let rev_a = compute_config_revision(None, &settings, PolicySource::Sandbox); + let rev_a = compute_config_revision(None, &settings, PolicySource::Sandbox, &[]); settings.insert( "log_level".to_string(), @@ -9321,7 +9400,7 @@ mod tests { }, ); - let rev_b = compute_config_revision(None, &settings, PolicySource::Sandbox); + let rev_b = compute_config_revision(None, &settings, PolicySource::Sandbox, &[]); assert_ne!(rev_a, rev_b); } diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index 04d5a4ed52..203cd7dbe0 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -164,6 +164,7 @@ async fn handle_create_sandbox_inner( openshell_policy::ensure_sandbox_process_identity(policy); validate_no_reserved_provider_policy_keys(policy)?; validate_policy_safety(policy)?; + crate::middleware::validate_policy(state.middleware_registry.as_ref(), policy).await?; } let id = uuid::Uuid::new_v4().to_string(); diff --git a/crates/openshell-server/src/grpc/validation.rs b/crates/openshell-server/src/grpc/validation.rs index 0b3548b062..c98ad2d625 100644 --- a/crates/openshell-server/src/grpc/validation.rs +++ b/crates/openshell-server/src/grpc/validation.rs @@ -1613,6 +1613,28 @@ mod tests { assert!(err.message().contains("TLD wildcard")); } + #[test] + fn validate_policy_safety_rejects_invalid_middleware_before_acceptance() { + use openshell_core::proto::{MiddlewareEndpointSelector, NetworkMiddlewareConfig}; + + let mut policy = openshell_policy::restrictive_default_policy(); + policy.network_middlewares.push(NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: "openshell/secrets".into(), + on_error: "maybe".into(), + endpoints: Some(MiddlewareEndpointSelector { + include: vec!["api[.example.com".into()], + exclude: Vec::new(), + }), + ..Default::default() + }); + + let err = validate_policy_safety(&policy).unwrap_err(); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("invalid on_error")); + assert!(err.message().contains("invalid host pattern")); + } + #[test] fn validate_no_reserved_provider_policy_keys_rejects_reserved_key() { use openshell_core::proto::NetworkPolicyRule; diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 6462ccbbf3..81b0fb666b 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -32,6 +32,7 @@ mod defaults; mod grpc; mod http; mod inference; +mod middleware; mod multiplex; mod persistence; pub(crate) mod policy_store; @@ -53,6 +54,7 @@ mod ws_tunnel; use metrics_exporter_prometheus::PrometheusBuilder; use openshell_core::{ComputeDriverKind, Config, Error, Result}; +use openshell_supervisor_middleware::MiddlewareRegistry; use std::collections::HashMap; use std::io::ErrorKind; use std::net::SocketAddr; @@ -126,6 +128,9 @@ pub struct ServerState { /// query session state to surface supervisor readiness. pub supervisor_sessions: Arc, + /// Validated built-in and operator-registered supervisor middleware. + pub middleware_registry: Arc, + /// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured. pub oidc_cache: Option>, @@ -192,6 +197,7 @@ impl ServerState { ssh_connections_by_sandbox: Mutex::new(HashMap::new()), settings_mutex: tokio::sync::Mutex::new(()), supervisor_sessions, + middleware_registry: Arc::new(MiddlewareRegistry::default()), oidc_cache, sandbox_jwt_issuer: None, sandbox_jwt_authenticator: None, @@ -223,6 +229,26 @@ pub(crate) async fn run_server( return Err(Error::config("database_url is required")); } + let middleware_registrations = config_file + .as_ref() + .map(|file| { + file.openshell + .gateway + .middleware + .iter() + .map(Into::into) + .collect() + }) + .unwrap_or_default(); + let middleware_registry = Arc::new( + MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + middleware_registrations, + ) + .await + .map_err(|error| Error::config(format!("middleware registration failed: {error}")))?, + ); + let store = Arc::new(Store::connect(database_url).await?); let oidc_cache = if let Some(ref oidc) = config.oidc { @@ -273,6 +299,7 @@ pub(crate) async fn run_server( supervisor_sessions, oidc_cache, ); + state.middleware_registry = middleware_registry; // Load the gateway-minted sandbox JWT signing key when configured. // Optional so single-driver dev deployments without certgen continue diff --git a/crates/openshell-server/src/middleware.rs b/crates/openshell-server/src/middleware.rs new file mode 100644 index 0000000000..603a086131 --- /dev/null +++ b/crates/openshell-server/src/middleware.rs @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use openshell_core::proto::SandboxPolicy; +use openshell_supervisor_middleware::MiddlewareRegistry; +use tonic::Status; + +/// Validate implementation-owned middleware config before accepting a policy. +pub async fn validate_policy( + registry: &MiddlewareRegistry, + policy: &SandboxPolicy, +) -> Result<(), Status> { + registry + .validate_policy_configs(policy) + .await + .map_err(|error| { + Status::invalid_argument(format!("policy middleware validation failed: {error}")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::NetworkMiddlewareConfig; + + #[tokio::test] + async fn unregistered_external_binding_is_rejected_before_admission() { + let policy = SandboxPolicy { + network_middlewares: vec![NetworkMiddlewareConfig { + name: "guard".into(), + middleware: "example/content-guard".into(), + ..Default::default() + }], + ..Default::default() + }; + + let error = validate_policy(&MiddlewareRegistry::default(), &policy) + .await + .expect_err("unregistered binding must fail"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("not registered")); + } + + #[tokio::test] + async fn invalid_builtin_config_is_rejected_by_implementation() { + let registry = MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await + .expect("built-in registry"); + let policy = SandboxPolicy { + network_middlewares: vec![NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: openshell_supervisor_middleware_builtins::BUILTIN_SECRETS.into(), + config: Some(prost_types::Struct { + fields: std::iter::once(( + "secrets".into(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("allow".into())), + }, + )) + .collect(), + }), + ..Default::default() + }], + ..Default::default() + }; + + let error = validate_policy(®istry, &policy) + .await + .expect_err("invalid built-in config must fail admission"); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + assert!(error.message().contains("supports only secrets: redact")); + } +} diff --git a/crates/openshell-supervisor-middleware-builtins/Cargo.toml b/crates/openshell-supervisor-middleware-builtins/Cargo.toml new file mode 100644 index 0000000000..f892c718fd --- /dev/null +++ b/crates/openshell-supervisor-middleware-builtins/Cargo.toml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-supervisor-middleware-builtins" +description = "First-party in-process supervisor middleware implementations for OpenShell" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } + +miette = { workspace = true } +prost-types = { workspace = true } +regex = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tonic = { workspace = true, features = ["server"] } + +[dev-dependencies] +tokio = { workspace = true } + +[lints] +workspace = true diff --git a/crates/openshell-supervisor-middleware-builtins/src/lib.rs b/crates/openshell-supervisor-middleware-builtins/src/lib.rs new file mode 100644 index 0000000000..ca040c3783 --- /dev/null +++ b/crates/openshell-supervisor-middleware-builtins/src/lib.rs @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! First-party in-process supervisor middleware implementations. + +mod secrets; + +use std::sync::Arc; + +use miette::{Result, miette}; +use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; +use openshell_core::proto::{ + HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, + ValidateConfigResponse, +}; +use tonic::{Request, Response, Status}; + +pub use secrets::{BINDING_ID as BUILTIN_SECRETS, SecretsConfig, SecretsMode}; + +/// Return the first-party services that the gateway and supervisor install. +pub fn services() -> Vec> { + vec![Arc::new(BuiltinMiddlewareService)] +} + +/// Validate configuration for a first-party binding. +pub fn validate_config(implementation: &str, config: &prost_types::Struct) -> Result<()> { + match implementation { + BUILTIN_SECRETS => secrets::validate_config(config), + other => Err(miette!( + "middleware implementation '{other}' is not a registered OpenShell built-in" + )), + } +} + +fn evaluate_http_request(evaluation: &HttpRequestEvaluation) -> Result { + match evaluation.binding_id.as_str() { + BUILTIN_SECRETS => secrets::evaluate_http_request(evaluation), + other => Err(miette!( + "middleware implementation '{other}' is not a registered OpenShell built-in" + )), + } +} + +/// Aggregate service exposing all first-party bindings through the standard +/// supervisor middleware contract. +#[derive(Debug, Default)] +pub struct BuiltinMiddlewareService; + +#[tonic::async_trait] +impl SupervisorMiddleware for BuiltinMiddlewareService { + async fn describe( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(MiddlewareManifest { + name: "openshell/builtins".into(), + service_version: env!("CARGO_PKG_VERSION").into(), + bindings: vec![secrets::describe()], + })) + } + + async fn validate_config( + &self, + request: Request, + ) -> Result, Status> { + let request = request.into_inner(); + let config = request.config.unwrap_or_default(); + Ok(Response::new( + match validate_config(&request.binding_id, &config) { + Ok(()) => ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + Err(error) => ValidateConfigResponse { + valid: false, + reason: error.to_string(), + }, + }, + )) + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> Result, Status> { + evaluate_http_request(&request.into_inner()) + .map(Response::new) + .map_err(|error| Status::invalid_argument(error.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{ + Decision, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, + }; + + fn string_config(key: &str, value: &str) -> prost_types::Struct { + prost_types::Struct { + fields: std::iter::once(( + key.to_string(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue(value.into())), + }, + )) + .collect(), + } + } + + #[tokio::test] + async fn service_describes_secrets_binding() { + let manifest = BuiltinMiddlewareService + .describe(Request::new(())) + .await + .expect("describe") + .into_inner(); + assert_eq!(manifest.bindings.len(), 1); + assert_eq!(manifest.bindings[0].id, BUILTIN_SECRETS); + assert_eq!( + manifest.bindings[0].operation, + SupervisorMiddlewareOperation::HttpRequest as i32 + ); + assert_eq!( + manifest.bindings[0].phase, + SupervisorMiddlewarePhase::PreCredentials as i32 + ); + assert_eq!(manifest.bindings[0].max_body_bytes, 256 * 1024); + } + + #[test] + fn secrets_config_defaults_to_redact() { + let config = SecretsConfig::from_struct(&prost_types::Struct::default()).unwrap(); + assert_eq!(config.secrets, SecretsMode::Redact); + } + + #[test] + fn secrets_config_accepts_explicit_redact() { + let config = SecretsConfig::from_struct(&string_config("secrets", "redact")).unwrap(); + assert_eq!(config.secrets, SecretsMode::Redact); + } + + #[test] + fn secrets_config_rejects_unsupported_or_malformed_values() { + for config in [ + string_config("secrets", "allow"), + string_config("secret", "redact"), + prost_types::Struct { + fields: std::iter::once(( + "secrets".into(), + prost_types::Value { + kind: Some(prost_types::value::Kind::NumberValue(42.0)), + }, + )) + .collect(), + }, + ] { + assert!(validate_config(BUILTIN_SECRETS, &config).is_err()); + } + } + + #[test] + fn secrets_redaction_evaluates_through_binding() { + let result = evaluate_http_request(&HttpRequestEvaluation { + binding_id: BUILTIN_SECRETS.into(), + body: br#"{"password":"top-secret","token":"sk-ABCDEFGHIJKLMNOP"}"#.to_vec(), + config: Some(prost_types::Struct::default()), + ..Default::default() + }) + .expect("evaluate secrets binding"); + + assert_eq!(result.decision, Decision::Allow as i32); + assert!(result.has_body); + let body = String::from_utf8(result.body).unwrap(); + assert!(!body.contains("top-secret")); + assert!(!body.contains("sk-ABCDEFGHIJKLMNOP")); + } +} diff --git a/crates/openshell-supervisor-middleware-builtins/src/secrets.rs b/crates/openshell-supervisor-middleware-builtins/src/secrets.rs new file mode 100644 index 0000000000..b9c41f02f8 --- /dev/null +++ b/crates/openshell-supervisor-middleware-builtins/src/secrets.rs @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::HashMap; +use std::sync::LazyLock; + +use miette::{Result, miette}; +use openshell_core::proto::{ + Decision, Finding, HttpRequestEvaluation, HttpRequestResult, MiddlewareBinding, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, +}; +use regex::Regex; +use serde::Deserialize; + +pub const BINDING_ID: &str = "openshell/secrets"; +const MAX_BODY_BYTES: u64 = 256 * 1024; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SecretsConfig { + /// Redaction mode. Omitting the field selects [`SecretsMode::Redact`]. + pub secrets: SecretsMode, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SecretsMode { + #[default] + Redact, +} + +impl SecretsConfig { + pub fn from_struct(config: &prost_types::Struct) -> Result { + serde_json::from_value(openshell_core::proto_struct::struct_to_json_value(config)).map_err( + |error| { + miette!( + "invalid {BINDING_ID} config: {error}; phase 1 supports only secrets: redact" + ) + }, + ) + } +} + +pub fn describe() -> MiddlewareBinding { + MiddlewareBinding { + id: BINDING_ID.into(), + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_body_bytes: MAX_BODY_BYTES, + } +} + +struct SecretPattern { + kind: &'static str, + regex: Regex, +} + +impl SecretPattern { + fn new(kind: &'static str, pattern: &str) -> Self { + Self { + kind, + regex: Regex::new(pattern).expect("valid built-in secret redaction pattern"), + } + } +} + +static SECRET_PATTERNS: LazyLock<[SecretPattern; 2]> = LazyLock::new(|| { + [ + SecretPattern::new( + "keyword", + r#"(?i)(api[_-]?key|access[_-]?token|secret|password)(["']?\s*[:=]\s*["'])[^"',\s}]+(["']?)"#, + ), + SecretPattern::new("openai", r"(sk-[A-Za-z0-9_-]{16,})"), + ] +}); + +pub fn validate_config(config: &prost_types::Struct) -> Result<()> { + SecretsConfig::from_struct(config).map(|_| ()) +} + +pub fn evaluate_http_request(evaluation: &HttpRequestEvaluation) -> Result { + let default_config = prost_types::Struct::default(); + validate_config(evaluation.config.as_ref().unwrap_or(&default_config))?; + let text = String::from_utf8(evaluation.body.clone()) + .map_err(|_| miette!("{} requires UTF-8 request bodies", BINDING_ID))?; + let (body, matches) = redact_common_secrets(&text); + let total: u32 = matches + .iter() + .fold(0u32, |acc, (_, count)| acc.saturating_add(*count)); + let mut result = HttpRequestResult { + decision: Decision::Allow as i32, + reason: String::new(), + body: body.into_bytes(), + has_body: !matches.is_empty(), + header_mutations: Vec::new(), + findings: Vec::new(), + metadata: HashMap::new(), + }; + for (kind, count) in &matches { + result.findings.push(Finding { + r#type: format!("secret.{kind}"), + label: format!("{kind} secret pattern"), + count: *count, + confidence: "medium".into(), + severity: "medium".into(), + }); + } + if !matches.is_empty() { + result + .metadata + .insert("secrets_redacted".into(), total.to_string()); + } + Ok(result) +} + +fn redact_common_secrets(input: &str) -> (String, Vec<(&'static str, u32)>) { + let mut output = input.to_string(); + let mut matches = Vec::new(); + for pattern in SECRET_PATTERNS.iter() { + let count = u32::try_from(pattern.regex.find_iter(&output).count()).unwrap_or(u32::MAX); + if count > 0 { + matches.push((pattern.kind, count)); + } + output = pattern + .regex + .replace_all(&output, |captures: ®ex::Captures<'_>| { + if captures.len() >= 4 { + format!("{}{}[REDACTED]{}", &captures[1], &captures[2], &captures[3]) + } else { + "[REDACTED]".to_string() + } + }) + .into_owned(); + } + (output, matches) +} diff --git a/crates/openshell-supervisor-middleware/Cargo.toml b/crates/openshell-supervisor-middleware/Cargo.toml new file mode 100644 index 0000000000..2c095c64a6 --- /dev/null +++ b/crates/openshell-supervisor-middleware/Cargo.toml @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[package] +name = "openshell-supervisor-middleware" +description = "Supervisor middleware registry and chain execution for OpenShell" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true + +[dependencies] +openshell-core = { path = "../openshell-core", default-features = false } + +miette = { workspace = true } +prost-types = { workspace = true } +tokio = { workspace = true } +tonic = { workspace = true, features = ["channel", "server", "tls-native-roots"] } + +[dev-dependencies] +openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } +tokio-stream = { workspace = true, features = ["net"] } + +[lints] +workspace = true diff --git a/crates/openshell-supervisor-middleware/src/headers.rs b/crates/openshell-supervisor-middleware/src/headers.rs new file mode 100644 index 0000000000..8eb4c448e8 --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/headers.rs @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Validation and logical application of middleware request-header mutations. + +use miette::{Result, miette}; +use openshell_core::proto::{ExistingHeaderAction, HeaderMutation, header_mutation}; + +const MAX_HEADER_MUTATIONS: usize = 64; +const MAX_HEADER_MUTATION_BYTES: usize = 32 * 1024; + +/// Validate and atomically apply one middleware response to the logical header +/// state observed by the next middleware. Repeated values and wire order are +/// preserved; comparisons are case-insensitive. +pub fn apply( + existing_headers: &[(String, String)], + mutations: &[HeaderMutation], +) -> Result> { + if mutations.len() > MAX_HEADER_MUTATIONS { + return Err(miette!( + "middleware returned too many header mutations: {} exceeds {MAX_HEADER_MUTATIONS}", + mutations.len() + )); + } + + let mut headers = existing_headers.to_vec(); + let mut mutation_bytes = 0usize; + for mutation in mutations { + match mutation.operation.as_ref() { + Some(header_mutation::Operation::Write(write)) => { + let name = validate_name(&write.name)?; + if is_connection_nominated(&headers, &name) { + return Err(miette!( + "middleware cannot mutate hop-by-hop header '{}'", + write.name + )); + } + if !name.starts_with("x-openshell-middleware-") { + return Err(miette!( + "middleware can only write request headers prefixed with \ + x-openshell-middleware- and cannot write '{}'", + write.name + )); + } + if !is_safe_value(&write.value) { + return Err(miette!( + "middleware cannot write header '{}' with an unsafe value", + write.name + )); + } + mutation_bytes = mutation_bytes + .saturating_add(name.len()) + .saturating_add(write.value.len()); + enforce_size_limit(mutation_bytes)?; + + let action = ExistingHeaderAction::try_from(write.on_existing) + .map_err(|_| miette!("middleware returned invalid on_existing action"))?; + if action == ExistingHeaderAction::Unspecified { + return Err(miette!( + "middleware must specify on_existing for header '{}'", + write.name + )); + } + let exists = headers.iter().any(|(existing, _)| *existing == name); + if !exists || action == ExistingHeaderAction::Append { + headers.push((name, write.value.clone())); + } else if action == ExistingHeaderAction::Overwrite { + headers.retain(|(existing, _)| *existing != name); + headers.push((name, write.value.clone())); + } else if action != ExistingHeaderAction::Skip { + return Err(miette!( + "middleware returned unsupported on_existing action" + )); + } + } + Some(header_mutation::Operation::Remove(remove)) => { + let name = validate_name(&remove.name)?; + if is_connection_nominated(&headers, &name) { + return Err(miette!( + "middleware cannot mutate hop-by-hop header '{}'", + remove.name + )); + } + mutation_bytes = mutation_bytes.saturating_add(name.len()); + enforce_size_limit(mutation_bytes)?; + headers.retain(|(existing, _)| *existing != name); + } + None => return Err(miette!("middleware returned an empty header mutation")), + } + } + Ok(headers) +} + +fn enforce_size_limit(mutation_bytes: usize) -> Result<()> { + if mutation_bytes > MAX_HEADER_MUTATION_BYTES { + return Err(miette!( + "middleware header mutations exceed {MAX_HEADER_MUTATION_BYTES} bytes" + )); + } + Ok(()) +} + +fn validate_name(name: &str) -> Result { + let lower = name.to_ascii_lowercase(); + if lower.is_empty() || !lower.bytes().all(is_name_token_byte) { + return Err(miette!("middleware returned invalid header name '{name}'")); + } + if is_protected(&lower) { + return Err(miette!( + "middleware cannot mutate protected header '{name}'" + )); + } + Ok(lower) +} + +fn is_name_token_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) +} + +/// A header value is safe to write only if it contains no control characters. +/// Horizontal tab, printable ASCII, and obs-text (>= 0x80) are permitted; CR, LF, +/// NUL, and other control bytes are rejected. +fn is_safe_value(value: &str) -> bool { + value + .bytes() + .all(|byte| byte == b'\t' || (0x20..=0x7e).contains(&byte) || byte >= 0x80) +} + +fn is_protected(name: &str) -> bool { + matches!( + name, + "authorization" + | "proxy-authorization" + | "proxy-authenticate" + | "cookie" + | "host" + | "content-length" + | "transfer-encoding" + | "connection" + | "proxy-connection" + | "keep-alive" + | "te" + | "trailer" + | "upgrade" + ) || name.starts_with("x-amz-") + || name.starts_with("x-openshell-credential") +} + +fn is_connection_nominated(headers: &[(String, String)], name: &str) -> bool { + headers + .iter() + .filter(|(header, _)| header == "connection") + .flat_map(|(_, value)| value.split(',')) + .any(|token| token.trim().eq_ignore_ascii_case(name)) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{RemoveHeader, WriteHeader}; + + fn write(name: &str, value: &str, on_existing: ExistingHeaderAction) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Write(WriteHeader { + name: name.into(), + value: value.into(), + on_existing: on_existing as i32, + })), + } + } + + fn remove(name: &str) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Remove(RemoveHeader { + name: name.into(), + })), + } + } + + #[test] + fn protected_header_write_is_rejected() { + let error = apply( + &[], + &[write( + "Authorization", + "Bearer nope", + ExistingHeaderAction::Overwrite, + )], + ) + .expect_err("protected header"); + assert!( + error + .to_string() + .contains("protected header 'Authorization'") + ); + } + + #[test] + fn unsafe_header_value_is_rejected() { + let error = apply( + &[], + &[write( + "x-openshell-middleware-inject", + "ok\r\nAuthorization: Bearer evil", + ExistingHeaderAction::Append, + )], + ) + .expect_err("CRLF value"); + assert!(error.to_string().contains("unsafe value")); + } + + #[test] + fn existing_header_write_obeys_collision_action() { + let existing = [ + ("x-openshell-middleware-tag".to_string(), "one".to_string()), + ("accept".to_string(), "application/json".to_string()), + ]; + let appended = apply( + &existing, + &[write( + "X-OpenShell-Middleware-Tag", + "two", + ExistingHeaderAction::Append, + )], + ) + .expect("append existing header"); + assert_eq!( + appended, + vec![ + ("x-openshell-middleware-tag".into(), "one".into()), + ("accept".into(), "application/json".into()), + ("x-openshell-middleware-tag".into(), "two".into()), + ] + ); + + let overwritten = apply( + &existing, + &[write( + "X-OpenShell-Middleware-Tag", + "two", + ExistingHeaderAction::Overwrite, + )], + ) + .expect("overwrite existing header"); + assert_eq!( + overwritten, + vec![ + ("accept".into(), "application/json".into()), + ("x-openshell-middleware-tag".into(), "two".into()), + ] + ); + + let skipped = apply( + &existing, + &[write( + "X-OpenShell-Middleware-Tag", + "two", + ExistingHeaderAction::Skip, + )], + ) + .expect("skip existing header"); + assert_eq!(skipped, existing); + } + + #[test] + fn remove_drops_every_case_insensitive_value() { + let existing = [ + ("x-trace".to_string(), "one".to_string()), + ("accept".to_string(), "application/json".to_string()), + ("x-trace".to_string(), "two".to_string()), + ]; + let updated = apply(&existing, &[remove("X-Trace")]).expect("remove visible header"); + assert_eq!(updated, vec![("accept".into(), "application/json".into())]); + } + + #[test] + fn protected_header_remove_is_rejected_even_when_not_visible() { + let error = apply(&[], &[remove("Authorization")]).expect_err("protected removal"); + assert!( + error + .to_string() + .contains("protected header 'Authorization'") + ); + } + + #[test] + fn connection_nominated_header_is_protected() { + let existing = [ + ("connection".to_string(), "keep-alive, x-hop".to_string()), + ("x-hop".to_string(), "value".to_string()), + ]; + let error = apply(&existing, &[remove("X-Hop")]).expect_err("hop-by-hop removal"); + assert!(error.to_string().contains("hop-by-hop header 'X-Hop'")); + } +} diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs new file mode 100644 index 0000000000..e6fd8ae7a5 --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -0,0 +1,2344 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Supervisor middleware registration and chain execution. + +mod headers; +mod remote; + +#[cfg(test)] +use std::collections::HashMap; +use std::collections::{BTreeMap, HashSet}; +use std::sync::Arc; + +use miette::{Result, miette}; + +use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; +use openshell_core::proto::{ + Decision, Finding, HeaderMutation, HttpHeader, HttpRequestEvaluation, HttpRequestTarget, + MiddlewareBinding, MiddlewareManifest, NetworkMiddlewareConfig, RequestContext, SandboxPolicy, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, SupervisorMiddlewareService, + ValidateConfigRequest, +}; +use tokio::sync::OnceCell; +use tonic::Request; + +const HTTP_REQUEST_OPERATION: SupervisorMiddlewareOperation = + SupervisorMiddlewareOperation::HttpRequest; +const PRE_CREDENTIALS_PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OnError { + FailClosed, + FailOpen, +} + +impl OnError { + pub fn parse(value: &str) -> Result { + match value { + "" | "fail_closed" => Ok(Self::FailClosed), + "fail_open" => Ok(Self::FailOpen), + other => Err(miette!( + "invalid middleware on_error '{other}', expected fail_closed or fail_open" + )), + } + } +} + +#[derive(Debug, Clone)] +pub struct ChainEntry { + pub name: String, + pub implementation: String, + pub order: i32, + pub config: prost_types::Struct, + pub on_error: OnError, +} + +impl TryFrom<&NetworkMiddlewareConfig> for ChainEntry { + type Error = miette::Report; + + fn try_from(value: &NetworkMiddlewareConfig) -> Result { + if value.name.is_empty() { + return Err(miette!("middleware config name cannot be empty")); + } + if value.middleware.is_empty() { + return Err(miette!( + "middleware config '{}' must name an implementation", + value.name + )); + } + Ok(Self { + name: value.name.clone(), + implementation: value.middleware.clone(), + order: value.order, + config: value.config.clone().unwrap_or_default(), + on_error: OnError::parse(&value.on_error)?, + }) + } +} + +/// A policy-selected middleware config joined with metadata reported by its +/// service's `Describe` call. A missing binding is retained so `on_error` can +/// decide whether the request fails open or closed. +#[derive(Clone)] +pub struct DescribedChainEntry { + entry: ChainEntry, + service: Option>, + binding: Option, + max_body_bytes: usize, +} + +impl DescribedChainEntry { + pub fn max_body_bytes(&self) -> usize { + self.max_body_bytes + } + + pub fn on_error(&self) -> OnError { + self.entry.on_error + } + + /// True when this entry resolved to a registered binding and will be + /// evaluated. When false, the binding is absent from the current registry + /// and the entry is handled entirely by its `on_error` policy, so it + /// imposes no body-buffering limit on the chain. + pub fn is_resolved(&self) -> bool { + self.binding.is_some() + } +} + +/// Re-checks a middleware-transformed request body against sandbox policy. +/// +/// Returns `Some(reason)` to deny the chain, `None` to proceed. Invoked after +/// each stage that replaces the body so neither a later stage nor the upstream +/// sees a payload the policy would reject. Protocols with no body-aware policy +/// select [`TransformedBodyPolicy::NotPolicyRelevant`] instead. +pub type TransformedBodyValidator<'a> = dyn Fn(&[u8]) -> Result> + Send + Sync + 'a; + +/// Whether middleware body replacements affect the selected request policy. +/// +/// The network pipeline must choose a mode explicitly. This avoids representing +/// a security-relevant re-evaluation requirement as an optional callback where +/// an omitted value is indistinguishable from an intentionally body-independent +/// protocol. +#[derive(Clone, Copy)] +pub enum TransformedBodyPolicy<'a> { + /// The selected policy does not inspect the request body. + NotPolicyRelevant, + /// Re-evaluate every body replacement before the next stage runs. + Reevaluate(&'a TransformedBodyValidator<'a>), +} + +#[derive(Debug, Clone)] +pub struct HttpRequestInput { + pub request_id: String, + pub sandbox_id: String, + pub scheme: String, + pub host: String, + pub port: u16, + pub method: String, + pub path: String, + pub query: String, + /// Lowercased request headers in wire order. Repeated header names are + /// preserved as separate entries so middleware inspects every value the + /// upstream will receive. + pub headers: Vec<(String, String)>, + pub body: Vec, +} + +#[derive(Debug, Clone)] +pub struct ChainOutcome { + pub allowed: bool, + pub reason: String, + pub body: Vec, + /// Ordered, validated mutations to replay against the original raw request. + pub header_mutations: Vec, + pub findings: Vec, + pub metadata: BTreeMap>, + pub applied: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NamespacedFinding { + pub middleware: String, + pub finding: Finding, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MiddlewareInvocation { + pub name: String, + pub implementation: String, + pub decision: Decision, + pub transformed: bool, + /// True when the middleware could not be evaluated and `on_error` was applied + /// (service error, malformed/unsafe response, etc.). The `decision` reflects + /// the `on_error` outcome, not a decision the middleware actually returned. + pub failed: bool, +} + +enum OnErrorAction { + /// `fail_open`: skip this middleware, leaving the request unchanged. + FailOpen, + /// `fail_closed`: short-circuit the chain and deny with the given reason. + FailClosed(String), +} + +/// Apply a middleware entry's `on_error` policy after a failure (service error or +/// malformed response). Records a `failed` invocation for telemetry in both cases. +fn apply_on_error( + entry: &DescribedChainEntry, + reason: &str, + applied: &mut Vec, +) -> OnErrorAction { + match entry.entry.on_error { + OnError::FailOpen => { + applied.push(MiddlewareInvocation { + name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + decision: Decision::Allow, + transformed: false, + failed: true, + }); + OnErrorAction::FailOpen + } + OnError::FailClosed => { + applied.push(MiddlewareInvocation { + name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + decision: Decision::Deny, + transformed: false, + failed: true, + }); + OnErrorAction::FailClosed(format!("middleware_failed: {reason}")) + } + } +} + +#[derive(Clone)] +pub struct ChainRunner { + registry: Arc, +} + +struct MiddlewareServiceState { + service: Arc, + manifest: OnceCell, + operator_max_body_bytes: Option, +} + +/// Validated middleware services available to a gateway or one supervisor. +/// +/// In-process services are supplied by the composition root; the generic +/// registry does not select concrete built-ins. All in-process and remote +/// services are described before construction succeeds, so callers never +/// observe a partially registered service set. +#[derive(Clone)] +pub struct MiddlewareRegistry { + services: Arc>>, + registered_services: Arc>, + binding_ids: Arc>, +} + +impl std::fmt::Debug for MiddlewareRegistry { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("MiddlewareRegistry") + .field("service_count", &self.services.len()) + .field("registered_service_count", &self.registered_services.len()) + .field("binding_count", &self.binding_ids.len()) + .finish() + } +} + +#[derive(Clone)] +struct RegisteredMiddlewareService { + registration: SupervisorMiddlewareService, + binding_ids: Vec, +} + +impl Default for MiddlewareRegistry { + fn default() -> Self { + Self { + services: Arc::new(Vec::new()), + registered_services: Arc::new(Vec::new()), + binding_ids: Arc::new(HashSet::new()), + } + } +} + +fn validate_registration(registration: &SupervisorMiddlewareService) -> Result<()> { + if registration.name.trim().is_empty() { + return Err(miette!( + "supervisor middleware registration name cannot be empty" + )); + } + if !registration.grpc_endpoint.starts_with("http://") + && !registration.grpc_endpoint.starts_with("https://") + { + return Err(miette!( + "middleware registration '{}' grpc_endpoint must use http:// or https://", + registration.name + )); + } + if registration.max_body_bytes == 0 { + return Err(miette!( + "middleware registration '{}' max_body_bytes must be greater than zero", + registration.name + )); + } + Ok(()) +} + +fn validate_manifest_bindings( + source: &str, + manifest: &MiddlewareManifest, + operator_max_body_bytes: Option, + allow_reserved_bindings: bool, + known_binding_ids: &mut HashSet, +) -> Result> { + if manifest.bindings.is_empty() { + return Err(miette!("{source} describes no bindings")); + } + + let mut described_ids = Vec::with_capacity(manifest.bindings.len()); + for binding in &manifest.bindings { + if binding.id.trim().is_empty() { + return Err(miette!("{source} describes an empty binding id")); + } + if !allow_reserved_bindings && binding.id.starts_with("openshell/") { + return Err(miette!( + "{source} cannot claim reserved binding '{}'", + binding.id + )); + } + if binding.operation != HTTP_REQUEST_OPERATION as i32 + || binding.phase != PRE_CREDENTIALS_PHASE as i32 + { + return Err(miette!( + "middleware binding '{}' must support HTTP_REQUEST/PRE_CREDENTIALS", + binding.id, + )); + } + let advertised = usize::try_from(binding.max_body_bytes).map_err(|_| { + miette!( + "middleware binding '{}' reports a body limit too large for this platform", + binding.id + ) + })?; + if advertised == 0 { + return Err(miette!( + "middleware binding '{}' must advertise a non-zero body limit", + binding.id + )); + } + if operator_max_body_bytes.is_some_and(|limit| limit > advertised) { + return Err(miette!( + "{source} max_body_bytes ({}) exceeds binding '{}' capability ({advertised})", + operator_max_body_bytes.expect("operator limit checked above"), + binding.id + )); + } + if !known_binding_ids.insert(binding.id.clone()) { + return Err(miette!( + "middleware binding '{}' is described by more than one service", + binding.id + )); + } + described_ids.push(binding.id.clone()); + } + Ok(described_ids) +} + +fn validate_external_manifest( + registration: &SupervisorMiddlewareService, + manifest: &MiddlewareManifest, + operator_max_body_bytes: usize, + known_binding_ids: &mut HashSet, +) -> Result> { + validate_manifest_bindings( + &format!("external middleware registration '{}'", registration.name), + manifest, + Some(operator_max_body_bytes), + false, + known_binding_ids, + ) +} + +impl MiddlewareRegistry { + /// Describe in-process services, then connect and validate every + /// operator-provided service registration. + pub async fn connect_services( + in_process_services: Vec>, + registrations: Vec, + ) -> Result { + let mut services = Vec::with_capacity(in_process_services.len() + registrations.len()); + let mut registered_services = Vec::with_capacity(registrations.len()); + let mut registration_names = HashSet::new(); + let mut binding_ids = HashSet::new(); + + for service in in_process_services { + let manifest = service + .describe(Request::new(())) + .await + .map(tonic::Response::into_inner) + .map_err(|error| { + miette!( + "in-process middleware Describe failed: {}", + safe_reason(&error.to_string()) + ) + })?; + let source = if manifest.name.trim().is_empty() { + "in-process middleware service".to_string() + } else { + format!("in-process middleware service '{}'", manifest.name) + }; + validate_manifest_bindings(&source, &manifest, None, true, &mut binding_ids)?; + let manifest_cell = OnceCell::new(); + manifest_cell + .set(manifest) + .map_err(|_| miette!("middleware manifest cache initialized twice"))?; + services.push(Arc::new(MiddlewareServiceState { + service, + manifest: manifest_cell, + operator_max_body_bytes: None, + })); + } + + for registration in registrations { + validate_registration(®istration)?; + if !registration_names.insert(registration.name.clone()) { + return Err(miette!( + "duplicate supervisor middleware registration name '{}'", + registration.name + )); + } + + let operator_max_body_bytes = + usize::try_from(registration.max_body_bytes).map_err(|_| { + miette!( + "middleware registration '{}' body limit is too large for this platform", + registration.name + ) + })?; + let service = Arc::new( + remote::RemoteMiddlewareService::connect( + ®istration.name, + ®istration.grpc_endpoint, + ) + .await?, + ); + let manifest = service + .describe(Request::new(())) + .await + .map(tonic::Response::into_inner) + .map_err(|error| { + miette!( + "middleware registration '{}' Describe failed: {}", + registration.name, + safe_reason(&error.to_string()) + ) + })?; + let described_ids = validate_external_manifest( + ®istration, + &manifest, + operator_max_body_bytes, + &mut binding_ids, + )?; + let manifest_cell = OnceCell::new(); + manifest_cell + .set(manifest) + .map_err(|_| miette!("middleware manifest cache initialized twice"))?; + services.push(Arc::new(MiddlewareServiceState { + service, + manifest: manifest_cell, + operator_max_body_bytes: Some(operator_max_body_bytes), + })); + registered_services.push(RegisteredMiddlewareService { + registration, + binding_ids: described_ids, + }); + } + + Ok(Self { + services: Arc::new(services), + registered_services: Arc::new(registered_services), + binding_ids: Arc::new(binding_ids), + }) + } + + /// Validate implementation-owned configuration for every middleware entry. + pub async fn validate_policy_configs(&self, policy: &SandboxPolicy) -> Result<()> { + let runner = ChainRunner::from_registry(self.clone()); + for config in &policy.network_middlewares { + runner + .validate_config( + &config.middleware, + config.config.clone().unwrap_or_default(), + ) + .await + .map_err(|error| { + miette!( + "middleware config '{}' is invalid: {}", + config.name, + safe_reason(&error.to_string()) + ) + })?; + } + Ok(()) + } + + /// Check that every policy binding still belongs to the current static + /// registry without making a network call. + pub fn ensure_policy_bindings_registered(&self, policy: &SandboxPolicy) -> Result<()> { + for config in &policy.network_middlewares { + if !self.binding_ids.contains(&config.middleware) { + return Err(miette!( + "middleware binding '{}' used by config '{}' is not registered", + config.middleware, + config.name + )); + } + } + Ok(()) + } + + /// Return only operator-registered services referenced by the effective policy. + pub fn required_services( + &self, + policy: Option<&SandboxPolicy>, + ) -> Vec { + let Some(policy) = policy else { + return Vec::new(); + }; + let selected: HashSet<&str> = policy + .network_middlewares + .iter() + .map(|config| config.middleware.as_str()) + .collect(); + self.registered_services + .iter() + .filter(|service| { + service + .binding_ids + .iter() + .any(|binding| selected.contains(binding.as_str())) + }) + .map(|service| service.registration.clone()) + .collect() + } +} + +impl Default for ChainRunner { + fn default() -> Self { + Self::from_registry(MiddlewareRegistry::default()) + } +} + +impl ChainRunner { + pub fn new(service: Arc) -> Self { + Self { + registry: Arc::new(MiddlewareRegistry { + services: Arc::new(vec![Arc::new(MiddlewareServiceState { + service, + manifest: OnceCell::new(), + operator_max_body_bytes: None, + })]), + registered_services: Arc::new(Vec::new()), + binding_ids: Arc::new(HashSet::new()), + }), + } + } + + pub fn from_registry(registry: MiddlewareRegistry) -> Self { + Self { + registry: Arc::new(registry), + } + } + + async fn manifests(&self) -> Result, MiddlewareManifest)>> { + let mut manifests = Vec::with_capacity(self.registry.services.len()); + for state in self.registry.services.iter() { + let manifest = state + .manifest + .get_or_try_init(|| async { + state + .service + .describe(Request::new(())) + .await + .map(tonic::Response::into_inner) + .map_err(|error| { + miette!( + "middleware Describe failed: {}", + safe_reason(&error.to_string()) + ) + }) + }) + .await?; + manifests.push((Arc::clone(state), manifest.clone())); + } + Ok(manifests) + } + + pub async fn describe_chain(&self, entries: &[ChainEntry]) -> Result> { + let manifests = self.manifests().await?; + let mut entries = entries.to_vec(); + sort_chain_entries(&mut entries); + entries + .iter() + .map(|entry| { + let described = manifests.iter().find_map(|(state, manifest)| { + manifest + .bindings + .iter() + .find(|binding| binding.id == entry.implementation) + .cloned() + .map(|binding| (Arc::clone(state), binding)) + }); + let (service, binding) = described.map_or((None, None), |(service, binding)| { + (Some(service), Some(binding)) + }); + let max_body_bytes = binding + .as_ref() + .map(|binding| { + let advertised = usize::try_from(binding.max_body_bytes).map_err(|_| { + miette!( + "middleware binding '{}' reports a body limit too large for this platform", + binding.id + ) + })?; + Ok::<_, miette::Report>(service + .as_ref() + .and_then(|state| state.operator_max_body_bytes) + .unwrap_or(advertised)) + }) + .transpose()? + .unwrap_or(0); + Ok(DescribedChainEntry { + entry: entry.clone(), + service, + binding, + max_body_bytes, + }) + }) + .collect() + } + + pub async fn validate_config( + &self, + implementation: &str, + config: prost_types::Struct, + ) -> Result<()> { + let manifests = self.manifests().await?; + let Some((state, _)) = manifests.iter().find(|(_, manifest)| { + manifest + .bindings + .iter() + .any(|binding| binding.id == implementation) + }) else { + return Err(miette!( + "middleware binding '{implementation}' is not registered" + )); + }; + let response = state + .service + .validate_config(Request::new(ValidateConfigRequest { + binding_id: implementation.into(), + config: Some(config), + })) + .await + .map(tonic::Response::into_inner) + .map_err(|error| { + miette!( + "middleware ValidateConfig failed: {}", + safe_reason(&error.to_string()) + ) + })?; + if response.valid { + Ok(()) + } else { + Err(miette!("{}", safe_reason(&response.reason))) + } + } + + pub async fn evaluate( + &self, + entries: &[ChainEntry], + input: HttpRequestInput, + ) -> Result { + let entries = self.describe_chain(entries).await?; + self.evaluate_described(&entries, input).await + } + + pub async fn evaluate_described( + &self, + entries: &[DescribedChainEntry], + input: HttpRequestInput, + ) -> Result { + self.evaluate_described_with_policy( + entries, + input, + TransformedBodyPolicy::NotPolicyRelevant, + ) + .await + } + + /// Evaluate a described chain, re-checking the request body against sandbox + /// policy after every stage that replaces it. Policy runs on the original + /// body before the chain, so without this a stage could hand the next stage + /// (or the upstream) a payload the policy rejects. When the evaluator returns + /// a deny reason the chain stops with that reason, so no later stage ever + /// sees a non-compliant body. Body-independent protocols must select + /// [`TransformedBodyPolicy::NotPolicyRelevant`] explicitly. + pub async fn evaluate_described_with_policy( + &self, + entries: &[DescribedChainEntry], + input: HttpRequestInput, + transformed_body_policy: TransformedBodyPolicy<'_>, + ) -> Result { + let mut headers = input.headers.clone(); + let mut body = input.body.clone(); + let mut header_mutations = Vec::new(); + let mut findings = Vec::new(); + let mut metadata = BTreeMap::new(); + let mut applied = Vec::new(); + + for entry in entries { + let Some(binding) = entry.binding.as_ref() else { + match apply_on_error(entry, "binding_not_described", &mut applied) { + OnErrorAction::FailOpen => continue, + OnErrorAction::FailClosed(reason) => { + return Ok(ChainOutcome { + allowed: false, + reason, + body, + header_mutations, + findings, + metadata, + applied, + }); + } + } + }; + if body.len() > entry.max_body_bytes { + match apply_on_error(entry, "request_body_over_capacity", &mut applied) { + OnErrorAction::FailOpen => continue, + OnErrorAction::FailClosed(reason) => { + return Ok(ChainOutcome { + allowed: false, + reason, + body, + header_mutations, + findings, + metadata, + applied, + }); + } + } + } + let evaluation = build_evaluation(entry, binding, &input, &headers, &body); + let Some(service) = entry.service.as_ref() else { + unreachable!("described binding always has a service") + }; + let result = match service + .service + .evaluate_http_request(Request::new(evaluation)) + .await + { + Ok(result) => result.into_inner(), + Err(err) => { + match apply_on_error(entry, &safe_reason(&err.to_string()), &mut applied) { + OnErrorAction::FailOpen => continue, + OnErrorAction::FailClosed(reason) => { + return Ok(ChainOutcome { + allowed: false, + reason, + body, + header_mutations, + findings, + metadata, + applied, + }); + } + } + } + }; + + let decision = match Decision::try_from(result.decision) { + Ok(decision @ (Decision::Allow | Decision::Deny)) => decision, + Ok(Decision::Unspecified) | Err(_) => { + match apply_on_error(entry, "invalid_response_decision", &mut applied) { + OnErrorAction::FailOpen => continue, + OnErrorAction::FailClosed(reason) => { + return Ok(ChainOutcome { + allowed: false, + reason, + body, + header_mutations, + findings, + metadata, + applied, + }); + } + } + } + }; + + if decision == Decision::Deny { + for finding in result.findings { + findings.push(NamespacedFinding { + middleware: entry.entry.name.clone(), + finding, + }); + } + if !result.metadata.is_empty() { + metadata.insert( + entry.entry.name.clone(), + result.metadata.into_iter().collect(), + ); + } + applied.push(MiddlewareInvocation { + name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + decision, + transformed: false, + failed: false, + }); + return Ok(ChainOutcome { + allowed: false, + reason: safe_reason(&result.reason), + body, + header_mutations, + findings, + metadata, + applied, + }); + } + + if result.has_body && result.body.len() > entry.max_body_bytes { + match apply_on_error(entry, "response_body_over_capacity", &mut applied) { + OnErrorAction::FailOpen => continue, + OnErrorAction::FailClosed(reason) => { + return Ok(ChainOutcome { + allowed: false, + reason, + body, + header_mutations, + findings, + metadata, + applied, + }); + } + } + } + + // Validate and apply the entire stage atomically. Under fail-open, + // one malformed mutation must not leave earlier mutations from the + // same response visible to later middleware. + let updated_headers = match headers::apply(&headers, &result.header_mutations) { + Ok(updated) => updated, + Err(error) => { + match apply_on_error(entry, &safe_reason(&error.to_string()), &mut applied) { + OnErrorAction::FailOpen => continue, + OnErrorAction::FailClosed(reason) => { + return Ok(ChainOutcome { + allowed: false, + reason, + body, + header_mutations, + findings, + metadata, + applied, + }); + } + } + } + }; + let headers_transformed = updated_headers != headers; + headers = updated_headers; + header_mutations.extend(result.header_mutations.iter().cloned()); + + let body_transformed = result.has_body; + if body_transformed { + result.body.clone_into(&mut body); + } + for finding in result.findings { + findings.push(NamespacedFinding { + middleware: entry.entry.name.clone(), + finding, + }); + } + if !result.metadata.is_empty() { + metadata.insert( + entry.entry.name.clone(), + result.metadata.clone().into_iter().collect(), + ); + } + applied.push(MiddlewareInvocation { + name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + decision, + transformed: body_transformed || headers_transformed, + failed: false, + }); + + // The stage ran successfully but its output must still satisfy the + // sandbox policy the original body was admitted under. Re-check now, + // before the next stage or the upstream sees the replaced body. A + // policy deny here is a hard deny, independent of `on_error`. + if body_transformed + && let TransformedBodyPolicy::Reevaluate(validate) = transformed_body_policy + { + let denied = match validate(&body) { + Ok(reason) => reason, + Err(error) => Some(format!( + "transformed_body_policy_evaluation_failed: {}", + safe_reason(&error.to_string()) + )), + }; + if let Some(reason) = denied { + return Ok(ChainOutcome { + allowed: false, + reason, + body, + header_mutations, + findings, + metadata, + applied, + }); + } + } + } + + Ok(ChainOutcome { + allowed: true, + reason: String::new(), + body, + header_mutations, + findings, + metadata, + applied, + }) + } +} + +/// Sort middleware using the policy-defined priority and a stable name tie-breaker. +pub fn sort_chain_entries(entries: &mut [ChainEntry]) { + entries.sort_by(|left, right| { + left.order + .cmp(&right.order) + .then_with(|| left.name.cmp(&right.name)) + }); +} + +fn build_evaluation( + entry: &DescribedChainEntry, + binding: &MiddlewareBinding, + input: &HttpRequestInput, + headers: &[(String, String)], + body: &[u8], +) -> HttpRequestEvaluation { + HttpRequestEvaluation { + binding_id: binding.id.clone(), + phase: binding.phase, + context: Some(RequestContext { + request_id: input.request_id.clone(), + sandbox_id: input.sandbox_id.clone(), + originating_process: None, + }), + config: Some(entry.entry.config.clone()), + target: Some(HttpRequestTarget { + scheme: input.scheme.clone(), + host: input.host.clone(), + port: u32::from(input.port), + method: input.method.clone(), + path: input.path.clone(), + query: input.query.clone(), + }), + headers: headers + .iter() + .map(|(name, value)| HttpHeader { + name: name.clone(), + value: value.clone(), + }) + .collect(), + body: body.to_vec(), + } +} + +pub(crate) fn safe_reason(reason: &str) -> String { + reason + .chars() + .filter(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':' | ' ')) + .take(160) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ + SupervisorMiddleware, SupervisorMiddlewareServer, + }; + use openshell_core::proto::{ExistingHeaderAction, header_mutation}; + use openshell_supervisor_middleware_builtins::{BUILTIN_SECRETS, services}; + use tokio_stream::wrappers::TcpListenerStream; + + fn builtin_runner() -> ChainRunner { + ChainRunner::new( + services() + .into_iter() + .next() + .expect("built-in middleware service"), + ) + } + + fn entry(name: &str, on_error: OnError) -> ChainEntry { + ChainEntry { + name: name.into(), + implementation: BUILTIN_SECRETS.into(), + order: 0, + config: prost_types::Struct { + fields: std::iter::once(( + "secrets".into(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue("redact".into())), + }, + )) + .collect(), + }, + on_error, + } + } + + fn input(body: &str) -> HttpRequestInput { + HttpRequestInput { + request_id: "req".into(), + sandbox_id: "sbx".into(), + scheme: "https".into(), + host: "api.example.com".into(), + port: 443, + method: "POST".into(), + path: "/v1".into(), + query: String::new(), + headers: Vec::new(), + body: body.as_bytes().to_vec(), + } + } + + fn write_header(name: &str, value: &str, on_existing: ExistingHeaderAction) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Write( + openshell_core::proto::WriteHeader { + name: name.into(), + value: value.into(), + on_existing: on_existing as i32, + }, + )), + } + } + + #[tokio::test] + async fn phase_one_evaluation_omits_originating_process() { + let entries = builtin_runner() + .describe_chain(&[entry("redact", OnError::FailClosed)]) + .await + .expect("describe chain"); + let entry = &entries[0]; + let binding = entry.binding.as_ref().expect("described binding"); + let input = input("payload"); + let evaluation = build_evaluation(entry, binding, &input, &[], b"payload"); + + assert_eq!( + evaluation.phase, + SupervisorMiddlewarePhase::PreCredentials as i32 + ); + assert!( + evaluation + .context + .expect("request context") + .originating_process + .is_none() + ); + } + + #[tokio::test] + async fn redacts_common_secret_patterns() { + let outcome = builtin_runner() + .evaluate( + &[entry("redact", OnError::FailClosed)], + input(r#"{"api_key":"sk-1234567890abcdef"}"#), + ) + .await + .expect("evaluate"); + assert!(outcome.allowed); + assert_eq!( + String::from_utf8(outcome.body).expect("utf8"), + r#"{"api_key":"[REDACTED]"}"# + ); + assert_eq!(outcome.findings[0].finding.count, 1); + } + + #[tokio::test] + async fn transformed_body_feeds_next_stage() { + let entries = [ + entry("first", OnError::FailClosed), + entry("second", OnError::FailClosed), + ]; + let outcome = builtin_runner() + .evaluate(&entries, input(r#"password="top-secret""#)) + .await + .expect("evaluate"); + assert!(outcome.allowed); + assert_eq!( + String::from_utf8(outcome.body).expect("utf8"), + r#"password="[REDACTED]""# + ); + assert_eq!(outcome.applied.len(), 2); + } + + #[tokio::test] + async fn describe_chain_sorts_by_order_then_name() { + let mut later = entry("later", OnError::FailClosed); + later.order = 20; + let mut beta = entry("beta", OnError::FailClosed); + beta.order = 10; + let mut alpha = entry("alpha", OnError::FailClosed); + alpha.order = 10; + + let described = builtin_runner() + .describe_chain(&[later, beta, alpha]) + .await + .expect("describe ordered chain"); + let names: Vec<_> = described + .iter() + .map(|entry| entry.entry.name.as_str()) + .collect(); + assert_eq!(names, vec!["alpha", "beta", "later"]); + } + + #[tokio::test] + async fn fail_open_allows_unavailable_middleware() { + let unavailable = ChainEntry { + name: "missing".into(), + implementation: "third-party/missing".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }; + let outcome = builtin_runner() + .evaluate(&[unavailable], input("hello")) + .await + .expect("evaluate"); + assert!(outcome.allowed); + assert_eq!(outcome.body, b"hello"); + } + + #[tokio::test] + async fn fail_closed_denies_unavailable_middleware() { + let unavailable = ChainEntry { + name: "missing".into(), + implementation: "third-party/missing".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let outcome = builtin_runner() + .evaluate(&[unavailable], input("hello")) + .await + .expect("evaluate"); + assert!(!outcome.allowed); + assert!(outcome.reason.starts_with("middleware_failed:")); + } + + #[tokio::test] + async fn injected_service_bindings_drive_registration_checks() { + let registry = MiddlewareRegistry::connect_services(services(), Vec::new()) + .await + .expect("connect built-in service"); + let policy = SandboxPolicy { + network_middlewares: vec![NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: BUILTIN_SECRETS.into(), + ..Default::default() + }], + ..Default::default() + }; + registry + .ensure_policy_bindings_registered(&policy) + .expect("described binding is registered"); + + let unknown = SandboxPolicy { + network_middlewares: vec![NetworkMiddlewareConfig { + name: "unknown".into(), + middleware: "openshell/unknown".into(), + ..Default::default() + }], + ..Default::default() + }; + assert!( + registry + .ensure_policy_bindings_registered(&unknown) + .is_err() + ); + } + + #[tokio::test] + async fn injected_services_cannot_duplicate_binding_ids() { + let first: Arc = Arc::new(ScriptedService { + binding_id: "openshell/test".into(), + max_body_bytes: 1024, + result: allow_result(), + }); + let second: Arc = Arc::new(ScriptedService { + binding_id: "openshell/test".into(), + max_body_bytes: 1024, + result: allow_result(), + }); + + let error = MiddlewareRegistry::connect_services(vec![first, second], Vec::new()) + .await + .expect_err("duplicate injected binding must fail registry construction"); + assert!(error.to_string().contains("more than one service")); + } + + /// A mock middleware that returns a fixed, caller-supplied result for every + /// evaluation. Used to exercise chain behavior the built-in cannot produce + /// (explicit deny, metadata, findings, unsafe header mutations). + struct ScriptedService { + binding_id: String, + max_body_bytes: u64, + result: openshell_core::proto::HttpRequestResult, + } + + #[tonic::async_trait] + impl SupervisorMiddleware for ScriptedService { + async fn describe( + &self, + _request: Request<()>, + ) -> std::result::Result, tonic::Status> { + Ok(tonic::Response::new(MiddlewareManifest { + name: "test/middleware".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + id: self.binding_id.clone(), + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_body_bytes: self.max_body_bytes, + }], + })) + } + + async fn validate_config( + &self, + _request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + _request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new(self.result.clone())) + } + } + + /// A two-binding service for exercising per-stage validation: `test/transform` + /// replaces the body, `test/second` records that it ran and allows. + struct TwoStageService { + second_ran: Arc, + } + + #[tonic::async_trait] + impl SupervisorMiddleware for TwoStageService { + async fn describe( + &self, + _request: Request<()>, + ) -> std::result::Result, tonic::Status> { + let binding = |id: &str| MiddlewareBinding { + id: id.into(), + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_body_bytes: 256 * 1024, + }; + Ok(tonic::Response::new(MiddlewareManifest { + name: "test/two-stage".into(), + service_version: "test".into(), + bindings: vec![binding("test/transform"), binding("test/second")], + })) + } + + async fn validate_config( + &self, + _request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + let evaluation = request.into_inner(); + let mut result = allow_result(); + if evaluation.binding_id == "test/transform" { + result.body = b"TRANSFORMED".to_vec(); + result.has_body = true; + } else { + self.second_ran + .store(true, std::sync::atomic::Ordering::SeqCst); + } + Ok(tonic::Response::new(result)) + } + } + + #[tokio::test] + async fn per_stage_validation_denies_before_the_next_stage_runs() { + // The validator rejects the first stage's transformed body. The chain + // must stop there: the second stage never runs, so it never sees a + // payload the policy would reject. + let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let service: Arc = Arc::new(TwoStageService { + second_ran: Arc::clone(&second_ran), + }); + let runner = ChainRunner::new(service); + let transform = ChainEntry { + name: "transform".into(), + implementation: "test/transform".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let second = ChainEntry { + name: "second".into(), + implementation: "test/second".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let described = runner + .describe_chain(&[transform, second]) + .await + .expect("describe two-stage chain"); + + let validator: Box> = Box::new(|body: &[u8]| { + if body == b"TRANSFORMED" { + Ok(Some("transformed body denied by policy".to_string())) + } else { + Ok(None) + } + }); + let outcome = runner + .evaluate_described_with_policy( + &described, + input("original"), + TransformedBodyPolicy::Reevaluate(&*validator), + ) + .await + .expect("evaluate two-stage chain"); + + assert!(!outcome.allowed); + assert_eq!(outcome.reason, "transformed body denied by policy"); + assert_eq!(outcome.applied.len(), 1, "only the first stage should run"); + assert_eq!(outcome.applied[0].name, "transform"); + assert!( + !second_ran.load(std::sync::atomic::Ordering::SeqCst), + "second stage must not run after a policy deny" + ); + } + + #[tokio::test] + async fn per_stage_validator_allows_compliant_transformations() { + // A validator that accepts every body lets both stages run; the second + // stage sees the first stage's output. + let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let service: Arc = Arc::new(TwoStageService { + second_ran: Arc::clone(&second_ran), + }); + let runner = ChainRunner::new(service); + let transform = ChainEntry { + name: "transform".into(), + implementation: "test/transform".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let second = ChainEntry { + name: "second".into(), + implementation: "test/second".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let described = runner + .describe_chain(&[transform, second]) + .await + .expect("describe two-stage chain"); + + let validator: Box> = Box::new(|_body: &[u8]| Ok(None)); + let outcome = runner + .evaluate_described_with_policy( + &described, + input("original"), + TransformedBodyPolicy::Reevaluate(&*validator), + ) + .await + .expect("evaluate two-stage chain"); + + assert!(outcome.allowed); + assert_eq!(outcome.applied.len(), 2); + assert!( + second_ran.load(std::sync::atomic::Ordering::SeqCst), + "second stage should run when the transformation is compliant" + ); + } + + #[tokio::test] + async fn per_stage_validator_error_becomes_structured_denial() { + let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let service: Arc = Arc::new(TwoStageService { + second_ran: Arc::clone(&second_ran), + }); + let runner = ChainRunner::new(service); + let entries = [ + ChainEntry { + name: "transform".into(), + implementation: "test/transform".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ChainEntry { + name: "second".into(), + implementation: "test/second".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ]; + let described = runner + .describe_chain(&entries) + .await + .expect("describe two-stage chain"); + let validator: Box> = + Box::new(|_body: &[u8]| Err(miette!("OPA engine unavailable"))); + + let outcome = runner + .evaluate_described_with_policy( + &described, + input("original"), + TransformedBodyPolicy::Reevaluate(&*validator), + ) + .await + .expect("policy evaluator failure should be a chain outcome"); + + assert!(!outcome.allowed); + assert!( + outcome + .reason + .starts_with("transformed_body_policy_evaluation_failed:"), + "{}", + outcome.reason + ); + assert_eq!(outcome.applied.len(), 1); + assert!(!second_ran.load(std::sync::atomic::Ordering::SeqCst)); + } + + fn scripted_service(result: openshell_core::proto::HttpRequestResult) -> ScriptedService { + ScriptedService { + binding_id: BUILTIN_SECRETS.into(), + max_body_bytes: 256 * 1024, + result, + } + } + + fn allow_result() -> openshell_core::proto::HttpRequestResult { + openshell_core::proto::HttpRequestResult { + decision: Decision::Allow as i32, + reason: String::new(), + body: Vec::new(), + has_body: false, + header_mutations: Vec::new(), + findings: Vec::new(), + metadata: HashMap::new(), + } + } + + /// A middleware that records every evaluation it receives and allows the + /// request, for asserting what the supervisor actually sends to services. + struct RecordingService { + received: std::sync::Mutex>, + } + + #[tonic::async_trait] + impl SupervisorMiddleware for RecordingService { + async fn describe( + &self, + _request: Request<()>, + ) -> std::result::Result, tonic::Status> { + Ok(tonic::Response::new(MiddlewareManifest { + name: "test/recorder".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + id: "example/recorder".into(), + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_body_bytes: 4096, + }], + })) + } + + async fn validate_config( + &self, + _request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.received + .lock() + .expect("recording lock") + .push(request.into_inner()); + Ok(tonic::Response::new(allow_result())) + } + } + + /// Three-stage service used to verify that each stage observes the header + /// state produced by all preceding stages. + struct HeaderChainService { + second_action: ExistingHeaderAction, + received: std::sync::Mutex>, + } + + #[tonic::async_trait] + impl SupervisorMiddleware for HeaderChainService { + async fn describe( + &self, + _request: Request<()>, + ) -> std::result::Result, tonic::Status> { + Ok(tonic::Response::new(MiddlewareManifest { + name: "test/header-chain".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + id: "example/header-chain".into(), + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_body_bytes: 4096, + }], + })) + } + + async fn validate_config( + &self, + _request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + let evaluation = request.into_inner(); + let invocation = { + let mut received = self.received.lock().expect("header chain lock"); + let invocation = received.len(); + received.push(evaluation); + invocation + }; + let mut result = allow_result(); + if invocation == 0 { + result.header_mutations.push(write_header( + "x-openshell-middleware-chain", + "first", + ExistingHeaderAction::Overwrite, + )); + } else if invocation == 1 { + result.header_mutations.push(write_header( + "x-openshell-middleware-chain", + "second", + self.second_action, + )); + } + Ok(tonic::Response::new(result)) + } + } + + #[tokio::test] + async fn later_middleware_observes_prior_header_mutations() { + for (action, expected) in [ + (ExistingHeaderAction::Append, vec!["first", "second"]), + (ExistingHeaderAction::Overwrite, vec!["second"]), + (ExistingHeaderAction::Skip, vec!["first"]), + ] { + let service = Arc::new(HeaderChainService { + second_action: action, + received: std::sync::Mutex::new(Vec::new()), + }); + let runner = ChainRunner::new(service.clone()); + let entries = [ + ChainEntry { + name: "first".into(), + implementation: "example/header-chain".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ChainEntry { + name: "second".into(), + implementation: "example/header-chain".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ChainEntry { + name: "observer".into(), + implementation: "example/header-chain".into(), + order: 20, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ]; + + let outcome = runner + .evaluate(&entries, input("payload")) + .await + .expect("evaluate header chain"); + assert!(outcome.allowed); + let received = service.received.lock().expect("recorded header chain"); + let observed: Vec<&str> = received[2] + .headers + .iter() + .filter(|header| header.name == "x-openshell-middleware-chain") + .map(|header| header.value.as_str()) + .collect(); + assert_eq!(observed, expected, "action {action:?}"); + } + } + + #[tokio::test] + async fn repeated_request_headers_reach_middleware_in_wire_order() { + // A map contract would collapse repeated header names to one value + // while the upstream still receives every original value, creating an + // inspection differential. The service must see each entry in wire + // order. + let service = Arc::new(RecordingService { + received: std::sync::Mutex::new(Vec::new()), + }); + let recorder: Arc = service.clone(); + let runner = ChainRunner::new(recorder); + let recorder_entry = ChainEntry { + name: "recorder".into(), + implementation: "example/recorder".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let mut request = input("payload"); + request.headers = vec![ + ("x-api-key".into(), "first-value".into()), + ("accept".into(), "application/json".into()), + ("x-api-key".into(), "second-value".into()), + ]; + + let outcome = runner + .evaluate(&[recorder_entry], request) + .await + .expect("evaluate recording chain"); + assert!(outcome.allowed); + + let received = service.received.lock().expect("recorded evaluations"); + assert_eq!(received.len(), 1); + let headers: Vec<(&str, &str)> = received[0] + .headers + .iter() + .map(|header| (header.name.as_str(), header.value.as_str())) + .collect(); + assert_eq!( + headers, + vec![ + ("x-api-key", "first-value"), + ("accept", "application/json"), + ("x-api-key", "second-value"), + ] + ); + } + + fn external_registration(max_body_bytes: u64) -> SupervisorMiddlewareService { + SupervisorMiddlewareService { + name: "local-guard-service".into(), + grpc_endpoint: "http://127.0.0.1:50051".into(), + max_body_bytes, + } + } + + async fn registry_with_external( + service: Arc, + registration: SupervisorMiddlewareService, + ) -> MiddlewareRegistry { + let builtin_service = services() + .into_iter() + .next() + .expect("built-in middleware service"); + let builtin_manifest = builtin_service + .describe(Request::new(())) + .await + .expect("describe built-in service") + .into_inner(); + let mut known = HashSet::new(); + validate_manifest_bindings( + "test built-in service", + &builtin_manifest, + None, + true, + &mut known, + ) + .expect("valid built-in manifest"); + let builtin_manifest_cell = OnceCell::new(); + builtin_manifest_cell + .set(builtin_manifest) + .expect("built-in manifest cache"); + + let manifest = service + .describe(Request::new(())) + .await + .expect("describe test service") + .into_inner(); + let operator_max_body_bytes = usize::try_from(registration.max_body_bytes).unwrap(); + let binding_ids = validate_external_manifest( + ®istration, + &manifest, + operator_max_body_bytes, + &mut known, + ) + .expect("valid external manifest"); + let manifest_cell = OnceCell::new(); + manifest_cell.set(manifest).expect("manifest cache"); + MiddlewareRegistry { + services: Arc::new(vec![ + Arc::new(MiddlewareServiceState { + service: builtin_service, + manifest: builtin_manifest_cell, + operator_max_body_bytes: None, + }), + Arc::new(MiddlewareServiceState { + service, + manifest: manifest_cell, + operator_max_body_bytes: Some(operator_max_body_bytes), + }), + ]), + registered_services: Arc::new(vec![RegisteredMiddlewareService { + registration, + binding_ids, + }]), + binding_ids: Arc::new(known), + } + } + + #[tokio::test] + async fn describe_chain_marks_resolved_and_unresolved_entries() { + let unresolved = ChainEntry { + name: "missing".into(), + implementation: "third-party/missing".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }; + let described = builtin_runner() + .describe_chain(&[entry("redact", OnError::FailClosed), unresolved]) + .await + .expect("describe chain"); + // The built-in resolves and reports its real limit; the missing binding + // does not resolve and must not contribute a body limit. + assert!(described[0].is_resolved()); + assert_eq!(described[0].max_body_bytes(), 256 * 1024); + assert!(!described[1].is_resolved()); + } + + #[tokio::test] + async fn descriptors_are_resolved_from_any_middleware_service() { + let runner = ChainRunner::new(Arc::new(ScriptedService { + binding_id: "example/redactor".into(), + max_body_bytes: 4096, + result: allow_result(), + })); + let entry = ChainEntry { + name: "external".into(), + implementation: "example/redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + + let described = runner + .describe_chain(std::slice::from_ref(&entry)) + .await + .expect("describe external middleware"); + assert_eq!(described[0].max_body_bytes(), 4096); + assert_eq!( + described[0] + .binding + .as_ref() + .expect("described binding") + .phase, + SupervisorMiddlewarePhase::PreCredentials as i32 + ); + + let outcome = runner + .evaluate_described(&described, input("hello")) + .await + .expect("evaluate external middleware"); + assert!(outcome.allowed); + } + + #[tokio::test] + async fn mixed_builtin_and_external_chain_uses_operator_limit() { + let external = Arc::new(ScriptedService { + binding_id: "example/content-guard".into(), + max_body_bytes: 4096, + result: allow_result(), + }); + let registry = registry_with_external(external, external_registration(1024)).await; + let runner = ChainRunner::from_registry(registry); + let external_entry = ChainEntry { + name: "external".into(), + implementation: "example/content-guard".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let entries = [entry("builtin", OnError::FailClosed), external_entry]; + + let described = runner + .describe_chain(&entries) + .await + .expect("describe chain"); + assert_eq!(described[0].max_body_bytes(), 256 * 1024); + assert_eq!(described[1].max_body_bytes(), 1024); + + let outcome = runner + .evaluate_described(&described, input(r#"password="top-secret""#)) + .await + .expect("evaluate mixed chain"); + assert!(outcome.allowed); + assert_eq!(outcome.applied.len(), 2); + assert_eq!( + String::from_utf8(outcome.body).expect("utf8"), + r#"password="[REDACTED]""# + ); + } + + #[tokio::test] + async fn undersized_stage_fails_open_while_later_stage_runs() { + // A body over one stage's limit must fail only that stage through its + // own `on_error`, not the whole chain: the 1 KiB fail-open guard is + // skipped while the 256 KiB fail-closed redactor still runs. + let external = Arc::new(ScriptedService { + binding_id: "example/content-guard".into(), + max_body_bytes: 4096, + result: allow_result(), + }); + let registry = registry_with_external(external, external_registration(1024)).await; + let runner = ChainRunner::from_registry(registry); + let guard_entry = ChainEntry { + name: "guard".into(), + implementation: "example/content-guard".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }; + let mut redact_entry = entry("redact", OnError::FailClosed); + redact_entry.order = 10; + let entries = [guard_entry, redact_entry]; + + let body = format!("{}password=\"top-secret\"", "x".repeat(1500)); + let outcome = runner + .evaluate(&entries, input(&body)) + .await + .expect("evaluate mixed-limit chain"); + + assert!(outcome.allowed); + assert_eq!(outcome.applied.len(), 2); + assert!( + outcome.applied[0].failed, + "undersized guard must be skipped" + ); + assert_eq!(outcome.applied[0].decision, Decision::Allow); + assert!(!outcome.applied[1].failed); + assert!(outcome.applied[1].transformed); + let body = String::from_utf8(outcome.body).expect("utf8"); + assert!(body.contains("[REDACTED]")); + assert!(!body.contains("top-secret")); + } + + #[tokio::test] + async fn transformed_body_still_over_later_stage_capacity_honors_on_error() { + // Per-stage capacity applies to the current body: the redactor's + // replacement is still over the 1 KiB guard limit, so the fail-closed + // guard denies through its own `on_error` after the redactor ran. + let external = Arc::new(ScriptedService { + binding_id: "example/content-guard".into(), + max_body_bytes: 4096, + result: allow_result(), + }); + let registry = registry_with_external(external, external_registration(1024)).await; + let runner = ChainRunner::from_registry(registry); + let guard_entry = ChainEntry { + name: "guard".into(), + implementation: "example/content-guard".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let entries = [entry("redact", OnError::FailClosed), guard_entry]; + + let body = format!("{}password=\"top-secret\"", "x".repeat(1500)); + let outcome = runner + .evaluate(&entries, input(&body)) + .await + .expect("evaluate mixed-limit chain"); + + assert!(!outcome.allowed); + assert_eq!( + outcome.reason, + "middleware_failed: request_body_over_capacity" + ); + assert_eq!(outcome.applied.len(), 2); + assert!( + outcome.applied[0].transformed, + "redactor ran before the deny" + ); + assert!(outcome.applied[1].failed); + } + + #[test] + fn external_manifest_rejects_operator_limit_above_capability() { + let registration = external_registration(4097); + let manifest = MiddlewareManifest { + name: "example/service".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + id: "example/content-guard".into(), + operation: HTTP_REQUEST_OPERATION as i32, + phase: PRE_CREDENTIALS_PHASE as i32, + max_body_bytes: 4096, + }], + }; + let error = validate_external_manifest(®istration, &manifest, 4097, &mut HashSet::new()) + .expect_err("operator limit must fit capability"); + assert!(error.to_string().contains("exceeds")); + } + + #[test] + fn external_manifest_cannot_claim_reserved_binding() { + let registration = external_registration(4096); + let manifest = MiddlewareManifest { + name: "example/service".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + id: "openshell/secrets".into(), + operation: HTTP_REQUEST_OPERATION as i32, + phase: PRE_CREDENTIALS_PHASE as i32, + max_body_bytes: 4096, + }], + }; + let error = validate_external_manifest(®istration, &manifest, 4096, &mut HashSet::new()) + .expect_err("external service cannot claim reserved namespace"); + assert!(error.to_string().contains("reserved binding")); + } + + #[test] + fn external_registration_accepts_http_and_https_grpc_endpoints() { + for grpc_endpoint in [ + "http://127.0.0.1:50051", + "https://middleware.example.com:443", + ] { + let mut registration = external_registration(4096); + registration.grpc_endpoint = grpc_endpoint.into(); + validate_registration(®istration).expect("supported gRPC endpoint scheme"); + } + } + + #[test] + fn external_registration_rejects_unsupported_grpc_endpoint_scheme() { + let mut registration = external_registration(4096); + registration.grpc_endpoint = "ftp://middleware.example.com".into(); + let error = validate_registration(®istration).expect_err("unsupported scheme"); + assert!(error.to_string().contains("http:// or https://")); + } + + #[tokio::test] + async fn external_registry_invokes_remote_service_over_grpc() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind test middleware"); + let address = listener.local_addr().expect("test middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(ScriptedService { + binding_id: "example/content-guard".into(), + max_body_bytes: 4096, + result: allow_result(), + })) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + + let mut registration = external_registration(1024); + registration.grpc_endpoint = format!("http://{address}"); + let registry = MiddlewareRegistry::connect_services(Vec::new(), vec![registration.clone()]) + .await + .expect("connect external middleware"); + let policy = SandboxPolicy { + network_middlewares: vec![NetworkMiddlewareConfig { + name: "guard".into(), + middleware: "example/content-guard".into(), + order: 0, + config: Some(prost_types::Struct::default()), + on_error: "fail_closed".into(), + endpoints: None, + }], + ..Default::default() + }; + + registry + .validate_policy_configs(&policy) + .await + .expect("remote config validates"); + assert_eq!( + registry.required_services(Some(&policy)), + vec![registration] + ); + + let outcome = ChainRunner::from_registry(registry) + .evaluate( + &[ChainEntry { + name: "guard".into(), + implementation: "example/content-guard".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + input("hello"), + ) + .await + .expect("remote evaluation"); + assert!(outcome.allowed); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join test middleware") + .expect("serve"); + } + + #[tokio::test] + async fn deny_decision_short_circuits_chain() { + let runner = ChainRunner::new(Arc::new(scripted_service( + openshell_core::proto::HttpRequestResult { + decision: Decision::Deny as i32, + reason: "blocked_by_policy".into(), + ..allow_result() + }, + ))); + let outcome = runner + .evaluate( + &[ + entry("first", OnError::FailClosed), + entry("second", OnError::FailClosed), + ], + input("hello"), + ) + .await + .expect("evaluate"); + assert!(!outcome.allowed); + assert_eq!(outcome.reason, "blocked_by_policy"); + // The deny short-circuits the chain: the second middleware never runs. + assert_eq!(outcome.applied.len(), 1); + assert_eq!(outcome.applied[0].decision, Decision::Deny); + assert!(!outcome.applied[0].failed); + } + + #[tokio::test] + async fn deny_decision_ignores_unsafe_mutations_under_fail_open() { + let runner = ChainRunner::new(Arc::new(scripted_service( + openshell_core::proto::HttpRequestResult { + decision: Decision::Deny as i32, + reason: "blocked_by_policy".into(), + header_mutations: vec![write_header( + "x-openshell-middleware-inject", + "ok\r\nHost: evil", + ExistingHeaderAction::Append, + )], + ..allow_result() + }, + ))); + + let outcome = runner + .evaluate(&[entry("guard", OnError::FailOpen)], input("hello")) + .await + .expect("evaluate"); + + assert!(!outcome.allowed); + assert_eq!(outcome.reason, "blocked_by_policy"); + assert!(outcome.header_mutations.is_empty()); + assert_eq!(outcome.applied.len(), 1); + assert_eq!(outcome.applied[0].decision, Decision::Deny); + assert!(!outcome.applied[0].failed); + } + + #[tokio::test] + async fn deny_decision_ignores_oversized_replacement_under_fail_open() { + let runner = ChainRunner::new(Arc::new(ScriptedService { + binding_id: BUILTIN_SECRETS.into(), + max_body_bytes: 4, + result: openshell_core::proto::HttpRequestResult { + decision: Decision::Deny as i32, + reason: "blocked_by_policy".into(), + body: b"too large".to_vec(), + has_body: true, + ..allow_result() + }, + })); + + let outcome = runner + .evaluate(&[entry("guard", OnError::FailOpen)], input("safe")) + .await + .expect("evaluate"); + + assert!(!outcome.allowed); + assert_eq!(outcome.reason, "blocked_by_policy"); + assert_eq!(outcome.body, b"safe"); + assert_eq!(outcome.applied.len(), 1); + assert_eq!(outcome.applied[0].decision, Decision::Deny); + assert!(!outcome.applied[0].transformed); + assert!(!outcome.applied[0].failed); + } + + #[tokio::test] + async fn metadata_and_findings_are_namespaced_per_config() { + let runner = ChainRunner::new(Arc::new(scripted_service( + openshell_core::proto::HttpRequestResult { + findings: vec![Finding { + r#type: "pii.email".into(), + label: "email address".into(), + count: 2, + confidence: "high".into(), + severity: "medium".into(), + }], + metadata: std::iter::once(("sensitivity".to_string(), "high".to_string())) + .collect(), + ..allow_result() + }, + ))); + let outcome = runner + .evaluate( + &[ + entry("alpha", OnError::FailClosed), + entry("beta", OnError::FailClosed), + ], + input("hello"), + ) + .await + .expect("evaluate"); + assert!(outcome.allowed); + // Metadata is bucketed under each config's local name, so two configs + // emitting the same key do not collide. + assert_eq!(outcome.metadata["alpha"]["sensitivity"], "high"); + assert_eq!(outcome.metadata["beta"]["sensitivity"], "high"); + // Findings are tagged with the emitting config's name. + assert_eq!(outcome.findings.len(), 2); + assert_eq!(outcome.findings[0].middleware, "alpha"); + assert_eq!(outcome.findings[1].middleware, "beta"); + assert_eq!(outcome.findings[0].finding.r#type, "pii.email"); + assert_eq!(outcome.findings[0].finding.count, 2); + } + + fn unsafe_header_service() -> ScriptedService { + scripted_service(openshell_core::proto::HttpRequestResult { + header_mutations: vec![ + write_header( + "x-openshell-middleware-safe", + "safe", + ExistingHeaderAction::Append, + ), + write_header( + "x-openshell-middleware-inject", + "ok\r\nHost: evil", + ExistingHeaderAction::Append, + ), + ], + ..allow_result() + }) + } + + #[tokio::test] + async fn malformed_response_headers_fail_closed_denies() { + let runner = ChainRunner::new(Arc::new(unsafe_header_service())); + let outcome = runner + .evaluate(&[entry("redact", OnError::FailClosed)], input("hello")) + .await + .expect("evaluate"); + assert!(!outcome.allowed); + assert!(outcome.reason.starts_with("middleware_failed:")); + // The deny reason names the offending header so operators can fix the + // service without reading supervisor source. + assert!( + outcome.reason.contains("x-openshell-middleware-inject"), + "reason should name the offending header: {}", + outcome.reason + ); + assert!(outcome.applied.iter().any(|inv| inv.failed)); + // The stage is atomic: neither the unsafe mutation nor the safe + // mutation preceding it is forwarded. + assert!(outcome.header_mutations.is_empty()); + } + + #[tokio::test] + async fn malformed_response_headers_fail_open_continues() { + let runner = ChainRunner::new(Arc::new(unsafe_header_service())); + let outcome = runner + .evaluate(&[entry("redact", OnError::FailOpen)], input("hello")) + .await + .expect("evaluate"); + assert!(outcome.allowed); + assert_eq!(outcome.body, b"hello"); + assert!(outcome.header_mutations.is_empty()); + assert_eq!(outcome.applied.len(), 1); + assert!(outcome.applied[0].failed); + } + + #[tokio::test] + async fn oversized_replacement_body_honors_on_error() { + let runner = ChainRunner::new(Arc::new(ScriptedService { + binding_id: BUILTIN_SECRETS.into(), + max_body_bytes: 4, + result: openshell_core::proto::HttpRequestResult { + body: b"too large".to_vec(), + has_body: true, + ..allow_result() + }, + })); + let fail_open = entry("small", OnError::FailOpen); + let mut fail_closed = fail_open.clone(); + fail_closed.on_error = OnError::FailClosed; + + let open_outcome = runner + .evaluate(&[fail_open], input("safe")) + .await + .expect("fail-open evaluation"); + assert!(open_outcome.allowed); + assert_eq!(open_outcome.body, b"safe"); + assert!(open_outcome.applied[0].failed); + + let closed_outcome = runner + .evaluate(&[fail_closed], input("safe")) + .await + .expect("fail-closed evaluation"); + assert!(!closed_outcome.allowed); + assert_eq!( + closed_outcome.reason, + "middleware_failed: response_body_over_capacity" + ); + assert!(closed_outcome.applied[0].failed); + } + + #[tokio::test] + async fn oversized_request_body_honors_on_error() { + let runner = ChainRunner::new(Arc::new(ScriptedService { + binding_id: BUILTIN_SECRETS.into(), + max_body_bytes: 4, + result: allow_result(), + })); + let fail_open = entry("small", OnError::FailOpen); + let mut fail_closed = fail_open.clone(); + fail_closed.on_error = OnError::FailClosed; + + let open_outcome = runner + .evaluate(&[fail_open], input("hello")) + .await + .expect("fail-open evaluation"); + assert!(open_outcome.allowed); + assert_eq!(open_outcome.body, b"hello"); + assert!(open_outcome.applied[0].failed); + + let closed_outcome = runner + .evaluate(&[fail_closed], input("hello")) + .await + .expect("fail-closed evaluation"); + assert!(!closed_outcome.allowed); + assert_eq!( + closed_outcome.reason, + "middleware_failed: request_body_over_capacity" + ); + assert!(closed_outcome.applied[0].failed); + } + + #[tokio::test] + async fn unspecified_decision_uses_fail_closed() { + let runner = ChainRunner::new(Arc::new(scripted_service( + openshell_core::proto::HttpRequestResult { + decision: Decision::Unspecified as i32, + ..allow_result() + }, + ))); + + let outcome = runner + .evaluate(&[entry("redact", OnError::FailClosed)], input("hello")) + .await + .expect("evaluate"); + + assert!(!outcome.allowed); + assert_eq!( + outcome.reason, + "middleware_failed: invalid_response_decision" + ); + assert!(outcome.applied[0].failed); + } +} diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs new file mode 100644 index 0000000000..7645ed811f --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -0,0 +1,102 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::time::Duration; + +use miette::{IntoDiagnostic, Result, WrapErr}; +use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; +use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; +use openshell_core::proto::{ + HttpRequestEvaluation, HttpRequestResult, MiddlewareManifest, ValidateConfigRequest, + ValidateConfigResponse, +}; +use tonic::transport::{Channel, ClientTlsConfig, Endpoint}; +use tonic::{Request, Response, Status}; + +const CONNECT_TIMEOUT: Duration = Duration::from_secs(5); +const RPC_TIMEOUT: Duration = Duration::from_secs(5); + +#[derive(Clone)] +pub struct RemoteMiddlewareService { + registration_name: String, + client: SupervisorMiddlewareClient, +} + +impl RemoteMiddlewareService { + pub async fn connect(registration_name: &str, grpc_endpoint: &str) -> Result { + let mut endpoint = Endpoint::from_shared(grpc_endpoint.to_string()) + .into_diagnostic() + .wrap_err_with(|| { + format!( + "middleware registration '{registration_name}' has an invalid grpc_endpoint" + ) + })?; + if grpc_endpoint.starts_with("https://") { + endpoint = endpoint + .tls_config(ClientTlsConfig::new().with_enabled_roots()) + .into_diagnostic() + .wrap_err_with(|| { + format!("middleware registration '{registration_name}' could not configure TLS") + })?; + } + let channel = endpoint + .connect_timeout(CONNECT_TIMEOUT) + .connect() + .await + .into_diagnostic() + .wrap_err_with(|| { + format!( + "middleware registration '{registration_name}' could not connect to {grpc_endpoint}" + ) + })?; + Ok(Self { + registration_name: registration_name.to_string(), + client: SupervisorMiddlewareClient::new(channel), + }) + } + + async fn with_timeout( + &self, + operation: &'static str, + future: impl Future, Status>>, + ) -> std::result::Result, Status> { + tokio::time::timeout(RPC_TIMEOUT, future) + .await + .map_err(|_| { + Status::deadline_exceeded(format!( + "middleware '{}' {operation} timed out", + self.registration_name + )) + })? + } +} + +#[tonic::async_trait] +impl SupervisorMiddleware for RemoteMiddlewareService { + async fn describe( + &self, + request: Request<()>, + ) -> std::result::Result, Status> { + let mut client = self.client.clone(); + self.with_timeout("Describe", client.describe(request)) + .await + } + + async fn validate_config( + &self, + request: Request, + ) -> std::result::Result, Status> { + let mut client = self.client.clone(); + self.with_timeout("ValidateConfig", client.validate_config(request)) + .await + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> std::result::Result, Status> { + let mut client = self.client.clone(); + self.with_timeout("EvaluateHttpRequest", client.evaluate_http_request(request)) + .await + } +} diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index 7d0079f7b3..1449276f4f 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -15,6 +15,7 @@ openshell-core = { path = "../openshell-core" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-router = { path = "../openshell-router" } +openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } apollo-parser = { workspace = true } aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } @@ -28,6 +29,7 @@ glob = { workspace = true } hex = "0.4" ipnet = "2" miette = { workspace = true } +prost-types = { workspace = true } rcgen = { workspace = true } regorus = { version = "0.9", default-features = false, features = ["std", "arc", "glob"] } reqwest = { workspace = true } @@ -49,10 +51,13 @@ webpki-roots = { workspace = true } [dev-dependencies] openshell-core = { path = "../openshell-core", features = ["test-helpers"] } +openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } tempfile = "3" +tonic = { workspace = true } temp-env = "0.3" tokio-tungstenite = { workspace = true } futures = { workspace = true } +tracing-subscriber = { workspace = true } [target.'cfg(unix)'.dev-dependencies] libc = "0.2" diff --git a/crates/openshell-supervisor-network/data/sandbox-policy.rego b/crates/openshell-supervisor-network/data/sandbox-policy.rego index d70c69b748..2684b018a8 100644 --- a/crates/openshell-supervisor-network/data/sandbox-policy.rego +++ b/crates/openshell-supervisor-network/data/sandbox-policy.rego @@ -856,20 +856,22 @@ _policy_endpoint_configs(policy) := [ep | endpoint_has_extended_config(ep) ] -# Collect matching endpoint configs across all policies. Iterates over -# _matching_policy_names (a set, safe from regorus variable collisions) -# then collects per-policy configs via the helper function. _matching_endpoint_configs := [cfg | some pname _matching_policy_names[pname] cfgs := _policy_endpoint_configs(data.network_policies[pname]) cfg := cfgs[_] + endpoint_has_extended_config(cfg) ] matched_endpoint_config := _matching_endpoint_configs[0] if { count(_matching_endpoint_configs) > 0 } +# Expose middleware policy data to Rust. Selection and validation stay in Rust; +# Rego does not evaluate middleware selectors. +network_middlewares := object.get(data, "network_middlewares", []) + _policy_has_exact_declared_endpoint(policy) if { some ep ep := policy.endpoints[_] diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs new file mode 100644 index 0000000000..611fa81cc8 --- /dev/null +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -0,0 +1,501 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Supervisor middleware application for L7 requests. + +use crate::l7::relay::L7EvalContext; +use crate::opa::PolicyGenerationGuard; +use miette::{Result, miette}; +use openshell_ocsf::{ + ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, + HttpActivityBuilder, HttpRequest, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, +}; +use std::path::PathBuf; +use tokio::io::{AsyncRead, AsyncWrite}; + +pub enum MiddlewareApplyResult { + Allowed(crate::l7::provider::L7Request), + Denied(String), +} + +/// How traffic a middleware chain can never inspect (h2c, non-HTTP TCP, +/// protocols without an L7 relay) must be handled for a matching chain. +/// +/// This is derived from each entry's `on_error` today. A future per-config +/// `on_uninspectable` knob could let an operator keep `fail_closed` error +/// handling for HTTP traffic while allowing uninspectable protocols through +/// without maintaining host excludes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UninspectableTrafficGate { + /// No middleware matches this destination; raw relay is unaffected. + Unrestricted, + /// Every matching entry is `fail_open`: relay raw bytes but emit a bypass + /// detection finding. + BypassWithFinding, + /// At least one matching entry is `fail_closed`: deny, the middleware + /// must be able to see the traffic for it to flow. + Deny, +} + +pub fn uninspectable_traffic_gate( + chain: &[openshell_supervisor_middleware::ChainEntry], +) -> UninspectableTrafficGate { + if chain.is_empty() { + return UninspectableTrafficGate::Unrestricted; + } + if chain + .iter() + .all(|entry| entry.on_error == openshell_supervisor_middleware::OnError::FailOpen) + { + UninspectableTrafficGate::BypassWithFinding + } else { + UninspectableTrafficGate::Deny + } +} + +/// Emit the detection finding for traffic a matching middleware chain cannot +/// inspect: denied under a fail-closed chain, bypassed under fail-open. +pub fn emit_middleware_uninspectable(ctx: &L7EvalContext, detail: &str, denied: bool) { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(if denied { + SeverityId::High + } else { + SeverityId::Medium + }) + .finding_info(FindingInfo::new( + "openshell.middleware.traffic_uninspectable", + "Supervisor middleware cannot inspect this traffic", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("protocol", detail), + ("disposition", if denied { "denied" } else { "fail_open" }), + ]) + .message(if denied { + "Uninspectable traffic to host with required middleware; denied" + } else { + "Uninspectable traffic bypassed middleware (fail_open)" + }) + .build(); + ocsf_emit!(event); +} + +/// Largest body-buffering limit across the entries that actually resolved to a +/// registered binding. Buffering for the most capable stage lets every stage +/// that can handle the body run; stages whose own limit is smaller are failed +/// individually with `request_body_over_capacity` through their `on_error` +/// policy in `evaluate_described`, instead of one undersized stage forcing the +/// whole chain onto the unbuffered path. Unresolved entries +/// (`is_resolved() == false`) report a zero limit and are excluded here: they +/// are handled by their `on_error` policy without inspecting the body. +/// Returns `None` when no entry resolved, so the caller can skip buffering. +pub(super) fn middleware_chain_body_limit( + chain: &[openshell_supervisor_middleware::DescribedChainEntry], +) -> Option { + chain + .iter() + .filter(|entry| entry.is_resolved()) + .map(openshell_supervisor_middleware::DescribedChainEntry::max_body_bytes) + .max() +} + +pub async fn apply_middleware_chain( + req: crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + chain: Vec, + runner: &openshell_supervisor_middleware::ChainRunner, + generation_guard: &PolicyGenerationGuard, + transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, +) -> Result { + apply_middleware_chain_for_scheme( + req, + client, + ctx, + "https", + chain, + runner, + generation_guard, + transformed_body_policy, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_middleware_chain_for_scheme( + req: crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + scheme: &str, + chain: Vec, + runner: &openshell_supervisor_middleware::ChainRunner, + generation_guard: &PolicyGenerationGuard, + transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, +) -> Result { + if chain.is_empty() { + return Ok(MiddlewareApplyResult::Allowed(req)); + } + let chain = runner.describe_chain(&chain).await?; + let Some(max_body_bytes) = middleware_chain_body_limit(&chain) else { + // No entry resolved to a registered binding, so nothing inspects the + // body. Apply each entry's `on_error` policy without buffering (an + // unresolved binding is handled before the body is read) and forward + // the original request unchanged if the chain allows. + let input = + middleware_request_input(scheme, &req, ctx, Vec::new(), String::new(), Vec::new()); + let outcome = runner.evaluate_described(&chain, input).await?; + emit_middleware_events(ctx, &req, &outcome); + return Ok(if outcome.allowed { + MiddlewareApplyResult::Allowed(req) + } else { + MiddlewareApplyResult::Denied(outcome.reason) + }); + }; + let buffered = match crate::l7::rest::buffer_request_body_for_middleware( + &req, + client, + Some(generation_guard), + max_body_bytes, + ) + .await? + { + crate::l7::rest::BufferResult::Buffered(buffered) => buffered, + crate::l7::rest::BufferResult::OverCapacity { recoverable } => { + return Ok(resolve_unbuffered_body(ctx, req, &chain, recoverable)); + } + }; + let headers = safe_middleware_headers(&buffered.headers)?; + let query = raw_query_from_request_headers(&buffered.headers)?; + let input = middleware_request_input(scheme, &req, ctx, headers, query, buffered.body); + // The explicitly selected transformation policy either re-checks every + // replacement or documents that this protocol's policy is body-independent. + // An ALLOW outcome therefore means the final body is policy-compliant. + let outcome = runner + .evaluate_described_with_policy(&chain, input, transformed_body_policy) + .await?; + emit_middleware_events(ctx, &req, &outcome); + if !outcome.allowed { + return Ok(MiddlewareApplyResult::Denied(outcome.reason)); + } + let rebuilt = crate::l7::rest::rebuild_request_with_buffered_body( + &req, + &buffered.headers, + &outcome.body, + &outcome.header_mutations, + )?; + Ok(MiddlewareApplyResult::Allowed(rebuilt)) +} + +pub(super) fn middleware_request_input( + scheme: &str, + req: &crate::l7::provider::L7Request, + ctx: &L7EvalContext, + headers: Vec<(String, String)>, + query: String, + body: Vec, +) -> openshell_supervisor_middleware::HttpRequestInput { + openshell_supervisor_middleware::HttpRequestInput { + request_id: uuid::Uuid::new_v4().to_string(), + sandbox_id: openshell_ocsf::ctx::ctx().sandbox_id.clone(), + scheme: scheme.into(), + host: ctx.host.clone(), + port: ctx.port, + method: req.action.clone(), + path: req.target.clone(), + query, + headers, + body, + } +} + +pub(super) fn raw_query_from_request_headers(headers: &[u8]) -> Result { + let header_str = + std::str::from_utf8(headers).map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; + let target = header_str + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .ok_or_else(|| miette!("HTTP request line is missing a target"))?; + Ok(target + .split_once('?') + .map_or_else(String::new, |(_, query)| query.to_string())) +} + +/// Apply the chain's `on_error` policy when the request body exceeds every +/// stage's buffering limit. No stage can inspect such a body, so each stage +/// would individually fail with `request_body_over_capacity`; the aggregate is +/// a deny unless every attached middleware is `fail_open`, and passing the +/// body through is only safe when no bytes were consumed. +pub(super) fn resolve_unbuffered_body( + ctx: &L7EvalContext, + req: crate::l7::provider::L7Request, + chain: &[openshell_supervisor_middleware::DescribedChainEntry], + recoverable: bool, +) -> MiddlewareApplyResult { + let all_fail_open = chain + .iter() + .all(|entry| entry.on_error() == openshell_supervisor_middleware::OnError::FailOpen); + if recoverable && all_fail_open { + emit_middleware_body_unavailable(ctx, false); + return MiddlewareApplyResult::Allowed(req); + } + emit_middleware_body_unavailable(ctx, true); + MiddlewareApplyResult::Denied("middleware_failed: request_body_over_capacity".into()) +} + +fn emit_middleware_body_unavailable(ctx: &L7EvalContext, denied: bool) { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(if denied { + SeverityId::High + } else { + SeverityId::Medium + }) + .finding_info(FindingInfo::new( + "openshell.middleware.body_unavailable", + "Supervisor middleware could not inspect request body", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("disposition", if denied { "denied" } else { "fail_open" }), + ]) + .message(if denied { + "Request body exceeded middleware inspection cap; denied" + } else { + "Request body exceeded middleware inspection cap; passed through (fail_open)" + }) + .build(); + ocsf_emit!(event); +} + +/// Parse the raw header block into middleware-visible headers, preserving +/// wire order and repeated names so middleware inspects every value the +/// upstream will receive. Credential-bearing headers are omitted. +fn safe_middleware_headers(headers: &[u8]) -> Result> { + let header_str = + std::str::from_utf8(headers).map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; + let mut out = Vec::new(); + for line in header_str.lines().skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let name = name.trim().to_ascii_lowercase(); + if name.is_empty() + || matches!( + name.as_str(), + "authorization" + | "proxy-authorization" + | "cookie" + | "host" + | "content-length" + | "transfer-encoding" + ) + || name.starts_with("x-amz-") + || name.starts_with("x-openshell-credential") + { + continue; + } + out.push((name, value.trim().to_string())); + } + Ok(out) +} + +pub fn middleware_network_input(ctx: &L7EvalContext) -> crate::opa::NetworkInput { + crate::opa::NetworkInput { + host: ctx.host.clone(), + port: ctx.port, + binary_path: PathBuf::from(&ctx.binary_path), + binary_sha256: String::new(), + ancestors: ctx.ancestors.iter().map(PathBuf::from).collect(), + cmdline_paths: ctx.cmdline_paths.iter().map(PathBuf::from).collect(), + } +} + +/// Build the OCSF events describing a middleware chain outcome, in emission +/// order. Separated from `emit_middleware_events` so tests can assert on the +/// events deterministically without routing through the global tracing pipeline, +/// whose callsite-interest cache is process-global and races under parallel +/// tests. +pub(super) fn middleware_events( + ctx: &L7EvalContext, + req: &crate::l7::provider::L7Request, + outcome: &openshell_supervisor_middleware::ChainOutcome, +) -> Vec { + let mut events = Vec::new(); + for invocation in &outcome.applied { + let allowed = invocation.decision == openshell_core::proto::Decision::Allow; + let mut event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(if allowed { + ActionId::Allowed + } else { + ActionId::Denied + }) + .disposition(if allowed { + DispositionId::Allowed + } else { + DispositionId::Blocked + }) + .severity(if allowed { + SeverityId::Informational + } else { + SeverityId::Medium + }) + .http_request(HttpRequest::new( + &req.action, + OcsfUrl::new("http", &ctx.host, &req.target, ctx.port), + )) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, "middleware") + .unmapped("transformed", invocation.transformed) + .unmapped("failed", invocation.failed) + .message(format!( + "MIDDLEWARE {} {} decision={:?}", + invocation.name, invocation.implementation, invocation.decision + )); + if !allowed && !outcome.reason.is_empty() { + event = event + .status(StatusId::Failure) + .status_detail(&outcome.reason); + } + let event = event.build(); + events.push(event); + + // A middleware that failed but was bypassed under `fail_open` is an + // enforcement failure operators must be able to alert on, even though the + // request proceeded. + if invocation.failed && allowed { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::Medium) + .finding_info(FindingInfo::new( + "openshell.middleware.failure", + "Supervisor middleware failed open", + )) + .evidence_pairs(&[ + ("middleware", invocation.name.as_str()), + ("implementation", invocation.implementation.as_str()), + ]) + .unmapped("middleware", invocation.name.as_str()) + .unmapped("implementation", invocation.implementation.as_str()) + .message(format!( + "Middleware {} failed and was bypassed (fail_open)", + invocation.name + )) + .build(); + events.push(event); + } + } + if !outcome.allowed && outcome.reason.starts_with("middleware_failed:") { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::High) + .finding_info(FindingInfo::new( + "openshell.middleware.failure", + "Supervisor middleware failure", + )) + .message("Required supervisor middleware failed closed") + .build(); + events.push(event); + } + if !outcome.allowed + && outcome + .reason + .starts_with("transformed_body_policy_evaluation_failed:") + { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(SeverityId::High) + .finding_info(FindingInfo::new( + "openshell.middleware.policy_evaluation_failure", + "Post-middleware policy evaluation failed", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ]) + .message("Transformed request denied because policy evaluation failed") + .build(); + events.push(event); + } + for finding in &outcome.findings { + let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .severity(match finding.finding.severity.as_str() { + "high" => SeverityId::High, + "low" => SeverityId::Low, + _ => SeverityId::Medium, + }) + .finding_info(FindingInfo::new( + &finding.finding.r#type, + &finding.finding.label, + )) + .evidence_pairs(&[ + ("middleware", &finding.middleware), + ("count", &finding.finding.count.to_string()), + ]) + .unmapped("middleware", finding.middleware.as_str()) + .unmapped("count", finding.finding.count) + .message(format!( + "Middleware finding {} count={}", + finding.finding.r#type, finding.finding.count + )) + .build(); + events.push(event); + } + events +} + +/// Emit the OCSF events describing a middleware chain outcome through the +/// tracing pipeline. +fn emit_middleware_events( + ctx: &L7EvalContext, + req: &crate::l7::provider::L7Request, + outcome: &openshell_supervisor_middleware::ChainOutcome, +) { + for event in middleware_events(ctx, req, outcome) { + ocsf_emit!(event); + } +} + +#[cfg(test)] +mod tests { + use super::safe_middleware_headers; + + #[test] + fn middleware_headers_exclude_origin_and_proxy_credentials() { + let headers = safe_middleware_headers( + b"GET http://api.example.test/v1 HTTP/1.1\r\n\ + Authorization: Bearer origin-secret\r\n\ + Proxy-Authorization: Basic proxy-secret\r\n\ + X-Request-ID: request-123\r\n\r\n", + ) + .expect("headers should parse"); + + assert_eq!( + headers, + vec![("x-request-id".to_string(), "request-123".to_string())] + ); + } + + #[test] + fn middleware_headers_preserve_repeated_names_in_wire_order() { + // Repeated header names must reach middleware as separate entries in + // wire order: keeping only one value would let a request smuggle a + // differently-positioned duplicate past inspection while the upstream + // still receives every original value. + let headers = safe_middleware_headers( + b"POST /v1 HTTP/1.1\r\n\ + X-Api-Key: first-value\r\n\ + Accept: application/json\r\n\ + X-Api-Key: second-value\r\n\r\n", + ) + .expect("headers should parse"); + + assert_eq!( + headers, + vec![ + ("x-api-key".to_string(), "first-value".to_string()), + ("accept".to_string(), "application/json".to_string()), + ("x-api-key".to_string(), "second-value".to_string()), + ] + ); + } +} diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index d10c19b8e8..691aeb87d9 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -12,6 +12,7 @@ pub mod graphql; pub(crate) mod http; pub mod inference; pub mod jsonrpc; +pub(crate) mod middleware; pub mod path; pub mod provider; pub mod relay; diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index c43fb35f9d..88c62dffc5 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -7,6 +7,15 @@ //! Parses each request within the tunnel, evaluates it against OPA policy, //! and either forwards or denies the request. +use crate::l7::middleware::{ + MiddlewareApplyResult, UninspectableTrafficGate, apply_middleware_chain, + emit_middleware_uninspectable, middleware_network_input, uninspectable_traffic_gate, +}; +#[cfg(test)] +use crate::l7::middleware::{ + middleware_chain_body_limit, middleware_events, middleware_request_input, + raw_query_from_request_headers, resolve_unbuffered_body, +}; use crate::l7::provider::{L7Provider, RelayOutcome}; use crate::l7::rest::WebSocketExtensionMode; use crate::l7::{EnforcementMode, L7EndpointConfig, L7Protocol, L7RequestInfo}; @@ -18,6 +27,8 @@ use openshell_ocsf::{ ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; +#[cfg(test)] +use std::collections::BTreeMap; use std::sync::Arc; use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tracing::{debug, warn}; @@ -202,6 +213,20 @@ where if close_if_stale(engine.generation_guard(), ctx) { return Ok(()); } + // The SQL relay is not implemented, so a matching middleware + // chain can never inspect this stream: gate it like any other + // uninspectable protocol. + let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + match uninspectable_traffic_gate(&chain) { + UninspectableTrafficGate::Deny => { + emit_middleware_uninspectable(ctx, "sql passthrough", true); + return Ok(()); + } + UninspectableTrafficGate::BypassWithFinding => { + emit_middleware_uninspectable(ctx, "sql passthrough", false); + } + UninspectableTrafficGate::Unrestricted => {} + } // SQL provider is Phase 3 — fall through to passthrough with warning { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) @@ -401,18 +426,9 @@ where return Ok(()); } - let parse_error_reason = graphql_info - .as_ref() - .and_then(|info| info.error.as_deref()) - .map(|error| format!("GraphQL request rejected: {error}")) - .or_else(|| { - jsonrpc_info - .as_ref() - .and_then(|info| info.error.as_deref()) - .map(|error| format!("JSON-RPC request rejected: {error}")) - }); - let force_deny = parse_error_reason.is_some(); - let (allowed, reason) = if let Some(reason) = parse_error_reason { + let hard_deny_reason = l7_request_hard_deny_reason(config.protocol, &request_info); + let force_deny = hard_deny_reason.is_some(); + let (allowed, reason) = if let Some(reason) = hard_deny_reason { (false, reason) } else { evaluate_l7_request(&engine, ctx, &request_info)? @@ -450,6 +466,48 @@ where let _ = &eval_target; if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { + let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + // Route selection resolved `config` per request, so re-check the + // body against that protocol's policy after every transforming + // stage (a no-op for REST and websocket, whose policy inputs the + // chain cannot mutate). + let validate = transformed_body_validator(config, &engine, ctx, &request_info); + let req = match apply_middleware_chain( + req, + client, + ctx, + chain, + engine.middleware_runner(), + engine.generation_guard(), + openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), + ) + .await? + { + MiddlewareApplyResult::Allowed(request) => request, + MiddlewareApplyResult::Denied(reason) => { + crate::l7::rest::RestProvider::default() + .deny_with_redacted_target( + &crate::l7::provider::L7Request { + action: request_info.action.clone(), + target: redacted_target.clone(), + query_params: request_info.query_params.clone(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }, + &ctx.policy_name, + &reason, + client, + Some(&redacted_target), + Some(crate::l7::rest::DenyResponseContext { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + }), + ) + .await?; + return Ok(()); + } + }; let outcome = crate::l7::rest::relay_http_request_with_options_guarded( &req, client, @@ -907,6 +965,46 @@ where let _ = &eval_target; if allowed || config.enforcement == EnforcementMode::Audit { + let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + // REST and websocket-upgrade policy evaluates only the method, + // path, and query, which a middleware result cannot mutate, so no + // per-stage body re-check is needed. + let req = match apply_middleware_chain( + req, + client, + ctx, + chain, + engine.middleware_runner(), + engine.generation_guard(), + openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + ) + .await? + { + MiddlewareApplyResult::Allowed(request) => request, + MiddlewareApplyResult::Denied(reason) => { + provider + .deny_with_redacted_target( + &crate::l7::provider::L7Request { + action: request_info.action.clone(), + target: redacted_target.clone(), + query_params: request_info.query_params.clone(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }, + &ctx.policy_name, + &reason, + client, + Some(&redacted_target), + Some(crate::l7::rest::DenyResponseContext { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + }), + ) + .await?; + return Ok(()); + } + }; let req_with_auth = match crate::l7::token_grant_injection::inject_if_needed(req, ctx).await { Ok(req) => req, @@ -1083,16 +1181,9 @@ where jsonrpc: Some(jsonrpc_info.clone()), }; - let parse_error_reason = jsonrpc_info - .error - .as_deref() - .map(|e| format!("JSON-RPC request rejected: {e}")); - let response_frame_reason = - jsonrpc_response_frame_hard_deny_reason(config.protocol, &jsonrpc_info); - let force_deny = parse_error_reason.is_some() || response_frame_reason.is_some(); - let (allowed, reason, jsonrpc_log_info) = if let Some(reason) = parse_error_reason { - (false, reason, jsonrpc_info.clone()) - } else if let Some(reason) = response_frame_reason { + let hard_deny_reason = l7_request_hard_deny_reason(config.protocol, &request_info); + let force_deny = hard_deny_reason.is_some(); + let (allowed, reason, jsonrpc_log_info) = if let Some(reason) = hard_deny_reason { (false, reason, jsonrpc_info.clone()) } else { let evaluation = @@ -1146,6 +1237,48 @@ where } if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { + let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + // Policy admitted the original body above; re-check the body + // against the same body-aware policy after every transforming + // stage so a middleware cannot smuggle a denied operation to the + // upstream or the next stage. + let validate = transformed_body_validator(config, engine, ctx, &request_info); + let req = match apply_middleware_chain( + req, + client, + ctx, + chain, + engine.middleware_runner(), + engine.generation_guard(), + openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), + ) + .await? + { + MiddlewareApplyResult::Allowed(request) => request, + MiddlewareApplyResult::Denied(reason) => { + crate::l7::rest::RestProvider::default() + .deny_with_redacted_target( + &crate::l7::provider::L7Request { + action: request_info.action.clone(), + target: redacted_target.clone(), + query_params: request_info.query_params.clone(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }, + &ctx.policy_name, + &reason, + client, + Some(&redacted_target), + Some(crate::l7::rest::DenyResponseContext { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + }), + ) + .await?; + return Ok(()); + } + }; // Future MCP response/SSE introspection or rewrite would hook here // before returning upstream bytes. The current policy schema has no // trusted-annotations or version-profile field, so MCP responses and @@ -1281,12 +1414,9 @@ where // control parameters, are rejected before policy evaluation. This // keeps parser-differential cases fail-closed even if the endpoint is // otherwise in audit mode. - let parse_error_reason = graphql_info - .error - .as_deref() - .map(|error| format!("GraphQL request rejected: {error}")); - let force_deny = parse_error_reason.is_some(); - let (allowed, reason) = if let Some(reason) = parse_error_reason { + let hard_deny_reason = l7_request_hard_deny_reason(config.protocol, &request_info); + let force_deny = hard_deny_reason.is_some(); + let (allowed, reason) = if let Some(reason) = hard_deny_reason { (false, reason) } else { evaluate_l7_request(engine, ctx, &request_info)? @@ -1340,6 +1470,48 @@ where let _ = &eval_target; if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { + let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + // Policy admitted the original body above; re-check the body + // against the same body-aware policy after every transforming + // stage so a middleware cannot smuggle a denied operation to the + // upstream or the next stage. + let validate = transformed_body_validator(config, engine, ctx, &request_info); + let req = match apply_middleware_chain( + req, + client, + ctx, + chain, + engine.middleware_runner(), + engine.generation_guard(), + openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), + ) + .await? + { + MiddlewareApplyResult::Allowed(request) => request, + MiddlewareApplyResult::Denied(reason) => { + crate::l7::rest::RestProvider::default() + .deny_with_redacted_target( + &crate::l7::provider::L7Request { + action: request_info.action.clone(), + target: redacted_target.clone(), + query_params: request_info.query_params.clone(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }, + &ctx.policy_name, + &reason, + client, + Some(&redacted_target), + Some(crate::l7::rest::DenyResponseContext { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + }), + ) + .await?; + return Ok(()); + } + }; let outcome = crate::l7::rest::relay_http_request_with_resolver_guarded( &req, client, @@ -1487,6 +1659,31 @@ pub(crate) fn jsonrpc_response_frame_hard_deny_reason( .then(|| JSONRPC_RESPONSE_FRAME_DENY_REASON.to_string()) } +/// Classify malformed or protocol-invalid requests that must be denied even +/// when the selected endpoint is in audit mode. +/// +/// All HTTP entry points use this helper so dedicated relays, route-selected +/// relays, forward proxying, and post-middleware re-evaluation cannot drift on +/// hard-deny semantics. +pub(crate) fn l7_request_hard_deny_reason( + protocol: L7Protocol, + request: &L7RequestInfo, +) -> Option { + request + .graphql + .as_ref() + .and_then(|info| info.error.as_deref()) + .map(|error| format!("GraphQL request rejected: {error}")) + .or_else(|| { + request.jsonrpc.as_ref().and_then(|info| { + info.error + .as_deref() + .map(|error| format!("JSON-RPC request rejected: {error}")) + .or_else(|| jsonrpc_response_frame_hard_deny_reason(protocol, info)) + }) + }) +} + /// Check if a miette error represents a benign connection close. /// /// TLS handshake EOF, missing `close_notify`, connection resets, and broken @@ -1611,6 +1808,130 @@ fn jsonrpc_request_for_call( item_request } +/// Re-evaluate body-aware policy against a middleware-transformed body. Policy +/// admits the original body before the chain runs, so each replaced body must +/// be checked again before the next stage or the upstream sees it: a +/// transformation cannot smuggle a denied or unparseable operation past the +/// policy. Returns the deny reason, or `None` when the transformed body is +/// admissible. An unparseable replacement or a response frame denies even +/// under audit, mirroring `force_deny` for the original body; a policy deny +/// respects the endpoint's enforcement mode. Method, path, and query come from +/// `request_info` because a middleware result cannot mutate them. +/// +/// The match is exhaustive over `L7Protocol` on purpose: adding a protocol +/// does not compile until its transformed-body re-evaluation is defined here, +/// either by re-deriving the body-dependent policy inputs or by documenting +/// why none exist. Build the per-request validator with +/// [`transformed_body_validator`]. +fn reevaluate_transformed_body( + config: &L7EndpointConfig, + engine: &TunnelPolicyEngine, + ctx: &L7EvalContext, + request_info: &L7RequestInfo, + body: &[u8], +) -> Result> { + let (engine_type, transformed_info) = match config.protocol { + // REST and websocket-upgrade policy evaluates only the method, path, + // and query, which a middleware result cannot mutate; the body is not + // a policy input. SQL has no body-aware L7 policy either; the + // uninspectable-traffic gate keeps required middleware ahead of the + // unimplemented SQL relay. + L7Protocol::Rest | L7Protocol::Websocket | L7Protocol::Sql => return Ok(None), + L7Protocol::JsonRpc | L7Protocol::Mcp => { + let info = crate::l7::jsonrpc::parse_jsonrpc_body_with_options( + body, + crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(config), + ); + let mut transformed_info = request_info.clone(); + transformed_info.jsonrpc = Some(info); + (jsonrpc_engine_type(config.protocol), transformed_info) + } + L7Protocol::Graphql => { + // GraphQL classification needs the request method and query + // params; only the body was replaced, so rebuild from + // `request_info` and the new body. + let request = crate::l7::provider::L7Request { + action: request_info.action.clone(), + target: request_info.target.clone(), + query_params: request_info.query_params.clone(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + let info = crate::l7::graphql::classify_request(&request, body); + let mut transformed_info = request_info.clone(); + transformed_info.graphql = Some(info); + ("l7-graphql", transformed_info) + } + }; + + if let Some(reason) = l7_request_hard_deny_reason(config.protocol, &transformed_info) { + let reason = format!("middleware transformation rejected: {reason}"); + emit_transformed_body_decision(ctx, request_info, engine_type, "deny", &reason); + return Ok(Some(reason)); + } + + let (allowed, reason) = evaluate_l7_request(engine, ctx, &transformed_info)?; + if allowed { + return Ok(None); + } + let reason = format!("middleware transformation denied by policy: {reason}"); + if config.enforcement == EnforcementMode::Audit { + emit_transformed_body_decision(ctx, request_info, engine_type, "audit", &reason); + return Ok(None); + } + emit_transformed_body_decision(ctx, request_info, engine_type, "deny", &reason); + Ok(Some(reason)) +} + +/// Build the per-stage transformed-body validator the middleware chain calls +/// after every stage that replaces the body. Borrows the policy inputs, so it +/// lives only as long as this request's evaluation. +pub(crate) fn transformed_body_validator<'a>( + config: &'a L7EndpointConfig, + engine: &'a TunnelPolicyEngine, + ctx: &'a L7EvalContext, + request_info: &'a L7RequestInfo, +) -> impl Fn(&[u8]) -> Result> + Send + Sync + 'a { + move |body: &[u8]| reevaluate_transformed_body(config, engine, ctx, request_info, body) +} + +/// Log the post-transformation policy decision as an OCSF HTTP Activity +/// event, mirroring the pre-middleware decision logs. `request_info.target` +/// is already redacted by the callers. +fn emit_transformed_body_decision( + ctx: &L7EvalContext, + request_info: &L7RequestInfo, + engine_type: &str, + decision_str: &str, + reason: &str, +) { + let (action_id, disposition_id, severity) = match decision_str { + "deny" => (ActionId::Denied, DispositionId::Blocked, SeverityId::Medium), + _ => ( + ActionId::Allowed, + DispositionId::Allowed, + SeverityId::Informational, + ), + }; + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(action_id) + .disposition(disposition_id) + .severity(severity) + .http_request(HttpRequest::new( + &request_info.action, + OcsfUrl::new("http", &ctx.host, &request_info.target, ctx.port), + )) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, engine_type) + .message(format!( + "L7_REQUEST_TRANSFORMED {decision_str} {} {}:{}{} reason={}", + request_info.action, ctx.host, ctx.port, request_info.target, reason + )) + .build(); + ocsf_emit!(event); +} + fn evaluate_l7_request_once( engine: &TunnelPolicyEngine, ctx: &L7EvalContext, @@ -1693,6 +2014,7 @@ pub async fn relay_passthrough_with_credentials( upstream: &mut U, ctx: &L7EvalContext, generation_guard: &PolicyGenerationGuard, + middleware_engine: Option<&crate::opa::OpaEngine>, ) -> Result<()> where C: AsyncRead + AsyncWrite + Unpin + Send, @@ -1775,6 +2097,55 @@ where ocsf_emit!(event); } + let req = if let Some(engine) = middleware_engine { + let input = middleware_network_input(ctx); + let (chain, generation) = engine.query_middleware_chain_with_generation(&input)?; + if generation != generation_guard.captured_generation() { + return Ok(()); + } + let runner = engine.middleware_runner()?; + // The passthrough path enforces no L7 policy, so there is no + // body-aware decision to re-check after a transformation. + match apply_middleware_chain( + req, + client, + ctx, + chain, + &runner, + generation_guard, + openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + ) + .await? + { + MiddlewareApplyResult::Allowed(request) => request, + MiddlewareApplyResult::Denied(reason) => { + crate::l7::rest::RestProvider::default() + .deny_with_redacted_target( + &crate::l7::provider::L7Request { + action: "HTTP".into(), + target: redacted_target.clone(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }, + &ctx.policy_name, + &reason, + client, + Some(&redacted_target), + Some(crate::l7::rest::DenyResponseContext { + host: Some(&ctx.host), + port: Some(ctx.port), + binary: Some(&ctx.binary_path), + }), + ) + .await?; + return Ok(()); + } + } + } else { + req + }; + let req_with_auth = match crate::l7::token_grant_injection::inject_if_needed(req, ctx).await { Ok(req) => req, @@ -1851,6 +2222,15 @@ mod tests { const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); + fn install_builtin_middleware(engine: &OpaEngine) { + engine.set_middleware_runner_for_tests(openshell_supervisor_middleware::ChainRunner::new( + openshell_supervisor_middleware_builtins::services() + .into_iter() + .next() + .expect("built-in middleware service"), + )); + } + fn rest_token_grant_relay_context( resolver_response: std::result::Result<&str, &str>, ) -> ( @@ -1920,6 +2300,73 @@ network_policies: (config, tunnel_engine, ctx, fixture) } + fn middleware_relay_context( + middleware_impl: &str, + on_error: &str, + ) -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + middleware_relay_context_with_enforcement(middleware_impl, on_error, "enforce") + } + + fn middleware_relay_context_with_enforcement( + middleware_impl: &str, + on_error: &str, + enforcement: &str, + ) -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + let data = format!( + r#" +network_middlewares: + - name: request-middleware + middleware: {middleware_impl} + on_error: {on_error} + endpoints: + include: ["api.example.test"] +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.test + port: 8080 + protocol: rest + enforcement: {enforcement} + rules: + - allow: + method: POST + path: "/v1/**" + binaries: + - {{ path: /usr/bin/curl }} +"# + ); + let engine = OpaEngine::from_strings(TEST_POLICY, &data).unwrap(); + install_builtin_middleware(&engine); + let input = NetworkInput { + host: "api.example.test".into(), + port: 8080, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .unwrap(); + let config = crate::l7::parse_l7_config(&endpoint_config.unwrap()).unwrap(); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 8080, + policy_name: "rest_api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + + (config, tunnel_engine, ctx) + } + fn passthrough_token_grant_relay_context( resolver_response: std::result::Result<&str, &str>, ) -> ( @@ -2131,7 +2578,10 @@ network_policies: .unwrap(); let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); - assert!(upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n")); + assert!( + upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n"), + "unexpected upstream request: {upstream_request:?}" + ); assert!(upstream_request.contains("Authorization: Bearer grant-token\r\n")); assert!(!upstream_request.contains("stale-token")); assert_eq!(authorization_header_count(&upstream_request), 1); @@ -2214,26 +2664,29 @@ network_policies: } #[tokio::test] - async fn passthrough_relay_injects_token_grant_authorization_header() { - let (generation_guard, ctx, fixture) = - passthrough_token_grant_relay_context(Ok("grant-token")); + async fn l7_rest_middleware_redacts_body_before_upstream() { + let (config, tunnel_engine, ctx) = + middleware_relay_context("openshell/secrets", "fail_closed"); let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); let relay = tokio::spawn(async move { - relay_passthrough_with_credentials( + relay_with_inspection( + &config, + tunnel_engine, &mut relay_client, &mut relay_upstream, &ctx, - &generation_guard, ) .await }); - app.write_all( - b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n", - ) - .await - .unwrap(); + let body = br#"{"api_key":"sk-1234567890abcdef"}"#; + let request = format!( + "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + std::str::from_utf8(body).unwrap() + ); + app.write_all(request.as_bytes()).await.unwrap(); let mut upstream_request = [0u8; 1024]; let n = tokio::time::timeout( @@ -2244,17 +2697,13 @@ network_policies: .expect("request should reach upstream") .unwrap(); let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); - - assert!(upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n")); - assert!(upstream_request.contains("Authorization: Bearer grant-token\r\n")); - assert!(!upstream_request.contains("stale-token")); - assert_eq!(authorization_header_count(&upstream_request), 1); + assert!(upstream_request.contains(r#""api_key":"[REDACTED]""#)); + assert!(!upstream_request.contains("sk-1234567890abcdef")); upstream .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") .await .unwrap(); - let mut client_response = [0u8; 512]; let n = tokio::time::timeout( std::time::Duration::from_secs(1), @@ -2265,74 +2714,1689 @@ network_policies: .unwrap(); assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); drop(app); - tokio::time::timeout(std::time::Duration::from_secs(1), relay) .await .expect("relay should finish") .unwrap() .unwrap(); - - fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); } #[tokio::test] - async fn passthrough_relay_token_grant_failure_returns_bad_gateway_without_forwarding() { - let (generation_guard, ctx, fixture) = - passthrough_token_grant_relay_context(Err("oauth unavailable")); + async fn l7_rest_middleware_acknowledges_expect_continue_before_reading_body() { + let (config, tunnel_engine, ctx) = + middleware_relay_context("openshell/secrets", "fail_closed"); let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); let relay = tokio::spawn(async move { - relay_passthrough_with_credentials( + relay_with_inspection( + &config, + tunnel_engine, &mut relay_client, &mut relay_upstream, &ctx, - &generation_guard, ) .await }); - app.write_all( - b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n", + let body = br#"{"api_key":"sk-1234567890abcdef"}"#; + let headers = format!( + "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n", + body.len() + ); + app.write_all(headers.as_bytes()).await.unwrap(); + + let mut interim = [0u8; 64]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut interim)) + .await + .expect("middleware buffering should acknowledge Expect before reading the body") + .unwrap(); + assert_eq!(&interim[..n], b"HTTP/1.1 100 Continue\r\n\r\n"); + + app.write_all(body).await.unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), ) .await + .expect("request should reach upstream after the body is released") .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + assert!(upstream_request.contains(r#""api_key":"[REDACTED]""#)); + assert!(!upstream_request.contains("Expect: 100-continue")); - tokio::time::timeout(std::time::Duration::from_secs(1), relay) + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") .await - .expect("relay should finish") - .unwrap() .unwrap(); - let mut client_response = [0u8; 512]; let n = tokio::time::timeout( std::time::Duration::from_secs(1), app.read(&mut client_response), ) .await - .expect("bad gateway response should reach client") + .expect("response should reach client") .unwrap(); - assert!(String::from_utf8_lossy(&client_response[..n]).contains("502 Bad Gateway")); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } - let mut upstream_request = [0u8; 128]; - let n = tokio::time::timeout( - std::time::Duration::from_secs(1), - upstream.read(&mut upstream_request), + #[tokio::test] + async fn l7_rest_middleware_fail_closed_does_not_reach_upstream() { + let (config, tunnel_engine, ctx) = + middleware_relay_context("example/unavailable", "fail_closed"); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}", ) .await - .expect("upstream should close without forwarded data") .unwrap(); - assert_eq!(n, 0, "unauthenticated request must not reach upstream"); - fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); - } + let mut response = [0u8; 512]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden")); + assert!(response.contains("middleware_failed")); - #[test] - fn websocket_text_policy_requires_explicit_message_rule() { - let data = r#" -network_policies: - ws_api: - name: ws_api - endpoints: + let mut upstream_request = [0u8; 32]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_request), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "upstream should not receive request bytes" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn audit_endpoint_forwards_policy_denied_request_through_healthy_chain() { + // Baseline for audit semantics: a request the L7 policy denies is + // still forwarded on an `enforcement: audit` endpoint when the + // middleware chain is healthy and allows it. + let (config, tunnel_engine, ctx) = + middleware_relay_context_with_enforcement("openshell/secrets", "fail_closed", "audit"); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /other HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut upstream_request = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("audited request should reach upstream") + .unwrap(); + assert!(String::from_utf8_lossy(&upstream_request[..n]).starts_with("GET /other")); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn audit_endpoint_still_enforces_middleware_deny() { + // `enforcement: audit` applies to the endpoint's L7 policy rules, not + // to middleware: a middleware deny (here a fail-closed failure) must + // block with 403 even though the same request would be forwarded + // under audit with a healthy chain. + let (config, tunnel_engine, ctx) = middleware_relay_context_with_enforcement( + "example/unavailable", + "fail_closed", + "audit", + ); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /other HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut response = [0u8; 512]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden")); + assert!(response.contains("middleware_failed")); + + let mut upstream_request = [0u8; 32]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_request), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "upstream should not receive request bytes" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn jsonrpc_middleware_fail_closed_does_not_reach_upstream() { + let data = r#" +network_middlewares: + - name: request-middleware + middleware: example/unavailable + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: api.example.test + port: 443 + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: reports.list + binaries: + - { path: /usr/bin/node } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "api.example.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let config = crate::l7::parse_l7_config(&endpoint_config.expect("json-rpc config")) + .expect("parse JSON-RPC config"); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "jsonrpc_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_jsonrpc( + &config, + &tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = br#"{"jsonrpc":"2.0","id":1,"method":"reports.list"}"#; + let request = format!( + "POST /rpc HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + std::str::from_utf8(body).unwrap() + ); + app.write_all(request.as_bytes()).await.unwrap(); + + let mut response = [0u8; 512]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden")); + assert!(response.contains("middleware_failed")); + + let mut upstream_request = [0u8; 32]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_request), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "upstream should not receive request bytes" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn l7_rest_middleware_over_capacity_fails_closed() { + let (config, tunnel_engine, ctx) = + middleware_relay_context("openshell/secrets", "fail_closed"); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + // A declared body far above the 256 KiB inspection cap must be denied + // (fail-closed) before the body is read or reaches the upstream. + let request = format!( + "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + 300 * 1024 + ); + app.write_all(request.as_bytes()).await.unwrap(); + + let mut response = [0u8; 512]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden")); + assert!(response.contains("request_body_over_capacity")); + + let mut upstream_request = [0u8; 32]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_request), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "upstream should not receive request bytes" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn over_capacity_resolution_honors_on_error() { + use openshell_supervisor_middleware::{ChainEntry, OnError}; + + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "p".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + let req = || crate::l7::provider::L7Request { + action: "POST".into(), + target: "/v1".into(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + let fail_open = ChainEntry { + name: "m".into(), + implementation: "openshell/secrets".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }; + let fail_closed = ChainEntry { + on_error: OnError::FailClosed, + ..fail_open.clone() + }; + + let runner = openshell_supervisor_middleware::ChainRunner::default(); + let open_chain = runner + .describe_chain(std::slice::from_ref(&fail_open)) + .await + .expect("describe fail-open chain"); + let mixed_chain = runner + .describe_chain(&[fail_open.clone(), fail_closed]) + .await + .expect("describe mixed chain"); + + // Recoverable (Content-Length over cap, nothing consumed) + all fail-open + // -> stream through unprocessed. + assert!(matches!( + resolve_unbuffered_body(&ctx, req(), &open_chain, true), + MiddlewareApplyResult::Allowed(_) + )); + // Any fail-closed entry -> deny. + assert!(matches!( + resolve_unbuffered_body(&ctx, req(), &mixed_chain, true), + MiddlewareApplyResult::Denied(_) + )); + // Not recoverable (chunked overflow already consumed bytes) -> deny even + // when every entry is fail-open. + assert!(matches!( + resolve_unbuffered_body(&ctx, req(), &open_chain, false), + MiddlewareApplyResult::Denied(_) + )); + } + + #[tokio::test] + async fn body_limit_ignores_unresolved_entries() { + use openshell_supervisor_middleware::{ChainEntry, ChainRunner, OnError}; + + let resolved = ChainEntry { + name: "redact".into(), + implementation: openshell_supervisor_middleware_builtins::BUILTIN_SECRETS.into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let unresolved = ChainEntry { + name: "missing".into(), + implementation: "third-party/missing".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }; + + // A single unresolved (0-limit) entry must not drag the chain limit to + // zero: the buffer limit reflects only the resolved built-in. + let mixed = ChainRunner::new( + openshell_supervisor_middleware_builtins::services() + .into_iter() + .next() + .expect("built-in middleware service"), + ) + .describe_chain(&[resolved, unresolved.clone()]) + .await + .expect("describe mixed chain"); + assert_eq!(middleware_chain_body_limit(&mixed), Some(256 * 1024)); + + // When nothing resolves, there is no body limit and the caller skips + // buffering entirely. + let none = ChainRunner::default() + .describe_chain(std::slice::from_ref(&unresolved)) + .await + .expect("describe unresolved chain"); + assert_eq!(middleware_chain_body_limit(&none), None); + } + + /// A middleware service whose single binding replaces every request body + /// with a fixed payload, for exercising post-transformation policy + /// re-evaluation. + struct BodyReplacingService { + replacement: &'static [u8], + } + + #[tonic::async_trait] + impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware + for BodyReplacingService + { + async fn describe( + &self, + _request: tonic::Request<()>, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::MiddlewareManifest { + name: "test/rewriter".into(), + service_version: "test".into(), + bindings: vec![openshell_core::proto::MiddlewareBinding { + id: "example/rewriter".into(), + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpRequest + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials + as i32, + max_body_bytes: 8192, + }], + }, + )) + } + + async fn validate_config( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::HttpRequestResult { + decision: openshell_core::proto::Decision::Allow as i32, + body: self.replacement.to_vec(), + has_body: true, + ..Default::default() + }, + )) + } + } + + fn jsonrpc_transforming_relay_parts( + enforcement: &str, + replacement: &'static [u8], + ) -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + let data = format!( + r#" +network_middlewares: + - name: rewriter + middleware: example/rewriter + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: api.example.test + port: 443 + protocol: json-rpc + enforcement: {enforcement} + rules: + - allow: + method: reports.list + binaries: + - {{ path: /usr/bin/node }} +"# + ); + let engine = OpaEngine::from_strings(TEST_POLICY, &data).unwrap(); + engine.set_middleware_runner_for_tests(openshell_supervisor_middleware::ChainRunner::new( + Arc::new(BodyReplacingService { replacement }), + )); + let input = NetworkInput { + host: "api.example.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let config = crate::l7::parse_l7_config(&endpoint_config.expect("json-rpc config")) + .expect("parse JSON-RPC config"); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "jsonrpc_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + (config, tunnel_engine, ctx) + } + + async fn run_jsonrpc_transform_case( + enforcement: &str, + replacement: &'static [u8], + ) -> (String, Option) { + let (config, tunnel_engine, ctx) = + jsonrpc_transforming_relay_parts(enforcement, replacement); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_jsonrpc( + &config, + &tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = br#"{"jsonrpc":"2.0","id":1,"method":"reports.list"}"#; + let request = format!( + "POST /rpc HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + std::str::from_utf8(body).unwrap() + ); + app.write_all(request.as_bytes()).await.unwrap(); + + // Give the relay a moment to either deny (client sees a response) or + // forward (upstream sees the request). + let mut upstream_request = [0u8; 1024]; + let upstream_read = tokio::time::timeout( + std::time::Duration::from_millis(300), + upstream.read(&mut upstream_request), + ) + .await; + let upstream_seen = match upstream_read { + Ok(Ok(n)) if n > 0 => { + let seen = String::from_utf8_lossy(&upstream_request[..n]).to_string(); + upstream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + Some(seen) + } + _ => None, + }; + + let mut response = [0u8; 1024]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("client should receive a response") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]).to_string(); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + (response, upstream_seen) + } + + #[tokio::test] + async fn transformed_jsonrpc_body_is_reevaluated_and_denied() { + // Policy allows reports.list; the middleware replaces the body with a + // method the policy denies. The transformed body must be re-evaluated + // and the request denied before anything reaches the upstream. + let (response, upstream_seen) = run_jsonrpc_transform_case( + "enforce", + br#"{"jsonrpc":"2.0","id":1,"method":"admin.delete"}"#, + ) + .await; + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("middleware transformation denied by policy"), + "{response}" + ); + assert!(upstream_seen.is_none(), "upstream must not see the request"); + } + + #[tokio::test] + async fn transformed_jsonrpc_body_policy_deny_forwards_under_audit() { + // Under enforcement: audit a policy deny of the transformed body is + // logged but forwarded, mirroring audit semantics for original + // bodies. + let (response, upstream_seen) = run_jsonrpc_transform_case( + "audit", + br#"{"jsonrpc":"2.0","id":1,"method":"admin.delete"}"#, + ) + .await; + assert!(response.contains("204 No Content"), "{response}"); + let upstream_seen = upstream_seen.expect("audited request reaches upstream"); + assert!(upstream_seen.contains("admin.delete"), "{upstream_seen}"); + } + + #[tokio::test] + async fn unparseable_transformation_denies_even_under_audit() { + // An unparseable replacement mirrors force_deny for original parse + // errors: denied even on an audit endpoint. + let (response, upstream_seen) = run_jsonrpc_transform_case("audit", b"not json").await; + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("middleware transformation rejected"), + "{response}" + ); + assert!(upstream_seen.is_none(), "upstream must not see the request"); + } + + #[tokio::test] + async fn transformed_graphql_body_is_reevaluated_and_denied() { + // GraphQL counterpart: policy allows query { viewer }; the middleware + // rewrites the body into a denied mutation. + let data = r#" +network_middlewares: + - name: rewriter + middleware: example/rewriter + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + graphql_api: + name: graphql_api + endpoints: + - host: api.example.test + port: 443 + protocol: graphql + enforcement: enforce + rules: + - allow: + operation_type: query + fields: [viewer] + binaries: + - { path: /usr/bin/node } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + engine.set_middleware_runner_for_tests(openshell_supervisor_middleware::ChainRunner::new( + Arc::new(BodyReplacingService { + replacement: br#"{"query":"mutation { deleteRepository }"}"#, + }), + )); + let input = NetworkInput { + host: "api.example.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let config = crate::l7::parse_l7_config(&endpoint_config.expect("graphql config")) + .expect("parse GraphQL config"); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "graphql_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_graphql( + &config, + &tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = br#"{"query":"query { viewer }"}"#; + let request = format!( + "POST /graphql HTTP/1.1\r\nHost: api.example.test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + std::str::from_utf8(body).unwrap() + ); + app.write_all(request.as_bytes()).await.unwrap(); + + let mut response = [0u8; 1024]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("middleware transformation denied by policy"), + "{response}" + ); + + let mut upstream_request = [0u8; 32]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_request), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "upstream should not receive request bytes" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + fn sql_middleware_relay_context( + on_error: &str, + ) -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + let data = format!( + r#" +network_middlewares: + - name: guard + middleware: example/unavailable + on_error: {on_error} + endpoints: + include: ["db.example.test"] +network_policies: + sql_db: + name: sql_db + endpoints: + - host: db.example.test + port: 5432 + protocol: sql + enforcement: audit + rules: + - allow: + command: SELECT + binaries: + - {{ path: /usr/bin/psql }} +"# + ); + let engine = OpaEngine::from_strings(TEST_POLICY, &data).unwrap(); + let input = NetworkInput { + host: "db.example.test".into(), + port: 5432, + binary_path: PathBuf::from("/usr/bin/psql"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let config = crate::l7::parse_l7_config(&endpoint_config.expect("sql config")) + .expect("parse SQL config"); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "db.example.test".into(), + port: 5432, + policy_name: "sql_db".into(), + binary_path: "/usr/bin/psql".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + (config, tunnel_engine, ctx) + } + + #[tokio::test] + async fn sql_passthrough_denies_with_fail_closed_middleware() { + // The SQL relay is unimplemented, so a fail-closed chain can never + // inspect the stream: the connection must be closed instead of + // silently bypassing the middleware. + let (config, tunnel_engine, ctx) = sql_middleware_relay_context("fail_closed"); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all(b"\x00\x00\x00\x08\x04\xd2\x16\x2f") + .await + .ok(); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should close the connection") + .unwrap() + .unwrap(); + + let mut upstream_bytes = [0u8; 16]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_bytes), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "upstream should not receive SQL bytes" + ); + } + + #[tokio::test] + async fn sql_passthrough_relays_with_fail_open_middleware() { + // An all-fail-open chain accepts the bypass (with a detection + // finding) and the raw stream flows. + let (config, tunnel_engine, ctx) = sql_middleware_relay_context("fail_open"); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let _relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all(b"\x00\x00\x00\x08\x04\xd2\x16\x2f") + .await + .unwrap(); + + let mut upstream_bytes = [0u8; 16]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_bytes), + ) + .await + .expect("fail-open chain must relay SQL bytes") + .unwrap(); + assert_eq!(&upstream_bytes[..n], b"\x00\x00\x00\x08\x04\xd2\x16\x2f"); + } + + #[test] + fn uninspectable_gate_reflects_chain_on_error() { + use openshell_supervisor_middleware::{ChainEntry, OnError}; + + let entry = |on_error| ChainEntry { + name: "m".into(), + implementation: "example/guard".into(), + order: 0, + config: prost_types::Struct::default(), + on_error, + }; + + assert_eq!( + uninspectable_traffic_gate(&[]), + UninspectableTrafficGate::Unrestricted + ); + assert_eq!( + uninspectable_traffic_gate(&[entry(OnError::FailOpen), entry(OnError::FailOpen)]), + UninspectableTrafficGate::BypassWithFinding + ); + assert_eq!( + uninspectable_traffic_gate(&[entry(OnError::FailOpen), entry(OnError::FailClosed)]), + UninspectableTrafficGate::Deny + ); + } + + /// A middleware service advertising two bindings with different body + /// limits, for exercising mixed-limit chain buffering at the relay level. + /// The redactor binding replaces the body; the guard binding allows as-is. + struct TwoLimitService; + + #[tonic::async_trait] + impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware + for TwoLimitService + { + async fn describe( + &self, + _request: tonic::Request<()>, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + use openshell_core::proto::{ + MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, + SupervisorMiddlewarePhase, + }; + let binding = |id: &str, max_body_bytes: u64| MiddlewareBinding { + id: id.into(), + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_body_bytes, + }; + Ok(tonic::Response::new(MiddlewareManifest { + name: "test/two-limits".into(), + service_version: "test".into(), + bindings: vec![binding("test/redactor", 8192), binding("test/guard", 16)], + })) + } + + async fn validate_config( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + let evaluation = request.into_inner(); + let mut result = openshell_core::proto::HttpRequestResult { + decision: openshell_core::proto::Decision::Allow as i32, + ..Default::default() + }; + if evaluation.binding_id == "test/redactor" { + result.body = b"[SCRUBBED BY TEST REDACTOR]".to_vec(); + result.has_body = true; + } + Ok(tonic::Response::new(result)) + } + } + + #[tokio::test] + async fn body_over_smallest_stage_limit_is_buffered_and_evaluated() { + use openshell_supervisor_middleware::{ChainEntry, ChainRunner, OnError}; + + // A 64-byte body exceeds the 16-byte guard limit but fits the 8 KiB + // redactor. The chain must buffer for its largest stage so the + // redactor runs and replaces the body, while the undersized fail-open + // guard is skipped through its own on_error, instead of the whole + // chain taking the unbuffered over-capacity path. + let (_config, tunnel_engine, ctx) = + middleware_relay_context("openshell/secrets", "fail_closed"); + let runner = ChainRunner::new(Arc::new(TwoLimitService)); + let chain = vec![ + ChainEntry { + name: "redact".into(), + implementation: "test/redactor".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ChainEntry { + name: "guard".into(), + implementation: "test/guard".into(), + order: 10, + config: prost_types::Struct::default(), + on_error: OnError::FailOpen, + }, + ]; + let described = runner.describe_chain(&chain).await.expect("describe chain"); + assert_eq!(middleware_chain_body_limit(&described), Some(8192)); + + let body = [b'a'; 64]; + let raw_header = format!( + "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let req = crate::l7::provider::L7Request { + action: "POST".into(), + target: "/v1/messages".into(), + query_params: std::collections::HashMap::new(), + raw_header: raw_header.into_bytes(), + body_length: crate::l7::provider::BodyLength::ContentLength(body.len() as u64), + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + app.write_all(&body).await.unwrap(); + + let result = crate::l7::middleware::apply_middleware_chain_for_scheme( + req, + &mut relay_client, + &ctx, + "https", + chain, + &runner, + tunnel_engine.generation_guard(), + openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + ) + .await + .expect("apply middleware chain"); + + match result { + MiddlewareApplyResult::Allowed(rebuilt) => { + let raw = String::from_utf8(rebuilt.raw_header).expect("utf8 request"); + assert!( + raw.ends_with("[SCRUBBED BY TEST REDACTOR]"), + "redactor must replace the body: {raw}" + ); + } + MiddlewareApplyResult::Denied(reason) => { + panic!("body within the largest stage limit must not fail the chain: {reason}") + } + } + } + + #[tokio::test] + async fn all_unresolved_fail_open_forwards_body_unbuffered() { + // A chain whose only entry is an unregistered binding has no resolvable + // body limit. Under fail_open the request must pass through with its + // body intact rather than being denied over a phantom zero-byte cap. + let (config, tunnel_engine, ctx) = + middleware_relay_context("third-party/missing", "fail_open"); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = br#"{"api_key":"sk-1234567890abcdef"}"#; + let request = format!( + "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + std::str::from_utf8(body).unwrap() + ); + app.write_all(request.as_bytes()).await.unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + // No middleware ran, so the body is forwarded verbatim. + assert!(upstream_request.contains(r#""api_key":"sk-1234567890abcdef""#)); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[test] + fn middleware_keeps_the_raw_request_query() { + let query = raw_query_from_request_headers( + b"POST /v1/messages?token=a%2Bb&scope=private HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + ) + .expect("query from request headers"); + + assert_eq!(query, "token=a%2Bb&scope=private"); + } + + #[test] + fn middleware_request_input_preserves_plain_http_scheme() { + let req = crate::l7::provider::L7Request { + action: "POST".into(), + target: "/v1/messages".into(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 80, + policy_name: "api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: Vec::new(), + cmdline_paths: Vec::new(), + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + + let input = + middleware_request_input("http", &req, &ctx, Vec::new(), String::new(), Vec::new()); + + assert_eq!(input.scheme, "http"); + } + + #[test] + fn middleware_ocsf_events_are_audit_safe() { + use openshell_supervisor_middleware::{ + ChainOutcome, MiddlewareInvocation, NamespacedFinding, + }; + + const RAW_SECRET: &str = "sk-RAWSECRETVALUE0123456789"; + + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + policy_name: "rest_api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + let req = crate::l7::provider::L7Request { + action: "POST".into(), + target: "/v1/messages".into(), + query_params: std::collections::HashMap::new(), + raw_header: Vec::new(), + body_length: crate::l7::provider::BodyLength::None, + }; + let outcome = ChainOutcome { + allowed: true, + reason: String::new(), + // The transformed body still holds the raw secret; emission must never + // serialize it. + body: format!(r#"{{"api_key":"{RAW_SECRET}"}}"#).into_bytes(), + header_mutations: Vec::new(), + findings: vec![NamespacedFinding { + middleware: "redact-secrets".into(), + finding: openshell_core::proto::Finding { + r#type: "secret.common".into(), + label: "common secret pattern".into(), + count: 1, + confidence: "medium".into(), + severity: "medium".into(), + }, + }], + metadata: BTreeMap::new(), + applied: vec![MiddlewareInvocation { + name: "redact-secrets".into(), + implementation: "openshell/secrets".into(), + decision: openshell_core::proto::Decision::Allow, + transformed: true, + failed: false, + }], + }; + + // Build the events directly rather than routing through the global + // tracing pipeline: its callsite-interest cache is process-global, so a + // parallel test that emits OCSF with no subscriber installed can cache + // the callsite as disabled and make captured-event assertions flaky. + let events = middleware_events(&ctx, &req, &outcome); + + // Per-invocation decisions are HTTP Activity (class 4002). + assert!( + events.iter().any(|e| e.class_uid() == 4002), + "expected an HTTP Activity event for the middleware invocation" + ); + // Findings are Detection Finding (class 2004) with the finding's severity. + let finding_event = events + .iter() + .find(|e| e.class_uid() == 2004) + .expect("expected a Detection Finding event"); + assert_eq!(finding_event.base().severity, SeverityId::Medium); + + // No raw payload material may appear in any emitted event. + let serialized = serde_json::to_string(&events).expect("serialize events"); + assert!( + !serialized.contains(RAW_SECRET), + "raw secret leaked into OCSF events: {serialized}" + ); + // Safe finding metadata is still present. + assert!(serialized.contains("secret.common")); + + let denied_outcome = ChainOutcome { + allowed: false, + reason: "request matched configured policy".into(), + body: Vec::new(), + header_mutations: Vec::new(), + findings: Vec::new(), + metadata: BTreeMap::new(), + applied: vec![MiddlewareInvocation { + name: "content-guard".into(), + implementation: "example/content-guard".into(), + decision: openshell_core::proto::Decision::Deny, + transformed: false, + failed: false, + }], + }; + let denied_events = middleware_events(&ctx, &req, &denied_outcome); + let denied_http = denied_events + .iter() + .find(|event| event.class_uid() == 4002) + .expect("expected denied HTTP Activity event"); + assert_eq!( + denied_http.base().status_detail.as_deref(), + Some("request matched configured policy") + ); + let denied_json = denied_http.to_json().expect("serialize denied event"); + assert_eq!(denied_json["unmapped"]["transformed"], false); + assert_eq!(denied_json["unmapped"]["failed"], false); + assert_eq!( + denied_http.format_shorthand(), + "HTTP:POST [MED] DENIED POST http://api.example.test:443/v1/messages \ + [policy:rest_api engine:middleware] \ + [failed:false transformed:false reason:request matched configured policy]" + ); + } + + #[tokio::test] + async fn passthrough_relay_runs_middleware_redaction() { + // A no-protocol endpoint takes the credential-injection passthrough path; + // host-selected middleware must still inspect and redact its body. + let data = r#" +network_middlewares: + - name: request-middleware + middleware: openshell/secrets + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + passthrough_api: + name: passthrough_api + endpoints: + - host: api.example.test + port: 8080 + binaries: + - { path: /usr/bin/curl } +"#; + let engine = Arc::new(OpaEngine::from_strings(TEST_POLICY, data).unwrap()); + install_builtin_middleware(engine.as_ref()); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 8080, + policy_name: "passthrough_api".into(), + binary_path: "/usr/bin/curl".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let engine_task = Arc::clone(&engine); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + Some(engine_task.as_ref()), + ) + .await + }); + + let body = br#"{"api_key":"sk-1234567890abcdef"}"#; + let request = format!( + "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + std::str::from_utf8(body).unwrap() + ); + app.write_all(request.as_bytes()).await.unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + assert!( + upstream_request.contains(r#""api_key":"[REDACTED]""#), + "unexpected upstream request: {upstream_request:?}" + ); + assert!(!upstream_request.contains("sk-1234567890abcdef")); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn websocket_upgrade_request_is_inspected_and_denied() { + // The WebSocket upgrade handshake is an HTTP request the hook can inspect + // and deny: a fail-closed middleware blocks the upgrade before it is + // forwarded. + let data = r#" +network_middlewares: + - name: request-middleware + middleware: example/unavailable + on_error: fail_closed + endpoints: + include: ["gateway.example.test"] +network_policies: + ws_api: + name: ws_api + endpoints: + - host: gateway.example.test + port: 443 + protocol: websocket + enforcement: enforce + rules: + - allow: + method: GET + path: "/ws" + binaries: + - { path: /usr/bin/node } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "gateway.example.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .unwrap(); + let config = crate::l7::parse_l7_config(&endpoint_config.unwrap()).unwrap(); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "gateway.example.test".into(), + port: 443, + policy_name: "ws_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /ws HTTP/1.1\r\nHost: gateway.example.test\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n", + ) + .await + .unwrap(); + + // Accumulate until the reason marker arrives: the deny response can be + // delivered in more than one write, so a single read may return only the + // status line and flake the body assertion. + let mut response = Vec::new(); + let mut buf = [0u8; 512]; + while !String::from_utf8_lossy(&response).contains("middleware_failed") { + match tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut buf)).await + { + Ok(Ok(0)) | Err(_) => break, // clean EOF, or no more data before the deadline + Ok(Ok(n)) => response.extend_from_slice(&buf[..n]), + Ok(Err(e)) => panic!("read from relay failed: {e}"), + } + } + let response = String::from_utf8_lossy(&response); + assert!(response.contains("403 Forbidden")); + assert!(response.contains("middleware_failed")); + + let mut upstream_request = [0u8; 32]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_request), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "upstream should not receive the upgrade request" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn passthrough_relay_injects_token_grant_authorization_header() { + let (generation_guard, ctx, fixture) = + passthrough_token_grant_relay_context(Ok("grant-token")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer stale-token\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut upstream_request = [0u8; 1024]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("request should reach upstream") + .unwrap(); + let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + + assert!(upstream_request.starts_with("GET /v1/projects HTTP/1.1\r\n")); + assert!(upstream_request.contains("Authorization: Bearer grant-token\r\n")); + assert!(!upstream_request.contains("stale-token")); + assert_eq!(authorization_header_count(&upstream_request), 1); + + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); + drop(app); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); + } + + #[tokio::test] + async fn passthrough_relay_token_grant_failure_returns_bad_gateway_without_forwarding() { + let (generation_guard, ctx, fixture) = + passthrough_token_grant_relay_context(Err("oauth unavailable")); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + app.write_all( + b"GET /v1/projects HTTP/1.1\r\nHost: api.example.test\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + let mut client_response = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read(&mut client_response), + ) + .await + .expect("bad gateway response should reach client") + .unwrap(); + assert!(String::from_utf8_lossy(&client_response[..n]).contains("502 Bad Gateway")); + + let mut upstream_request = [0u8; 128]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut upstream_request), + ) + .await + .expect("upstream should close without forwarded data") + .unwrap(); + assert_eq!(n, 0, "unauthenticated request must not reach upstream"); + + fixture.assert_one_request("api.example.test\t8080\t/v1/**\tprovider:access_token"); + } + + #[test] + fn websocket_text_policy_requires_explicit_message_rule() { + let data = r#" +network_policies: + ws_api: + name: ws_api + endpoints: - host: gateway.example.test port: 443 protocol: websocket @@ -2725,6 +4789,102 @@ network_policies: assert!(no_params_message.contains("rule_methods=initialize")); } + #[tokio::test] + async fn route_selected_jsonrpc_response_frame_hard_denies_under_audit() { + let data = r" +network_policies: + route_api: + name: route_api + endpoints: + - host: gateway.example.test + port: 443 + path: /rpc + protocol: json-rpc + enforcement: audit + rules: + - allow: + method: initialize + binaries: + - { path: /usr/bin/node } +"; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "gateway.example.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let configs = vec![ + crate::l7::parse_l7_config(&endpoint.expect("JSON-RPC endpoint")) + .expect("parse JSON-RPC config"), + ]; + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "gateway.example.test".into(), + port: 443, + policy_name: "route_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_route_selection( + &configs, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body = br#"{"jsonrpc":"2.0","id":7,"result":{"ok":true}}"#; + let request = format!( + "POST /rpc HTTP/1.1\r\nHost: gateway.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + app.write_all(request.as_bytes()).await.unwrap(); + app.write_all(body).await.unwrap(); + + let mut response = [0u8; 1024]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("hard denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!(response.contains("response frames"), "{response}"); + + let mut upstream_bytes = [0u8; 16]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_bytes), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "hard-denied response frame must not reach upstream" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + #[tokio::test] async fn route_selected_websocket_upgrade_rejects_invalid_accept_without_forwarding_101() { let data = r#" @@ -3214,7 +5374,10 @@ network_policies: .expect("first request should reach upstream") .unwrap(); let first_upstream = String::from_utf8_lossy(&first_upstream[..n]); - assert!(first_upstream.starts_with("POST /write HTTP/1.1")); + assert!( + first_upstream.starts_with("POST /write HTTP/1.1"), + "unexpected upstream request: {first_upstream:?}" + ); upstream .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: keep-alive\r\n\r\nOK") @@ -3284,6 +5447,7 @@ network_policies: &mut relay_upstream, &ctx, &generation_guard, + None, ) .await }); diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 0558a67e55..51fd7192d1 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -12,6 +12,7 @@ use crate::opa::PolicyGenerationGuard; use aws_sigv4::http_request::SignableBody; use base64::Engine as _; use miette::{IntoDiagnostic, Result, miette}; +use openshell_core::proto::{ExistingHeaderAction, HeaderMutation, header_mutation}; use openshell_core::secrets::{ SecretResolver, contains_reserved_credential_marker, rewrite_http_header_block, }; @@ -27,6 +28,25 @@ const MAX_REWRITE_BODY_BYTES: usize = 256 * 1024; /// Maximum body bytes for `SigV4` body-signing mode. Larger than the credential /// rewrite limit because Bedrock payloads can be several megabytes. const MAX_SIGV4_BODY_BYTES: usize = 10 * 1024 * 1024; +#[cfg(test)] +async fn max_middleware_body_bytes() -> usize { + let chain = openshell_supervisor_middleware::ChainRunner::new( + openshell_supervisor_middleware_builtins::services() + .into_iter() + .next() + .expect("built-in middleware service"), + ) + .describe_chain(&[openshell_supervisor_middleware::ChainEntry { + name: "test".into(), + implementation: openshell_supervisor_middleware_builtins::BUILTIN_SECRETS.into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]) + .await + .expect("describe built-in middleware"); + chain[0].max_body_bytes() +} const RELAY_BUF_SIZE: usize = 8192; const HTTP_METHOD_PREFIXES: &[&[u8]] = &[ b"GET ", @@ -245,6 +265,36 @@ async fn parse_http_request( })) } +/// Build an L7 request from a request already buffered by another proxy path. +/// +/// The forward proxy needs this after it has consumed the incoming HTTP/1 +/// headers itself. Keep the framing and query parsing here so it matches the +/// stream-based REST parser rather than growing another local parser. +pub(crate) fn request_from_buffered_http( + action: impl Into, + target: impl Into, + query_target: &str, + raw_header: Vec, +) -> Result { + let header_end = raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or_else(|| miette!("HTTP request headers are missing the CRLF terminator"))? + + 4; + let header_str = std::str::from_utf8(&raw_header[..header_end]) + .map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; + let body_length = parse_body_length(header_str)?; + let (_, query_params) = parse_target_query(query_target)?; + + Ok(L7Request { + action: action.into(), + target: target.into(), + query_params, + raw_header, + body_length, + }) +} + /// Rebuild the request line in a raw HTTP header block with a canonicalized /// target. Called when the canonical path differs from what the client sent, /// so the upstream dispatches on the exact bytes the policy engine evaluated. @@ -768,6 +818,137 @@ struct PreparedRequestBody { body: Vec, } +pub(crate) struct BufferedRequestBody { + pub(crate) headers: Vec, + pub(crate) body: Vec, +} + +/// Result of attempting to buffer a request body for middleware inspection. +pub(crate) enum BufferResult { + /// The full body was buffered within the size cap. + Buffered(BufferedRequestBody), + /// The body exceeded the inspection cap. `recoverable` is true when no body + /// bytes were consumed yet (a declared `Content-Length` over the cap), so the + /// request can still be streamed through unprocessed under fail-open. It is + /// false once bytes have been consumed (chunked overflow), where denying is + /// the only safe outcome. + OverCapacity { recoverable: bool }, +} + +pub(crate) async fn buffer_request_body_for_middleware( + req: &L7Request, + client: &mut C, + generation_guard: Option<&PolicyGenerationGuard>, + max_body_bytes: usize, +) -> Result { + let header_end = req + .raw_header + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(req.raw_header.len(), |p| p + 4); + let mut headers = req.raw_header[..header_end].to_vec(); + let already_read = &req.raw_header[header_end..]; + match req.body_length { + BodyLength::None => Ok(BufferResult::Buffered(BufferedRequestBody { + headers, + body: already_read.to_vec(), + })), + BodyLength::ContentLength(len) => { + // The declared length is known before any further reads, so an + // over-cap body here has not consumed the stream and can be passed + // through unprocessed if every middleware is fail-open. + let Ok(len) = usize::try_from(len) else { + return Ok(BufferResult::OverCapacity { recoverable: true }); + }; + if len > max_body_bytes { + return Ok(BufferResult::OverCapacity { recoverable: true }); + } + let initial_len = already_read.len().min(len); + let mut body = Vec::with_capacity(len); + body.extend_from_slice(&already_read[..initial_len]); + let mut remaining = len.saturating_sub(initial_len); + if remaining > 0 && already_read.is_empty() { + acknowledge_expect_continue(client, &mut headers).await?; + } + let mut buf = [0u8; RELAY_BUF_SIZE]; + while remaining > 0 { + let to_read = remaining.min(buf.len()); + let n = client.read(&mut buf[..to_read]).await.into_diagnostic()?; + if n == 0 { + return Err(miette!( + "Connection closed with {remaining} body bytes remaining" + )); + } + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + body.extend_from_slice(&buf[..n]); + remaining -= n; + } + Ok(BufferResult::Buffered(BufferedRequestBody { + headers, + body, + })) + } + BodyLength::Chunked => { + // Chunked bodies are decoded incrementally into the payload bytes + // middleware expects, but the middleware cap counts the complete + // wire representation, including framing and trailers. On overflow, + // we have already consumed wire bytes from the client stream and + // cannot re-enter the normal raw relay path without a separate + // splice-through buffer. + if already_read.is_empty() { + acknowledge_expect_continue(client, &mut headers).await?; + } + Ok( + collect_chunked_body(client, already_read, generation_guard, Some(max_body_bytes)) + .await + .map_or(BufferResult::OverCapacity { recoverable: false }, |body| { + BufferResult::Buffered(BufferedRequestBody { headers, body }) + }), + ) + } + } +} + +async fn acknowledge_expect_continue( + client: &mut C, + headers: &mut Vec, +) -> Result<()> { + let header_str = + std::str::from_utf8(headers).map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; + if !has_expect_continue(header_str) { + return Ok(()); + } + + client + .write_all(b"HTTP/1.1 100 Continue\r\n\r\n") + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + *headers = strip_header(headers, "expect")?; + Ok(()) +} + +pub(crate) fn rebuild_request_with_buffered_body( + req: &L7Request, + headers: &[u8], + body: &[u8], + header_mutations: &[HeaderMutation], +) -> Result { + let mut header_bytes = set_content_length(headers, body.len())?; + header_bytes = strip_header(&header_bytes, "transfer-encoding")?; + header_bytes = apply_header_mutations(&header_bytes, header_mutations)?; + header_bytes.extend_from_slice(body); + Ok(L7Request { + action: req.action.clone(), + target: req.target.clone(), + query_params: req.query_params.clone(), + raw_header: header_bytes, + body_length: BodyLength::ContentLength(body.len() as u64), + }) +} + async fn collect_and_rewrite_request_body( req: &L7Request, client: &mut C, @@ -821,16 +1002,12 @@ async fn collect_and_rewrite_request_body( Ok(PreparedRequestBody { headers, body }) } BodyLength::Chunked => { - let body = collect_chunked_body(client, already_read, generation_guard).await?; - if body_bytes_contain_reserved_marker(&body) { - return Err(miette!( - "request body credential rewrite does not support chunked bodies containing credential placeholders" - )); - } - Ok(PreparedRequestBody { - headers: rewritten_headers.to_vec(), - body, - }) + let body = collect_chunked_body(client, already_read, generation_guard, None).await?; + let (mut headers, body) = + rewrite_buffered_body(rewritten_headers, original_header_str, body, resolver)?; + headers = set_content_length(&headers, body.len())?; + headers = strip_header(&headers, "transfer-encoding")?; + Ok(PreparedRequestBody { headers, body }) } } } @@ -997,38 +1174,21 @@ async fn collect_chunked_body( client: &mut C, already_read: &[u8], generation_guard: Option<&PolicyGenerationGuard>, + max_wire_bytes: Option, ) -> Result> { - let mut read_buf = [0u8; RELAY_BUF_SIZE]; - let mut parse_buf = Vec::from(already_read); - let mut pos = 0usize; + let max_decoded_bytes = max_wire_bytes.unwrap_or(MAX_REWRITE_BODY_BYTES); + let mut read_state = ChunkedReadState { + buffered_pos: 0, + wire_bytes: 0, + max_wire_bytes, + }; + let mut body = Vec::new(); loop { - if parse_buf.len() > MAX_REWRITE_BODY_BYTES { - return Err(miette!( - "request body credential rewrite buffers at most {MAX_REWRITE_BODY_BYTES} bytes" - )); - } - - let size_line_end = loop { - if let Some(end) = find_crlf(&parse_buf, pos) { - break end; - } - let n = client.read(&mut read_buf).await.into_diagnostic()?; - if n == 0 { - return Err(miette!("Chunked body ended before chunk-size line")); - } - if let Some(guard) = generation_guard { - guard.ensure_current()?; - } - parse_buf.extend_from_slice(&read_buf[..n]); - if parse_buf.len() > MAX_REWRITE_BODY_BYTES { - return Err(miette!( - "request body credential rewrite buffers at most {MAX_REWRITE_BODY_BYTES} bytes" - )); - } - }; - - let size_line = std::str::from_utf8(&parse_buf[pos..size_line_end]) + let size_line = read_chunked_line(client, already_read, &mut read_state, generation_guard) + .await + .map_err(|e| miette!("Chunked body ended before chunk-size line: {e}"))?; + let size_line = std::str::from_utf8(&size_line) .into_diagnostic() .map_err(|_| miette!("Invalid UTF-8 in chunk-size line"))?; let size_token = size_line @@ -1039,64 +1199,127 @@ async fn collect_chunked_body( let chunk_size = usize::from_str_radix(size_token, 16) .into_diagnostic() .map_err(|_| miette!("Invalid chunk size token: {size_token:?}"))?; - pos = size_line_end + 2; if chunk_size == 0 { loop { - let trailer_end = loop { - if let Some(end) = find_crlf(&parse_buf, pos) { - break end; - } - let n = client.read(&mut read_buf).await.into_diagnostic()?; - if n == 0 { - return Err(miette!("Chunked body ended before trailer terminator")); - } - if let Some(guard) = generation_guard { - guard.ensure_current()?; - } - parse_buf.extend_from_slice(&read_buf[..n]); - if parse_buf.len() > MAX_REWRITE_BODY_BYTES { - return Err(miette!( - "request body credential rewrite buffers at most {MAX_REWRITE_BODY_BYTES} bytes" - )); - } - }; - let trailer_line = &parse_buf[pos..trailer_end]; - pos = trailer_end + 2; + let trailer_line = + read_chunked_line(client, already_read, &mut read_state, generation_guard) + .await + .map_err(|e| { + miette!("Chunked body ended before trailer terminator: {e}") + })?; if trailer_line.is_empty() { - return Ok(parse_buf); + return Ok(body); } } } - let chunk_end = pos - .checked_add(chunk_size) - .ok_or_else(|| miette!("Chunk size overflow"))?; - let chunk_with_crlf_end = chunk_end - .checked_add(2) - .ok_or_else(|| miette!("Chunk size overflow"))?; - while parse_buf.len() < chunk_with_crlf_end { - let n = client.read(&mut read_buf).await.into_diagnostic()?; - if n == 0 { - return Err(miette!("Chunked body ended mid-chunk")); - } - if let Some(guard) = generation_guard { - guard.ensure_current()?; - } - parse_buf.extend_from_slice(&read_buf[..n]); - if parse_buf.len() > MAX_REWRITE_BODY_BYTES { - return Err(miette!( - "request body credential rewrite buffers at most {MAX_REWRITE_BODY_BYTES} bytes" - )); - } + if body.len().saturating_add(chunk_size) > max_decoded_bytes { + return Err(miette!( + "decoded chunked body exceeds {max_decoded_bytes} byte buffer limit" + )); } - if &parse_buf[chunk_end..chunk_with_crlf_end] != b"\r\n" { + read_buffered_exact( + client, + already_read, + &mut read_state, + chunk_size, + &mut body, + generation_guard, + ) + .await + .map_err(|e| miette!("Chunked body ended mid-chunk: {e}"))?; + + let mut chunk_crlf = Vec::with_capacity(2); + read_buffered_exact( + client, + already_read, + &mut read_state, + 2, + &mut chunk_crlf, + generation_guard, + ) + .await + .map_err(|e| miette!("Chunked body ended before chunk terminator: {e}"))?; + if chunk_crlf.as_slice() != b"\r\n" { return Err(miette!("Chunk missing terminating CRLF")); } - pos = chunk_with_crlf_end; } } +struct ChunkedReadState { + buffered_pos: usize, + wire_bytes: usize, + max_wire_bytes: Option, +} + +async fn read_chunked_line( + client: &mut C, + already_read: &[u8], + state: &mut ChunkedReadState, + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result> { + let mut line = Vec::new(); + loop { + let byte = read_buffered_byte(client, already_read, state, generation_guard).await?; + line.push(byte); + if line.len() > MAX_REWRITE_BODY_BYTES { + return Err(miette!( + "request body credential rewrite buffers at most {MAX_REWRITE_BODY_BYTES} bytes" + )); + } + if line.ends_with(b"\r\n") { + line.truncate(line.len() - 2); + return Ok(line); + } + } +} + +async fn read_buffered_exact( + client: &mut C, + already_read: &[u8], + state: &mut ChunkedReadState, + len: usize, + out: &mut Vec, + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result<()> { + for _ in 0..len { + let byte = read_buffered_byte(client, already_read, state, generation_guard).await?; + out.push(byte); + } + Ok(()) +} + +async fn read_buffered_byte( + client: &mut C, + already_read: &[u8], + state: &mut ChunkedReadState, + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result { + if state + .max_wire_bytes + .is_some_and(|max| state.wire_bytes >= max) + { + return Err(miette!( + "chunked body wire representation exceeds middleware buffer limit" + )); + } + + let byte = if state.buffered_pos < already_read.len() { + let byte = already_read[state.buffered_pos]; + state.buffered_pos += 1; + byte + } else { + let byte = client.read_u8().await.into_diagnostic()?; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + byte + }; + state.wire_bytes += 1; + Ok(byte) +} + fn content_type(headers: &str) -> Option { headers.lines().skip(1).find_map(|line| { let (name, value) = line.split_once(':')?; @@ -1160,6 +1383,82 @@ fn set_content_length(headers: &[u8], len: usize) -> Result> { Ok(out.into_bytes()) } +fn strip_header(headers: &[u8], strip_name: &str) -> Result> { + let header_str = + std::str::from_utf8(headers).map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; + let mut out = String::with_capacity(header_str.len()); + for line in header_str.split("\r\n") { + if line.is_empty() { + out.push_str("\r\n"); + break; + } + if line + .split_once(':') + .is_some_and(|(name, _)| name.trim().eq_ignore_ascii_case(strip_name)) + { + continue; + } + out.push_str(line); + out.push_str("\r\n"); + } + Ok(out.into_bytes()) +} + +fn append_header(headers: &[u8], name: &str, value: &str) -> Vec { + let split = headers + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map_or(headers.len(), |pos| pos); + let mut out = Vec::with_capacity(headers.len() + name.len() + value.len() + 4); + out.extend_from_slice(&headers[..split]); + out.extend_from_slice(b"\r\n"); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(b": "); + out.extend_from_slice(value.as_bytes()); + out.extend_from_slice(b"\r\n\r\n"); + out +} + +fn apply_header_mutations(headers: &[u8], mutations: &[HeaderMutation]) -> Result> { + let mut out = headers.to_vec(); + for mutation in mutations { + match mutation.operation.as_ref() { + Some(header_mutation::Operation::Write(write)) => { + let action = ExistingHeaderAction::try_from(write.on_existing) + .map_err(|_| miette!("invalid middleware header on_existing action"))?; + if action == ExistingHeaderAction::Unspecified { + return Err(miette!( + "middleware header mutation has unspecified on_existing action" + )); + } + let exists = has_header(&out, &write.name)?; + if !exists || action == ExistingHeaderAction::Append { + out = append_header(&out, &write.name, &write.value); + } else if action == ExistingHeaderAction::Overwrite { + out = strip_header(&out, &write.name)?; + out = append_header(&out, &write.name, &write.value); + } else if action != ExistingHeaderAction::Skip { + return Err(miette!("unsupported middleware header on_existing action")); + } + } + Some(header_mutation::Operation::Remove(remove)) => { + out = strip_header(&out, &remove.name)?; + } + None => return Err(miette!("empty middleware header mutation")), + } + } + Ok(out) +} + +fn has_header(headers: &[u8], name: &str) -> Result { + let header_str = + std::str::from_utf8(headers).map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; + Ok(header_str.lines().skip(1).any(|line| { + line.split_once(':') + .is_some_and(|(candidate, _)| candidate.trim().eq_ignore_ascii_case(name)) + })) +} + pub(crate) fn request_is_websocket_upgrade(raw_header: &[u8]) -> bool { let header_end = raw_header .windows(4) @@ -2421,6 +2720,71 @@ mod tests { const VALID_WS_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; const TEXT_OPCODE: u8 = 0x1; + fn write_header(name: &str, value: &str, on_existing: ExistingHeaderAction) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Write( + openshell_core::proto::WriteHeader { + name: name.into(), + value: value.into(), + on_existing: on_existing as i32, + }, + )), + } + } + + fn remove_header(name: &str) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Remove( + openshell_core::proto::RemoveHeader { name: name.into() }, + )), + } + } + + #[test] + fn ordered_header_mutations_replay_against_raw_request() { + let raw = b"GET / HTTP/1.1\r\nHost: example.test\r\nX-OpenShell-Middleware-Chain: first\r\nX-Drop: one\r\nX-Drop: two\r\n\r\n"; + let mutations = [ + write_header( + "x-openshell-middleware-chain", + "second", + ExistingHeaderAction::Append, + ), + write_header( + "x-openshell-middleware-chain", + "ignored", + ExistingHeaderAction::Skip, + ), + write_header( + "x-openshell-middleware-chain", + "replacement", + ExistingHeaderAction::Overwrite, + ), + write_header( + "x-openshell-middleware-chain", + "tail", + ExistingHeaderAction::Append, + ), + remove_header("x-drop"), + ]; + + let updated = String::from_utf8( + apply_header_mutations(raw, &mutations).expect("apply ordered header mutations"), + ) + .expect("UTF-8 request"); + let values: Vec<&str> = updated + .lines() + .filter_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("x-openshell-middleware-chain") + .then_some(value.trim()) + }) + }) + .collect(); + assert_eq!(values, vec!["replacement", "tail"]); + assert!(!updated.to_ascii_lowercase().contains("x-drop:")); + assert!(updated.contains("Host: example.test")); + } + #[derive(Debug)] struct CapturedFrame { fin_opcode: u8, @@ -2850,6 +3214,39 @@ mod tests { } } + #[test] + fn buffered_request_parser_uses_shared_framing_and_query_parsing() { + let request = request_from_buffered_http( + "POST", + "/v1/items", + "/v1/items?tag=first&tag=second", + b"POST /v1/items?tag=first&tag=second HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 3\r\n\r\nabc" + .to_vec(), + ) + .expect("parse buffered request"); + + assert_eq!(request.action, "POST"); + assert_eq!(request.target, "/v1/items"); + assert_eq!( + request.query_params.get("tag"), + Some(&vec!["first".to_string(), "second".to_string()]) + ); + assert!(matches!(request.body_length, BodyLength::ContentLength(3))); + } + + #[test] + fn buffered_request_parser_rejects_missing_header_terminator() { + let err = request_from_buffered_http( + "GET", + "/v1/items", + "/v1/items", + b"GET /v1/items HTTP/1.1\r\nHost: api.example.com\r\n".to_vec(), + ) + .expect_err("unterminated headers must be rejected"); + + assert!(err.to_string().contains("missing the CRLF terminator")); + } + #[test] fn parse_chunked() { let headers = @@ -3029,6 +3426,71 @@ mod tests { } } + #[tokio::test] + async fn collect_chunked_body_decodes_payload_bytes() { + let mut client = tokio::io::empty(); + let body = collect_chunked_body( + &mut client, + b"5\r\nhello\r\n6;ext=value\r\n world\r\n0\r\nx-checksum: abc\r\n\r\n", + None, + None, + ) + .await + .expect("chunked body should decode"); + + assert_eq!(body, b"hello world"); + } + + #[tokio::test] + async fn middleware_chunked_wire_body_at_cap_is_allowed() { + let max_body_bytes = max_middleware_body_bytes().await; + let payload_len = max_body_bytes - 14; + let mut wire = format!("{payload_len:x}\r\n").into_bytes(); + wire.extend(std::iter::repeat_n(b'x', payload_len)); + wire.extend_from_slice(b"\r\n0\r\n\r\n"); + assert_eq!(wire.len(), max_body_bytes); + + let body = collect_chunked_body(&mut tokio::io::empty(), &wire, None, Some(max_body_bytes)) + .await + .expect("wire representation at the cap should be allowed"); + + assert_eq!(body.len(), payload_len); + } + + #[tokio::test] + async fn middleware_chunked_wire_body_over_cap_is_rejected() { + let max_body_bytes = max_middleware_body_bytes().await; + let payload_len = max_body_bytes - 13; + let mut wire = format!("{payload_len:x}\r\n").into_bytes(); + wire.extend(std::iter::repeat_n(b'x', payload_len)); + wire.extend_from_slice(b"\r\n0\r\n\r\n"); + assert_eq!(wire.len(), max_body_bytes + 1); + assert!(payload_len < max_body_bytes); + + let error = + collect_chunked_body(&mut tokio::io::empty(), &wire, None, Some(max_body_bytes)) + .await + .expect_err("wire framing over the cap must be rejected"); + + assert!(error.to_string().contains("wire representation")); + } + + #[tokio::test] + async fn middleware_chunked_body_can_exceed_credential_rewrite_limit() { + let max_body_bytes = 1024 * 1024; + let payload_len = 300 * 1024; + assert!(payload_len > MAX_REWRITE_BODY_BYTES); + let mut wire = format!("{payload_len:x}\r\n").into_bytes(); + wire.extend(std::iter::repeat_n(b'x', payload_len)); + wire.extend_from_slice(b"\r\n0\r\n\r\n"); + + let body = collect_chunked_body(&mut tokio::io::empty(), &wire, None, Some(max_body_bytes)) + .await + .expect("middleware cap should control chunked body collection"); + + assert_eq!(body.len(), payload_len); + } + /// SEC-009: Bare LF in headers enables header injection. #[tokio::test] async fn reject_bare_lf_in_headers() { @@ -5135,6 +5597,38 @@ mod tests { assert!(!forwarded.contains("OPENSHELL-RESOLVE-ENV")); } + #[tokio::test] + async fn relay_request_body_rewrite_normalizes_chunked_payload() { + let (_, resolver) = SecretResolver::from_provider_env( + [("API_TOKEN".to_string(), "provider-real-token".to_string())] + .into_iter() + .collect(), + ); + let resolver = resolver.expect("resolver"); + let alias = "provider.v1-OPENSHELL-RESOLVE-ENV-API_TOKEN"; + let raw = format!( + "POST /api/messages HTTP/1.1\r\n\ + Host: api.example.com\r\n\ + Authorization: Bearer {alias}\r\n\ + Transfer-Encoding: chunked\r\n\r\n\ + 5\r\nhello\r\n0\r\n\r\n", + ); + + let forwarded = relay_and_capture_with_options( + raw.into_bytes(), + BodyLength::Chunked, + Some(&resolver), + true, + ) + .await + .expect("relay should succeed"); + + assert!(forwarded.contains("Authorization: Bearer provider-real-token\r\n")); + assert!(forwarded.contains("Content-Length: 5\r\n")); + assert!(!forwarded.contains("Transfer-Encoding: chunked\r\n")); + assert!(forwarded.ends_with("hello")); + } + #[tokio::test] async fn relay_request_body_rewrites_percent_encoded_canonical_urlencoded_token() { let (_, resolver) = SecretResolver::from_provider_env( diff --git a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs index 0d7c18e996..07a8c641f1 100644 --- a/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs +++ b/crates/openshell-supervisor-network/src/l7/token_grant_injection.rs @@ -199,45 +199,16 @@ fn dynamic_credential_key_match_score( return None; } - let host_lc = host.to_ascii_lowercase(); - let endpoint_host_lc = endpoint_host.to_ascii_lowercase(); - if !host_pattern_matches(&endpoint_host_lc, &host_lc) + if !openshell_core::host_pattern::host_matches(endpoint_host, host).unwrap_or(false) || !crate::l7::endpoint_path_matches(endpoint_path, request_path) { return None; } - Some(host_pattern_specificity(&endpoint_host_lc) + endpoint_path_specificity(endpoint_path)) -} - -fn host_pattern_matches(pattern: &str, host: &str) -> bool { - if pattern == host { - return true; - } - if !pattern.contains('*') { - return false; - } - - let pattern_labels: Vec<&str> = pattern.split('.').collect(); - let host_labels: Vec<&str> = host.split('.').collect(); - host_pattern_labels_match(&pattern_labels, &host_labels) -} - -fn host_pattern_labels_match(pattern: &[&str], host: &[&str]) -> bool { - match pattern.split_first() { - None => host.is_empty(), - Some((label, rest)) if *label == "**" => { - host_pattern_labels_match(rest, host) - || (!host.is_empty() && host_pattern_labels_match(pattern, &host[1..])) - } - Some((label, rest)) if *label == "*" => { - !host.is_empty() && host_pattern_labels_match(rest, &host[1..]) - } - Some((literal, rest)) => { - host.first().is_some_and(|label| label == literal) - && host_pattern_labels_match(rest, &host[1..]) - } - } + Some( + host_pattern_specificity(&endpoint_host.to_ascii_lowercase()) + + endpoint_path_specificity(endpoint_path), + ) } fn host_pattern_specificity(pattern: &str) -> u32 { @@ -575,6 +546,24 @@ mod tests { )); } + #[test] + fn dynamic_credential_key_matches_case_insensitive_intra_label_wildcard() { + let key = "*-API.Example.COM\t443\t\tprovider:access_token"; + + assert!(dynamic_credential_key_matches( + key, + "tenant-api.example.com", + 443, + "/anything" + )); + assert!(!dynamic_credential_key_matches( + key, + "api.deep.example.com", + 443, + "/anything" + )); + } + #[test] fn dynamic_credential_key_matches_double_wildcard_hosts() { let key = "**.example.com\t443\t\tprovider:access_token"; diff --git a/crates/openshell-supervisor-network/src/opa.rs b/crates/openshell-supervisor-network/src/opa.rs index 829c63caa4..cd5ab98c03 100644 --- a/crates/openshell-supervisor-network/src/opa.rs +++ b/crates/openshell-supervisor-network/src/opa.rs @@ -8,14 +8,16 @@ //! on every proxy CONNECT request. use miette::Result; +use openshell_core::host_pattern::HostSelector; use openshell_core::policy::{ FilesystemPolicy, LandlockCompatibility, LandlockPolicy, ProcessPolicy, }; use openshell_core::proto::SandboxPolicy as ProtoSandboxPolicy; use openshell_policy::L7ConfigStanza; +use openshell_supervisor_middleware::{ChainEntry, ChainRunner, MiddlewareRegistry}; use std::path::{Path, PathBuf}; use std::sync::{ - Arc, Mutex, + Arc, Mutex, RwLock, atomic::{AtomicU64, Ordering}, }; use tracing::info; @@ -25,6 +27,11 @@ use tracing::info; /// passthroughs. They reference `data.sandbox.*` for policy data. const BAKED_POLICY_RULES: &str = include_str!("../data/sandbox-policy.rego"); +/// Implementation-owned middleware config validation supplied by the active +/// in-process catalog for local policy files. +pub type MiddlewareConfigValidator = + dyn Fn(&str, &prost_types::Struct) -> Result<(), String> + Send + Sync; + /// Result of evaluating a network access request against OPA policy. pub struct PolicyDecision { pub allowed: bool, @@ -115,6 +122,7 @@ pub struct SandboxConfig { pub struct OpaEngine { engine: Mutex, generation: Arc, + middleware_runner: RwLock, } /// Generation guard captured when an HTTP tunnel or request path starts. @@ -154,6 +162,7 @@ impl PolicyGenerationGuard { pub struct TunnelPolicyEngine { engine: Mutex, generation_guard: PolicyGenerationGuard, + middleware_runner: ChainRunner, } impl TunnelPolicyEngine { @@ -176,6 +185,19 @@ impl TunnelPolicyEngine { pub(crate) fn engine(&self) -> &Mutex { &self.engine } + + pub(crate) fn middleware_runner(&self) -> &ChainRunner { + &self.middleware_runner + } + + /// Query the ordered middleware chain for a destination within this tunnel. + pub fn query_middleware_chain(&self, input: &NetworkInput) -> Result> { + let mut engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; + query_middleware_chain_locked(&mut engine, input) + } } impl OpaEngine { @@ -183,6 +205,16 @@ impl OpaEngine { /// /// Preprocesses the YAML data to expand access presets and validate L7 config. pub fn from_files(policy_path: &Path, data_path: &Path) -> Result { + Self::from_files_with_middleware_config(policy_path, data_path, None) + } + + /// Load local policy files and validate implementation-owned middleware + /// config through the catalog installed by the supervisor. + pub fn from_files_with_middleware_config( + policy_path: &Path, + data_path: &Path, + validate_middleware_config: Option<&MiddlewareConfigValidator>, + ) -> Result { let yaml_str = std::fs::read_to_string(data_path).map_err(|e| { miette::miette!("failed to read YAML data from {}: {e}", data_path.display()) })?; @@ -192,13 +224,18 @@ impl OpaEngine { .map_err(|e| miette::miette!("{e}"))?; let require_binary_identity = network_binary_identity_required(); emit_binary_identity_mode(require_binary_identity, "files"); - let data_json = preprocess_yaml_data(&yaml_str, require_binary_identity)?; + let data_json = preprocess_yaml_data( + &yaml_str, + require_binary_identity, + validate_middleware_config, + )?; engine .add_data_json(&data_json) .map_err(|e| miette::miette!("{e}"))?; Ok(Self { engine: Mutex::new(engine), generation: Arc::new(AtomicU64::new(0)), + middleware_runner: RwLock::new(ChainRunner::default()), }) } @@ -206,30 +243,54 @@ impl OpaEngine { /// /// Preprocesses the YAML data to expand access presets and validate L7 config. pub fn from_strings(policy: &str, data_yaml: &str) -> Result { - Self::from_strings_with_binary_identity_required( + Self::from_strings_with_options(policy, data_yaml, network_binary_identity_required(), None) + } + + pub fn from_strings_with_middleware_config( + policy: &str, + data_yaml: &str, + validate_middleware_config: Option<&MiddlewareConfigValidator>, + ) -> Result { + Self::from_strings_with_options( policy, data_yaml, network_binary_identity_required(), + validate_middleware_config, ) } + #[cfg(test)] pub(crate) fn from_strings_with_binary_identity_required( policy: &str, data_yaml: &str, require_binary_identity: bool, + ) -> Result { + Self::from_strings_with_options(policy, data_yaml, require_binary_identity, None) + } + + fn from_strings_with_options( + policy: &str, + data_yaml: &str, + require_binary_identity: bool, + validate_middleware_config: Option<&MiddlewareConfigValidator>, ) -> Result { let mut engine = regorus::Engine::new(); engine .add_policy("policy.rego".into(), policy.into()) .map_err(|e| miette::miette!("{e}"))?; emit_binary_identity_mode(require_binary_identity, "strings"); - let data_json = preprocess_yaml_data(data_yaml, require_binary_identity)?; + let data_json = preprocess_yaml_data( + data_yaml, + require_binary_identity, + validate_middleware_config, + )?; engine .add_data_json(&data_json) .map_err(|e| miette::miette!("{e}"))?; Ok(Self { engine: Mutex::new(engine), generation: Arc::new(AtomicU64::new(0)), + middleware_runner: RwLock::new(ChainRunner::default()), }) } @@ -265,6 +326,15 @@ impl OpaEngine { require_binary_identity: bool, ) -> Result { emit_binary_identity_mode(require_binary_identity, "proto"); + if let Err(violations) = openshell_policy::validate_sandbox_policy(proto) { + let errors = violations + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n"); + return Err(miette::miette!("policy validation failed:\n{errors}")); + } + let data_json_str = proto_to_opa_data_json(proto, entrypoint_pid); // Parse back to Value for preprocessing, then re-serialize @@ -308,6 +378,7 @@ impl OpaEngine { Ok(Self { engine: Mutex::new(engine), generation: Arc::new(AtomicU64::new(0)), + middleware_runner: RwLock::new(ChainRunner::default()), }) } @@ -317,27 +388,7 @@ impl OpaEngine { /// `allow_network` rule, and returns a `PolicyDecision` with the result, /// deny reason, and matched policy name. pub fn evaluate_network(&self, input: &NetworkInput) -> Result { - let ancestor_strs: Vec = input - .ancestors - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let cmdline_strs: Vec = input - .cmdline_paths - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let input_json = serde_json::json!({ - "exec": { - "path": input.binary_path.to_string_lossy(), - "ancestors": ancestor_strs, - "cmdline_paths": cmdline_strs, - }, - "network": { - "host": input.host, - "port": input.port, - } - }); + let input_json = network_input_json(input); let mut engine = self .engine @@ -387,27 +438,7 @@ impl OpaEngine { &self, input: &NetworkInput, ) -> Result<(NetworkAction, u64)> { - let ancestor_strs: Vec = input - .ancestors - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let cmdline_strs: Vec = input - .cmdline_paths - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let input_json = serde_json::json!({ - "exec": { - "path": input.binary_path.to_string_lossy(), - "ancestors": ancestor_strs, - "cmdline_paths": cmdline_strs, - }, - "network": { - "host": input.host, - "port": input.port, - } - }); + let input_json = network_input_json(input); let mut engine = self .engine @@ -500,11 +531,76 @@ impl OpaEngine { Ok(()) } + /// Reload the policy and middleware registry as one runtime generation. + /// + /// Both replacements are prepared before the live locks are acquired. The + /// engine and runner are then swapped while holding both locks, followed by + /// a single generation increment. A preparation or lock failure leaves the + /// live pair and generation untouched. + pub fn reload_policy_and_middleware_from_proto_with_pid( + &self, + proto: &ProtoSandboxPolicy, + entrypoint_pid: u32, + registry: MiddlewareRegistry, + ) -> Result<()> { + let new = Self::from_proto_with_pid(proto, entrypoint_pid)?; + let new_engine = new + .engine + .into_inner() + .map_err(|_| miette::miette!("lock poisoned on new engine"))?; + let new_runner = ChainRunner::from_registry(registry); + + // Match clone_engine_for_tunnel's lock order (engine, then runner) so + // readers can observe only the old pair or the new pair. + let mut engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; + let mut runner = self + .middleware_runner + .write() + .map_err(|_| miette::miette!("middleware runner lock poisoned"))?; + *engine = new_engine; + *runner = new_runner; + self.generation.fetch_add(1, Ordering::AcqRel); + Ok(()) + } + /// Current policy generation. Successful reloads increment this value. pub fn current_generation(&self) -> u64 { self.generation.load(Ordering::Acquire) } + /// Replace the complete middleware service registry and invalidate + /// existing tunnels so subsequent requests use the new service set. + pub fn replace_middleware_registry(&self, registry: MiddlewareRegistry) -> Result<()> { + let mut runner = self + .middleware_runner + .write() + .map_err(|_| miette::miette!("middleware runner lock poisoned"))?; + *runner = ChainRunner::from_registry(registry); + self.generation.fetch_add(1, Ordering::AcqRel); + Ok(()) + } + + pub(crate) fn middleware_runner(&self) -> Result { + self.middleware_runner + .read() + .map(|runner| runner.clone()) + .map_err(|_| miette::miette!("middleware runner lock poisoned")) + } + + /// Test-only: swap the middleware runner without a connected registry, so + /// relay tests can inject scripted middleware services. Does not bump the + /// policy generation; call before capturing tunnel engines. + #[cfg(test)] + pub(crate) fn set_middleware_runner_for_tests(&self, runner: ChainRunner) { + *self + .middleware_runner + .write() + .expect("middleware runner lock") = runner; + } + /// Return a guard for a previously captured policy generation. pub fn generation_guard(&self, expected_generation: u64) -> Result { let generation = self.current_generation(); @@ -578,27 +674,7 @@ impl OpaEngine { &self, input: &NetworkInput, ) -> Result<(Vec, u64)> { - let ancestor_strs: Vec = input - .ancestors - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let cmdline_strs: Vec = input - .cmdline_paths - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let input_json = serde_json::json!({ - "exec": { - "path": input.binary_path.to_string_lossy(), - "ancestors": ancestor_strs, - "cmdline_paths": cmdline_strs, - }, - "network": { - "host": input.host, - "port": input.port, - } - }); + let input_json = network_input_json(input); let mut engine = self .engine @@ -621,6 +697,20 @@ impl OpaEngine { } } + /// Query the ordered middleware chain for an admitted destination. + pub fn query_middleware_chain_with_generation( + &self, + input: &NetworkInput, + ) -> Result<(Vec, u64)> { + let mut engine = self + .engine + .lock() + .map_err(|_| miette::miette!("OPA engine lock poisoned"))?; + let generation = self.current_generation(); + let chain = query_middleware_chain_locked(&mut engine, input)?; + Ok((chain, generation)) + } + /// Query `allowed_ips` from the matched endpoint config for a given request. /// /// Returns the list of CIDR/IP strings from the endpoint's `allowed_ips` @@ -642,27 +732,7 @@ impl OpaEngine { /// denial while preserving separate handling for `allowed_ips` and advisor /// proposals. pub fn query_exact_declared_endpoint_host(&self, input: &NetworkInput) -> Result { - let ancestor_strs: Vec = input - .ancestors - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let cmdline_strs: Vec = input - .cmdline_paths - .iter() - .map(|p| p.to_string_lossy().into_owned()) - .collect(); - let input_json = serde_json::json!({ - "exec": { - "path": input.binary_path.to_string_lossy(), - "ancestors": ancestor_strs, - "cmdline_paths": cmdline_strs, - }, - "network": { - "host": input.host, - "port": input.port, - } - }); + let input_json = network_input_json(input); let mut engine = self .engine @@ -702,6 +772,7 @@ impl OpaEngine { captured_generation: generation, current_generation: Arc::clone(&self.generation), }, + middleware_runner: self.middleware_runner()?, }) } } @@ -760,6 +831,148 @@ fn get_str_array(val: ®orus::Value, key: &str) -> Vec { } } +fn network_input_json(input: &NetworkInput) -> serde_json::Value { + let ancestor_strs: Vec = input + .ancestors + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + let cmdline_strs: Vec = input + .cmdline_paths + .iter() + .map(|p| p.to_string_lossy().into_owned()) + .collect(); + serde_json::json!({ + "exec": { + "path": input.binary_path.to_string_lossy(), + "ancestors": ancestor_strs, + "cmdline_paths": cmdline_strs, + }, + "network": { + "host": input.host, + "port": input.port, + } + }) +} + +fn query_middleware_chain_locked( + engine: &mut regorus::Engine, + input: &NetworkInput, +) -> Result> { + let configs_val = engine + .eval_rule("data.openshell.sandbox.network_middlewares".into()) + .map_err(|e| miette::miette!("{e}"))?; + let configs = parse_middleware_configs(&configs_val)?; + if configs.is_empty() { + return Ok(Vec::new()); + } + global_middleware_entries(&configs, &input.host) +} + +fn parse_middleware_configs(value: ®orus::Value) -> Result> { + match value { + regorus::Value::Undefined => Ok(Vec::new()), + regorus::Value::Array(values) => Ok(values.to_vec()), + other => Err(miette::miette!( + "network_middlewares must be an array, got {other:?}" + )), + } +} + +fn global_middleware_entries(configs: &[regorus::Value], host: &str) -> Result> { + let mut entries = Vec::new(); + for config in configs { + if middleware_selector_matches(config, host)? { + entries.push(chain_entry_from_value(config)?); + } + } + openshell_supervisor_middleware::sort_chain_entries(&mut entries); + Ok(entries) +} + +fn middleware_selector_matches(config: ®orus::Value, host: &str) -> Result { + let Some(selector) = get_field(config, "endpoints") else { + return Ok(false); + }; + let include = get_str_array(selector, "include"); + let exclude = get_str_array(selector, "exclude"); + let selector = HostSelector::new(&include, &exclude).map_err(|error| miette::miette!(error))?; + Ok(selector.matches(host)) +} + +fn chain_entry_from_value(value: ®orus::Value) -> Result { + let name = get_str(value, "name").unwrap_or_default(); + let implementation = get_str(value, "middleware").unwrap_or_default(); + Ok(ChainEntry { + name, + implementation, + order: get_field(value, "order") + .and_then(|value| match value { + regorus::Value::Number(number) => number.as_i64(), + _ => None, + }) + .and_then(|value| i32::try_from(value).ok()) + .unwrap_or_default(), + config: get_field(value, "config") + .map(regorus_value_to_struct) + .unwrap_or_default(), + on_error: openshell_supervisor_middleware::OnError::parse( + get_str(value, "on_error").as_deref().unwrap_or_default(), + )?, + }) +} + +fn get_field<'a>(val: &'a regorus::Value, key: &str) -> Option<&'a regorus::Value> { + let key_val = regorus::Value::String(key.into()); + match val { + regorus::Value::Object(map) => map.get(&key_val), + _ => None, + } +} + +fn regorus_value_to_struct(value: ®orus::Value) -> prost_types::Struct { + let regorus::Value::Object(map) = value else { + return prost_types::Struct::default(); + }; + prost_types::Struct { + fields: map + .iter() + .filter_map(|(key, value)| match key { + regorus::Value::String(key) => { + Some((key.to_string(), regorus_value_to_prost(value))) + } + _ => None, + }) + .collect(), + } +} + +fn regorus_value_to_prost(value: ®orus::Value) -> prost_types::Value { + use prost_types::{ListValue, Struct, Value, value::Kind}; + Value { + kind: Some(match value { + regorus::Value::Bool(value) => Kind::BoolValue(*value), + regorus::Value::Number(value) => Kind::NumberValue(value.as_f64().unwrap_or_default()), + regorus::Value::String(value) => Kind::StringValue(value.to_string()), + regorus::Value::Array(values) => Kind::ListValue(ListValue { + values: values.iter().map(regorus_value_to_prost).collect(), + }), + regorus::Value::Object(values) => Kind::StructValue(Struct { + fields: values + .iter() + .filter_map(|(key, value)| match key { + regorus::Value::String(key) => { + Some((key.to_string(), regorus_value_to_prost(value))) + } + _ => None, + }) + .collect(), + }), + _ => Kind::NullValue(0), + }), + } +} + fn parse_filesystem_policy(val: ®orus::Value) -> FilesystemPolicy { FilesystemPolicy { read_only: get_str_array(val, "read_only") @@ -793,7 +1006,11 @@ fn parse_process_policy(val: ®orus::Value) -> ProcessPolicy { } /// Preprocess YAML policy data: parse, normalize, validate, expand access presets, return JSON. -fn preprocess_yaml_data(yaml_str: &str, require_binary_identity: bool) -> Result { +fn preprocess_yaml_data( + yaml_str: &str, + require_binary_identity: bool, + validate_middleware_config: Option<&MiddlewareConfigValidator>, +) -> Result { let mut data: serde_json::Value = serde_yml::from_str(yaml_str) .map_err(|e| miette::miette!("failed to parse YAML data: {e}"))?; inject_runtime_policy_data(&mut data, require_binary_identity); @@ -809,6 +1026,25 @@ fn preprocess_yaml_data(yaml_str: &str, require_binary_identity: bool) -> Result } // Validate BEFORE expanding presets (catches user errors like rules+access) + let middleware_errors = validate_middleware_config + .map_or_else( + || openshell_policy::validate_network_middleware_json(&data), + |validate| { + openshell_policy::validate_network_middleware_json_with_config(&data, validate) + }, + ) + .map_err(|error| miette::miette!(error))?; + if !middleware_errors.is_empty() { + return Err(miette::miette!( + "middleware policy validation failed:\n{}", + middleware_errors + .iter() + .map(ToString::to_string) + .collect::>() + .join("\n") + )); + } + let (errors, warnings) = crate::l7::validate_l7_policies(&data); for w in &warnings { openshell_ocsf::ocsf_emit!( @@ -1415,14 +1651,41 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St entries }) .collect(); - ( - key.clone(), - serde_json::json!({ - "name": rule.name, - "endpoints": endpoints, - "binaries": binaries, - }), - ) + let policy = serde_json::json!({ + "name": rule.name, + "endpoints": endpoints, + "binaries": binaries, + }); + (key.clone(), policy) + }) + .collect(); + + let network_middlewares: Vec = proto + .network_middlewares + .iter() + .map(|mw| { + let mut value = serde_json::json!({ + "name": mw.name, + "middleware": mw.middleware, + "order": mw.order, + }); + if let Some(config) = &mw.config { + value["config"] = openshell_core::proto_struct::struct_to_json_value(config); + } + if !mw.on_error.is_empty() { + value["on_error"] = mw.on_error.clone().into(); + } + if let Some(selector) = &mw.endpoints { + let mut endpoints = serde_json::json!({}); + if !selector.include.is_empty() { + endpoints["include"] = selector.include.clone().into(); + } + if !selector.exclude.is_empty() { + endpoints["exclude"] = selector.exclude.clone().into(); + } + value["endpoints"] = endpoints; + } + value }) .collect(); @@ -1431,6 +1694,7 @@ fn proto_to_opa_data_json(proto: &ProtoSandboxPolicy, entrypoint_pid: u32) -> St "landlock": landlock, "process": process, "network_policies": network_policies, + "network_middlewares": network_middlewares, }) .to_string() } @@ -1448,7 +1712,7 @@ mod tests { use openshell_core::proto::{ FilesystemPolicy as ProtoFs, L7Allow, L7QueryMatcher, L7Rule, NetworkBinary, - NetworkEndpoint, NetworkPolicyRule, ProcessPolicy as ProtoProc, + NetworkEndpoint, NetworkMiddlewareConfig, NetworkPolicyRule, ProcessPolicy as ProtoProc, SandboxPolicy as ProtoSandboxPolicy, }; @@ -1513,6 +1777,7 @@ mod tests { run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], } } @@ -2430,6 +2695,7 @@ process: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: Vec::new(), }; let engine = OpaEngine::from_proto_with_pid_and_binary_identity_required(&proto, 0, false) .expect("engine from relaxed proto"); @@ -2754,6 +3020,7 @@ network_policies: let engine = OpaEngine { engine: Mutex::new(rego), generation: Arc::new(AtomicU64::new(0)), + middleware_runner: RwLock::new(ChainRunner::default()), }; let input = l7_websocket_graphql_input( "realtime.graphql.com", @@ -2971,6 +3238,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -3042,6 +3310,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -3114,6 +3383,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -3990,6 +4260,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4047,6 +4318,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4105,6 +4377,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4165,6 +4438,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -4224,6 +4498,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); @@ -5213,6 +5488,7 @@ process: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); let input = NetworkInput { @@ -5267,6 +5543,7 @@ process: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("engine from proto"); let input = NetworkInput { @@ -5337,6 +5614,7 @@ process: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).expect("Failed to create engine from proto"); @@ -5567,6 +5845,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; let engine = OpaEngine::from_proto(&proto).unwrap(); // Port 443 @@ -6309,6 +6588,181 @@ network_policies: ); } + #[tokio::test] + async fn policy_and_middleware_reload_commit_as_one_generation() { + let proto = test_proto(); + let engine = OpaEngine::from_proto(&proto).expect("initial load should succeed"); + let mut new_proto = proto; + new_proto.network_policies.insert( + "python_api".to_string(), + NetworkPolicyRule { + name: "python_api".to_string(), + endpoints: vec![NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/python3".to_string(), + ..Default::default() + }], + }, + ); + let registry = MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await + .expect("built-in registry"); + + engine + .reload_policy_and_middleware_from_proto_with_pid(&new_proto, 0, registry) + .expect("combined reload"); + + assert_eq!(engine.current_generation(), 1); + let python_input = NetworkInput { + host: "pypi.org".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/python3"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + assert!(engine.evaluate_network(&python_input).unwrap().allowed); + + let entry = ChainEntry { + name: "secrets".into(), + implementation: openshell_supervisor_middleware_builtins::BUILTIN_SECRETS.into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }; + let described = engine + .middleware_runner() + .expect("middleware runner") + .describe_chain(&[entry]) + .await + .expect("describe chain"); + assert!(described[0].is_resolved()); + } + + #[tokio::test] + async fn policy_only_reload_keeps_connected_middleware_registry() { + let proto = test_proto(); + let engine = OpaEngine::from_proto(&proto).expect("initial load should succeed"); + let registry = MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await + .expect("built-in registry"); + engine + .replace_middleware_registry(registry) + .expect("install registry"); + let generation_with_registry = engine.current_generation(); + + let mut new_proto = proto; + new_proto.network_policies.insert( + "python_api".to_string(), + NetworkPolicyRule { + name: "python_api".to_string(), + endpoints: vec![NetworkEndpoint { + host: "pypi.org".to_string(), + port: 443, + ..Default::default() + }], + binaries: vec![NetworkBinary { + path: "/usr/bin/python3".to_string(), + ..Default::default() + }], + }, + ); + engine + .reload_from_proto_with_pid(&new_proto, 0) + .expect("policy-only reload"); + + assert_eq!(engine.current_generation(), generation_with_registry + 1); + let python_input = NetworkInput { + host: "pypi.org".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/python3"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + assert!(engine.evaluate_network(&python_input).unwrap().allowed); + + let entry = ChainEntry { + name: "secrets".into(), + implementation: openshell_supervisor_middleware_builtins::BUILTIN_SECRETS.into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }; + let described = engine + .middleware_runner() + .expect("middleware runner") + .describe_chain(&[entry]) + .await + .expect("describe chain"); + assert!(described[0].is_resolved()); + } + + #[tokio::test] + async fn failed_combined_reload_preserves_policy_registry_and_generation() { + let proto = test_proto(); + let engine = OpaEngine::from_proto(&proto).expect("initial load should succeed"); + let builtins = MiddlewareRegistry::connect_services( + openshell_supervisor_middleware_builtins::services(), + Vec::new(), + ) + .await + .expect("built-in registry"); + engine + .reload_policy_and_middleware_from_proto_with_pid(&proto, 0, builtins) + .expect("install last-known-good runtime"); + + let mut invalid = proto; + invalid.network_middlewares.push(NetworkMiddlewareConfig { + name: String::new(), + middleware: openshell_supervisor_middleware_builtins::BUILTIN_SECRETS.into(), + ..Default::default() + }); + let empty_registry = MiddlewareRegistry::connect_services(Vec::new(), Vec::new()) + .await + .expect("empty registry"); + + engine + .reload_policy_and_middleware_from_proto_with_pid(&invalid, 0, empty_registry) + .expect_err("invalid policy must reject the combined reload"); + + assert_eq!(engine.current_generation(), 1); + let claude_input = NetworkInput { + host: "api.anthropic.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/local/bin/claude"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + assert!(engine.evaluate_network(&claude_input).unwrap().allowed); + + let entry = ChainEntry { + name: "secrets".into(), + implementation: openshell_supervisor_middleware_builtins::BUILTIN_SECRETS.into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }; + let described = engine + .middleware_runner() + .expect("middleware runner") + .describe_chain(&[entry]) + .await + .expect("describe chain"); + assert!(described[0].is_resolved()); + } + #[test] fn deny_reason_includes_symlink_hint() { // Verify the deny reason includes an actionable symlink hint @@ -6526,6 +6980,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; // Build engine with our PID (symlink resolution will work via /proc/self/root/) @@ -6603,6 +7058,7 @@ network_policies: run_as_group: "sandbox".to_string(), }), network_policies, + network_middlewares: vec![], }; // Initial load at pid=0 — no symlink expansion @@ -6645,6 +7101,342 @@ network_policies: assert!(eval_l7(&engine, &input)); } + #[test] + fn middleware_chain_uses_configured_order_and_name_tie_breaker() { + let data = r#" +network_middlewares: + - name: global-redactor + middleware: openshell/secrets + order: 20 + endpoints: + include: ["api.example.com"] + - name: policy-redactor + middleware: openshell/secrets + order: 10 + endpoints: + include: ["api.example.com"] + - name: endpoint-redactor + middleware: openshell/secrets + order: 10 + endpoints: + include: ["api.example.com"] +network_policies: + api: + name: api + endpoints: + - host: api.example.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: { method: POST, path: "/v1/**" } + binaries: + - { path: /usr/bin/curl } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "api.example.com".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (chain, _) = engine + .query_middleware_chain_with_generation(&input) + .unwrap(); + let names: Vec<_> = chain.iter().map(|entry| entry.name.as_str()).collect(); + assert_eq!( + names, + vec!["endpoint-redactor", "policy-redactor", "global-redactor"] + ); + } + + #[test] + fn middleware_chain_uses_dns_label_glob_semantics() { + let data = r#" +network_middlewares: + - name: single-label + middleware: openshell/secrets + order: 10 + endpoints: + include: ["*.Example.COM"] + exclude: ["trusted.example.com"] + - name: recursive + middleware: openshell/secrets + order: 20 + endpoints: + include: ["**.example.com"] + - name: intra-label + middleware: openshell/secrets + order: 30 + endpoints: + include: ["*-api.example.com"] +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let names_for = |host: &str| { + let input = NetworkInput { + host: host.into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + engine + .query_middleware_chain_with_generation(&input) + .unwrap() + .0 + .into_iter() + .map(|entry| entry.name) + .collect::>() + }; + + assert_eq!( + names_for("api.example.com"), + vec!["single-label", "recursive"] + ); + assert_eq!(names_for("deep.api.example.com"), vec!["recursive"]); + assert_eq!(names_for("trusted.example.com"), vec!["recursive"]); + assert_eq!( + names_for("tenant-api.example.com"), + vec!["single-label", "recursive", "intra-label"] + ); + } + + #[test] + fn host_pattern_matches_rego_endpoint_host_semantics() { + // Middleware selectors and the tls-skip overlap validation promise the + // same host semantics as endpoint admission, which is decided by the + // endpoint_allowed branches in sandbox-policy.rego. Pin parity by + // running one table through openshell_core::host_pattern and through + // regorus with those branches verbatim. + let policy = r#" +package test + +default host_match = false + +host_match if { + not contains(input.pattern, "*") + lower(input.pattern) == lower(input.host) +} + +host_match if { + contains(input.pattern, "*") + glob.match(lower(input.pattern), ["."], lower(input.host)) +} +"#; + let mut engine = regorus::Engine::new(); + engine + .add_policy("test.rego".into(), policy.into()) + .unwrap(); + + let cases = [ + ("api.example.com", "api.example.com"), + ("api.example.com", "API.EXAMPLE.COM"), + ("api.example.com", "api.example.org"), + ("*.example.com", "api.example.com"), + ("*.example.com", "example.com"), + ("*.example.com", "deep.api.example.com"), + ("*-api.example.com", "tenant-api.example.com"), + ("*-api.example.com", "api.example.com"), + ("*.a?i.example.com", "x.abi.example.com"), + ("**.example.com", "example.com"), + ("**.example.com", "api.example.com"), + ("**.example.com", "deep.api.example.com"), + ("api.**.com", "api.com"), + ("api.**.com", "api.x.com"), + ("api.**.com", "api.x.y.com"), + ("api.**", "api"), + ("api.**", "api.com"), + ("*", "com"), + ("*", "example.com"), + ("**", "com"), + ("**", "deep.api.example.com"), + ]; + for (pattern, host) in cases { + let rust = openshell_core::host_pattern::host_matches(pattern, host).unwrap(); + engine + .set_input_json( + &serde_json::json!({ "pattern": pattern, "host": host }).to_string(), + ) + .unwrap(); + let rego = engine.eval_rule("data.test.host_match".into()).unwrap() + == regorus::Value::from(true); + assert_eq!( + rust, rego, + "host pattern parity mismatch: pattern={pattern} host={host} rust={rust} rego={rego}" + ); + } + } + + #[test] + fn middleware_policy_validation_rejects_bad_configs() { + let cases = [ + ( + "invalid on_error", + r#" +network_middlewares: + - name: redactor + middleware: openshell/secrets + on_error: maybe + endpoints: + include: ["api.example.com"] +"#, + "invalid on_error", + ), + ( + "duplicate names", + r#" +network_middlewares: + - name: redactor + middleware: openshell/secrets + endpoints: + include: ["api.example.com"] + - name: redactor + middleware: openshell/secrets + endpoints: + include: ["api.example.com"] +"#, + "duplicate middleware config 'redactor'", + ), + ( + "missing selector", + r#" +network_middlewares: + - name: redactor + middleware: openshell/secrets +"#, + "endpoint selector is required", + ), + ( + "malformed selector", + r#" +network_middlewares: + - name: redactor + middleware: openshell/secrets + endpoints: + include: ["api[.example.com"] +"#, + "invalid host pattern", + ), + ( + "tls skip selector", + r#" +network_middlewares: + - name: redactor + middleware: openshell/secrets + endpoints: + include: ["api.example.com"] +network_policies: + api: + endpoints: + - host: api.example.com + port: 443 + tls: skip + binaries: + - { path: /usr/bin/curl } +"#, + "tls: skip", + ), + ( + "tls skip wildcard overlap", + r#" +network_middlewares: + - name: redactor + middleware: openshell/secrets + endpoints: + include: ["api.example.com"] +network_policies: + api: + endpoints: + - host: "*.example.com" + port: 443 + tls: skip + binaries: + - { path: /usr/bin/curl } +"#, + "tls: skip", + ), + ]; + + for (name, data, expected) in cases { + let err = match OpaEngine::from_strings(TEST_POLICY, data) { + Ok(_) => panic!("{name}: expected policy validation failure"), + Err(err) => err.to_string(), + }; + assert!( + err.contains(expected), + "{name}: expected {expected:?} in {err:?}" + ); + } + } + + #[test] + fn middleware_catalog_validation_rejects_unknown_or_invalid_builtins() { + let validate = |implementation: &str, config: &prost_types::Struct| { + openshell_supervisor_middleware_builtins::validate_config(implementation, config) + .map_err(|error| error.to_string()) + }; + for (name, data, expected) in [ + ( + "unknown built-in", + r#" +network_middlewares: + - name: unknown + middleware: openshell/unknown + endpoints: + include: ["api.example.com"] +"#, + "not a registered OpenShell built-in", + ), + ( + "invalid secrets config", + r#" +network_middlewares: + - name: redactor + middleware: openshell/secrets + config: + secrets: allow + endpoints: + include: ["api.example.com"] +"#, + "supports only secrets: redact", + ), + ] { + let error = + OpaEngine::from_strings_with_middleware_config(TEST_POLICY, data, Some(&validate)) + .err() + .unwrap_or_else(|| panic!("{name}: expected catalog validation failure")) + .to_string(); + assert!( + error.contains(expected), + "{name}: expected {expected:?} in {error:?}" + ); + } + } + + #[test] + fn from_proto_revalidates_middleware_policy() { + let mut policy = openshell_policy::restrictive_default_policy(); + policy.network_middlewares.push(NetworkMiddlewareConfig { + name: "redactor".into(), + middleware: "openshell/secrets".into(), + endpoints: Some(openshell_core::proto::MiddlewareEndpointSelector { + include: vec!["api[.example.com".into()], + exclude: Vec::new(), + }), + ..Default::default() + }); + + let error = OpaEngine::from_proto(&policy) + .err() + .expect("supervisor must reject invalid effective middleware policy") + .to_string(); + assert!(error.contains("policy validation failed"), "{error}"); + assert!(error.contains("invalid host pattern"), "{error}"); + } + #[test] fn l7_head_denied_when_only_post_allowed() { let engine = OpaEngine::from_strings( diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index c0c33e6fd1..2e2f935ff9 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -386,25 +386,64 @@ fn could_be_supported_tunnel_protocol_prefix(peek: &[u8]) -> bool { || crate::l7::rest::could_be_http2_prior_knowledge_prefix(peek) } -fn unsupported_l7_tunnel_protocol_detail( - tunnel_protocol: TunnelProtocol, +/// Why tunnel payload inspection is mandatory for a connection, in message +/// precedence order: an L7-configured endpoint owns the wording even when a +/// fail-closed middleware chain also matches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum InspectionRequirement { + None, + L7Route, + RequiredMiddleware, +} + +fn inspection_requirement( should_inspect_l7: bool, -) -> Option<&'static str> { - if !should_inspect_l7 { - return None; + middleware_gate: crate::l7::middleware::UninspectableTrafficGate, +) -> InspectionRequirement { + if should_inspect_l7 { + InspectionRequirement::L7Route + } else if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::Deny { + InspectionRequirement::RequiredMiddleware + } else { + InspectionRequirement::None } +} - match tunnel_protocol { - TunnelProtocol::H2cPriorKnowledge => { +fn unsupported_l7_tunnel_protocol_detail( + tunnel_protocol: TunnelProtocol, + requirement: InspectionRequirement, +) -> Option<&'static str> { + match (tunnel_protocol, requirement) { + (_, InspectionRequirement::None) | (TunnelProtocol::Tls | TunnelProtocol::Http1, _) => None, + (TunnelProtocol::H2cPriorKnowledge, InspectionRequirement::L7Route) => { Some("HTTP/2 prior-knowledge (h2c) is not supported for L7-inspected endpoints") } - TunnelProtocol::Unsupported => { + (TunnelProtocol::H2cPriorKnowledge, InspectionRequirement::RequiredMiddleware) => { + Some("HTTP/2 prior-knowledge (h2c) cannot be inspected by required middleware") + } + (TunnelProtocol::Unsupported, InspectionRequirement::L7Route) => { Some("Unsupported tunnel protocol for L7-inspected endpoint") } - TunnelProtocol::Tls | TunnelProtocol::Http1 => None, + (TunnelProtocol::Unsupported, InspectionRequirement::RequiredMiddleware) => { + Some("Unsupported tunnel protocol cannot be inspected by required middleware") + } } } +/// Gate for traffic that would bypass L7 inspection entirely: query the +/// middleware chain matching this destination and process identity, and +/// decide whether raw relay is allowed. Uninspectable traffic is denied when +/// any matching entry is `fail_closed`; an all-`fail_open` chain passes it +/// through with a bypass detection finding. +fn middleware_uninspectable_gate( + opa_engine: &OpaEngine, + ctx: &crate::l7::relay::L7EvalContext, +) -> Result { + let input = crate::l7::middleware::middleware_network_input(ctx); + let (chain, _generation) = opa_engine.query_middleware_chain_with_generation(&input)?; + Ok(crate::l7::middleware::uninspectable_traffic_gate(&chain)) +} + async fn peek_tunnel_protocol(client: &TcpStream) -> Result> { let mut peek_buf = [0u8; TUNNEL_PROTOCOL_PEEK_BYTES]; let deadline = tokio::time::Instant::now() + TUNNEL_PROTOCOL_PEEK_TIMEOUT; @@ -456,25 +495,65 @@ fn emit_forward_success_activity(tx: Option<&ActivitySender>, l7_activity_pendin ); } -fn forward_l7_hard_deny_reason( - protocol: crate::l7::L7Protocol, - request_info: &crate::l7::L7RequestInfo, -) -> Option { - request_info - .graphql - .as_ref() - .and_then(|info| info.error.as_deref()) - .map(|error| format!("GraphQL request rejected: {error}")) - .or_else(|| { - request_info.jsonrpc.as_ref().and_then(|info| { - info.error - .as_deref() - .map(|error| format!("JSON-RPC request rejected: {error}")) - .or_else(|| { - crate::l7::relay::jsonrpc_response_frame_hard_deny_reason(protocol, info) - }) - }) - }) +/// Body-aware policy state carried from forward L7 admission to middleware +/// execution. The selected route and its captured policy engine stay paired so +/// middleware cannot be invoked with only half of the re-evaluation context. +struct ForwardL7Reevaluation<'a> { + config: &'a crate::l7::L7EndpointConfig, + engine: &'a crate::opa::TunnelPolicyEngine, + request_info: &'a crate::l7::L7RequestInfo, +} + +/// Executes the middleware portion of the forward HTTP pipeline with an +/// explicit transformed-body policy. +struct ForwardMiddlewarePipeline<'a> { + ctx: &'a crate::l7::relay::L7EvalContext, + scheme: &'a str, + runner: &'a openshell_supervisor_middleware::ChainRunner, + generation_guard: &'a PolicyGenerationGuard, + l7_reevaluation: Option>, +} + +impl ForwardMiddlewarePipeline<'_> { + #[allow( + clippy::option_if_let_else, + reason = "the Some branch must keep a borrowed evaluator alive across the async call" + )] + async fn apply( + &self, + request: crate::l7::provider::L7Request, + client: &mut C, + chain: Vec, + ) -> Result + where + C: TokioAsyncRead + TokioAsyncWrite + Unpin + Send, + { + let validate; + let transformed_body_policy = match &self.l7_reevaluation { + Some(l7) => { + validate = crate::l7::relay::transformed_body_validator( + l7.config, + l7.engine, + self.ctx, + l7.request_info, + ); + openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate) + } + None => openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + }; + + crate::l7::middleware::apply_middleware_chain_for_scheme( + request, + client, + self.ctx, + self.scheme, + chain, + self.runner, + self.generation_guard, + transformed_body_policy, + ) + .await + } } /// Emit a denial event to the aggregator channel (if configured). @@ -1106,6 +1185,32 @@ async fn handle_tcp_connection( }; if effective_tls_skip { + // Policy validation rejects fail-closed middleware overlapping + // `tls: skip` endpoints; this runtime gate is defense in depth. + match middleware_uninspectable_gate(&opa_engine, &ctx)? { + crate::l7::middleware::UninspectableTrafficGate::Deny => { + crate::l7::middleware::emit_middleware_uninspectable(&ctx, "tls-skip tunnel", true); + respond( + &mut client, + &build_json_error_response( + 403, + "Forbidden", + "middleware_required", + "tls: skip tunnel cannot be inspected by required middleware", + ), + ) + .await?; + return Ok(()); + } + crate::l7::middleware::UninspectableTrafficGate::BypassWithFinding => { + crate::l7::middleware::emit_middleware_uninspectable( + &ctx, + "tls-skip tunnel", + false, + ); + } + crate::l7::middleware::UninspectableTrafficGate::Unrestricted => {} + } // tls: skip — raw tunnel, no termination, no credential injection. debug!( host = %host_lc, @@ -1185,6 +1290,7 @@ async fn handle_tcp_connection( &mut tls_upstream, &ctx, &generation_guard, + Some(&opa_engine), ) .await } @@ -1209,6 +1315,29 @@ async fn handle_tcp_connection( } } } else { + // Without TLS state the stream cannot be terminated, so a + // matching middleware chain can never inspect it. Close instead + // of raw-copying when any matching entry is fail_closed; a 403 + // is useless mid-handshake, so the deny is the closed socket + // plus the finding. + match middleware_uninspectable_gate(&opa_engine, &ctx)? { + crate::l7::middleware::UninspectableTrafficGate::Deny => { + crate::l7::middleware::emit_middleware_uninspectable( + &ctx, + "tls without termination state", + true, + ); + return Ok(()); + } + crate::l7::middleware::UninspectableTrafficGate::BypassWithFinding => { + crate::l7::middleware::emit_middleware_uninspectable( + &ctx, + "tls without termination state", + false, + ); + } + crate::l7::middleware::UninspectableTrafficGate::Unrestricted => {} + } { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) @@ -1290,6 +1419,7 @@ async fn handle_tcp_connection( &mut upstream, &ctx, &generation_guard, + Some(&opa_engine), ) .await { @@ -1308,9 +1438,14 @@ async fn handle_tcp_connection( } } } else { + let middleware_gate = middleware_uninspectable_gate(&opa_engine, &ctx)?; + let requirement = inspection_requirement(should_inspect_l7, middleware_gate); if let Some(protocol_detail) = - unsupported_l7_tunnel_protocol_detail(tunnel_protocol, should_inspect_l7) + unsupported_l7_tunnel_protocol_detail(tunnel_protocol, requirement) { + if requirement == InspectionRequirement::RequiredMiddleware { + crate::l7::middleware::emit_middleware_uninspectable(&ctx, protocol_detail, true); + } let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) @@ -1353,6 +1488,9 @@ async fn handle_tcp_connection( return Ok(()); } + if middleware_gate == crate::l7::middleware::UninspectableTrafficGate::BypassWithFinding { + crate::l7::middleware::emit_middleware_uninspectable(&ctx, "non-http tcp", false); + } // Neither TLS nor HTTP — raw binary relay. debug!( host = %host_lc, @@ -3479,6 +3617,11 @@ async fn handle_forward_proxy( let mut upstream_target = path.clone(); let mut websocket_extensions = crate::l7::rest::WebSocketExtensionMode::Preserve; let mut forward_tunnel_engine: Option = None; + // L7 endpoint config and evaluated request info, carried past the L7 + // block so a middleware-transformed body can be re-evaluated against the + // same policy inputs before it is forwarded. + let mut forward_l7_reeval: Option<(crate::l7::L7EndpointConfig, crate::l7::L7RequestInfo)> = + None; let mut forward_upgrade_config: Option = None; let mut forward_upgrade_target = String::new(); let mut forward_upgrade_query_params = std::collections::HashMap::new(); @@ -3820,10 +3963,10 @@ async fn handle_forward_proxy( jsonrpc, }; - let parse_error_reason = - forward_l7_hard_deny_reason(l7_config.config.protocol, &request_info); - let force_deny = parse_error_reason.is_some(); - let (allowed, reason) = parse_error_reason.map_or_else( + let hard_deny_reason = + crate::l7::relay::l7_request_hard_deny_reason(l7_config.config.protocol, &request_info); + let force_deny = hard_deny_reason.is_some(); + let (allowed, reason) = hard_deny_reason.map_or_else( || { crate::l7::relay::evaluate_l7_request(&tunnel_engine, &l7_ctx, &request_info) .unwrap_or_else(|e| { @@ -3941,6 +4084,7 @@ async fn handle_forward_proxy( } l7_activity_pending = true; forward_tunnel_engine = Some(tunnel_engine); + forward_l7_reeval = Some((l7_config.config.clone(), request_info)); } // 5. DNS resolution + SSRF defence (mirrors the CONNECT path logic). @@ -4326,6 +4470,81 @@ async fn handle_forward_proxy( } emit_forward_success_activity(activity_tx, l7_activity_pending); + let middleware_path = path.split_once('?').map_or(path.as_str(), |(path, _)| path); + let middleware_input = crate::opa::NetworkInput { + host: host_lc.clone(), + port, + binary_path: decision.binary.clone().unwrap_or_default(), + binary_sha256: String::new(), + ancestors: decision.ancestors.clone(), + cmdline_paths: decision.cmdline_paths.clone(), + }; + let (chain, generation) = + opa_engine.query_middleware_chain_with_generation(&middleware_input)?; + if generation != forward_generation_guard.captured_generation() { + emit_l7_tunnel_close_after_policy_change( + &host_lc, + port, + miette::miette!( + "policy changed before forward middleware evaluation [expected_generation:{} current_generation:{}]", + forward_generation_guard.captured_generation(), + generation, + ), + ); + respond( + client, + &build_json_error_response( + 403, + "Forbidden", + "policy_denied", + &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + ), + ) + .await?; + return Ok(()); + } + if !chain.is_empty() { + let middleware_runner = opa_engine.middleware_runner()?; + let request = crate::l7::rest::request_from_buffered_http( + method, + middleware_path, + &upstream_target, + forward_request_bytes, + )?; + let l7_reevaluation = match (forward_l7_reeval.as_ref(), forward_tunnel_engine.as_ref()) { + (Some((config, request_info)), Some(engine)) => Some(ForwardL7Reevaluation { + config, + engine, + request_info, + }), + _ => None, + }; + let pipeline = ForwardMiddlewarePipeline { + ctx: &l7_ctx, + scheme: &scheme, + runner: &middleware_runner, + generation_guard: &forward_generation_guard, + l7_reevaluation, + }; + forward_request_bytes = match pipeline.apply(request, client, chain).await? { + crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request.raw_header, + crate::l7::middleware::MiddlewareApplyResult::Denied(reason) => { + emit_activity_simple(activity_tx, true, "middleware"); + respond( + client, + &build_json_error_response( + 403, + "Forbidden", + "middleware_denied", + &format!("{method} {host_lc}:{port}{path} denied by middleware: {reason}"), + ), + ) + .await?; + return Ok(()); + } + }; + } + forward_request_bytes = match inject_token_grant_for_forward_request( method, &upstream_target, @@ -4623,6 +4842,8 @@ network_policies: #[tokio::test] async fn h2c_prior_knowledge_is_blocked_for_l7_tunnel() { + use crate::l7::middleware::UninspectableTrafficGate; + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let mut client = TcpStream::connect(addr).await.unwrap(); @@ -4639,10 +4860,44 @@ network_policies: .expect("client sent bytes"); assert_eq!(protocol, TunnelProtocol::H2cPriorKnowledge); assert_eq!( - unsupported_l7_tunnel_protocol_detail(protocol, true), + unsupported_l7_tunnel_protocol_detail(protocol, InspectionRequirement::L7Route), Some("HTTP/2 prior-knowledge (h2c) is not supported for L7-inspected endpoints") ); - assert_eq!(unsupported_l7_tunnel_protocol_detail(protocol, false), None); + // A fail-closed middleware chain makes inspection mandatory even for + // an L4-only endpoint. + assert_eq!( + unsupported_l7_tunnel_protocol_detail( + protocol, + InspectionRequirement::RequiredMiddleware + ), + Some("HTTP/2 prior-knowledge (h2c) cannot be inspected by required middleware") + ); + assert_eq!( + unsupported_l7_tunnel_protocol_detail( + TunnelProtocol::Unsupported, + InspectionRequirement::RequiredMiddleware + ), + Some("Unsupported tunnel protocol cannot be inspected by required middleware") + ); + assert_eq!( + unsupported_l7_tunnel_protocol_detail(protocol, InspectionRequirement::None), + None + ); + + // The L7 route owns the wording even when middleware also requires + // inspection; the middleware requirement applies only without a route. + assert_eq!( + inspection_requirement(true, UninspectableTrafficGate::Deny), + InspectionRequirement::L7Route + ); + assert_eq!( + inspection_requirement(false, UninspectableTrafficGate::Deny), + InspectionRequirement::RequiredMiddleware + ); + assert_eq!( + inspection_requirement(false, UninspectableTrafficGate::BypassWithFinding), + InspectionRequirement::None + ); } #[tokio::test] @@ -4745,7 +5000,7 @@ network_policies: } #[test] - fn forward_l7_hard_deny_reason_includes_jsonrpc_errors() { + fn l7_hard_deny_reason_includes_jsonrpc_errors() { let request_info = crate::l7::L7RequestInfo { action: "POST".to_string(), target: "/rpc".to_string(), @@ -4760,8 +5015,11 @@ network_policies: }), }; - let reason = forward_l7_hard_deny_reason(crate::l7::L7Protocol::JsonRpc, &request_info) - .expect("JSON-RPC parse error"); + let reason = crate::l7::relay::l7_request_hard_deny_reason( + crate::l7::L7Protocol::JsonRpc, + &request_info, + ) + .expect("JSON-RPC parse error"); assert_eq!( reason, @@ -4770,7 +5028,7 @@ network_policies: } #[test] - fn forward_l7_hard_deny_reason_includes_jsonrpc_response_frames() { + fn l7_hard_deny_reason_includes_jsonrpc_response_frames() { let request_info = crate::l7::L7RequestInfo { action: "POST".to_string(), target: "/rpc".to_string(), @@ -4785,16 +5043,130 @@ network_policies: }), }; - let reason = forward_l7_hard_deny_reason(crate::l7::L7Protocol::JsonRpc, &request_info) - .expect("JSON-RPC response hard deny"); + let reason = crate::l7::relay::l7_request_hard_deny_reason( + crate::l7::L7Protocol::JsonRpc, + &request_info, + ) + .expect("JSON-RPC response hard deny"); assert_eq!(reason, crate::l7::relay::JSONRPC_RESPONSE_FRAME_DENY_REASON); assert!( - forward_l7_hard_deny_reason(crate::l7::L7Protocol::Mcp, &request_info).is_none(), + crate::l7::relay::l7_request_hard_deny_reason( + crate::l7::L7Protocol::Mcp, + &request_info, + ) + .is_none(), "MCP response frames are evaluated by policy instead of hard-denied" ); } + #[tokio::test] + async fn forward_middleware_pipeline_denies_policy_invalid_transformation() { + const TEST_POLICY: &str = include_str!("../data/sandbox-policy.rego"); + let data = r#" +network_policies: + jsonrpc_api: + name: jsonrpc_api + endpoints: + - host: api.example.test + port: 80 + path: /rpc + protocol: json-rpc + enforcement: enforce + rules: + - allow: + method: sk-ABCDEFGHIJKLMNOP + binaries: + - { path: /usr/bin/node } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = crate::opa::NetworkInput { + host: "api.example.test".into(), + port: 80, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let config = crate::l7::parse_l7_config(&endpoint.expect("JSON-RPC endpoint")) + .expect("parse JSON-RPC config"); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let body = br#"{"jsonrpc":"2.0","id":1,"method":"sk-ABCDEFGHIJKLMNOP"}"#; + let request_info = crate::l7::L7RequestInfo { + action: "POST".into(), + target: "/rpc".into(), + query_params: std::collections::HashMap::new(), + graphql: None, + jsonrpc: Some(crate::l7::jsonrpc::parse_jsonrpc_body_with_options( + body, + crate::l7::jsonrpc::JsonRpcInspectionOptions::for_config(&config), + )), + }; + let raw = format!( + "POST /rpc HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + std::str::from_utf8(body).unwrap() + ) + .into_bytes(); + let request = + crate::l7::rest::request_from_buffered_http("POST", "/rpc", "/rpc", raw).unwrap(); + let ctx = crate::l7::relay::L7EvalContext { + host: "api.example.test".into(), + port: 80, + policy_name: "jsonrpc_api".into(), + binary_path: "/usr/bin/node".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + activity_tx: None, + dynamic_credentials: None, + token_grant_resolver: None, + }; + let runner = openshell_supervisor_middleware::ChainRunner::new( + openshell_supervisor_middleware_builtins::services() + .into_iter() + .next() + .expect("built-in middleware service"), + ); + let pipeline = ForwardMiddlewarePipeline { + ctx: &ctx, + scheme: "http", + runner: &runner, + generation_guard: tunnel_engine.generation_guard(), + l7_reevaluation: Some(ForwardL7Reevaluation { + config: &config, + engine: &tunnel_engine, + request_info: &request_info, + }), + }; + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "redactor".into(), + implementation: openshell_supervisor_middleware_builtins::BUILTIN_SECRETS.into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]; + let (_app, mut client) = tokio::io::duplex(8192); + + let outcome = pipeline + .apply(request, &mut client, chain) + .await + .expect("forward middleware pipeline"); + + match outcome { + crate::l7::middleware::MiddlewareApplyResult::Denied(reason) => assert!( + reason.contains("middleware transformation denied by policy"), + "{reason}" + ), + crate::l7::middleware::MiddlewareApplyResult::Allowed(_) => { + panic!("policy-invalid transformed request must be denied") + } + } + } + #[test] fn forward_l7_allowed_activity_is_deferred_until_after_ssrf() { let (tx, mut rx) = mpsc::channel(4); diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx new file mode 100644 index 0000000000..473984c99e --- /dev/null +++ b/docs/extensibility/supervisor-middleware.mdx @@ -0,0 +1,162 @@ +--- +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +title: "Supervisor Middleware" +sidebar-title: "Supervisor Middleware" +description: "Configure and operate built-in and operator-run middleware for sandbox HTTP requests." +keywords: "Generative AI, Cybersecurity, AI Agents, Supervisor Middleware, Extensibility, Request Filtering" +--- + +Supervisor middleware adds ordered request-processing stages to allowed HTTP egress. Middleware runs after network and L7 policy admit a request and before OpenShell injects provider credentials. A stage can allow or deny the request, replace its body, add approved headers, and report audit-safe findings. + +Middleware selection is independent of the network policy rule that admitted the request. OpenShell matches middleware by destination host, so the same middleware applies consistently across broad, specific, user-authored, and provider-derived network policies. + +## Request Flow + +For each inspected HTTP request, the supervisor: + +1. Evaluates network and L7 policy. +2. Selects middleware whose host selectors match the admitted destination. +3. Buffers the request body using the largest body limit in the selected chain. +4. Runs matching middleware by ascending `order`, using the policy-local name to break ties. +5. Re-checks body-aware protocol policy (GraphQL, JSON-RPC, MCP) after each stage that replaces the body. Every middleware receives a payload the policy admits, and a transformation cannot smuggle a denied or unparseable operation to a later stage or the upstream. +6. Applies allowed transformations, injects provider credentials, and forwards the request. + +Because each transformed body is re-checked before the next stage runs, a middleware hook always receives a request that satisfies the sandbox policy. A stage whose output the policy rejects stops the chain; under `enforcement: audit` the rejection is logged and the request proceeds. + +If post-transformation policy evaluation itself fails, OpenShell denies the request and emits a high-severity detection finding. This failure is separate from middleware `on_error` because the middleware completed successfully; the sandbox policy could not validate its output. + +Middleware receives the request before credential injection. Operator-run services cannot inspect OpenShell-managed credentials. Request headers are delivered in wire order and repeated header names are preserved as separate entries, so a service inspects every value the upstream will receive. + +## Choose a Middleware Type + +| Type | Registration | Body limit | Deployment | +| --- | --- | --- | --- | +| Built-in | None | Defined by OpenShell | Runs inside the supervisor | +| Operator-run service | Required in gateway TOML | Set by the operator, up to the service capability | Runs as a separate service reachable by the gateway and supervisors | + +`openshell/secrets` is the built-in middleware currently available. It identifies common secret patterns in UTF-8 request bodies and replaces matched values before the request leaves the sandbox. Its `config` accepts one field, `secrets: redact`, which is also the default when the field is omitted. Unknown config fields and non-string values are rejected at policy validation. + +Operator-run services expose one or more binding IDs. Policies reference a binding ID, such as `example/content-guard`, rather than the gateway registration name. + +## Register a Middleware Service + +Start an operator-run service before starting the gateway, then add a registration to the local gateway TOML: + +```toml +[[openshell.gateway.middleware]] +name = "local-content-guard" +grpc_endpoint = "http://host.openshell.internal:50051" +max_body_bytes = 262144 +``` + +| Field | Description | +| --- | --- | +| `name` | Operator-facing registration name used in diagnostics. Policies do not reference this value. | +| `grpc_endpoint` | Service address reachable from both the gateway and sandbox supervisors. Supports plaintext `http://` and TLS `https://` with platform trust roots. | +| `max_body_bytes` | Operator limit applied to every binding exposed by the service. | + +The gateway connects to every registered service and verifies its capabilities before accepting traffic. Gateway startup fails when a service is unavailable, reports an invalid capability, or exposes a binding ID already owned by another service. Operator-run services cannot claim the reserved `openshell/` namespace. + +Registration is static. Restart the gateway after adding, removing, or changing a service. See [Gateway Configuration](/reference/gateway-config#supervisor-middleware-services) for the complete gateway TOML context. + +## Apply Middleware with Policy + +Add middleware configs to the top-level `network_middlewares` list: + +```yaml +network_middlewares: + - name: redact-secrets + middleware: openshell/secrets + order: 10 + config: + secrets: redact + on_error: fail_closed + endpoints: + include: ["*.example.com"] + exclude: ["trusted.example.com"] +``` + +Each config has a policy-local `name`, a built-in or operator-provided binding ID in `middleware`, an integer `order`, implementation-owned `config`, failure behavior, and host selectors. + +`include` selects destination hosts. `exclude` takes precedence and removes hosts from that selection. Matching is case-insensitive and uses the same exact-host and DNS glob behavior as network policy endpoints: `*` matches exactly one DNS label, `**` matches one or more labels, and intra-label patterns like `*-api.example.com` work. Brace alternates such as `{prod,staging}` are rejected at validation; list each host pattern separately. + +Matching configs run once each by ascending `order`; lower values run first and policy-local names break ties. The default order is `0`. Different config names may reference the same binding and run as separate stages. Config names must be unique. + +See [Policy Schema](/reference/policy-schema#network-middleware) for the complete field reference. + +## Configure Failure Behavior + +`on_error` controls what happens when middleware is unavailable, rejects its configuration, returns an invalid result, or exceeds a body limit. + +| Value | Behavior | +| --- | --- | +| `fail_closed` | Denies the request when the middleware stage fails. This is the default. | +| `fail_open` | Skips the failed stage and continues the request through the remaining chain. | + +Use `fail_open` only when bypassing the middleware preserves the intended security policy. OpenShell emits a detection finding when a failed stage is bypassed. + +An explicit deny decision always stops the chain and denies the request, regardless of `on_error`. + +Middleware decisions are enforced regardless of the endpoint's `enforcement` mode. `enforcement: audit` applies to an endpoint's network and L7 policy rules and does not bypass middleware: a middleware deny, or a failed `fail_closed` stage, blocks the request even on an audit endpoint. A middleware service that needs to observe traffic without blocking should return an allow decision with findings, which OpenShell emits as detection findings. + +## Set Body Limits + +Every middleware binding declares the largest request or replacement body it supports. + +- Built-in middleware uses its OpenShell-defined limit. +- Each operator-run registration sets `max_body_bytes` no higher than the service capability. +- A selected chain buffers using its largest stage limit, so every stage that can process the body receives it. +- The same per-stage limit applies to request bodies and replacement bodies. + +The gateway rejects a registration whose operator limit exceeds the service capability instead of silently clamping it. At request time, exceeding a selected stage's limit is a middleware failure for that stage alone and follows that config's `on_error` behavior; other stages in the chain still run against their own limits. A body larger than every stage limit cannot be inspected at all and is denied unless every selected stage is `fail_open`. + +## Mutate Request Headers + +A middleware result can return ordered header mutations before OpenShell injects credentials. A `write` mutation adds a value when the case-insensitive header name is absent and selects one behavior when it is already present: + +- `append` adds another field value. +- `overwrite` removes every existing value before adding the new value. +- `skip` leaves existing values unchanged. + +A `remove` mutation removes every value for a case-insensitive header name. OpenShell applies each successful stage's mutations before invoking the next middleware, so later stages observe the accumulated header state. + +Header writes must use the `x-openshell-middleware-` prefix. Removes may target other middleware-visible request headers. Protected credential, routing, framing, and hop-by-hop headers are always rejected. Header values must not contain control characters. + +OpenShell validates and applies each stage's mutations atomically. An invalid operation discards every mutation from that stage and follows its `on_error` behavior. The failure reason names the offending header. + +## Operate Middleware Services + +Plan startup and updates around these boundaries: + +- Start registered services before the gateway. The gateway validates every registration during startup. +- Keep service endpoints reachable from both the gateway and sandbox supervisors. The supervisors call operator-run services directly on the request path. +- Restart the gateway after changing registrations. +- Keep required services available before creating or updating policies. The gateway validates implementation-owned config before persisting a policy. +- Treat `fail_open` as an explicit availability-over-enforcement decision. + +When the effective sandbox configuration changes, a running supervisor validates the new service registry before installing it. If the reload fails, the supervisor keeps its last-known-good registry and emits a configuration failure event. + +## Observe Middleware + +Middleware activity is emitted through OpenShell's OCSF logging: + +- Each invocation records its policy-local middleware name, binding, decision, transformation state, and failure state. +- A denied invocation records the service-provided audit-safe reason without request content, configured terms, credentials, or other secrets. +- A bypass under `fail_open` emits a detection finding. +- A required stage that fails closed emits a high-severity detection finding. +- Findings include the service-provided type and label plus aggregate counts. Middleware services should keep those fields audit-safe and omit request content or matched values. +- Registry reload success and failure are emitted as configuration state changes. + +See [Logging](/observability/logging) for log access and [OCSF JSON Export](/observability/ocsf-json-export) for structured export. + +## Current Limitations + +- Middleware applies only to HTTP requests parsed by the supervisor. Traffic the supervisor cannot inspect, such as HTTP/2 prior knowledge or non-HTTP TCP protocols, is denied on hosts matched by a fail-closed middleware, and relayed with a detection finding when every matching middleware is `fail_open`. +- The typed operation and phase are `HTTP_REQUEST/PRE_CREDENTIALS`. +- Selection uses destination host include and exclude patterns. +- Required middleware cannot cover `tls: skip` endpoints because OpenShell cannot inspect that traffic. +- Operator-run services support plaintext `http://` and TLS `https://` endpoints. HTTPS certificates must chain to a CA in the platform trust store. +- Custom trust roots, client authentication, health checks, and runtime registration are not available. + +For a runnable operator workflow, see the [content guard example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-content-guard). diff --git a/docs/index.yml b/docs/index.yml index b2443e4afd..45db451a78 100644 --- a/docs/index.yml +++ b/docs/index.yml @@ -19,6 +19,8 @@ navigation: title: "Manage OpenShell" - folder: providers title: "Providers" +- folder: extensibility + title: "Extensibility" - folder: observability title: "Observability" - folder: kubernetes diff --git a/docs/observability/logging.mdx b/docs/observability/logging.mdx index dcfe9f19d4..6cd24c70e2 100644 --- a/docs/observability/logging.mdx +++ b/docs/observability/logging.mdx @@ -94,9 +94,9 @@ CLASS:ACTIVITY [SEVERITY] ACTION DETAILS [CONTEXT] - SSH: peer address and authentication type - Process: `name(pid)` with exit code or command line - Config: description of what changed -- Finding: quoted title with confidence level +- Finding: quoted title with the stable finding type, optional confidence, and source-specific context attributes when available -**Context** in brackets at the end provides the policy rule and enforcement engine that produced the decision. +**Context** in brackets provides structured fields such as policy provenance, source-specific attributes, and denial reasons. ### Examples @@ -130,6 +130,13 @@ An HTTP request to a non-default port. HTTP log URLs include the port whenever i OCSF HTTP:GET [INFO] ALLOWED GET http://api.internal.corp:8080/v1/status [policy:internal_api engine:opa] ``` +A supervisor middleware HTTP event records whether it transformed the request. If the middleware also emits a finding, that remains a separate event: + +```text +OCSF HTTP:POST [INFO] ALLOWED POST http://httpbin.org:443/anything [policy:httpbin engine:middleware] [failed:false transformed:true] +OCSF FINDING:CREATE [MED] "configured content matched" [type:content_guard.match count:1 middleware:prototype-content-guard] +``` + Proxy and SSH servers ready: ```text @@ -160,6 +167,8 @@ OCSF CONFIG:LOADED [INFO] Policy reloaded successfully [policy_hash:0cc0c2b52557 Denied `NET:` and `HTTP:` events carry a `[reason:...]` suffix that surfaces the decision detail from the event's `status_detail` field. The reason helps distinguish between policy misses, SSRF hardening, and L7 enforcement without inspecting the full OCSF JSONL record. +For supervisor middleware denials, `status_detail` contains the service-provided audit-safe reason from the gRPC response. + Common reason phrases emitted by the sandbox include: | Reason | Meaning | diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 247774a6c0..b08c3b0d06 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -104,6 +104,13 @@ guest_tls_key = "/etc/openshell/certs/client-key.pem" grpc_rate_limit_requests = 120 grpc_rate_limit_window_seconds = 60 +# Operator-run supervisor middleware. The gRPC endpoint must be reachable from +# both the gateway and sandbox supervisors. +[[openshell.gateway.middleware]] +name = "local-content-guard" +grpc_endpoint = "http://host.openshell.internal:50051" +max_body_bytes = 262144 + # Gateway listener TLS (distinct from the per-driver guest_tls_*). [openshell.gateway.tls] cert_path = "/etc/openshell/certs/gateway.pem" @@ -141,6 +148,27 @@ Local Docker, Podman, and VM gateways can also set `[openshell.gateway.mtls_auth `[openshell.gateway.auth] allow_unauthenticated_users = true` is an unsafe local-development and trusted-proxy escape hatch. It accepts user-facing CLI/API calls without OIDC or mTLS credentials while sandbox supervisors still authenticate with gateway-minted sandbox JWTs. Leave it false for shared and production gateways. +## Supervisor Middleware Services + +Register operator-run supervisor middleware services with one or more `[[openshell.gateway.middleware]]` entries. Registration is static and operator-owned; changing it requires restarting the gateway. + +```toml +[[openshell.gateway.middleware]] +name = "local-content-guard" +grpc_endpoint = "http://host.openshell.internal:50051" +max_body_bytes = 262144 +``` + +Each service implements the supervisor middleware gRPC contract and may expose multiple binding IDs through `Describe`. Policies reference those binding IDs, not the registration `name`. The gateway rejects duplicate binding IDs across services and prevents operator-run services from claiming the reserved `openshell/` namespace. + +The gateway connects to every registered service and validates `Describe` before it starts. The service must therefore be running before the gateway. Policy creation and full policy updates call `ValidateConfig`; an unavailable service or invalid middleware configuration rejects the policy before persistence. + +`max_body_bytes` is the operator limit for every binding exposed by the service. It must be greater than zero and no larger than each binding's advertised limit. OpenShell rejects an oversized value instead of silently clamping it. + +The service `grpc_endpoint` currently supports plaintext `http://` and TLS `https://` using the platform trust store. Custom trust roots, client authentication, health checks, and runtime registration are not currently supported. The endpoint must be reachable from both the gateway and sandbox supervisors; use `host.openshell.internal` or another shared address that can be resolved in both places. + +See [Supervisor Middleware](/extensibility/supervisor-middleware) for selection, failure, body-limit, and operational guidance. + `image_pull_policy` is intentionally not a shared gateway key. Kubernetes and Docker use `Always`, `IfNotPresent`, or `Never`. Podman uses `always`, `missing`, `never`, or `newer`. Set it inside the relevant driver table. ## Driver References diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 049ad47979..ea3cb68cf6 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -20,6 +20,7 @@ filesystem_policy: { ... } landlock: { ... } process: { ... } network_policies: { ... } +network_middlewares: [ ... ] ``` | Field | Type | Required | Category | Description | @@ -29,6 +30,7 @@ network_policies: { ... } | `landlock` | object | No | Static | Configures Landlock LSM enforcement behavior. | | `process` | object | No | Static | Sets the user and group the agent process runs as. | | `network_policies` | map | No | Dynamic | Declares which binaries can reach which network endpoints. | +| `network_middlewares` | list | No | Dynamic | Selects ordered HTTP request middleware by destination host. | Static fields are set at sandbox creation time. Changing them requires destroying and recreating the sandbox. Dynamic fields can be updated on a running sandbox with `openshell policy update` for incremental merges or `openshell policy set` for full replacement, and take effect without restarting. @@ -472,7 +474,39 @@ Identifies an executable that is permitted to use the associated endpoints. |---|---|---|---| | `path` | string | Yes | Filesystem path to the executable. Supports glob patterns with `*` and `**`. For example, `/sandbox/.vscode-server/**` matches any executable under that directory tree. | -### Full Example +## Network Middleware + +**Category:** Dynamic + +An ordered list of middleware configs selected after network and L7 policy admit an HTTP request. Middleware selection is independent of the network policy entry that admitted the request. Every matching config runs once by ascending `order`, with the policy-local name breaking ties, before provider credential injection. + +```yaml showLineNumbers={false} +network_middlewares: + - name: redact-secrets + middleware: openshell/secrets + order: 10 + config: + secrets: redact + on_error: fail_closed + endpoints: + include: ["*.example.com"] + exclude: ["trusted.example.com"] +``` + +| Field | Type | Required | Description | +|---|---|---|---| +| `name` | string | Yes | Policy-local config name. Names must be unique within the list. | +| `middleware` | string | Yes | Built-in or operator-registered binding ID. `openshell/` is reserved for built-ins. | +| `order` | integer | No | Execution priority. Lower values run first; names break ties. Defaults to `0`. | +| `config` | object | No | Implementation-owned configuration validated by the selected middleware. | +| `on_error` | string | No | `fail_closed` denies the request when the stage fails; `fail_open` skips the failed stage. Defaults to `fail_closed`. | +| `endpoints` | object | Yes | Host selector with required non-empty `include` and optional `exclude` lists. Exclusions take precedence. | + +Host selectors use the same case-insensitive exact and DNS glob semantics as network endpoints: `*` matches exactly one DNS label and `**` matches one or more labels, so `**.example.com` covers subdomains but not `example.com` itself. Brace alternates are rejected at validation. Middleware runs only on HTTP requests the supervisor parses. A selector that can require middleware on a `tls: skip` endpoint is rejected because OpenShell cannot inspect that traffic. + +See [Supervisor Middleware](/extensibility/supervisor-middleware) for registration, failure behavior, body limits, and operational guidance. + +## Full Example The following policy grants read-only GitHub API access and npm registry access: diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 212ba76fce..a49e89c19b 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -12,7 +12,7 @@ Use this page to apply and iterate policy changes on running sandboxes. For a fu ## Policy Structure -A policy has static sections `filesystem_policy`, `landlock`, and `process` that are locked at sandbox creation, and a dynamic section `network_policies` that is hot-reloadable on a running sandbox. +A policy has static sections `filesystem_policy`, `landlock`, and `process` that are locked at sandbox creation, and dynamic `network_policies` and `network_middlewares` sections that are hot-reloadable on a running sandbox. ```yaml wordWrap showLineNumbers={false} version: 1 @@ -44,6 +44,18 @@ network_policies: binaries: - path: /usr/bin/curl +# Dynamic: ordered middleware selected independently by admitted host. +network_middlewares: + - name: redact-secrets + middleware: openshell/secrets + order: 10 + config: + secrets: redact + on_error: fail_closed + endpoints: + include: ["api.example.com"] + exclude: [] + ``` Static sections are locked at sandbox creation. Changing them requires destroying and recreating the sandbox. @@ -57,6 +69,32 @@ Raw streams are connection-scoped and outside L7 live-reload guarantees. This in | `landlock` | Static | Configures Landlock LSM enforcement behavior. Set `compatibility` to `best_effort` (skip individual inaccessible paths while applying remaining rules) or `hard_requirement` (fail if any path is inaccessible or the required kernel ABI is unavailable). Refer to the [Policy Schema Reference](/reference/policy-schema#landlock) for the full behavior table. | | `process` | Static | Sets the OS-level identity for the agent process. `run_as_user` and `run_as_group` default to `sandbox`. Root (`root` or `0`) is rejected. The agent also runs with seccomp filters that block dangerous system calls. | | `network_policies` | Dynamic | Controls network access for ordinary outbound traffic from the sandbox. Each block has a name, a list of endpoints (host, port, protocol, and optional rules), and a list of binaries allowed to use those endpoints.
Every outbound connection except `https://inference.local` goes through the proxy, which queries the [policy engine](/about/how-it-works#core-components) with the destination and calling binary. A connection is allowed only when both match an entry in the same policy block.
For endpoints with `protocol: rest`, the proxy auto-detects TLS and terminates it so each HTTP request can be checked against that endpoint's `rules` (method and path). For endpoints with `protocol: websocket`, the proxy validates the RFC 6455 upgrade and evaluates `GET` rules for the handshake plus either `WEBSOCKET_TEXT` rules for raw client text messages or GraphQL operation rules for GraphQL-over-WebSocket messages. Set `websocket_credential_rewrite: true` only when a WebSocket or REST compatibility endpoint must keep placeholder credentials in sandbox-owned text frames and resolve them at the OpenShell relay boundary.
Endpoints without `protocol` allow the TCP stream through without inspecting payloads.
If no endpoint matches, the connection is denied. Configure managed inference separately through [Inference Routing](/sandboxes/inference-routing). | +| `network_middlewares` | Dynamic | Declares ordered HTTP request middleware configs. After network and L7 policy admit a request, OpenShell matches each config's host selectors independently and runs matching entries by ascending `order`, using the policy-local name to break ties, before credential injection. | + +## Supervisor Middleware + +Supervisor middleware can inspect, deny, or replace admitted HTTP request bodies before provider credentials are injected. Middleware selection is independent of the `network_policies` rule that admitted the request: each `network_middlewares` entry matches the destination host through `endpoints.include` and `endpoints.exclude`. + +```yaml +network_middlewares: + - name: redact-secrets + middleware: openshell/secrets + order: 10 + config: + secrets: redact + on_error: fail_closed + endpoints: + include: ["*.example.com"] + exclude: ["trusted.example.com"] +``` + +Matching entries run once each by ascending `order`; lower values run first and policy-local names break ties. The default order is `0`. Config names must be unique. Different config names may use the same implementation and run as distinct stages. `exclude` takes precedence over `include`. + +`openshell/secrets` is built into the supervisor. Operator-provided binding IDs must be registered before a policy can reference them. The gateway validates implementation-owned config before accepting the policy. + +`on_error` defaults to `fail_closed`. Use `fail_open` only when skipping a failed middleware is acceptable. Middleware applies only to HTTP traffic the supervisor can parse and inspect; policy validation rejects a required selector that can cover a `tls: skip` endpoint. + +See [Supervisor Middleware](/extensibility/supervisor-middleware) for registration, chain ordering, body limits, failure behavior, and operations. ## Baseline Filesystem Paths diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 8a5a593334..4e4d4e053b 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -5,6 +5,8 @@ syntax = "proto3"; package openshell.sandbox.v1; +import "google/protobuf/struct.proto"; + // Sandbox-supervisor configuration and policy messages. // // Conventions: @@ -25,6 +27,8 @@ message SandboxPolicy { ProcessPolicy process = 4; // Network access policies keyed by name (e.g. "claude_code", "gitlab"). map network_policies = 5; + // Reusable supervisor middleware configs for network egress. + repeated NetworkMiddlewareConfig network_middlewares = 6; } // Filesystem access policy. @@ -61,6 +65,31 @@ message NetworkPolicyRule { repeated NetworkBinary binaries = 3; } +// A reusable middleware config selected for admitted egress by host. +message NetworkMiddlewareConfig { + // Policy-local config name. + string name = 1; + // Built-in or registered middleware implementation name. + string middleware = 2; + // Service-specific configuration. + google.protobuf.Struct config = 3; + // Failure behavior: "fail_closed" (default) or "fail_open". + string on_error = 4; + // Host selector controlling which admitted destinations use this config. + MiddlewareEndpointSelector endpoints = 5; + // Deterministic execution order. Lower values run first; names break ties. + int32 order = 6; +} + +// Host selector controlling which admitted destinations use a middleware config. +message MiddlewareEndpointSelector { + // Exact host or DNS glob patterns included in the selection. + repeated string include = 1; + // Exact host or DNS glob patterns removed from the selection. + // Exclusions take precedence over inclusions. + repeated string exclude = 2; +} + // A network endpoint (host + port) with optional L7 inspection config. message NetworkEndpoint { // Hostname or host glob pattern. Exact match is case-insensitive. @@ -329,4 +358,18 @@ message GetSandboxConfigResponse { // Fingerprint for provider credential inputs attached to this sandbox. // Changes when attached provider names or attached provider records change. uint64 provider_env_revision = 8; + // Operator-registered supervisor middleware services required by the + // effective policy. Built-in middleware is not included. + repeated SupervisorMiddlewareService supervisor_middleware_services = 9; +} + +// Connection details for one operator-registered supervisor middleware service. +// V1 supports plaintext and server-authenticated TLS gRPC. +message SupervisorMiddlewareService { + // Operator-facing registration name used for diagnostics. + string name = 1; + // gRPC endpoint reachable from the sandbox supervisor. + string grpc_endpoint = 2; + // Operator-owned body limit applied to every binding exposed by the service. + uint64 max_body_bytes = 3; } diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto new file mode 100644 index 0000000000..2614bf8a3b --- /dev/null +++ b/proto/supervisor_middleware.proto @@ -0,0 +1,223 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +syntax = "proto3"; + +package openshell.middleware.v1; + +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; + +// SupervisorMiddleware lets an operator-run service inspect and transform +// sandbox HTTP egress before OpenShell injects credentials. +service SupervisorMiddleware { + // Describe returns the service manifest and declared bindings. + rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); + + // ValidateConfig checks service-specific configuration for one binding. + rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse); + + // EvaluateHttpRequest returns an allow, deny, or mutation decision for one + // buffered HTTP request. + rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); +} + +// MiddlewareManifest describes one middleware service and the bindings it +// exposes. The service is the operator-run gRPC server implementing +// SupervisorMiddleware. +message MiddlewareManifest { + // Human-readable middleware service name used for diagnostics. + string name = 1; + // Release version of the middleware service implementation, used for + // diagnostics. + string service_version = 2; + // Bindings exposed by this middleware service. + repeated MiddlewareBinding bindings = 3; +} + +// MiddlewareBinding declares one operation and phase supported by a service. +message MiddlewareBinding { + // Stable binding id used by policy configuration and audit logs. + string id = 1; + // Supported operation. V1 supports HTTP_REQUEST. + SupervisorMiddlewareOperation operation = 2; + // Supported evaluation phase. V1 supports PRE_CREDENTIALS. + SupervisorMiddlewarePhase phase = 3; + // Maximum request or replacement body this binding can process. + uint64 max_body_bytes = 4; +} + +// ValidateConfigRequest contains one policy configuration to validate. +message ValidateConfigRequest { + // Manifest binding id associated with this configuration. + string binding_id = 1; + // Service-specific policy configuration. + google.protobuf.Struct config = 2; +} + +// ValidateConfigResponse reports whether a policy configuration is accepted. +message ValidateConfigResponse { + // True when the service accepts the configuration. + bool valid = 1; + // Human-readable validation failure reason. Empty when valid is true. + string reason = 2; +} + +// HttpRequestEvaluation contains one buffered HTTP request to evaluate. +message HttpRequestEvaluation { + // Manifest binding id selected for this evaluation. + string binding_id = 1; + // Evaluation phase selected for this request. + SupervisorMiddlewarePhase phase = 2; + // Sandbox and request identity available to the supervisor. + RequestContext context = 3; + // Validated service-specific policy configuration. + google.protobuf.Struct config = 4; + // Destination and HTTP request target. + HttpRequestTarget target = 5; + // HTTP request headers before OpenShell injects credentials, in wire + // order. Repeated header names are preserved as separate entries so a + // service inspects every value the upstream will receive. Protected + // headers such as authorization or cookie are omitted. + repeated HttpHeader headers = 6; + // Buffered request body. Empty for a bodyless request. + bytes body = 7; +} + +// HttpHeader is one request header line. +message HttpHeader { + // Lowercased header name. + string name = 1; + // Header value with surrounding whitespace trimmed. + string value = 2; +} + +// Supervisor operation selected for middleware evaluation. +enum SupervisorMiddlewareOperation { + SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; + SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; +} + +// Ordered phase within a supervisor operation. +enum SupervisorMiddlewarePhase { + SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; + SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; +} + +// RequestContext identifies the sandbox request being evaluated. +message RequestContext { + // Request id used to correlate middleware and supervisor logs. + string request_id = 1; + // Sandbox id that originated the request. + string sandbox_id = 2; + // Workload process that originated the request, when available. + Process originating_process = 3; +} + +// HttpRequestTarget describes the admitted HTTP destination and request target. +message HttpRequestTarget { + // Request scheme, such as "http" or "https". + string scheme = 1; + // Destination hostname selected by network policy. + string host = 2; + // Destination TCP port. + uint32 port = 3; + // HTTP request method. + string method = 4; + // Request path without the query string. + string path = 5; + // Raw request query string without the leading question mark. + string query = 6; +} + +// Process identifies a workload process and its executable ancestry. +message Process { + // Executable path for the originating process. + string binary = 1; + // Process id within the sandbox. + uint32 pid = 2; + // Executable paths for ancestor processes, nearest parent first. + repeated string ancestors = 3; +} + +// Decision controls whether OpenShell continues processing the request. +enum Decision { + // Invalid response value handled according to the policy failure mode. + DECISION_UNSPECIFIED = 0; + // Continue processing the request and apply any returned mutations. + DECISION_ALLOW = 1; + // Deny the request before credentials are injected or data is sent upstream. + DECISION_DENY = 2; +} + +// Finding is an audit-safe observation produced during evaluation. +message Finding { + // Stable, service-defined finding type. + string type = 1; + // Human-readable finding label that does not contain request content. + string label = 2; + // Number of matching observations represented by this finding. + uint32 count = 3; + // Service-defined confidence level. + string confidence = 4; + // Service-defined severity level. + string severity = 5; +} + +// ExistingHeaderAction controls how a header write behaves when the +// case-insensitive header name is already present. Every action writes the +// value when the header is absent. +enum ExistingHeaderAction { + EXISTING_HEADER_ACTION_UNSPECIFIED = 0; + // Add another field value without changing existing values. + EXISTING_HEADER_ACTION_APPEND = 1; + // Remove every existing value, then add the new value. + EXISTING_HEADER_ACTION_OVERWRITE = 2; + // Leave the existing values unchanged. + EXISTING_HEADER_ACTION_SKIP = 3; +} + +// WriteHeader proposes one header value and defines collision behavior. +message WriteHeader { + string name = 1; + string value = 2; + ExistingHeaderAction on_existing = 3; +} + +// RemoveHeader removes every value for a case-insensitive header name. +message RemoveHeader { + string name = 1; +} + +// HeaderMutation is one ordered request-header operation. +message HeaderMutation { + oneof operation { + WriteHeader write = 1; + RemoveHeader remove = 2; + } +} + +// HttpRequestResult contains the decision and optional request mutations. +message HttpRequestResult { + // Allow or deny decision for this request. + Decision decision = 1; + // Audit-safe human-readable reason used for diagnostics, denied responses, + // and security logs. Must not contain request content, configured terms, + // credentials, or other secrets. + string reason = 2; + // Replacement request body when has_body is true. + bytes body = 3; + // True when body should replace the request body, including with an empty body. + bool has_body = 4; + // Ordered request-header mutations applied before the next middleware and + // before forwarding. Header writes are restricted to the + // "x-openshell-middleware-" namespace. Removes may target other visible + // request headers, but credential, routing, framing, and hop-by-hop headers + // are always protected. A violating result is a middleware failure handled + // according to the policy failure mode. + repeated HeaderMutation header_mutations = 5; + // Audit-safe findings produced during evaluation. + repeated Finding findings = 6; + // Non-secret service-defined metadata included in diagnostics. + map metadata = 7; +}