From bdfa18c025c70a08597c71b9162e9a1cd91901f2 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 4 Sep 2026 18:41:04 -0700 Subject: [PATCH 01/13] feat(middleware): implement HTTP response processing Signed-off-by: Piotr Mlocek --- architecture/gateway.md | 5 + architecture/sandbox.md | 10 + crates/openshell-core/src/grpc_client.rs | 16 + crates/openshell-core/src/lib.rs | 3 + crates/openshell-sandbox/src/lib.rs | 12 + crates/openshell-server/src/config_file.rs | 73 + crates/openshell-server/src/grpc/policy.rs | 1 + crates/openshell-server/src/lib.rs | 13 + .../src/lib.rs | 33 +- .../src/remote.rs | 8 + .../src/response.rs | 2641 +++++++++ .../src/l7/middleware.rs | 55 +- .../src/l7/relay.rs | 219 +- .../src/l7/rest.rs | 4784 +++++++++++++---- .../openshell-supervisor-network/src/lib.rs | 18 + .../openshell-supervisor-network/src/proxy.rs | 350 +- docs/reference/gateway-config.mdx | 16 +- .../Cargo.lock | 274 + .../Cargo.toml | 3 + .../README.md | 39 +- .../policy.yaml | 25 + .../smoke.sh | 102 +- .../src/main.rs | 382 +- .../upstream.py | 67 + proto/sandbox.proto | 4 + sdk/go/proto/sandboxv1/sandbox.pb.go | 16 +- skills/debug-openshell-cluster/SKILL.md | 7 +- skills/generate-sandbox-policy/SKILL.md | 6 +- skills/openshell-cli/SKILL.md | 4 +- 29 files changed, 8045 insertions(+), 1141 deletions(-) create mode 100644 crates/openshell-supervisor-middleware/src/response.rs create mode 100644 examples/supervisor-middleware-content-guard/upstream.py diff --git a/architecture/gateway.md b/architecture/gateway.md index 769f57f6a0..df340f6cc0 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -509,6 +509,11 @@ 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. +The effective sandbox config also carries the supervisor-wide HTTP response +whole-body timeout. The gateway reads this static value from +`[openshell.supervisor]`, defaults it to 120 seconds, and distributes it as +milliseconds. A zero value from an older gateway maps to the same default. + 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 055ef7e4a3..ad0ce61117 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -185,6 +185,16 @@ middleware registry validates implementation-owned config. The generic registry and chain runner live in `openshell-supervisor-middleware`; first-party implementations live in `openshell-supervisor-middleware-builtins`. +The same selected chain can inspect the matching final HTTP response before it +returns to the workload. Response stages select header-only, whole-body, or +streaming mode independently. The relay preserves upstream framing for a +header-only chain and owns normalized downstream framing only when body bytes +can change. Whole-body stages delay commitment and share one non-resetting, +supervisor-wide accumulation deadline. Body stages receive a final body result +and then one trailer exchange; trailer mutations can only change or remove +existing, non-protected names. Intentional blocks return the canonical 403 +before commitment and abort delivery without injected bytes after commitment. + The supervisor installs policy and middleware registry changes as one runtime generation and preserves the last-known-good generation if preparation fails. Policy-only updates reuse the connected registry, so an external middleware diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 54f0db6902..4da38ae1ee 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -943,6 +943,8 @@ pub struct SettingsPollResult { pub policy_validation_failure_mode: crate::PolicyValidationFailureMode, /// Whether the gateway can mint authenticated extension credentials. pub extension_authentication_enabled: bool, + /// Supervisor-wide response whole-body accumulation timeout. + pub http_response_whole_body_timeout_ms: u64, } fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult { @@ -963,6 +965,11 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin .parse() .unwrap_or_default(), extension_authentication_enabled: inner.extension_authentication_enabled, + http_response_whole_body_timeout_ms: if inner.http_response_whole_body_timeout_ms == 0 { + crate::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS + } else { + inner.http_response_whole_body_timeout_ms + }, } } @@ -984,6 +991,15 @@ mod settings_poll_tests { ); } + #[test] + fn zero_whole_body_timeout_uses_compatibility_default() { + let result = settings_poll_result(GetSandboxConfigResponse::default()); + assert_eq!( + result.http_response_whole_body_timeout_ms, + crate::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS + ); + } + #[test] fn unknown_validation_failure_mode_fails_closed() { let result = settings_poll_result(GetSandboxConfigResponse { diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index 7acb72dd6f..f38ce8d5b2 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -74,6 +74,9 @@ pub const VERSION: &str = match option_env!("OPENSHELL_GIT_VERSION") { None => env!("CARGO_PKG_VERSION"), }; +/// Default wall-clock bound for HTTP response whole-body middleware buffering. +pub const DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS: u64 = 120_000; + #[cfg(test)] #[path = "../build_version.rs"] mod build_version; diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index 7afae200b5..f92b5ba5fd 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -2380,6 +2380,9 @@ async fn load_policy( openshell_core::grpc_client::fetch_settings_snapshot(endpoint, id) }) .await?; + openshell_supervisor_network::set_http_response_whole_body_timeout(Duration::from_millis( + snapshot.http_response_whole_body_timeout_ms, + )); let mut proto_policy = if let Some(p) = snapshot.policy.clone() { p @@ -3781,6 +3784,9 @@ async fn run_policy_poll_loop_with_client( // reconciled below instead of being recorded as already applied. match client.poll_settings(&ctx.sandbox_id).await { Ok(result) => { + openshell_supervisor_network::set_http_response_whole_body_timeout( + Duration::from_millis(result.http_response_whole_body_timeout_ms), + ); let _ = ctx.workspace_tx.send(client.workspace()); match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { InitialPollDisposition::Acknowledge(candidate) => { @@ -3867,6 +3873,10 @@ async fn run_policy_poll_loop_with_client( } }; + openshell_supervisor_network::set_http_response_whole_body_timeout(Duration::from_millis( + result.http_response_whole_body_timeout_ms, + )); + // Reuse installed per-service credentials, rotating only when one is // missing or due. Rotation happens on the existing gateway channel and // updates slots in place, so it is independent of config revision and @@ -4963,6 +4973,8 @@ network_policies: workspace: String::new(), policy_validation_failure_mode: PolicyValidationFailureMode::default(), extension_authentication_enabled: false, + http_response_whole_body_timeout_ms: + openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS, } } diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 64ac953624..6bfe0b7ae5 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -214,12 +214,40 @@ pub struct OtlpConfig { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SupervisorFileSection { + /// Wall-clock limit for accumulating and processing a response through + /// whole-body middleware. Accepts a positive integer followed by `ms`, + /// `s`, or `m`. + #[serde(default)] + pub http_response_whole_body_timeout: Option, + /// Statically registered supervisor middleware services. Registration is /// operator-owned and changes require a gateway restart. #[serde(default)] pub middleware: Vec, } +impl SupervisorFileSection { + /// Resolve the configured whole-body timeout to milliseconds. + #[must_use] + pub fn http_response_whole_body_timeout_ms(&self) -> u64 { + self.http_response_whole_body_timeout + .as_deref() + .and_then(parse_positive_duration_ms) + .unwrap_or(openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS) + } +} + +fn parse_positive_duration_ms(value: &str) -> Option { + let value = value.trim(); + let (number, multiplier) = value + .strip_suffix("ms") + .map(|number| (number, 1)) + .or_else(|| value.strip_suffix('s').map(|number| (number, 1_000))) + .or_else(|| value.strip_suffix('m').map(|number| (number, 60_000)))?; + let number = number.parse::().ok()?; + (number > 0).then_some(number.checked_mul(multiplier)?) +} + /// One `[[openshell.supervisor.middleware]]` supervisor middleware registration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -415,6 +443,18 @@ pub fn load(path: &Path) -> Result { message: "omit the field to use default encrypted gateway credential storage, or specify exactly one external credential driver", }); } + if file + .openshell + .supervisor + .http_response_whole_body_timeout + .as_deref() + .is_some_and(|value| parse_positive_duration_ms(value).is_none()) + { + return Err(ConfigFileError::InvalidValue { + field: "openshell.supervisor.http_response_whole_body_timeout", + message: "expected a positive integer duration ending in ms, s, or m", + }); + } Ok(file) } @@ -604,6 +644,39 @@ service_name = "openshell-gateway-dev" assert_eq!(otlp.service_name.as_deref(), Some("openshell-gateway-dev")); } + #[test] + fn parses_http_response_whole_body_timeout() { + let tmp = write_tmp( + r#" +[openshell.supervisor] +http_response_whole_body_timeout = "2m" +"#, + ); + let file = load(tmp.path()).expect("valid supervisor timeout parses"); + assert_eq!( + file.openshell + .supervisor + .http_response_whole_body_timeout_ms(), + 120_000 + ); + } + + #[test] + fn rejects_invalid_http_response_whole_body_timeout() { + for value in ["0s", "120", "later", "18446744073709551615m"] { + let tmp = write_tmp(&format!( + "[openshell.supervisor]\nhttp_response_whole_body_timeout = \"{value}\"\n" + )); + let error = load(tmp.path()).expect_err("invalid timeout must be rejected"); + assert!( + error + .to_string() + .contains("http_response_whole_body_timeout"), + "{error}" + ); + } + } + #[test] fn otlp_config_requires_only_endpoint() { let toml = r#" diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index f1f57c2f5b..1f191a7beb 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2524,6 +2524,7 @@ pub(super) async fn handle_get_sandbox_config( .as_str() .to_string(), extension_authentication_enabled: state.sandbox_jwt_issuer.is_some(), + http_response_whole_body_timeout_ms: state.http_response_whole_body_timeout_ms, })) } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index a8c8afdf08..219d4badd8 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -304,6 +304,9 @@ pub struct ServerState { /// Validated built-in and operator-registered supervisor middleware. pub middleware_registry: Arc, + /// Supervisor-wide response whole-body accumulation timeout. + pub http_response_whole_body_timeout_ms: u64, + /// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured. pub oidc_cache: Option>, @@ -419,6 +422,8 @@ impl ServerState { gateway_shutting_down: AtomicBool::new(false), extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), + http_response_whole_body_timeout_ms: + openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS, oidc_cache, sandbox_jwt_issuer: None, sandbox_jwt_authenticator: None, @@ -658,6 +663,14 @@ pub(crate) async fn run_server( oidc_cache, credentials, ); + state.http_response_whole_body_timeout_ms = config_file.as_ref().map_or( + openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS, + |file| { + file.openshell + .supervisor + .http_response_whole_body_timeout_ms() + }, + ); state.middleware_registry = middleware_registry; state.gateway_interceptors = gateway_interceptors; state.provider_profile_sources = provider_profile_sources; diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 1065c18faf..57d350cd4e 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -5,8 +5,15 @@ pub mod headers; mod remote; +mod response; mod websocket; +pub use response::{ + HttpResponseDiagnostics, HttpResponseFinish, HttpResponseInvocation, + HttpResponseInvocationOutcome, HttpResponseMiddlewareFailure, HttpResponsePreflightInput, + HttpResponsePreflightOutcome, HttpResponseSession, MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES, +}; + pub use websocket::{ WebSocketCoverage, WebSocketCoverageState, WebSocketInvocation, WebSocketInvocationOutcome, WebSocketMessageAdmission, WebSocketMessageOutcome, WebSocketMessageType, @@ -626,6 +633,16 @@ impl MiddlewareDispatch { Self::Grpc(service) => service.open_websocket_session(receiver).await, } } + + async fn open_http_response_pre_return( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + match self { + Self::InProcess(service) => service.open_http_response_pre_return(receiver).await, + Self::Grpc(service) => service.open_http_response_pre_return(receiver).await, + } + } } struct MiddlewareServiceState { @@ -831,6 +848,7 @@ fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result Result Err(miette!( - "{source} advertises HTTP_RESPONSE/PRE_RETURN, which is not yet supported" - )), + ) => Ok(SupportedBinding::HttpResponsePreReturn), ( Some(SupervisorMiddlewareOperation::WebsocketMessage), Some(SupervisorMiddlewarePhase::PreCredentials), @@ -3686,7 +3702,7 @@ mod tests { } #[test] - fn manifest_rejects_http_response_pre_return_binding_until_dispatch_is_available() { + fn manifest_accepts_http_response_pre_return_binding_when_dispatch_is_available() { let registration = external_registration(4096); let manifest = MiddlewareManifest { name: "example/response".into(), @@ -3700,13 +3716,8 @@ mod tests { expected_audience: String::new(), }; - let error = validate_external_manifest(®istration, &manifest, 4096, false) - .expect_err("HTTP response pre-return binding must remain unavailable"); - assert!( - error - .to_string() - .contains("HTTP_RESPONSE/PRE_RETURN, which is not yet supported") - ); + validate_external_manifest(®istration, &manifest, 4096, false) + .expect("HTTP response pre-return binding is supported"); } #[test] diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index 9443038100..80049b69dc 100644 --- a/crates/openshell-supervisor-middleware/src/remote.rs +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -103,6 +103,14 @@ impl GrpcMiddlewareService { ) -> std::result::Result { self.service.open_websocket_session(receiver).await } + + /// Open a remote HTTP response pre-return stream through the gRPC adapter. + pub async fn open_http_response_pre_return( + &self, + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + self.service.open_http_response_pre_return(receiver).await + } } #[derive(Clone)] diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs new file mode 100644 index 0000000000..5baa7f93a1 --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -0,0 +1,2641 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! HTTP response pre-return middleware chain execution. + +use std::collections::BTreeMap; +use std::time::Duration; + +use futures::StreamExt as _; +use prost::Message as _; +use tokio::sync::mpsc; +use tokio::time::Instant; + +use openshell_core::proto::{ + Finding, HttpHeader, HttpRequestTarget, HttpResponseBodyMode, HttpResponseBodyPassThrough, + HttpResponseBodyUnit, HttpResponseEvent, HttpResponseEventResult, HttpResponsePreflight, + HttpResponseTrailers, MiddlewareSessionEnd, MiddlewareSessionEndReason, RequestContext, + http_response_body_result, http_response_body_skip_remaining, http_response_body_transform, + http_response_body_unit, http_response_event, http_response_event_result, + http_response_preflight_result, +}; + +use super::{ + ChainEntry, ChainRunner, DescribedChainEntry, MAX_MIDDLEWARE_CHAIN_TIMEOUT, + MAX_MIDDLEWARE_CONTEXT_BYTES, MAX_MIDDLEWARE_FINDING_BYTES, MAX_MIDDLEWARE_FINDINGS_PER_STAGE, + MAX_MIDDLEWARE_HEADER_BYTES, MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES, MAX_MIDDLEWARE_HEADERS, + MAX_MIDDLEWARE_METADATA_BYTES, MAX_MIDDLEWARE_METADATA_ENTRIES, MAX_MIDDLEWARE_REASON_BYTES, + MAX_MIDDLEWARE_REASON_CODE_BYTES, MAX_MIDDLEWARE_TARGET_BYTES, MiddlewareDiagnosticPolicy, + MiddlewareSessionAdmission, MiddlewareSessionPermit, NamespacedFinding, OnError, headers, + is_stable_reason_code, middleware_denial_reason, +}; + +const STREAM_CHANNEL_CAPACITY: usize = 4; +pub const MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES: usize = 64 * 1024; + +#[derive(Debug, Clone)] +pub struct HttpResponsePreflightInput { + pub context: RequestContext, + pub target: HttpRequestTarget, + pub status_code: u16, + /// Parsed upstream Content-Length when present and valid. + pub declared_body_length: Option, + /// Sanitized, lowercased final response headers in wire order. + pub headers: Vec, + /// Lowercased names nominated by the original response's `Connection` + /// fields. Their values are not exposed to middleware. + pub connection_nominated_headers: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HttpResponseInvocationOutcome { + Skip, + BlockDelivery, + HeadersOnly, + WholeBody, + Stream, + Trailers, + PassThrough, + Transform, + SkipRemaining, + FailOpen, + FailClosed, +} + +#[derive(Debug, Clone)] +pub struct HttpResponseInvocation { + pub config_name: String, + pub implementation: String, + pub outcome: HttpResponseInvocationOutcome, + pub sequence: Option, + pub input_size: usize, + pub output_size: Option, + pub failed: bool, + pub stage_disabled: bool, + pub reason_code: Option, + pub failure_category: Option, +} + +pub struct HttpResponsePreflightOutcome { + pub allowed: bool, + pub reason: String, + pub denial: Option, + pub headers: Vec, + pub session: Option, + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, + pub session_capacity_exhausted: bool, +} + +#[derive(Debug)] +pub struct HttpResponseMiddlewareFailure { + pub reason: String, + pub denial: Option, + /// Exchange diagnostics collected before a consuming operation failed. + pub diagnostics: HttpResponseDiagnostics, +} + +impl std::fmt::Display for HttpResponseMiddlewareFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.reason) + } +} + +impl std::error::Error for HttpResponseMiddlewareFailure {} + +impl HttpResponseMiddlewareFailure { + fn with_diagnostics(mut self, diagnostics: HttpResponseDiagnostics) -> Self { + self.diagnostics = diagnostics; + self + } +} + +#[derive(Debug)] +pub struct HttpResponseFinish { + /// Units released while whole-body stages were finalized. + pub body_units: Vec>, + pub trailers: Vec, + /// True when a whole-body stage transformed or deleted body bytes. The + /// caller must strip stale representation validators before commitment. + pub strip_stale_integrity_headers: bool, + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, +} + +#[derive(Debug, Default)] +pub struct HttpResponseDiagnostics { + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, +} + +struct HttpResponseStageTransport { + sender: mpsc::Sender, + responses: super::HttpResponseResultStream, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StageMode { + HeadersOnly, + WholeBody, + Stream, +} + +struct HttpResponseStage { + entry: DescribedChainEntry, + transport: Option, + mode: StageMode, + next_sequence: u64, + whole_body: Vec, +} + +impl HttpResponseStage { + fn is_active(&self) -> bool { + self.transport.is_some() + } + + fn is_body_active(&self) -> bool { + self.is_active() && self.mode != StageMode::HeadersOnly + } + + async fn end(&mut self, reason: MiddlewareSessionEndReason) { + if let Some(transport) = self.transport.take() { + let _ = tokio::time::timeout( + Duration::from_millis(10), + transport.sender.send(session_end_event(reason)), + ) + .await; + } + } +} + +pub struct HttpResponseSession { + runner: ChainRunner, + stages: Vec, + findings: Vec, + metadata: BTreeMap>, + invocations: Vec, + session_admission: Option, + body_transformed: bool, + defer_output_until_finish: bool, + deferred_output: Vec>, + connection_nominated_headers: Vec, + whole_body_deadline: Option, +} + +impl HttpResponseSession { + pub fn take_diagnostics(&mut self) -> HttpResponseDiagnostics { + HttpResponseDiagnostics { + findings: std::mem::take(&mut self.findings), + metadata: std::mem::take(&mut self.metadata), + invocations: std::mem::take(&mut self.invocations), + } + } + + #[must_use] + pub fn requires_whole_body(&self) -> bool { + self.stages.iter().any(|stage| { + stage.is_active() && stage.mode == StageMode::WholeBody && stage.next_sequence == 1 + }) + } + + /// Start the platform-owned whole-body wall-clock deadline. + pub fn start_whole_body_deadline(&mut self, timeout: Duration) { + self.whole_body_deadline = self.requires_whole_body().then(|| Instant::now() + timeout); + } + + #[must_use] + pub fn whole_body_deadline(&self) -> Option { + self.requires_whole_body() + .then_some(self.whole_body_deadline) + .flatten() + } + + /// Fail each still-buffering whole-body stage in policy order. + /// + /// Fail-open stages release their retained input through the remaining + /// chain. A fail-closed stage stops the response with a typed failure. + pub async fn expire_whole_body_deadline( + &mut self, + ) -> Result>, HttpResponseMiddlewareFailure> { + self.whole_body_deadline = None; + let mut released = std::mem::take(&mut self.deferred_output); + let chain_deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; + for index in 0..self.stages.len() { + if !self.stages[index].is_active() + || self.stages[index].mode != StageMode::WholeBody + || self.stages[index].next_sequence != 1 + { + continue; + } + let original = std::mem::take(&mut self.stages[index].whole_body); + let output = self + .handle_stage_failure(index, "whole_body_accumulation_timeout", None, original) + .await?; + if !output.is_empty() { + released.extend( + self.process_units_from(index + 1, output, chain_deadline) + .await?, + ); + } + } + self.defer_output_until_finish = false; + Ok(released) + } + + #[must_use] + pub fn stream_unit_limit(&self) -> usize { + self.stages + .iter() + .filter(|stage| stage.is_active() && stage.mode == StageMode::Stream) + .map(|stage| { + stage + .entry + .max_payload_bytes + .clamp(1, MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES) + }) + .min() + .unwrap_or(MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES) + } + + /// Process one normalized body unit through the active chain. + /// + /// The caller must provide no more than [`Self::stream_unit_limit`] bytes. + /// A whole-body barrier retains output until [`Self::finish`] is called. + pub async fn push_body( + &mut self, + data: Vec, + ) -> Result>, HttpResponseMiddlewareFailure> { + if data.len() > self.stream_unit_limit() { + return Err(HttpResponseMiddlewareFailure { + reason: "response_stream_unit_over_capacity".into(), + denial: None, + diagnostics: HttpResponseDiagnostics::default(), + }); + } + let _work = self + .runner + .reserve_middleware_work_admission() + .await + .map_err(|error| HttpResponseMiddlewareFailure { + reason: format!("middleware_failed: {error}"), + denial: None, + diagnostics: HttpResponseDiagnostics::default(), + })?; + let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; + let output = self.process_units_from(0, vec![data], deadline).await?; + if !self.defer_output_until_finish { + return Ok(output); + } + if self.requires_whole_body() { + self.deferred_output.extend(output); + return Ok(Vec::new()); + } + + self.defer_output_until_finish = false; + let mut released = std::mem::take(&mut self.deferred_output); + released.extend(output); + Ok(released) + } + + /// Finalize every body stage, preserve normalized trailers, and end streams. + pub async fn finish( + mut self, + mut trailers: Vec, + ) -> Result { + let _work = match self.runner.reserve_middleware_work_admission().await { + Ok(work) => work, + Err(error) => { + return Err(HttpResponseMiddlewareFailure { + reason: format!("middleware_failed: {error}"), + denial: None, + diagnostics: self.take_diagnostics(), + }); + } + }; + let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; + let mut released = std::mem::take(&mut self.deferred_output); + for index in 0..self.stages.len() { + let stage_output = match self.finish_stage(index, deadline).await { + Ok(output) => output, + Err(failure) => { + self.end_all(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Err(failure.with_diagnostics(self.take_diagnostics())); + } + }; + if !stage_output.is_empty() { + let output = match self + .process_units_from(index + 1, stage_output, deadline) + .await + { + Ok(output) => output, + Err(failure) => { + self.end_all(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Err(failure.with_diagnostics(self.take_diagnostics())); + } + }; + released.extend(output); + } + } + + if self.body_transformed { + strip_stale_integrity(&mut trailers); + } + let trailers = match self.process_trailers(trailers, deadline).await { + Ok(trailers) => trailers, + Err(failure) => { + self.end_all(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Err(failure.with_diagnostics(self.take_diagnostics())); + } + }; + self.end_all(MiddlewareSessionEndReason::Normal).await; + self.session_admission.take(); + Ok(HttpResponseFinish { + body_units: released, + trailers, + strip_stale_integrity_headers: self.body_transformed, + findings: self.findings, + metadata: self.metadata, + invocations: self.invocations, + }) + } + + pub async fn end(mut self, reason: MiddlewareSessionEndReason) { + self.end_all(reason).await; + } + + async fn process_units_from( + &mut self, + start: usize, + mut units: Vec>, + deadline: Instant, + ) -> Result>, HttpResponseMiddlewareFailure> { + for index in start..self.stages.len() { + let mut next = Vec::new(); + for unit in units { + let chunk_limit = if self.stages[index].mode == StageMode::Stream { + self.stages[index] + .entry + .max_payload_bytes + .min(MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES) + } else { + unit.len().max(1) + }; + if unit.is_empty() { + next.extend(self.process_stage_unit(index, unit, deadline).await?); + } else { + for chunk in unit.chunks(chunk_limit) { + next.extend( + self.process_stage_unit(index, chunk.to_vec(), deadline) + .await?, + ); + } + } + } + units = next; + if units.is_empty() + && self.stages[index + 1..] + .iter() + .all(|stage| stage.mode != StageMode::WholeBody) + { + break; + } + } + Ok(units) + } + + async fn process_stage_unit( + &mut self, + index: usize, + data: Vec, + deadline: Instant, + ) -> Result>, HttpResponseMiddlewareFailure> { + let deadline = self.exchange_deadline(deadline); + let stage = &mut self.stages[index]; + if !stage.is_active() || stage.mode == StageMode::HeadersOnly { + return Ok(vec![data]); + } + if stage.mode == StageMode::WholeBody { + if stage.whole_body.len().saturating_add(data.len()) > stage.entry.max_payload_bytes { + let mut original = std::mem::take(&mut stage.whole_body); + original.extend_from_slice(&data); + return self + .handle_stage_failure(index, "whole_body_over_capacity", None, original) + .await; + } + stage.whole_body.extend_from_slice(&data); + return Ok(Vec::new()); + } + + let sequence = stage.next_sequence; + stage.next_sequence += 1; + let event = body_event(sequence, data.clone(), false); + let result = match exchange(stage, event, deadline).await { + Ok(result) => result, + Err(reason) => { + let reason = self.classify_timeout_reason(reason); + return self + .handle_stage_failure(index, &reason, Some(sequence), data) + .await; + } + }; + self.apply_body_result(index, result, sequence, data).await + } + + async fn finish_stage( + &mut self, + index: usize, + deadline: Instant, + ) -> Result>, HttpResponseMiddlewareFailure> { + if !self.stages[index].is_body_active() { + return Ok(Vec::new()); + } + let deadline = self.exchange_deadline(deadline); + let mode = self.stages[index].mode; + let mut output = Vec::new(); + if mode == StageMode::WholeBody { + let data = std::mem::take(&mut self.stages[index].whole_body); + let sequence = 1; + self.stages[index].next_sequence = 2; + let result = match exchange( + &mut self.stages[index], + body_event(sequence, data.clone(), true), + deadline, + ) + .await + { + Ok(result) => result, + Err(reason) => { + let reason = self.classify_timeout_reason(reason); + return self + .handle_stage_failure(index, &reason, Some(sequence), data) + .await; + } + }; + output.extend( + self.apply_body_result(index, result, sequence, data) + .await?, + ); + } + + if mode == StageMode::Stream { + let sequence = self.stages[index].next_sequence; + self.stages[index].next_sequence += 1; + let result = match exchange( + &mut self.stages[index], + body_event(sequence, Vec::new(), true), + deadline, + ) + .await + { + Ok(result) => result, + Err(reason) => { + let reason = self.classify_timeout_reason(reason); + return self + .handle_stage_failure(index, &reason, Some(sequence), Vec::new()) + .await; + } + }; + output.extend( + self.apply_body_result(index, result, sequence, Vec::new()) + .await?, + ); + } + Ok(output) + } + + async fn apply_body_result( + &mut self, + index: usize, + result: HttpResponseEventResult, + sequence: u64, + original: Vec, + ) -> Result>, HttpResponseMiddlewareFailure> { + let max_payload_bytes = self.stages[index].entry.max_payload_bytes; + let decision = match validate_body_result(result, sequence, max_payload_bytes) { + Ok(decision) => decision, + Err(reason) => { + return self + .handle_stage_failure(index, reason, Some(sequence), original) + .await; + } + }; + let input_size = original.len(); + let stage = &mut self.stages[index]; + collect_diagnostics( + stage, + decision.findings, + decision.metadata, + &mut self.findings, + &mut self.metadata, + ); + let reason_code = (!decision.reason_code.is_empty()).then_some(decision.reason_code); + match decision.action { + BodyAction::PassThrough => { + let output_size = original.len(); + self.invocations.push(body_invocation_with_reason( + stage, + HttpResponseInvocationOutcome::PassThrough, + sequence, + input_size, + output_size, + reason_code, + )); + Ok((!original.is_empty()) + .then_some(original) + .into_iter() + .collect()) + } + BodyAction::Transform(replacement) => { + self.body_transformed = true; + self.invocations.push(body_invocation_with_reason( + stage, + HttpResponseInvocationOutcome::Transform, + sequence, + input_size, + replacement.len(), + reason_code, + )); + Ok((!replacement.is_empty()) + .then_some(replacement) + .into_iter() + .collect()) + } + BodyAction::SkipRemaining(action) => { + let output = match action { + CurrentBodyAction::PassThrough => original, + CurrentBodyAction::Transform(replacement) => { + self.body_transformed = true; + replacement + } + }; + stage.mode = StageMode::HeadersOnly; + self.invocations.push(body_invocation_with_reason( + stage, + HttpResponseInvocationOutcome::SkipRemaining, + sequence, + input_size, + output.len(), + reason_code, + )); + stage.end(MiddlewareSessionEndReason::Normal).await; + self.release_admission_if_idle(); + Ok((!output.is_empty()).then_some(output).into_iter().collect()) + } + BodyAction::BlockDelivery => { + let config_name = stage.entry.entry.name.clone(); + let denial_reason = middleware_denial_reason(&config_name, reason_code.as_deref()); + self.invocations.push(body_invocation_with_reason( + stage, + HttpResponseInvocationOutcome::BlockDelivery, + sequence, + input_size, + 0, + reason_code.clone(), + )); + self.end_all(MiddlewareSessionEndReason::MiddlewareDenial) + .await; + Err(HttpResponseMiddlewareFailure { + reason: denial_reason, + denial: Some(super::MiddlewareDenial { + config_name, + reason_code, + }), + diagnostics: HttpResponseDiagnostics::default(), + }) + } + } + } + + async fn handle_stage_failure( + &mut self, + index: usize, + reason: &str, + sequence: Option, + original: Vec, + ) -> Result>, HttpResponseMiddlewareFailure> { + let stage = &mut self.stages[index]; + let fail_open = stage.entry.on_error() == OnError::FailOpen; + let outcome = if fail_open { + HttpResponseInvocationOutcome::FailOpen + } else { + HttpResponseInvocationOutcome::FailClosed + }; + self.invocations.push(HttpResponseInvocation { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + outcome, + sequence, + input_size: original.len(), + output_size: None, + failed: true, + stage_disabled: true, + reason_code: None, + failure_category: Some(response_failure_category(reason).into()), + }); + stage + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + self.release_admission_if_idle(); + if fail_open { + if original.is_empty() { + Ok(Vec::new()) + } else { + Ok(vec![original]) + } + } else { + Err(HttpResponseMiddlewareFailure { + reason: format!("middleware_failed: {reason}"), + denial: None, + diagnostics: HttpResponseDiagnostics::default(), + }) + } + } + + async fn end_all(&mut self, reason: MiddlewareSessionEndReason) { + for stage in &mut self.stages { + stage.end(reason).await; + } + } + + fn release_admission_if_idle(&mut self) { + if self.stages.iter().all(|stage| !stage.is_active()) { + self.session_admission.take(); + } + } + + fn exchange_deadline(&self, chain_deadline: Instant) -> Instant { + self.whole_body_deadline() + .map_or(chain_deadline, |deadline| deadline.min(chain_deadline)) + } + + fn classify_timeout_reason(&self, reason: String) -> String { + if reason == "middleware_timeout" + && self + .whole_body_deadline + .is_some_and(|deadline| Instant::now() >= deadline) + { + "whole_body_accumulation_timeout".into() + } else { + reason + } + } + + async fn process_trailers( + &mut self, + mut trailers: Vec, + deadline: Instant, + ) -> Result, HttpResponseMiddlewareFailure> { + for index in 0..self.stages.len() { + if !self.stages[index].is_body_active() { + continue; + } + let event = HttpResponseEvent { + event: Some(http_response_event::Event::Trailers(HttpResponseTrailers { + headers: trailers.clone(), + })), + }; + let result = match exchange(&mut self.stages[index], event, deadline).await { + Ok(result) => result, + Err(reason) => { + trailers = self + .handle_trailer_failure(index, &reason, trailers) + .await?; + continue; + } + }; + let decision = match validate_trailers_result( + result, + &trailers, + &self.stages[index].entry, + &self.connection_nominated_headers, + ) { + Ok(decision) => decision, + Err(reason) => { + trailers = self + .handle_trailer_failure(index, &reason, trailers) + .await?; + continue; + } + }; + let input_size = encoded_header_bytes(&trailers); + trailers = decision.headers; + let output_size = encoded_header_bytes(&trailers); + let reason_code = (!decision.reason_code.is_empty()).then_some(decision.reason_code); + let stage = &mut self.stages[index]; + collect_diagnostics( + stage, + decision.findings, + decision.metadata, + &mut self.findings, + &mut self.metadata, + ); + self.invocations.push(HttpResponseInvocation { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + outcome: HttpResponseInvocationOutcome::Trailers, + sequence: None, + input_size, + output_size: Some(output_size), + failed: false, + stage_disabled: false, + reason_code, + failure_category: None, + }); + } + Ok(trailers) + } + + async fn handle_trailer_failure( + &mut self, + index: usize, + reason: &str, + original: Vec, + ) -> Result, HttpResponseMiddlewareFailure> { + let stage = &mut self.stages[index]; + let fail_open = stage.entry.on_error() == OnError::FailOpen; + self.invocations.push(HttpResponseInvocation { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + outcome: if fail_open { + HttpResponseInvocationOutcome::FailOpen + } else { + HttpResponseInvocationOutcome::FailClosed + }, + sequence: None, + input_size: encoded_header_bytes(&original), + output_size: None, + failed: true, + stage_disabled: true, + reason_code: None, + failure_category: Some(response_failure_category(reason).into()), + }); + stage + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + self.release_admission_if_idle(); + if fail_open { + Ok(original) + } else { + Err(HttpResponseMiddlewareFailure { + reason: format!("middleware_failed: {reason}"), + denial: None, + diagnostics: HttpResponseDiagnostics::default(), + }) + } + } +} + +impl ChainRunner { + pub async fn preflight_http_response( + &self, + entries: &[ChainEntry], + input: HttpResponsePreflightInput, + ) -> miette::Result { + validate_preflight_input(&input)?; + let described = self.describe_http_response_chain(entries).await?; + if described.is_empty() { + return Ok(empty_preflight_outcome(input.headers)); + } + let session_admission = match self.try_reserve_middleware_session() { + MiddlewareSessionAdmission::Admitted(admission) => admission, + MiddlewareSessionAdmission::AtCapacity => { + return Ok(response_session_capacity_exhausted( + described, + input.headers, + )); + } + }; + let _work = self.reserve_middleware_work_admission().await?; + let original_restriction = body_restriction(&input); + let mut headers = input.headers.clone(); + let mut stages = Vec::new(); + let mut findings = Vec::new(); + let mut metadata = BTreeMap::new(); + let mut invocations = Vec::new(); + + for entry in described { + let Some(service) = entry.service.as_ref() else { + if let Some(reason) = + collect_preflight_failure(&entry, "binding_not_described", &mut invocations) + { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + }; + let (sender, receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let preflight = HttpResponsePreflight { + context: Some(input.context.clone()), + target: Some(input.target.clone()), + status_code: u32::from(input.status_code), + headers: headers.clone(), + middleware_name: entry.entry.implementation.clone(), + config: Some(entry.entry.config.clone()), + max_payload_bytes: entry.max_payload_bytes as u64, + permitted_body_modes: permitted_body_modes( + &input, + &entry, + original_restriction.as_deref(), + ), + }; + let timeout = entry.timeout; + let opened = tokio::time::timeout(timeout, async { + let mut responses = service + .service + .open_http_response_pre_return(receiver) + .await?; + sender + .send(HttpResponseEvent { + event: Some(http_response_event::Event::Preflight(preflight)), + }) + .await + .map_err(|_| tonic::Status::unavailable("middleware request stream closed"))?; + let response = responses.next().await.ok_or_else(|| { + tonic::Status::unavailable("middleware result stream closed") + })??; + Ok::<_, tonic::Status>((responses, response)) + }) + .await; + let (responses, response) = match opened { + Ok(Ok(opened)) => opened, + Ok(Err(error)) => { + let reason = if error.code() == tonic::Code::DeadlineExceeded { + "middleware_timeout".to_string() + } else { + service.diagnostic_policy.error_reason(&error) + }; + if let Some(reason) = + collect_preflight_failure(&entry, &reason, &mut invocations) + { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + Err(_) => { + if let Some(reason) = + collect_preflight_failure(&entry, "middleware_timeout", &mut invocations) + { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + let Some(http_response_event_result::Result::PreflightResult(decision)) = + response.result + else { + if let Some(reason) = collect_preflight_failure( + &entry, + "unexpected_response_result", + &mut invocations, + ) { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + }; + if let Err(reason) = validate_diagnostics( + &decision.reason, + &decision.reason_code, + &decision.findings, + &decision.metadata, + ) { + if let Some(reason) = collect_preflight_failure(&entry, reason, &mut invocations) { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + let reason_code = + (!decision.reason_code.is_empty()).then(|| decision.reason_code.clone()); + let decision_findings = decision.findings; + let decision_metadata = decision.metadata; + match decision.action { + Some(http_response_preflight_result::Action::Skip(_)) => { + collect_preflight_diagnostics( + &entry, + decision_findings, + decision_metadata, + &mut findings, + &mut metadata, + ); + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: HttpResponseInvocationOutcome::Skip, + sequence: None, + input_size: 0, + output_size: None, + failed: false, + stage_disabled: false, + reason_code, + failure_category: None, + }); + let mut skipped = HttpResponseStage { + entry, + transport: Some(HttpResponseStageTransport { sender, responses }), + mode: StageMode::HeadersOnly, + next_sequence: 1, + whole_body: Vec::new(), + }; + skipped.end(MiddlewareSessionEndReason::StageSkipped).await; + } + Some(http_response_preflight_result::Action::Inspect(inspect)) => { + let permitted_modes = + permitted_body_modes(&input, &entry, original_restriction.as_deref()); + let mode = match validate_inspect(&entry, &inspect, &permitted_modes) { + Ok(mode) => mode, + Err(reason) => { + if let Some(reason) = + collect_preflight_failure(&entry, &reason, &mut invocations) + { + end_stages( + &mut stages, + MiddlewareSessionEndReason::MiddlewareFailure, + ) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + let updated = match headers::apply( + headers::HeaderAuthority::Response, + &headers, + &input.connection_nominated_headers, + &inspect.header_mutations, + ) { + Ok(updated) => updated, + Err(error) => { + let reason = service + .diagnostic_policy + .header_mutation_error_reason(&error); + if let Some(reason) = + collect_preflight_failure(&entry, &reason, &mut invocations) + { + end_stages( + &mut stages, + MiddlewareSessionEndReason::MiddlewareFailure, + ) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + headers = updated; + if mode == StageMode::Stream { + strip_stale_integrity(&mut headers); + } + collect_preflight_diagnostics( + &entry, + decision_findings, + decision_metadata, + &mut findings, + &mut metadata, + ); + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: match mode { + StageMode::HeadersOnly => HttpResponseInvocationOutcome::HeadersOnly, + StageMode::WholeBody => HttpResponseInvocationOutcome::WholeBody, + StageMode::Stream => HttpResponseInvocationOutcome::Stream, + }, + sequence: None, + input_size: 0, + output_size: None, + failed: false, + stage_disabled: false, + reason_code, + failure_category: None, + }); + let mut stage = HttpResponseStage { + entry, + transport: Some(HttpResponseStageTransport { sender, responses }), + mode, + next_sequence: 1, + whole_body: Vec::new(), + }; + if mode == StageMode::HeadersOnly { + stage.end(MiddlewareSessionEndReason::Normal).await; + } else { + stages.push(stage); + } + } + Some(http_response_preflight_result::Action::BlockDelivery(_)) => { + collect_preflight_diagnostics( + &entry, + decision_findings, + decision_metadata, + &mut findings, + &mut metadata, + ); + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: HttpResponseInvocationOutcome::BlockDelivery, + sequence: None, + input_size: 0, + output_size: None, + failed: false, + stage_disabled: false, + reason_code: reason_code.clone(), + failure_category: None, + }); + stages.push(HttpResponseStage { + entry: entry.clone(), + transport: Some(HttpResponseStageTransport { sender, responses }), + mode: StageMode::HeadersOnly, + next_sequence: 1, + whole_body: Vec::new(), + }); + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareDenial).await; + return Ok(blocked_preflight_outcome( + headers, + super::MiddlewareDenial { + config_name: entry.entry.name.clone(), + reason_code, + }, + findings, + metadata, + invocations, + )); + } + None => { + if let Some(reason) = collect_preflight_failure( + &entry, + "invalid_preflight_decision", + &mut invocations, + ) { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + } + } + } + + if stages.is_empty() { + drop(session_admission); + return Ok(HttpResponsePreflightOutcome { + allowed: true, + reason: String::new(), + denial: None, + headers, + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }); + } + let defer_output_until_finish = stages + .iter() + .any(|stage| stage.is_active() && stage.mode == StageMode::WholeBody); + Ok(HttpResponsePreflightOutcome { + allowed: true, + reason: String::new(), + denial: None, + headers, + session: Some(HttpResponseSession { + runner: self.clone(), + stages, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + session_admission: Some(session_admission), + body_transformed: false, + defer_output_until_finish, + deferred_output: Vec::new(), + connection_nominated_headers: input.connection_nominated_headers, + whole_body_deadline: None, + }), + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }) + } +} + +enum BodyAction { + PassThrough, + Transform(Vec), + BlockDelivery, + SkipRemaining(CurrentBodyAction), +} + +enum CurrentBodyAction { + PassThrough, + Transform(Vec), +} + +struct BodyDecision { + action: BodyAction, + reason_code: String, + findings: Vec, + metadata: std::collections::HashMap, +} + +struct TrailersDecision { + headers: Vec, + reason_code: String, + findings: Vec, + metadata: std::collections::HashMap, +} + +fn validate_trailers_result( + result: HttpResponseEventResult, + trailers: &[HttpHeader], + entry: &DescribedChainEntry, + connection_nominated_headers: &[String], +) -> Result { + let Some(http_response_event_result::Result::TrailersResult(result)) = result.result else { + return Err("unexpected_response_result".into()); + }; + validate_diagnostics( + &result.reason, + &result.reason_code, + &result.findings, + &result.metadata, + ) + .map_err(str::to_string)?; + if result.trailer_mutations.len() > headers::MAX_HEADER_MUTATIONS { + return Err("header_mutation_count_over_capacity".into()); + } + let encoded_mutations = result + .trailer_mutations + .iter() + .fold(0usize, |total, mutation| { + total.saturating_add(mutation.encoded_len()) + }); + if encoded_mutations > MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES { + return Err("header_mutation_bytes_over_capacity".into()); + } + let headers = headers::apply( + headers::HeaderAuthority::ResponseTrailers, + trailers, + connection_nominated_headers, + &result.trailer_mutations, + ) + .map_err(|error| { + entry.service.as_ref().map_or_else( + || error.to_string(), + |service| { + service + .diagnostic_policy + .header_mutation_error_reason(&error) + }, + ) + })?; + Ok(TrailersDecision { + headers, + reason_code: result.reason_code, + findings: result.findings, + metadata: result.metadata, + }) +} + +fn encoded_header_bytes(headers: &[HttpHeader]) -> usize { + headers.iter().fold(0usize, |total, header| { + total.saturating_add(header.encoded_len()) + }) +} + +fn validate_body_result( + result: HttpResponseEventResult, + sequence: u64, + max_payload_bytes: usize, +) -> Result { + let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { + return Err("unexpected_response_result"); + }; + if body.sequence != sequence { + return Err("response_body_sequence_mismatch"); + } + validate_diagnostics( + &body.reason, + &body.reason_code, + &body.findings, + &body.metadata, + )?; + let action = match body.action { + Some(http_response_body_result::Action::PassThrough(HttpResponseBodyPassThrough {})) => { + BodyAction::PassThrough + } + Some(http_response_body_result::Action::Transform(transform)) => BodyAction::Transform( + validate_replacement(transform.replacement, max_payload_bytes)?, + ), + Some(http_response_body_result::Action::BlockDelivery(_)) => BodyAction::BlockDelivery, + Some(http_response_body_result::Action::SkipRemaining(skip)) => { + let current = match skip.current { + Some(http_response_body_skip_remaining::Current::PassThrough( + HttpResponseBodyPassThrough {}, + )) => CurrentBodyAction::PassThrough, + Some(http_response_body_skip_remaining::Current::Transform(transform)) => { + CurrentBodyAction::Transform(validate_replacement( + transform.replacement, + max_payload_bytes, + )?) + } + None => return Err("invalid_response_body_skip_remaining_action"), + }; + BodyAction::SkipRemaining(current) + } + None => return Err("invalid_response_body_decision"), + }; + Ok(BodyDecision { + action, + reason_code: body.reason_code, + findings: body.findings, + metadata: body.metadata, + }) +} + +fn validate_replacement( + replacement: Option, + max_payload_bytes: usize, +) -> Result, &'static str> { + let Some(http_response_body_transform::Replacement::Data(replacement)) = replacement else { + return Err("response_body_replacement_missing"); + }; + if replacement.len() > max_payload_bytes { + return Err("response_body_replacement_over_capacity"); + } + Ok(replacement) +} + +fn validate_inspect( + entry: &DescribedChainEntry, + inspect: &openshell_core::proto::HttpResponsePreflightInspect, + permitted_modes: &[i32], +) -> Result { + let mode = match HttpResponseBodyMode::try_from(inspect.body_mode) { + Ok(HttpResponseBodyMode::HeadersOnly) => StageMode::HeadersOnly, + Ok(HttpResponseBodyMode::WholeBodyBytes) => StageMode::WholeBody, + Ok(HttpResponseBodyMode::StreamBytes) => StageMode::Stream, + Ok(HttpResponseBodyMode::Unspecified) | Err(_) => { + return Err("invalid_response_body_mode".into()); + } + }; + if !permitted_modes.contains(&inspect.body_mode) { + return Err("response_body_mode_not_permitted".into()); + } + if inspect.header_mutations.len() > headers::MAX_HEADER_MUTATIONS { + return Err("header_mutation_count_over_capacity".into()); + } + let encoded_mutations = inspect + .header_mutations + .iter() + .fold(0usize, |total, mutation| { + total.saturating_add(mutation.encoded_len()) + }); + if encoded_mutations > MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES { + return Err("header_mutation_bytes_over_capacity".into()); + } + if entry.max_payload_bytes == 0 && mode != StageMode::HeadersOnly { + return Err("response_payload_limit_invalid".into()); + } + Ok(mode) +} + +fn validate_preflight_input(input: &HttpResponsePreflightInput) -> miette::Result<()> { + if input.context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES { + return Err(miette::miette!("response context exceeds platform limit")); + } + if input.target.encoded_len() > MAX_MIDDLEWARE_TARGET_BYTES { + return Err(miette::miette!("response target exceeds platform limit")); + } + if input.headers.len() > MAX_MIDDLEWARE_HEADERS { + return Err(miette::miette!( + "response header count exceeds platform limit" + )); + } + if input.headers.iter().fold(0usize, |total, header| { + total.saturating_add(header.encoded_len()) + }) > MAX_MIDDLEWARE_HEADER_BYTES + { + return Err(miette::miette!("response headers exceed platform limit")); + } + Ok(()) +} + +fn validate_diagnostics( + reason: &str, + reason_code: &str, + findings: &[Finding], + metadata: &std::collections::HashMap, +) -> Result<(), &'static str> { + if reason.len() > MAX_MIDDLEWARE_REASON_BYTES { + return Err("response_reason_over_capacity"); + } + if !reason_code.is_empty() + && (reason_code.len() > MAX_MIDDLEWARE_REASON_CODE_BYTES + || !is_stable_reason_code(reason_code)) + { + return Err("response_reason_code_invalid"); + } + if findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE { + return Err("response_findings_over_capacity"); + } + if findings + .iter() + .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) + { + return Err("response_finding_over_capacity"); + } + if metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES { + return Err("response_metadata_count_over_capacity"); + } + if metadata.iter().fold(0usize, |total, (key, value)| { + total.saturating_add(key.len()).saturating_add(value.len()) + }) > MAX_MIDDLEWARE_METADATA_BYTES + { + return Err("response_metadata_bytes_over_capacity"); + } + Ok(()) +} + +fn body_restriction(input: &HttpResponsePreflightInput) -> Option { + if input.target.method.eq_ignore_ascii_case("HEAD") + || input.status_code == 204 + || input.status_code == 304 + { + return Some("bodyless_response".into()); + } + if input.status_code == 206 + || input + .headers + .iter() + .any(|header| header.name.eq_ignore_ascii_case("content-range")) + || input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-type") + && header + .value + .split(';') + .next() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("multipart/byteranges")) + }) + { + return Some("unsupported_partial_response".into()); + } + if input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("cache-control") + && header.value.split(',').any(|directive| { + directive + .split('=') + .next() + .is_some_and(|name| name.trim().eq_ignore_ascii_case("no-transform")) + }) + }) { + return Some("response_no_transform".into()); + } + if input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-encoding") + && header + .value + .split(',') + .any(|coding| !coding.trim().eq_ignore_ascii_case("identity")) + }) { + return Some("unsupported_content_encoding".into()); + } + None +} + +fn permitted_body_modes( + input: &HttpResponsePreflightInput, + entry: &DescribedChainEntry, + body_restriction: Option<&str>, +) -> Vec { + let mut modes = vec![HttpResponseBodyMode::HeadersOnly as i32]; + if body_restriction.is_some() { + return modes; + } + if input + .declared_body_length + .is_none_or(|length| length <= entry.max_payload_bytes as u64) + && !is_open_ended_response(input) + { + modes.push(HttpResponseBodyMode::WholeBodyBytes as i32); + } + if entry.max_payload_bytes >= 2 { + modes.push(HttpResponseBodyMode::StreamBytes as i32); + } + modes +} + +fn is_open_ended_response(input: &HttpResponsePreflightInput) -> bool { + input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-type") + && matches!( + header.value.split(';').next().map(str::trim), + Some(value) + if value.eq_ignore_ascii_case("text/event-stream") + || value.eq_ignore_ascii_case("multipart/x-mixed-replace") + ) + }) +} + +fn strip_stale_integrity(headers: &mut Vec) { + headers.retain(|header| { + !matches!( + header.name.to_ascii_lowercase().as_str(), + "accept-ranges" + | "etag" + | "content-md5" + | "digest" + | "content-digest" + | "repr-digest" + | "signature" + | "signature-input" + ) + }); +} + +async fn exchange( + stage: &mut HttpResponseStage, + event: HttpResponseEvent, + chain_deadline: Instant, +) -> Result { + let remaining = chain_deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err("middleware_chain_timeout".into()); + } + let timeout = stage.entry.timeout.min(remaining); + let Some(transport) = stage.transport.as_mut() else { + return Err("middleware_stream_closed".into()); + }; + match tokio::time::timeout(timeout, async { + transport + .sender + .send(event) + .await + .map_err(|_| tonic::Status::unavailable("middleware request stream closed"))?; + transport + .responses + .next() + .await + .ok_or_else(|| tonic::Status::unavailable("middleware result stream closed"))? + }) + .await + { + Ok(Ok(result)) => Ok(result), + Ok(Err(error)) => { + let policy = stage + .entry + .service + .as_ref() + .map_or(MiddlewareDiagnosticPolicy::Preserve, |service| { + service.diagnostic_policy + }); + Err(policy.error_reason(&error)) + } + Err(_) => Err("middleware_timeout".into()), + } +} + +fn body_event(sequence: u64, data: Vec, end_of_stream: bool) -> HttpResponseEvent { + HttpResponseEvent { + event: Some(http_response_event::Event::Body(HttpResponseBodyUnit { + sequence, + payload: Some(http_response_body_unit::Payload::Data(data)), + end_of_stream, + })), + } +} + +fn session_end_event(reason: MiddlewareSessionEndReason) -> HttpResponseEvent { + HttpResponseEvent { + event: Some(http_response_event::Event::SessionEnd( + MiddlewareSessionEnd { + reason: reason as i32, + protocol_error: None, + }, + )), + } +} + +fn body_invocation_with_reason( + stage: &HttpResponseStage, + outcome: HttpResponseInvocationOutcome, + sequence: u64, + input_size: usize, + output_size: usize, + reason_code: Option, +) -> HttpResponseInvocation { + HttpResponseInvocation { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + outcome, + sequence: Some(sequence), + input_size, + output_size: Some(output_size), + failed: false, + stage_disabled: false, + reason_code, + failure_category: None, + } +} + +fn collect_diagnostics( + stage: &HttpResponseStage, + mut findings: Vec, + mut metadata: std::collections::HashMap, + all_findings: &mut Vec, + all_metadata: &mut BTreeMap>, +) { + if stage + .entry + .service + .as_ref() + .is_some_and(|service| service.diagnostic_policy == MiddlewareDiagnosticPolicy::Normalize) + { + metadata.clear(); + for finding in &mut findings { + finding.r#type = format!("{}.finding", stage.entry.entry.implementation); + finding.label = super::EXTERNAL_FINDING_LABEL.to_string(); + finding.confidence.clear(); + finding.severity = "medium".into(); + } + } + all_findings.extend(findings.into_iter().map(|finding| NamespacedFinding { + middleware: stage.entry.entry.name.clone(), + finding, + })); + if !metadata.is_empty() { + all_metadata.insert( + stage.entry.entry.name.clone(), + metadata.into_iter().collect(), + ); + } +} + +fn collect_preflight_diagnostics( + entry: &DescribedChainEntry, + findings: Vec, + metadata: std::collections::HashMap, + all_findings: &mut Vec, + all_metadata: &mut BTreeMap>, +) { + let stage = HttpResponseStage { + entry: entry.clone(), + transport: None, + mode: StageMode::HeadersOnly, + next_sequence: 1, + whole_body: Vec::new(), + }; + collect_diagnostics(&stage, findings, metadata, all_findings, all_metadata); +} + +fn collect_preflight_failure( + entry: &DescribedChainEntry, + reason: &str, + invocations: &mut Vec, +) -> Option { + let fail_closed = entry.on_error() == OnError::FailClosed; + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: if fail_closed { + HttpResponseInvocationOutcome::FailClosed + } else { + HttpResponseInvocationOutcome::FailOpen + }, + sequence: None, + input_size: 0, + output_size: None, + failed: true, + stage_disabled: true, + reason_code: None, + failure_category: Some(response_failure_category(reason).into()), + }); + fail_closed.then(|| format!("middleware_failed: {reason}")) +} + +fn response_failure_category(reason: &str) -> &'static str { + if reason == "middleware_session_capacity_exhausted" { + "session_capacity" + } else if reason.contains("over_capacity") { + "payload_capacity" + } else if reason.contains("timeout") { + "timeout" + } else if reason.contains("stream_closed") + || reason.contains("stream closed") + || reason.contains("transport") + || reason.contains("unavailable") + { + "transport" + } else if matches!( + reason, + "bodyless_response" + | "partial_response" + | "content_coding_not_identity" + | "cache_control_no_transform" + ) { + "response_not_inspectable" + } else { + "invalid_result" + } +} + +fn empty_preflight_outcome(headers: Vec) -> HttpResponsePreflightOutcome { + HttpResponsePreflightOutcome { + allowed: true, + reason: String::new(), + denial: None, + headers, + session: None, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + session_capacity_exhausted: false, + } +} + +fn failed_preflight_outcome( + headers: Vec, + reason: String, + findings: Vec, + metadata: BTreeMap>, + invocations: Vec, +) -> HttpResponsePreflightOutcome { + HttpResponsePreflightOutcome { + allowed: false, + reason, + denial: None, + headers, + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + } +} + +fn blocked_preflight_outcome( + headers: Vec, + denial: super::MiddlewareDenial, + findings: Vec, + metadata: BTreeMap>, + invocations: Vec, +) -> HttpResponsePreflightOutcome { + HttpResponsePreflightOutcome { + allowed: false, + reason: middleware_denial_reason(&denial.config_name, denial.reason_code.as_deref()), + denial: Some(denial), + headers, + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + } +} + +fn response_session_capacity_exhausted( + entries: Vec, + headers: Vec, +) -> HttpResponsePreflightOutcome { + let mut invocations = Vec::new(); + let fail_closed = entries.iter().any(|entry| { + collect_preflight_failure( + entry, + "middleware_session_capacity_exhausted", + &mut invocations, + ) + .is_some() + }); + HttpResponsePreflightOutcome { + allowed: !fail_closed, + reason: if fail_closed { + "middleware_failed: middleware_session_capacity_exhausted".into() + } else { + String::new() + }, + denial: None, + headers, + session: None, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations, + session_capacity_exhausted: true, + } +} + +async fn end_stages(stages: &mut [HttpResponseStage], reason: MiddlewareSessionEndReason) { + for stage in stages { + stage.end(reason).await; + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use openshell_core::middleware::{HttpRequestView, InProcessMiddleware}; + use openshell_core::proto::{ + Decision, ExistingHeaderAction, HeaderMutation, HttpRequestResult, HttpResponseBodyResult, + HttpResponseBodyTransform, HttpResponsePreflightInspect, HttpResponsePreflightResult, + HttpResponsePreflightSkip, HttpResponseTrailersResult, MiddlewareBinding, + MiddlewareManifest, WriteHeader, header_mutation, http_response_preflight_result, + }; + use tokio_stream::wrappers::ReceiverStream; + use tokio_stream::wrappers::TcpListenerStream; + + use super::*; + + #[derive(Clone, Copy)] + enum Script { + HeadersOnly, + Stream, + WholeBody, + InvalidSequence, + Configured, + HangBody, + LargeStream, + Skip, + InvalidSkipReason, + TrailerMutation, + InvalidTrailerMutation, + } + + struct ResponseService { + script: Script, + } + + #[derive(Clone)] + struct RemoteResponseService; + + #[tonic::async_trait] + impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware + for RemoteResponseService + { + type EvaluateWebSocketSessionStream = super::super::WebSocketResponseStream; + + async fn describe( + &self, + _request: tonic::Request<()>, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(response_manifest( + "test/remote-response", + ))) + } + + async fn validate_config( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> + { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + _request: tonic::Request, + ) -> Result, tonic::Status> { + Ok(tonic::Response::new(HttpRequestResult { + decision: Decision::Allow as i32, + ..Default::default() + })) + } + + async fn evaluate_web_socket_session( + &self, + _request: tonic::Request< + tonic::Streaming, + >, + ) -> Result, tonic::Status> { + Err(tonic::Status::unimplemented("HTTP response-only service")) + } + } + + #[tonic::async_trait] + impl openshell_core::proto::middleware::v1::http_response_pre_return_server::HttpResponsePreReturn + for RemoteResponseService + { + type EvaluateStream = super::super::HttpResponseResultStream; + + async fn evaluate( + &self, + request: tonic::Request>, + ) -> Result, tonic::Status> { + let mut requests = request.into_inner(); + let (sender, receiver) = mpsc::channel(4); + tokio::spawn(async move { + while let Some(Ok(event)) = requests.next().await { + match event.event { + Some(http_response_event::Event::Preflight(_)) => { + let result = HttpResponseEventResult { + result: Some( + http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some( + http_response_preflight_result::Action::Inspect( + HttpResponsePreflightInspect { + body_mode: + HttpResponseBodyMode::HeadersOnly as i32, + header_mutations: vec![write_header( + "cache-control", + "remote", + )], + }, + ), + ), + ..Default::default() + }, + ), + ), + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + Some(http_response_event::Event::SessionEnd(_)) | None => break, + _ => {} + } + } + }); + Ok(tonic::Response::new(Box::pin(ReceiverStream::new(receiver)))) + } + } + + #[tonic::async_trait] + impl InProcessMiddleware for ResponseService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/response".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpResponse + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: if matches!(self.script, Script::LargeStream) { + 128 * 1024 + } else { + 4096 + }, + timeout: if matches!(self.script, Script::HangBody) { + "10ms".into() + } else { + String::new() + }, + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> miette::Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + _request: HttpRequestView<'_>, + ) -> miette::Result { + Ok(HttpRequestResult { + decision: Decision::Allow as i32, + ..Default::default() + }) + } + + async fn open_http_response_pre_return( + &self, + mut requests: mpsc::Receiver, + ) -> Result { + let (sender, receiver) = mpsc::channel(4); + let script = self.script; + tokio::spawn(async move { + let mut selected_script = script; + while let Some(event) = requests.recv().await { + let Some(event) = event.event else { + break; + }; + let result = match event { + http_response_event::Event::Preflight(preflight) => { + if matches!(script, Script::Configured) { + selected_script = match preflight + .config + .as_ref() + .and_then(|config| config.fields.get("mode")) + .and_then(|value| value.kind.as_ref()) + { + Some(prost_types::value::Kind::StringValue(mode)) + if mode == "whole" => + { + Script::WholeBody + } + Some(prost_types::value::Kind::StringValue(mode)) + if mode == "stream" => + { + Script::Stream + } + _ => Script::HeadersOnly, + }; + } + if matches!(selected_script, Script::Skip | Script::InvalidSkipReason) { + HttpResponseEventResult { + result: Some( + http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some( + http_response_preflight_result::Action::Skip( + HttpResponsePreflightSkip {}, + ), + ), + reason: if matches!( + selected_script, + Script::InvalidSkipReason + ) { + "x".repeat(MAX_MIDDLEWARE_REASON_BYTES + 1) + } else { + "not selected".into() + }, + reason_code: "path_not_selected".into(), + ..Default::default() + }, + ), + ), + } + } else { + let (body_mode, header_mutations) = match selected_script { + Script::HeadersOnly => ( + HttpResponseBodyMode::HeadersOnly, + vec![write_header("cache-control", "private")], + ), + Script::Stream + | Script::InvalidSequence + | Script::HangBody + | Script::LargeStream + | Script::TrailerMutation + | Script::InvalidTrailerMutation => { + (HttpResponseBodyMode::StreamBytes, Vec::new()) + } + Script::WholeBody => { + (HttpResponseBodyMode::WholeBodyBytes, Vec::new()) + } + Script::Configured + | Script::Skip + | Script::InvalidSkipReason => unreachable!(), + }; + HttpResponseEventResult { + result: Some( + http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some( + http_response_preflight_result::Action::Inspect( + HttpResponsePreflightInspect { + body_mode: body_mode as i32, + header_mutations, + }, + ), + ), + ..Default::default() + }, + ), + ), + } + } + } + http_response_event::Event::Body(body) => { + if matches!(selected_script, Script::HangBody) { + continue; + } + let Some(http_response_body_unit::Payload::Data(data)) = body.payload + else { + break; + }; + let replacement = match selected_script { + Script::Stream + | Script::InvalidSequence + | Script::LargeStream + | Script::TrailerMutation + | Script::InvalidTrailerMutation => data.to_ascii_uppercase(), + Script::WholeBody => [b"whole:".as_slice(), &data].concat(), + Script::HeadersOnly + | Script::Configured + | Script::HangBody + | Script::Skip + | Script::InvalidSkipReason => break, + }; + HttpResponseEventResult { + result: Some(http_response_event_result::Result::BodyResult( + HttpResponseBodyResult { + sequence: if matches!( + selected_script, + Script::InvalidSequence + ) { + body.sequence + 1 + } else { + body.sequence + }, + action: Some(http_response_body_result::Action::Transform( + HttpResponseBodyTransform { + replacement: Some( + http_response_body_transform::Replacement::Data( + replacement, + ), + ), + }, + )), + ..Default::default() + }, + )), + } + } + http_response_event::Event::Trailers(_) => HttpResponseEventResult { + result: Some(http_response_event_result::Result::TrailersResult( + HttpResponseTrailersResult { + trailer_mutations: match selected_script { + Script::TrailerMutation => { + vec![write_header("x-upstream", "changed")] + } + Script::InvalidTrailerMutation => vec![ + write_header("x-upstream", "changed"), + write_header("x-new", "not-allowed"), + ], + _ => Vec::new(), + }, + ..Default::default() + }, + )), + }, + http_response_event::Event::SessionEnd(_) => break, + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + }); + Ok(Box::pin(ReceiverStream::new(receiver))) + } + } + + fn write_header(name: &str, value: &str) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Write(WriteHeader { + name: name.into(), + value: value.into(), + on_existing: ExistingHeaderAction::Overwrite as i32, + })), + } + } + + fn response_manifest(name: &str) -> MiddlewareManifest { + MiddlewareManifest { + name: name.into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpResponse + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + fn entry(on_error: OnError) -> ChainEntry { + ChainEntry { + name: "response".into(), + implementation: "test/response".into(), + order: 0, + config: prost_types::Struct::default(), + on_error, + } + } + + fn configured_entry(name: &str, order: i32, mode: &str) -> ChainEntry { + ChainEntry { + name: name.into(), + implementation: "test/response".into(), + order, + config: prost_types::Struct { + fields: [( + "mode".into(), + prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue(mode.into())), + }, + )] + .into(), + }, + on_error: OnError::FailClosed, + } + } + + fn input(status_code: u16) -> HttpResponsePreflightInput { + HttpResponsePreflightInput { + context: RequestContext { + request_id: "req-1".into(), + sandbox_id: "sandbox-1".into(), + ..Default::default() + }, + target: HttpRequestTarget { + scheme: "https".into(), + host: "example.com".into(), + port: 443, + method: "GET".into(), + path: "/data".into(), + query: String::new(), + }, + status_code, + declared_body_length: None, + headers: vec![HttpHeader { + name: "content-type".into(), + value: "text/plain".into(), + }], + connection_nominated_headers: Vec::new(), + } + } + + #[tokio::test] + async fn headers_only_preflight_applies_end_to_end_mutation() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::HeadersOnly, + })); + let outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("response preflight"); + + assert!(outcome.allowed); + assert_eq!( + outcome + .headers + .iter() + .find(|header| header.name == "cache-control") + .map(|header| header.value.as_str()), + Some("private") + ); + assert!(outcome.session.is_none()); + } + + #[tokio::test] + async fn stream_mode_transforms_lockstep_units_and_preserves_trailers() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Stream, + })); + let mut outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("response preflight"); + let mut session = outcome.session.take().expect("streaming session"); + + assert_eq!( + session + .push_body(b"hello".to_vec()) + .await + .expect("transform stream unit"), + vec![b"HELLO".to_vec()] + ); + let original_trailers = vec![HttpHeader { + name: "x-upstream".into(), + value: "retained".into(), + }]; + let finish = session + .finish(original_trailers.clone()) + .await + .expect("finish stream"); + assert!(finish.body_units.is_empty()); + assert_eq!(finish.trailers, original_trailers); + } + + #[tokio::test] + async fn whole_body_mode_releases_replacement_only_at_finish() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::WholeBody, + })); + let mut outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("response preflight"); + let mut session = outcome.session.take().expect("whole-body session"); + assert!(session.requires_whole_body()); + assert!( + session + .push_body(b"one".to_vec()) + .await + .expect("buffer first unit") + .is_empty() + ); + assert!( + session + .push_body(b"two".to_vec()) + .await + .expect("buffer second unit") + .is_empty() + ); + + let finish = session.finish(Vec::new()).await.expect("finish whole body"); + assert_eq!(finish.body_units, vec![b"whole:onetwo".to_vec()]); + } + + #[tokio::test] + async fn mixed_profile_chain_respects_policy_order_and_whole_body_barrier() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Configured, + })); + let entries = vec![ + configured_entry("stream", 20, "stream"), + configured_entry("whole", 10, "whole"), + ]; + let mut outcome = runner + .preflight_http_response(&entries, input(200)) + .await + .expect("mixed response preflight"); + let mut session = outcome.session.take().expect("mixed response session"); + assert!(session.requires_whole_body()); + assert!( + session + .push_body(b"hello".to_vec()) + .await + .expect("buffer mixed response") + .is_empty() + ); + let finish = session + .finish(Vec::new()) + .await + .expect("finish mixed chain"); + assert_eq!(finish.body_units, vec![b"WHOLE:HELLO".to_vec()]); + } + + #[tokio::test] + async fn whole_body_overflow_obeys_fail_open_and_fail_closed() { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::WholeBody, + })); + let mut outcome = runner + .preflight_http_response(&[entry(on_error)], input(200)) + .await + .expect("whole-body response preflight"); + let mut session = outcome.session.take().expect("whole-body session"); + let original = vec![b'a'; 4097]; + let pushed = session.push_body(original.clone()).await; + assert_eq!(pushed.is_ok(), allowed); + if allowed { + assert_eq!(pushed.unwrap(), vec![original]); + assert!(!session.requires_whole_body()); + for fill in [b'b', b'c'] { + let unit = vec![fill; MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES]; + assert_eq!( + session + .push_body(unit.clone()) + .await + .expect("fail-open stage must release later units"), + vec![unit] + ); + } + let finish = session.finish(Vec::new()).await.expect("fail-open finish"); + assert!(finish.body_units.is_empty()); + } + } + } + + #[tokio::test] + async fn response_body_timeout_obeys_fail_open_and_fail_closed() { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::HangBody, + })); + let mut outcome = runner + .preflight_http_response(&[entry(on_error)], input(200)) + .await + .expect("timed response preflight"); + let mut session = outcome.session.take().expect("timed response session"); + let result = session.push_body(b"unchanged".to_vec()).await; + assert_eq!(result.is_ok(), allowed); + if let Ok(units) = result { + assert_eq!(units, vec![b"unchanged".to_vec()]); + } + } + } + + #[tokio::test] + async fn stream_unit_limit_never_exceeds_platform_cap() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::LargeStream, + })); + let mut outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("large stream preflight"); + let mut session = outcome.session.take().expect("large stream session"); + assert_eq!( + session.stream_unit_limit(), + MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES + ); + let maximum_unit = vec![b'A'; MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES]; + assert_eq!( + session + .push_body(maximum_unit.clone()) + .await + .expect("maximum stream unit"), + vec![maximum_unit] + ); + assert_eq!( + session + .push_body(vec![b'b'; MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES + 1]) + .await + .expect_err("oversized stream unit") + .reason, + "response_stream_unit_over_capacity" + ); + session + .finish(Vec::new()) + .await + .expect("finish large stream"); + } + + #[tokio::test] + async fn skip_reason_code_is_retained_and_oversized_reason_obeys_on_error() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Skip, + })); + let outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("skip response preflight"); + assert!(outcome.allowed); + assert!(outcome.session.is_none()); + assert_eq!( + outcome.invocations[0].reason_code.as_deref(), + Some("path_not_selected") + ); + + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::InvalidSkipReason, + })); + let outcome = runner + .preflight_http_response(&[entry(on_error)], input(200)) + .await + .expect("invalid skip response preflight"); + assert_eq!(outcome.allowed, allowed); + assert!(outcome.session.is_none()); + } + } + + #[tokio::test] + async fn response_trailers_are_mutated_by_body_stage() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::TrailerMutation, + })); + let mut outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .expect("trailer response preflight"); + let mut session = outcome.session.take().expect("trailer response session"); + session + .push_body(b"body".to_vec()) + .await + .expect("transform response body"); + let trailers = vec![HttpHeader { + name: "x-upstream".into(), + value: "retained".into(), + }]; + let finish = session + .finish(trailers.clone()) + .await + .expect("finish response"); + assert_eq!( + finish.trailers, + vec![HttpHeader { + name: "x-upstream".into(), + value: "changed".into(), + }] + ); + } + + #[tokio::test] + async fn invalid_trailer_mutations_are_atomic_and_keep_failure_diagnostics() { + let trailers = vec![HttpHeader { + name: "x-upstream".into(), + value: "retained".into(), + }]; + for on_error in [OnError::FailOpen, OnError::FailClosed] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::InvalidTrailerMutation, + })); + let mut outcome = runner + .preflight_http_response(&[entry(on_error)], input(200)) + .await + .expect("invalid trailer response preflight"); + let mut session = outcome.session.take().expect("trailer response session"); + session + .push_body(b"body".to_vec()) + .await + .expect("response body exchange"); + session.take_diagnostics(); + + match session.finish(trailers.clone()).await { + Ok(finish) => { + assert_eq!(on_error, OnError::FailOpen); + assert_eq!(finish.trailers, trailers); + assert_eq!( + finish.invocations.last().map(|entry| entry.outcome), + Some(HttpResponseInvocationOutcome::FailOpen) + ); + } + Err(failure) => { + assert_eq!(on_error, OnError::FailClosed); + assert_eq!( + failure + .diagnostics + .invocations + .last() + .map(|entry| entry.outcome), + Some(HttpResponseInvocationOutcome::FailClosed) + ); + } + } + } + } + + #[tokio::test] + async fn invalid_sequence_obeys_fail_open_and_fail_closed() { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::InvalidSequence, + })); + let mut outcome = runner + .preflight_http_response(&[entry(on_error)], input(200)) + .await + .expect("response preflight"); + let mut session = outcome.session.take().expect("stream session"); + let result = session.push_body(b"unchanged".to_vec()).await; + assert_eq!(result.is_ok(), allowed); + if let Ok(units) = result { + assert_eq!(units, vec![b"unchanged".to_vec()]); + } + } + } + + #[tokio::test] + async fn body_inspection_restrictions_obey_fail_open_and_fail_closed() { + let mut cases = Vec::new(); + cases.push(input(206)); + for (name, value) in [ + ("content-range", "bytes 0-3/10"), + ("content-type", "multipart/byteranges; boundary=test"), + ("cache-control", "private, no-transform"), + ("content-encoding", "gzip"), + ] { + let mut candidate = input(200); + candidate.headers.push(HttpHeader { + name: name.into(), + value: value.into(), + }); + cases.push(candidate); + } + for status in [204, 304] { + cases.push(input(status)); + } + let mut head = input(200); + head.target.method = "HEAD".into(); + cases.push(head); + + for candidate in cases { + for (on_error, allowed) in [(OnError::FailOpen, true), (OnError::FailClosed, false)] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Stream, + })); + let outcome = runner + .preflight_http_response(&[entry(on_error)], candidate.clone()) + .await + .expect("restricted response preflight"); + assert_eq!(outcome.allowed, allowed); + assert!(outcome.session.is_none()); + } + } + } + + #[tokio::test] + async fn remote_service_executes_through_http_response_pre_return_rpc() { + use openshell_core::proto::middleware::v1::http_response_pre_return_server::HttpResponsePreReturnServer; + use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddlewareServer; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind response middleware"); + let address = listener.local_addr().expect("response middleware address"); + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let server = tonic::transport::Server::builder() + .add_service(SupervisorMiddlewareServer::new(RemoteResponseService)) + .add_service(HttpResponsePreReturnServer::new(RemoteResponseService)) + .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { + let _ = shutdown_rx.await; + }); + let server_task = tokio::spawn(server); + let registry = super::super::MiddlewareRegistry::connect_services( + Vec::new(), + vec![openshell_core::proto::SupervisorMiddlewareService { + name: "remote-response".into(), + grpc_endpoint: format!("http://{address}"), + max_payload_bytes: 4096, + allow_insecure_transport: true, + ..Default::default() + }], + ) + .await + .expect("connect remote response middleware"); + let runner = ChainRunner::from_registry(registry); + let outcome = runner + .preflight_http_response( + &[ChainEntry { + name: "response".into(), + implementation: "remote-response".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }], + input(200), + ) + .await + .expect("remote response preflight"); + + assert!(outcome.allowed); + assert_eq!( + outcome + .headers + .iter() + .find(|header| header.name == "cache-control") + .map(|header| header.value.as_str()), + Some("remote") + ); + assert!(outcome.session.is_none()); + + let _ = shutdown_tx.send(()); + server_task + .await + .expect("join response middleware server") + .expect("serve response middleware"); + } +} diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 6305653f6a..f8a22a9bb9 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -197,7 +197,7 @@ pub(super) fn websocket_message_finding_events( middleware_finding_events(&outcome.findings) } -fn middleware_finding_events( +pub(super) fn middleware_finding_events( findings: &[openshell_supervisor_middleware::NamespacedFinding], ) -> Vec { findings @@ -423,7 +423,8 @@ pub(super) fn middleware_chain_body_limit( .max() } -pub async fn apply_middleware_chain( +#[allow(clippy::too_many_arguments)] +pub async fn apply_middleware_chain_with_request_id( req: crate::l7::provider::L7Request, client: &mut C, ctx: &L7EvalContext, @@ -431,8 +432,9 @@ pub async fn apply_middleware_chain( runner: &openshell_supervisor_middleware::ChainRunner, generation_guard: &PolicyGenerationGuard, transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, + request_id: &str, ) -> Result { - apply_middleware_chain_for_scheme( + apply_middleware_chain_for_scheme_with_request_id( req, client, ctx, @@ -441,12 +443,15 @@ pub async fn apply_middleware_chain( runner, generation_guard, transformed_body_policy, + request_id, ) .await } #[allow(clippy::too_many_arguments)] -pub async fn apply_middleware_chain_for_scheme( +pub async fn apply_middleware_chain_for_scheme_with_request_id< + C: AsyncRead + AsyncWrite + Unpin + Send, +>( req: crate::l7::provider::L7Request, client: &mut C, ctx: &L7EvalContext, @@ -455,6 +460,7 @@ pub async fn apply_middleware_chain_for_scheme, + request_id: &str, ) -> Result { if chain.is_empty() { return Ok(MiddlewareApplyResult::Allowed(req)); @@ -479,7 +485,7 @@ pub async fn apply_middleware_chain_for_scheme, query: String, body: Vec, + request_id: &str, ) -> openshell_supervisor_middleware::HttpRequestInput { openshell_supervisor_middleware::HttpRequestInput { - request_id: uuid::Uuid::new_v4().to_string(), + request_id: request_id.to_string(), sandbox_id: sandbox.sandbox_id.clone(), sandbox_name: sandbox.sandbox_name.clone(), workspace: ctx.workspace.clone(), @@ -637,6 +646,32 @@ pub(super) fn middleware_request_input( } } +#[cfg(test)] +#[allow(clippy::too_many_arguments)] +pub(super) fn middleware_request_input( + sandbox: &openshell_ocsf::SandboxContext, + scheme: &str, + req: &crate::l7::provider::L7Request, + ctx: &L7EvalContext, + headers: Vec<(String, String)>, + connection_nominated_headers: Vec, + query: String, + body: Vec, +) -> openshell_supervisor_middleware::HttpRequestInput { + let request_id = uuid::Uuid::new_v4().to_string(); + middleware_request_input_with_id( + sandbox, + scheme, + req, + ctx, + headers, + connection_nominated_headers, + query, + body, + &request_id, + ) +} + 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"))?; @@ -1116,7 +1151,7 @@ mod tests { body_length: crate::l7::provider::BodyLength::None, }; - let input = super::middleware_request_input( + let input = super::middleware_request_input_with_id( &sandbox, "https", &req, @@ -1125,11 +1160,13 @@ mod tests { Vec::new(), String::new(), Vec::new(), + "exchange-123", ); assert_eq!(input.sandbox_name, "nightly-build"); assert_eq!(input.sandbox_id, "sbx-123"); assert_eq!(input.workspace, "wrks-default"); + assert_eq!(input.request_id, "exchange-123"); } #[tokio::test] diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 9e4949b191..2a5de4d6a8 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -8,7 +8,7 @@ //! and either forwards or denies the request. use crate::l7::middleware::{ - MiddlewareApplyResult, UninspectableTrafficGate, apply_middleware_chain, + MiddlewareApplyResult, UninspectableTrafficGate, apply_middleware_chain_with_request_id, emit_middleware_uninspectable, middleware_network_input, uninspectable_traffic_gate, }; #[cfg(test)] @@ -288,13 +288,20 @@ async fn relay_http_request_with_credential_rejection( upstream: &mut U, options: crate::l7::rest::RelayRequestOptions<'_>, ctx: &L7EvalContext, + response_middleware: Option>, ) -> Result> where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, { - match crate::l7::rest::relay_http_request_with_options_guarded( - request, client, upstream, options, + match Box::pin( + crate::l7::rest::relay_http_request_with_response_middleware_guarded( + request, + client, + upstream, + options, + response_middleware, + ), ) .await { @@ -310,6 +317,90 @@ where } } +pub(crate) fn http_response_middleware_relay<'a>( + request: &crate::l7::provider::L7Request, + ctx: &'a L7EvalContext, + scheme: &str, + request_id: &str, + chain: &'a [openshell_supervisor_middleware::ChainEntry], + runner: &'a openshell_supervisor_middleware::ChainRunner, + generation_guard: Option<&'a PolicyGenerationGuard>, +) -> crate::l7::rest::HttpResponseMiddlewareRelay<'a> { + let sandbox = openshell_ocsf::ctx::ctx(); + crate::l7::rest::HttpResponseMiddlewareRelay { + chain, + runner, + request_context: openshell_core::proto::RequestContext { + request_id: request_id.to_string(), + sandbox_id: sandbox.sandbox_id.clone(), + sandbox_name: sandbox.sandbox_name.clone(), + workspace: ctx.workspace.clone(), + originating_process: None, + }, + target: openshell_core::proto::HttpRequestTarget { + scheme: scheme.to_string(), + host: ctx.host.clone(), + port: u32::from(ctx.port), + method: request.action.clone(), + path: request.target.clone(), + query: policy_safe_response_query(&request.query_params), + }, + policy_name: &ctx.policy_name, + generation_guard, + whole_body_timeout: crate::http_response_whole_body_timeout(), + } +} + +fn policy_safe_response_query( + query_params: &std::collections::HashMap>, +) -> String { + let mut parameters: Vec<_> = query_params.iter().collect(); + parameters.sort_by_key(|(name, _)| *name); + let mut output = String::new(); + for (name, values) in parameters { + let empty_value = String::new(); + let values = if values.is_empty() { + std::slice::from_ref(&empty_value) + } else { + values.as_slice() + }; + for value in values { + if !output.is_empty() { + output.push('&'); + } + let name = if secrets::contains_reserved_credential_marker(name) { + "[REDACTED]" + } else { + name + }; + let value = if secrets::contains_reserved_credential_marker(value) { + "[REDACTED]" + } else { + value + }; + push_form_component(&mut output, name); + output.push('='); + push_form_component(&mut output, value); + } + } + output +} + +fn push_form_component(output: &mut String, value: &str) { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + for byte in value.bytes() { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') { + output.push(char::from(byte)); + } else if byte == b' ' { + output.push('+'); + } else { + output.push('%'); + output.push(char::from(HEX[usize::from(byte >> 4)])); + output.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + } +} + #[derive(Default)] pub(crate) struct UpgradeRelayOptions<'a> { pub(crate) websocket_request: bool, @@ -736,13 +827,15 @@ where if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let response_chain = chain.clone(); + let request_id = uuid::Uuid::new_v4().to_string(); let websocket_chain = websocket_request.then(|| chain.clone()); // 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 middleware_result = apply_middleware_chain( + let middleware_result = apply_middleware_chain_with_request_id( req, client, ctx, @@ -750,6 +843,7 @@ where engine.middleware_runner(), engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), + &request_id, ) .await; let req = match middleware_result? { @@ -848,6 +942,15 @@ where port: ctx.port, }, ctx, + Some(http_response_middleware_relay( + &req, + ctx, + "https", + &request_id, + &response_chain, + engine.middleware_runner(), + Some(engine.generation_guard()), + )), ) .await; let outcome_result = match outcome_result { @@ -1461,11 +1564,13 @@ where if allowed || config.enforcement == EnforcementMode::Audit { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let response_chain = chain.clone(); + let request_id = uuid::Uuid::new_v4().to_string(); let websocket_chain = websocket_request.then(|| chain.clone()); // 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 middleware_result = apply_middleware_chain( + let middleware_result = apply_middleware_chain_with_request_id( req, client, ctx, @@ -1473,6 +1578,7 @@ where engine.middleware_runner(), engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + &request_id, ) .await; let req = match middleware_result? { @@ -1587,6 +1693,15 @@ where port: ctx.port, }, ctx, + Some(http_response_middleware_relay( + &req_with_auth, + ctx, + "https", + &request_id, + &response_chain, + engine.middleware_runner(), + Some(engine.generation_guard()), + )), ) .await; let outcome_result = match outcome_result { @@ -1875,12 +1990,14 @@ where if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let response_chain = chain.clone(); + let request_id = uuid::Uuid::new_v4().to_string(); // 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( + let req = match apply_middleware_chain_with_request_id( req, client, ctx, @@ -1888,6 +2005,7 @@ where engine.middleware_runner(), engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), + &request_id, ) .await? { @@ -1946,6 +2064,15 @@ where ..Default::default() }, ctx, + Some(http_response_middleware_relay( + &req, + ctx, + "https", + &request_id, + &response_chain, + engine.middleware_runner(), + Some(engine.generation_guard()), + )), ) .await? else { @@ -2115,12 +2242,14 @@ where if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; + let response_chain = chain.clone(); + let request_id = uuid::Uuid::new_v4().to_string(); // 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( + let req = match apply_middleware_chain_with_request_id( req, client, ctx, @@ -2128,6 +2257,7 @@ where engine.middleware_runner(), engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), + &request_id, ) .await? { @@ -2181,6 +2311,15 @@ where ..Default::default() }, ctx, + Some(http_response_middleware_relay( + &req, + ctx, + "https", + &request_id, + &response_chain, + engine.middleware_runner(), + Some(engine.generation_guard()), + )), ) .await? else { @@ -2733,6 +2872,8 @@ where ocsf_emit!(event); } + let request_id = uuid::Uuid::new_v4().to_string(); + let mut response_selection = None; let req = if let Some(engine) = middleware_engine { let input = middleware_network_input(ctx); let (chain, generation) = engine.query_middleware_chain_with_generation(&input)?; @@ -2740,9 +2881,10 @@ where return Ok(()); } let runner = engine.middleware_runner()?; + response_selection = Some((chain.clone(), runner.clone())); // The passthrough path enforces no L7 policy, so there is no // body-aware decision to re-check after a transformation. - match apply_middleware_chain( + match apply_middleware_chain_with_request_id( req, client, ctx, @@ -2750,6 +2892,7 @@ where &runner, generation_guard, openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + &request_id, ) .await? { @@ -2811,6 +2954,17 @@ where let scoped_ctx = scoped_context_for_request(ctx, &req_with_auth); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); let resolver = ctx.secret_resolver.as_deref(); + let response_middleware = response_selection.as_ref().map(|(chain, runner)| { + http_response_middleware_relay( + &req_with_auth, + ctx, + "http", + &request_id, + chain, + runner, + Some(generation_guard), + ) + }); // Forward request with credential rewriting and relay the response. // relay_http_request_with_resolver handles both directions: it sends @@ -2826,6 +2980,7 @@ where ..Default::default() }, ctx, + response_middleware, ) .await? else { @@ -3132,6 +3287,7 @@ mod tests { ..options }, &ctx, + None, ) .await .expect("typed credential denial"); @@ -6055,7 +6211,7 @@ network_policies: 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( + let result = crate::l7::middleware::apply_middleware_chain_for_scheme_with_request_id( req, &mut relay_client, &ctx, @@ -6064,6 +6220,7 @@ network_policies: &runner, tunnel_engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + "test-request-id", ) .await .expect("apply middleware chain"); @@ -6191,6 +6348,50 @@ network_policies: assert_eq!(input.scheme, "http"); } + #[test] + fn response_middleware_context_reuses_exchange_request_id() { + let req = crate::l7::provider::L7Request { + action: "GET".into(), + target: "/v1/data".into(), + query_params: std::collections::HashMap::from([ + ("cursor".into(), vec!["next page".into()]), + ( + "token".into(), + vec!["openshell:resolve:env:API_TOKEN".into()], + ), + ]), + raw_header: b"GET /v1/data?cursor=next+page&token=sk-live-secret HTTP/1.1\r\nHost: api.example.test\r\n\r\n".to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + workspace: "workspace-1".into(), + policy_name: "api".into(), + ..Default::default() + }; + let runner = openshell_supervisor_middleware::ChainRunner::default(); + let chain = Vec::new(); + let response = http_response_middleware_relay( + &req, + &ctx, + "https", + "exchange-123", + &chain, + &runner, + None, + ); + + assert_eq!(response.request_context.request_id, "exchange-123"); + assert_eq!( + response.target.query, + "cursor=next+page&token=%5BREDACTED%5D" + ); + assert!(!response.target.query.contains("sk-live-secret")); + assert!(!response.target.query.contains("API_TOKEN")); + assert_eq!(response.target.scheme, "https"); + } + #[test] fn middleware_ocsf_events_are_audit_safe() { use openshell_supervisor_middleware::{ diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 93315a671a..ec0da7d135 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -12,7 +12,10 @@ 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::proto::{ + ExistingHeaderAction, HeaderMutation, HttpHeader, HttpRequestTarget, RequestContext, + header_mutation, +}; use openshell_core::secrets::{ CREDENTIAL_MARKER_SCAN_TAIL_BYTES, SecretResolver, contains_reserved_credential_marker, contains_reserved_credential_marker_bytes, rewrite_http_header_block, @@ -20,7 +23,7 @@ use openshell_core::secrets::{ use openshell_ocsf::ctx::ctx as ocsf_ctx; use sha1::{Digest, Sha1}; use std::collections::{HashMap, HashSet}; -use std::fmt; +use std::fmt::{self, Write as _}; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::debug; @@ -49,6 +52,7 @@ async fn max_middleware_body_bytes() -> usize { chain[0].max_payload_bytes() } const RELAY_BUF_SIZE: usize = 8192; +const RESPONSE_UNIT_COALESCE_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(2); const HTTP_METHOD_PREFIXES: &[&[u8]] = &[ b"GET ", b"HEAD ", @@ -798,6 +802,31 @@ pub(crate) async fn relay_http_request_with_options_guarded( upstream: &mut U, options: RelayRequestOptions<'_>, ) -> Result +where + C: AsyncRead + AsyncWrite + Unpin, + U: AsyncRead + AsyncWrite + Unpin, +{ + relay_http_request_with_response_middleware_guarded(req, client, upstream, options, None).await +} + +/// Context retained from request evaluation for the matching response hook. +pub(crate) struct HttpResponseMiddlewareRelay<'a> { + pub(crate) chain: &'a [openshell_supervisor_middleware::ChainEntry], + pub(crate) runner: &'a openshell_supervisor_middleware::ChainRunner, + pub(crate) request_context: RequestContext, + pub(crate) target: HttpRequestTarget, + pub(crate) policy_name: &'a str, + pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>, + pub(crate) whole_body_timeout: std::time::Duration, +} + +pub(crate) async fn relay_http_request_with_response_middleware_guarded( + req: &L7Request, + client: &mut C, + upstream: &mut U, + options: RelayRequestOptions<'_>, + response_middleware: Option>, +) -> Result where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, @@ -1152,6 +1181,7 @@ where websocket: websocket_response, client_requested_upgrade, }, + response_middleware, ) .await?; @@ -3124,6 +3154,7 @@ async fn relay_response( upstream: &mut U, client: &mut C, options: RelayResponseOptions, + response_middleware: Option>, ) -> Result where U: AsyncRead + Unpin, @@ -3133,7 +3164,8 @@ where let mut buf = Vec::with_capacity(4096); let mut tmp = [0u8; 1024]; - // Read response headers + // Read response headers. Forward interim responses unchanged, but retain + // the final response head until response middleware preflight completes. loop { if buf.len() > MAX_HEADER_BYTES { return Err(miette!("HTTP response headers exceed limit")); @@ -3149,6 +3181,21 @@ where } buf.extend_from_slice(&tmp[..n]); + while let Some(position) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + let header_end = position + 4; + let header_str = String::from_utf8_lossy(&buf[..header_end]); + let status_code = parse_status_code(&header_str).unwrap_or(200); + if (100..200).contains(&status_code) && status_code != 101 { + client + .write_all(&buf[..header_end]) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + buf.drain(..header_end); + continue; + } + break; + } if buf.windows(4).any(|w| w == b"\r\n\r\n") { break; } @@ -3204,6 +3251,24 @@ where }); } + if let Some(response_middleware) = response_middleware + && let Some(outcome) = Box::pin(relay_response_through_middleware( + request_method, + upstream, + client, + response_middleware, + &buf, + header_end, + status_code, + body_length, + server_wants_close, + event_stream, + )) + .await? + { + return Ok(outcome); + } + // Bodiless responses (HEAD, 1xx, 204, 304): forward headers only, skip body if is_bodiless_response(request_method, status_code) { client @@ -3292,584 +3357,2342 @@ where Ok(RelayOutcome::Reusable) } -/// Parse the HTTP status code from a response status line. -/// -/// Expects the first line to look like `HTTP/1.1 200 OK`. -fn parse_status_code(headers: &str) -> Option { - let status_line = headers.lines().next()?; - let code_str = status_line.split_whitespace().nth(1)?; - code_str.parse().ok() -} - -/// Check if the response headers contain `Connection: close`. -fn parse_connection_close(headers: &str) -> bool { - for line in headers.lines().skip(1) { - let lower = line.to_ascii_lowercase(); - if lower.starts_with("connection:") { - let val = lower.split_once(':').map_or("", |(_, v)| v.trim()); - return val.contains("close"); +#[allow(clippy::too_many_arguments)] +async fn relay_response_through_middleware( + request_method: &str, + upstream: &mut U, + client: &mut C, + middleware: HttpResponseMiddlewareRelay<'_>, + buffered: &[u8], + header_end: usize, + status_code: u16, + body_length: BodyLength, + server_wants_close: bool, + event_stream: bool, +) -> Result> +where + U: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + if let Some(guard) = middleware.generation_guard { + guard.ensure_current()?; + } + let header_bytes = &buffered[..header_end]; + let parsed = match parse_response_head_for_middleware(header_bytes) { + Ok(parsed) => parsed, + Err(error) => { + debug!(error = %error, "HTTP response head normalization failed"); + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + let original_headers = parsed.headers.clone(); + let upstream_declared_trailers = parsed.declared_trailers.clone(); + let connection_nominated_headers = parsed.connection_nominated.clone(); + let input = openshell_supervisor_middleware::HttpResponsePreflightInput { + context: middleware.request_context, + target: middleware.target.clone(), + status_code, + declared_body_length: match body_length { + BodyLength::ContentLength(length) => Some(length), + BodyLength::Chunked | BodyLength::None => None, + }, + headers: parsed.headers, + connection_nominated_headers: parsed.connection_nominated, + }; + let preflight = match middleware + .runner + .preflight_http_response(middleware.chain, input) + .await + { + Ok(preflight) => preflight, + Err(error) => { + debug!(error = %error, "HTTP response middleware preflight failed"); + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + debug!( + configured_stage_count = middleware.chain.len(), + active_session = preflight.session.is_some(), + allowed = preflight.allowed, + "HTTP response middleware preflight completed" + ); + for event in crate::l7::middleware::middleware_finding_events(&preflight.findings) { + openshell_ocsf::ocsf_emit!(event); + } + if !preflight.allowed { + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &preflight.invocations, + ); + if let Some(denial) = preflight.denial.as_ref() { + send_response_middleware_denial( + client, + request_method, + middleware.policy_name, + &middleware.target, + denial, + ) + .await?; + } else { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; } + return Ok(Some(RelayOutcome::Consumed)); } - false -} - -fn response_is_event_stream(headers: &str) -> bool { - headers.lines().skip(1).any(|line| { - let lower = line.to_ascii_lowercase(); - let Some(value) = lower.strip_prefix("content-type:") else { - return false; - }; - value - .split(';') - .next() - .is_some_and(|mime| mime.trim() == "text/event-stream") - }) -} + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &preflight.invocations, + ); -fn validate_websocket_response( - headers: &str, - mode: WebSocketExtensionMode, - websocket: Option<&WebSocketResponseValidation>, -) -> Result<(bool, Option)> { - let Some(validation) = websocket else { - return validate_websocket_response_extensions_preserved(headers, mode) - .map(|compressed| (compressed, None)); + let status_line = response_status_line(header_bytes)?; + let Some(mut session) = preflight.session else { + if preflight.headers == original_headers { + return Ok(None); + } + let outcome = relay_headers_only_response( + request_method, + upstream, + client, + &status_line, + &preflight.headers, + &upstream_declared_trailers, + &buffered[header_end..], + status_code, + body_length, + server_wants_close, + event_stream, + ) + .await?; + return Ok(Some(outcome)); }; - let mut upgrade_websocket = false; - let mut connection_upgrade = false; - let mut accept_count = 0usize; - let mut accept_matches = false; - let mut subprotocol_count = 0usize; - let mut selected_subprotocol = None; - - for line in headers.lines().skip(1) { - let Some((name, value)) = line.split_once(':') else { - continue; - }; - let name = name.trim().to_ascii_lowercase(); - let value = value.trim(); - match name.as_str() { - "upgrade" if header_value_contains_token(value, "websocket") => { - upgrade_websocket = true; - } - "connection" if header_value_contains_token(value, "upgrade") => { - connection_upgrade = true; - } - "sec-websocket-accept" => { - accept_count += 1; - accept_matches = value == validation.expected_accept; - } - "sec-websocket-protocol" => { - subprotocol_count += 1; - if !is_http_token(value) { - return Err(miette!( - "websocket upgrade response has invalid Sec-WebSocket-Protocol" - )); - } - selected_subprotocol = Some(value.to_string()); + let supports_chunked_response = !status_line.starts_with("HTTP/1.0 "); + let bodiless = is_bodiless_response(request_method, status_code); + if bodiless { + let finish = match session.finish(Vec::new()).await { + Ok(finish) => finish, + Err(error) => { + debug!(error = %error, "HTTP response middleware finalization failed"); + emit_http_response_diagnostics( + middleware.policy_name, + &middleware.target, + status_code, + &error.diagnostics, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); } - _ => {} - } + }; + let mut headers = preflight.headers; + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &finish.invocations, + ); + for event in crate::l7::middleware::middleware_finding_events(&finish.findings) { + openshell_ocsf::ocsf_emit!(event); + } + if finish.strip_stale_integrity_headers { + strip_response_integrity_headers(&mut headers); + } + let head = serialize_response_head( + &status_line, + &headers, + ResponseFraming::Preserve(body_length), + server_wants_close, + &[], + ); + client.write_all(&head).await.into_diagnostic()?; + client.flush().await.into_diagnostic()?; + return Ok(Some(if server_wants_close { + RelayOutcome::Consumed + } else { + RelayOutcome::Reusable + })); } - if !upgrade_websocket { - return Err(miette!( - "websocket upgrade response missing Upgrade: websocket" - )); - } - if !connection_upgrade { - return Err(miette!( - "websocket upgrade response missing Connection: Upgrade" - )); - } - if accept_count != 1 || !accept_matches { - return Err(miette!( - "websocket upgrade response has invalid Sec-WebSocket-Accept" - )); + let whole_body = session.requires_whole_body(); + if whole_body { + session.start_whole_body_deadline(middleware.whole_body_timeout); } - if subprotocol_count > 1 { - return Err(miette!( - "websocket upgrade response has multiple Sec-WebSocket-Protocol headers" - )); - } - if let Some(ref protocol) = selected_subprotocol - && !validation - .offered_subprotocols - .iter() - .any(|offered| offered == protocol) - { - return Err(miette!( - "upstream selected WebSocket subprotocol that was not offered" - )); + let unit_limit = session.stream_unit_limit().max(1); + let chunked_output = supports_chunked_response; + let close_delimited_output = !supports_chunked_response; + let declared_trailers = upstream_declared_trailers; + let downstream_trailers = if chunked_output { + declared_trailers.as_slice() + } else { + &[] + }; + let streaming_head = serialize_response_head( + &status_line, + &preflight.headers, + if chunked_output { + ResponseFraming::Chunked + } else { + ResponseFraming::Preserve(BodyLength::None) + }, + server_wants_close || close_delimited_output, + downstream_trailers, + ); + let mut committed = !whole_body; + if committed { + if let Err(error) = client.write_all(&streaming_head).await { + session + .end(openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect) + .await; + return Err(error).into_diagnostic(); + } + if let Err(error) = client.flush().await { + session + .end(openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect) + .await; + return Err(error).into_diagnostic(); + } } - let actual_extension = normalized_websocket_extension(headers)?; - match (&validation.expected_extension, actual_extension.as_deref()) { - (None, Some(_)) => Err(miette!( - "upstream negotiated WebSocket extension that was not offered" - )), - (None | Some(_), None) => Ok((false, selected_subprotocol)), - (Some(expected), Some(actual)) if expected.eq_ignore_ascii_case(actual) => { - Ok((true, selected_subprotocol)) + let mut reader = BufferedResponseReader::new(upstream, &buffered[header_end..]); + let body_result = relay_normalized_response_body( + &mut reader, + &mut session, + client, + body_length, + server_wants_close, + event_stream, + &mut committed, + chunked_output, + &streaming_head, + unit_limit, + middleware.generation_guard, + middleware.policy_name, + &middleware.target, + status_code, + &connection_nominated_headers, + ) + .await; + let trailers = match body_result { + Ok(trailers) => trailers, + Err(error) => { + let middleware_stop = error.downcast_ref::(); + let end_reason = if middleware + .generation_guard + .is_some_and(PolicyGenerationGuard::is_stale) + { + openshell_core::proto::MiddlewareSessionEndReason::PolicyReload + } else if error + .to_string() + .starts_with("HTTP response client write failed:") + { + openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect + } else if middleware_stop.is_some_and(|stop| stop.failure.denial.is_some()) { + openshell_core::proto::MiddlewareSessionEndReason::MiddlewareDenial + } else if middleware_stop.is_some() { + openshell_core::proto::MiddlewareSessionEndReason::MiddlewareFailure + } else { + openshell_core::proto::MiddlewareSessionEndReason::UpstreamDisconnect + }; + session.end(end_reason).await; + if committed { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + true, + ); + return Err(error); + } + debug!(error = %error, "HTTP response processing failed before commitment"); + if let Some(denial) = middleware_stop.and_then(|stop| stop.failure.denial.as_ref()) { + send_response_middleware_denial( + client, + request_method, + middleware.policy_name, + &middleware.target, + denial, + ) + .await?; + } else { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + } + return Ok(Some(RelayOutcome::Consumed)); } - (Some(_), Some(_)) => Err(miette!( - "upstream negotiated WebSocket extension that does not match the safe offer" - )), + }; + + if let Some(guard) = middleware.generation_guard + && let Err(error) = guard.ensure_current() + { + session + .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) + .await; + return Err(error); } -} -fn validate_websocket_response_extensions_preserved( - headers: &str, - mode: WebSocketExtensionMode, -) -> Result { - match mode { - WebSocketExtensionMode::Preserve => Ok(false), - WebSocketExtensionMode::PermessageDeflate => { - let offers = websocket_extension_offers(headers)?; - if offers.is_empty() { - Ok(false) + let finish = match session.finish(trailers).await { + Ok(finish) => finish, + Err(error) => { + emit_http_response_diagnostics( + middleware.policy_name, + &middleware.target, + status_code, + &error.diagnostics, + ); + if committed { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + true, + ); + return Err(miette!( + "HTTP response middleware failed after commitment: {error}" + )); + } + debug!(error = %error, "HTTP response middleware failed before commitment"); + if let Some(denial) = error.denial.as_ref() { + send_response_middleware_denial( + client, + request_method, + middleware.policy_name, + &middleware.target, + denial, + ) + .await?; } else { - Err(miette!( - "upstream negotiated WebSocket extension that was not offered" - )) + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; } + return Ok(Some(RelayOutcome::Consumed)); } + }; + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &finish.invocations, + ); + for event in crate::l7::middleware::middleware_finding_events(&finish.findings) { + openshell_ocsf::ocsf_emit!(event); } -} -fn normalized_websocket_extension(headers: &str) -> Result> { - let offers = websocket_extension_offers(headers)?; - if offers.is_empty() { - return Ok(None); - } - if offers.len() != 1 { - return Err(miette!("upstream negotiated multiple WebSocket extensions")); - } - let offer = &offers[0]; - if !offer.name.eq_ignore_ascii_case("permessage-deflate") { - return Err(miette!( - "upstream negotiated unsupported WebSocket extension" - )); - } - let mut client_no_context_takeover = false; - let mut server_no_context_takeover = false; - let mut seen = HashSet::new(); - for param in &offer.params { - let name = param.name.to_ascii_lowercase(); - if param.value.is_some() || !seen.insert(name.clone()) { - return Err(miette!( - "upstream negotiated unsupported permessage-deflate parameter" - )); + if whole_body && !committed { + let mut headers = preflight.headers; + if finish.strip_stale_integrity_headers { + strip_response_integrity_headers(&mut headers); } - if name == "client_no_context_takeover" { - client_no_context_takeover = true; - } else if name == "server_no_context_takeover" { - server_no_context_takeover = true; + let output_length = finish + .body_units + .iter() + .try_fold(0usize, |total, unit| total.checked_add(unit.len())) + .ok_or_else(|| miette!("HTTP response middleware output length overflow"))?; + let framing = if finish.trailers.is_empty() || !supports_chunked_response { + ResponseFraming::ContentLength(output_length as u64) } else { - return Err(miette!( - "upstream negotiated unsupported permessage-deflate parameter" - )); - } - } - let mut normalized = String::from("permessage-deflate"); - if client_no_context_takeover { - normalized.push_str("; client_no_context_takeover"); - } - if server_no_context_takeover { - normalized.push_str("; server_no_context_takeover"); - } - Ok(Some(normalized)) -} - -/// Check if the client request headers contain both `Upgrade` and -/// `Connection: Upgrade` headers, indicating the client requested a -/// protocol upgrade (e.g. WebSocket). -/// -/// Per RFC 9110 Section 7.8, a server MUST NOT send 101 Switching Protocols -/// unless the client sent these headers. -fn client_requested_upgrade(headers: &str) -> bool { - let mut has_upgrade_header = false; - let mut connection_contains_upgrade = false; - - for line in headers.lines().skip(1) { - let lower = line.to_ascii_lowercase(); - if lower.starts_with("upgrade:") { - has_upgrade_header = true; + ResponseFraming::Chunked + }; + let trailer_names: Vec = if supports_chunked_response { + finish + .trailers + .iter() + .map(|header| header.name.clone()) + .collect() + } else { + Vec::new() + }; + let head = serialize_response_head( + &status_line, + &headers, + framing, + server_wants_close, + &trailer_names, + ); + client.write_all(&head).await.into_diagnostic()?; + if matches!(framing, ResponseFraming::Chunked) { + for unit in &finish.body_units { + write_chunk(client, unit).await?; + } + write_response_trailers(client, &finish.trailers).await?; + } else { + for unit in &finish.body_units { + client.write_all(unit).await.into_diagnostic()?; + } } - if lower.starts_with("connection:") { - let val = lower.split_once(':').map_or("", |(_, v)| v.trim()); - // Connection header can have comma-separated values - if val.split(',').any(|tok| tok.trim() == "upgrade") { - connection_contains_upgrade = true; + } else { + for unit in &finish.body_units { + if chunked_output { + write_chunk(client, unit).await?; + } else { + client.write_all(unit).await.into_diagnostic()?; } } + if chunked_output { + write_response_trailers(client, &finish.trailers).await?; + } } - - has_upgrade_header && connection_contains_upgrade -} - -/// Returns true for responses that MUST NOT contain a message body per RFC 7230 §3.3.3: -/// HEAD responses, 1xx informational, 204 No Content, 304 Not Modified. -fn is_bodiless_response(request_method: &str, status_code: u16) -> bool { - request_method.eq_ignore_ascii_case("HEAD") - || (100..200).contains(&status_code) - || status_code == 204 - || status_code == 304 + client.flush().await.into_diagnostic()?; + Ok(Some( + if (committed && close_delimited_output) + || (matches!(body_length, BodyLength::None) && (server_wants_close || event_stream)) + { + RelayOutcome::Consumed + } else { + RelayOutcome::Reusable + }, + )) } -/// Relay all bytes from reader to writer until EOF or idle timeout. -/// -/// Used for HTTP responses with no explicit framing (no Content-Length, -/// no Transfer-Encoding) where the body is delimited by connection close. -/// An idle timeout prevents blocking when servers keep the TCP connection -/// alive longer than expected (e.g. CDN keep-alive timers). -async fn relay_until_eof(reader: &mut R, writer: &mut W) -> Result<()> +#[allow(clippy::too_many_arguments)] +async fn relay_headers_only_response( + request_method: &str, + upstream: &mut U, + client: &mut C, + status_line: &str, + headers: &[HttpHeader], + declared_trailers: &[String], + overflow: &[u8], + status_code: u16, + body_length: BodyLength, + server_wants_close: bool, + event_stream: bool, +) -> Result where - R: AsyncRead + Unpin, - W: AsyncWrite + Unpin, + U: AsyncRead + Unpin, + C: AsyncWrite + Unpin, { - let mut buf = [0u8; RELAY_BUF_SIZE]; - loop { - match tokio::time::timeout(RELAY_EOF_IDLE_TIMEOUT, reader.read(&mut buf)).await { - Ok(Ok(0)) => return Ok(()), - Ok(Ok(n)) => { - writer.write_all(&buf[..n]).await.into_diagnostic()?; - writer.flush().await.into_diagnostic()?; + let head = serialize_response_head( + status_line, + headers, + ResponseFraming::Preserve(body_length), + server_wants_close, + declared_trailers, + ); + client.write_all(&head).await.into_diagnostic()?; + + if is_bodiless_response(request_method, status_code) { + client.flush().await.into_diagnostic()?; + return Ok(if server_wants_close { + RelayOutcome::Consumed + } else { + RelayOutcome::Reusable + }); + } + + client.write_all(overflow).await.into_diagnostic()?; + match body_length { + BodyLength::ContentLength(length) => { + let remaining = length.saturating_sub(overflow.len() as u64); + if remaining > 0 { + relay_fixed(upstream, client, remaining, None).await?; } - Ok(Err(e)) => return Err(miette::miette!("{e}")), - Err(_) => { - debug!( - "relay_until_eof idle timeout after {:?}", - RELAY_EOF_IDLE_TIMEOUT - ); - return Ok(()); + } + BodyLength::Chunked => relay_chunked(upstream, client, overflow, None).await?, + BodyLength::None if server_wants_close || event_stream => { + if event_stream { + relay_until_eof_without_idle_timeout(upstream, client).await?; + } else { + relay_until_eof(upstream, client).await?; } + client.flush().await.into_diagnostic()?; + return Ok(RelayOutcome::Consumed); } + BodyLength::None => {} } + client.flush().await.into_diagnostic()?; + Ok(RelayOutcome::Reusable) } -/// Relay all bytes from reader to writer until EOF without an idle timeout. -/// -/// Used for server-sent events, where long idle gaps are part of the protocol -/// and do not mean the response body is complete. -async fn relay_until_eof_without_idle_timeout(reader: &mut R, writer: &mut W) -> Result<()> -where - R: AsyncRead + Unpin, - W: AsyncWrite + Unpin, -{ - let mut buf = [0u8; RELAY_BUF_SIZE]; - loop { - let n = reader.read(&mut buf).await.into_diagnostic()?; - if n == 0 { - return Ok(()); +fn emit_http_response_middleware_invocations( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + invocations: &[openshell_supervisor_middleware::HttpResponseInvocation], +) { + for event in + http_response_middleware_invocation_events(policy_name, target, status_code, invocations) + { + openshell_ocsf::ocsf_emit!(event); + } + for invocation in invocations { + if let Some(event) = + http_response_middleware_fail_open_finding_event(policy_name, target, invocation) + { + openshell_ocsf::ocsf_emit!(event); + } + if let Some(event) = + http_response_middleware_block_finding_event(policy_name, target, invocation) + { + openshell_ocsf::ocsf_emit!(event); } - writer.write_all(&buf[..n]).await.into_diagnostic()?; - writer.flush().await.into_diagnostic()?; } } -/// Detect if the first bytes look like an HTTP request. -/// -/// Checks for common HTTP methods at the start of the stream. -pub fn looks_like_http(peek: &[u8]) -> bool { - HTTP_METHOD_PREFIXES +fn emit_http_response_diagnostics( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + diagnostics: &openshell_supervisor_middleware::HttpResponseDiagnostics, +) { + emit_http_response_middleware_invocations( + policy_name, + target, + status_code, + &diagnostics.invocations, + ); + for event in crate::l7::middleware::middleware_finding_events(&diagnostics.findings) { + openshell_ocsf::ocsf_emit!(event); + } +} + +fn http_response_middleware_invocation_events( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + invocations: &[openshell_supervisor_middleware::HttpResponseInvocation], +) -> Vec { + invocations .iter() - .any(|method| peek.starts_with(method)) + .map(|invocation| { + let outcome = format!("{:?}", invocation.outcome).to_ascii_lowercase(); + let failed = invocation.failed; + let blocked = invocation.outcome + == openshell_supervisor_middleware::HttpResponseInvocationOutcome::BlockDelivery; + openshell_ocsf::HttpActivityBuilder::new(ocsf_ctx()) + .activity(openshell_ocsf::ActivityId::Other) + .action(if blocked { + openshell_ocsf::ActionId::Denied + } else if failed { + openshell_ocsf::ActionId::Other + } else { + openshell_ocsf::ActionId::Allowed + }) + .disposition(if blocked { + openshell_ocsf::DispositionId::Blocked + } else if failed { + openshell_ocsf::DispositionId::Error + } else { + openshell_ocsf::DispositionId::Allowed + }) + .severity(if failed || blocked { + openshell_ocsf::SeverityId::Medium + } else { + openshell_ocsf::SeverityId::Informational + }) + .status(if failed || blocked { + openshell_ocsf::StatusId::Failure + } else { + openshell_ocsf::StatusId::Success + }) + .http_request(openshell_ocsf::HttpRequest::new( + &target.method, + openshell_ocsf::Url::new( + &target.scheme, + &target.host, + &target.path, + u16::try_from(target.port).unwrap_or_default(), + ), + )) + .http_response(openshell_ocsf::HttpResponse { code: status_code }) + .dst_endpoint(openshell_ocsf::Endpoint::from_domain( + &target.host, + u16::try_from(target.port).unwrap_or_default(), + )) + .firewall_rule(policy_name, "supervisor-middleware") + .unmapped("middleware_config", invocation.config_name.as_str()) + .unmapped( + "middleware_implementation", + invocation.implementation.as_str(), + ) + .unmapped("response_middleware_outcome", outcome.as_str()) + .unmapped("sequence", invocation.sequence.unwrap_or_default()) + .unmapped("input_bytes", invocation.input_size) + .unmapped("failed", failed) + .message(format!( + "HTTP_RESPONSE_MIDDLEWARE config={} implementation={} outcome={} sequence={} input_bytes={} failed={failed}", + invocation.config_name, + invocation.implementation, + outcome, + invocation.sequence.unwrap_or_default(), + invocation.input_size, + )) + .build() + }) + .collect() } -pub(crate) fn could_be_http_request_prefix(peek: &[u8]) -> bool { - !peek.is_empty() - && HTTP_METHOD_PREFIXES - .iter() - .any(|method| peek.len() < method.len() && method.starts_with(peek)) +fn http_response_middleware_block_finding_event( + policy_name: &str, + target: &HttpRequestTarget, + invocation: &openshell_supervisor_middleware::HttpResponseInvocation, +) -> Option { + if invocation.outcome + != openshell_supervisor_middleware::HttpResponseInvocationOutcome::BlockDelivery + { + return None; + } + Some( + openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) + .severity(openshell_ocsf::SeverityId::Medium) + .finding_info(openshell_ocsf::FindingInfo::new( + "openshell.middleware.http_response_blocked", + "HTTP response blocked by middleware", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("middleware_config", invocation.config_name.as_str()), + ( + "middleware_implementation", + invocation.implementation.as_str(), + ), + ("host", target.host.as_str()), + ("phase", "pre_return"), + ]) + .unmapped("middleware_config", invocation.config_name.as_str()) + .unmapped( + "middleware_implementation", + invocation.implementation.as_str(), + ) + .unmapped("phase", "pre_return") + .message("HTTP response delivery blocked by middleware") + .build(), + ) } -pub fn looks_like_http2_prior_knowledge(peek: &[u8]) -> bool { - peek.len() >= MIN_HTTP2_PREFACE_DETECTION_BYTES - && HTTP2_PRIOR_KNOWLEDGE_PREFACE.starts_with(peek) +fn http_response_middleware_fail_open_finding_event( + policy_name: &str, + target: &HttpRequestTarget, + invocation: &openshell_supervisor_middleware::HttpResponseInvocation, +) -> Option { + if !invocation.failed + || invocation.outcome + != openshell_supervisor_middleware::HttpResponseInvocationOutcome::FailOpen + { + return None; + } + let failure_category = invocation + .failure_category + .as_deref() + .unwrap_or("middleware_failure"); + Some( + openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) + .severity(openshell_ocsf::SeverityId::Medium) + .finding_info(openshell_ocsf::FindingInfo::new( + "openshell.middleware.http_response_fail_open", + "HTTP response middleware failed open", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("middleware_config", invocation.config_name.as_str()), + ( + "middleware_implementation", + invocation.implementation.as_str(), + ), + ("host", target.host.as_str()), + ("phase", "pre_return"), + ("failure_category", failure_category), + ]) + .unmapped("middleware_config", invocation.config_name.as_str()) + .unmapped( + "middleware_implementation", + invocation.implementation.as_str(), + ) + .unmapped("phase", "pre_return") + .unmapped("failure_category", failure_category) + .message("HTTP response middleware failed and response inspection was bypassed") + .build(), + ) } -pub(crate) fn could_be_http2_prior_knowledge_prefix(peek: &[u8]) -> bool { - !peek.is_empty() - && peek.len() < MIN_HTTP2_PREFACE_DETECTION_BYTES - && HTTP2_PRIOR_KNOWLEDGE_PREFACE.starts_with(peek) +fn emit_http_response_middleware_failure( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + committed: bool, +) { + let status_code = status_code.to_string(); + let event = openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) + .severity(openshell_ocsf::SeverityId::High) + .finding_info(openshell_ocsf::FindingInfo::new( + "openshell.middleware.http_response_failure", + "HTTP response middleware delivery failure", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("host", target.host.as_str()), + ( + "commitment", + if committed { + "after_commit" + } else { + "before_commit" + }, + ), + ("upstream_status", status_code.as_str()), + ]) + .message(if committed { + "HTTP response middleware failed after response commitment" + } else { + "HTTP response middleware failed before response commitment" + }) + .build(); + openshell_ocsf::ocsf_emit!(event); } -/// Check if an IO error represents a benign connection close. -/// -/// TLS peers commonly close the socket without sending a `close_notify` alert. -/// Rustls reports this as `UnexpectedEof`, but it's functionally equivalent -/// to a clean close when no request data has been received yet. -fn is_benign_close(err: &std::io::Error) -> bool { - matches!( - err.kind(), - std::io::ErrorKind::UnexpectedEof - | std::io::ErrorKind::ConnectionReset - | std::io::ErrorKind::BrokenPipe - ) +#[derive(Debug)] +struct ParsedResponseHead { + headers: Vec, + connection_nominated: Vec, + declared_trailers: Vec, } -#[cfg(test)] -#[allow( - clippy::iter_on_single_items, - clippy::manual_string_new, - clippy::collapsible_if, - clippy::cast_possible_truncation, - reason = "Test code: test fixtures and explicit value-shape assertions are idiomatic in tests." -)] -mod tests { - use super::*; - use crate::opa::OpaEngine; - use flate2::{Compress, Compression, Decompress, FlushCompress, FlushDecompress, Status}; - use openshell_core::proposals::AgentProposals; - use openshell_core::secrets::SecretResolver; - use std::pin::Pin; - use std::sync::Arc; - use std::task::{Context, Poll}; - use tokio::io::ReadBuf; - - const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); - const VALID_WS_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; - const VALID_WS_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; - const TEXT_OPCODE: u8 = 0x1; - - struct CountingReader { - bytes: Vec, - position: usize, - reads: usize, +fn parse_response_head_for_middleware(header_bytes: &[u8]) -> Result { + let header = std::str::from_utf8(header_bytes) + .map_err(|_| miette!("HTTP response headers contain invalid UTF-8"))?; + if parse_status_code(header).is_none() { + return Err(miette!("HTTP response status line is malformed")); } - - impl CountingReader { - fn new(bytes: Vec) -> Self { - Self { - bytes, - position: 0, - reads: 0, + let mut nominated = HashSet::new(); + let mut declared_trailers = Vec::new(); + for line in header.split("\r\n").skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + validate_http_field_name(name)?; + validate_http_field_value(value.trim())?; + if name.eq_ignore_ascii_case("connection") { + for token in value + .split(',') + .map(str::trim) + .filter(|token| !token.is_empty()) + { + nominated.insert(token.to_ascii_lowercase()); + } + } else if name.eq_ignore_ascii_case("trailer") { + for token in parse_http_token_list(value)? { + let token = token.to_ascii_lowercase(); + if !declared_trailers.contains(&token) { + declared_trailers.push(token); + } } } } - - impl AsyncRead for CountingReader { - fn poll_read( - mut self: Pin<&mut Self>, - _context: &mut Context<'_>, - buffer: &mut ReadBuf<'_>, - ) -> Poll> { - self.reads += 1; - let available = self.bytes.len().saturating_sub(self.position); - let amount = available.min(buffer.remaining()); - let end = self.position + amount; - buffer.put_slice(&self.bytes[self.position..end]); - self.position = end; - Poll::Ready(Ok(())) - } - } - - 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, - }, - )), + for trailer in &declared_trailers { + if is_protected_response_field(trailer) || nominated.contains(trailer) { + return Err(miette!("HTTP response declares a protected trailer field")); } } - - fn remove_header(name: &str) -> HeaderMutation { - HeaderMutation { - operation: Some(header_mutation::Operation::Remove( - openshell_core::proto::RemoveHeader { name: name.into() }, - )), + let mut headers = Vec::new(); + for line in header.split("\r\n").skip(1).filter(|line| !line.is_empty()) { + let (name, value) = line + .split_once(':') + .ok_or_else(|| miette!("Malformed HTTP response header field"))?; + validate_http_field_name(name)?; + validate_http_field_value(value.trim())?; + let name = name.to_ascii_lowercase(); + if nominated.contains(&name) || is_protected_response_field(&name) { + continue; } + headers.push(HttpHeader { + name, + value: value.trim().to_string(), + }); } + let mut connection_nominated: Vec<_> = nominated.into_iter().collect(); + connection_nominated.sort(); + Ok(ParsedResponseHead { + headers, + connection_nominated, + declared_trailers, + }) +} - #[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")); +fn validate_http_field_name(name: &str) -> Result<()> { + if name.is_empty() + || !name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) + { + return Err(miette!("HTTP response field name is malformed")); } + Ok(()) +} - #[derive(Debug)] - struct CapturedFrame { - fin_opcode: u8, - masked: bool, - payload: Vec, +fn validate_http_field_value(value: &str) -> Result<()> { + if value + .bytes() + .any(|byte| (byte < 0x20 && byte != b'\t') || byte == 0x7f) + { + return Err(miette!("HTTP response field value contains a control byte")); } + Ok(()) +} - async fn read_http_header_block(reader: &mut R) -> Vec { - tokio::time::timeout(std::time::Duration::from_secs(2), async { - let mut header = Vec::new(); - let mut byte = [0u8; 1]; - loop { - reader.read_exact(&mut byte).await.unwrap(); - header.push(byte[0]); - if header.ends_with(b"\r\n\r\n") { - break; - } - } - header - }) - .await - .expect("HTTP header block should arrive") - } +fn is_protected_response_field(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "connection" + | "content-length" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} - async fn read_websocket_frame(reader: &mut R) -> CapturedFrame { - tokio::time::timeout(std::time::Duration::from_secs(2), async { - let mut prefix = [0u8; 2]; - reader.read_exact(&mut prefix).await.unwrap(); - let masked = prefix[1] & 0x80 != 0; - let mut payload_len = u64::from(prefix[1] & 0x7f); - if payload_len == 126 { - let mut extended = [0u8; 2]; - reader.read_exact(&mut extended).await.unwrap(); - payload_len = u64::from(u16::from_be_bytes(extended)); - } else if payload_len == 127 { - let mut extended = [0u8; 8]; - reader.read_exact(&mut extended).await.unwrap(); - payload_len = u64::from_be_bytes(extended); - } - let mut mask_key = [0u8; 4]; - if masked { - reader.read_exact(&mut mask_key).await.unwrap(); - } - let payload_len = usize::try_from(payload_len).unwrap(); - let mut payload = vec![0u8; payload_len]; - reader.read_exact(&mut payload).await.unwrap(); - if masked { - apply_test_mask(&mut payload, mask_key); - } - CapturedFrame { - fin_opcode: prefix[0], - masked, - payload, - } - }) - .await - .expect("WebSocket frame should arrive") - } +fn response_status_line(header_bytes: &[u8]) -> Result { + let line_end = header_bytes + .windows(2) + .position(|window| window == b"\r\n") + .ok_or_else(|| miette!("HTTP response status line is incomplete"))?; + std::str::from_utf8(&header_bytes[..line_end]) + .map(str::to_string) + .map_err(|_| miette!("HTTP response status line contains invalid UTF-8")) +} - async fn policy_local_json_response( - ctx: Arc, - ) -> serde_json::Value { - let (mut client, mut server) = tokio::io::duplex(4096); - let task = tokio::spawn(async move { - crate::policy_local::handle_forward_request( - ctx.as_ref(), - "GET", - "/v1/policy/current", - b"GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n\r\n", - &mut server, - ) - .await - .unwrap(); - }); +#[derive(Clone, Copy)] +enum ResponseFraming { + Preserve(BodyLength), + ContentLength(u64), + Chunked, +} - let mut received = Vec::new(); - client.read_to_end(&mut received).await.unwrap(); - task.await.unwrap(); +fn serialize_response_head( + status_line: &str, + headers: &[HttpHeader], + framing: ResponseFraming, + connection_close: bool, + trailer_names: &[String], +) -> Vec { + let mut output = format!("{status_line}\r\n"); + for header in headers { + output.push_str(&header.name); + output.push_str(": "); + output.push_str(&header.value); + output.push_str("\r\n"); + } + match framing { + ResponseFraming::Preserve(BodyLength::ContentLength(length)) + | ResponseFraming::ContentLength(length) => { + write!(&mut output, "Content-Length: {length}\r\n") + .expect("writing to a String cannot fail"); + } + ResponseFraming::Preserve(BodyLength::Chunked) | ResponseFraming::Chunked => { + output.push_str("Transfer-Encoding: chunked\r\n"); + } + ResponseFraming::Preserve(BodyLength::None) => {} + } + if !trailer_names.is_empty() { + output.push_str("Trailer: "); + output.push_str(&trailer_names.join(", ")); + output.push_str("\r\n"); + } + if connection_close { + output.push_str("Connection: close\r\n"); + } + output.push_str("\r\n"); + output.into_bytes() +} - let response = String::from_utf8(received).unwrap(); - let (_, body) = response.split_once("\r\n\r\n").unwrap(); - serde_json::from_str(body).unwrap() - } +fn strip_response_integrity_headers(headers: &mut Vec) { + headers.retain(|header| { + !matches!( + header.name.to_ascii_lowercase().as_str(), + "accept-ranges" + | "etag" + | "content-md5" + | "digest" + | "content-digest" + | "repr-digest" + | "signature" + | "signature-input" + ) + }); +} - fn masked_frame_with_rsv(opcode: u8, rsv: u8, payload: &[u8]) -> Vec { - let mask_key = [0x37, 0xfa, 0x21, 0x3d]; - let mut frame = Vec::new(); - frame.push(0x80 | rsv | opcode); - write_test_payload_len(&mut frame, 0x80, payload.len()); - frame.extend_from_slice(&mask_key); - let mut masked = payload.to_vec(); - apply_test_mask(&mut masked, mask_key); - frame.extend_from_slice(&masked); - frame - } +struct BufferedResponseReader<'a, R> { + upstream: &'a mut R, + buffered: &'a [u8], + position: usize, + exact_buffer: Vec, + exact_target: Option, + line_buffer: Vec, +} - fn unmasked_frame(opcode: u8, payload: &[u8]) -> Vec { - let mut frame = Vec::new(); - frame.push(0x80 | opcode); - write_test_payload_len(&mut frame, 0, payload.len()); - frame.extend_from_slice(payload); - frame +impl<'a, R: AsyncRead + Unpin> BufferedResponseReader<'a, R> { + fn new(upstream: &'a mut R, buffered: &'a [u8]) -> Self { + Self { + upstream, + buffered, + position: 0, + exact_buffer: Vec::new(), + exact_target: None, + line_buffer: Vec::new(), + } } - fn write_test_payload_len(frame: &mut Vec, mask_bit: u8, payload_len: usize) { - if payload_len < 126 { - frame.push(mask_bit | payload_len as u8); - } else if u16::try_from(payload_len).is_ok() { - frame.push(mask_bit | 0x7e); - frame.extend_from_slice(&(payload_len as u16).to_be_bytes()); - } else { - frame.push(mask_bit | 0x7f); - frame.extend_from_slice(&(payload_len as u64).to_be_bytes()); + async fn read_some(&mut self, limit: usize) -> Result>> { + if self.position < self.buffered.len() { + let end = self.position.saturating_add(limit).min(self.buffered.len()); + let data = self.buffered[self.position..end].to_vec(); + self.position = end; + return Ok(Some(data)); } + let mut data = vec![0u8; limit.max(1)]; + let count = self.upstream.read(&mut data).await.into_diagnostic()?; + if count == 0 { + return Ok(None); + } + data.truncate(count); + Ok(Some(data)) } - fn apply_test_mask(payload: &mut [u8], mask_key: [u8; 4]) { - for (index, byte) in payload.iter_mut().enumerate() { - *byte ^= mask_key[index % 4]; + async fn read_exact_vec(&mut self, length: usize) -> Result> { + match self.exact_target { + Some(target) if target != length => { + return Err(miette!("HTTP response reader exact-read state mismatch")); + } + None => { + self.exact_target = Some(length); + self.exact_buffer.reserve(length); + } + Some(_) => {} + } + while self.exact_buffer.len() < length { + let remaining = length - self.exact_buffer.len(); + let Some(data) = self.read_some(remaining).await? else { + return Err(miette!("HTTP response body ended unexpectedly")); + }; + self.exact_buffer.extend_from_slice(&data); } + self.exact_target = None; + Ok(std::mem::take(&mut self.exact_buffer)) } - fn compress_test_permessage_deflate(payload: &[u8]) -> Vec { - let mut compressor = Compress::new(Compression::fast(), false); - let mut out = Vec::with_capacity(payload.len().saturating_add(128)); + async fn read_line(&mut self) -> Result> { loop { - let consumed = usize::try_from(compressor.total_in()).unwrap(); - if consumed >= payload.len() { - break; + let Some(byte) = self.read_some(1).await? else { + return Err(miette!("HTTP response ended before line terminator")); + }; + self.line_buffer.push(byte[0]); + if self.line_buffer.len() > MAX_HEADER_BYTES { + return Err(miette!("HTTP response line exceeds limit")); } - let before_in = compressor.total_in(); - let before_out = compressor.total_out(); - let status = compressor - .compress_vec(&payload[consumed..], &mut out, FlushCompress::None) - .unwrap(); - if matches!(status, Status::BufError) - || (compressor.total_in() == before_in && compressor.total_out() == before_out) - { - out.reserve(out.capacity().max(1024)); + if self.line_buffer.ends_with(b"\r\n") { + self.line_buffer.truncate(self.line_buffer.len() - 2); + return Ok(std::mem::take(&mut self.line_buffer)); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn relay_normalized_response_body( + reader: &mut BufferedResponseReader<'_, R>, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + body_length: BodyLength, + server_wants_close: bool, + event_stream: bool, + committed: &mut bool, + chunked_output: bool, + commit_head: &[u8], + unit_limit: usize, + generation_guard: Option<&PolicyGenerationGuard>, + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + connection_nominated_headers: &[String], +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + let mut pending = Vec::with_capacity(unit_limit); + let mut framing = ResponseOutputState { + committed, + chunked: chunked_output, + commit_head, + policy_name, + target, + status_code, + }; + match body_length { + BodyLength::ContentLength(mut remaining) => { + while remaining > 0 { + let length = usize::try_from(remaining) + .unwrap_or(unit_limit) + .min(unit_limit); + let unit = read_exact_response_with_deadline( + reader, + length, + session, + client, + &mut framing, + ) + .await?; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + remaining -= unit.len() as u64; + buffer_normalized_response_bytes( + session, + client, + &mut pending, + unit, + &mut framing, + unit_limit, + ) + .await?; + } + flush_normalized_response_bytes(session, client, pending, &mut framing).await?; + Ok(Vec::new()) + } + BodyLength::Chunked => { + let mut size_line = + read_response_line_with_deadline(reader, session, client, &mut framing).await?; + loop { + let size_line_text = std::str::from_utf8(&size_line) + .map_err(|_| miette!("Invalid UTF-8 in response chunk-size line"))?; + let size_token = size_line_text + .split(';') + .next() + .map(str::trim) + .unwrap_or_default(); + let chunk_size = usize::from_str_radix(size_token, 16) + .map_err(|_| miette!("Invalid HTTP response chunk size"))?; + if chunk_size == 0 { + flush_normalized_response_bytes(session, client, pending, &mut framing).await?; + return read_response_trailers( + reader, + session, + client, + &mut framing, + connection_nominated_headers, + ) + .await; + } + let mut remaining = chunk_size; + while remaining > 0 { + let length = remaining.min(unit_limit); + let unit = read_exact_response_with_deadline( + reader, + length, + session, + client, + &mut framing, + ) + .await?; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + remaining -= unit.len(); + buffer_normalized_response_bytes( + session, + client, + &mut pending, + unit, + &mut framing, + unit_limit, + ) + .await?; + } + if read_exact_response_with_deadline(reader, 2, session, client, &mut framing) + .await? + .as_slice() + != b"\r\n" + { + return Err(miette!("HTTP response chunk is missing its terminator")); + } + size_line = if let Ok(line) = tokio::time::timeout( + RESPONSE_UNIT_COALESCE_TIMEOUT, + read_response_line_with_deadline(reader, session, client, &mut framing), + ) + .await + { + line? + } else { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + read_response_line_with_deadline(reader, session, client, &mut framing).await? + }; + } + } + BodyLength::None if server_wants_close || event_stream => loop { + let read = + read_response_with_deadline(reader, unit_limit, session, client, &mut framing); + let next = if pending.is_empty() && event_stream { + read.await? + } else if pending.is_empty() { + match tokio::time::timeout(RELAY_EOF_IDLE_TIMEOUT, read).await { + Ok(result) => result?, + Err(_) => None, + } + } else if let Ok(result) = + tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, read).await + { + result? + } else { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + continue; + }; + let Some(unit) = next else { + flush_normalized_response_bytes(session, client, pending, &mut framing).await?; + return Ok(Vec::new()); + }; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + buffer_normalized_response_bytes( + session, + client, + &mut pending, + unit, + &mut framing, + unit_limit, + ) + .await?; + }, + BodyLength::None => { + flush_normalized_response_bytes(session, client, pending, &mut framing).await?; + Ok(Vec::new()) + } + } +} + +struct ResponseOutputState<'a> { + committed: &'a mut bool, + chunked: bool, + commit_head: &'a [u8], + policy_name: &'a str, + target: &'a HttpRequestTarget, + status_code: u16, +} + +#[derive(Debug)] +struct ResponseMiddlewareStop { + failure: openshell_supervisor_middleware::HttpResponseMiddlewareFailure, +} + +impl ResponseMiddlewareStop { + fn new(failure: openshell_supervisor_middleware::HttpResponseMiddlewareFailure) -> Self { + Self { failure } + } +} + +impl fmt::Display for ResponseMiddlewareStop { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "HTTP response middleware stopped delivery: {}", + self.failure + ) + } +} + +impl std::error::Error for ResponseMiddlewareStop {} + +impl miette::Diagnostic for ResponseMiddlewareStop {} + +async fn expire_whole_body_deadline( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, +) -> Result<()> { + let output = session.expire_whole_body_deadline().await; + let diagnostics = session.take_diagnostics(); + emit_http_response_diagnostics( + framing.policy_name, + framing.target, + framing.status_code, + &diagnostics, + ); + let output = output.map_err(ResponseMiddlewareStop::new)?; + deliver_response_units(client, output, framing, session.requires_whole_body()).await +} + +async fn read_response_with_deadline( + reader: &mut BufferedResponseReader<'_, R>, + limit: usize, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, +) -> Result>> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + loop { + let Some(deadline) = session.whole_body_deadline() else { + return reader.read_some(limit).await; + }; + match tokio::time::timeout_at(deadline, reader.read_some(limit)).await { + Ok(result) => return result, + Err(_) => expire_whole_body_deadline(session, client, framing).await?, + } + } +} + +async fn read_exact_response_with_deadline( + reader: &mut BufferedResponseReader<'_, R>, + length: usize, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + loop { + let Some(deadline) = session.whole_body_deadline() else { + return reader.read_exact_vec(length).await; + }; + match tokio::time::timeout_at(deadline, reader.read_exact_vec(length)).await { + Ok(result) => return result, + Err(_) => expire_whole_body_deadline(session, client, framing).await?, + } + } +} + +async fn read_response_line_with_deadline( + reader: &mut BufferedResponseReader<'_, R>, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + loop { + let Some(deadline) = session.whole_body_deadline() else { + return reader.read_line().await; + }; + match tokio::time::timeout_at(deadline, reader.read_line()).await { + Ok(result) => return result, + Err(_) => expire_whole_body_deadline(session, client, framing).await?, + } + } +} + +async fn buffer_normalized_response_bytes( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + pending: &mut Vec, + data: Vec, + framing: &mut ResponseOutputState<'_>, + unit_limit: usize, +) -> Result<()> { + pending.extend_from_slice(&data); + while pending.len() >= unit_limit { + let remainder = pending.split_off(unit_limit); + let unit = std::mem::replace(pending, remainder); + process_response_unit(session, client, unit, framing).await?; + } + Ok(()) +} + +async fn flush_normalized_response_bytes( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + pending: Vec, + framing: &mut ResponseOutputState<'_>, +) -> Result<()> { + if pending.is_empty() { + return Ok(()); + } + process_response_unit(session, client, pending, framing).await +} + +async fn process_response_unit( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + unit: Vec, + framing: &mut ResponseOutputState<'_>, +) -> Result<()> { + let output = session.push_body(unit).await; + let diagnostics = session.take_diagnostics(); + emit_http_response_diagnostics( + framing.policy_name, + framing.target, + framing.status_code, + &diagnostics, + ); + let output = output.map_err(ResponseMiddlewareStop::new)?; + deliver_response_units(client, output, framing, session.requires_whole_body()).await +} + +async fn deliver_response_units( + client: &mut C, + output: Vec>, + framing: &mut ResponseOutputState<'_>, + whole_body_pending: bool, +) -> Result<()> { + if !*framing.committed && !output.is_empty() { + if whole_body_pending { + return Err(miette!( + "whole-body response middleware released output before finalization" + )); + } + // `write_all` may return an error after a partial write. Treat the + // response as committed before the attempt so callers never append a + // canonical error response behind a partially delivered upstream head. + *framing.committed = true; + client + .write_all(framing.commit_head) + .await + .map_err(|error| miette!("HTTP response client write failed: {error}"))?; + client + .flush() + .await + .map_err(|error| miette!("HTTP response client write failed: {error}"))?; + } + if *framing.committed { + for unit in output { + if framing.chunked { + write_chunk(client, &unit) + .await + .map_err(|error| miette!("HTTP response client write failed: {error}"))?; + } else { + client + .write_all(&unit) + .await + .map_err(|error| miette!("HTTP response client write failed: {error}"))?; + } + } + client + .flush() + .await + .map_err(|error| miette!("HTTP response client write failed: {error}"))?; + } + Ok(()) +} + +async fn read_response_trailers( + reader: &mut BufferedResponseReader<'_, R>, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, + connection_nominated_headers: &[String], +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + let mut trailers = Vec::new(); + loop { + let line = read_response_line_with_deadline(reader, session, client, framing).await?; + if line.is_empty() { + return Ok(trailers); + } + let line = std::str::from_utf8(&line) + .map_err(|_| miette!("HTTP response trailer contains invalid UTF-8"))?; + let (name, value) = line + .split_once(':') + .ok_or_else(|| miette!("Malformed HTTP response trailer"))?; + validate_http_field_name(name)?; + validate_http_field_value(value.trim())?; + let name = name.to_ascii_lowercase(); + if is_protected_response_field(&name) || connection_nominated_headers.contains(&name) { + return Err(miette!("HTTP response trailer uses a protected field name")); + } + trailers.push(HttpHeader { + name, + value: value.trim().to_string(), + }); + if trailers.len() > openshell_supervisor_middleware::MAX_MIDDLEWARE_HEADERS { + return Err(miette!("HTTP response trailer count exceeds limit")); + } + } +} + +async fn write_response_trailers( + client: &mut C, + trailers: &[HttpHeader], +) -> Result<()> { + client.write_all(b"0\r\n").await.into_diagnostic()?; + for trailer in trailers { + client + .write_all(format!("{}: {}\r\n", trailer.name, trailer.value).as_bytes()) + .await + .into_diagnostic()?; + } + client.write_all(b"\r\n").await.into_diagnostic()?; + Ok(()) +} + +async fn send_response_middleware_denial( + client: &mut C, + request_method: &str, + policy_name: &str, + target: &HttpRequestTarget, + denial: &openshell_supervisor_middleware::MiddlewareDenial, +) -> Result<()> { + let mut body = serde_json::Map::new(); + body.insert("error".into(), serde_json::json!("middleware_denied")); + body.insert( + "detail".into(), + serde_json::json!("Response blocked by configured middleware"), + ); + body.insert("policy".into(), serde_json::json!(policy_name)); + body.insert("middleware".into(), serde_json::json!(denial.config_name)); + if let Some(reason_code) = &denial.reason_code { + body.insert("reason_code".into(), serde_json::json!(reason_code)); + } + body.insert( + "layer".into(), + serde_json::json!("http_response_pre_return"), + ); + body.insert("method".into(), serde_json::json!(target.method)); + body.insert("path".into(), serde_json::json!(target.path)); + body.insert("host".into(), serde_json::json!(target.host)); + body.insert("port".into(), serde_json::json!(target.port)); + let body = serde_json::to_vec(&serde_json::Value::Object(body)).into_diagnostic()?; + let head = format!( + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-OpenShell-Policy: {policy_name}\r\nConnection: close\r\n\r\n", + body.len() + ); + client.write_all(head.as_bytes()).await.into_diagnostic()?; + if !request_method.eq_ignore_ascii_case("HEAD") { + client.write_all(&body).await.into_diagnostic()?; + } + client.flush().await.into_diagnostic()?; + Ok(()) +} + +async fn send_response_delivery_failure( + client: &mut C, + request_method: &str, + policy_name: &str, + target: &HttpRequestTarget, +) -> Result<()> { + let body = serde_json::to_vec(&serde_json::json!({ + "error": "response_delivery_failed", + "detail": "The upstream request may have completed, but OpenShell could not deliver its response. Retrying may repeat upstream side effects.", + "policy": policy_name, + "layer": "http_response_pre_return", + "method": target.method, + "path": target.path, + "host": target.host, + "port": target.port, + })) + .into_diagnostic()?; + let head = format!( + "HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-OpenShell-Policy: {policy_name}\r\nConnection: close\r\n\r\n", + body.len() + ); + client.write_all(head.as_bytes()).await.into_diagnostic()?; + if !request_method.eq_ignore_ascii_case("HEAD") { + client.write_all(&body).await.into_diagnostic()?; + } + client.flush().await.into_diagnostic()?; + Ok(()) +} + +/// Parse the HTTP status code from a response status line. +/// +/// Expects the first line to look like `HTTP/1.1 200 OK`. +fn parse_status_code(headers: &str) -> Option { + let status_line = headers.lines().next()?; + let code_str = status_line.split_whitespace().nth(1)?; + code_str.parse().ok() +} + +/// Check if the response headers contain `Connection: close`. +fn parse_connection_close(headers: &str) -> bool { + for line in headers.lines().skip(1) { + let lower = line.to_ascii_lowercase(); + if lower.starts_with("connection:") { + let val = lower.split_once(':').map_or("", |(_, v)| v.trim()); + return val.contains("close"); + } + } + false +} + +fn response_is_event_stream(headers: &str) -> bool { + headers.lines().skip(1).any(|line| { + let lower = line.to_ascii_lowercase(); + let Some(value) = lower.strip_prefix("content-type:") else { + return false; + }; + value + .split(';') + .next() + .is_some_and(|mime| mime.trim() == "text/event-stream") + }) +} + +fn validate_websocket_response( + headers: &str, + mode: WebSocketExtensionMode, + websocket: Option<&WebSocketResponseValidation>, +) -> Result<(bool, Option)> { + let Some(validation) = websocket else { + return validate_websocket_response_extensions_preserved(headers, mode) + .map(|compressed| (compressed, None)); + }; + + let mut upgrade_websocket = false; + let mut connection_upgrade = false; + let mut accept_count = 0usize; + let mut accept_matches = false; + let mut subprotocol_count = 0usize; + let mut selected_subprotocol = None; + + for line in headers.lines().skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let name = name.trim().to_ascii_lowercase(); + let value = value.trim(); + match name.as_str() { + "upgrade" if header_value_contains_token(value, "websocket") => { + upgrade_websocket = true; + } + "connection" if header_value_contains_token(value, "upgrade") => { + connection_upgrade = true; + } + "sec-websocket-accept" => { + accept_count += 1; + accept_matches = value == validation.expected_accept; + } + "sec-websocket-protocol" => { + subprotocol_count += 1; + if !is_http_token(value) { + return Err(miette!( + "websocket upgrade response has invalid Sec-WebSocket-Protocol" + )); + } + selected_subprotocol = Some(value.to_string()); + } + _ => {} + } + } + + if !upgrade_websocket { + return Err(miette!( + "websocket upgrade response missing Upgrade: websocket" + )); + } + if !connection_upgrade { + return Err(miette!( + "websocket upgrade response missing Connection: Upgrade" + )); + } + if accept_count != 1 || !accept_matches { + return Err(miette!( + "websocket upgrade response has invalid Sec-WebSocket-Accept" + )); + } + if subprotocol_count > 1 { + return Err(miette!( + "websocket upgrade response has multiple Sec-WebSocket-Protocol headers" + )); + } + if let Some(ref protocol) = selected_subprotocol + && !validation + .offered_subprotocols + .iter() + .any(|offered| offered == protocol) + { + return Err(miette!( + "upstream selected WebSocket subprotocol that was not offered" + )); + } + + let actual_extension = normalized_websocket_extension(headers)?; + match (&validation.expected_extension, actual_extension.as_deref()) { + (None, Some(_)) => Err(miette!( + "upstream negotiated WebSocket extension that was not offered" + )), + (None | Some(_), None) => Ok((false, selected_subprotocol)), + (Some(expected), Some(actual)) if expected.eq_ignore_ascii_case(actual) => { + Ok((true, selected_subprotocol)) + } + (Some(_), Some(_)) => Err(miette!( + "upstream negotiated WebSocket extension that does not match the safe offer" + )), + } +} + +fn validate_websocket_response_extensions_preserved( + headers: &str, + mode: WebSocketExtensionMode, +) -> Result { + match mode { + WebSocketExtensionMode::Preserve => Ok(false), + WebSocketExtensionMode::PermessageDeflate => { + let offers = websocket_extension_offers(headers)?; + if offers.is_empty() { + Ok(false) + } else { + Err(miette!( + "upstream negotiated WebSocket extension that was not offered" + )) + } + } + } +} + +fn normalized_websocket_extension(headers: &str) -> Result> { + let offers = websocket_extension_offers(headers)?; + if offers.is_empty() { + return Ok(None); + } + if offers.len() != 1 { + return Err(miette!("upstream negotiated multiple WebSocket extensions")); + } + let offer = &offers[0]; + if !offer.name.eq_ignore_ascii_case("permessage-deflate") { + return Err(miette!( + "upstream negotiated unsupported WebSocket extension" + )); + } + let mut client_no_context_takeover = false; + let mut server_no_context_takeover = false; + let mut seen = HashSet::new(); + for param in &offer.params { + let name = param.name.to_ascii_lowercase(); + if param.value.is_some() || !seen.insert(name.clone()) { + return Err(miette!( + "upstream negotiated unsupported permessage-deflate parameter" + )); + } + if name == "client_no_context_takeover" { + client_no_context_takeover = true; + } else if name == "server_no_context_takeover" { + server_no_context_takeover = true; + } else { + return Err(miette!( + "upstream negotiated unsupported permessage-deflate parameter" + )); + } + } + let mut normalized = String::from("permessage-deflate"); + if client_no_context_takeover { + normalized.push_str("; client_no_context_takeover"); + } + if server_no_context_takeover { + normalized.push_str("; server_no_context_takeover"); + } + Ok(Some(normalized)) +} + +/// Check if the client request headers contain both `Upgrade` and +/// `Connection: Upgrade` headers, indicating the client requested a +/// protocol upgrade (e.g. WebSocket). +/// +/// Per RFC 9110 Section 7.8, a server MUST NOT send 101 Switching Protocols +/// unless the client sent these headers. +fn client_requested_upgrade(headers: &str) -> bool { + let mut has_upgrade_header = false; + let mut connection_contains_upgrade = false; + + for line in headers.lines().skip(1) { + let lower = line.to_ascii_lowercase(); + if lower.starts_with("upgrade:") { + has_upgrade_header = true; + } + if lower.starts_with("connection:") { + let val = lower.split_once(':').map_or("", |(_, v)| v.trim()); + // Connection header can have comma-separated values + if val.split(',').any(|tok| tok.trim() == "upgrade") { + connection_contains_upgrade = true; + } + } + } + + has_upgrade_header && connection_contains_upgrade +} + +/// Returns true for responses that MUST NOT contain a message body per RFC 7230 §3.3.3: +/// HEAD responses, 1xx informational, 204 No Content, 304 Not Modified. +fn is_bodiless_response(request_method: &str, status_code: u16) -> bool { + request_method.eq_ignore_ascii_case("HEAD") + || (100..200).contains(&status_code) + || status_code == 204 + || status_code == 304 +} + +/// Relay all bytes from reader to writer until EOF or idle timeout. +/// +/// Used for HTTP responses with no explicit framing (no Content-Length, +/// no Transfer-Encoding) where the body is delimited by connection close. +/// An idle timeout prevents blocking when servers keep the TCP connection +/// alive longer than expected (e.g. CDN keep-alive timers). +async fn relay_until_eof(reader: &mut R, writer: &mut W) -> Result<()> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let mut buf = [0u8; RELAY_BUF_SIZE]; + loop { + match tokio::time::timeout(RELAY_EOF_IDLE_TIMEOUT, reader.read(&mut buf)).await { + Ok(Ok(0)) => return Ok(()), + Ok(Ok(n)) => { + writer.write_all(&buf[..n]).await.into_diagnostic()?; + writer.flush().await.into_diagnostic()?; + } + Ok(Err(e)) => return Err(miette::miette!("{e}")), + Err(_) => { + debug!( + "relay_until_eof idle timeout after {:?}", + RELAY_EOF_IDLE_TIMEOUT + ); + return Ok(()); + } + } + } +} + +/// Relay all bytes from reader to writer until EOF without an idle timeout. +/// +/// Used for server-sent events, where long idle gaps are part of the protocol +/// and do not mean the response body is complete. +async fn relay_until_eof_without_idle_timeout(reader: &mut R, writer: &mut W) -> Result<()> +where + R: AsyncRead + Unpin, + W: AsyncWrite + Unpin, +{ + let mut buf = [0u8; RELAY_BUF_SIZE]; + loop { + let n = reader.read(&mut buf).await.into_diagnostic()?; + if n == 0 { + return Ok(()); + } + writer.write_all(&buf[..n]).await.into_diagnostic()?; + writer.flush().await.into_diagnostic()?; + } +} + +/// Detect if the first bytes look like an HTTP request. +/// +/// Checks for common HTTP methods at the start of the stream. +pub fn looks_like_http(peek: &[u8]) -> bool { + HTTP_METHOD_PREFIXES + .iter() + .any(|method| peek.starts_with(method)) +} + +pub(crate) fn could_be_http_request_prefix(peek: &[u8]) -> bool { + !peek.is_empty() + && HTTP_METHOD_PREFIXES + .iter() + .any(|method| peek.len() < method.len() && method.starts_with(peek)) +} + +pub fn looks_like_http2_prior_knowledge(peek: &[u8]) -> bool { + peek.len() >= MIN_HTTP2_PREFACE_DETECTION_BYTES + && HTTP2_PRIOR_KNOWLEDGE_PREFACE.starts_with(peek) +} + +pub(crate) fn could_be_http2_prior_knowledge_prefix(peek: &[u8]) -> bool { + !peek.is_empty() + && peek.len() < MIN_HTTP2_PREFACE_DETECTION_BYTES + && HTTP2_PRIOR_KNOWLEDGE_PREFACE.starts_with(peek) +} + +/// Check if an IO error represents a benign connection close. +/// +/// TLS peers commonly close the socket without sending a `close_notify` alert. +/// Rustls reports this as `UnexpectedEof`, but it's functionally equivalent +/// to a clean close when no request data has been received yet. +fn is_benign_close(err: &std::io::Error) -> bool { + matches!( + err.kind(), + std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::BrokenPipe + ) +} + +#[cfg(test)] +#[allow( + clippy::iter_on_single_items, + clippy::manual_string_new, + clippy::collapsible_if, + clippy::cast_possible_truncation, + reason = "Test code: test fixtures and explicit value-shape assertions are idiomatic in tests." +)] +mod tests { + use super::*; + use crate::opa::OpaEngine; + use flate2::{Compress, Compression, Decompress, FlushCompress, FlushDecompress, Status}; + use openshell_core::proposals::AgentProposals; + use openshell_core::proto::{ + Decision, HttpRequestResult, HttpResponseBlockDelivery, HttpResponseBodyMode, + HttpResponseBodyResult, HttpResponseBodyTransform, HttpResponseEvent, + HttpResponseEventResult, HttpResponsePreflightInspect, HttpResponsePreflightResult, + HttpResponseTrailersResult, MiddlewareBinding, MiddlewareManifest, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, http_response_body_result, + http_response_body_transform, http_response_body_unit, http_response_event, + http_response_event_result, http_response_preflight_result, + }; + use openshell_core::secrets::SecretResolver; + use std::pin::Pin; + use std::sync::Arc; + use std::task::{Context, Poll}; + use tokio::io::ReadBuf; + use tokio::sync::mpsc; + use tokio_stream::wrappers::ReceiverStream; + + const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); + const VALID_WS_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; + const VALID_WS_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + const TEXT_OPCODE: u8 = 0x1; + + #[derive(Clone, Copy)] + enum ResponseRelayScript { + HeadersOnly, + WholeBody, + WholeBodyWithTrailer, + Stream, + BlockPreflight, + BlockWholeBody, + BlockStream, + SlowWholeBody, + InvalidBodySequence, + InvalidWholeBodySequence, + } + + struct ResponseRelayService { + script: ResponseRelayScript, + } + + #[tonic::async_trait] + impl openshell_supervisor_middleware::InProcessMiddleware for ResponseRelayService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/response-relay".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpResponse as i32, + phase: SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: 4096, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + _request: openshell_supervisor_middleware::HttpRequestView<'_>, + ) -> Result { + Ok(HttpRequestResult { + decision: Decision::Allow as i32, + ..Default::default() + }) + } + + async fn open_http_response_pre_return( + &self, + mut requests: mpsc::Receiver, + ) -> std::result::Result< + openshell_supervisor_middleware::HttpResponseResultStream, + tonic::Status, + > { + let script = self.script; + let (sender, receiver) = mpsc::channel(4); + tokio::spawn(async move { + while let Some(event) = requests.recv().await { + let Some(event) = event.event else { + break; + }; + let result = match event { + http_response_event::Event::Preflight(_) => { + if matches!(script, ResponseRelayScript::BlockPreflight) { + HttpResponseEventResult { + result: Some( + http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some( + http_response_preflight_result::Action::BlockDelivery( + HttpResponseBlockDelivery {}, + ), + ), + reason_code: "content_match".into(), + ..Default::default() + }, + ), + ), + } + } else { + let (body_mode, header_mutations) = match script { + ResponseRelayScript::HeadersOnly => ( + HttpResponseBodyMode::HeadersOnly, + vec![write_header( + "cache-control", + "private", + ExistingHeaderAction::Overwrite, + )], + ), + ResponseRelayScript::WholeBody + | ResponseRelayScript::BlockWholeBody + | ResponseRelayScript::SlowWholeBody + | ResponseRelayScript::InvalidWholeBodySequence + | ResponseRelayScript::WholeBodyWithTrailer => { + (HttpResponseBodyMode::WholeBodyBytes, Vec::new()) + } + ResponseRelayScript::Stream + | ResponseRelayScript::BlockStream + | ResponseRelayScript::InvalidBodySequence => { + (HttpResponseBodyMode::StreamBytes, Vec::new()) + } + ResponseRelayScript::BlockPreflight => unreachable!(), + }; + HttpResponseEventResult { + result: Some( + http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some( + http_response_preflight_result::Action::Inspect( + HttpResponsePreflightInspect { + body_mode: body_mode as i32, + header_mutations, + }, + ), + ), + ..Default::default() + }, + ), + ), + } + } + } + http_response_event::Event::Body(body) => { + let Some(http_response_body_unit::Payload::Data(data)) = body.payload + else { + break; + }; + let replacement = match script { + ResponseRelayScript::WholeBody + | ResponseRelayScript::BlockWholeBody + | ResponseRelayScript::SlowWholeBody + | ResponseRelayScript::WholeBodyWithTrailer + | ResponseRelayScript::InvalidWholeBodySequence => { + [b"whole:".as_slice(), &data].concat() + } + ResponseRelayScript::Stream + | ResponseRelayScript::BlockStream + | ResponseRelayScript::InvalidBodySequence => { + data.to_ascii_uppercase() + } + ResponseRelayScript::HeadersOnly + | ResponseRelayScript::BlockPreflight => break, + }; + if matches!(script, ResponseRelayScript::SlowWholeBody) { + tokio::time::sleep(std::time::Duration::from_millis(75)).await; + } + HttpResponseEventResult { + result: Some(http_response_event_result::Result::BodyResult( + HttpResponseBodyResult { + sequence: if matches!( + script, + ResponseRelayScript::InvalidBodySequence + | ResponseRelayScript::InvalidWholeBodySequence + ) { + body.sequence + 1 + } else { + body.sequence + }, + action: Some( + if matches!( + script, + ResponseRelayScript::BlockWholeBody + | ResponseRelayScript::BlockStream + ) { + http_response_body_result::Action::BlockDelivery( + HttpResponseBlockDelivery {}, + ) + } else { + http_response_body_result::Action::Transform( + HttpResponseBodyTransform { + replacement: Some( + http_response_body_transform::Replacement::Data( + replacement, + ), + ), + }, + ) + }, + ), + reason_code: if matches!( + script, + ResponseRelayScript::BlockWholeBody + | ResponseRelayScript::BlockStream + ) { + "content_match".into() + } else { + String::new() + }, + ..Default::default() + }, + )), + } + } + http_response_event::Event::Trailers(_) => HttpResponseEventResult { + result: Some(http_response_event_result::Result::TrailersResult( + HttpResponseTrailersResult::default(), + )), + }, + http_response_event::Event::SessionEnd(_) => break, + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + }); + Ok(Box::pin(ReceiverStream::new(receiver))) + } + } + + struct CountingReader { + bytes: Vec, + position: usize, + reads: usize, + } + + impl CountingReader { + fn new(bytes: Vec) -> Self { + Self { + bytes, + position: 0, + reads: 0, + } + } + } + + impl AsyncRead for CountingReader { + fn poll_read( + mut self: Pin<&mut Self>, + _context: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + self.reads += 1; + let available = self.bytes.len().saturating_sub(self.position); + let amount = available.min(buffer.remaining()); + let end = self.position + amount; + buffer.put_slice(&self.bytes[self.position..end]); + self.position = end; + Poll::Ready(Ok(())) + } + } + + 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, + masked: bool, + payload: Vec, + } + + async fn read_http_header_block(reader: &mut R) -> Vec { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + let mut header = Vec::new(); + let mut byte = [0u8; 1]; + loop { + reader.read_exact(&mut byte).await.unwrap(); + header.push(byte[0]); + if header.ends_with(b"\r\n\r\n") { + break; + } + } + header + }) + .await + .expect("HTTP header block should arrive") + } + + async fn read_websocket_frame(reader: &mut R) -> CapturedFrame { + tokio::time::timeout(std::time::Duration::from_secs(2), async { + let mut prefix = [0u8; 2]; + reader.read_exact(&mut prefix).await.unwrap(); + let masked = prefix[1] & 0x80 != 0; + let mut payload_len = u64::from(prefix[1] & 0x7f); + if payload_len == 126 { + let mut extended = [0u8; 2]; + reader.read_exact(&mut extended).await.unwrap(); + payload_len = u64::from(u16::from_be_bytes(extended)); + } else if payload_len == 127 { + let mut extended = [0u8; 8]; + reader.read_exact(&mut extended).await.unwrap(); + payload_len = u64::from_be_bytes(extended); + } + let mut mask_key = [0u8; 4]; + if masked { + reader.read_exact(&mut mask_key).await.unwrap(); + } + let payload_len = usize::try_from(payload_len).unwrap(); + let mut payload = vec![0u8; payload_len]; + reader.read_exact(&mut payload).await.unwrap(); + if masked { + apply_test_mask(&mut payload, mask_key); + } + CapturedFrame { + fin_opcode: prefix[0], + masked, + payload, + } + }) + .await + .expect("WebSocket frame should arrive") + } + + async fn policy_local_json_response( + ctx: Arc, + ) -> serde_json::Value { + let (mut client, mut server) = tokio::io::duplex(4096); + let task = tokio::spawn(async move { + crate::policy_local::handle_forward_request( + ctx.as_ref(), + "GET", + "/v1/policy/current", + b"GET http://policy.local/v1/policy/current HTTP/1.1\r\nHost: policy.local\r\n\r\n", + &mut server, + ) + .await + .unwrap(); + }); + + let mut received = Vec::new(); + client.read_to_end(&mut received).await.unwrap(); + task.await.unwrap(); + + let response = String::from_utf8(received).unwrap(); + let (_, body) = response.split_once("\r\n\r\n").unwrap(); + serde_json::from_str(body).unwrap() + } + + fn masked_frame_with_rsv(opcode: u8, rsv: u8, payload: &[u8]) -> Vec { + let mask_key = [0x37, 0xfa, 0x21, 0x3d]; + let mut frame = Vec::new(); + frame.push(0x80 | rsv | opcode); + write_test_payload_len(&mut frame, 0x80, payload.len()); + frame.extend_from_slice(&mask_key); + let mut masked = payload.to_vec(); + apply_test_mask(&mut masked, mask_key); + frame.extend_from_slice(&masked); + frame + } + + fn unmasked_frame(opcode: u8, payload: &[u8]) -> Vec { + let mut frame = Vec::new(); + frame.push(0x80 | opcode); + write_test_payload_len(&mut frame, 0, payload.len()); + frame.extend_from_slice(payload); + frame + } + + fn write_test_payload_len(frame: &mut Vec, mask_bit: u8, payload_len: usize) { + if payload_len < 126 { + frame.push(mask_bit | payload_len as u8); + } else if u16::try_from(payload_len).is_ok() { + frame.push(mask_bit | 0x7e); + frame.extend_from_slice(&(payload_len as u16).to_be_bytes()); + } else { + frame.push(mask_bit | 0x7f); + frame.extend_from_slice(&(payload_len as u64).to_be_bytes()); + } + } + + fn apply_test_mask(payload: &mut [u8], mask_key: [u8; 4]) { + for (index, byte) in payload.iter_mut().enumerate() { + *byte ^= mask_key[index % 4]; + } + } + + fn compress_test_permessage_deflate(payload: &[u8]) -> Vec { + let mut compressor = Compress::new(Compression::fast(), false); + let mut out = Vec::with_capacity(payload.len().saturating_add(128)); + loop { + let consumed = usize::try_from(compressor.total_in()).unwrap(); + if consumed >= payload.len() { + break; + } + let before_in = compressor.total_in(); + let before_out = compressor.total_out(); + let status = compressor + .compress_vec(&payload[consumed..], &mut out, FlushCompress::None) + .unwrap(); + if matches!(status, Status::BufError) + || (compressor.total_in() == before_in && compressor.total_out() == before_out) + { + out.reserve(out.capacity().max(1024)); } } loop { @@ -4756,751 +6579,1527 @@ mod tests { panic!("aggregate chunk extensions must be bounded") }; assert!( - error - .to_string() - .contains("chunked body wire representation exceeds configured buffer limit"), - "unexpected error: {error}" + error + .to_string() + .contains("chunked body wire representation exceeds configured buffer limit"), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn credential_rewrite_chunked_request_with_trailers_is_rejected() { + let req = L7Request { + action: "POST".into(), + target: "/api".into(), + query_params: HashMap::new(), + raw_header: Vec::new(), + body_length: BodyLength::Chunked, + }; + let headers = + b"POST /api HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\nTrailer: Digest\r\n\r\n"; + let result = collect_and_rewrite_request_body( + &req, + &mut tokio::io::empty(), + headers, + std::str::from_utf8(headers).expect("headers"), + b"1\r\nx\r\n0\r\nDigest: sha-256=:abc123:\r\n\r\n", + None, + None, + ) + .await; + let Err(error) = result else { + panic!("credential rewriting must reject non-empty chunked trailers") + }; + assert!( + error.to_string().contains( + "chunked request trailers are not supported when buffering or transforming request bodies" + ), + "unexpected error: {error}" + ); + } + + #[tokio::test] + async fn collect_chunked_body_reads_payload_in_blocks() { + let payload_len = 64 * 1024; + 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 mut client = CountingReader::new(wire); + + let body = collect_chunked_body( + &mut client, + &[], + None, + Some(openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES), + ) + .await + .expect("chunked body should decode"); + + assert_eq!(body.len(), payload_len); + assert!( + client.reads <= 32, + "payload should be read in blocks, observed {} reads", + client.reads + ); + } + + #[tokio::test] + async fn extreme_content_length_is_rejected_before_allocation() { + let req = L7Request { + action: "POST".into(), + target: "/upload".into(), + query_params: HashMap::new(), + raw_header: b"POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Length: 18446744073709551615\r\n\r\n".to_vec(), + body_length: BodyLength::ContentLength(u64::MAX), + }; + let (mut client, _peer) = tokio::io::duplex(1); + + let result = buffer_request_body_for_middleware( + &req, + &mut client, + None, + openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES, + ) + .await + .expect("oversized body should produce a capacity result"); + + assert!(matches!( + result, + BufferResult::OverCapacity { recoverable: true } + )); + } + + #[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!( + matches!(error, CollectChunkedError::OverCapacity), + "over-cap wire body must be OverCapacity, got {error:?}" + ); + } + + #[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); + } + + #[tokio::test] + async fn middleware_chunked_invalid_size_is_not_over_capacity() { + let mut raw = + b"POST /api HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n" + .to_vec(); + raw.extend_from_slice(b"xyz\r\n"); + let req = L7Request { + action: "POST".into(), + target: "/api".into(), + query_params: HashMap::new(), + raw_header: raw, + body_length: BodyLength::Chunked, + }; + let err = + buffer_request_body_for_middleware(&req, &mut tokio::io::empty(), None, 64 * 1024) + .await + .expect_err("invalid chunk framing must surface as an error"); + + assert!( + err.to_string().contains("Invalid chunk size token"), + "unexpected error: {err}" + ); + assert!( + !err.to_string().contains("over_capacity") + && !err.to_string().contains("exceeds configured buffer limit"), + "protocol errors must not be reported as over-capacity: {err}" ); } #[tokio::test] - async fn credential_rewrite_chunked_request_with_trailers_is_rejected() { + async fn middleware_chunked_over_capacity_still_maps_to_buffer_over_capacity() { + let max_body_bytes = 32; + let payload = "hello world that is definitely over the tiny cap"; + let wire = format!("{:x}\r\n{payload}\r\n0\r\n\r\n", payload.len()); + let mut raw = + b"POST /api HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n" + .to_vec(); + raw.extend_from_slice(wire.as_bytes()); let req = L7Request { action: "POST".into(), target: "/api".into(), query_params: HashMap::new(), - raw_header: Vec::new(), + raw_header: raw, body_length: BodyLength::Chunked, }; - let headers = - b"POST /api HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\nTrailer: Digest\r\n\r\n"; - let result = collect_and_rewrite_request_body( - &req, - &mut tokio::io::empty(), - headers, - std::str::from_utf8(headers).expect("headers"), - b"1\r\nx\r\n0\r\nDigest: sha-256=:abc123:\r\n\r\n", - None, - None, - ) - .await; - let Err(error) = result else { - panic!("credential rewriting must reject non-empty chunked trailers") - }; + + let result = + buffer_request_body_for_middleware(&req, &mut tokio::io::empty(), None, max_body_bytes) + .await + .expect("over-capacity is a BufferResult, not an Err"); + assert!( - error.to_string().contains( - "chunked request trailers are not supported when buffering or transforming request bodies" - ), - "unexpected error: {error}" + matches!(result, BufferResult::OverCapacity { recoverable: false }), + "expected OverCapacity, got {result:?}" ); } #[tokio::test] - async fn collect_chunked_body_reads_payload_in_blocks() { - let payload_len = 64 * 1024; - 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 mut client = CountingReader::new(wire); + async fn middleware_none_body_with_header_overshoot_is_rejected() { + // Mimic the forward-proxy multi-byte read: headers plus pipelined bytes + // after `\r\n\r\n` on a request with no body framing. + let raw = b"GET /api HTTP/1.1\r\nHost: example.com\r\n\r\nGET /other HTTP/1.1\r\n"; + let req = L7Request { + action: "GET".into(), + target: "/api".into(), + query_params: HashMap::new(), + raw_header: raw.to_vec(), + body_length: BodyLength::None, + }; - let body = collect_chunked_body( - &mut client, - &[], - None, - Some(openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES), - ) - .await - .expect("chunked body should decode"); + let err = + buffer_request_body_for_middleware(&req, &mut tokio::io::empty(), None, 64 * 1024) + .await + .expect_err("read-ahead leftovers must not become a request body"); - assert_eq!(body.len(), payload_len); assert!( - client.reads <= 32, - "payload should be read in blocks, observed {} reads", - client.reads + err.to_string().contains("no body framing"), + "unexpected error: {err}" ); } #[tokio::test] - async fn extreme_content_length_is_rejected_before_allocation() { + async fn middleware_none_body_without_overshoot_buffers_empty() { + let raw = b"GET /api HTTP/1.1\r\nHost: example.com\r\n\r\n"; let req = L7Request { - action: "POST".into(), - target: "/upload".into(), + action: "GET".into(), + target: "/api".into(), query_params: HashMap::new(), - raw_header: b"POST /upload HTTP/1.1\r\nHost: example.com\r\nContent-Length: 18446744073709551615\r\n\r\n".to_vec(), - body_length: BodyLength::ContentLength(u64::MAX), + raw_header: raw.to_vec(), + body_length: BodyLength::None, }; - let (mut client, _peer) = tokio::io::duplex(1); - let result = buffer_request_body_for_middleware( - &req, + let result = + buffer_request_body_for_middleware(&req, &mut tokio::io::empty(), None, 64 * 1024) + .await + .expect("empty no-body request should buffer"); + + match result { + BufferResult::Buffered(buffered) => { + assert!(buffered.body.is_empty()); + let rebuilt = rebuild_request_with_buffered_body( + &req, + &buffered.headers, + &buffered.body, + &[], + ) + .expect("rebuild no-body request"); + assert!(matches!(rebuilt.body_length, BodyLength::None)); + let text = String::from_utf8(rebuilt.raw_header).unwrap(); + assert!( + !text.to_ascii_lowercase().contains("content-length"), + "rebuild must preserve no-body framing: {text}" + ); + assert!(!text.contains("GET /other")); + } + other @ BufferResult::OverCapacity { .. } => { + panic!("expected Buffered, got {other:?}") + } + } + } + + /// SEC-009: Bare LF in headers enables header injection. + #[tokio::test] + async fn reject_bare_lf_in_headers() { + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + // Bare \n between two header values creates a parsing discrepancy + writer + .write_all( + b"GET /api HTTP/1.1\r\nX-Injected: value\nEvil: header\r\nHost: x\r\n\r\n", + ) + .await + .unwrap(); + }); + let result = parse_http_request( &mut client, - None, - openshell_supervisor_middleware::MAX_MIDDLEWARE_PAYLOAD_BYTES, + &crate::l7::path::CanonicalizeOptions::default(), ) - .await - .expect("oversized body should produce a capacity result"); + .await; + assert!(result.is_err(), "Must reject headers with bare LF"); + } - assert!(matches!( - result, - BufferResult::OverCapacity { recoverable: true } - )); + /// SEC-009: Invalid UTF-8 in headers creates interpretation gap. + #[tokio::test] + async fn reject_invalid_utf8_in_headers() { + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + let mut raw = Vec::new(); + raw.extend_from_slice(b"GET /api HTTP/1.1\r\nHost: x\r\nX-Bad: \xc0\xaf\r\n\r\n"); + writer.write_all(&raw).await.unwrap(); + }); + let result = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await; + assert!(result.is_err(), "Must reject headers with invalid UTF-8"); } #[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); + async fn reject_malformed_header_fields_before_forwarding() { + let cases = [ + ( + "space continuation", + b"GET /api HTTP/1.1\r\nX-Test: first\r\n continued\r\nHost: x\r\n\r\n".as_slice(), + ), + ( + "tab continuation", + b"GET /api HTTP/1.1\r\nX-Test: first\r\n\tcontinued\r\nHost: x\r\n\r\n".as_slice(), + ), + ( + "missing colon", + b"GET /api HTTP/1.1\r\nX-Test value\r\nHost: x\r\n\r\n".as_slice(), + ), + ( + "whitespace before colon", + b"GET /api HTTP/1.1\r\nX-Test : value\r\nHost: x\r\n\r\n".as_slice(), + ), + ( + "invalid field-name token", + b"GET /api HTTP/1.1\r\nX@Test: value\r\nHost: x\r\n\r\n".as_slice(), + ), + ]; - 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"); + for (case, raw) in cases { + let (mut client, mut writer) = tokio::io::duplex(4096); + writer.write_all(raw).await.unwrap(); + let result = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await; + assert!(result.is_err(), "{case} must be rejected before forwarding"); + } + } - assert_eq!(body.len(), payload_len); + /// SEC-009: Reject unsupported HTTP versions. + #[tokio::test] + async fn reject_invalid_http_version() { + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + writer + .write_all(b"GET /api JUNK/9.9\r\nHost: x\r\n\r\n") + .await + .unwrap(); + }); + let result = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await; + assert!(result.is_err(), "Must reject unsupported HTTP version"); } #[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); + async fn parse_http_request_canonicalizes_target_and_rewrites_raw_header() { + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + writer + .write_all(b"GET /public/../secret HTTP/1.1\r\nHost: api.example.com\r\n\r\n") + .await + .unwrap(); + }); + let req = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .expect("request should parse") + .expect("request should exist"); + // Path fed to OPA evaluation is canonical. + assert_eq!(req.target, "/secret"); + // raw_header (forwarded byte-for-byte to upstream) is also canonical + // — this is the invariant the L7 canonicalization PR must uphold. + assert_eq!( + req.raw_header, b"GET /secret HTTP/1.1\r\nHost: api.example.com\r\n\r\n", + "outbound request line must carry the canonical path" + ); + } - 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"); + #[tokio::test] + async fn parse_http_request_rejects_absolute_authority_mismatched_with_host() { + let (mut client, mut peer) = tokio::io::duplex(1024); + peer.write_all( + b"GET http://attacker.example.test/v1 HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + ) + .await + .unwrap(); + let error = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .expect_err("absolute-form authority mismatch must fail closed"); assert!( - matches!(error, CollectChunkedError::OverCapacity), - "over-cap wire body must be OverCapacity, got {error:?}" + error + .to_string() + .contains("request authority does not match the Host header"), + "{error}" ); } - #[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"); + #[test] + fn origin_form_targets_with_embedded_urls_use_host_authority() { + let host: http::uri::Authority = "api.example.test".parse().unwrap(); - let body = collect_chunked_body(&mut tokio::io::empty(), &wire, None, Some(max_body_bytes)) - .await - .expect("middleware cap should control chunked body collection"); + for target in ["/fetch/http://example.test", "/?next=http://example.test"] { + assert!( + absolute_form_uri(target).unwrap().is_none(), + "{target} must remain origin-form" + ); + validate_absolute_form_authority(target, Some(&host)) + .expect("embedded URL must not trigger absolute-form validation"); - assert_eq!(body.len(), payload_len); + let raw = format!("GET {target} HTTP/1.1\r\nHost: api.example.test\r\n\r\n"); + let authority = request_authority(raw.as_bytes(), Some(443)) + .unwrap() + .expect("origin-form request with Host must have an authority"); + assert_eq!(authority.authority, host); + assert_eq!(authority.effective_port, 443); + } } #[tokio::test] - async fn middleware_chunked_invalid_size_is_not_over_capacity() { - let mut raw = - b"POST /api HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n" - .to_vec(); - raw.extend_from_slice(b"xyz\r\n"); - let req = L7Request { - action: "POST".into(), - target: "/api".into(), - query_params: HashMap::new(), - raw_header: raw, - body_length: BodyLength::Chunked, - }; - let err = - buffer_request_body_for_middleware(&req, &mut tokio::io::empty(), None, 64 * 1024) - .await - .expect_err("invalid chunk framing must surface as an error"); + async fn parse_http_request_keeps_embedded_url_in_origin_form_path() { + let (mut client, mut peer) = tokio::io::duplex(1024); + peer.write_all( + b"GET /fetch/http://example.test?next=http://other.test HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + ) + .await + .unwrap(); - assert!( - err.to_string().contains("Invalid chunk size token"), - "unexpected error: {err}" - ); - assert!( - !err.to_string().contains("over_capacity") - && !err.to_string().contains("exceeds configured buffer limit"), - "protocol errors must not be reported as over-capacity: {err}" + let request = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .expect("embedded URL origin-form request must parse") + .expect("request must be present"); + assert_eq!(request.target, "/fetch/http:/example.test"); + assert_eq!( + request.raw_header, + b"GET /fetch/http:/example.test?next=http://other.test HTTP/1.1\r\nHost: api.example.test\r\n\r\n", ); } #[tokio::test] - async fn middleware_chunked_over_capacity_still_maps_to_buffer_over_capacity() { - let max_body_bytes = 32; - let payload = "hello world that is definitely over the tiny cap"; - let wire = format!("{:x}\r\n{payload}\r\n0\r\n\r\n", payload.len()); - let mut raw = - b"POST /api HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\n\r\n" - .to_vec(); - raw.extend_from_slice(wire.as_bytes()); - let req = L7Request { - action: "POST".into(), - target: "/api".into(), - query_params: HashMap::new(), - raw_header: raw, - body_length: BodyLength::Chunked, - }; - - let result = - buffer_request_body_for_middleware(&req, &mut tokio::io::empty(), None, max_body_bytes) + async fn parse_http_request_canonicalization_preserves_query_string() { + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + writer + .write_all(b"GET /public/../v1/list?limit=10&sort=asc HTTP/1.1\r\nHost: h\r\n\r\n") .await - .expect("over-capacity is a BufferResult, not an Err"); - - assert!( - matches!(result, BufferResult::OverCapacity { recoverable: false }), - "expected OverCapacity, got {result:?}" + .unwrap(); + }); + let req = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(req.target, "/v1/list"); + assert_eq!( + req.raw_header, b"GET /v1/list?limit=10&sort=asc HTTP/1.1\r\nHost: h\r\n\r\n", + "canonical rewrite must preserve the query string verbatim" ); } #[tokio::test] - async fn middleware_none_body_with_header_overshoot_is_rejected() { - // Mimic the forward-proxy multi-byte read: headers plus pipelined bytes - // after `\r\n\r\n` on a request with no body framing. - let raw = b"GET /api HTTP/1.1\r\nHost: example.com\r\n\r\nGET /other HTTP/1.1\r\n"; - let req = L7Request { - action: "GET".into(), - target: "/api".into(), - query_params: HashMap::new(), - raw_header: raw.to_vec(), - body_length: BodyLength::None, - }; - - let err = - buffer_request_body_for_middleware(&req, &mut tokio::io::empty(), None, 64 * 1024) + async fn parse_http_request_leaves_canonical_input_byte_for_byte() { + // When the input is already canonical, the raw_header must pass + // through unchanged — otherwise legitimate traffic pays a rewrite + // cost on every request. + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + writer + .write_all(b"GET /api/v1/users HTTP/1.1\r\nHost: api.example.com\r\n\r\n") .await - .expect_err("read-ahead leftovers must not become a request body"); + .unwrap(); + }); + let req = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(req.target, "/api/v1/users"); + assert_eq!( + req.raw_header, + b"GET /api/v1/users HTTP/1.1\r\nHost: api.example.com\r\n\r\n", + ); + } + #[tokio::test] + async fn parse_http_request_rejects_traversal_above_root() { + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + writer + .write_all(b"GET /.. HTTP/1.1\r\nHost: h\r\n\r\n") + .await + .unwrap(); + }); + let result = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await; assert!( - err.to_string().contains("no body framing"), - "unexpected error: {err}" + result.is_err(), + "a target that escapes the path root must be rejected at the parser" ); } #[tokio::test] - async fn middleware_none_body_without_overshoot_buffers_empty() { - let raw = b"GET /api HTTP/1.1\r\nHost: example.com\r\n\r\n"; - let req = L7Request { - action: "GET".into(), - target: "/api".into(), - query_params: HashMap::new(), - raw_header: raw.to_vec(), - body_length: BodyLength::None, - }; - - let result = - buffer_request_body_for_middleware(&req, &mut tokio::io::empty(), None, 64 * 1024) + async fn parse_http_request_accepts_encoded_slash_when_endpoint_opts_in() { + // GitLab-style endpoints legitimately embed `%2F` in path segments + // (e.g. `/api/v4/projects/group%2Fproject`). Passing a provider + // constructed with `allow_encoded_slash: true` models the + // endpoint-config wiring that flows from `L7EndpointConfig`. + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + writer + .write_all(b"GET /api/v4/projects/group%2Fproject HTTP/1.1\r\nHost: g\r\n\r\n") .await - .expect("empty no-body request should buffer"); + .unwrap(); + }); + let options = crate::l7::path::CanonicalizeOptions { + allow_encoded_slash: true, + ..Default::default() + }; + let req = parse_http_request(&mut client, &options) + .await + .unwrap() + .unwrap(); + assert_eq!(req.target, "/api/v4/projects/group%2Fproject"); + } - match result { - BufferResult::Buffered(buffered) => { - assert!(buffered.body.is_empty()); - let rebuilt = rebuild_request_with_buffered_body( - &req, - &buffered.headers, - &buffered.body, - &[], - ) - .expect("rebuild no-body request"); - assert!(matches!(rebuilt.body_length, BodyLength::None)); - let text = String::from_utf8(rebuilt.raw_header).unwrap(); - assert!( - !text.to_ascii_lowercase().contains("content-length"), - "rebuild must preserve no-body framing: {text}" - ); - assert!(!text.contains("GET /other")); - } - other @ BufferResult::OverCapacity { .. } => { - panic!("expected Buffered, got {other:?}") - } - } + #[tokio::test] + async fn parse_http_request_rejects_encoded_slash_by_default() { + // Default strict options must reject `%2F` — this is the security + // posture for endpoints where an encoded slash would let an + // attacker disagree with the upstream on segment boundaries. + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + writer + .write_all(b"GET /api/v4/projects/group%2Fproject HTTP/1.1\r\nHost: g\r\n\r\n") + .await + .unwrap(); + }); + let result = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await; + assert!( + result.is_err(), + "default options must reject encoded slashes in the path" + ); } - /// SEC-009: Bare LF in headers enables header injection. #[tokio::test] - async fn reject_bare_lf_in_headers() { + async fn parse_http_request_preserves_http_10_version_on_rewrite() { + let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { + writer + .write_all(b"GET /a/./b HTTP/1.0\r\nHost: h\r\n\r\n") + .await + .unwrap(); + }); + let req = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .unwrap() + .unwrap(); + assert_eq!(req.target, "/a/b"); + assert!( + req.raw_header.starts_with(b"GET /a/b HTTP/1.0\r\n"), + "rewrite must preserve the original HTTP version, got: {:?}", + String::from_utf8_lossy(&req.raw_header) + ); + } + + #[tokio::test] + async fn parse_http_request_splits_path_and_query_params() { let (mut client, mut writer) = tokio::io::duplex(4096); tokio::spawn(async move { - // Bare \n between two header values creates a parsing discrepancy writer .write_all( - b"GET /api HTTP/1.1\r\nX-Injected: value\nEvil: header\r\nHost: x\r\n\r\n", + b"GET /download?slug=my%2Fskill&tag=foo&tag=bar HTTP/1.1\r\nHost: x\r\n\r\n", ) .await .unwrap(); }); - let result = parse_http_request( + let req = parse_http_request( &mut client, &crate::l7::path::CanonicalizeOptions::default(), ) - .await; - assert!(result.is_err(), "Must reject headers with bare LF"); + .await + .expect("request should parse") + .expect("request should exist"); + assert_eq!(req.target, "/download"); + assert_eq!( + req.query_params.get("slug").cloned(), + Some(vec!["my/skill".to_string()]) + ); + assert_eq!( + req.query_params.get("tag").cloned(), + Some(vec!["foo".to_string(), "bar".to_string()]) + ); } - /// SEC-009: Invalid UTF-8 in headers creates interpretation gap. + /// Regression test: two pipelined requests in a single write must be + /// parsed independently. Before the fix, the 1024-byte `read()` buffer + /// could capture bytes from the second request, which were forwarded + /// upstream as body overflow of the first -- bypassing L7 policy checks. #[tokio::test] - async fn reject_invalid_utf8_in_headers() { + async fn parse_http_request_does_not_overread_next_request() { let (mut client, mut writer) = tokio::io::duplex(4096); + tokio::spawn(async move { - let mut raw = Vec::new(); - raw.extend_from_slice(b"GET /api HTTP/1.1\r\nHost: x\r\nX-Bad: \xc0\xaf\r\n\r\n"); - writer.write_all(&raw).await.unwrap(); + writer + .write_all( + b"GET /allowed HTTP/1.1\r\nHost: example.com\r\n\r\n\ + POST /blocked HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n", + ) + .await + .unwrap(); }); - let result = parse_http_request( + + let first = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .expect("first request should parse") + .expect("expected first request"); + assert_eq!(first.action, "GET"); + assert_eq!(first.target, "/allowed"); + assert!(first.query_params.is_empty()); + assert_eq!( + first.raw_header, b"GET /allowed HTTP/1.1\r\nHost: example.com\r\n\r\n", + "raw_header must contain only the first request's headers" + ); + + let second = parse_http_request( &mut client, &crate::l7::path::CanonicalizeOptions::default(), ) + .await + .expect("second request should parse") + .expect("expected second request"); + assert_eq!(second.action, "POST"); + assert_eq!(second.target, "/blocked"); + assert!(second.query_params.is_empty()); + } + + #[test] + fn http_method_detection() { + assert!(looks_like_http(b"GET / HTTP/1.1\r\n")); + assert!(looks_like_http(b"POST /api HTTP/1.1\r\n")); + assert!(looks_like_http(b"DELETE /foo HTTP/1.1\r\n")); + assert!(could_be_http_request_prefix(b"GE")); + assert!(!could_be_http_request_prefix(b"GET ")); + assert!(!looks_like_http(b"\x00\x00\x00\x08")); // Postgres + assert!(!looks_like_http(HTTP2_PRIOR_KNOWLEDGE_PREFACE)); + assert!(!looks_like_http(b"HELLO")); // Unknown + } + + #[test] + fn http2_prior_knowledge_detection() { + assert!(looks_like_http2_prior_knowledge( + HTTP2_PRIOR_KNOWLEDGE_PREFACE + )); + assert!(looks_like_http2_prior_knowledge( + &HTTP2_PRIOR_KNOWLEDGE_PREFACE[..8] + )); + assert!(could_be_http2_prior_knowledge_prefix(b"PRI * H")); + assert!(!looks_like_http2_prior_knowledge(b"PRI * H")); + assert!(!looks_like_http2_prior_knowledge(b"PRI / HTTP/1.1\r\n")); + } + + #[test] + fn test_parse_status_code() { + assert_eq!( + parse_status_code("HTTP/1.1 200 OK\r\nHost: x\r\n\r\n"), + Some(200) + ); + assert_eq!( + parse_status_code("HTTP/1.1 204 No Content\r\n\r\n"), + Some(204) + ); + assert_eq!( + parse_status_code("HTTP/1.1 304 Not Modified\r\n\r\n"), + Some(304) + ); + assert_eq!( + parse_status_code("HTTP/1.1 100 Continue\r\n\r\n"), + Some(100) + ); + assert_eq!(parse_status_code(""), None); + } + + #[test] + fn test_parse_connection_close() { + assert!(parse_connection_close( + "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" + )); + assert!(!parse_connection_close( + "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\n\r\n" + )); + assert!(!parse_connection_close( + "HTTP/1.1 200 OK\r\nHost: x\r\n\r\n" + )); + } + + #[test] + fn test_response_is_event_stream() { + assert!(response_is_event_stream( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n" + )); + assert!(response_is_event_stream( + "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream; charset=utf-8\r\n\r\n" + )); + assert!(!response_is_event_stream( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n" + )); + } + + #[test] + fn test_is_bodiless_response() { + assert!(is_bodiless_response("HEAD", 200)); + assert!(is_bodiless_response("GET", 100)); + assert!(is_bodiless_response("GET", 199)); + assert!(is_bodiless_response("GET", 204)); + assert!(is_bodiless_response("GET", 304)); + assert!(!is_bodiless_response("GET", 200)); + assert!(!is_bodiless_response("POST", 201)); + } + + fn response_middleware_fixture( + script: ResponseRelayScript, + ) -> ( + openshell_supervisor_middleware::ChainRunner, + Vec, + ) { + response_middleware_fixture_with_error( + script, + openshell_supervisor_middleware::OnError::FailClosed, + ) + } + + fn response_middleware_fixture_with_error( + script: ResponseRelayScript, + on_error: openshell_supervisor_middleware::OnError, + ) -> ( + openshell_supervisor_middleware::ChainRunner, + Vec, + ) { + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(ResponseRelayService { + script, + })); + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "response".into(), + implementation: "test/response-relay".into(), + order: 0, + config: prost_types::Struct::default(), + on_error, + }]; + (runner, chain) + } + + fn response_middleware_context<'a>( + runner: &'a openshell_supervisor_middleware::ChainRunner, + chain: &'a [openshell_supervisor_middleware::ChainEntry], + method: &str, + ) -> HttpResponseMiddlewareRelay<'a> { + HttpResponseMiddlewareRelay { + chain, + runner, + request_context: RequestContext { + request_id: "request-1".into(), + sandbox_id: "sandbox-1".into(), + ..Default::default() + }, + target: HttpRequestTarget { + scheme: "https".into(), + host: "example.test".into(), + port: 443, + method: method.into(), + path: "/data".into(), + query: String::new(), + }, + policy_name: "test-policy", + generation_guard: None, + whole_body_timeout: std::time::Duration::from_secs(120), + } + } + + async fn run_response_middleware_relay( + response: &'static [u8], + method: &str, + script: ResponseRelayScript, + ) -> (Result, Vec) { + run_response_middleware_relay_with_error( + response, + method, + script, + openshell_supervisor_middleware::OnError::FailClosed, + ) + .await + } + + async fn run_response_middleware_relay_with_error( + response: &'static [u8], + method: &str, + script: ResponseRelayScript, + on_error: openshell_supervisor_middleware::OnError, + ) -> (Result, Vec) { + run_response_middleware_relay_with_timeout( + response, + method, + script, + on_error, + std::time::Duration::from_secs(120), + ) + .await + } + + async fn run_response_middleware_relay_with_timeout( + response: &'static [u8], + method: &str, + script: ResponseRelayScript, + on_error: openshell_supervisor_middleware::OnError, + whole_body_timeout: std::time::Duration, + ) -> (Result, Vec) { + let (runner, chain) = response_middleware_fixture_with_error(script, on_error); + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(16 * 1024); + let (mut client_read, mut client_write) = tokio::io::duplex(16 * 1024); + tokio::spawn(async move { + upstream_write.write_all(response).await.unwrap(); + upstream_write.shutdown().await.unwrap(); + }); + let mut middleware = response_middleware_context(&runner, &chain, method); + middleware.whole_body_timeout = whole_body_timeout; + let outcome = relay_response( + method, + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + Some(middleware), + ) .await; - assert!(result.is_err(), "Must reject headers with invalid UTF-8"); + drop(client_write); + let mut delivered = Vec::new(); + client_read.read_to_end(&mut delivered).await.unwrap(); + (outcome, delivered) } #[tokio::test] - async fn reject_malformed_header_fields_before_forwarding() { - let cases = [ - ( - "space continuation", - b"GET /api HTTP/1.1\r\nX-Test: first\r\n continued\r\nHost: x\r\n\r\n".as_slice(), - ), - ( - "tab continuation", - b"GET /api HTTP/1.1\r\nX-Test: first\r\n\tcontinued\r\nHost: x\r\n\r\n".as_slice(), - ), - ( - "missing colon", - b"GET /api HTTP/1.1\r\nX-Test value\r\nHost: x\r\n\r\n".as_slice(), - ), - ( - "whitespace before colon", - b"GET /api HTTP/1.1\r\nX-Test : value\r\nHost: x\r\n\r\n".as_slice(), - ), - ( - "invalid field-name token", - b"GET /api HTTP/1.1\r\nX@Test: value\r\nHost: x\r\n\r\n".as_slice(), - ), - ]; + async fn response_middleware_headers_only_mutates_head_and_preserves_framing() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nCache-Control: public\r\n\r\nhello", + "GET", + ResponseRelayScript::HeadersOnly, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.contains("cache-control: private\r\n"), + "{delivered}" + ); + assert!(delivered.contains("Content-Length: 5\r\n"), "{delivered}"); + assert!( + !delivered.to_ascii_lowercase().contains("transfer-encoding"), + "{delivered}" + ); + assert!(delivered.ends_with("\r\n\r\nhello"), "{delivered}"); + } - for (case, raw) in cases { - let (mut client, mut writer) = tokio::io::duplex(4096); - writer.write_all(raw).await.unwrap(); - let result = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), - ) - .await; - assert!(result.is_err(), "{case} must be rejected before forwarding"); - } + #[tokio::test] + async fn response_middleware_headers_only_preserves_chunked_and_close_delimited_bodies() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n2;ext=yes\r\nhe\r\n3\r\nllo\r\n0\r\n\r\n", + "GET", + ResponseRelayScript::HeadersOnly, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.contains("Transfer-Encoding: chunked\r\n"), + "{delivered}" + ); + assert!( + delivered.ends_with("2;ext=yes\r\nhe\r\n3\r\nllo\r\n0\r\n\r\n"), + "{delivered}" + ); + + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nhello", + "GET", + ResponseRelayScript::HeadersOnly, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + assert!( + String::from_utf8(delivered) + .unwrap() + .ends_with("\r\n\r\nhello") + ); } - /// SEC-009: Reject unsupported HTTP versions. #[tokio::test] - async fn reject_invalid_http_version() { - let (mut client, mut writer) = tokio::io::duplex(4096); - tokio::spawn(async move { - writer - .write_all(b"GET /api JUNK/9.9\r\nHost: x\r\n\r\n") - .await - .unwrap(); - }); - let result = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + async fn response_middleware_whole_body_delays_commit_and_sets_length() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nETag: stale\r\n\r\nhello", + "GET", + ResponseRelayScript::WholeBody, ) .await; - assert!(result.is_err(), "Must reject unsupported HTTP version"); + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.contains("Content-Length: 11\r\n"), "{delivered}"); + assert!( + !delivered.to_ascii_lowercase().contains("etag:"), + "{delivered}" + ); + assert!(delivered.ends_with("\r\n\r\nwhole:hello"), "{delivered}"); } #[tokio::test] - async fn parse_http_request_canonicalizes_target_and_rewrites_raw_header() { - let (mut client, mut writer) = tokio::io::duplex(4096); - tokio::spawn(async move { - writer - .write_all(b"GET /public/../secret HTTP/1.1\r\nHost: api.example.com\r\n\r\n") - .await - .unwrap(); - }); - let req = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + async fn response_middleware_preflight_block_returns_canonical_403() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + ResponseRelayScript::BlockPreflight, ) - .await - .expect("request should parse") - .expect("request should exist"); - // Path fed to OPA evaluation is canonical. - assert_eq!(req.target, "/secret"); - // raw_header (forwarded byte-for-byte to upstream) is also canonical - // — this is the invariant the L7 canonicalization PR must uphold. - assert_eq!( - req.raw_header, b"GET /secret HTTP/1.1\r\nHost: api.example.com\r\n\r\n", - "outbound request line must carry the canonical path" + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.starts_with("HTTP/1.1 403 Forbidden\r\n"), + "{delivered}" ); + assert!( + delivered.contains("\"error\":\"middleware_denied\""), + "{delivered}" + ); + assert!( + delivered.contains("\"reason_code\":\"content_match\""), + "{delivered}" + ); + assert!(delivered.contains("Connection: close\r\n"), "{delivered}"); } #[tokio::test] - async fn parse_http_request_rejects_absolute_authority_mismatched_with_host() { - let (mut client, mut peer) = tokio::io::duplex(1024); - peer.write_all( - b"GET http://attacker.example.test/v1 HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + async fn response_middleware_head_block_reports_length_without_body() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n", + "HEAD", + ResponseRelayScript::BlockPreflight, ) - .await - .unwrap(); + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let header_end = delivered + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + let head = String::from_utf8(delivered[..header_end].to_vec()).unwrap(); + assert!(head.starts_with("HTTP/1.1 403 Forbidden\r\n"), "{head}"); + assert!(head.contains("Content-Length: "), "{head}"); + assert_eq!(delivered.len(), header_end); + } - let error = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + #[tokio::test] + async fn response_middleware_whole_body_block_returns_403_before_commit() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + ResponseRelayScript::BlockWholeBody, ) - .await - .expect_err("absolute-form authority mismatch must fail closed"); + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let delivered = String::from_utf8(delivered).unwrap(); assert!( - error - .to_string() - .contains("request authority does not match the Host header"), - "{error}" + delivered.starts_with("HTTP/1.1 403 Forbidden\r\n"), + "{delivered}" ); + assert!(!delivered.contains("HTTP/1.1 200 OK"), "{delivered}"); } - #[test] - fn origin_form_targets_with_embedded_urls_use_host_authority() { - let host: http::uri::Authority = "api.example.test".parse().unwrap(); - - for target in ["/fetch/http://example.test", "/?next=http://example.test"] { - assert!( - absolute_form_uri(target).unwrap().is_none(), - "{target} must remain origin-form" - ); - validate_absolute_form_authority(target, Some(&host)) - .expect("embedded URL must not trigger absolute-form validation"); - - let raw = format!("GET {target} HTTP/1.1\r\nHost: api.example.test\r\n\r\n"); - let authority = request_authority(raw.as_bytes(), Some(443)) - .unwrap() - .expect("origin-form request with Host must have an authority"); - assert_eq!(authority.authority, host); - assert_eq!(authority.effective_port, 443); - } + #[tokio::test] + async fn response_middleware_stream_block_aborts_after_commit_without_error_bytes() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + ResponseRelayScript::BlockStream, + ) + .await; + assert!(outcome.is_err()); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.starts_with("HTTP/1.1 200 OK\r\n"), "{delivered}"); + assert!(!delivered.contains("middleware_denied"), "{delivered}"); + assert!( + !delivered.contains("response_delivery_failed"), + "{delivered}" + ); } #[tokio::test] - async fn parse_http_request_keeps_embedded_url_in_origin_form_path() { - let (mut client, mut peer) = tokio::io::duplex(1024); - peer.write_all( - b"GET /fetch/http://example.test?next=http://other.test HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + async fn response_middleware_whole_body_timeout_obeys_failure_policy() { + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; + let (outcome, delivered) = run_response_middleware_relay_with_timeout( + response, + "GET", + ResponseRelayScript::SlowWholeBody, + openshell_supervisor_middleware::OnError::FailOpen, + std::time::Duration::from_millis(15), ) - .await - .unwrap(); + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + assert!( + String::from_utf8(delivered) + .unwrap() + .ends_with("\r\n\r\nhello") + ); - let request = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + let (outcome, delivered) = run_response_middleware_relay_with_timeout( + response, + "GET", + ResponseRelayScript::SlowWholeBody, + openshell_supervisor_middleware::OnError::FailClosed, + std::time::Duration::from_millis(15), ) - .await - .expect("embedded URL origin-form request must parse") - .expect("request must be present"); - assert_eq!(request.target, "/fetch/http:/example.test"); - assert_eq!( - request.raw_header, - b"GET /fetch/http:/example.test?next=http://other.test HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + assert!( + String::from_utf8(delivered) + .unwrap() + .contains("response_delivery_failed") ); } #[tokio::test] - async fn parse_http_request_canonicalization_preserves_query_string() { - let (mut client, mut writer) = tokio::io::duplex(4096); + async fn response_middleware_whole_body_timeout_does_not_reset_for_trickle_input() { + let (runner, chain) = response_middleware_fixture_with_error( + ResponseRelayScript::WholeBody, + openshell_supervisor_middleware::OnError::FailOpen, + ); + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(16 * 1024); + let (mut client_read, mut client_write) = tokio::io::duplex(16 * 1024); tokio::spawn(async move { - writer - .write_all(b"GET /public/../v1/list?limit=10&sort=asc HTTP/1.1\r\nHost: h\r\n\r\n") + upstream_write + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nh") .await .unwrap(); + for byte in b"ello" { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + upstream_write.write_all(&[*byte]).await.unwrap(); + } + upstream_write.shutdown().await.unwrap(); }); - let req = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + let mut middleware = response_middleware_context(&runner, &chain, "GET"); + middleware.whole_body_timeout = std::time::Duration::from_millis(20); + let outcome = relay_response( + "GET", + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + Some(middleware), ) - .await - .unwrap() - .unwrap(); - assert_eq!(req.target, "/v1/list"); - assert_eq!( - req.raw_header, b"GET /v1/list?limit=10&sort=asc HTTP/1.1\r\nHost: h\r\n\r\n", - "canonical rewrite must preserve the query string verbatim" + .await; + drop(client_write); + let mut delivered = Vec::new(); + client_read.read_to_end(&mut delivered).await.unwrap(); + + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.ends_with("5\r\nhello\r\n0\r\n\r\n"), + "{delivered}" ); + assert!(!delivered.contains("whole:hello"), "{delivered}"); } #[tokio::test] - async fn parse_http_request_leaves_canonical_input_byte_for_byte() { - // When the input is already canonical, the raw_header must pass - // through unchanged — otherwise legitimate traffic pays a rewrite - // cost on every request. - let (mut client, mut writer) = tokio::io::duplex(4096); - tokio::spawn(async move { - writer - .write_all(b"GET /api/v1/users HTTP/1.1\r\nHost: api.example.com\r\n\r\n") - .await - .unwrap(); - }); - let req = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + async fn response_middleware_streams_normalized_chunks_and_preserves_trailers() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nTrailer: x-upstream\r\n\r\n2;ext=yes\r\nhe\r\n3\r\nllo\r\n0\r\nX-Upstream: kept\r\n\r\n", + "GET", + ResponseRelayScript::Stream, ) - .await - .unwrap() - .unwrap(); - assert_eq!(req.target, "/api/v1/users"); - assert_eq!( - req.raw_header, - b"GET /api/v1/users HTTP/1.1\r\nHost: api.example.com\r\n\r\n", - ); + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.contains("Trailer: x-upstream\r\n"), "{delivered}"); + assert!(delivered.contains("5\r\nHELLO\r\n"), "{delivered}"); + assert!(delivered.contains("x-upstream: kept\r\n"), "{delivered}"); + assert!(!delivered.contains("digest:"), "{delivered}"); + assert!(!delivered.contains("ext=yes"), "{delivered}"); } #[tokio::test] - async fn parse_http_request_rejects_traversal_above_root() { - let (mut client, mut writer) = tokio::io::duplex(4096); - tokio::spawn(async move { - writer - .write_all(b"GET /.. HTTP/1.1\r\nHost: h\r\n\r\n") - .await - .unwrap(); - }); - let result = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + async fn response_middleware_rejects_malformed_response_fields_before_commit() { + for response in [ + b"HTTP/1.1 200 OK\r\nBad Name: value\r\nContent-Length: 0\r\n\r\n".as_slice(), + b"HTTP/1.1 200 OK\r\nTrailer: content-length\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n".as_slice(), + b"HTTP/1.1 200 OK\r\nConnection: x-private\r\nTrailer: x-private\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n".as_slice(), + ] { + let (outcome, delivered) = run_response_middleware_relay( + response, + "GET", + ResponseRelayScript::WholeBody, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.starts_with("HTTP/1.1 502 Bad Gateway\r\n"), + "{delivered}" + ); + } + } + + #[tokio::test] + async fn response_middleware_rejects_malformed_or_protected_upstream_trailers_atomically() { + for response in [ + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nBad Name: value\r\n\r\n".as_slice(), + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\nContent-Length: 7\r\n\r\n".as_slice(), + ] { + let (outcome, delivered) = run_response_middleware_relay( + response, + "GET", + ResponseRelayScript::WholeBody, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.starts_with("HTTP/1.1 502 Bad Gateway\r\n"), + "{delivered}" + ); + assert!(!delivered.contains("whole:hello"), "{delivered}"); + } + } + + #[tokio::test] + async fn response_middleware_never_uses_chunked_framing_for_http_10() { + for (script, expected_body) in [ + (ResponseRelayScript::HeadersOnly, "hello"), + (ResponseRelayScript::Stream, "HELLO"), + (ResponseRelayScript::WholeBodyWithTrailer, "whole:hello"), + ] { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + script, + ) + .await; + assert!(outcome.is_ok()); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.starts_with("HTTP/1.0 200 OK\r\n"), "{delivered}"); + assert!( + !delivered.to_ascii_lowercase().contains("transfer-encoding"), + "{delivered}" + ); + assert!( + !delivered.to_ascii_lowercase().contains("trailer:"), + "{delivered}" + ); + assert!(delivered.ends_with(expected_body), "{delivered}"); + } + } + + #[tokio::test] + async fn response_middleware_preserves_baseline_connection_outcomes() { + let (outcome, _) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello", + "GET", + ResponseRelayScript::HeadersOnly, ) .await; - assert!( - result.is_err(), - "a target that escapes the path root must be rejected at the parser" - ); + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\n\r\n", + "GET", + ResponseRelayScript::HeadersOnly, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + assert!(String::from_utf8(delivered).unwrap().ends_with("\r\n\r\n")); } #[tokio::test] - async fn parse_http_request_accepts_encoded_slash_when_endpoint_opts_in() { - // GitLab-style endpoints legitimately embed `%2F` in path segments - // (e.g. `/api/v4/projects/group%2Fproject`). Passing a provider - // constructed with `allow_encoded_slash: true` models the - // endpoint-config wiring that flows from `L7EndpointConfig`. - let (mut client, mut writer) = tokio::io::duplex(4096); - tokio::spawn(async move { - writer - .write_all(b"GET /api/v4/projects/group%2Fproject HTTP/1.1\r\nHost: g\r\n\r\n") - .await - .unwrap(); - }); - let options = crate::l7::path::CanonicalizeOptions { - allow_encoded_slash: true, - ..Default::default() - }; - let req = parse_http_request(&mut client, &options) - .await - .unwrap() - .unwrap(); - assert_eq!(req.target, "/api/v4/projects/group%2Fproject"); + async fn response_middleware_forwards_interim_head_before_final_preflight() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 100 Continue\r\nX-Interim: yes\r\n\r\nHTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok", + "GET", + ResponseRelayScript::WholeBody, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.starts_with("HTTP/1.1 100 Continue\r\nX-Interim: yes\r\n\r\n")); + assert!(delivered.ends_with("whole:ok"), "{delivered}"); + } + + #[tokio::test] + async fn response_middleware_handles_bodyless_responses_without_body_events() { + for response in [ + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n".as_slice(), + b"HTTP/1.1 304 Not Modified\r\nContent-Length: 5\r\n\r\n".as_slice(), + ] { + let (outcome, delivered) = + run_response_middleware_relay(response, "GET", ResponseRelayScript::HeadersOnly) + .await; + assert!(outcome.is_ok()); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.contains("cache-control: private\r\n"), + "{delivered}" + ); + assert!(delivered.ends_with("\r\n\r\n"), "{delivered}"); + } + + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n", + "HEAD", + ResponseRelayScript::HeadersOnly, + ) + .await; + assert!(outcome.is_ok()); + let split = delivered + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + assert_eq!(&delivered[split..], b""); } #[tokio::test] - async fn parse_http_request_rejects_encoded_slash_by_default() { - // Default strict options must reject `%2F` — this is the security - // posture for endpoints where an encoded slash would let an - // attacker disagree with the upstream on segment boundaries. - let (mut client, mut writer) = tokio::io::duplex(4096); - tokio::spawn(async move { - writer - .write_all(b"GET /api/v4/projects/group%2Fproject HTTP/1.1\r\nHost: g\r\n\r\n") - .await - .unwrap(); - }); - let result = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + async fn response_middleware_bypasses_protocol_upgrades() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n\r\n\x81\x02ok", + "GET", + ResponseRelayScript::HeadersOnly, ) .await; - assert!( - result.is_err(), - "default options must reject encoded slashes in the path" - ); + assert!(matches!( + outcome.unwrap(), + RelayOutcome::Upgraded { ref overflow, .. } if overflow == b"\x81\x02ok" + )); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(!delivered.contains("cache-control: private"), "{delivered}"); } #[tokio::test] - async fn parse_http_request_preserves_http_10_version_on_rewrite() { - let (mut client, mut writer) = tokio::io::duplex(4096); - tokio::spawn(async move { - writer - .write_all(b"GET /a/./b HTTP/1.0\r\nHost: h\r\n\r\n") - .await - .unwrap(); - }); - let req = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + async fn response_middleware_fail_closed_before_commit_returns_canonical_502() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + ResponseRelayScript::InvalidWholeBodySequence, ) - .await - .unwrap() - .unwrap(); - assert_eq!(req.target, "/a/b"); + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let delivered = String::from_utf8(delivered).unwrap(); assert!( - req.raw_header.starts_with(b"GET /a/b HTTP/1.0\r\n"), - "rewrite must preserve the original HTTP version, got: {:?}", - String::from_utf8_lossy(&req.raw_header) + delivered.starts_with("HTTP/1.1 502 Bad Gateway\r\n"), + "{delivered}" ); + assert!(delivered.contains("\"error\":\"response_delivery_failed\"")); + assert!(delivered.contains( + "The upstream request may have completed, but OpenShell could not deliver its response. Retrying may repeat upstream side effects." + )); + assert!(!delivered.contains("invalid_body_sequence"), "{delivered}"); } #[tokio::test] - async fn parse_http_request_splits_path_and_query_params() { - let (mut client, mut writer) = tokio::io::duplex(4096); - tokio::spawn(async move { - writer - .write_all( - b"GET /download?slug=my%2Fskill&tag=foo&tag=bar HTTP/1.1\r\nHost: x\r\n\r\n", - ) - .await - .unwrap(); - }); - let req = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + async fn response_middleware_head_failure_reports_body_length_without_body() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n", + "HEAD", + ResponseRelayScript::WholeBody, ) - .await - .expect("request should parse") - .expect("request should exist"); - assert_eq!(req.target, "/download"); - assert_eq!( - req.query_params.get("slug").cloned(), - Some(vec!["my/skill".to_string()]) - ); - assert_eq!( - req.query_params.get("tag").cloned(), - Some(vec!["foo".to_string(), "bar".to_string()]) - ); + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let split = delivered + .windows(4) + .position(|window| window == b"\r\n\r\n") + .unwrap() + + 4; + let head = String::from_utf8(delivered[..split].to_vec()).unwrap(); + assert!(head.starts_with("HTTP/1.1 502 Bad Gateway\r\n"), "{head}"); + assert!(head.contains("Content-Length: "), "{head}"); + assert_eq!(&delivered[split..], b""); } - /// Regression test: two pipelined requests in a single write must be - /// parsed independently. Before the fix, the 1024-byte `read()` buffer - /// could capture bytes from the second request, which were forwarded - /// upstream as body overflow of the first -- bypassing L7 policy checks. #[tokio::test] - async fn parse_http_request_does_not_overread_next_request() { - let (mut client, mut writer) = tokio::io::duplex(4096); - - tokio::spawn(async move { - writer - .write_all( - b"GET /allowed HTTP/1.1\r\nHost: example.com\r\n\r\n\ - POST /blocked HTTP/1.1\r\nHost: example.com\r\nContent-Length: 0\r\n\r\n", - ) - .await - .unwrap(); - }); - - let first = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), - ) - .await - .expect("first request should parse") - .expect("expected first request"); - assert_eq!(first.action, "GET"); - assert_eq!(first.target, "/allowed"); - assert!(first.query_params.is_empty()); - assert_eq!( - first.raw_header, b"GET /allowed HTTP/1.1\r\nHost: example.com\r\n\r\n", - "raw_header must contain only the first request's headers" - ); - - let second = parse_http_request( - &mut client, - &crate::l7::path::CanonicalizeOptions::default(), + async fn response_middleware_fail_closed_after_commit_aborts_without_replacement() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + ResponseRelayScript::InvalidBodySequence, ) - .await - .expect("second request should parse") - .expect("expected second request"); - assert_eq!(second.action, "POST"); - assert_eq!(second.target, "/blocked"); - assert!(second.query_params.is_empty()); + .await; + assert!(outcome.is_err()); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.starts_with("HTTP/1.1 200 OK\r\n"), "{delivered}"); + assert!(!delivered.contains("502 Bad Gateway"), "{delivered}"); } - #[test] - fn http_method_detection() { - assert!(looks_like_http(b"GET / HTTP/1.1\r\n")); - assert!(looks_like_http(b"POST /api HTTP/1.1\r\n")); - assert!(looks_like_http(b"DELETE /foo HTTP/1.1\r\n")); - assert!(could_be_http_request_prefix(b"GE")); - assert!(!could_be_http_request_prefix(b"GET ")); - assert!(!looks_like_http(b"\x00\x00\x00\x08")); // Postgres - assert!(!looks_like_http(HTTP2_PRIOR_KNOWLEDGE_PREFACE)); - assert!(!looks_like_http(b"HELLO")); // Unknown + #[tokio::test] + async fn response_middleware_fail_open_preserves_input_before_and_after_commit() { + for (script, expected_framing) in [ + ( + ResponseRelayScript::InvalidWholeBodySequence, + "Content-Length: 5\r\n", + ), + ( + ResponseRelayScript::InvalidBodySequence, + "Transfer-Encoding: chunked\r\n", + ), + ] { + let (outcome, delivered) = run_response_middleware_relay_with_error( + b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello", + "GET", + script, + openshell_supervisor_middleware::OnError::FailOpen, + ) + .await; + assert!(outcome.is_ok()); + let delivered = String::from_utf8(delivered).unwrap(); + assert!(delivered.contains(expected_framing), "{delivered}"); + assert!(delivered.contains("hello"), "{delivered}"); + assert!(!delivered.contains("502 Bad Gateway"), "{delivered}"); + } } - #[test] - fn http2_prior_knowledge_detection() { - assert!(looks_like_http2_prior_knowledge( - HTTP2_PRIOR_KNOWLEDGE_PREFACE - )); - assert!(looks_like_http2_prior_knowledge( - &HTTP2_PRIOR_KNOWLEDGE_PREFACE[..8] - )); - assert!(could_be_http2_prior_knowledge_prefix(b"PRI * H")); - assert!(!looks_like_http2_prior_knowledge(b"PRI * H")); - assert!(!looks_like_http2_prior_knowledge(b"PRI / HTTP/1.1\r\n")); + #[tokio::test] + async fn response_middleware_stale_policy_generation_aborts_before_preflight() { + let policy_data = "network_policies: {}\n"; + let engine = OpaEngine::from_strings(TEST_POLICY, policy_data).unwrap(); + let guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + engine.reload(TEST_POLICY, policy_data).unwrap(); + let (runner, chain) = response_middleware_fixture(ResponseRelayScript::Stream); + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(4096); + let (mut client_read, mut client_write) = tokio::io::duplex(4096); + upstream_write + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello") + .await + .unwrap(); + upstream_write.shutdown().await.unwrap(); + let mut context = response_middleware_context(&runner, &chain, "GET"); + context.generation_guard = Some(&guard); + let outcome = relay_response( + "GET", + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + Some(context), + ) + .await; + assert!(outcome.is_err()); + drop(client_write); + let mut delivered = Vec::new(); + client_read.read_to_end(&mut delivered).await.unwrap(); + assert!(delivered.is_empty()); } - #[test] - fn test_parse_status_code() { - assert_eq!( - parse_status_code("HTTP/1.1 200 OK\r\nHost: x\r\n\r\n"), - Some(200) - ); - assert_eq!( - parse_status_code("HTTP/1.1 204 No Content\r\n\r\n"), - Some(204) - ); - assert_eq!( - parse_status_code("HTTP/1.1 304 Not Modified\r\n\r\n"), - Some(304) - ); - assert_eq!( - parse_status_code("HTTP/1.1 100 Continue\r\n\r\n"), - Some(100) - ); - assert_eq!(parse_status_code(""), None); + #[tokio::test] + async fn response_middleware_client_disconnect_aborts_stream_delivery() { + let (runner, chain) = response_middleware_fixture(ResponseRelayScript::Stream); + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(4096); + let (client_read, mut client_write) = tokio::io::duplex(4096); + drop(client_read); + upstream_write + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello") + .await + .unwrap(); + upstream_write.shutdown().await.unwrap(); + let outcome = relay_response( + "GET", + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + Some(response_middleware_context(&runner, &chain, "GET")), + ) + .await; + assert!(outcome.is_err()); } - #[test] - fn test_parse_connection_close() { - assert!(parse_connection_close( - "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n" - )); - assert!(!parse_connection_close( - "HTTP/1.1 200 OK\r\nConnection: keep-alive\r\n\r\n" - )); - assert!(!parse_connection_close( - "HTTP/1.1 200 OK\r\nHost: x\r\n\r\n" - )); + #[tokio::test] + async fn response_middleware_streams_close_delimited_body_with_owned_framing() { + let (outcome, delivered) = run_response_middleware_relay( + b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nhello", + "GET", + ResponseRelayScript::Stream, + ) + .await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + let delivered = String::from_utf8(delivered).unwrap(); + assert!( + delivered.contains("Transfer-Encoding: chunked\r\n"), + "{delivered}" + ); + assert!(delivered.contains("5\r\nHELLO\r\n"), "{delivered}"); } #[test] - fn test_response_is_event_stream() { - assert!(response_is_event_stream( - "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\r\n" - )); - assert!(response_is_event_stream( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream; charset=utf-8\r\n\r\n" - )); - assert!(!response_is_event_stream( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\r\n" - )); + fn response_middleware_ocsf_events_omit_content_headers_and_free_form_reasons() { + let target = HttpRequestTarget { + scheme: "https".into(), + host: "example.test".into(), + port: 443, + method: "GET".into(), + path: "/safe".into(), + query: String::new(), + }; + let events = http_response_middleware_invocation_events( + "policy", + &target, + 200, + &[openshell_supervisor_middleware::HttpResponseInvocation { + config_name: "scan".into(), + implementation: "example/scan".into(), + outcome: openshell_supervisor_middleware::HttpResponseInvocationOutcome::FailOpen, + sequence: Some(1), + input_size: 19, + output_size: None, + failed: true, + stage_disabled: true, + reason_code: Some("stable_reason".into()), + failure_category: Some("timeout".into()), + }], + ); + let json = events[0].to_json().unwrap().to_string(); + for forbidden in [ + "secret-response-body", + "authorization", + "content-length", + "middleware said secret", + "stable_reason", + ] { + assert!(!json.contains(forbidden), "{json}"); + } + assert!( + json.to_ascii_lowercase() + .contains("http_response_middleware"), + "{json}" + ); + assert!(json.contains("example/scan"), "{json}"); } #[test] - fn test_is_bodiless_response() { - assert!(is_bodiless_response("HEAD", 200)); - assert!(is_bodiless_response("GET", 100)); - assert!(is_bodiless_response("GET", 199)); - assert!(is_bodiless_response("GET", 204)); - assert!(is_bodiless_response("GET", 304)); - assert!(!is_bodiless_response("GET", 200)); - assert!(!is_bodiless_response("POST", 201)); + fn response_middleware_fail_open_dual_emits_sanitized_findings() { + let target = HttpRequestTarget { + scheme: "https".into(), + host: "example.test".into(), + port: 443, + method: "GET".into(), + path: "/safe".into(), + query: String::new(), + }; + for category in [ + "invalid_result", + "timeout", + "payload_capacity", + "session_capacity", + ] { + let invocation = openshell_supervisor_middleware::HttpResponseInvocation { + config_name: "scan".into(), + implementation: "example/scan".into(), + outcome: openshell_supervisor_middleware::HttpResponseInvocationOutcome::FailOpen, + sequence: Some(1), + input_size: 19, + output_size: None, + failed: true, + stage_disabled: true, + reason_code: None, + failure_category: Some(category.into()), + }; + assert_eq!( + http_response_middleware_invocation_events( + "policy", + &target, + 200, + std::slice::from_ref(&invocation), + ) + .len(), + 1 + ); + let finding = + http_response_middleware_fail_open_finding_event("policy", &target, &invocation) + .expect("fail-open failure must create a detection finding") + .to_json() + .unwrap() + .to_string(); + for expected in [ + "openshell.middleware.http_response_fail_open", + "example.test", + "pre_return", + category, + ] { + assert!(finding.contains(expected), "{finding}"); + } + assert!(!finding.contains("stable_reason"), "{finding}"); + } } #[tokio::test] @@ -5524,6 +8123,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5569,6 +8169,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5619,6 +8220,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5663,6 +8265,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5704,6 +8307,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5742,6 +8346,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5782,6 +8387,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5826,6 +8432,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5869,6 +8476,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5905,6 +8513,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5951,6 +8560,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -5998,6 +8608,7 @@ mod tests { &mut upstream_read, &mut client_write, RelayResponseOptions::default(), + None, ), ) .await @@ -7949,4 +10560,35 @@ mod tests { SigV4PayloadMode::UnsignedPayload ); } + + #[test] + fn response_body_transform_strips_stale_integrity_headers() { + let mut headers = [ + "accept-ranges", + "etag", + "content-md5", + "digest", + "content-digest", + "repr-digest", + "signature", + "signature-input", + "content-type", + ] + .into_iter() + .map(|name| HttpHeader { + name: name.to_string(), + value: "value".to_string(), + }) + .collect(); + + strip_response_integrity_headers(&mut headers); + + assert_eq!( + headers, + vec![HttpHeader { + name: "content-type".to_string(), + value: "value".to_string(), + }] + ); + } } diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 4fec48b300..592228bc79 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -8,6 +8,24 @@ //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +static HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS: AtomicU64 = + AtomicU64::new(openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS); + +/// Configure the supervisor-wide wall-clock bound for whole-response buffering. +pub fn set_http_response_whole_body_timeout(timeout: Duration) { + let milliseconds = u64::try_from(timeout.as_millis()) + .unwrap_or(u64::MAX) + .max(1); + HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS.store(milliseconds, Ordering::Relaxed); +} + +pub(crate) fn http_response_whole_body_timeout() -> Duration { + Duration::from_millis(HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS.load(Ordering::Relaxed)) +} + pub mod identity; pub mod inference_routes; pub mod l7; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 177d640fd8..e915ae9bdf 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1272,6 +1272,7 @@ impl ForwardMiddlewarePipeline<'_> { request: crate::l7::provider::L7Request, client: &mut C, chain: Vec, + request_id: &str, ) -> Result where C: TokioAsyncRead + TokioAsyncWrite + Unpin + Send, @@ -1290,7 +1291,7 @@ impl ForwardMiddlewarePipeline<'_> { None => openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, }; - crate::l7::middleware::apply_middleware_chain_for_scheme( + crate::l7::middleware::apply_middleware_chain_for_scheme_with_request_id( request, client, self.ctx, @@ -1299,6 +1300,7 @@ impl ForwardMiddlewarePipeline<'_> { self.runner, self.generation_guard, transformed_body_policy, + request_id, ) .await } @@ -1751,7 +1753,7 @@ async fn handle_tcp_connection( let target = parts.next().unwrap_or(""); if method != "CONNECT" { - return handle_forward_proxy( + return Box::pin(handle_forward_proxy( method, target, &buf[..], @@ -1768,7 +1770,7 @@ async fn handle_tcp_connection( dynamic_credentials, denial_tx.as_ref(), activity_tx.as_ref(), - ) + )) .await; } @@ -4727,6 +4729,15 @@ struct ForwardRelayOptions<'a> { signing_region: &'a str, host: &'a str, port: u16, + response_middleware: Option>, +} + +struct ForwardResponseMiddleware<'a> { + ctx: &'a crate::l7::relay::L7EvalContext, + scheme: &'a str, + request_id: &'a str, + chain: &'a [openshell_supervisor_middleware::ChainEntry], + runner: &'a openshell_supervisor_middleware::ChainRunner, } async fn relay_rewritten_forward_request( @@ -4747,16 +4758,28 @@ where .map_or(rewritten.len(), |p| p + 4); let header_str = String::from_utf8_lossy(&rewritten[..header_end]); let body_length = crate::l7::rest::parse_body_length(&header_str)?; - let (_, query_params) = crate::l7::rest::parse_target_query(path)?; + let (request_path, query_params) = crate::l7::rest::parse_target_query(path)?; let req = crate::l7::provider::L7Request { action: method.to_string(), - target: path.to_string(), + target: request_path, query_params, raw_header: rewritten, body_length, }; - crate::l7::rest::relay_http_request_with_options_guarded( + let response_middleware = options.response_middleware.map(|middleware| { + crate::l7::relay::http_response_middleware_relay( + &req, + middleware.ctx, + middleware.scheme, + middleware.request_id, + middleware.chain, + middleware.runner, + Some(options.generation_guard), + ) + }); + + crate::l7::rest::relay_http_request_with_response_middleware_guarded( &req, client, upstream, @@ -4773,6 +4796,7 @@ where host: options.host, port: options.port, }, + response_middleware, ) .await } @@ -5699,7 +5723,9 @@ async fn handle_forward_proxy( .await?; return Ok(()); } + let request_id = uuid::Uuid::new_v4().to_string(); let websocket_chain = forward_websocket_request.then(|| chain.clone()); + let mut response_selection = None; if !chain.is_empty() { let middleware_runner = opa_engine.middleware_runner()?; let request = crate::l7::rest::request_from_buffered_http( @@ -5723,7 +5749,8 @@ async fn handle_forward_proxy( generation_guard: &forward_generation_guard, l7_reevaluation, }; - forward_request_bytes = match pipeline.apply(request, client, chain).await? { + response_selection = Some((chain.clone(), middleware_runner.clone())); + forward_request_bytes = match pipeline.apply(request, client, chain, &request_id).await? { crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request.raw_header, crate::l7::middleware::MiddlewareApplyResult::Denied { denial, .. } => { emit_activity_simple(activity_tx, true, "middleware"); @@ -6031,6 +6058,15 @@ async fn handle_forward_proxy( signing_region, host: &host_lc, port, + response_middleware: response_selection.as_ref().map(|(chain, runner)| { + ForwardResponseMiddleware { + ctx: &l7_ctx, + scheme: &scheme, + request_id: &request_id, + chain, + runner, + } + }), }, ) .await; @@ -6450,6 +6486,118 @@ mod tests { release: Arc, } + struct ForwardResponseHeadersMiddleware { + expected_path: String, + forbidden_path_fragment: String, + block: bool, + } + + #[tonic::async_trait] + impl openshell_core::middleware::InProcessMiddleware for ForwardResponseHeadersMiddleware { + async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { + openshell_core::proto::MiddlewareManifest { + name: "test/forward-response".into(), + service_version: "test".into(), + bindings: vec![openshell_core::proto::MiddlewareBinding { + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpResponse + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: 8192, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + _request: openshell_core::middleware::HttpRequestView<'_>, + ) -> Result { + Ok(openshell_core::proto::HttpRequestResult { + decision: openshell_core::proto::Decision::Allow as i32, + ..Default::default() + }) + } + + async fn open_http_response_pre_return( + &self, + mut requests: mpsc::Receiver, + ) -> std::result::Result + { + let (sender, receiver) = mpsc::channel(2); + let expected_path = self.expected_path.clone(); + let forbidden_path_fragment = self.forbidden_path_fragment.clone(); + let block = self.block; + tokio::spawn(async move { + while let Some(event) = requests.recv().await { + match event.event { + Some(openshell_core::proto::http_response_event::Event::Preflight( + preflight, + )) => { + let target = preflight.target.expect("response target"); + assert_eq!(target.path, expected_path); + assert!(!target.path.contains(&forbidden_path_fragment)); + let action = if block { + openshell_core::proto::http_response_preflight_result::Action::BlockDelivery( + openshell_core::proto::HttpResponseBlockDelivery {}, + ) + } else { + openshell_core::proto::http_response_preflight_result::Action::Inspect( + openshell_core::proto::HttpResponsePreflightInspect { + body_mode: openshell_core::proto::HttpResponseBodyMode::HeadersOnly as i32, + header_mutations: vec![openshell_core::proto::HeaderMutation { + operation: Some( + openshell_core::proto::header_mutation::Operation::Write( + openshell_core::proto::WriteHeader { + name: "x-forward-response-test".into(), + value: "selected".into(), + on_existing: openshell_core::proto::ExistingHeaderAction::Overwrite as i32, + }, + ), + ), + }], + }, + ) + }; + let result = openshell_core::proto::HttpResponseEventResult { + result: Some( + openshell_core::proto::http_response_event_result::Result::PreflightResult( + openshell_core::proto::HttpResponsePreflightResult { + action: Some(action), + reason_code: if block { + "query_guard".into() + } else { + String::new() + }, + ..Default::default() + }, + ), + ), + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + Some(openshell_core::proto::http_response_event::Event::SessionEnd(_)) + | None => break, + Some(_) => panic!("headers-only response received an unexpected event"), + } + } + }); + Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new( + receiver, + ))) + } + } + #[tonic::async_trait] impl openshell_core::middleware::InProcessMiddleware for BlockingForwardMiddleware { async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { @@ -6630,7 +6778,7 @@ network_policies: tokio::time::timeout( std::time::Duration::from_secs(30), - handle_forward_proxy( + Box::pin(handle_forward_proxy( "GET", &target, request.as_bytes(), @@ -6647,7 +6795,7 @@ network_policies: None, None, None, - ), + )), ) .await .expect("denied preflight must complete without an upstream response") @@ -6763,7 +6911,7 @@ network_policies: let (mut proxy_connection, _) = proxy_listener.accept().await.unwrap(); let handler = tokio::spawn(async move { - handle_forward_proxy( + Box::pin(handle_forward_proxy( "GET", &target, request.as_bytes(), @@ -6780,7 +6928,7 @@ network_policies: None, None, None, - ) + )) .await }); let scenario = tokio::time::timeout(std::time::Duration::from_secs(60), async { @@ -7684,7 +7832,7 @@ network_policies: let (_app, mut client) = tokio::io::duplex(8192); let outcome = pipeline - .apply(request, &mut client, chain) + .apply(request, &mut client, chain, "test-request-id") .await .expect("forward middleware pipeline"); @@ -7781,7 +7929,10 @@ network_policies: state.revoke_static_provider_environment(2); release.notify_one(); }; - let (outcome, ()) = tokio::join!(pipeline.apply(request, &mut client, chain), revoke); + let (outcome, ()) = tokio::join!( + pipeline.apply(request, &mut client, chain, "test-request-id"), + revoke + ); let request = match outcome.expect("middleware pipeline") { crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request, crate::l7::middleware::MiddlewareApplyResult::Denied { .. } => { @@ -7895,6 +8046,171 @@ network_policies: .unwrap() } + #[tokio::test] + async fn plaintext_forward_relay_applies_response_middleware() { + let guard = forward_test_guard(); + let ctx = crate::l7::relay::L7EvalContext { + host: "api.example.test".into(), + port: 80, + request_default_port: Some(80), + policy_name: "forward".into(), + binary_path: "/usr/bin/curl".into(), + ..Default::default() + }; + let runner = openshell_supervisor_middleware::ChainRunner::new(Arc::new( + ForwardResponseHeadersMiddleware { + expected_path: "/demo".into(), + forbidden_path_fragment: "not-present".into(), + block: false, + }, + )); + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "response".into(), + implementation: "test/forward-response".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]; + let request = b"GET /demo HTTP/1.1\r\nHost: api.example.test\r\n\r\n".to_vec(); + let (mut proxy_to_upstream, mut upstream) = tokio::io::duplex(8192); + let (mut app, mut proxy_to_client) = tokio::io::duplex(8192); + let upstream_task = tokio::spawn(async move { + let mut request = vec![0; 1024]; + let size = upstream.read(&mut request).await.unwrap(); + assert!(request[..size].ends_with(b"\r\n\r\n")); + upstream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await + .unwrap(); + }); + + let outcome = relay_rewritten_forward_request( + "GET", + "/demo", + request, + &mut proxy_to_client, + &mut proxy_to_upstream, + ForwardRelayOptions { + generation_guard: &guard, + credential_generation: None, + websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, + secret_resolver: None, + request_body_credential_rewrite: false, + deny_uninspected_credentials: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "api.example.test", + port: 80, + response_middleware: Some(ForwardResponseMiddleware { + ctx: &ctx, + scheme: "http", + request_id: "correlated-request-id", + chain: &chain, + runner: &runner, + }), + }, + ) + .await + .expect("plaintext forward relay"); + assert!(matches!( + outcome, + crate::l7::provider::RelayOutcome::Reusable + )); + upstream_task.await.unwrap(); + drop(proxy_to_client); + let mut response = Vec::new(); + app.read_to_end(&mut response).await.unwrap(); + let response = String::from_utf8(response).unwrap(); + assert!(response.contains("x-forward-response-test: selected\r\n")); + assert!(response.ends_with("\r\n\r\nok")); + } + + #[tokio::test] + async fn plaintext_forward_response_denial_never_echoes_query_secret() { + const SECRET: &str = "sk-forward-query-secret"; + let guard = forward_test_guard(); + let ctx = crate::l7::relay::L7EvalContext { + host: "api.example.test".into(), + port: 80, + request_default_port: Some(80), + policy_name: "forward".into(), + binary_path: "/usr/bin/curl".into(), + ..Default::default() + }; + let runner = openshell_supervisor_middleware::ChainRunner::new(Arc::new( + ForwardResponseHeadersMiddleware { + expected_path: "/demo".into(), + forbidden_path_fragment: SECRET.into(), + block: true, + }, + )); + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "response".into(), + implementation: "test/forward-response".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]; + let target = format!("/demo?access_token={SECRET}"); + let request = + format!("GET {target} HTTP/1.1\r\nHost: api.example.test\r\n\r\n").into_bytes(); + let (mut proxy_to_upstream, mut upstream) = tokio::io::duplex(8192); + let (mut app, mut proxy_to_client) = tokio::io::duplex(8192); + let upstream_task = tokio::spawn(async move { + let mut request = vec![0; 1024]; + let size = upstream.read(&mut request).await.unwrap(); + assert!(request[..size].ends_with(b"\r\n\r\n")); + upstream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await + .unwrap(); + }); + + let outcome = relay_rewritten_forward_request( + "GET", + &target, + request, + &mut proxy_to_client, + &mut proxy_to_upstream, + ForwardRelayOptions { + generation_guard: &guard, + credential_generation: None, + websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, + secret_resolver: None, + request_body_credential_rewrite: false, + deny_uninspected_credentials: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "api.example.test", + port: 80, + response_middleware: Some(ForwardResponseMiddleware { + ctx: &ctx, + scheme: "http", + request_id: "correlated-request-id", + chain: &chain, + runner: &runner, + }), + }, + ) + .await + .expect("plaintext forward response denial"); + assert!(matches!( + outcome, + crate::l7::provider::RelayOutcome::Consumed + )); + upstream_task.await.unwrap(); + drop(proxy_to_client); + let mut response = Vec::new(); + app.read_to_end(&mut response).await.unwrap(); + let response = String::from_utf8(response).unwrap(); + assert!(response.starts_with("HTTP/1.1 403 Forbidden\r\n")); + assert!(response.contains("\"path\":\"/demo\"")); + assert!(!response.contains(SECRET)); + assert!(!response.contains("access_token")); + } + async fn relay_forward_request_and_capture( method: &str, path: &str, @@ -7979,6 +8295,7 @@ network_policies: signing_region: "", host: "", port: 0, + response_middleware: None, }, ) .await?; @@ -8244,6 +8561,7 @@ network_policies: signing_region: "", host: "", port: 0, + response_middleware: None, }, ) .await?; @@ -11098,7 +11416,7 @@ network_policies: let (_app, mut client) = tokio::io::duplex(8192); let allowed = pipeline - .apply(request, &mut client, chain) + .apply(request, &mut client, chain, "test-request-id") .await .expect("middleware pipeline"); let crate::l7::middleware::MiddlewareApplyResult::Allowed(request) = allowed else { @@ -11400,6 +11718,7 @@ network_policies: signing_region: "", host: "", port: 0, + response_middleware: None, }, ) .await @@ -11481,6 +11800,7 @@ network_policies: signing_region: "us-west-2", host: "api.example.com", port: 80, + response_middleware: None, }, ) .await @@ -11570,6 +11890,7 @@ network_policies: signing_region: "", host: "", port: 0, + response_middleware: None, }, ) .await; @@ -11620,6 +11941,7 @@ network_policies: signing_region: "", host: "", port: 0, + response_middleware: None, }, ) .await; diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 1c846c29a8..8b1cb5c584 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -131,6 +131,9 @@ provider_profile_sources = [ # Operator-run supervisor middleware. The gRPC endpoint must be reachable from # both the gateway and sandbox supervisors. +[openshell.supervisor] +http_response_whole_body_timeout = "120s" + [[openshell.supervisor.middleware]] name = "local-content-guard" grpc_endpoint = "https://host.openshell.internal:50051" @@ -290,6 +293,11 @@ The gateway flushes buffered spans during shutdown, so spans from in-flight requ Register operator-run supervisor middleware services with one or more `[[openshell.supervisor.middleware]]` entries. Registration is static and operator-owned; changing it requires restarting the gateway. ```toml +[openshell.supervisor] +# One non-resetting wall-clock limit for response accumulation and whole-body +# middleware barriers. Accepts a positive integer followed by ms, s, or m. +http_response_whole_body_timeout = "120s" + [[openshell.supervisor.middleware]] name = "local-content-guard" grpc_endpoint = "https://host.openshell.internal:50051" @@ -299,13 +307,15 @@ max_payload_bytes = 262144 timeout = "500ms" ``` -Each service implements the supervisor middleware gRPC contract and exposes bindings through `Describe`. Policies reference the operator-owned registration `name`, attaching the complete middleware and all of its bindings. Bindings are identified by operation and phase. A manifest may expose at most one binding for each operation and phase pair. V1 supports `HttpRequest/pre_credentials` and `WebSocketMessage/pre_credentials`, so a service can inspect HTTP, WebSocket, or both. Registration names must be unique, and operator-run registrations cannot claim the reserved `openshell/` namespace. The service-reported manifest name is diagnostic metadata and does not need to match the registration name. +Each service implements the supervisor middleware gRPC contract and exposes bindings through `Describe`. Policies reference the operator-owned registration `name`, attaching the complete middleware and all of its bindings. Bindings are identified by operation and phase. A manifest may expose at most one binding for each operation and phase pair. V1 supports `HttpRequest/pre_credentials`, `HttpResponse/pre_return`, and `WebSocketMessage/pre_credentials`. Registration names must be unique, and operator-run registrations cannot claim the reserved `openshell/` namespace. The service-reported manifest name is diagnostic metadata and does not need to match the registration name. 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_payload_bytes` is the shared operator limit for inspectable logical payloads across every binding exposed by the service. It caps HTTP request and replacement bodies as well as complete WebSocket text messages and replacements. The value must be greater than zero, no larger than each binding's advertised `max_payload_bytes` capability, and no larger than the 4 MiB platform maximum. OpenShell rejects oversized values instead of silently clamping them. Binary WebSocket messages are not exposed to V1 middleware, so this field does not limit binary pass-through. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size payload and its protobuf envelope fit on the transport. +`max_payload_bytes` is the shared operator limit for inspectable logical payloads across every binding exposed by the service. It caps HTTP request and response units, replacement bodies, and complete WebSocket text messages and replacements. Whole-response inspection uses it as the stage's total body limit. Streaming response inspection applies it to each self-contained unit, with a platform maximum of 64 KiB per input unit. The value must be greater than zero, no larger than each binding's advertised `max_payload_bytes` capability, and no larger than the 4 MiB platform maximum. OpenShell rejects oversized values instead of silently clamping them. Binary WebSocket messages are not exposed to V1 middleware, so this field does not limit binary pass-through. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size payload and its protobuf envelope fit on the transport. + +`timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies to HTTP request evaluation, HTTP response preflight and unit exchanges, WebSocket preflight, and each WebSocket message. Accepted streaming protocols have no connection-wide RPC deadline. -`timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies only to `EvaluateHttpRequest`, WebSocket preflight, and each WebSocket message. An accepted WebSocket stream has no connection-wide RPC deadline. +`http_response_whole_body_timeout` is a supervisor-wide safety bound, not a middleware RPC timeout. It defaults to `120s` and accepts a positive integer followed by `ms`, `s`, or `m`. The timer starts after ordered response preflight selects a `WHOLE_BODY_BYTES` stage and before OpenShell reads the first response body byte. It does not reset as chunks arrive. It ends after the normalized body reaches end of stream and every whole-body barrier produces output. On expiry, each active whole-body stage follows its configured `on_error` policy: fail-open releases OpenShell-owned bytes through later stages, while fail-closed returns the canonical pre-commit 502 response. Changing this field requires restarting the gateway. The service `grpc_endpoint` supports plaintext `http://` and TLS `https://`. HTTPS uses the platform trust store unless `tls_ca_cert_path` names a certificate-only PEM bundle. OpenShell rejects bundles containing private keys, loads the certificates at gateway startup, and distributes only public certificates to sandbox supervisors; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:middleware:`. After authenticated `Describe` succeeds, OpenShell treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest. diff --git a/examples/supervisor-middleware-content-guard/Cargo.lock b/examples/supervisor-middleware-content-guard/Cargo.lock index f31d5be9b5..18bcbcb1b7 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.lock +++ b/examples/supervisor-middleware-content-guard/Cargo.lock @@ -206,6 +206,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + [[package]] name = "clap" version = "4.6.1" @@ -252,6 +263,16 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -268,6 +289,27 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "displaydoc" version = "0.2.6" @@ -399,6 +441,7 @@ dependencies = [ "cfg-if", "libc", "r-efi", + "rand_core", ] [[package]] @@ -453,6 +496,25 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hickory-proto" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand", + "ring", + "thiserror", + "tinyvec", + "tracing", + "url", +] + [[package]] name = "http" version = "1.4.2" @@ -699,12 +761,71 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -832,6 +953,10 @@ name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -854,6 +979,8 @@ dependencies = [ "prost-types", "protoc-bin-vendored", "rustix", + "rustls", + "rustls-pemfile", "serde", "serde_json", "thiserror", @@ -878,12 +1005,26 @@ dependencies = [ "tower", ] +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "hickory-proto", + "miette", + "openshell-core", + "prost-types", + "serde", + "serde_json", + "serde_yml", +] + [[package]] name = "openshell-supervisor-middleware-content-guard" version = "0.0.0" dependencies = [ "clap", "openshell-core", + "openshell-policy", "prost-types", "tokio", "tokio-stream", @@ -968,6 +1109,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + [[package]] name = "potential_utf" version = "0.1.5" @@ -1148,6 +1295,23 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1206,6 +1370,15 @@ version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustix" version = "1.1.4" @@ -1246,6 +1419,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.14.1" @@ -1266,6 +1448,21 @@ dependencies = [ "untrusted", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -1304,6 +1501,12 @@ dependencies = [ "libc", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -1347,6 +1550,21 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + [[package]] name = "shlex" version = "2.0.1" @@ -1363,6 +1581,22 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "slab" version = "0.4.12" @@ -1515,6 +1749,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.52.3" @@ -1775,6 +2024,22 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -1790,6 +2055,15 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-link" version = "0.2.1" diff --git a/examples/supervisor-middleware-content-guard/Cargo.toml b/examples/supervisor-middleware-content-guard/Cargo.toml index ef0d1f47c3..9a9f98381a 100644 --- a/examples/supervisor-middleware-content-guard/Cargo.toml +++ b/examples/supervisor-middleware-content-guard/Cargo.toml @@ -20,6 +20,9 @@ tokio = { version = "1.43", features = ["macros", "rt-multi-thread"] } tokio-stream = "0.1" tonic = { version = "0.14", features = ["transport"] } +[dev-dependencies] +openshell-policy = { path = "../../crates/openshell-policy" } + [[bin]] name = "supervisor-middleware-content-guard" path = "src/main.rs" diff --git a/examples/supervisor-middleware-content-guard/README.md b/examples/supervisor-middleware-content-guard/README.md index 53eda94210..21b2ae87f7 100644 --- a/examples/supervisor-middleware-content-guard/README.md +++ b/examples/supervisor-middleware-content-guard/README.md @@ -8,18 +8,18 @@ SPDX-License-Identifier: Apache-2.0 > [!WARNING] > Supervisor middleware is a research preview. Its policy and service contracts may change without compatibility guarantees. Use it only to prototype and evaluate middleware integrations. -This example implements an operator-run supervisor middleware service. It scans UTF-8 HTTP request bodies and complete client-to-upstream WebSocket text messages for configured literal strings, then either replaces every match or denies the request or message. Findings report only aggregate counts and never include configured terms or inspected content. +This example implements request, response, and client WebSocket bindings in one operator-run supervisor middleware service. The response binding demonstrates header-only inspection, whole-body and streaming transforms, trailer mutation, and block delivery. > [!WARNING] > This intentionally simple implementation demonstrates the supervisor middleware service contract. It is not a complete or reliable content guard and must not be used as a security control. It handles only UTF-8 HTTP request bodies and WebSocket text messages with case-sensitive literal terms, merges overlapping literal match ranges before redaction, and does not address encodings, transformations, normalization, binary WebSocket messages, upstream-to-client messages, or adversarial inputs that a production content guard must handle. ## Prerequisites -Install `cargo`, `curl`, `jq`, and `openssl` on the host before running the smoke script. +Install `cargo`, `curl`, `jq`, `openssl`, and Python 3 on the host before running the smoke script. ## Run the smoke example -Run the end-to-end smoke suite to build and start a local gateway, start the content-guard service, create a sandbox, and send the same request body to two destinations: +Run the end-to-end smoke suite to build a local gateway and sandbox supervisor, start the content-guard service, create a sandbox, and send the same request body to two destinations: ```shell ./examples/supervisor-middleware-content-guard/smoke.sh --test-suite @@ -39,6 +39,8 @@ The script creates the sandbox and prints the guarded and unguarded request comm CONTENT_GUARD_SMOKE_HOST=192.168.1.10 ./examples/supervisor-middleware-content-guard/smoke.sh --test-suite ``` +The gateway auto-detects its compute driver. Set `CONTENT_GUARD_SMOKE_DRIVER=docker` or `CONTENT_GUARD_SMOKE_DRIVER=podman` if more than one local runtime is installed and auto-detection selects the wrong one. + ## Run manually Start the service before starting the gateway. Bind to all host interfaces so a local containerized gateway and sandbox supervisor can reach it: @@ -54,6 +56,7 @@ Add the service registration to your local gateway TOML: [[openshell.supervisor.middleware]] name = "content-guard-example" grpc_endpoint = "http://host.openshell.internal:50051" +allow_insecure_transport = true max_payload_bytes = 262144 timeout = "500ms" ``` @@ -84,6 +87,34 @@ curl -sS https://httpbin.org/anything \ The echoed JSON body contains `[FILTERED]` instead of the configured term. +## HTTP response behavior + +Start the included raw HTTP upstream in another terminal: + +```shell +python3 examples/supervisor-middleware-content-guard/upstream.py +``` + +From the sandbox, exercise the response protocol: + +```shell +curl -i http://host.openshell.internal:18081/headers-only +curl -i http://host.openshell.internal:18081/whole-body +curl -i --raw http://host.openshell.internal:18081/stream +curl -i --raw http://host.openshell.internal:18081/stream-close +curl -i http://host.openshell.internal:18081/block +``` + +| Path | Mode | Result | +| --- | --- | --- | +| `/headers-only` | `HEADERS_ONLY` | Adds `x-example-response-mode` without changing content-length framing. | +| `/whole-body` | `WHOLE_BODY_BYTES` | Prefixes the normalized body with `[whole]`. | +| `/stream` | `STREAM_BYTES` | Uppercases normalized units and changes the existing `x-example-body-bytes` trailer to `11`. | +| `/stream-close` | `STREAM_BYTES` | Uppercases a close-delimited `text/event-stream` response. | +| `/block` | `WHOLE_BODY_BYTES` | Returns OpenShell's canonical 403 response with reason code `content_match`. | + +The upstream deliberately uses content-length, chunked, and close-delimited responses. OpenShell normalizes transport framing only for body-processing modes and validates trailer changes against the names supplied by the upstream. + ## WebSocket behavior For a selected WebSocket upgrade, the service accepts preflight, waits for the session-start notification, and evaluates each complete client-to-upstream text message. Redact mode returns a replacement message, while deny mode returns `content_match` and OpenShell closes the session according to middleware policy. Session-start and session-end events are notifications and do not produce results. @@ -107,4 +138,4 @@ config: - prototype-secret ``` -The implementation supports `HTTP_REQUEST/PRE_CREDENTIALS` and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, advertises a 256 KiB limit for each operation, and inherits the service-wide RPC timeout. The gateway registration's `max_payload_bytes` may set a smaller shared limit. A binding can advertise a shorter timeout, but it cannot extend the operator-configured timeout. +The implementation supports `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. It advertises a 256 KiB limit for each operation and inherits the service-wide RPC timeout. The gateway registration's `max_payload_bytes` may set a smaller shared limit. A binding can advertise a shorter timeout, but it cannot extend the operator-configured timeout. diff --git a/examples/supervisor-middleware-content-guard/policy.yaml b/examples/supervisor-middleware-content-guard/policy.yaml index ff3d9ef89e..b72af7ac2f 100644 --- a/examples/supervisor-middleware-content-guard/policy.yaml +++ b/examples/supervisor-middleware-content-guard/policy.yaml @@ -18,6 +18,7 @@ network_middlewares: endpoints: include: - httpbin.org + - host.openshell.internal network_policies: httpbin: @@ -44,3 +45,27 @@ network_policies: path: /anything binaries: - path: /usr/bin/curl + response-framing-demo: + name: Response framing demo + endpoints: + - host: host.openshell.internal + port: 18081 + protocol: rest + rules: + - allow: + method: GET + path: /headers-only + - allow: + method: GET + path: /whole-body + - allow: + method: GET + path: /stream + - allow: + method: GET + path: /stream-close + - allow: + method: GET + path: /block + binaries: + - path: /usr/bin/curl diff --git a/examples/supervisor-middleware-content-guard/smoke.sh b/examples/supervisor-middleware-content-guard/smoke.sh index b30475c8ca..106d544891 100755 --- a/examples/supervisor-middleware-content-guard/smoke.sh +++ b/examples/supervisor-middleware-content-guard/smoke.sh @@ -24,6 +24,8 @@ Options: Environment: CONTENT_GUARD_SMOKE_HOST Non-loopback host address reachable from both the gateway and sandbox containers. + CONTENT_GUARD_SMOKE_DRIVER + Optional compute driver name, such as docker or podman. EOF } @@ -103,6 +105,7 @@ detect_service_host() { } SERVICE_HOST="$(detect_service_host)" +COMPUTE_DRIVER="${CONTENT_GUARD_SMOKE_DRIVER:-}" if [[ "$SERVICE_HOST" == "localhost" || "$SERVICE_HOST" == "::1" || "$SERVICE_HOST" == 127.* || "$SERVICE_HOST" == *:* ]]; then echo "CONTENT_GUARD_SMOKE_HOST must be a non-loopback IPv4 address: $SERVICE_HOST" >&2 exit 1 @@ -115,6 +118,8 @@ GATEWAY_CONFIG="$TMPDIR/gateway.toml" SETUP_LOG="$LOG_DIR/setup.log" GATEWAY_LOG="$LOG_DIR/gateway.log" MIDDLEWARE_LOG="$LOG_DIR/middleware.log" +UPSTREAM_LOG="$LOG_DIR/upstream.log" +SANDBOX_LOG="$LOG_DIR/sandbox.log" RUN_ID="content-guard-smoke-$$-$RANDOM" # Sandbox names are capped at 19 characters. Use a short prefix with # the PID for uniqueness; keep the full RUN_ID for gateway identity. @@ -141,6 +146,11 @@ cleanup() { wait "$MIDDLEWARE_PID" 2>/dev/null || true fi + if [[ -n "${UPSTREAM_PID:-}" ]]; then + kill "$UPSTREAM_PID" 2>/dev/null || true + wait "$UPSTREAM_PID" 2>/dev/null || true + fi + if [[ "$status" -eq 0 ]]; then rm -rf "$TMPDIR" else @@ -214,8 +224,12 @@ ttl_secs = 0 [[openshell.supervisor.middleware]] name = "content-guard-example" grpc_endpoint = "http://$SERVICE_HOST:$MIDDLEWARE_PORT" +allow_insecure_transport = true max_payload_bytes = 262144 timeout = "500ms" + +[openshell.drivers.docker] +supervisor_bin = "$ROOT/target/debug/openshell-sandbox" EOF } @@ -239,11 +253,13 @@ generate_gateway_jwt_bundle() { dump_logs() { local label path - for label in setup gateway middleware; do + for label in setup gateway middleware upstream sandbox; do case "$label" in setup) path="$SETUP_LOG" ;; gateway) path="$GATEWAY_LOG" ;; middleware) path="$MIDDLEWARE_LOG" ;; + upstream) path="$UPSTREAM_LOG" ;; + sandbox) path="$SANDBOX_LOG" ;; esac printf '\n--- %s log: %s ---\n' "$label" "$path" >&2 if [[ -f "$path" ]]; then @@ -254,8 +270,23 @@ dump_logs() { done } +capture_sandbox_log() { + local container_id + + if [[ "$SANDBOX_CREATED" -ne 1 || "$COMPUTE_DRIVER" != "docker" ]] || + ! command -v docker >/dev/null 2>&1; then + return + fi + + container_id="$(docker ps -aq --filter "name=$SANDBOX_NAME" | head -n 1)" + if [[ -n "$container_id" ]]; then + docker logs "$container_id" >"$SANDBOX_LOG" 2>&1 || true + fi +} + fail() { printf 'FAIL %s\n' "$1" >&2 + capture_sandbox_log dump_logs exit 1 } @@ -316,15 +347,40 @@ wait_for_middleware() { fail "content guard service is reachable at $SERVICE_HOST:$MIDDLEWARE_PORT" } +start_upstream() { + printf 'INFO starting response framing upstream at %s:18081\n' "$SERVICE_HOST" + python3 "$EXAMPLE_DIR/upstream.py" >"$UPSTREAM_LOG" 2>&1 & + UPSTREAM_PID=$! +} + +wait_for_upstream() { + for _ in {1..30}; do + if ! kill -0 "$UPSTREAM_PID" 2>/dev/null; then + fail "response framing upstream starts" + fi + if curl -fsS --max-time 1 "http://127.0.0.1:18081/headers-only" >/dev/null 2>&1; then + printf 'INFO response framing upstream is ready\n' + return + fi + sleep 1 + done + fail "response framing upstream is reachable" +} + start_gateway() { + local -a driver_args=() + if [[ -n "$COMPUTE_DRIVER" ]]; then + driver_args=(--drivers "$COMPUTE_DRIVER") + fi printf 'INFO starting gateway\n' env -u OPENSHELL_DRIVERS "$GATEWAY_BIN" \ + "${driver_args[@]}" \ --config "$GATEWAY_CONFIG" \ --bind-address 127.0.0.1 \ --port "$GATEWAY_PORT" \ --health-port "$HEALTH_PORT" \ --metrics-port 0 \ - --log-level info \ + --log-level "${CONTENT_GUARD_SMOKE_LOG_LEVEL:-info}" \ --disable-tls \ --db-url "sqlite://$TMPDIR/gateway.db" >"$GATEWAY_LOG" 2>&1 & GATEWAY_PID=$! @@ -354,10 +410,10 @@ create_sandbox() { "$CLI_BIN" --gateway-endpoint "$GATEWAY_ENDPOINT" ) + SANDBOX_CREATED=1 run_setup_step \ "creating content guard sandbox" \ - "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" --keep --no-tty -- /bin/sh -lc true - SANDBOX_CREATED=1 + "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" --no-tty --detach -- sleep infinity } request() { @@ -368,14 +424,48 @@ request() { --data '{"note":"prototype-secret"}' } +response_request() { + local path="$1" + "${CLI[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- \ + curl -sS -i --raw --max-time 20 "http://host.openshell.internal:18081/$path" +} + run_suite() { local guarded_output="$LOG_DIR/guarded.out" local unguarded_output="$LOG_DIR/unguarded.out" + local response_output="$LOG_DIR/response.out" printf 'INFO sending guarded request to httpbin.org\n' if ! request httpbin.org >"$guarded_output" 2>>"$SETUP_LOG"; then fail "guarded request completes" fi + + printf 'INFO exercising HTTP response middleware modes\n' + if ! response_request headers-only >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fiq 'x-example-response-mode: headers-only' "$response_output" || + ! grep -Fq 'headers-only' "$response_output"; then + fail "headers-only response middleware" + fi + if ! response_request whole-body >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq '[whole] whole body' "$response_output"; then + fail "whole-body response middleware" + fi + if ! response_request stream >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'STREAM BODY' "$response_output" || + ! grep -Fiq 'x-example-body-bytes: 11' "$response_output"; then + fail "stream response middleware with trailer mutation" + fi + if ! response_request stream-close >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'DATA: STREAM CLOSE' "$response_output"; then + fail "close-delimited SSE response middleware" + fi + if ! response_request block >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'HTTP/1.1 403 Forbidden' "$response_output" || + ! grep -Fq 'middleware_denied' "$response_output" || + ! grep -Fq 'content_match' "$response_output"; then + fail "response middleware block" + fi + printf 'PASS HTTP response middleware modes\n' if grep -Fq '[FILTERED]' "$guarded_output" && ! grep -Fq 'prototype-secret' "$guarded_output"; then printf 'PASS guarded request is filtered\n' else @@ -439,15 +529,19 @@ require_command cargo require_command curl require_command jq require_command openssl +require_command python3 ROOT_TARGET_DIR="$(cargo_target_dir "$ROOT/Cargo.toml")" EXAMPLE_TARGET_DIR="$(cargo_target_dir "$EXAMPLE_DIR/Cargo.toml")" GATEWAY_BIN="$ROOT_TARGET_DIR/debug/openshell-gateway" CLI_BIN="$ROOT_TARGET_DIR/debug/openshell" MIDDLEWARE_BIN="$EXAMPLE_TARGET_DIR/debug/supervisor-middleware-content-guard" run_setup_step "building gateway" cargo build --quiet -p openshell-gateway --bin openshell-gateway +run_setup_step "building sandbox supervisor" cargo build --quiet -p openshell-sandbox --bin openshell-sandbox run_setup_step "building content guard" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell generate_gateway_jwt_bundle +start_upstream +wait_for_upstream start_middleware wait_for_middleware start_gateway diff --git a/examples/supervisor-middleware-content-guard/src/main.rs b/examples/supervisor-middleware-content-guard/src/main.rs index 8d714264e7..effce70a5a 100644 --- a/examples/supervisor-middleware-content-guard/src/main.rs +++ b/examples/supervisor-middleware-content-guard/src/main.rs @@ -6,20 +6,31 @@ use std::net::SocketAddr; use std::ops::Range; use clap::Parser; -use openshell_core::middleware::WebSocketResponseStream; +use openshell_core::middleware::{HttpResponseResultStream, WebSocketResponseStream}; +use openshell_core::proto::middleware::v1::http_response_pre_return_server::{ + HttpResponsePreReturn, HttpResponsePreReturnServer, +}; use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ SupervisorMiddleware, SupervisorMiddlewareServer, }; use openshell_core::proto::{ - Decision, Finding, HttpRequestEvaluation, HttpRequestResult, MiddlewareBinding, - MiddlewareManifest, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, - ValidateConfigRequest, ValidateConfigResponse, WebSocketMessage, WebSocketMessageResult, - WebSocketPreflightAction, WebSocketPreflightDecision, WebSocketSessionEvent, - WebSocketSessionEventResult, web_socket_message, web_socket_message_result, + Decision, ExistingHeaderAction, Finding, HeaderMutation, HttpRequestEvaluation, + HttpRequestResult, HttpResponseBlockDelivery, HttpResponseBodyMode, HttpResponseBodyResult, + HttpResponseBodyTransform, HttpResponseEvent, HttpResponseEventResult, + HttpResponsePreflightInspect, HttpResponsePreflightResult, HttpResponsePreflightSkip, + HttpResponseTrailersResult, MiddlewareBinding, MiddlewareManifest, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, + ValidateConfigResponse, WebSocketMessage, WebSocketMessageResult, WebSocketPreflightAction, + WebSocketPreflightDecision, WebSocketSessionEvent, WebSocketSessionEventResult, WriteHeader, + header_mutation, http_response_body_result, http_response_body_transform, + http_response_body_unit, http_response_event, http_response_event_result, + http_response_preflight_result, web_socket_message, web_socket_message_result, web_socket_session_event, web_socket_session_event_result, }; use prost_types::Struct; use prost_types::value::Kind; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; use tokio_stream::{Stream, StreamExt}; use tonic::transport::Server; use tonic::{Request, Response, Status}; @@ -238,6 +249,12 @@ impl SupervisorMiddleware for ContentGuard { max_payload_bytes: MAX_PAYLOAD_BYTES, timeout: String::new(), }, + MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpResponse as i32, + phase: SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: MAX_PAYLOAD_BYTES, + timeout: String::new(), + }, ], expected_audience: String::new(), })) @@ -282,6 +299,222 @@ impl SupervisorMiddleware for ContentGuard { } } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResponseMode { + HeadersOnly, + WholeBody, + Stream, + StreamClose, + Block, +} + +#[derive(Debug, Default)] +struct ResponseSessionState { + selected: Option, + next_sequence: u64, + body_ended: bool, +} + +impl ResponseSessionState { + fn preflight( + &mut self, + preflight: openshell_core::proto::HttpResponsePreflight, + ) -> Result { + if self.selected.is_some() { + return Err(Status::failed_precondition("duplicate response preflight")); + } + GuardConfig::parse(preflight.config.as_ref()).map_err(Status::invalid_argument)?; + let path = preflight + .target + .as_ref() + .map(|target| target.path.as_str()) + .unwrap_or_default(); + let Some(selected) = response_mode_for_path(path) else { + return Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some(http_response_preflight_result::Action::Skip( + HttpResponsePreflightSkip {}, + )), + reason_code: "path_not_selected".into(), + ..Default::default() + }, + )), + }); + }; + self.selected = Some(selected); + self.next_sequence = 1; + let body_mode = match selected { + ResponseMode::HeadersOnly => HttpResponseBodyMode::HeadersOnly, + ResponseMode::WholeBody | ResponseMode::Block => HttpResponseBodyMode::WholeBodyBytes, + ResponseMode::Stream | ResponseMode::StreamClose => HttpResponseBodyMode::StreamBytes, + }; + Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some(http_response_preflight_result::Action::Inspect( + HttpResponsePreflightInspect { + body_mode: body_mode as i32, + header_mutations: vec![write_header( + "x-example-response-mode", + match selected { + ResponseMode::HeadersOnly => "headers-only", + ResponseMode::WholeBody => "whole-body", + ResponseMode::Stream => "stream", + ResponseMode::StreamClose => "stream-close", + ResponseMode::Block => "block", + }, + )], + }, + )), + ..Default::default() + }, + )), + }) + } + + fn body( + &mut self, + body: openshell_core::proto::HttpResponseBodyUnit, + ) -> Result { + let selected = self + .selected + .ok_or_else(|| Status::failed_precondition("body arrived before preflight"))?; + if selected == ResponseMode::HeadersOnly || self.body_ended { + return Err(Status::failed_precondition( + "body event is invalid for the response session state", + )); + } + if body.sequence != self.next_sequence { + return Err(Status::invalid_argument( + "unexpected response body sequence", + )); + } + self.next_sequence = self.next_sequence.saturating_add(1); + self.body_ended = body.end_of_stream; + let Some(http_response_body_unit::Payload::Data(data)) = body.payload else { + return Err(Status::invalid_argument("body data is required")); + }; + let (action, reason_code) = match selected { + ResponseMode::WholeBody => ( + http_response_body_result::Action::Transform(HttpResponseBodyTransform { + replacement: Some(http_response_body_transform::Replacement::Data( + [b"[whole] ".as_slice(), &data].concat(), + )), + }), + String::new(), + ), + ResponseMode::Stream | ResponseMode::StreamClose => ( + http_response_body_result::Action::Transform(HttpResponseBodyTransform { + replacement: Some(http_response_body_transform::Replacement::Data( + data.to_ascii_uppercase(), + )), + }), + String::new(), + ), + ResponseMode::Block => ( + http_response_body_result::Action::BlockDelivery(HttpResponseBlockDelivery {}), + "content_match".into(), + ), + ResponseMode::HeadersOnly => unreachable!(), + }; + Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::BodyResult( + HttpResponseBodyResult { + sequence: body.sequence, + action: Some(action), + reason_code, + ..Default::default() + }, + )), + }) + } + + fn trailers(&self) -> Result { + if !self.body_ended { + return Err(Status::failed_precondition( + "trailers arrived before the final body result", + )); + } + let trailer_mutations = if self.selected == Some(ResponseMode::Stream) { + vec![write_header("x-example-body-bytes", "11")] + } else { + Vec::new() + }; + Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::TrailersResult( + HttpResponseTrailersResult { + trailer_mutations, + ..Default::default() + }, + )), + }) + } +} + +fn response_mode_for_path(path: &str) -> Option { + match path { + "/headers-only" => Some(ResponseMode::HeadersOnly), + "/whole-body" => Some(ResponseMode::WholeBody), + "/stream" => Some(ResponseMode::Stream), + "/stream-close" => Some(ResponseMode::StreamClose), + "/block" => Some(ResponseMode::Block), + _ => None, + } +} + +fn write_header(name: &str, value: &str) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Write(WriteHeader { + name: name.into(), + value: value.into(), + on_existing: ExistingHeaderAction::Overwrite as i32, + })), + } +} + +#[tonic::async_trait] +impl HttpResponsePreReturn for ContentGuard { + type EvaluateStream = HttpResponseResultStream; + + async fn evaluate( + &self, + request: Request>, + ) -> Result, Status> { + let mut events = request.into_inner(); + let (sender, receiver) = mpsc::channel(4); + tokio::spawn(async move { + let mut state = ResponseSessionState::default(); + while let Some(event) = events.next().await { + let result = match event { + Ok(event) => match event.event { + Some(http_response_event::Event::Preflight(preflight)) => { + state.preflight(preflight) + } + Some(http_response_event::Event::Body(body)) => state.body(body), + Some(http_response_event::Event::Trailers(_)) => state.trailers(), + Some(http_response_event::Event::SessionEnd(_)) => break, + None => Err(Status::invalid_argument("response event is required")), + }, + Err(error) => Err(error), + }; + match result { + Ok(result) => { + if sender.send(Ok(result)).await.is_err() { + break; + } + } + Err(error) => { + let _ = sender.send(Err(error)).await; + break; + } + } + } + }); + Ok(Response::new(Box::pin(ReceiverStream::new(receiver)))) + } +} + fn validate_phase(phase: i32) -> Result<(), String> { if phase != PHASE as i32 { return Err(format!("unsupported phase '{phase}'")); @@ -470,6 +703,7 @@ async fn main() -> Result<(), Box> { println!("serving {MANIFEST_NAME} on http://{}", cli.bind); Server::builder() .add_service(SupervisorMiddlewareServer::new(ContentGuard)) + .add_service(HttpResponsePreReturnServer::new(ContentGuard)) .serve(cli.bind) .await?; Ok(()) @@ -478,7 +712,10 @@ async fn main() -> Result<(), Box> { #[cfg(test)] mod tests { use super::*; - use openshell_core::proto::{MiddlewareSessionEnd, WebSocketPreflight, WebSocketSessionStart}; + use openshell_core::proto::{ + HttpRequestTarget, HttpResponseBodyUnit, HttpResponsePreflight, MiddlewareSessionEnd, + WebSocketPreflight, WebSocketSessionStart, + }; use prost_types::{ListValue, Value}; use std::collections::BTreeMap; @@ -511,13 +748,13 @@ mod tests { } #[tokio::test] - async fn manifest_advertises_http_and_websocket_bindings() { + async fn manifest_advertises_request_response_and_websocket_bindings() { let manifest = SupervisorMiddleware::describe(&ContentGuard, Request::new(())) .await .expect("describe") .into_inner(); - assert_eq!(manifest.bindings.len(), 2); + assert_eq!(manifest.bindings.len(), 3); assert_eq!( manifest.bindings[0].operation, SupervisorMiddlewareOperation::HttpRequest as i32 @@ -528,6 +765,126 @@ mod tests { SupervisorMiddlewareOperation::WebsocketMessage as i32 ); assert_eq!(manifest.bindings[1].max_payload_bytes, MAX_PAYLOAD_BYTES); + assert_eq!( + manifest.bindings[2].operation, + SupervisorMiddlewareOperation::HttpResponse as i32 + ); + assert_eq!( + manifest.bindings[2].phase, + SupervisorMiddlewarePhase::PreReturn as i32 + ); + } + + fn response_preflight(path: &str) -> HttpResponsePreflight { + HttpResponsePreflight { + target: Some(HttpRequestTarget { + path: path.into(), + ..Default::default() + }), + config: Some(config("redact", &["prototype-secret"], Some("[FILTERED]"))), + ..Default::default() + } + } + + #[test] + fn response_paths_select_all_modes() { + for (path, expected) in [ + ("/headers-only", HttpResponseBodyMode::HeadersOnly), + ("/whole-body", HttpResponseBodyMode::WholeBodyBytes), + ("/stream", HttpResponseBodyMode::StreamBytes), + ("/stream-close", HttpResponseBodyMode::StreamBytes), + ("/block", HttpResponseBodyMode::WholeBodyBytes), + ] { + let mut state = ResponseSessionState::default(); + let result = state.preflight(response_preflight(path)).unwrap(); + let Some(http_response_event_result::Result::PreflightResult(result)) = result.result + else { + panic!("expected preflight result"); + }; + let Some(http_response_preflight_result::Action::Inspect(inspect)) = result.action + else { + panic!("expected inspect action"); + }; + assert_eq!(inspect.body_mode, expected as i32); + } + } + + #[test] + fn response_whole_body_transforms_and_block_is_typed() { + let mut whole = ResponseSessionState::default(); + whole.preflight(response_preflight("/whole-body")).unwrap(); + let result = whole + .body(HttpResponseBodyUnit { + sequence: 1, + payload: Some(http_response_body_unit::Payload::Data(b"body".to_vec())), + end_of_stream: true, + }) + .unwrap(); + let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { + panic!("expected body result"); + }; + let Some(http_response_body_result::Action::Transform(transform)) = body.action else { + panic!("expected body transform"); + }; + let Some(http_response_body_transform::Replacement::Data(data)) = transform.replacement + else { + panic!("expected data replacement"); + }; + assert_eq!(data, b"[whole] body"); + + let mut block = ResponseSessionState::default(); + block.preflight(response_preflight("/block")).unwrap(); + let result = block + .body(HttpResponseBodyUnit { + sequence: 1, + payload: Some(http_response_body_unit::Payload::Data( + b"prototype-secret".to_vec(), + )), + end_of_stream: true, + }) + .unwrap(); + let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { + panic!("expected body result"); + }; + assert!(matches!( + body.action, + Some(http_response_body_result::Action::BlockDelivery(_)) + )); + assert_eq!(body.reason_code, "content_match"); + } + + #[test] + fn response_stream_returns_the_required_trailer_exchange() { + let mut state = ResponseSessionState::default(); + state.preflight(response_preflight("/stream")).unwrap(); + state + .body(HttpResponseBodyUnit { + sequence: 1, + payload: Some(http_response_body_unit::Payload::Data(b"body".to_vec())), + end_of_stream: true, + }) + .unwrap(); + let result = state.trailers().unwrap(); + let Some(http_response_event_result::Result::TrailersResult(trailers)) = result.result + else { + panic!("expected trailers result"); + }; + assert_eq!(trailers.trailer_mutations.len(), 1); + } + + #[test] + fn response_paths_outside_the_example_are_skipped() { + let mut state = ResponseSessionState::default(); + let result = state.preflight(response_preflight("/outside")).unwrap(); + let Some(http_response_event_result::Result::PreflightResult(result)) = result.result + else { + panic!("expected preflight result"); + }; + assert!(matches!( + result.action, + Some(http_response_preflight_result::Action::Skip(_)) + )); + assert_eq!(result.reason_code, "path_not_selected"); } #[tokio::test] @@ -737,4 +1094,11 @@ mod tests { assert_eq!(parsed.mode, Mode::Redact); assert_eq!(parsed.replacement, DEFAULT_REPLACEMENT); } + + #[test] + fn example_policy_is_valid() { + let policy = openshell_policy::parse_sandbox_policy(include_str!("../policy.yaml")) + .expect("example policy must parse"); + openshell_policy::validate_sandbox_policy(&policy).expect("example policy must be valid"); + } } diff --git a/examples/supervisor-middleware-content-guard/upstream.py b/examples/supervisor-middleware-content-guard/upstream.py new file mode 100644 index 0000000000..02897ddcfc --- /dev/null +++ b/examples/supervisor-middleware-content-guard/upstream.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import socketserver + + +class Handler(socketserver.BaseRequestHandler): + def handle(self): + request = b"" + while b"\r\n\r\n" not in request: + block = self.request.recv(4096) + if not block: + return + request += block + path = request.split(b" ", 2)[1] + if path == b"/headers-only": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: 12\r\n\r\n" + b"headers-only" + ) + elif path == b"/whole-body": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + b"6\r\nwhole \r\n4\r\nbody\r\n0\r\n\r\n" + ) + elif path == b"/stream": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Trailer: x-example-body-bytes\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + b"6\r\nstream\r\n5\r\n body\r\n" + b"0\r\nX-Example-Body-Bytes: 0\r\n\r\n" + ) + elif path == b"/stream-close": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/event-stream\r\n" + b"Connection: close\r\n\r\n" + b"data: stream close\n\n" + ) + elif path == b"/block": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: 16\r\n\r\n" + b"prototype-secret" + ) + else: + response = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" + self.request.sendall(response) + + +class DemoServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + + +with DemoServer(("0.0.0.0", 18081), Handler) as server: + print("response framing demo upstream listening on 0.0.0.0:18081", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 51139ba461..0fe09899b7 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -394,6 +394,10 @@ message GetSandboxConfigResponse { // False also covers older gateways that do not advertise this capability; // supervisors preserve their legacy unauthenticated connection behavior. bool extension_authentication_enabled = 12; + // Supervisor-wide wall-clock limit for accumulating and processing a response + // through whole-body middleware. Zero means the supervisor default for + // compatibility with older gateways. + uint64 http_response_whole_body_timeout_ms = 13; } // Connection details for one operator-registered supervisor middleware service. diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 8da143ebaa..9b4fa8a9d7 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -1826,6 +1826,10 @@ type GetSandboxConfigResponse struct { // False also covers older gateways that do not advertise this capability; // supervisors preserve their legacy unauthenticated connection behavior. ExtensionAuthenticationEnabled bool `protobuf:"varint,12,opt,name=extension_authentication_enabled,json=extensionAuthenticationEnabled,proto3" json:"extension_authentication_enabled,omitempty"` + // Supervisor-wide wall-clock limit for accumulating and processing a response + // through whole-body middleware. Zero means the supervisor default for + // compatibility with older gateways. + HttpResponseWholeBodyTimeoutMs uint64 `protobuf:"varint,13,opt,name=http_response_whole_body_timeout_ms,json=httpResponseWholeBodyTimeoutMs,proto3" json:"http_response_whole_body_timeout_ms,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1944,6 +1948,13 @@ func (x *GetSandboxConfigResponse) GetExtensionAuthenticationEnabled() bool { return false } +func (x *GetSandboxConfigResponse) GetHttpResponseWholeBodyTimeoutMs() uint64 { + if x != nil { + return x.HttpResponseWholeBodyTimeoutMs + } + return 0 +} + // Connection details for one operator-registered supervisor middleware service. // V1 supports plaintext and server-authenticated TLS gRPC. type SupervisorMiddlewareService struct { @@ -2208,7 +2219,7 @@ const file_sandbox_proto_rawDesc = "" + "\x05value\"\x86\x01\n" + "\x10EffectiveSetting\x128\n" + "\x05value\x18\x01 \x01(\v2\".openshell.sandbox.v1.SettingValueR\x05value\x128\n" + - "\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\xd1\x06\n" + + "\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\x9e\a\n" + "\x18GetSandboxConfigResponse\x12;\n" + "\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x18\n" + "\aversion\x18\x02 \x01(\rR\aversion\x12\x1f\n" + @@ -2223,7 +2234,8 @@ const file_sandbox_proto_rawDesc = "" + "\tworkspace\x18\n" + " \x01(\tR\tworkspace\x12C\n" + "\x1epolicy_validation_failure_mode\x18\v \x01(\tR\x1bpolicyValidationFailureMode\x12H\n" + - " extension_authentication_enabled\x18\f \x01(\bR\x1eextensionAuthenticationEnabled\x1ac\n" + + " extension_authentication_enabled\x18\f \x01(\bR\x1eextensionAuthenticationEnabled\x12K\n" + + "#http_response_whole_body_timeout_ms\x18\r \x01(\x04R\x1ehttpResponseWholeBodyTimeoutMs\x1ac\n" + "\rSettingsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\x99\x02\n" + diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 8e86bc0643..4501934ee4 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -106,13 +106,15 @@ The gateway calls each interceptor's `Describe` RPC and validates its manifest a For operator-run supervisor middleware, inspect `[[openshell.supervisor.middleware]]`, service reachability, and both gateway and supervisor logs: ```bash -rg -n 'supervisor|middleware|grpc_endpoint|tls_ca_cert_path|audience|allow_insecure_transport|max_payload_bytes|timeout|gateway_jwt' /etc/openshell/gateway.toml +rg -n 'supervisor|middleware|grpc_endpoint|tls_ca_cert_path|audience|allow_insecure_transport|max_payload_bytes|timeout|http_response_whole_body_timeout|gateway_jwt' /etc/openshell/gateway.toml journalctl -u --no-pager --lines=200 journalctl -u openshell-gateway --no-pager --lines=200 openshell logs --tail --source sandbox ``` -The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, a manifest exposes duplicate operation/phase bindings, the registration claims the reserved `openshell/` namespace, or payload and timeout limits are invalid. Supported V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS` and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. When gateway JWT signing is disabled, supervisors preserve the legacy unauthenticated connector and do not request extension credentials. When signing is enabled, credential acquisition and verification failures are fail closed: check HTTPS trust and hostname validation, audience and issuer agreement, the token `kid`, gateway `RefreshSandboxToken` errors, and middleware logs. Changing a registration requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config. +The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, a manifest exposes duplicate operation/phase bindings, the registration claims the reserved `openshell/` namespace, or payload and timeout limits are invalid. Supported V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. When gateway JWT signing is disabled, supervisors preserve the legacy unauthenticated connector and do not request extension credentials. When signing is enabled, credential acquisition and verification failures are fail closed: check HTTPS trust and hostname validation, audience and issuer agreement, the token `kid`, gateway `RefreshSandboxToken` errors, and middleware logs. Changing a registration or `http_response_whole_body_timeout` requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config. + +For response failures, distinguish a deliberate `middleware_denied` decision from `response_delivery_failed`. Before response commitment, they produce canonical 403 and 502 responses respectively. After commitment, OpenShell aborts without adding an error body, final chunk, or trailer. A whole-body accumulation timeout is one non-resetting wall-clock deadline shared across response reads and whole-body barriers; inspect the active stage's `on_error`, the supervisor timeout setting, and `whole_body_accumulation_timeout` diagnostics. Header-only stages preserve upstream body framing. Body-processing stages normalize framing and send a trailer exchange, including an empty trailer set, after the final body result. At request time, distinguish attachment, binding selection, coverage, denial, and failure. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; the connection proceeds under either `on_error` mode and emits `binding_not_selected` coverage. A selected WebSocket stage receives text messages only. Binary messages pass under both modes, emit `unsupported_message_type` coverage, and consume a session sequence without an RPC. An explicit `middleware_denied` result is always enforced. WebSocket preflight returns `INSPECT`, voluntary `SKIP`, or authoritative `DENY`; `DENY` rejects the upgrade before upstream contact under both `on_error` modes. A selected-stage failure follows the policy-local `on_error`: `fail_closed` blocks the HTTP request or closes the WebSocket, while `fail_open` bypasses only that stage and emits a detection finding. A fail-open per-message capacity failure bypasses that message without disabling the stage. A timeout, transport failure, stream closure, missing or invalid response, duplicate or regressed sequence, or other failure that makes an established WebSocket stream unreliable disables that stage for later messages on the connection and emits `openshell.middleware.websocket_stage_disabled`. Confirm preflight, session-start, and session-end in service logs. OpenShell best-effort sends at most one session-end to each still-writable opened stage, including a preflight that terminates before session start; distinguish `MIDDLEWARE_DENIAL` from `MIDDLEWARE_FAILURE`. WebSocket message sequences are allocated session-wide; each stage receives a strictly increasing subset, so gaps are valid when binary messages or other units are not delivered to that stage. Zero, duplicate, or regressed sequences are protocol errors. If a running supervisor cannot install a new registry, it preserves its last-known-good generation and emits a configuration failure event. @@ -696,6 +698,7 @@ configuration — check that the gateway spawned the driver binary you expect | Policy mutation returns `FAILED_PRECONDITION` for endpoint ambiguity | Equally specific effective endpoint selectors disagree on connection or request-processing metadata | CLI error, base and provider-composed policy, affected profile attachments; confirm no new revision was stored | | Supervisor enters policy quarantine | A runtime candidate failed validation while `policy_validation_failure_mode = "fail_closed"` | Sandbox OCSF config/finding events, validation rationale, active generation, `previous_policy_active` | | HTTP request returns `middleware_failed` or `middleware_denied`, or WebSocket closes with `1008` | Selected stage failed or explicitly denied admitted traffic | Sandbox OCSF logs; policy-local middleware config; service availability; binding operation; `on_error` | +| HTTP response becomes canonical `403 middleware_denied`, `502 response_delivery_failed`, or closes mid-body | Response middleware blocked, failed before commitment, or stopped delivery after commitment | Sandbox OCSF response middleware events; `HTTP_RESPONSE/PRE_RETURN` binding; `on_error`; `http_response_whole_body_timeout`; service stream lifecycle | | WebSocket upgrades but a host-matched middleware receives no preflight or message RPC | The implementation did not advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` | `WEBSOCKET_MIDDLEWARE_COVERAGE state=binding_not_selected`; service `Describe`; the upgrade GET may still have used its HTTP binding | | Binary WebSocket message passes without a middleware RPC | Binary is unsupported by the V1 text-message binding under both `on_error` modes | `WEBSOCKET_MIDDLEWARE_COVERAGE state=unsupported_message_type`; the next text RPC may have a valid sequence gap | | WebSocket messages stop reaching middleware after one failure | A fail-open stage stream was disabled for the rest of the connection | `openshell.middleware.websocket_stage_disabled`; middleware timeout/stream/protocol logs. A per-message capacity bypass alone leaves the stage active. Reconnect to create a fresh stream after a genuine stream failure | diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 73c0863df7..2f7436e567 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -83,7 +83,7 @@ Regardless of tier, extract (or infer) these from the user's description: | **Paths** | Specific URL paths or patterns | Only for custom/fine-grained | | **Enforcement** | `enforce` or `audit`? Default to `enforce`. | No — has a default | | **Binary** | Which binary/process should have access | Yes — ask if not stated | -| **Middleware** | Whether admitted HTTP requests or client WebSocket text messages need an ordered built-in or operator-run processing stage | No | +| **Middleware** | Whether admitted HTTP requests, final HTTP responses, or client WebSocket text messages need an ordered built-in or operator-run processing stage | No | If the host and access level are clear but binaries are not specified, ask the user which binary or process will be making the requests. Suggest common defaults like `/usr/bin/curl`, `/usr/local/bin/claude`, etc. @@ -209,11 +209,12 @@ Is L7 inspection needed? ### Middleware Decision -Add `network_middlewares` only when the user asks to inspect, transform, redact, or independently authorize admitted HTTP requests or client WebSocket text messages. Middleware runs after network and L7 policy admission and before provider credential injection. +Add `network_middlewares` only when the user asks to inspect, transform, redact, or independently authorize admitted HTTP requests, final HTTP responses, or client WebSocket text messages. Request middleware runs after network and L7 policy admission and before provider credential injection. Response middleware runs on the matching final response before it returns to the sandbox. - Use `openshell/regex` without gateway registration for fixed-pattern redaction of UTF-8 HTTP request bodies or complete client-to-upstream WebSocket text messages. - Use an operator-owned middleware name only when it is already registered under `[[openshell.supervisor.middleware]]` and reachable from both the gateway and sandbox supervisors. - Confirm that a requested WebSocket implementation exposes a `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` binding. `openshell/regex` exposes this binding. A host-matched HTTP-only implementation may inspect the upgrade GET but does not join the post-upgrade chain; messages pass and OpenShell emits `binding_not_selected` coverage regardless of `on_error`. +- Confirm that requested response processing exposes `HTTP_RESPONSE/PRE_RETURN`. Response stages choose header-only, whole-body, or streaming inspection independently. Whole-body stages delay response commitment and are bounded by the gateway's supervisor-wide accumulation timeout. Intentional blocks produce a canonical 403 before commitment and abort the connection after commitment. - WebSocket middleware runs for both `ws://` and `wss://` and receives complete client text messages only. Binary messages pass under both error modes and emit `unsupported_message_type` coverage for active stages. Upstream-to-client messages remain uninspected. Do not claim that V1 provides all-message WebSocket inspection. - Treat `fail_open` on WebSocket as a session-scoped bypass: if the stage stream fails, OpenShell disables it for later messages on that connection and emits a state-change finding. Prefer `fail_closed` for required redaction or authorization. - `on_error` governs failures after an advertised operation binding is selected. It does not apply to an unadvertised WebSocket binding or binary-message pass-through. An explicit HTTP, WebSocket preflight, or WebSocket message denial is authoritative under both `fail_open` and `fail_closed`. @@ -380,6 +381,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] Middleware `order` values are unique and no selected chain exceeds 10 stages - [ ] No fail-closed middleware selector can cover a `tls: skip` endpoint - [ ] Any required WebSocket control advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, and the user understands that V1 does not inspect binary messages +- [ ] Any required response control advertises `HTTP_RESPONSE/PRE_RETURN`, and whole-body buffering fits the registered payload limit and supervisor deadline - [ ] Endpoints contributed by a credentialed provider are not L4-only or `tls: skip` unless `allow_uninspected_credentials: true` explicitly records the exception ### Schema Warnings (log-only, but should be fixed) diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index c46f569789..dc3466f8bb 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -498,12 +498,14 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au - TLS termination configuration - Enforcement modes (`audit` vs `enforce`) - Binary matching patterns -- Ordered `network_middlewares`, host selection, HTTP and WebSocket bindings, and `fail_open` or `fail_closed` behavior +- Ordered `network_middlewares`, host selection, HTTP request/response and WebSocket bindings, and `fail_open` or `fail_closed` behavior `network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. Middleware can inspect parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. +An operator-run implementation can also advertise `HTTP_RESPONSE/PRE_RETURN`. Each selected response stage chooses header-only, whole-body, or streaming inspection. Whole-body inspection delays downstream commitment and uses the supervisor-wide `http_response_whole_body_timeout` from gateway configuration. A valid middleware block returns the canonical 403 before commitment; after commitment OpenShell aborts the response without adding error bytes. Use sandbox OCSF events to distinguish an intentional block from `response_delivery_failed` and fail-open bypass. + ### Step 5: Push the updated policy ```bash From fb489c8376fa8d5a733022f50e287103c6d367b6 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 8 Sep 2026 16:29:33 -0700 Subject: [PATCH 02/13] fix(middleware): allow one-byte response stream units Signed-off-by: Piotr Mlocek --- .../src/response.rs | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index 5baa7f93a1..2504b1a14c 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -1476,7 +1476,7 @@ fn permitted_body_modes( { modes.push(HttpResponseBodyMode::WholeBodyBytes as i32); } - if entry.max_payload_bytes >= 2 { + if entry.max_payload_bytes > 0 { modes.push(HttpResponseBodyMode::StreamBytes as i32); } modes @@ -2215,6 +2215,24 @@ mod tests { } } + #[test] + fn stream_mode_requires_only_one_byte_of_payload_capacity() { + let mut described = DescribedChainEntry { + entry: entry(OnError::FailClosed), + service: None, + binding: None, + max_payload_bytes: 1, + timeout: Duration::from_millis(500), + }; + + let modes = permitted_body_modes(&input(200), &described, None); + assert!(modes.contains(&(HttpResponseBodyMode::StreamBytes as i32))); + + described.max_payload_bytes = 0; + let modes = permitted_body_modes(&input(200), &described, None); + assert!(!modes.contains(&(HttpResponseBodyMode::StreamBytes as i32))); + } + #[tokio::test] async fn headers_only_preflight_applies_end_to_end_mutation() { let runner = ChainRunner::new(Arc::new(ResponseService { From c863bd771e375ab74f6bd2e98125f92664a95bcc Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 8 Sep 2026 17:14:04 -0700 Subject: [PATCH 03/13] feat(examples): separate content guard from middleware protocol demos Signed-off-by: Piotr Mlocek --- .github/workflows/branch-checks.yml | 4 + architecture/sandbox.md | 6 + docs/extensibility/supervisor-middleware.mdx | 15 +- .../README.md | 48 +- .../policy.yaml | 17 +- .../smoke.sh | 79 +- .../src/main.rs | 469 ++-- .../upstream.py | 71 +- .../supervisor-middleware-protocol/Cargo.lock | 2247 +++++++++++++++++ .../supervisor-middleware-protocol/Cargo.toml | 27 + .../supervisor-middleware-protocol/README.md | 100 + .../policy.yaml | 49 + .../supervisor-middleware-protocol/smoke.sh | 536 ++++ .../src/main.rs | 173 ++ .../src/request.rs | 49 + .../src/response.rs | 345 +++ .../src/websocket.rs | 163 ++ .../upstream.py | 114 + .../websocket-client.py | 58 + tasks/rust.toml | 3 + tasks/test.toml | 2 + 21 files changed, 4165 insertions(+), 410 deletions(-) create mode 100644 examples/supervisor-middleware-protocol/Cargo.lock create mode 100644 examples/supervisor-middleware-protocol/Cargo.toml create mode 100644 examples/supervisor-middleware-protocol/README.md create mode 100644 examples/supervisor-middleware-protocol/policy.yaml create mode 100755 examples/supervisor-middleware-protocol/smoke.sh create mode 100644 examples/supervisor-middleware-protocol/src/main.rs create mode 100644 examples/supervisor-middleware-protocol/src/request.rs create mode 100644 examples/supervisor-middleware-protocol/src/response.rs create mode 100644 examples/supervisor-middleware-protocol/src/websocket.rs create mode 100644 examples/supervisor-middleware-protocol/upstream.py create mode 100644 examples/supervisor-middleware-protocol/websocket-client.py diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 5d4a98c61a..fbdefb8ed2 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -159,6 +159,7 @@ jobs: cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check + cargo fmt --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all -- --check - name: Lint run: | @@ -166,12 +167,15 @@ jobs: cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets cargo check --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets + cargo check --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all-targets - name: Test env: OPENSHELL_TELEMETRY_ENABLED: "false" run: | cargo nextest run --profile ci --workspace --features openshell-server/test-support + cargo test --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml + cargo test --manifest-path examples/supervisor-middleware-protocol/Cargo.toml - name: Verify telemetry can be compiled out run: | diff --git a/architecture/sandbox.md b/architecture/sandbox.md index ad0ce61117..81c4a1b76f 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -163,6 +163,12 @@ host selectors choose the chain independently of the network rule that admitted the request. Policy-local map keys identify configs, while built-in names or operator-owned registration names identify implementations. +The configured-literal content-guard example shares matching semantics across +request bodies, complete response bodies, and client WebSocket text messages. +It requires whole-body response inspection and returns a middleware failure +when that mode is unavailable. The separate protocol example demonstrates each +V1 hook, response framing modes, unit-local transforms, and trailer mutation. + Built-ins run in-process against a borrowed view of the chain's current HTTP request state. Operator services retain the bounded protobuf/gRPC contract, and the remote adapter materializes an owned HTTP evaluation only when a request diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index 9f3c723b16..3c667faf09 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -220,10 +220,23 @@ Middleware activity is emitted through OpenShell's OCSF logging: See [Logging](/observability/logging) for log access and [OCSF JSON Export](/observability/ocsf-json-export) for structured export. +## Runnable examples + +The [content guard example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-content-guard) +matches configured literal terms in UTF-8 request bodies, complete response +bodies, and client WebSocket text messages. It supports redaction and denial. +Responses require whole-body inspection; unavailable inspection or invalid +UTF-8 invokes the configured `on_error` policy. It is not a general PII detector. + +The [protocol example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-protocol) +demonstrates all V1 hooks in one service, including header-only response changes, +whole-body and unit-local streaming transformations, trailers, and typed blocks. +Each example includes a policy, local fixture, and smoke launcher. + ## Current Limitations - Middleware applies only through operation bindings advertised by each implementation. For protocols that have no supported middleware operation at all, such as HTTP/2 prior knowledge or non-HTTP TCP, the existing uninspectable-traffic gate denies a host match containing `fail_closed` and relays an all-`fail_open` match with a detection finding. -- The typed operation and phase pairs are `HTTP_REQUEST/PRE_CREDENTIALS` and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. +- The typed operation and phase pairs are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. - A host match does not imply every advertised operation: an HTTP-only attachment can inspect the upgrade GET, then post-upgrade traffic passes with `binding_not_selected` coverage. - The V1 WebSocket binding inspects complete client text messages only. Binary messages pass with `unsupported_message_type` coverage for active stages; control frames and upstream-to-client messages remain outside the middleware operation. - Selection uses destination host include and exclude patterns. diff --git a/examples/supervisor-middleware-content-guard/README.md b/examples/supervisor-middleware-content-guard/README.md index 21b2ae87f7..4ccda9162a 100644 --- a/examples/supervisor-middleware-content-guard/README.md +++ b/examples/supervisor-middleware-content-guard/README.md @@ -8,14 +8,14 @@ SPDX-License-Identifier: Apache-2.0 > [!WARNING] > Supervisor middleware is a research preview. Its policy and service contracts may change without compatibility guarantees. Use it only to prototype and evaluate middleware integrations. -This example implements request, response, and client WebSocket bindings in one operator-run supervisor middleware service. The response binding demonstrates header-only inspection, whole-body and streaming transforms, trailer mutation, and block delivery. +This configured-literal guard applies the same case-sensitive terms to UTF-8 HTTP request bodies, complete HTTP response bodies, and client WebSocket text messages. It is not a general PII detector. See [the protocol example](../supervisor-middleware-protocol/) for demonstrations of response modes and framing. > [!WARNING] -> This intentionally simple implementation demonstrates the supervisor middleware service contract. It is not a complete or reliable content guard and must not be used as a security control. It handles only UTF-8 HTTP request bodies and WebSocket text messages with case-sensitive literal terms, merges overlapping literal match ranges before redaction, and does not address encodings, transformations, normalization, binary WebSocket messages, upstream-to-client messages, or adversarial inputs that a production content guard must handle. +> This intentionally simple implementation demonstrates the supervisor middleware service contract. It is not a complete or reliable content guard and must not be used as a security control. It handles only UTF-8 HTTP request and response bodies and WebSocket text messages with case-sensitive literal terms, merges overlapping literal match ranges before redaction, and does not address encodings, transformations, normalization, binary WebSocket messages, upstream-to-client messages, or adversarial inputs that a production content guard must handle. ## Prerequisites -Install `cargo`, `curl`, `jq`, `openssl`, and Python 3 on the host before running the smoke script. +Install `cargo`, `curl`, `jq`, `openssl`, and `uv` with Python 3 on the host before running the smoke script. ## Run the smoke example @@ -89,31 +89,31 @@ The echoed JSON body contains `[FILTERED]` instead of the configured term. ## HTTP response behavior -Start the included raw HTTP upstream in another terminal: +The smoke launcher starts the local fixture. To start it manually: ```shell -python3 examples/supervisor-middleware-content-guard/upstream.py +uv run --no-project python examples/supervisor-middleware-content-guard/upstream.py ``` -From the sandbox, exercise the response protocol: - -```shell -curl -i http://host.openshell.internal:18081/headers-only -curl -i http://host.openshell.internal:18081/whole-body -curl -i --raw http://host.openshell.internal:18081/stream -curl -i --raw http://host.openshell.internal:18081/stream-close -curl -i http://host.openshell.internal:18081/block -``` - -| Path | Mode | Result | -| --- | --- | --- | -| `/headers-only` | `HEADERS_ONLY` | Adds `x-example-response-mode` without changing content-length framing. | -| `/whole-body` | `WHOLE_BODY_BYTES` | Prefixes the normalized body with `[whole]`. | -| `/stream` | `STREAM_BYTES` | Uppercases normalized units and changes the existing `x-example-body-bytes` trailer to `11`. | -| `/stream-close` | `STREAM_BYTES` | Uppercases a close-delimited `text/event-stream` response. | -| `/block` | `WHOLE_BODY_BYTES` | Returns OpenShell's canonical 403 response with reason code `content_match`. | - -The upstream deliberately uses content-length, chunked, and close-delimited responses. OpenShell normalizes transport framing only for body-processing modes and validates trailer changes against the names supplied by the upstream. +The policy permits `GET /clean` and `GET /sensitive` on +`http://host.openshell.internal:18081`. The first returns ordinary public text. +The second contains both configured terms. Redact mode returns +`contains [FILTERED] and [FILTERED]`. Deny mode returns typed `BlockDelivery` +with reason code `content_match`, which produces the canonical 403 response +before delivery. The smoke suite recreates the sandbox in deny mode and checks +both clean and matching responses through the external gRPC service. + +Every selected response requires `WHOLE_BODY_BYTES`. If that mode is unavailable, +the service returns a middleware failure and the policy's `on_error` decides +whether delivery fails open or closed. This includes encoded, partial, +no-transform, bodyless, and known oversized responses. Unknown-length bodies can +also exceed the runtime limit during collection. Invalid UTF-8 fails the same way. +The example policy uses `fail_closed`. + +Clean bodies pass unchanged. Matching spans are merged and replaced in the +complete body, so transport chunk boundaries do not affect matching. Trailers +are accepted without mutation. The guard does not decode compressed bodies, +normalize Unicode, scan response headers, retain stream units, or spool bodies. ## WebSocket behavior diff --git a/examples/supervisor-middleware-content-guard/policy.yaml b/examples/supervisor-middleware-content-guard/policy.yaml index b72af7ac2f..da08607f27 100644 --- a/examples/supervisor-middleware-content-guard/policy.yaml +++ b/examples/supervisor-middleware-content-guard/policy.yaml @@ -45,8 +45,8 @@ network_policies: path: /anything binaries: - path: /usr/bin/curl - response-framing-demo: - name: Response framing demo + guard-responses: + name: Guard responses endpoints: - host: host.openshell.internal port: 18081 @@ -54,18 +54,9 @@ network_policies: rules: - allow: method: GET - path: /headers-only + path: /clean - allow: method: GET - path: /whole-body - - allow: - method: GET - path: /stream - - allow: - method: GET - path: /stream-close - - allow: - method: GET - path: /block + path: /sensitive binaries: - path: /usr/bin/curl diff --git a/examples/supervisor-middleware-content-guard/smoke.sh b/examples/supervisor-middleware-content-guard/smoke.sh index 106d544891..a372a7ffe1 100755 --- a/examples/supervisor-middleware-content-guard/smoke.sh +++ b/examples/supervisor-middleware-content-guard/smoke.sh @@ -111,10 +111,10 @@ if [[ "$SERVICE_HOST" == "localhost" || "$SERVICE_HOST" == "::1" || "$SERVICE_HO exit 1 fi -TMPDIR="$(mktemp -d)" -LOG_DIR="$TMPDIR/logs" -JWT_DIR="$TMPDIR/jwt" -GATEWAY_CONFIG="$TMPDIR/gateway.toml" +SMOKE_TMP_DIR="$(mktemp -d)" +LOG_DIR="$SMOKE_TMP_DIR/logs" +JWT_DIR="$SMOKE_TMP_DIR/jwt" +GATEWAY_CONFIG="$SMOKE_TMP_DIR/gateway.toml" SETUP_LOG="$LOG_DIR/setup.log" GATEWAY_LOG="$LOG_DIR/gateway.log" MIDDLEWARE_LOG="$LOG_DIR/middleware.log" @@ -152,7 +152,7 @@ cleanup() { fi if [[ "$status" -eq 0 ]]; then - rm -rf "$TMPDIR" + rm -rf "$SMOKE_TMP_DIR" else echo "logs retained in $LOG_DIR" >&2 fi @@ -348,23 +348,23 @@ wait_for_middleware() { } start_upstream() { - printf 'INFO starting response framing upstream at %s:18081\n' "$SERVICE_HOST" - python3 "$EXAMPLE_DIR/upstream.py" >"$UPSTREAM_LOG" 2>&1 & + printf 'INFO starting content guard upstream at %s:18081\n' "$SERVICE_HOST" + uv run --no-project python "$EXAMPLE_DIR/upstream.py" >"$UPSTREAM_LOG" 2>&1 & UPSTREAM_PID=$! } wait_for_upstream() { for _ in {1..30}; do if ! kill -0 "$UPSTREAM_PID" 2>/dev/null; then - fail "response framing upstream starts" + fail "content guard upstream starts" fi - if curl -fsS --max-time 1 "http://127.0.0.1:18081/headers-only" >/dev/null 2>&1; then - printf 'INFO response framing upstream is ready\n' + if curl -fsS --max-time 1 "http://127.0.0.1:18081/clean" >/dev/null 2>&1; then + printf 'INFO content guard upstream is ready\n' return fi sleep 1 done - fail "response framing upstream is reachable" + fail "content guard upstream is reachable" } start_gateway() { @@ -382,7 +382,7 @@ start_gateway() { --metrics-port 0 \ --log-level "${CONTENT_GUARD_SMOKE_LOG_LEVEL:-info}" \ --disable-tls \ - --db-url "sqlite://$TMPDIR/gateway.db" >"$GATEWAY_LOG" 2>&1 & + --db-url "sqlite://$SMOKE_TMP_DIR/gateway.db" >"$GATEWAY_LOG" 2>&1 & GATEWAY_PID=$! } @@ -427,7 +427,7 @@ request() { response_request() { local path="$1" "${CLI[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- \ - curl -sS -i --raw --max-time 20 "http://host.openshell.internal:18081/$path" + curl -sS -i --max-time 20 "http://host.openshell.internal:18081/$path" } run_suite() { @@ -440,32 +440,17 @@ run_suite() { fail "guarded request completes" fi - printf 'INFO exercising HTTP response middleware modes\n' - if ! response_request headers-only >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fiq 'x-example-response-mode: headers-only' "$response_output" || - ! grep -Fq 'headers-only' "$response_output"; then - fail "headers-only response middleware" + printf 'INFO checking response pass-through and redaction\n' + if ! response_request clean >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'ordinary public text' "$response_output"; then + fail "clean response passes unchanged" fi - if ! response_request whole-body >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fq '[whole] whole body' "$response_output"; then - fail "whole-body response middleware" + if ! response_request sensitive >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'contains [FILTERED] and [FILTERED]' "$response_output" || + grep -Fq 'prototype-secret' "$response_output"; then + fail "configured response terms are redacted" fi - if ! response_request stream >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fq 'STREAM BODY' "$response_output" || - ! grep -Fiq 'x-example-body-bytes: 11' "$response_output"; then - fail "stream response middleware with trailer mutation" - fi - if ! response_request stream-close >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fq 'DATA: STREAM CLOSE' "$response_output"; then - fail "close-delimited SSE response middleware" - fi - if ! response_request block >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fq 'HTTP/1.1 403 Forbidden' "$response_output" || - ! grep -Fq 'middleware_denied' "$response_output" || - ! grep -Fq 'content_match' "$response_output"; then - fail "response middleware block" - fi - printf 'PASS HTTP response middleware modes\n' + printf 'PASS response pass-through and redaction\n' if grep -Fq '[FILTERED]' "$guarded_output" && ! grep -Fq 'prototype-secret' "$guarded_output"; then printf 'PASS guarded request is filtered\n' else @@ -484,6 +469,24 @@ run_suite() { fail "unguarded request is unchanged" fi + # Recreate with the same terms in deny mode, through the external service. + "${CLI[@]}" sandbox delete "$SANDBOX_NAME" >>"$SETUP_LOG" 2>&1 + SANDBOX_CREATED=0 + sed '/replacement:/d; s/mode: redact/mode: deny/' "$EXAMPLE_DIR/policy.yaml" >"$SMOKE_TMP_DIR/deny.yaml" + SANDBOX_CREATED=1 + run_setup_step "creating deny sandbox" "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --policy "$SMOKE_TMP_DIR/deny.yaml" --no-tty --detach -- sleep infinity + if ! response_request sensitive >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'HTTP/1.1 403 Forbidden' "$response_output" || + ! grep -Fq 'content_match' "$response_output" || + grep -Fq 'prototype-secret' "$response_output"; then + fail "configured response term blocks delivery" + fi + if ! response_request clean >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'ordinary public text' "$response_output"; then + fail "deny mode passes clean responses" + fi + printf 'PASS response denial\n' + "${CLI[@]}" sandbox delete "$SANDBOX_NAME" >>"$SETUP_LOG" 2>&1 SANDBOX_CREATED=0 echo "ALL PASS content guard smoke" @@ -529,7 +532,7 @@ require_command cargo require_command curl require_command jq require_command openssl -require_command python3 +require_command uv ROOT_TARGET_DIR="$(cargo_target_dir "$ROOT/Cargo.toml")" EXAMPLE_TARGET_DIR="$(cargo_target_dir "$EXAMPLE_DIR/Cargo.toml")" GATEWAY_BIN="$ROOT_TARGET_DIR/debug/openshell-gateway" diff --git a/examples/supervisor-middleware-content-guard/src/main.rs b/examples/supervisor-middleware-content-guard/src/main.rs index effce70a5a..f395b2a070 100644 --- a/examples/supervisor-middleware-content-guard/src/main.rs +++ b/examples/supervisor-middleware-content-guard/src/main.rs @@ -14,18 +14,17 @@ use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ SupervisorMiddleware, SupervisorMiddlewareServer, }; use openshell_core::proto::{ - Decision, ExistingHeaderAction, Finding, HeaderMutation, HttpRequestEvaluation, - HttpRequestResult, HttpResponseBlockDelivery, HttpResponseBodyMode, HttpResponseBodyResult, - HttpResponseBodyTransform, HttpResponseEvent, HttpResponseEventResult, - HttpResponsePreflightInspect, HttpResponsePreflightResult, HttpResponsePreflightSkip, + Decision, Finding, HttpRequestEvaluation, HttpRequestResult, HttpResponseBlockDelivery, + HttpResponseBodyMode, HttpResponseBodyResult, HttpResponseBodyTransform, HttpResponseEvent, + HttpResponseEventResult, HttpResponsePreflightInspect, HttpResponsePreflightResult, HttpResponseTrailersResult, MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse, WebSocketMessage, WebSocketMessageResult, WebSocketPreflightAction, - WebSocketPreflightDecision, WebSocketSessionEvent, WebSocketSessionEventResult, WriteHeader, - header_mutation, http_response_body_result, http_response_body_transform, - http_response_body_unit, http_response_event, http_response_event_result, - http_response_preflight_result, web_socket_message, web_socket_message_result, - web_socket_session_event, web_socket_session_event_result, + WebSocketPreflightDecision, WebSocketSessionEvent, WebSocketSessionEventResult, + http_response_body_result, http_response_body_transform, http_response_body_unit, + http_response_event, http_response_event_result, http_response_preflight_result, + web_socket_message, web_socket_message_result, web_socket_session_event, + web_socket_session_event_result, }; use prost_types::Struct; use prost_types::value::Kind; @@ -299,72 +298,38 @@ impl SupervisorMiddleware for ContentGuard { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ResponseMode { - HeadersOnly, - WholeBody, - Stream, - StreamClose, - Block, -} - #[derive(Debug, Default)] struct ResponseSessionState { - selected: Option, - next_sequence: u64, + config: Option, body_ended: bool, + trailers_seen: bool, } - impl ResponseSessionState { fn preflight( &mut self, preflight: openshell_core::proto::HttpResponsePreflight, ) -> Result { - if self.selected.is_some() { - return Err(Status::failed_precondition("duplicate response preflight")); + if self.config.is_some() { + return Err(Status::failed_precondition("duplicate preflight")); } - GuardConfig::parse(preflight.config.as_ref()).map_err(Status::invalid_argument)?; - let path = preflight - .target - .as_ref() - .map(|target| target.path.as_str()) - .unwrap_or_default(); - let Some(selected) = response_mode_for_path(path) else { - return Ok(HttpResponseEventResult { - result: Some(http_response_event_result::Result::PreflightResult( - HttpResponsePreflightResult { - action: Some(http_response_preflight_result::Action::Skip( - HttpResponsePreflightSkip {}, - )), - reason_code: "path_not_selected".into(), - ..Default::default() - }, - )), - }); - }; - self.selected = Some(selected); - self.next_sequence = 1; - let body_mode = match selected { - ResponseMode::HeadersOnly => HttpResponseBodyMode::HeadersOnly, - ResponseMode::WholeBody | ResponseMode::Block => HttpResponseBodyMode::WholeBodyBytes, - ResponseMode::Stream | ResponseMode::StreamClose => HttpResponseBodyMode::StreamBytes, - }; + let config = + GuardConfig::parse(preflight.config.as_ref()).map_err(Status::invalid_argument)?; + if !preflight + .permitted_body_modes + .contains(&(HttpResponseBodyMode::WholeBodyBytes as i32)) + { + return Err(Status::failed_precondition( + "content guard requires WHOLE_BODY_BYTES", + )); + } + self.config = Some(config); Ok(HttpResponseEventResult { result: Some(http_response_event_result::Result::PreflightResult( HttpResponsePreflightResult { action: Some(http_response_preflight_result::Action::Inspect( HttpResponsePreflightInspect { - body_mode: body_mode as i32, - header_mutations: vec![write_header( - "x-example-response-mode", - match selected { - ResponseMode::HeadersOnly => "headers-only", - ResponseMode::WholeBody => "whole-body", - ResponseMode::Stream => "stream", - ResponseMode::StreamClose => "stream-close", - ResponseMode::Block => "block", - }, - )], + body_mode: HttpResponseBodyMode::WholeBodyBytes as i32, + header_mutations: vec![], }, )), ..Default::default() @@ -372,107 +337,65 @@ impl ResponseSessionState { )), }) } - fn body( &mut self, body: openshell_core::proto::HttpResponseBodyUnit, ) -> Result { - let selected = self - .selected - .ok_or_else(|| Status::failed_precondition("body arrived before preflight"))?; - if selected == ResponseMode::HeadersOnly || self.body_ended { + let config = self + .config + .as_ref() + .ok_or_else(|| Status::failed_precondition("body before preflight"))?; + if self.body_ended || body.sequence != 1 || !body.end_of_stream { return Err(Status::failed_precondition( - "body event is invalid for the response session state", - )); - } - if body.sequence != self.next_sequence { - return Err(Status::invalid_argument( - "unexpected response body sequence", + "expected one complete response body", )); } - self.next_sequence = self.next_sequence.saturating_add(1); - self.body_ended = body.end_of_stream; let Some(http_response_body_unit::Payload::Data(data)) = body.payload else { - return Err(Status::invalid_argument("body data is required")); + return Err(Status::invalid_argument("body data required")); }; - let (action, reason_code) = match selected { - ResponseMode::WholeBody => ( - http_response_body_result::Action::Transform(HttpResponseBodyTransform { - replacement: Some(http_response_body_transform::Replacement::Data( - [b"[whole] ".as_slice(), &data].concat(), - )), - }), - String::new(), - ), - ResponseMode::Stream | ResponseMode::StreamClose => ( - http_response_body_result::Action::Transform(HttpResponseBodyTransform { - replacement: Some(http_response_body_transform::Replacement::Data( - data.to_ascii_uppercase(), - )), - }), - String::new(), - ), - ResponseMode::Block => ( - http_response_body_result::Action::BlockDelivery(HttpResponseBlockDelivery {}), - "content_match".into(), - ), - ResponseMode::HeadersOnly => unreachable!(), + let text = std::str::from_utf8(&data) + .map_err(|_| Status::invalid_argument("content guard requires a UTF-8 body"))?; + let result = inspect(config, text); + let action = if result.denied { + http_response_body_result::Action::BlockDelivery(HttpResponseBlockDelivery {}) + } else if let Some(replacement) = result.replacement { + http_response_body_result::Action::Transform(HttpResponseBodyTransform { + replacement: Some(http_response_body_transform::Replacement::Data( + replacement.into_bytes(), + )), + }) + } else { + http_response_body_result::Action::PassThrough( + openshell_core::proto::HttpResponseBodyPassThrough {}, + ) }; + self.body_ended = true; Ok(HttpResponseEventResult { result: Some(http_response_event_result::Result::BodyResult( HttpResponseBodyResult { sequence: body.sequence, action: Some(action), - reason_code, - ..Default::default() + reason: result.reason, + reason_code: result.reason_code, + findings: result.findings, + metadata: result.metadata, }, )), }) } - - fn trailers(&self) -> Result { - if !self.body_ended { - return Err(Status::failed_precondition( - "trailers arrived before the final body result", - )); + fn trailers(&mut self) -> Result { + if !self.body_ended || self.trailers_seen { + return Err(Status::failed_precondition("expected trailers after body")); } - let trailer_mutations = if self.selected == Some(ResponseMode::Stream) { - vec![write_header("x-example-body-bytes", "11")] - } else { - Vec::new() - }; + self.trailers_seen = true; Ok(HttpResponseEventResult { result: Some(http_response_event_result::Result::TrailersResult( - HttpResponseTrailersResult { - trailer_mutations, - ..Default::default() - }, + HttpResponseTrailersResult::default(), )), }) } } -fn response_mode_for_path(path: &str) -> Option { - match path { - "/headers-only" => Some(ResponseMode::HeadersOnly), - "/whole-body" => Some(ResponseMode::WholeBody), - "/stream" => Some(ResponseMode::Stream), - "/stream-close" => Some(ResponseMode::StreamClose), - "/block" => Some(ResponseMode::Block), - _ => None, - } -} - -fn write_header(name: &str, value: &str) -> HeaderMutation { - HeaderMutation { - operation: Some(header_mutation::Operation::Write(WriteHeader { - name: name.into(), - value: value.into(), - on_existing: ExistingHeaderAction::Overwrite as i32, - })), - } -} - #[tonic::async_trait] impl HttpResponsePreReturn for ContentGuard { type EvaluateStream = HttpResponseResultStream; @@ -522,11 +445,37 @@ fn validate_phase(phase: i32) -> Result<(), String> { Ok(()) } +#[derive(Default)] +struct GuardOutcome { + denied: bool, + replacement: Option, + reason: String, + reason_code: String, + findings: Vec, + metadata: HashMap, +} fn evaluate(config: &GuardConfig, body: &str) -> HttpRequestResult { + let result = inspect(config, body); + HttpRequestResult { + decision: if result.denied { + Decision::Deny + } else { + Decision::Allow + } as i32, + has_body: result.replacement.is_some(), + body: result.replacement.unwrap_or_default().into_bytes(), + reason: result.reason, + reason_code: result.reason_code, + findings: result.findings, + metadata: result.metadata, + ..Default::default() + } +} +fn inspect(config: &GuardConfig, body: &str) -> GuardOutcome { let (ranges, match_count, matched_term_count) = find_match_ranges(body, &config.terms); if match_count == 0 { - return allow_result(); + return GuardOutcome::default(); } let finding = Finding { @@ -548,27 +497,22 @@ fn evaluate(config: &GuardConfig, body: &str) -> HttpRequestResult { ), ]); - match config.mode { - Mode::Redact => HttpRequestResult { - decision: Decision::Allow as i32, - reason: String::new(), - body: redact_ranges(body, &ranges, &config.replacement).into_bytes(), - has_body: true, - header_mutations: Vec::new(), - findings: vec![finding], - metadata, - reason_code: String::new(), + GuardOutcome { + denied: config.mode == Mode::Deny, + replacement: (config.mode == Mode::Redact) + .then(|| redact_ranges(body, &ranges, &config.replacement)), + reason: if config.mode == Mode::Deny { + "payload matched configured content".into() + } else { + String::new() }, - Mode::Deny => HttpRequestResult { - decision: Decision::Deny as i32, - reason: "payload matched configured content".into(), - body: Vec::new(), - has_body: false, - header_mutations: Vec::new(), - findings: vec![finding], - metadata, - reason_code: "content_match".into(), + reason_code: if config.mode == Mode::Deny { + "content_match".into() + } else { + String::new() }, + findings: vec![finding], + metadata, } } @@ -589,23 +533,21 @@ fn evaluate_websocket_message( "WebSocket text message exceeds {MAX_PAYLOAD_BYTES} bytes" ))); } - let result = evaluate(config, payload); - let replacement = if result.has_body { - Some(web_socket_message_result::Replacement::Text( - String::from_utf8(result.body) - .expect("content guard replacements are constructed from UTF-8 text"), - )) - } else { - None - }; + let result = inspect(config, payload); Ok(WebSocketMessageResult { sequence: message.sequence, - decision: result.decision, - replacement, + decision: if result.denied { + Decision::Deny + } else { + Decision::Allow + } as i32, + replacement: result + .replacement + .map(web_socket_message_result::Replacement::Text), reason: result.reason, + reason_code: result.reason_code, findings: result.findings, metadata: result.metadata, - reason_code: result.reason_code, }) } @@ -684,19 +626,6 @@ fn redact_ranges(body: &str, ranges: &[Range], replacement: &str) -> Stri transformed } -fn allow_result() -> HttpRequestResult { - 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(), - reason_code: String::new(), - } -} - #[tokio::main] async fn main() -> Result<(), Box> { let cli = Cli::parse(); @@ -713,8 +642,8 @@ async fn main() -> Result<(), Box> { mod tests { use super::*; use openshell_core::proto::{ - HttpRequestTarget, HttpResponseBodyUnit, HttpResponsePreflight, MiddlewareSessionEnd, - WebSocketPreflight, WebSocketSessionStart, + HttpResponseBodyUnit, HttpResponsePreflight, MiddlewareSessionEnd, WebSocketPreflight, + WebSocketSessionStart, }; use prost_types::{ListValue, Value}; use std::collections::BTreeMap; @@ -775,116 +704,100 @@ mod tests { ); } - fn response_preflight(path: &str) -> HttpResponsePreflight { + fn response_preflight(mode: &str) -> HttpResponsePreflight { HttpResponsePreflight { - target: Some(HttpRequestTarget { - path: path.into(), - ..Default::default() - }), - config: Some(config("redact", &["prototype-secret"], Some("[FILTERED]"))), + config: Some(config(mode, &["prototype-secret", "秘密"], None)), + permitted_body_modes: vec![HttpResponseBodyMode::WholeBodyBytes as i32], ..Default::default() } } - #[test] - fn response_paths_select_all_modes() { - for (path, expected) in [ - ("/headers-only", HttpResponseBodyMode::HeadersOnly), - ("/whole-body", HttpResponseBodyMode::WholeBodyBytes), - ("/stream", HttpResponseBodyMode::StreamBytes), - ("/stream-close", HttpResponseBodyMode::StreamBytes), - ("/block", HttpResponseBodyMode::WholeBodyBytes), + fn response_guard_passes_redacts_and_denies() { + for (mode, input, expected) in [ + ("redact", "clean", None), + ( + "redact", + "a prototype-secret 秘密", + Some("a [REDACTED] [REDACTED]"), + ), + ("deny", "prototype-secret", None), ] { let mut state = ResponseSessionState::default(); - let result = state.preflight(response_preflight(path)).unwrap(); - let Some(http_response_event_result::Result::PreflightResult(result)) = result.result - else { - panic!("expected preflight result"); - }; - let Some(http_response_preflight_result::Action::Inspect(inspect)) = result.action - else { - panic!("expected inspect action"); - }; - assert_eq!(inspect.body_mode, expected as i32); - } - } - - #[test] - fn response_whole_body_transforms_and_block_is_typed() { - let mut whole = ResponseSessionState::default(); - whole.preflight(response_preflight("/whole-body")).unwrap(); - let result = whole - .body(HttpResponseBodyUnit { - sequence: 1, - payload: Some(http_response_body_unit::Payload::Data(b"body".to_vec())), - end_of_stream: true, - }) - .unwrap(); - let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { - panic!("expected body result"); - }; - let Some(http_response_body_result::Action::Transform(transform)) = body.action else { - panic!("expected body transform"); - }; - let Some(http_response_body_transform::Replacement::Data(data)) = transform.replacement - else { - panic!("expected data replacement"); - }; - assert_eq!(data, b"[whole] body"); - - let mut block = ResponseSessionState::default(); - block.preflight(response_preflight("/block")).unwrap(); - let result = block - .body(HttpResponseBodyUnit { + state.preflight(response_preflight(mode)).unwrap(); + let unit = HttpResponseBodyUnit { sequence: 1, payload: Some(http_response_body_unit::Payload::Data( - b"prototype-secret".to_vec(), + input.as_bytes().to_vec(), )), end_of_stream: true, - }) - .unwrap(); - let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { - panic!("expected body result"); - }; - assert!(matches!( - body.action, - Some(http_response_body_result::Action::BlockDelivery(_)) - )); - assert_eq!(body.reason_code, "content_match"); - } - - #[test] - fn response_stream_returns_the_required_trailer_exchange() { - let mut state = ResponseSessionState::default(); - state.preflight(response_preflight("/stream")).unwrap(); - state - .body(HttpResponseBodyUnit { - sequence: 1, - payload: Some(http_response_body_unit::Payload::Data(b"body".to_vec())), - end_of_stream: true, - }) - .unwrap(); - let result = state.trailers().unwrap(); - let Some(http_response_event_result::Result::TrailersResult(trailers)) = result.result - else { - panic!("expected trailers result"); - }; - assert_eq!(trailers.trailer_mutations.len(), 1); + }; + let result = state.body(unit.clone()).unwrap(); + assert!(state.body(unit).is_err()); + let Some(http_response_event_result::Result::BodyResult(result)) = result.result else { + panic!("body result") + }; + if mode == "deny" { + assert!(matches!( + result.action, + Some(http_response_body_result::Action::BlockDelivery(_)) + )); + assert_eq!(result.reason_code, "content_match"); + } else if let Some(expected) = expected { + let Some(http_response_body_result::Action::Transform(transform)) = result.action + else { + panic!("transform") + }; + assert_eq!( + transform.replacement, + Some(http_response_body_transform::Replacement::Data( + expected.as_bytes().to_vec() + )) + ); + } else { + assert!(matches!( + result.action, + Some(http_response_body_result::Action::PassThrough(_)) + )); + } + let trailers = state.trailers().unwrap(); + let Some(http_response_event_result::Result::TrailersResult(trailers)) = + trailers.result + else { + panic!("trailers") + }; + assert!(trailers.trailer_mutations.is_empty()); + assert!(state.trailers().is_err()); + } } - #[test] - fn response_paths_outside_the_example_are_skipped() { - let mut state = ResponseSessionState::default(); - let result = state.preflight(response_preflight("/outside")).unwrap(); - let Some(http_response_event_result::Result::PreflightResult(result)) = result.result - else { - panic!("expected preflight result"); - }; - assert!(matches!( - result.action, - Some(http_response_preflight_result::Action::Skip(_)) - )); - assert_eq!(result.reason_code, "path_not_selected"); + fn response_guard_rejects_unavailable_inspection_and_invalid_input() { + let mut preflight = response_preflight("redact"); + preflight.permitted_body_modes = vec![HttpResponseBodyMode::HeadersOnly as i32]; + assert!( + ResponseSessionState::default() + .preflight(preflight) + .is_err() + ); + for (sequence, end_of_stream, payload) in [ + (2, true, Some(vec![])), + (1, false, Some(vec![])), + (1, true, Some(vec![0xff])), + (1, true, None), + ] { + let mut state = ResponseSessionState::default(); + assert!(state.trailers().is_err()); + state.preflight(response_preflight("redact")).unwrap(); + assert!(state.preflight(response_preflight("redact")).is_err()); + assert!( + state + .body(HttpResponseBodyUnit { + sequence, + end_of_stream, + payload: payload.map(http_response_body_unit::Payload::Data) + }) + .is_err() + ); + } } #[tokio::test] diff --git a/examples/supervisor-middleware-content-guard/upstream.py b/examples/supervisor-middleware-content-guard/upstream.py index 02897ddcfc..85a85396cf 100644 --- a/examples/supervisor-middleware-content-guard/upstream.py +++ b/examples/supervisor-middleware-content-guard/upstream.py @@ -1,66 +1,25 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -import socketserver +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -class Handler(socketserver.BaseRequestHandler): - def handle(self): - request = b"" - while b"\r\n\r\n" not in request: - block = self.request.recv(4096) - if not block: - return - request += block - path = request.split(b" ", 2)[1] - if path == b"/headers-only": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/plain\r\n" - b"Content-Length: 12\r\n\r\n" - b"headers-only" - ) - elif path == b"/whole-body": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/plain\r\n" - b"Transfer-Encoding: chunked\r\n\r\n" - b"6\r\nwhole \r\n4\r\nbody\r\n0\r\n\r\n" - ) - elif path == b"/stream": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/plain\r\n" - b"Trailer: x-example-body-bytes\r\n" - b"Transfer-Encoding: chunked\r\n\r\n" - b"6\r\nstream\r\n5\r\n body\r\n" - b"0\r\nX-Example-Body-Bytes: 0\r\n\r\n" - ) - elif path == b"/stream-close": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/event-stream\r\n" - b"Connection: close\r\n\r\n" - b"data: stream close\n\n" - ) - elif path == b"/block": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/plain\r\n" - b"Content-Length: 16\r\n\r\n" - b"prototype-secret" - ) - else: - response = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" - self.request.sendall(response) +class Handler(BaseHTTPRequestHandler): + def do_GET(self): + bodies = { + "/clean": b"ordinary public text", + "/sensitive": b"contains prototype-secret and internal-only", + } + body = bodies.get(self.path, b"not found") + self.send_response(200 if self.path in bodies else 404) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) -class DemoServer(socketserver.ThreadingTCPServer): - allow_reuse_address = True - - -with DemoServer(("0.0.0.0", 18081), Handler) as server: - print("response framing demo upstream listening on 0.0.0.0:18081", flush=True) +with ThreadingHTTPServer(("0.0.0.0", 18081), Handler) as server: + print("content guard upstream listening on 0.0.0.0:18081", flush=True) try: server.serve_forever() except KeyboardInterrupt: diff --git a/examples/supervisor-middleware-protocol/Cargo.lock b/examples/supervisor-middleware-protocol/Cargo.lock new file mode 100644 index 0000000000..9472da85be --- /dev/null +++ b/examples/supervisor-middleware-protocol/Cargo.lock @@ -0,0 +1,2247 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + +[[package]] +name = "backtrace-ext" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" +dependencies = [ + "backtrace", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cc" +version = "1.2.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hickory-proto" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d" +dependencies = [ + "data-encoding", + "idna", + "ipnet", + "jni", + "once_cell", + "rand", + "ring", + "thiserror", + "tinyvec", + "tracing", + "url", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "libc", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libyml" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" +dependencies = [ + "anyhow", + "version_check", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "miette" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" +dependencies = [ + "backtrace", + "backtrace-ext", + "cfg-if", + "miette-derive", + "owo-colors", + "supports-color", + "supports-hyperlinks", + "supports-unicode", + "terminal_size", + "textwrap", + "unicode-width 0.1.14", +] + +[[package]] +name = "miette-derive" +version = "7.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "nix" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +dependencies = [ + "critical-section", + "portable-atomic", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openshell-core" +version = "0.0.0" +dependencies = [ + "async-trait", + "base64", + "glob", + "ipnet", + "miette", + "nix", + "openshell-extension-core", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tracing", + "url", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy" +version = "0.0.0" +dependencies = [ + "hickory-proto", + "miette", + "openshell-core", + "prost-types", + "serde", + "serde_json", + "serde_yml", +] + +[[package]] +name = "openshell-supervisor-middleware-protocol" +version = "0.0.0" +dependencies = [ + "clap", + "openshell-core", + "openshell-policy", + "tokio", + "tokio-stream", + "tonic", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "protoc-bin-vendored" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" +dependencies = [ + "protoc-bin-vendored-linux-aarch_64", + "protoc-bin-vendored-linux-ppcle_64", + "protoc-bin-vendored-linux-s390_64", + "protoc-bin-vendored-linux-x86_32", + "protoc-bin-vendored-linux-x86_64", + "protoc-bin-vendored-macos-aarch_64", + "protoc-bin-vendored-macos-x86_64", + "protoc-bin-vendored-win32", +] + +[[package]] +name = "protoc-bin-vendored-linux-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" + +[[package]] +name = "protoc-bin-vendored-linux-ppcle_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" + +[[package]] +name = "protoc-bin-vendored-linux-s390_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" + +[[package]] +name = "protoc-bin-vendored-linux-x86_32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" + +[[package]] +name = "protoc-bin-vendored-linux-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" + +[[package]] +name = "protoc-bin-vendored-macos-aarch_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" + +[[package]] +name = "protoc-bin-vendored-macos-x86_64" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" + +[[package]] +name = "protoc-bin-vendored-win32" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_yml" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +dependencies = [ + "indexmap", + "itoa", + "libyml", + "memchr", + "ryu", + "serde", + "version_check", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + +[[package]] +name = "supports-hyperlinks" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" + +[[package]] +name = "supports-unicode" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "textwrap" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" +dependencies = [ + "unicode-linebreak", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "rustls-native-certs", + "socket2", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-linebreak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/supervisor-middleware-protocol/Cargo.toml b/examples/supervisor-middleware-protocol/Cargo.toml new file mode 100644 index 0000000000..03bea27a78 --- /dev/null +++ b/examples/supervisor-middleware-protocol/Cargo.toml @@ -0,0 +1,27 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] + +[package] +name = "openshell-supervisor-middleware-protocol" +description = "Example OpenShell supervisor middleware service" +version = "0.0.0" +edition = "2024" +rust-version = "1.90" +license = "Apache-2.0" +publish = false + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +openshell-core = { path = "../../crates/openshell-core", default-features = false } +tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "time"] } +tokio-stream = "0.1" +tonic = { version = "0.14", features = ["transport"] } + +[dev-dependencies] +openshell-policy = { path = "../../crates/openshell-policy" } + +[[bin]] +name = "supervisor-middleware-protocol" +path = "src/main.rs" diff --git a/examples/supervisor-middleware-protocol/README.md b/examples/supervisor-middleware-protocol/README.md new file mode 100644 index 0000000000..987b96533b --- /dev/null +++ b/examples/supervisor-middleware-protocol/README.md @@ -0,0 +1,100 @@ + + +# Supervisor middleware protocol example + +This standalone gRPC service demonstrates all current V1 hooks: +`HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and +`WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. Supervisor middleware is a research preview. + +The example selects canned behavior by path. For configured literal matching, +use [content guard](../supervisor-middleware-content-guard/) instead. + +## Run + +Install Cargo, curl, jq, OpenSSL, and uv with Python 3. A local Docker or Podman +runtime must support OpenShell sandboxes. + +```shell +./examples/supervisor-middleware-protocol/smoke.sh --test-suite +``` + +The launcher builds the gateway, supervisor, CLI, and middleware, starts the +local fixture on port 18081, and creates a sandbox with the included policy. +It checks each behavior below and removes its sandbox and processes on exit. +The fixture port must be free. Run the two middleware examples sequentially. + +Run without flags to keep the stack running. Use `PROTOCOL_SMOKE_HOST` to +override the detected non-loopback IPv4 address and `PROTOCOL_SMOKE_DRIVER` +to select `docker` or `podman`. Both the gateway and sandbox must reach the +service endpoint. `--print-config` prints the generated gateway registration. + +For manual startup: + +```shell +cargo run --manifest-path examples/supervisor-middleware-protocol/Cargo.toml -- --bind 0.0.0.0:50051 +uv run --no-project python examples/supervisor-middleware-protocol/upstream.py +``` + +Register the service before starting the gateway: + +```toml +[[openshell.supervisor.middleware]] +name = "protocol-example" +grpc_endpoint = "http://host.openshell.internal:50051" +allow_insecure_transport = true +max_payload_bytes = 262144 +timeout = "500ms" +``` + +The endpoint uses plaintext without peer authentication for local development. +Adjust its hostname to an address reachable from the gateway and sandbox. +The service takes empty configuration and advertises a 256 KiB payload limit. + +## Behaviors + +All routes use `http://host.openshell.internal:18081`. + +| Route | Hook and behavior | +| --- | --- | +| `POST /request` | Request hook uppercases ASCII bytes; the fixture echoes the changed body. | +| `GET /headers-only` | Response hook adds `x-example-response-mode: headers-only` and preserves content-length framing. | +| `GET /whole-body` | Selects `WHOLE_BODY_BYTES` and prefixes the normalized chunked body with `[whole]`. | +| `GET /stream` | Selects `STREAM_BYTES`, uppercases each unit, and overwrites the supplied `x-example-body-bytes` trailer with `11`. | +| `GET /stream-close` | Uppercases a close-delimited event-stream response. | +| `GET /block` | Blocks the complete body before commitment with typed `BlockDelivery` and reason code `content_match`. | +| `GET /ws` upgrade | WebSocket hook uppercases each complete client text message; the fixture echoes it. | + +The smoke suite checks the canonical 403 for `/block`. Other response paths +return `Skip`, including the request echo path. Request bodies outside +`/request` pass unchanged. + +Stream transformations act only on the current unit. Unit boundaries have no +application meaning, so this example neither matches cross-unit terms nor +retains bytes for a future result. Header-only inspection preserves transport +framing. Body modes receive normalized bytes and finish with a trailer exchange, +including an empty trailer set. Selecting an unavailable mode returns a +middleware failure, handled by the policy's `fail_closed` setting. + +WebSocket preflight chooses inspection, session start/end are notifications, +and each message result echoes its sequence number. Only client text messages +are inspected. Binary, control, and upstream messages are outside this hook. +The fixture and client perform one text exchange; they are not general WebSocket +implementations. + +## Source and tests + +`src/request.rs`, `src/response.rs`, and `src/websocket.rs` own their hook +behavior. `src/main.rs` owns startup, the manifest, and empty configuration +validation. The policy selects this service by the operator registration +`protocol-example`. + +```shell +cargo test --manifest-path examples/supervisor-middleware-protocol/Cargo.toml +bash -n examples/supervisor-middleware-protocol/smoke.sh +``` + +Malformed framing, HTTP/1.0 edge cases, timeouts, disconnects, and security +boundary tests belong in the runtime crates. diff --git a/examples/supervisor-middleware-protocol/policy.yaml b/examples/supervisor-middleware-protocol/policy.yaml new file mode 100644 index 0000000000..cfe5e381a3 --- /dev/null +++ b/examples/supervisor-middleware-protocol/policy.yaml @@ -0,0 +1,49 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +network_middlewares: + prototype-protocol: + name: Protocol demonstration + middleware: protocol-example + order: 10 + config: {} + on_error: fail_closed + endpoints: + include: + - host.openshell.internal + +network_policies: + response-framing-demo: + name: Response framing demo + endpoints: + - host: host.openshell.internal + port: 18081 + protocol: rest + rules: + - allow: + method: POST + path: /request + - allow: + method: GET + path: /ws + - allow: + method: GET + path: /headers-only + - allow: + method: GET + path: /whole-body + - allow: + method: GET + path: /stream + - allow: + method: GET + path: /stream-close + - allow: + method: GET + path: /block + binaries: + - path: /usr/bin/curl + - path: /usr/bin/python3 + - path: /usr/local/bin/python3 diff --git a/examples/supervisor-middleware-protocol/smoke.sh b/examples/supervisor-middleware-protocol/smoke.sh new file mode 100755 index 0000000000..c41bb8469f --- /dev/null +++ b/examples/supervisor-middleware-protocol/smoke.sh @@ -0,0 +1,536 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +EXAMPLE_DIR="$ROOT/examples/supervisor-middleware-protocol" +RUN_TEST_SUITE=0 +PRINT_CONFIG=0 + +usage() { + cat <&2 + usage >&2 + exit 2 + ;; + esac +done + +detect_service_host() { + local interface address + + if [[ -n "${PROTOCOL_SMOKE_HOST:-}" ]]; then + printf '%s\n' "$PROTOCOL_SMOKE_HOST" + return + fi + + if [[ "$(uname -s)" == "Darwin" ]] && command -v route >/dev/null 2>&1 && command -v ipconfig >/dev/null 2>&1; then + interface="$(route -n get default 2>/dev/null | awk '/interface:/ { print $2; exit }')" + if [[ -n "$interface" ]]; then + address="$(ipconfig getifaddr "$interface" 2>/dev/null || true)" + if [[ -n "$address" ]]; then + printf '%s\n' "$address" + return + fi + fi + + if command -v ifconfig >/dev/null 2>&1; then + for interface in $(ifconfig -l 2>/dev/null); do + if [[ "$interface" != en* ]]; then + continue + fi + address="$(ipconfig getifaddr "$interface" 2>/dev/null || true)" + if [[ -n "$address" ]]; then + printf '%s\n' "$address" + return + fi + done + fi + fi + + if command -v ip >/dev/null 2>&1; then + address="$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "src") { print $(i + 1); exit } }')" + if [[ -n "$address" ]]; then + printf '%s\n' "$address" + return + fi + fi + + if command -v hostname >/dev/null 2>&1; then + address="$(hostname -I 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i !~ /^127\./ && $i !~ /:/) { print $i; exit } }')" + if [[ -n "$address" ]]; then + printf '%s\n' "$address" + return + fi + fi + + echo "could not detect a non-loopback host address" >&2 + echo "set PROTOCOL_SMOKE_HOST to an address reachable from sandbox containers" >&2 + exit 1 +} + +SERVICE_HOST="$(detect_service_host)" +COMPUTE_DRIVER="${PROTOCOL_SMOKE_DRIVER:-}" +if [[ "$SERVICE_HOST" == "localhost" || "$SERVICE_HOST" == "::1" || "$SERVICE_HOST" == 127.* || "$SERVICE_HOST" == *:* ]]; then + echo "PROTOCOL_SMOKE_HOST must be a non-loopback IPv4 address: $SERVICE_HOST" >&2 + exit 1 +fi + +SMOKE_TMP_DIR="$(mktemp -d)" +LOG_DIR="$SMOKE_TMP_DIR/logs" +JWT_DIR="$SMOKE_TMP_DIR/jwt" +GATEWAY_CONFIG="$SMOKE_TMP_DIR/gateway.toml" +SETUP_LOG="$LOG_DIR/setup.log" +GATEWAY_LOG="$LOG_DIR/gateway.log" +MIDDLEWARE_LOG="$LOG_DIR/middleware.log" +UPSTREAM_LOG="$LOG_DIR/upstream.log" +SANDBOX_LOG="$LOG_DIR/sandbox.log" +RUN_ID="protocol-smoke-$$-$RANDOM" +# Sandbox names are capped at 19 characters. Use a short prefix with +# the PID for uniqueness; keep the full RUN_ID for gateway identity. +SANDBOX_NAME="cg-$$-$RANDOM" +SANDBOX_CREATED=0 + +mkdir -p "$LOG_DIR" + +cleanup() { + local status=$? + trap - EXIT + + if [[ "$SANDBOX_CREATED" -eq 1 && -n "${CLI+x}" ]]; then + "${CLI[@]}" sandbox delete "$SANDBOX_NAME" >>"$SETUP_LOG" 2>&1 || true + fi + + if [[ -n "${GATEWAY_PID:-}" ]]; then + kill "$GATEWAY_PID" 2>/dev/null || true + wait "$GATEWAY_PID" 2>/dev/null || true + fi + + if [[ -n "${MIDDLEWARE_PID:-}" ]]; then + kill "$MIDDLEWARE_PID" 2>/dev/null || true + wait "$MIDDLEWARE_PID" 2>/dev/null || true + fi + + if [[ -n "${UPSTREAM_PID:-}" ]]; then + kill "$UPSTREAM_PID" 2>/dev/null || true + wait "$UPSTREAM_PID" 2>/dev/null || true + fi + + if [[ "$status" -eq 0 ]]; then + rm -rf "$SMOKE_TMP_DIR" + else + echo "logs retained in $LOG_DIR" >&2 + fi + + exit "$status" +} +trap cleanup EXIT + +port_is_free() { + local port="$1" + + if command -v lsof >/dev/null 2>&1; then + ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 + return + fi + + if command -v nc >/dev/null 2>&1; then + ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1 + return + fi + + return 0 +} + +choose_port_block() { + local count="$1" + local start offset ok + + for _ in {1..200}; do + start=$((20000 + RANDOM % 20000)) + ok=1 + for ((offset = 0; offset < count; offset++)); do + if ! port_is_free "$((start + offset))"; then + ok=0 + break + fi + done + if [[ "$ok" == "1" ]]; then + printf '%s\n' "$start" + return + fi + done + + echo "failed to find free local ports for protocol demo launcher" >&2 + exit 1 +} + +PORT_BASE="$(choose_port_block 3)" +MIDDLEWARE_PORT="$PORT_BASE" +GATEWAY_PORT="$((PORT_BASE + 1))" +HEALTH_PORT="$((PORT_BASE + 2))" +GATEWAY_ENDPOINT="http://127.0.0.1:$GATEWAY_PORT" + +write_gateway_config() { + cat >"$GATEWAY_CONFIG" </dev/null 2>&1; then + echo "openssl is required to generate local smoke-test gateway JWT keys" >&2 + exit 1 + fi + + mkdir -p "$JWT_DIR" + openssl genpkey -algorithm ed25519 -out "$JWT_DIR/signing.pem" >/dev/null 2>&1 + openssl pkey -in "$JWT_DIR/signing.pem" -pubout -out "$JWT_DIR/public.pem" >/dev/null 2>&1 + printf '%s\n' "$RUN_ID" >"$JWT_DIR/kid" +} + +dump_logs() { + local label path + for label in setup gateway middleware upstream sandbox; do + case "$label" in + setup) path="$SETUP_LOG" ;; + gateway) path="$GATEWAY_LOG" ;; + middleware) path="$MIDDLEWARE_LOG" ;; + upstream) path="$UPSTREAM_LOG" ;; + sandbox) path="$SANDBOX_LOG" ;; + esac + printf '\n--- %s log: %s ---\n' "$label" "$path" >&2 + if [[ -f "$path" ]]; then + cat "$path" >&2 + else + printf '(missing)\n' >&2 + fi + done +} + +capture_sandbox_log() { + local container_id + + if [[ "$SANDBOX_CREATED" -ne 1 || "$COMPUTE_DRIVER" != "docker" ]] || + ! command -v docker >/dev/null 2>&1; then + return + fi + + container_id="$(docker ps -aq --filter "name=$SANDBOX_NAME" | head -n 1)" + if [[ -n "$container_id" ]]; then + docker logs "$container_id" >"$SANDBOX_LOG" 2>&1 || true + fi +} + +fail() { + printf 'FAIL %s\n' "$1" >&2 + capture_sandbox_log + dump_logs + exit 1 +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" +} + +run_setup_step() { + local label="$1" + shift + printf 'INFO %s\n' "$label" + printf '\n== %s ==\n+' "$label" >>"$SETUP_LOG" + printf ' %q' "$@" >>"$SETUP_LOG" + printf '\n' >>"$SETUP_LOG" + if ! "$@" >>"$SETUP_LOG" 2>&1; then + fail "$label" + fi +} + +cargo_target_dir() { + local manifest_path="$1" + + cargo metadata \ + --format-version=1 \ + --no-deps \ + --manifest-path "$manifest_path" \ + | jq -er '.target_directory' +} + +start_middleware() { + printf 'INFO starting protocol demo service at %s:%s\n' "$SERVICE_HOST" "$MIDDLEWARE_PORT" + "$MIDDLEWARE_BIN" \ + --bind "0.0.0.0:$MIDDLEWARE_PORT" >"$MIDDLEWARE_LOG" 2>&1 & + MIDDLEWARE_PID=$! +} + +middleware_port_is_ready() { + if command -v nc >/dev/null 2>&1; then + nc -z "$SERVICE_HOST" "$MIDDLEWARE_PORT" >/dev/null 2>&1 + return + fi + + (exec 3<>"/dev/tcp/$SERVICE_HOST/$MIDDLEWARE_PORT") 2>/dev/null +} + +wait_for_middleware() { + for _ in {1..60}; do + if ! kill -0 "$MIDDLEWARE_PID" 2>/dev/null; then + fail "protocol demo service starts" + fi + if middleware_port_is_ready; then + printf 'INFO protocol demo service is ready\n' + return + fi + sleep 1 + done + fail "protocol demo service is reachable at $SERVICE_HOST:$MIDDLEWARE_PORT" +} + +start_upstream() { + printf 'INFO starting response framing upstream at %s:18081\n' "$SERVICE_HOST" + uv run --no-project python "$EXAMPLE_DIR/upstream.py" >"$UPSTREAM_LOG" 2>&1 & + UPSTREAM_PID=$! +} + +wait_for_upstream() { + for _ in {1..30}; do + if ! kill -0 "$UPSTREAM_PID" 2>/dev/null; then + fail "response framing upstream starts" + fi + if curl -fsS --max-time 1 "http://127.0.0.1:18081/headers-only" >/dev/null 2>&1; then + printf 'INFO response framing upstream is ready\n' + return + fi + sleep 1 + done + fail "response framing upstream is reachable" +} + +start_gateway() { + local -a driver_args=() + if [[ -n "$COMPUTE_DRIVER" ]]; then + driver_args=(--drivers "$COMPUTE_DRIVER") + fi + printf 'INFO starting gateway\n' + env -u OPENSHELL_DRIVERS "$GATEWAY_BIN" \ + "${driver_args[@]}" \ + --config "$GATEWAY_CONFIG" \ + --bind-address 127.0.0.1 \ + --port "$GATEWAY_PORT" \ + --health-port "$HEALTH_PORT" \ + --metrics-port 0 \ + --log-level "${PROTOCOL_SMOKE_LOG_LEVEL:-info}" \ + --disable-tls \ + --db-url "sqlite://$SMOKE_TMP_DIR/gateway.db" >"$GATEWAY_LOG" 2>&1 & + GATEWAY_PID=$! +} + +wait_for_gateway() { + for _ in {1..60}; do + if ! kill -0 "$MIDDLEWARE_PID" 2>/dev/null; then + fail "protocol demo service starts" + fi + if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then + fail "gateway starts with protocol demo" + fi + if curl -fsS "http://127.0.0.1:$HEALTH_PORT/healthz" >/dev/null 2>&1; then + printf 'INFO gateway starts with protocol demo\n' + return + fi + sleep 1 + done + fail "gateway starts with protocol demo" +} + +create_sandbox() { + CLI=( + env + -u OPENSHELL_SANDBOX_POLICY + "$CLI_BIN" + --gateway-endpoint "$GATEWAY_ENDPOINT" + ) + SANDBOX_CREATED=1 + run_setup_step \ + "creating protocol demo sandbox" \ + "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" --no-tty --detach -- sleep infinity +} + +response_request() { + local path="$1" + "${CLI[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- \ + curl -sS -i --max-time 20 "http://host.openshell.internal:18081/$path" +} + +run_suite() { + local response_output="$LOG_DIR/response.out" + if ! "${CLI[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- \ + curl -fsS --max-time 20 http://host.openshell.internal:18081/request --data 'request body' >"$response_output" 2>>"$SETUP_LOG" || + [[ "$(cat "$response_output")" != "REQUEST BODY" ]]; then + fail "request body replacement" + fi + printf 'PASS request body replacement\n' + if ! "${CLI[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- \ + python3 -c "$(cat "$EXAMPLE_DIR/websocket-client.py")" >"$response_output" 2>>"$SETUP_LOG" || + [[ "$(cat "$response_output")" != "HELLO PROTOCOL" ]]; then + fail "WebSocket message replacement" + fi + printf 'PASS WebSocket message replacement\n' + + printf 'INFO exercising HTTP response middleware modes\n' + if ! response_request headers-only >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fiq 'x-example-response-mode: headers-only' "$response_output" || + ! grep -Fiq 'content-length: 12' "$response_output" || + ! grep -Fq 'headers-only' "$response_output"; then + fail "headers-only response middleware" + fi + if ! response_request whole-body >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq '[whole] whole body' "$response_output"; then + fail "whole-body response middleware" + fi + if ! response_request stream >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'STREAM BODY' "$response_output" || + ! grep -Fiq 'x-example-body-bytes: 11' "$response_output"; then + fail "stream response middleware with trailer mutation" + fi + if ! response_request stream-close >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'DATA: STREAM CLOSE' "$response_output"; then + fail "close-delimited SSE response middleware" + fi + if ! response_request block >"$response_output" 2>>"$SETUP_LOG" || + ! grep -Fq 'HTTP/1.1 403 Forbidden' "$response_output" || + ! grep -Fq 'middleware_denied' "$response_output" || + ! grep -Fq 'content_match' "$response_output"; then + fail "response middleware block" + fi + printf 'PASS HTTP response middleware modes\n' + "${CLI[@]}" sandbox delete "$SANDBOX_NAME" >>"$SETUP_LOG" 2>&1 + SANDBOX_CREATED=0 + echo "ALL PASS protocol demo smoke" +} + +print_ready() { + cat </dev/null; then + fail "gateway process exited" + fi + if ! kill -0 "$MIDDLEWARE_PID" 2>/dev/null; then + fail "protocol demo process exited" + fi + sleep 1 + done +} + +cd "$ROOT" +require_command cargo +require_command curl +require_command jq +require_command openssl +require_command uv +ROOT_TARGET_DIR="$(cargo_target_dir "$ROOT/Cargo.toml")" +EXAMPLE_TARGET_DIR="$(cargo_target_dir "$EXAMPLE_DIR/Cargo.toml")" +GATEWAY_BIN="$ROOT_TARGET_DIR/debug/openshell-gateway" +CLI_BIN="$ROOT_TARGET_DIR/debug/openshell" +MIDDLEWARE_BIN="$EXAMPLE_TARGET_DIR/debug/supervisor-middleware-protocol" +run_setup_step "building gateway" cargo build --quiet -p openshell-gateway --bin openshell-gateway +run_setup_step "building sandbox supervisor" cargo build --quiet -p openshell-sandbox --bin openshell-sandbox +run_setup_step "building protocol demo" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" +run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell +generate_gateway_jwt_bundle +start_upstream +wait_for_upstream +start_middleware +wait_for_middleware +start_gateway +wait_for_gateway +create_sandbox + +if [[ "$RUN_TEST_SUITE" -eq 1 ]]; then + run_suite +else + print_ready + wait_until_stopped +fi diff --git a/examples/supervisor-middleware-protocol/src/main.rs b/examples/supervisor-middleware-protocol/src/main.rs new file mode 100644 index 0000000000..d1c153eaa2 --- /dev/null +++ b/examples/supervisor-middleware-protocol/src/main.rs @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::net::SocketAddr; + +use clap::Parser; +use openshell_core::middleware::{HttpResponseResultStream, WebSocketResponseStream}; +use openshell_core::proto::middleware::v1::http_response_pre_return_server::{ + HttpResponsePreReturn, HttpResponsePreReturnServer, +}; +use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ + SupervisorMiddleware, SupervisorMiddlewareServer, +}; +use openshell_core::proto::{ + Decision, ExistingHeaderAction, HeaderMutation, HttpRequestEvaluation, HttpRequestResult, + HttpResponseBlockDelivery, HttpResponseBodyMode, HttpResponseBodyResult, + HttpResponseBodyTransform, HttpResponseEvent, HttpResponseEventResult, + HttpResponsePreflightInspect, HttpResponsePreflightResult, HttpResponsePreflightSkip, + HttpResponseTrailersResult, MiddlewareBinding, MiddlewareManifest, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, + ValidateConfigResponse, WebSocketMessage, WebSocketMessageResult, WebSocketPreflightAction, + WebSocketPreflightDecision, WebSocketSessionEvent, WebSocketSessionEventResult, WriteHeader, + header_mutation, http_response_body_result, http_response_body_transform, + http_response_body_unit, http_response_event, http_response_event_result, + http_response_preflight_result, web_socket_message, web_socket_message_result, + web_socket_session_event, web_socket_session_event_result, +}; +use tokio::sync::mpsc; +use tokio_stream::wrappers::ReceiverStream; +use tokio_stream::{Stream, StreamExt}; +use tonic::transport::Server; +use tonic::{Request, Response, Status}; + +const MANIFEST_NAME: &str = "example/protocol-service"; +const PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials; +const MAX_PAYLOAD_BYTES: u64 = 256 * 1024; +mod request; +mod response; +mod websocket; + +#[derive(Debug, Parser)] +#[command(about = "Run the example OpenShell supervisor middleware service")] +struct Cli { + /// Address on which to serve plaintext gRPC. + #[arg(long, default_value = "127.0.0.1:50051")] + bind: SocketAddr, +} + +#[derive(Debug, Default)] +struct ProtocolDemo; + +#[tonic::async_trait] +impl SupervisorMiddleware for ProtocolDemo { + type EvaluateWebSocketSessionStream = WebSocketResponseStream; + + async fn describe( + &self, + _request: Request<()>, + ) -> Result, Status> { + Ok(Response::new(MiddlewareManifest { + name: MANIFEST_NAME.into(), + service_version: env!("CARGO_PKG_VERSION").into(), + bindings: vec![ + MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: PHASE as i32, + max_payload_bytes: MAX_PAYLOAD_BYTES, + timeout: String::new(), + }, + MiddlewareBinding { + operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, + phase: PHASE as i32, + max_payload_bytes: MAX_PAYLOAD_BYTES, + timeout: String::new(), + }, + MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpResponse as i32, + phase: SupervisorMiddlewarePhase::PreReturn as i32, + max_payload_bytes: MAX_PAYLOAD_BYTES, + timeout: String::new(), + }, + ], + expected_audience: String::new(), + })) + } + + async fn validate_config( + &self, + request: Request, + ) -> Result, Status> { + let valid = request + .get_ref() + .config + .as_ref() + .is_none_or(|config| config.fields.is_empty()); + Ok(Response::new(ValidateConfigResponse { + valid, + reason: if valid { + String::new() + } else { + "protocol demo takes no configuration".into() + }, + })) + } + + async fn evaluate_http_request( + &self, + request: Request, + ) -> Result, Status> { + request::evaluate(request.into_inner()).map(Response::new) + } + + async fn evaluate_web_socket_session( + &self, + request: Request>, + ) -> Result, Status> { + Ok(Response::new(websocket::stream(request.into_inner()))) + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = Cli::parse(); + println!("serving {MANIFEST_NAME} on http://{}", cli.bind); + Server::builder() + .add_service(SupervisorMiddlewareServer::new(ProtocolDemo)) + .add_service(HttpResponsePreReturnServer::new(ProtocolDemo)) + .serve(cli.bind) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn example_policy_is_valid() { + let policy = openshell_policy::parse_sandbox_policy(include_str!("../policy.yaml")) + .expect("example policy parses"); + openshell_policy::validate_sandbox_policy(&policy).expect("example policy is valid"); + } + + #[tokio::test] + async fn manifest_advertises_all_three_v1_hooks() { + let manifest = SupervisorMiddleware::describe(&ProtocolDemo, Request::new(())) + .await + .unwrap() + .into_inner(); + let bindings: Vec<_> = manifest + .bindings + .iter() + .map(|binding| (binding.operation, binding.phase)) + .collect(); + assert_eq!( + bindings, + vec![ + ( + SupervisorMiddlewareOperation::HttpRequest as i32, + PHASE as i32 + ), + ( + SupervisorMiddlewareOperation::WebsocketMessage as i32, + PHASE as i32 + ), + ( + SupervisorMiddlewareOperation::HttpResponse as i32, + SupervisorMiddlewarePhase::PreReturn as i32 + ), + ] + ); + } +} diff --git a/examples/supervisor-middleware-protocol/src/request.rs b/examples/supervisor-middleware-protocol/src/request.rs new file mode 100644 index 0000000000..0ee19b3945 --- /dev/null +++ b/examples/supervisor-middleware-protocol/src/request.rs @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +pub(crate) fn evaluate(request: HttpRequestEvaluation) -> Result { + if request.phase != PHASE as i32 { + return Err(Status::invalid_argument("expected PRE_CREDENTIALS")); + } + // Only this route demonstrates request replacement. Response routes pass through. + let selected = request + .target + .as_ref() + .is_some_and(|target| target.path == "/request"); + Ok(HttpRequestResult { + decision: Decision::Allow as i32, + body: if selected { + request.body.to_ascii_uppercase() + } else { + Vec::new() + }, + has_body: selected, + ..Default::default() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn replaces_only_the_selected_request() { + for path in ["/request", "/whole-body"] { + let result = evaluate(HttpRequestEvaluation { + phase: PHASE as i32, + target: Some(openshell_core::proto::HttpRequestTarget { + path: path.into(), + ..Default::default() + }), + body: b"request body".to_vec(), + ..Default::default() + }) + .unwrap(); + assert_eq!(result.has_body, path == "/request"); + if result.has_body { + assert_eq!(result.body, b"REQUEST BODY"); + } + } + } +} diff --git a/examples/supervisor-middleware-protocol/src/response.rs b/examples/supervisor-middleware-protocol/src/response.rs new file mode 100644 index 0000000000..e4773f22ba --- /dev/null +++ b/examples/supervisor-middleware-protocol/src/response.rs @@ -0,0 +1,345 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum ResponseMode { + HeadersOnly, + WholeBody, + Stream, + StreamClose, + Block, +} + +#[derive(Debug, Default)] +struct ResponseSessionState { + selected: Option, + next_sequence: u64, + body_ended: bool, +} + +impl ResponseSessionState { + fn preflight( + &mut self, + preflight: openshell_core::proto::HttpResponsePreflight, + ) -> Result { + if self.selected.is_some() { + return Err(Status::failed_precondition("duplicate response preflight")); + } + let path = preflight + .target + .as_ref() + .map(|target| target.path.as_str()) + .unwrap_or_default(); + let Some(selected) = response_mode_for_path(path) else { + return Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some(http_response_preflight_result::Action::Skip( + HttpResponsePreflightSkip {}, + )), + reason_code: "path_not_selected".into(), + ..Default::default() + }, + )), + }); + }; + self.selected = Some(selected); + self.next_sequence = 1; + let body_mode = match selected { + ResponseMode::HeadersOnly => HttpResponseBodyMode::HeadersOnly, + ResponseMode::WholeBody | ResponseMode::Block => HttpResponseBodyMode::WholeBodyBytes, + ResponseMode::Stream | ResponseMode::StreamClose => HttpResponseBodyMode::StreamBytes, + }; + if !preflight.permitted_body_modes.contains(&(body_mode as i32)) { + return Err(Status::failed_precondition( + "selected demo body mode is unavailable", + )); + } + Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some(http_response_preflight_result::Action::Inspect( + HttpResponsePreflightInspect { + body_mode: body_mode as i32, + header_mutations: vec![write_header( + "x-example-response-mode", + match selected { + ResponseMode::HeadersOnly => "headers-only", + ResponseMode::WholeBody => "whole-body", + ResponseMode::Stream => "stream", + ResponseMode::StreamClose => "stream-close", + ResponseMode::Block => "block", + }, + )], + }, + )), + ..Default::default() + }, + )), + }) + } + + fn body( + &mut self, + body: openshell_core::proto::HttpResponseBodyUnit, + ) -> Result { + let selected = self + .selected + .ok_or_else(|| Status::failed_precondition("body arrived before preflight"))?; + if selected == ResponseMode::HeadersOnly || self.body_ended { + return Err(Status::failed_precondition( + "body event is invalid for the response session state", + )); + } + if body.sequence != self.next_sequence { + return Err(Status::invalid_argument( + "unexpected response body sequence", + )); + } + self.next_sequence = self.next_sequence.saturating_add(1); + self.body_ended = body.end_of_stream; + let Some(http_response_body_unit::Payload::Data(data)) = body.payload else { + return Err(Status::invalid_argument("body data is required")); + }; + let (action, reason_code) = match selected { + ResponseMode::WholeBody => ( + http_response_body_result::Action::Transform(HttpResponseBodyTransform { + replacement: Some(http_response_body_transform::Replacement::Data( + [b"[whole] ".as_slice(), &data].concat(), + )), + }), + String::new(), + ), + ResponseMode::Stream | ResponseMode::StreamClose => ( + http_response_body_result::Action::Transform(HttpResponseBodyTransform { + replacement: Some(http_response_body_transform::Replacement::Data( + data.to_ascii_uppercase(), + )), + }), + String::new(), + ), + ResponseMode::Block => ( + http_response_body_result::Action::BlockDelivery(HttpResponseBlockDelivery {}), + "content_match".into(), + ), + ResponseMode::HeadersOnly => unreachable!(), + }; + Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::BodyResult( + HttpResponseBodyResult { + sequence: body.sequence, + action: Some(action), + reason_code, + ..Default::default() + }, + )), + }) + } + + fn trailers(&self) -> Result { + if !self.body_ended { + return Err(Status::failed_precondition( + "trailers arrived before the final body result", + )); + } + let trailer_mutations = if self.selected == Some(ResponseMode::Stream) { + vec![write_header("x-example-body-bytes", "11")] + } else { + Vec::new() + }; + Ok(HttpResponseEventResult { + result: Some(http_response_event_result::Result::TrailersResult( + HttpResponseTrailersResult { + trailer_mutations, + ..Default::default() + }, + )), + }) + } +} + +fn response_mode_for_path(path: &str) -> Option { + match path { + "/headers-only" => Some(ResponseMode::HeadersOnly), + "/whole-body" => Some(ResponseMode::WholeBody), + "/stream" => Some(ResponseMode::Stream), + "/stream-close" => Some(ResponseMode::StreamClose), + "/block" => Some(ResponseMode::Block), + _ => None, + } +} + +fn write_header(name: &str, value: &str) -> HeaderMutation { + HeaderMutation { + operation: Some(header_mutation::Operation::Write(WriteHeader { + name: name.into(), + value: value.into(), + on_existing: ExistingHeaderAction::Overwrite as i32, + })), + } +} + +#[tonic::async_trait] +impl HttpResponsePreReturn for ProtocolDemo { + type EvaluateStream = HttpResponseResultStream; + + async fn evaluate( + &self, + request: Request>, + ) -> Result, Status> { + let mut events = request.into_inner(); + let (sender, receiver) = mpsc::channel(4); + tokio::spawn(async move { + let mut state = ResponseSessionState::default(); + while let Some(event) = events.next().await { + let result = match event { + Ok(event) => match event.event { + Some(http_response_event::Event::Preflight(preflight)) => { + state.preflight(preflight) + } + Some(http_response_event::Event::Body(body)) => state.body(body), + Some(http_response_event::Event::Trailers(_)) => state.trailers(), + Some(http_response_event::Event::SessionEnd(_)) => break, + None => Err(Status::invalid_argument("response event is required")), + }, + Err(error) => Err(error), + }; + match result { + Ok(result) => { + if sender.send(Ok(result)).await.is_err() { + break; + } + } + Err(error) => { + let _ = sender.send(Err(error)).await; + break; + } + } + } + }); + Ok(Response::new(Box::pin(ReceiverStream::new(receiver)))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{HttpRequestTarget, HttpResponseBodyUnit, HttpResponsePreflight}; + fn response_preflight(path: &str) -> HttpResponsePreflight { + HttpResponsePreflight { + target: Some(HttpRequestTarget { + path: path.into(), + ..Default::default() + }), + permitted_body_modes: vec![ + HttpResponseBodyMode::HeadersOnly as i32, + HttpResponseBodyMode::WholeBodyBytes as i32, + HttpResponseBodyMode::StreamBytes as i32, + ], + ..Default::default() + } + } + + #[test] + fn response_paths_select_all_modes() { + for (path, expected) in [ + ("/headers-only", HttpResponseBodyMode::HeadersOnly), + ("/whole-body", HttpResponseBodyMode::WholeBodyBytes), + ("/stream", HttpResponseBodyMode::StreamBytes), + ("/stream-close", HttpResponseBodyMode::StreamBytes), + ("/block", HttpResponseBodyMode::WholeBodyBytes), + ] { + let mut state = ResponseSessionState::default(); + let result = state.preflight(response_preflight(path)).unwrap(); + let Some(http_response_event_result::Result::PreflightResult(result)) = result.result + else { + panic!("expected preflight result"); + }; + let Some(http_response_preflight_result::Action::Inspect(inspect)) = result.action + else { + panic!("expected inspect action"); + }; + assert_eq!(inspect.body_mode, expected as i32); + } + } + + #[test] + fn response_whole_body_transforms_and_block_is_typed() { + let mut whole = ResponseSessionState::default(); + whole.preflight(response_preflight("/whole-body")).unwrap(); + let result = whole + .body(HttpResponseBodyUnit { + sequence: 1, + payload: Some(http_response_body_unit::Payload::Data(b"body".to_vec())), + end_of_stream: true, + }) + .unwrap(); + let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { + panic!("expected body result"); + }; + let Some(http_response_body_result::Action::Transform(transform)) = body.action else { + panic!("expected body transform"); + }; + let Some(http_response_body_transform::Replacement::Data(data)) = transform.replacement + else { + panic!("expected data replacement"); + }; + assert_eq!(data, b"[whole] body"); + + let mut block = ResponseSessionState::default(); + block.preflight(response_preflight("/block")).unwrap(); + let result = block + .body(HttpResponseBodyUnit { + sequence: 1, + payload: Some(http_response_body_unit::Payload::Data( + b"prototype-secret".to_vec(), + )), + end_of_stream: true, + }) + .unwrap(); + let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { + panic!("expected body result"); + }; + assert!(matches!( + body.action, + Some(http_response_body_result::Action::BlockDelivery(_)) + )); + assert_eq!(body.reason_code, "content_match"); + } + + #[test] + fn response_stream_returns_the_required_trailer_exchange() { + let mut state = ResponseSessionState::default(); + state.preflight(response_preflight("/stream")).unwrap(); + state + .body(HttpResponseBodyUnit { + sequence: 1, + payload: Some(http_response_body_unit::Payload::Data(b"body".to_vec())), + end_of_stream: true, + }) + .unwrap(); + let result = state.trailers().unwrap(); + let Some(http_response_event_result::Result::TrailersResult(trailers)) = result.result + else { + panic!("expected trailers result"); + }; + assert_eq!(trailers.trailer_mutations.len(), 1); + } + + #[test] + fn response_paths_outside_the_example_are_skipped() { + let mut state = ResponseSessionState::default(); + let result = state.preflight(response_preflight("/outside")).unwrap(); + let Some(http_response_event_result::Result::PreflightResult(result)) = result.result + else { + panic!("expected preflight result"); + }; + assert!(matches!( + result.action, + Some(http_response_preflight_result::Action::Skip(_)) + )); + assert_eq!(result.reason_code, "path_not_selected"); + } +} diff --git a/examples/supervisor-middleware-protocol/src/websocket.rs b/examples/supervisor-middleware-protocol/src/websocket.rs new file mode 100644 index 0000000000..6ce5af1718 --- /dev/null +++ b/examples/supervisor-middleware-protocol/src/websocket.rs @@ -0,0 +1,163 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use super::*; + +pub(crate) fn stream(mut events: S) -> WebSocketResponseStream +where + S: Stream> + Send + Unpin + 'static, +{ + let (results_tx, results_rx) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + let mut config = None; + let mut started = false; + let mut sequence_lower_bound = Some(1_u64); + + while let Some(event) = events.next().await { + let event = match event { + Ok(event) => event, + Err(error) => { + let _ = results_tx.send(Err(error)).await; + break; + } + }; + let result = match event.event { + Some(web_socket_session_event::Event::Preflight(preflight)) + if config.is_none() && !started => + { + if preflight.phase != PHASE as i32 { + Err(Status::invalid_argument("expected PRE_CREDENTIALS")) + } else { + config = Some(()); + Ok(Some(WebSocketSessionEventResult { + result: Some( + web_socket_session_event_result::Result::PreflightDecision( + WebSocketPreflightDecision { + action: WebSocketPreflightAction::Inspect as i32, + ..Default::default() + }, + ), + ), + })) + } + } + Some(web_socket_session_event::Event::SessionStart(_)) + if config.is_some() && !started => + { + started = true; + Ok(None) + } + Some(web_socket_session_event::Event::Message(message)) if started => { + if let Err(error) = + advance_sequence_lower_bound(&mut sequence_lower_bound, message.sequence) + { + Err(error) + } else { + evaluate_message(&message).map(|result| { + Some(WebSocketSessionEventResult { + result: Some( + web_socket_session_event_result::Result::MessageResult(result), + ), + }) + }) + } + } + Some(web_socket_session_event::Event::SessionEnd(_)) if config.is_some() => { + break; + } + _ => Err(Status::failed_precondition( + "invalid protocol demo WebSocket session lifecycle", + )), + }; + + match result { + Ok(Some(result)) => { + if results_tx.send(Ok(result)).await.is_err() { + break; + } + } + Ok(None) => {} + Err(error) => { + let _ = results_tx.send(Err(error)).await; + break; + } + } + } + }); + Box::pin(tokio_stream::wrappers::ReceiverStream::new(results_rx)) +} + +fn evaluate_message(message: &WebSocketMessage) -> Result { + let Some(web_socket_message::Payload::Text(text)) = message.payload.as_ref() else { + return Err(Status::invalid_argument("expected text message")); + }; + Ok(WebSocketMessageResult { + sequence: message.sequence, + decision: Decision::Allow as i32, + replacement: Some(web_socket_message_result::Replacement::Text( + text.to_ascii_uppercase(), + )), + ..Default::default() + }) +} + +fn advance_sequence_lower_bound( + lower_bound: &mut Option, + sequence: u64, +) -> Result<(), Status> { + let Some(current_lower_bound) = *lower_bound else { + return Err(Status::invalid_argument( + "WebSocket message sequence must be strictly increasing", + )); + }; + if sequence < current_lower_bound { + return Err(Status::invalid_argument( + "WebSocket message sequence must be strictly increasing", + )); + } + *lower_bound = sequence.checked_add(1); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + #[tokio::test] + async fn lifecycle_transforms_a_complete_message() { + tokio::time::timeout(std::time::Duration::from_secs(5), check_lifecycle()) + .await + .expect("WebSocket lifecycle completes"); + } + + async fn check_lifecycle() { + use openshell_core::proto::{ + MiddlewareSessionEnd, WebSocketPreflight, WebSocketSessionStart, + }; + let events = [ + web_socket_session_event::Event::Preflight(WebSocketPreflight { + phase: PHASE as i32, + ..Default::default() + }), + web_socket_session_event::Event::SessionStart(WebSocketSessionStart::default()), + web_socket_session_event::Event::Message(WebSocketMessage { + sequence: 1, + payload: Some(web_socket_message::Payload::Text("hello".into())), + }), + web_socket_session_event::Event::SessionEnd(MiddlewareSessionEnd::default()), + ]; + let mut results = stream(tokio_stream::iter( + events.map(|event| Ok(WebSocketSessionEvent { event: Some(event) })), + )); + assert!(results.next().await.unwrap().is_ok()); + let result = results.next().await.unwrap().unwrap(); + let Some(web_socket_session_event_result::Result::MessageResult(message)) = result.result + else { + panic!("message result") + }; + assert_eq!( + message.replacement, + Some(web_socket_message_result::Replacement::Text("HELLO".into())) + ); + assert!(results.next().await.is_none()); + } +} diff --git a/examples/supervisor-middleware-protocol/upstream.py b/examples/supervisor-middleware-protocol/upstream.py new file mode 100644 index 0000000000..084b3d2896 --- /dev/null +++ b/examples/supervisor-middleware-protocol/upstream.py @@ -0,0 +1,114 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import base64 +import hashlib +import socket +import socketserver +import struct + + +class Handler(socketserver.BaseRequestHandler): + def read_exact(self, count): + data = b"" + while len(data) < count: + block = self.request.recv(count - len(data)) + if not block: + raise ConnectionError("unexpected EOF") + data += block + return data + + def handle(self): + self.request.settimeout(10) + self.request.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + request = b"" + while b"\r\n\r\n" not in request: + block = self.request.recv(4096) + if not block: + return + request += block + path = request.split(b" ", 2)[1] + if path == b"/request": + head, body = request.split(b"\r\n\r\n", 1) + length = next(int(line.split(b":", 1)[1]) for line in head.split(b"\r\n") if line.lower().startswith(b"content-length:")) + while len(body) < length: + block = self.request.recv(length - len(body)) + if not block: + return + body += block + response = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: " + str(length).encode() + b"\r\n\r\n" + body[:length] + elif path == b"/ws": + key = next(line.split(b":", 1)[1].strip() for line in request.split(b"\r\n") if line.lower().startswith(b"sec-websocket-key:")) + accept = base64.b64encode(hashlib.sha1(key + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest()) + self.request.sendall(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + b"\r\n\r\n") + header = self.read_exact(2) + length = header[1] & 127 + if length == 126: + length = struct.unpack("!H", self.read_exact(2))[0] + elif length == 127: + length = struct.unpack("!Q", self.read_exact(8))[0] + if length > 262144 or header[0] != 0x81 or not header[1] & 0x80: + return + mask = self.read_exact(4) + payload = self.read_exact(length) + body = bytes(value ^ mask[index % 4] for index, value in enumerate(payload)) + if len(body) < 126: + framing = bytes([0x81, len(body)]) + elif len(body) < 65536: + framing = b"\x81\x7e" + struct.pack("!H", len(body)) + else: + framing = b"\x81\x7f" + struct.pack("!Q", len(body)) + self.request.sendall(framing + body) + return + elif path == b"/headers-only": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: 12\r\n\r\n" + b"headers-only" + ) + elif path == b"/whole-body": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + b"6\r\nwhole \r\n4\r\nbody\r\n0\r\n\r\n" + ) + elif path == b"/stream": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Trailer: x-example-body-bytes\r\n" + b"Transfer-Encoding: chunked\r\n\r\n" + b"6\r\nstream\r\n5\r\n body\r\n" + b"0\r\nX-Example-Body-Bytes: 0\r\n\r\n" + ) + elif path == b"/stream-close": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/event-stream\r\n" + b"Connection: close\r\n\r\n" + b"data: stream close\n\n" + ) + elif path == b"/block": + response = ( + b"HTTP/1.1 200 OK\r\n" + b"Content-Type: text/plain\r\n" + b"Content-Length: 16\r\n\r\n" + b"prototype-secret" + ) + else: + response = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" + self.request.sendall(response) + + +class DemoServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + + +with DemoServer(("0.0.0.0", 18081), Handler) as server: + print("response framing demo upstream listening on 0.0.0.0:18081", flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass diff --git a/examples/supervisor-middleware-protocol/websocket-client.py b/examples/supervisor-middleware-protocol/websocket-client.py new file mode 100644 index 0000000000..260f5a0f1e --- /dev/null +++ b/examples/supervisor-middleware-protocol/websocket-client.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""One text-message exchange for the protocol smoke test.""" + +import base64 +import hashlib +import os +import socket +from urllib.parse import urlsplit + + +def read_exact(stream, count): + data = b"" + while len(data) < count: + block = stream.recv(count - len(data)) + if not block: + raise ConnectionError("unexpected EOF") + data += block + return data + + +proxy_url = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") +if not proxy_url: + raise RuntimeError("run this client inside the sandbox with HTTP_PROXY configured") +proxy = urlsplit(proxy_url) +if proxy.scheme != "http" or not proxy.hostname: + raise RuntimeError("expected an HTTP proxy endpoint") + +with socket.create_connection((proxy.hostname, proxy.port or 80), timeout=20) as stream: + stream.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) + stream.sendall( + b"CONNECT host.openshell.internal:18081 HTTP/1.1\r\n" + b"Host: host.openshell.internal:18081\r\n\r\n" + ) + tunnel = b"" + while not tunnel.endswith(b"\r\n\r\n"): + tunnel += read_exact(stream, 1) + assert tunnel.startswith((b"HTTP/1.1 200 ", b"HTTP/1.0 200 ")), tunnel + key = base64.b64encode(os.urandom(16)) + stream.sendall( + b"GET /ws HTTP/1.1\r\nHost: host.openshell.internal:18081\r\n" + b"Upgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: " + key + b"\r\n\r\n" + ) + head = b"" + while not head.endswith(b"\r\n\r\n"): + head += read_exact(stream, 1) + assert head.startswith(b"HTTP/1.1 101 ") + expected = base64.b64encode(hashlib.sha1(key + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest()) + assert expected in head + payload = b"hello protocol" + mask = os.urandom(4) + stream.sendall(bytes([0x81, 0x80 | len(payload)]) + mask + bytes(value ^ mask[index % 4] for index, value in enumerate(payload))) + header = read_exact(stream, 2) + assert header == bytes([0x81, len(payload)]), header + print(read_exact(stream, len(payload)).decode()) + stream.sendall(b"\x88\x80" + os.urandom(4)) diff --git a/tasks/rust.toml b/tasks/rust.toml index e62e22b3cf..302eb5ab8b 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -16,6 +16,7 @@ run = [ "cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings", "cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets", "cargo check --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets", + "cargo check --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all-targets", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -27,6 +28,7 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all", + "cargo fmt --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all", ] hide = true @@ -37,6 +39,7 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check", + "cargo fmt --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all -- --check", ] hide = true diff --git a/tasks/test.toml b/tasks/test.toml index 4a5cda0890..db58b1cb66 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -75,6 +75,8 @@ run = [ # with test-only helpers enabled. "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", + "cargo test --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", + "cargo test --manifest-path examples/supervisor-middleware-protocol/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true From ec505db147e9f11e98de11511a388e67ee63aeb3 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Tue, 8 Sep 2026 18:14:42 -0700 Subject: [PATCH 04/13] fix(middleware): address HTTP response review findings Signed-off-by: Piotr Mlocek --- architecture/sandbox.md | 7 +- .../src/lib.rs | 3 +- .../src/response.rs | 370 ++++++++++++++- .../src/l7/rest.rs | 443 ++++++++++++++++-- docs/reference/gateway-config.mdx | 2 + .../README.md | 12 +- .../smoke.sh | 26 +- skills/generate-sandbox-policy/SKILL.md | 2 +- tasks/scripts/stage-prebuilt-binaries.sh | 4 +- 9 files changed, 794 insertions(+), 75 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 81c4a1b76f..db443bc795 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -199,7 +199,12 @@ can change. Whole-body stages delay commitment and share one non-resetting, supervisor-wide accumulation deadline. Body stages receive a final body result and then one trailer exchange; trailer mutations can only change or remove existing, non-protected names. Intentional blocks return the canonical 403 -before commitment and abort delivery without injected bytes after commitment. +before commitment and abort delivery without injected bytes after commitment. Streaming input units flush after bounded coalescing even within a +content-length body or transfer chunk. Coalescing cancels only input acquisition; +deadline transitions and downstream writes finish outside those timeouts. +The response runtime caps aggregate retained body data across stages and pending +output at 8 MiB. A transformation that exceeds the budget follows its stage's +failure policy, preserving its input when failing open. The supervisor installs policy and middleware registry changes as one runtime generation and preserves the last-known-good generation if preparation fails. diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 57d350cd4e..dcf695539a 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -11,7 +11,8 @@ mod websocket; pub use response::{ HttpResponseDiagnostics, HttpResponseFinish, HttpResponseInvocation, HttpResponseInvocationOutcome, HttpResponseMiddlewareFailure, HttpResponsePreflightInput, - HttpResponsePreflightOutcome, HttpResponseSession, MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES, + HttpResponsePreflightOutcome, HttpResponseSession, MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES, + MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES, }; pub use websocket::{ diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index 2504b1a14c..225df45e82 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -32,6 +32,9 @@ use super::{ const STREAM_CHANNEL_CAPACITY: usize = 4; pub const MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES: usize = 64 * 1024; +/// Maximum logical body bytes retained across a session's stage buffers and +/// pending output. Temporary exchange copies have the per-binding payload cap. +pub const MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES: usize = 8 * 1024 * 1024; #[derive(Debug, Clone)] pub struct HttpResponsePreflightInput { @@ -179,6 +182,7 @@ pub struct HttpResponseSession { invocations: Vec, session_admission: Option, body_transformed: bool, + retained_body_bytes: usize, defer_output_until_finish: bool, deferred_output: Vec>, connection_nominated_headers: Vec, @@ -242,6 +246,7 @@ impl HttpResponseSession { } } self.defer_output_until_finish = false; + self.release_body_bytes(&released); Ok(released) } @@ -285,8 +290,16 @@ impl HttpResponseSession { diagnostics: HttpResponseDiagnostics::default(), })?; let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; + // Between pushes, the first active whole-body barrier owns all input + // not returned to the relay (at most the 4 MiB binding cap). Later + // barriers cannot receive bytes until it finishes or disables itself; + // finish consumes the session and expiry disables all such barriers. + // Replacement admission reserves an additional upstream unit below. + self.retained_body_bytes += data.len(); + debug_assert!(self.retained_body_bytes <= MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES); let output = self.process_units_from(0, vec![data], deadline).await?; if !self.defer_output_until_finish { + self.release_body_bytes(&output); return Ok(output); } if self.requires_whole_body() { @@ -297,6 +310,7 @@ impl HttpResponseSession { self.defer_output_until_finish = false; let mut released = std::mem::take(&mut self.deferred_output); released.extend(output); + self.release_body_bytes(&released); Ok(released) } @@ -369,6 +383,10 @@ impl HttpResponseSession { self.end_all(reason).await; } + fn release_body_bytes(&mut self, units: &[Vec]) { + self.retained_body_bytes -= units.iter().map(Vec::len).sum::(); + } + async fn process_units_from( &mut self, start: usize, @@ -526,6 +544,31 @@ impl HttpResponseSession { } }; let input_size = original.len(); + let replacement_size = match &decision.action { + BodyAction::Transform(replacement) + | BodyAction::SkipRemaining(CurrentBodyAction::Transform(replacement)) => { + Some(replacement.len()) + } + _ => None, + }; + if let Some(replacement_size) = replacement_size { + let retained = self.retained_body_bytes - input_size + replacement_size; + // Reserve room for one more normalized upstream unit. Whole-body + // barriers bound the input retained between calls to push_body. + if retained + > MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES - MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES + { + return self + .handle_stage_failure( + index, + "response_body_aggregate_over_capacity", + Some(sequence), + original, + ) + .await; + } + self.retained_body_bytes = retained; + } let stage = &mut self.stages[index]; collect_diagnostics( stage, @@ -797,11 +840,11 @@ impl ChainRunner { entries: &[ChainEntry], input: HttpResponsePreflightInput, ) -> miette::Result { - validate_preflight_input(&input)?; let described = self.describe_http_response_chain(entries).await?; if described.is_empty() { return Ok(empty_preflight_outcome(input.headers)); } + validate_preflight_input(&input)?; let session_admission = match self.try_reserve_middleware_session() { MiddlewareSessionAdmission::Admitted(admission) => admission, MiddlewareSessionAdmission::AtCapacity => { @@ -852,16 +895,16 @@ impl ChainRunner { }; let timeout = entry.timeout; let opened = tokio::time::timeout(timeout, async { - let mut responses = service - .service - .open_http_response_pre_return(receiver) - .await?; sender .send(HttpResponseEvent { event: Some(http_response_event::Event::Preflight(preflight)), }) .await .map_err(|_| tonic::Status::unavailable("middleware request stream closed"))?; + let mut responses = service + .service + .open_http_response_pre_return(receiver) + .await?; let response = responses.next().await.ok_or_else(|| { tonic::Status::unavailable("middleware result stream closed") })??; @@ -1163,6 +1206,7 @@ impl ChainRunner { invocations: Vec::new(), session_admission: Some(session_admission), body_transformed: false, + retained_body_bytes: 0, defer_output_until_finish, deferred_output: Vec::new(), connection_nominated_headers: input.connection_nominated_headers, @@ -1811,6 +1855,9 @@ mod tests { Configured, HangBody, LargeStream, + Expansion, + DeleteBody, + SkipBody, Skip, InvalidSkipReason, TrailerMutation, @@ -1883,6 +1930,13 @@ mod tests { request: tonic::Request>, ) -> Result, tonic::Status> { let mut requests = request.into_inner(); + // Exercise servers that inspect the initial request before sending + // response headers, rather than returning a stream immediately. + let first = requests.next().await.expect("initial request"); + assert!(matches!(&first, Ok(HttpResponseEvent { + event: Some(http_response_event::Event::Preflight(_)) + }))); + let mut requests = futures::stream::iter([first]).chain(requests); let (sender, receiver) = mpsc::channel(4); tokio::spawn(async move { while let Some(Ok(event)) = requests.next().await { @@ -1932,7 +1986,10 @@ mod tests { operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpResponse as i32, phase: openshell_core::proto::SupervisorMiddlewarePhase::PreReturn as i32, - max_payload_bytes: if matches!(self.script, Script::LargeStream) { + max_payload_bytes: if matches!( + self.script, + Script::LargeStream | Script::Expansion + ) { 128 * 1024 } else { 4096 @@ -2033,6 +2090,9 @@ mod tests { | Script::InvalidSequence | Script::HangBody | Script::LargeStream + | Script::Expansion + | Script::DeleteBody + | Script::SkipBody | Script::TrailerMutation | Script::InvalidTrailerMutation => { (HttpResponseBodyMode::StreamBytes, Vec::new()) @@ -2072,6 +2132,9 @@ mod tests { break; }; let replacement = match selected_script { + Script::Expansion => vec![b'x'; 128 * 1024], + Script::DeleteBody => Vec::new(), + Script::SkipBody => b"replacement".to_vec(), Script::Stream | Script::InvalidSequence | Script::LargeStream @@ -2084,6 +2147,24 @@ mod tests { | Script::Skip | Script::InvalidSkipReason => break, }; + let transform = HttpResponseBodyTransform { + replacement: Some(http_response_body_transform::Replacement::Data( + replacement, + )), + }; + let action = if matches!(selected_script, Script::SkipBody) { + http_response_body_result::Action::SkipRemaining( + openshell_core::proto::HttpResponseBodySkipRemaining { + current: Some( + http_response_body_skip_remaining::Current::Transform( + transform, + ), + ), + }, + ) + } else { + http_response_body_result::Action::Transform(transform) + }; HttpResponseEventResult { result: Some(http_response_event_result::Result::BodyResult( HttpResponseBodyResult { @@ -2095,15 +2176,7 @@ mod tests { } else { body.sequence }, - action: Some(http_response_body_result::Action::Transform( - HttpResponseBodyTransform { - replacement: Some( - http_response_body_transform::Replacement::Data( - replacement, - ), - ), - }, - )), + action: Some(action), ..Default::default() }, )), @@ -2233,6 +2306,270 @@ mod tests { assert!(!modes.contains(&(HttpResponseBodyMode::StreamBytes as i32))); } + struct ReadPreflightBeforeOpening { + failure: Option, + } + + #[tonic::async_trait] + impl InProcessMiddleware for ReadPreflightBeforeOpening { + async fn describe(&self) -> MiddlewareManifest { + let mut manifest = response_manifest("test/response"); + manifest.bindings[0].timeout = "10ms".into(); + manifest + } + + async fn validate_config(&self, _: &str, _: &prost_types::Struct) -> miette::Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + _: HttpRequestView<'_>, + ) -> miette::Result { + unreachable!() + } + + async fn open_http_response_pre_return( + &self, + mut requests: mpsc::Receiver, + ) -> Result { + let first = requests.recv().await.expect("initial preflight"); + assert!(matches!( + first.event, + Some(http_response_event::Event::Preflight(_)) + )); + if let Some(hang) = self.failure { + if hang { + futures::future::pending::<()>().await; + } + return Err(tonic::Status::unavailable("startup failed")); + } + let response = HttpResponseEventResult { + result: Some(http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult { + action: Some(http_response_preflight_result::Action::Inspect( + HttpResponsePreflightInspect { + body_mode: HttpResponseBodyMode::HeadersOnly as i32, + header_mutations: Vec::new(), + }, + )), + ..Default::default() + }, + )), + }; + Ok(Box::pin(futures::stream::iter([Ok(response)]))) + } + } + + #[tokio::test] + async fn preflight_can_be_read_before_open_returns() { + let runner = ChainRunner::new(Arc::new(ReadPreflightBeforeOpening { failure: None })); + let outcome = tokio::time::timeout( + Duration::from_secs(1), + runner.preflight_http_response(&[entry(OnError::FailClosed)], input(200)), + ) + .await + .expect("bounded startup") + .expect("preflight"); + assert!(outcome.allowed, "{}", outcome.reason); + } + + #[tokio::test] + async fn preflight_opening_failure_obeys_policy_and_releases_admission() { + for hang in [false, true] { + for on_error in [OnError::FailOpen, OnError::FailClosed] { + let runner = ChainRunner::new(Arc::new(ReadPreflightBeforeOpening { + failure: Some(hang), + })); + let permits = runner.registry.session_admission.available_permits(); + let outcome = tokio::time::timeout( + Duration::from_secs(1), + runner.preflight_http_response(&[entry(on_error)], input(200)), + ) + .await + .expect("bounded opening failure") + .unwrap(); + assert_eq!(outcome.allowed, on_error == OnError::FailOpen); + assert!(outcome.session.is_none()); + assert_eq!( + runner.registry.session_admission.available_permits(), + permits + ); + assert!(outcome.invocations[0].failed); + } + } + } + + #[tokio::test] + async fn multiple_whole_body_barriers_preserve_accounting_on_overflow_and_expiry() { + for expire in [false, true] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Configured, + })); + let mut entries = vec![ + configured_entry("first", 0, "whole"), + configured_entry("second", 1, "whole"), + configured_entry("stream", 2, "stream"), + ]; + for entry in &mut entries { + entry.on_error = OnError::FailOpen; + } + let mut outcome = runner + .preflight_http_response(&entries, input(200)) + .await + .unwrap(); + let mut session = outcome.session.take().unwrap(); + for _ in 0..2 { + assert!( + session + .push_body(vec![b'a'; 2048]) + .await + .unwrap() + .is_empty() + ); + assert!(session.retained_body_bytes <= 4096); + assert_eq!( + session + .stages + .iter() + .filter(|stage| !stage.whole_body.is_empty()) + .count(), + 1 + ); + } + let output = if expire { + session.start_whole_body_deadline(Duration::ZERO); + session.expire_whole_body_deadline().await.unwrap() + } else { + session.push_body(vec![b'a'; 2048]).await.unwrap() + }; + assert_eq!( + output.concat(), + vec![b'A'; if expire { 4096 } else { 6144 }] + ); + assert_eq!(session.retained_body_bytes, 0); + assert_eq!( + session.push_body(b"next".to_vec()).await.unwrap().concat(), + b"NEXT" + ); + assert_eq!(session.retained_body_bytes, 0); + assert!( + session + .finish(Vec::new()) + .await + .unwrap() + .body_units + .is_empty() + ); + } + } + + #[tokio::test] + async fn deleted_and_skip_remaining_units_release_body_accounting() { + for script in [Script::DeleteBody, Script::SkipBody] { + let runner = ChainRunner::new(Arc::new(ResponseService { script })); + let mut outcome = runner + .preflight_http_response(&[entry(OnError::FailClosed)], input(200)) + .await + .unwrap(); + let mut session = outcome.session.take().unwrap(); + for index in 0..3 { + let output = session.push_body(b"original".to_vec()).await.unwrap(); + let expected = match script { + Script::DeleteBody => Vec::new(), + Script::SkipBody if index == 0 => b"replacement".to_vec(), + Script::SkipBody => b"original".to_vec(), + _ => unreachable!(), + }; + assert_eq!(output.concat(), expected); + assert_eq!(session.retained_body_bytes, 0); + } + assert!( + session + .finish(Vec::new()) + .await + .unwrap() + .body_units + .is_empty() + ); + } + } + + #[tokio::test] + async fn expanding_stages_obey_aggregate_budget_and_failure_policy() { + for on_error in [OnError::FailClosed, OnError::FailOpen] { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::Expansion, + })); + let permits = runner.registry.session_admission.available_permits(); + let entries = (0..9) + .map(|order| { + let mut entry = entry(on_error); + entry.name = format!("expand-{order}"); + entry.order = order; + entry + }) + .collect::>(); + let mut outcome = runner + .preflight_http_response(&entries, input(200)) + .await + .unwrap(); + let mut session = outcome.session.take().unwrap(); + let result = tokio::time::timeout(Duration::from_secs(5), session.push_body(vec![1])) + .await + .expect("bounded expansion"); + match result { + Ok(output) => { + assert_eq!(on_error, OnError::FailOpen); + assert!( + output.iter().map(Vec::len).sum::() + < MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES + ); + assert!(output.iter().flatten().all(|byte| *byte == b'x')); + assert_eq!(session.retained_body_bytes, 0); + assert!( + session + .invocations + .iter() + .any(|invocation| invocation.outcome + == HttpResponseInvocationOutcome::FailOpen) + ); + // Holding returned output applies backpressure: subsequent + // stage work starts only when the relay calls again. + drop(output); + for _ in 0..3 { + let output = session.push_body(vec![1]).await.unwrap(); + assert!( + output.iter().map(Vec::len).sum::() + < MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES + ); + assert_eq!(session.retained_body_bytes, 0); + } + let finish = session.finish(Vec::new()).await.unwrap(); + assert!( + finish.body_units.iter().map(Vec::len).sum::() + < MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES + ); + } + Err(failure) => { + assert_eq!(on_error, OnError::FailClosed); + assert!( + failure + .reason + .contains("response_body_aggregate_over_capacity") + ); + session + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + } + } + assert_eq!( + runner.registry.session_admission.available_permits(), + permits + ); + } + } + #[tokio::test] async fn headers_only_preflight_applies_end_to_end_mutation() { let runner = ChainRunner::new(Arc::new(ResponseService { @@ -2651,8 +2988,9 @@ mod tests { assert!(outcome.session.is_none()); let _ = shutdown_tx.send(()); - server_task + tokio::time::timeout(Duration::from_secs(2), server_task) .await + .expect("bounded server shutdown") .expect("join response middleware server") .expect("serve response middleware"); } diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index ec0da7d135..e918a292cb 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -3378,7 +3378,21 @@ where guard.ensure_current()?; } let header_bytes = &buffered[..header_end]; - let parsed = match parse_response_head_for_middleware(header_bytes) { + // Ordinary responses retain the HTTP parser's byte-preserving behavior. + // Response-specific normalization and limits apply only to selected hooks. + if middleware.chain.is_empty() { + return Ok(None); + } + let parsed = match middleware + .runner + .describe_http_response_chain(middleware.chain) + .await + { + Ok(described) if described.is_empty() => return Ok(None), + Ok(_) => parse_response_head_for_middleware(header_bytes), + Err(error) => Err(error), + }; + let parsed = match parsed { Ok(parsed) => parsed, Err(error) => { debug!(error = %error, "HTTP response head normalization failed"); @@ -4412,9 +4426,10 @@ where let length = usize::try_from(remaining) .unwrap_or(unit_limit) .min(unit_limit); - let unit = read_exact_response_with_deadline( + let unit = read_response_payload_with_deadline( reader, length, + !pending.is_empty(), session, client, &mut framing, @@ -4423,6 +4438,7 @@ where if let Some(guard) = generation_guard { guard.ensure_current()?; } + let partial = unit.len() < length; remaining -= unit.len() as u64; buffer_normalized_response_bytes( session, @@ -4433,6 +4449,15 @@ where unit_limit, ) .await?; + if partial { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + } } flush_normalized_response_bytes(session, client, pending, &mut framing).await?; Ok(Vec::new()) @@ -4464,9 +4489,10 @@ where let mut remaining = chunk_size; while remaining > 0 { let length = remaining.min(unit_limit); - let unit = read_exact_response_with_deadline( + let unit = read_response_payload_with_deadline( reader, length, + !pending.is_empty(), session, client, &mut framing, @@ -4475,6 +4501,7 @@ where if let Some(guard) = generation_guard { guard.ensure_current()?; } + let partial = unit.len() < length; remaining -= unit.len(); buffer_normalized_response_bytes( session, @@ -4485,19 +4512,37 @@ where unit_limit, ) .await?; + if partial { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + } } - if read_exact_response_with_deadline(reader, 2, session, client, &mut framing) - .await? - .as_slice() - != b"\r\n" + let terminator = if let Ok(result) = + tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, reader.read_exact_vec(2)) + .await { + result? + } else { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + read_exact_response_with_deadline(reader, 2, session, client, &mut framing) + .await? + }; + if terminator != b"\r\n" { return Err(miette!("HTTP response chunk is missing its terminator")); } - size_line = if let Ok(line) = tokio::time::timeout( - RESPONSE_UNIT_COALESCE_TIMEOUT, - read_response_line_with_deadline(reader, session, client, &mut framing), - ) - .await + size_line = if let Ok(line) = + tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, reader.read_line()).await { line? } else { @@ -4513,28 +4558,45 @@ where } } BodyLength::None if server_wants_close || event_stream => loop { - let read = - read_response_with_deadline(reader, unit_limit, session, client, &mut framing); - let next = if pending.is_empty() && event_stream { - read.await? - } else if pending.is_empty() { - match tokio::time::timeout(RELAY_EOF_IDLE_TIMEOUT, read).await { - Ok(result) => result?, - Err(_) => None, + // Cancel only input acquisition, never expiry or client writes. + let wait = if pending.is_empty() { + if event_stream { + None + } else { + Some(RELAY_EOF_IDLE_TIMEOUT) } - } else if let Ok(result) = - tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, read).await - { - result? } else { - flush_normalized_response_bytes( - session, - client, - std::mem::take(&mut pending), - &mut framing, - ) - .await?; - continue; + Some(RESPONSE_UNIT_COALESCE_TIMEOUT) + }; + let read_deadline = wait.map(|wait| tokio::time::Instant::now() + wait); + let whole_deadline = session.whole_body_deadline(); + let deadline = match (read_deadline, whole_deadline) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + let next = if let Some(deadline) = deadline { + if let Ok(result) = + tokio::time::timeout_at(deadline, reader.read_some(unit_limit)).await + { + result? + } else { + if whole_deadline.is_some_and(|d| d <= tokio::time::Instant::now()) { + expire_whole_body_deadline(session, client, &mut framing).await?; + } else if pending.is_empty() { + return Ok(Vec::new()); + } else { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + } + continue; + } + } else { + reader.read_some(unit_limit).await? }; let Some(unit) = next else { flush_normalized_response_bytes(session, client, pending, &mut framing).await?; @@ -4611,25 +4673,52 @@ async fn expire_whole_body_deadline( deliver_response_units(client, output, framing, session.requires_whole_body()).await } -async fn read_response_with_deadline( +// Unit size is a maximum. Coalesce available payload without waiting for +// the rest of a transfer chunk, and finish expiry outside read timeouts. +async fn read_response_payload_with_deadline( reader: &mut BufferedResponseReader<'_, R>, limit: usize, + has_pending: bool, session: &mut openshell_supervisor_middleware::HttpResponseSession, client: &mut C, framing: &mut ResponseOutputState<'_>, -) -> Result>> +) -> Result> where R: AsyncRead + Unpin, C: AsyncWrite + Unpin, { + let mut payload = Vec::new(); + let mut coalesce_deadline = + has_pending.then(|| tokio::time::Instant::now() + RESPONSE_UNIT_COALESCE_TIMEOUT); loop { - let Some(deadline) = session.whole_body_deadline() else { - return reader.read_some(limit).await; + let whole_deadline = session.whole_body_deadline(); + let deadline = match (coalesce_deadline, whole_deadline) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), }; - match tokio::time::timeout_at(deadline, reader.read_some(limit)).await { - Ok(result) => return result, - Err(_) => expire_whole_body_deadline(session, client, framing).await?, + let read = reader.read_some(limit - payload.len()); + let result = if let Some(deadline) = deadline { + if let Ok(result) = tokio::time::timeout_at(deadline, read).await { + result + } else { + if whole_deadline.is_some_and(|d| d <= tokio::time::Instant::now()) { + expire_whole_body_deadline(session, client, framing).await?; + } + if coalesce_deadline.is_some_and(|d| d <= tokio::time::Instant::now()) { + return Ok(payload); + } + continue; + } + } else { + read.await + }; + let data = result?.ok_or_else(|| miette!("HTTP response body ended unexpectedly"))?; + payload.extend_from_slice(&data); + if payload.len() == limit { + return Ok(payload); } + coalesce_deadline + .get_or_insert_with(|| tokio::time::Instant::now() + RESPONSE_UNIT_COALESCE_TIMEOUT); } } @@ -5253,12 +5342,14 @@ mod tests { BlockWholeBody, BlockStream, SlowWholeBody, + SlowStream, InvalidBodySequence, InvalidWholeBodySequence, } struct ResponseRelayService { script: ResponseRelayScript, + request_only: bool, } #[tonic::async_trait] @@ -5268,8 +5359,16 @@ mod tests { name: "test/response-relay".into(), service_version: "test".into(), bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpResponse as i32, - phase: SupervisorMiddlewarePhase::PreReturn as i32, + operation: if self.request_only { + SupervisorMiddlewareOperation::HttpRequest + } else { + SupervisorMiddlewareOperation::HttpResponse + } as i32, + phase: if self.request_only { + SupervisorMiddlewarePhase::PreCredentials + } else { + SupervisorMiddlewarePhase::PreReturn + } as i32, max_payload_bytes: 4096, timeout: String::new(), }], @@ -5302,7 +5401,11 @@ mod tests { openshell_supervisor_middleware::HttpResponseResultStream, tonic::Status, > { - let script = self.script; + assert!( + !self.request_only, + "request-only service received a response" + ); + let mut script = self.script; let (sender, receiver) = mpsc::channel(4); tokio::spawn(async move { while let Some(event) = requests.recv().await { @@ -5310,7 +5413,14 @@ mod tests { break; }; let result = match event { - http_response_event::Event::Preflight(_) => { + http_response_event::Event::Preflight(preflight) => { + if preflight + .config + .as_ref() + .is_some_and(|config| config.fields.contains_key("whole_body")) + { + script = ResponseRelayScript::WholeBody; + } if matches!(script, ResponseRelayScript::BlockPreflight) { HttpResponseEventResult { result: Some( @@ -5345,6 +5455,7 @@ mod tests { (HttpResponseBodyMode::WholeBodyBytes, Vec::new()) } ResponseRelayScript::Stream + | ResponseRelayScript::SlowStream | ResponseRelayScript::BlockStream | ResponseRelayScript::InvalidBodySequence => { (HttpResponseBodyMode::StreamBytes, Vec::new()) @@ -5384,6 +5495,7 @@ mod tests { [b"whole:".as_slice(), &data].concat() } ResponseRelayScript::Stream + | ResponseRelayScript::SlowStream | ResponseRelayScript::BlockStream | ResponseRelayScript::InvalidBodySequence => { data.to_ascii_uppercase() @@ -5391,7 +5503,11 @@ mod tests { ResponseRelayScript::HeadersOnly | ResponseRelayScript::BlockPreflight => break, }; - if matches!(script, ResponseRelayScript::SlowWholeBody) { + if matches!( + script, + ResponseRelayScript::SlowWholeBody + | ResponseRelayScript::SlowStream + ) { tokio::time::sleep(std::time::Duration::from_millis(75)).await; } HttpResponseEventResult { @@ -7326,6 +7442,132 @@ mod tests { assert!(!is_bodiless_response("POST", 201)); } + #[tokio::test] + async fn response_middleware_unbound_chains_preserve_ordinary_headers() { + let mut many_headers = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n".to_vec(); + for _ in 0..129 { + many_headers.extend_from_slice(b"Set-Cookie: a=b\r\n"); + } + many_headers.extend_from_slice(b"\r\n"); + let opaque_headers = + b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nX-Opaque: \xff\xfe\r\n\r\nabc".to_vec(); + let (_, chain) = response_middleware_fixture(ResponseRelayScript::HeadersOnly); + let request_only_runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(ResponseRelayService { + script: ResponseRelayScript::HeadersOnly, + request_only: true, + })); + let empty_runner = openshell_supervisor_middleware::ChainRunner::default(); + for response in [many_headers, opaque_headers] { + assert!(response.len() < MAX_HEADER_BYTES); + for context in [ + None, + Some(response_middleware_context(&empty_runner, &[], "GET")), + Some(response_middleware_context( + &request_only_runner, + &chain, + "GET", + )), + ] { + let mut upstream = response.as_slice(); + let mut delivered = Vec::new(); + let outcome = relay_response( + "GET", + &mut upstream, + &mut delivered, + RelayResponseOptions::default(), + context, + ) + .await + .unwrap(); + assert!(matches!(outcome, RelayOutcome::Reusable)); + assert_eq!(delivered, response); + } + } + } + + #[tokio::test] + async fn response_middleware_selected_hook_enforces_header_limits() { + let mut response = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n".to_vec(); + for _ in 0..129 { + response.extend_from_slice(b"Set-Cookie: a=b\r\n"); + } + response.extend_from_slice(b"\r\n"); + let (runner, chain) = response_middleware_fixture(ResponseRelayScript::HeadersOnly); + let mut upstream = response.as_slice(); + let mut delivered = Vec::new(); + let outcome = relay_response( + "GET", + &mut upstream, + &mut delivered, + RelayResponseOptions::default(), + Some(response_middleware_context(&runner, &chain, "GET")), + ) + .await + .unwrap(); + assert!(matches!(outcome, RelayOutcome::Consumed)); + assert!(delivered.starts_with(b"HTTP/1.1 502 ")); + } + + #[tokio::test] + async fn response_middleware_flushes_partial_framed_payload_promptly() { + for chunked in [false, true] { + let (runner, chain) = response_middleware_fixture(ResponseRelayScript::Stream); + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(8192); + // Small capacity forces the relay to complete partial downstream writes. + let (mut client_read, mut client_write) = tokio::io::duplex(7); + let head = if chunked { + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nContent-Type: text/event-stream\r\n\r\n1000\r\nabc".as_slice() + } else { + b"HTTP/1.1 200 OK\r\nContent-Length: 4096\r\nContent-Type: text/event-stream\r\n\r\nabc".as_slice() + }; + upstream_write.write_all(head).await.unwrap(); + let task = tokio::spawn(async move { + relay_response( + "GET", + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + Some(response_middleware_context(&runner, &chain, "GET")), + ) + .await + }); + let result = tokio::time::timeout(std::time::Duration::from_secs(2), async { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + head.push(client_read.read_u8().await.unwrap()); + } + let mut first = [0; 8]; + client_read.read_exact(&mut first).await.unwrap(); + assert_eq!(&first, b"3\r\nABC\r\n"); + // Complete the same framed payload only after its first transformed + // bytes have reached the consumer. + upstream_write.write_all(&vec![b'd'; 4093]).await.unwrap(); + if chunked { + for fragment in [b"\r".as_slice(), b"\n0\r", b"\n\r", b"\n"] { + upstream_write.write_all(fragment).await.unwrap(); + tokio::task::yield_now().await; + } + } + drop(upstream_write); + let mut rest = Vec::new(); + client_read.read_to_end(&mut rest).await.unwrap(); + assert!(rest.ends_with(b"0\r\n\r\n")); + let body = collect_chunked_body(&mut tokio::io::empty(), &rest, None, None) + .await + .unwrap(); + assert_eq!(body, vec![b'D'; 4093]); + }) + .await; + if result.is_err() { + task.abort(); + } + let relay = task.await; + assert!(result.is_ok(), "partial payload stalled, chunked={chunked}"); + assert!(relay.unwrap().is_ok()); + } + } + fn response_middleware_fixture( script: ResponseRelayScript, ) -> ( @@ -7348,6 +7590,7 @@ mod tests { let runner = openshell_supervisor_middleware::ChainRunner::new(Arc::new(ResponseRelayService { script, + request_only: false, })); let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "response".into(), @@ -7668,13 +7911,117 @@ mod tests { assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); let delivered = String::from_utf8(delivered).unwrap(); - assert!( - delivered.ends_with("5\r\nhello\r\n0\r\n\r\n"), - "{delivered}" - ); + let (_, body) = delivered.split_once("\r\n\r\n").unwrap(); + let decoded = collect_chunked_body(&mut tokio::io::empty(), body.as_bytes(), None, None) + .await + .unwrap(); + assert_eq!(decoded, b"hello"); assert!(!delivered.contains("whole:hello"), "{delivered}"); } + #[tokio::test(start_paused = true)] + async fn response_middleware_expiry_preserves_bytes_through_slow_stream_and_client() { + for chunked in [true, false] { + let (runner, mut chain) = response_middleware_fixture_with_error( + ResponseRelayScript::SlowStream, + openshell_supervisor_middleware::OnError::FailOpen, + ); + let mut whole_body = chain[0].clone(); + whole_body.name = "whole-body".into(); + whole_body + .config + .fields + .insert("whole_body".into(), prost_types::Value::default()); + chain[0].order = 1; + chain.insert(0, whole_body); + let (mut upstream_read, mut upstream_write) = tokio::io::duplex(8192); + // Force write_all to make partial progress before each wait. + let (mut client_read, mut client_write) = tokio::io::duplex(7); + let producer = async move { + let head = if chunked { + b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n800\r\n".as_slice() + } else { + b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n".as_slice() + }; + upstream_write.write_all(head).await.unwrap(); + upstream_write.write_all(&vec![b'a'; 2048]).await.unwrap(); + if chunked { + upstream_write.write_all(b"\r\n").await.unwrap(); + } + // The first coalesced unit belongs to the whole-body stage. A new + // partial unit starts coalescing just before its deadline. + tokio::time::sleep(std::time::Duration::from_millis(9)).await; + upstream_write + .write_all(if chunked { b"1\r\nb\r\n" } else { b"b" }) + .await + .unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + if chunked { + upstream_write.write_all(b"0\r\n\r\n").await.unwrap(); + } + upstream_write.shutdown().await.unwrap(); + }; + let relay = async { + let mut context = response_middleware_context(&runner, &chain, "GET"); + context.whole_body_timeout = std::time::Duration::from_millis(10); + let outcome = relay_response( + "GET", + &mut upstream_read, + &mut client_write, + RelayResponseOptions::default(), + Some(context), + ) + .await; + drop(client_write); + outcome + }; + let consumer = async move { + let mut delivered = Vec::new(); + let mut bytes = [0; 7]; + loop { + let count = client_read.read(&mut bytes).await.unwrap(); + if count == 0 { + break; + } + delivered.extend_from_slice(&bytes[..count]); + tokio::time::sleep(std::time::Duration::from_millis(1)).await; + } + delivered + }; + let ((), outcome, delivered) = tokio::time::timeout( + std::time::Duration::from_secs(3), + Box::pin(async { tokio::join!(producer, relay, consumer) }), + ) + .await + .expect("response relay stalled"); + assert!(outcome.is_ok(), "{outcome:?}"); + assert!(delivered.starts_with(b"HTTP/1.1 200 OK\r\n")); + let head_end = delivered + .windows(4) + .position(|bytes| bytes == b"\r\n\r\n") + .unwrap() + + 4; + let mut wire = &delivered[head_end..]; + let mut body = Vec::new(); + loop { + let end = wire.windows(2).position(|bytes| bytes == b"\r\n").unwrap(); + let size = + usize::from_str_radix(std::str::from_utf8(&wire[..end]).unwrap(), 16).unwrap(); + wire = &wire[end + 2..]; + if size == 0 { + assert_eq!(wire, b"\r\n"); + break; + } + body.extend_from_slice(&wire[..size]); + assert_eq!(&wire[size..size + 2], b"\r\n"); + wire = &wire[size + 2..]; + } + let mut expected = vec![b'A'; 2048]; + expected.push(b'B'); + assert_eq!(body, expected, "chunked={chunked}"); + } + } + #[tokio::test] async fn response_middleware_streams_normalized_chunks_and_preserves_trailers() { let (outcome, delivered) = run_response_middleware_relay( diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 8b1cb5c584..878a6665ef 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -315,6 +315,8 @@ The gateway connects to every registered service and validates `Describe` before `timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies to HTTP request evaluation, HTTP response preflight and unit exchanges, WebSocket preflight, and each WebSocket message. Accepted streaming protocols have no connection-wide RPC deadline. +Response middleware also has an 8 MiB aggregate retained-body limit per response session across stage buffers and pending output. This limit is separate from each service's `max_payload_bytes` and cannot be configured. A replacement that exceeds the available budget follows the stage's `on_error` policy. Fail-open disables that stage and forwards its original input; fail-closed returns 502 before commitment or stops delivery after commitment. Streaming inputs flush after a short coalescing window without waiting for an entire upstream transfer chunk. + `http_response_whole_body_timeout` is a supervisor-wide safety bound, not a middleware RPC timeout. It defaults to `120s` and accepts a positive integer followed by `ms`, `s`, or `m`. The timer starts after ordered response preflight selects a `WHOLE_BODY_BYTES` stage and before OpenShell reads the first response body byte. It does not reset as chunks arrive. It ends after the normalized body reaches end of stream and every whole-body barrier produces output. On expiry, each active whole-body stage follows its configured `on_error` policy: fail-open releases OpenShell-owned bytes through later stages, while fail-closed returns the canonical pre-commit 502 response. Changing this field requires restarting the gateway. The service `grpc_endpoint` supports plaintext `http://` and TLS `https://`. HTTPS uses the platform trust store unless `tls_ca_cert_path` names a certificate-only PEM bundle. OpenShell rejects bundles containing private keys, loads the certificates at gateway startup, and distributes only public certificates to sandbox supervisors; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:middleware:`. After authenticated `Describe` succeeds, OpenShell treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest. diff --git a/examples/supervisor-middleware-content-guard/README.md b/examples/supervisor-middleware-content-guard/README.md index 4ccda9162a..c9f4a6f927 100644 --- a/examples/supervisor-middleware-content-guard/README.md +++ b/examples/supervisor-middleware-content-guard/README.md @@ -15,7 +15,7 @@ This configured-literal guard applies the same case-sensitive terms to UTF-8 HTT ## Prerequisites -Install `cargo`, `curl`, `jq`, `openssl`, and `uv` with Python 3 on the host before running the smoke script. +Install `cargo`, `curl`, `jq`, `openssl`, `mise`, and `uv` with Python 3 on the host before running the smoke script. Start Docker or Podman. The supervisor image build uses the repository's Linux cross-compilation toolchain, including `cargo-zigbuild` and Zig on macOS. Install the repository's mise tools before running it. ## Run the smoke example @@ -39,7 +39,15 @@ The script creates the sandbox and prints the guarded and unguarded request comm CONTENT_GUARD_SMOKE_HOST=192.168.1.10 ./examples/supervisor-middleware-content-guard/smoke.sh --test-suite ``` -The gateway auto-detects its compute driver. Set `CONTENT_GUARD_SMOKE_DRIVER=docker` or `CONTENT_GUARD_SMOKE_DRIVER=podman` if more than one local runtime is installed and auto-detection selects the wrong one. +The script defaults to Docker. Set `CONTENT_GUARD_SMOKE_DRIVER=podman` to build and run with Podman instead. + +On Linux and macOS, the script runs `mise run docker:build:supervisor` with the selected container engine to build a Linux supervisor from the current checkout. It configures that driver's `supervisor_image` with a unique local tag, so the response checks exercise the local runtime changes. macOS host binaries are never used inside the sandbox. The local image remains available after the smoke run. + +Cargo's configured target directory applies to the host binaries and the Linux supervisor build. For example: + +```shell +CARGO_TARGET_DIR=/tmp/content-guard-target ./examples/supervisor-middleware-content-guard/smoke.sh --test-suite +``` ## Run manually diff --git a/examples/supervisor-middleware-content-guard/smoke.sh b/examples/supervisor-middleware-content-guard/smoke.sh index a372a7ffe1..f16ed28ca7 100755 --- a/examples/supervisor-middleware-content-guard/smoke.sh +++ b/examples/supervisor-middleware-content-guard/smoke.sh @@ -25,7 +25,7 @@ Environment: CONTENT_GUARD_SMOKE_HOST Non-loopback host address reachable from both the gateway and sandbox containers. CONTENT_GUARD_SMOKE_DRIVER - Optional compute driver name, such as docker or podman. + Compute driver: docker (default) or podman. EOF } @@ -105,7 +105,14 @@ detect_service_host() { } SERVICE_HOST="$(detect_service_host)" -COMPUTE_DRIVER="${CONTENT_GUARD_SMOKE_DRIVER:-}" +COMPUTE_DRIVER="${CONTENT_GUARD_SMOKE_DRIVER:-docker}" +case "$COMPUTE_DRIVER" in + docker | podman) ;; + *) + echo "CONTENT_GUARD_SMOKE_DRIVER must be docker or podman" >&2 + exit 1 + ;; +esac if [[ "$SERVICE_HOST" == "localhost" || "$SERVICE_HOST" == "::1" || "$SERVICE_HOST" == 127.* || "$SERVICE_HOST" == *:* ]]; then echo "CONTENT_GUARD_SMOKE_HOST must be a non-loopback IPv4 address: $SERVICE_HOST" >&2 exit 1 @@ -121,6 +128,7 @@ MIDDLEWARE_LOG="$LOG_DIR/middleware.log" UPSTREAM_LOG="$LOG_DIR/upstream.log" SANDBOX_LOG="$LOG_DIR/sandbox.log" RUN_ID="content-guard-smoke-$$-$RANDOM" +SUPERVISOR_IMAGE="localhost/openshell-content-guard/supervisor:$RUN_ID" # Sandbox names are capped at 19 characters. Use a short prefix with # the PID for uniqueness; keep the full RUN_ID for gateway identity. SANDBOX_NAME="cg-$$-$RANDOM" @@ -228,8 +236,8 @@ allow_insecure_transport = true max_payload_bytes = 262144 timeout = "500ms" -[openshell.drivers.docker] -supervisor_bin = "$ROOT/target/debug/openshell-sandbox" +[openshell.drivers.$COMPUTE_DRIVER] +supervisor_image = "$SUPERVISOR_IMAGE" EOF } @@ -533,13 +541,21 @@ require_command curl require_command jq require_command openssl require_command uv +require_command mise ROOT_TARGET_DIR="$(cargo_target_dir "$ROOT/Cargo.toml")" EXAMPLE_TARGET_DIR="$(cargo_target_dir "$EXAMPLE_DIR/Cargo.toml")" GATEWAY_BIN="$ROOT_TARGET_DIR/debug/openshell-gateway" CLI_BIN="$ROOT_TARGET_DIR/debug/openshell" MIDDLEWARE_BIN="$EXAMPLE_TARGET_DIR/debug/supervisor-middleware-content-guard" run_setup_step "building gateway" cargo build --quiet -p openshell-gateway --bin openshell-gateway -run_setup_step "building sandbox supervisor" cargo build --quiet -p openshell-sandbox --bin openshell-sandbox +# Always rebuild from this checkout and load into the selected runtime. Native +# macOS binaries cannot run in Linux sandboxes; Podman also needs an image. +# A unique tag prevents the driver from selecting an older published runtime. +run_setup_step "building Linux sandbox supervisor image" \ + env -u CI -u DOCKER_PLATFORM -u DOCKER_PUSH -u DOCKER_OUTPUT \ + CONTAINER_ENGINE="$COMPUTE_DRIVER" PREBUILT_AUTO_STAGE=1 \ + IMAGE_REGISTRY=localhost/openshell-content-guard IMAGE_TAG="$RUN_ID" \ + mise run docker:build:supervisor run_setup_step "building content guard" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell generate_gateway_jwt_bundle diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 2f7436e567..26cfd736db 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -214,7 +214,7 @@ Add `network_middlewares` only when the user asks to inspect, transform, redact, - Use `openshell/regex` without gateway registration for fixed-pattern redaction of UTF-8 HTTP request bodies or complete client-to-upstream WebSocket text messages. - Use an operator-owned middleware name only when it is already registered under `[[openshell.supervisor.middleware]]` and reachable from both the gateway and sandbox supervisors. - Confirm that a requested WebSocket implementation exposes a `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` binding. `openshell/regex` exposes this binding. A host-matched HTTP-only implementation may inspect the upgrade GET but does not join the post-upgrade chain; messages pass and OpenShell emits `binding_not_selected` coverage regardless of `on_error`. -- Confirm that requested response processing exposes `HTTP_RESPONSE/PRE_RETURN`. Response stages choose header-only, whole-body, or streaming inspection independently. Whole-body stages delay response commitment and are bounded by the gateway's supervisor-wide accumulation timeout. Intentional blocks produce a canonical 403 before commitment and abort the connection after commitment. +- Confirm that requested response processing exposes `HTTP_RESPONSE/PRE_RETURN`. Response stages choose header-only, whole-body, or streaming inspection independently. Whole-body stages delay response commitment and are bounded by the gateway's supervisor-wide accumulation timeout. Expanding response transformations also share an 8 MiB retained-body budget per session; exhaustion follows the stage's `on_error` policy. Intentional blocks produce a canonical 403 before commitment and abort the connection after commitment. - WebSocket middleware runs for both `ws://` and `wss://` and receives complete client text messages only. Binary messages pass under both error modes and emit `unsupported_message_type` coverage for active stages. Upstream-to-client messages remain uninspected. Do not claim that V1 provides all-message WebSocket inspection. - Treat `fail_open` on WebSocket as a session-scoped bypass: if the stage stream fails, OpenShell disables it for later messages on that connection and emits a state-change finding. Prefer `fail_closed` for required redaction or authorization. - `on_error` governs failures after an advertised operation binding is selected. It does not apply to an unadvertised WebSocket binding or binary-message pass-through. An explicit HTTP, WebSocket preflight, or WebSocket message denial is authoritative under both `fail_open` and `fail_closed`. diff --git a/tasks/scripts/stage-prebuilt-binaries.sh b/tasks/scripts/stage-prebuilt-binaries.sh index fe4913439a..ff7eda26f8 100755 --- a/tasks/scripts/stage-prebuilt-binaries.sh +++ b/tasks/scripts/stage-prebuilt-binaries.sh @@ -171,6 +171,7 @@ build_component_for_arch() { local current_host_os local current_host_arch local binary_path + local cargo_output_dir local build_rustflags resolve_component "$component" @@ -257,7 +258,8 @@ build_component_for_arch() { CARGO_INCREMENTAL=0 mise x -- "${cargo_env[@]}" "${cargo_subcommand[@]}" "${args[@]}" ) - binary_path="${ROOT}/target/${target}/release/${binary}" + cargo_output_dir="$(cd "$ROOT" && mise x -- cargo metadata --format-version=1 --no-deps | jq -er '.target_directory')" + binary_path="${cargo_output_dir}/${target}/release/${binary}" if [[ "$component" == "gateway" ]]; then "$SCRIPT_DIR/verify-glibc-symbols.sh" 2.28 "$binary_path" elif [[ "$component" == "supervisor" ]]; then From 190454c0012252b0dd84ef311b82115d43ffea6f Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 9 Sep 2026 09:49:55 -0700 Subject: [PATCH 05/13] refactor(examples): defer protocol demo to a separate PR Signed-off-by: Piotr Mlocek --- .github/workflows/branch-checks.yml | 3 - architecture/sandbox.md | 3 +- docs/extensibility/supervisor-middleware.mdx | 7 +- .../README.md | 2 +- .../supervisor-middleware-protocol/Cargo.lock | 2247 ----------------- .../supervisor-middleware-protocol/Cargo.toml | 27 - .../supervisor-middleware-protocol/README.md | 100 - .../policy.yaml | 49 - .../supervisor-middleware-protocol/smoke.sh | 536 ---- .../src/main.rs | 173 -- .../src/request.rs | 49 - .../src/response.rs | 345 --- .../src/websocket.rs | 163 -- .../upstream.py | 114 - .../websocket-client.py | 58 - tasks/rust.toml | 3 - tasks/test.toml | 1 - 17 files changed, 4 insertions(+), 3876 deletions(-) delete mode 100644 examples/supervisor-middleware-protocol/Cargo.lock delete mode 100644 examples/supervisor-middleware-protocol/Cargo.toml delete mode 100644 examples/supervisor-middleware-protocol/README.md delete mode 100644 examples/supervisor-middleware-protocol/policy.yaml delete mode 100755 examples/supervisor-middleware-protocol/smoke.sh delete mode 100644 examples/supervisor-middleware-protocol/src/main.rs delete mode 100644 examples/supervisor-middleware-protocol/src/request.rs delete mode 100644 examples/supervisor-middleware-protocol/src/response.rs delete mode 100644 examples/supervisor-middleware-protocol/src/websocket.rs delete mode 100644 examples/supervisor-middleware-protocol/upstream.py delete mode 100644 examples/supervisor-middleware-protocol/websocket-client.py diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index fbdefb8ed2..8427102ce8 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -159,7 +159,6 @@ jobs: cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check - cargo fmt --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all -- --check - name: Lint run: | @@ -167,7 +166,6 @@ jobs: cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets cargo check --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets - cargo check --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all-targets - name: Test env: @@ -175,7 +173,6 @@ jobs: run: | cargo nextest run --profile ci --workspace --features openshell-server/test-support cargo test --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml - cargo test --manifest-path examples/supervisor-middleware-protocol/Cargo.toml - name: Verify telemetry can be compiled out run: | diff --git a/architecture/sandbox.md b/architecture/sandbox.md index db443bc795..8336bd4775 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -166,8 +166,7 @@ operator-owned registration names identify implementations. The configured-literal content-guard example shares matching semantics across request bodies, complete response bodies, and client WebSocket text messages. It requires whole-body response inspection and returns a middleware failure -when that mode is unavailable. The separate protocol example demonstrates each -V1 hook, response framing modes, unit-local transforms, and trailer mutation. +when that mode is unavailable. Built-ins run in-process against a borrowed view of the chain's current HTTP request state. Operator services retain the bounded protobuf/gRPC contract, and diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index 3c667faf09..989c03bd00 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -220,7 +220,7 @@ Middleware activity is emitted through OpenShell's OCSF logging: See [Logging](/observability/logging) for log access and [OCSF JSON Export](/observability/ocsf-json-export) for structured export. -## Runnable examples +## Runnable example The [content guard example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-content-guard) matches configured literal terms in UTF-8 request bodies, complete response @@ -228,10 +228,7 @@ bodies, and client WebSocket text messages. It supports redaction and denial. Responses require whole-body inspection; unavailable inspection or invalid UTF-8 invokes the configured `on_error` policy. It is not a general PII detector. -The [protocol example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-protocol) -demonstrates all V1 hooks in one service, including header-only response changes, -whole-body and unit-local streaming transformations, trailers, and typed blocks. -Each example includes a policy, local fixture, and smoke launcher. +The example includes a policy, local fixture, and smoke launcher. ## Current Limitations diff --git a/examples/supervisor-middleware-content-guard/README.md b/examples/supervisor-middleware-content-guard/README.md index c9f4a6f927..fcdc37df56 100644 --- a/examples/supervisor-middleware-content-guard/README.md +++ b/examples/supervisor-middleware-content-guard/README.md @@ -8,7 +8,7 @@ SPDX-License-Identifier: Apache-2.0 > [!WARNING] > Supervisor middleware is a research preview. Its policy and service contracts may change without compatibility guarantees. Use it only to prototype and evaluate middleware integrations. -This configured-literal guard applies the same case-sensitive terms to UTF-8 HTTP request bodies, complete HTTP response bodies, and client WebSocket text messages. It is not a general PII detector. See [the protocol example](../supervisor-middleware-protocol/) for demonstrations of response modes and framing. +This configured-literal guard applies the same case-sensitive terms to UTF-8 HTTP request bodies, complete HTTP response bodies, and client WebSocket text messages. It is not a general PII detector. > [!WARNING] > This intentionally simple implementation demonstrates the supervisor middleware service contract. It is not a complete or reliable content guard and must not be used as a security control. It handles only UTF-8 HTTP request and response bodies and WebSocket text messages with case-sensitive literal terms, merges overlapping literal match ranges before redaction, and does not address encodings, transformations, normalization, binary WebSocket messages, upstream-to-client messages, or adversarial inputs that a production content guard must handle. diff --git a/examples/supervisor-middleware-protocol/Cargo.lock b/examples/supervisor-middleware-protocol/Cargo.lock deleted file mode 100644 index 9472da85be..0000000000 --- a/examples/supervisor-middleware-protocol/Cargo.lock +++ /dev/null @@ -1,2247 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "addr2line" -version = "0.25.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" -dependencies = [ - "gimli", -] - -[[package]] -name = "adler2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" - -[[package]] -name = "aho-corasick" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" -dependencies = [ - "memchr", -] - -[[package]] -name = "anstream" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" -dependencies = [ - "anstyle", - "anstyle-parse", - "anstyle-query", - "anstyle-wincon", - "colorchoice", - "is_terminal_polyfill", - "utf8parse", -] - -[[package]] -name = "anstyle" -version = "1.0.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" - -[[package]] -name = "anstyle-parse" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" -dependencies = [ - "utf8parse", -] - -[[package]] -name = "anstyle-query" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "anstyle-wincon" -version = "3.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" -dependencies = [ - "anstyle", - "once_cell_polyfill", - "windows-sys 0.61.2", -] - -[[package]] -name = "anyhow" -version = "1.0.103" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" - -[[package]] -name = "async-trait" -version = "0.1.89" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sync_wrapper", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - -[[package]] -name = "backtrace" -version = "0.3.76" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-link", -] - -[[package]] -name = "backtrace-ext" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "537beee3be4a18fb023b570f80e3ae28003db9167a751266b259926e25539d50" -dependencies = [ - "backtrace", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - -[[package]] -name = "bitflags" -version = "2.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" - -[[package]] -name = "bytes" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" - -[[package]] -name = "cc" -version = "1.2.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chacha20" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" -dependencies = [ - "cfg-if", - "cpufeatures", - "rand_core", -] - -[[package]] -name = "clap" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "clap_lex" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[package]] -name = "combine" -version = "4.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" -dependencies = [ - "bytes", - "memchr", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "cpufeatures" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" -dependencies = [ - "libc", -] - -[[package]] -name = "critical-section" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" - -[[package]] -name = "data-encoding" -version = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" - -[[package]] -name = "displaydoc" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - -[[package]] -name = "equivalent" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" - -[[package]] -name = "errno" -version = "0.3.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "fastrand" -version = "2.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" - -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - -[[package]] -name = "form_urlencoded" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" -dependencies = [ - "percent-encoding", -] - -[[package]] -name = "futures-channel" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" - -[[package]] -name = "futures-sink" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" - -[[package]] -name = "futures-task" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" - -[[package]] -name = "futures-util" -version = "0.3.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "rand_core", -] - -[[package]] -name = "gimli" -version = "0.32.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" - -[[package]] -name = "glob" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" - -[[package]] -name = "h2" -version = "0.4.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" -dependencies = [ - "atomic-waker", - "bytes", - "fnv", - "futures-core", - "futures-sink", - "http", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "heck" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" - -[[package]] -name = "hickory-proto" -version = "0.26.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d" -dependencies = [ - "data-encoding", - "idna", - "ipnet", - "jni", - "once_cell", - "rand", - "ring", - "thiserror", - "tinyvec", - "tracing", - "url", -] - -[[package]] -name = "http" -version = "1.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "pin-project-lite", -] - -[[package]] -name = "httparse" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" - -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - -[[package]] -name = "hyper" -version = "1.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" -dependencies = [ - "atomic-waker", - "bytes", - "futures-channel", - "futures-core", - "h2", - "http", - "http-body", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "smallvec", - "tokio", - "want", -] - -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - -[[package]] -name = "hyper-util" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" -dependencies = [ - "bytes", - "futures-channel", - "futures-util", - "http", - "http-body", - "hyper", - "libc", - "pin-project-lite", - "socket2", - "tokio", - "tower-service", - "tracing", -] - -[[package]] -name = "icu_collections" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" -dependencies = [ - "displaydoc", - "litemap", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_normalizer" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" - -[[package]] -name = "icu_properties" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" -dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" - -[[package]] -name = "icu_provider" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" -dependencies = [ - "displaydoc", - "icu_locale_core", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "idna" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" -dependencies = [ - "idna_adapter", - "smallvec", - "utf8_iter", -] - -[[package]] -name = "idna_adapter" -version = "1.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" -dependencies = [ - "icu_normalizer", - "icu_properties", -] - -[[package]] -name = "indexmap" -version = "2.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", -] - -[[package]] -name = "ipnet" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" - -[[package]] -name = "is_ci" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" - -[[package]] -name = "is_terminal_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" - -[[package]] -name = "itertools" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" -dependencies = [ - "either", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "jni" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" -dependencies = [ - "cfg-if", - "combine", - "jni-macros", - "jni-sys", - "log", - "simd_cesu8", - "thiserror", - "walkdir", - "windows-link", -] - -[[package]] -name = "jni-macros" -version = "0.22.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" -dependencies = [ - "proc-macro2", - "quote", - "rustc_version", - "simd_cesu8", - "syn", -] - -[[package]] -name = "jni-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" -dependencies = [ - "jni-sys-macros", -] - -[[package]] -name = "jni-sys-macros" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" -dependencies = [ - "quote", - "syn", -] - -[[package]] -name = "libc" -version = "0.2.186" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" - -[[package]] -name = "libyml" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" -dependencies = [ - "anyhow", - "version_check", -] - -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - -[[package]] -name = "litemap" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" -dependencies = [ - "scopeguard", -] - -[[package]] -name = "log" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" - -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - -[[package]] -name = "memchr" -version = "2.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" - -[[package]] -name = "miette" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f98efec8807c63c752b5bd61f862c165c115b0a35685bdcfd9238c7aeb592b7" -dependencies = [ - "backtrace", - "backtrace-ext", - "cfg-if", - "miette-derive", - "owo-colors", - "supports-color", - "supports-hyperlinks", - "supports-unicode", - "terminal_size", - "textwrap", - "unicode-width 0.1.14", -] - -[[package]] -name = "miette-derive" -version = "7.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db5b29714e950dbb20d5e6f74f9dcec4edbcc1067bb7f8ed198c097b8c1a818b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "mime" -version = "0.3.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" - -[[package]] -name = "miniz_oxide" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" -dependencies = [ - "adler2", -] - -[[package]] -name = "mio" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" -dependencies = [ - "libc", - "wasi", - "windows-sys 0.61.2", -] - -[[package]] -name = "multimap" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" - -[[package]] -name = "nix" -version = "0.29.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46" -dependencies = [ - "bitflags", - "cfg-if", - "cfg_aliases", - "libc", -] - -[[package]] -name = "object" -version = "0.37.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" -dependencies = [ - "memchr", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" -dependencies = [ - "critical-section", - "portable-atomic", -] - -[[package]] -name = "once_cell_polyfill" -version = "1.70.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" - -[[package]] -name = "openshell-core" -version = "0.0.0" -dependencies = [ - "async-trait", - "base64", - "glob", - "ipnet", - "miette", - "nix", - "openshell-extension-core", - "prost", - "prost-types", - "protoc-bin-vendored", - "rustix", - "rustls", - "rustls-pemfile", - "serde", - "serde_json", - "thiserror", - "tokio", - "tokio-stream", - "tonic", - "tonic-prost", - "tonic-prost-build", - "tracing", - "url", -] - -[[package]] -name = "openshell-extension-core" -version = "0.0.0" -dependencies = [ - "hyper-util", - "serde", - "thiserror", - "tokio", - "tonic", - "tower", -] - -[[package]] -name = "openshell-policy" -version = "0.0.0" -dependencies = [ - "hickory-proto", - "miette", - "openshell-core", - "prost-types", - "serde", - "serde_json", - "serde_yml", -] - -[[package]] -name = "openshell-supervisor-middleware-protocol" -version = "0.0.0" -dependencies = [ - "clap", - "openshell-core", - "openshell-policy", - "tokio", - "tokio-stream", - "tonic", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "owo-colors" -version = "4.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" - -[[package]] -name = "parking_lot" -version = "0.12.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" -dependencies = [ - "lock_api", - "parking_lot_core", -] - -[[package]] -name = "parking_lot_core" -version = "0.9.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" -dependencies = [ - "cfg-if", - "libc", - "redox_syscall", - "smallvec", - "windows-link", -] - -[[package]] -name = "percent-encoding" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" - -[[package]] -name = "petgraph" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" -dependencies = [ - "fixedbitset", - "hashbrown 0.15.5", - "indexmap", -] - -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "portable-atomic" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" - -[[package]] -name = "potential_utf" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" -dependencies = [ - "zerovec", -] - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "proc-macro2" -version = "1.0.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "prost" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" -dependencies = [ - "bytes", - "prost-derive", -] - -[[package]] -name = "prost-build" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" -dependencies = [ - "heck", - "itertools", - "log", - "multimap", - "petgraph", - "prettyplease", - "prost", - "prost-types", - "pulldown-cmark", - "pulldown-cmark-to-cmark", - "regex", - "syn", - "tempfile", -] - -[[package]] -name = "prost-derive" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" -dependencies = [ - "anyhow", - "itertools", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "prost-types" -version = "0.14.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" -dependencies = [ - "prost", -] - -[[package]] -name = "protoc-bin-vendored" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1c381df33c98266b5f08186583660090a4ffa0889e76c7e9a5e175f645a67fa" -dependencies = [ - "protoc-bin-vendored-linux-aarch_64", - "protoc-bin-vendored-linux-ppcle_64", - "protoc-bin-vendored-linux-s390_64", - "protoc-bin-vendored-linux-x86_32", - "protoc-bin-vendored-linux-x86_64", - "protoc-bin-vendored-macos-aarch_64", - "protoc-bin-vendored-macos-x86_64", - "protoc-bin-vendored-win32", -] - -[[package]] -name = "protoc-bin-vendored-linux-aarch_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c350df4d49b5b9e3ca79f7e646fde2377b199e13cfa87320308397e1f37e1a4c" - -[[package]] -name = "protoc-bin-vendored-linux-ppcle_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a55a63e6c7244f19b5c6393f025017eb5d793fd5467823a099740a7a4222440c" - -[[package]] -name = "protoc-bin-vendored-linux-s390_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dba5565db4288e935d5330a07c264a4ee8e4a5b4a4e6f4e83fad824cc32f3b0" - -[[package]] -name = "protoc-bin-vendored-linux-x86_32" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8854774b24ee28b7868cd71dccaae8e02a2365e67a4a87a6cd11ee6cdbdf9cf5" - -[[package]] -name = "protoc-bin-vendored-linux-x86_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b38b07546580df720fa464ce124c4b03630a6fb83e05c336fea2a241df7e5d78" - -[[package]] -name = "protoc-bin-vendored-macos-aarch_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89278a9926ce312e51f1d999fee8825d324d603213344a9a706daa009f1d8092" - -[[package]] -name = "protoc-bin-vendored-macos-x86_64" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81745feda7ccfb9471d7a4de888f0652e806d5795b61480605d4943176299756" - -[[package]] -name = "protoc-bin-vendored-win32" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95067976aca6421a523e491fce939a3e65249bac4b977adee0ee9771568e8aa3" - -[[package]] -name = "pulldown-cmark" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" -dependencies = [ - "bitflags", - "memchr", - "unicase", -] - -[[package]] -name = "pulldown-cmark-to-cmark" -version = "22.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" -dependencies = [ - "pulldown-cmark", -] - -[[package]] -name = "quote" -version = "1.0.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "6.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" - -[[package]] -name = "rand" -version = "0.10.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" -dependencies = [ - "chacha20", - "getrandom 0.4.3", - "rand_core", -] - -[[package]] -name = "rand_core" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" - -[[package]] -name = "redox_syscall" -version = "0.5.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" - -[[package]] -name = "rustc_version" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" -dependencies = [ - "semver", -] - -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.41" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-native-certs" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" -dependencies = [ - "openssl-probe", - "rustls-pki-types", - "schannel", - "security-framework", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "rustls-pki-types" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - -[[package]] -name = "ryu" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" - -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "scopeguard" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" - -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "semver" -version = "1.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" - -[[package]] -name = "serde" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.228" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "serde_json" -version = "1.0.150" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_yml" -version = "0.0.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" -dependencies = [ - "indexmap", - "itoa", - "libyml", - "memchr", - "ryu", - "serde", - "version_check", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "signal-hook-registry" -version = "1.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" -dependencies = [ - "errno", - "libc", -] - -[[package]] -name = "simd_cesu8" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" -dependencies = [ - "rustc_version", - "simdutf8", -] - -[[package]] -name = "simdutf8" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" - -[[package]] -name = "socket2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" -dependencies = [ - "libc", - "windows-sys 0.61.2", -] - -[[package]] -name = "stable_deref_trait" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" - -[[package]] -name = "strsim" -version = "0.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" - -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "supports-color" -version = "3.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" -dependencies = [ - "is_ci", -] - -[[package]] -name = "supports-hyperlinks" -version = "3.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e396b6523b11ccb83120b115a0b7366de372751aa6edf19844dfb13a6af97e91" - -[[package]] -name = "supports-unicode" -version = "3.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7401a30af6cb5818bb64852270bb722533397edcfc7344954a38f420819ece2" - -[[package]] -name = "syn" -version = "2.0.118" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "sync_wrapper" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" - -[[package]] -name = "synstructure" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "terminal_size" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" -dependencies = [ - "rustix", - "windows-sys 0.61.2", -] - -[[package]] -name = "textwrap" -version = "0.16.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057" -dependencies = [ - "unicode-linebreak", - "unicode-width 0.2.2", -] - -[[package]] -name = "thiserror" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tinystr" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" -dependencies = [ - "displaydoc", - "zerovec", -] - -[[package]] -name = "tinyvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" - -[[package]] -name = "tokio" -version = "1.52.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" -dependencies = [ - "bytes", - "libc", - "mio", - "parking_lot", - "pin-project-lite", - "signal-hook-registry", - "socket2", - "tokio-macros", - "windows-sys 0.61.2", -] - -[[package]] -name = "tokio-macros" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "axum", - "base64", - "bytes", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "rustls-native-certs", - "socket2", - "sync_wrapper", - "tokio", - "tokio-rustls", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tonic-build" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" -dependencies = [ - "prettyplease", - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tonic-prost" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" -dependencies = [ - "bytes", - "prost", - "tonic", -] - -[[package]] -name = "tonic-prost-build" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" -dependencies = [ - "prettyplease", - "proc-macro2", - "prost-build", - "prost-types", - "quote", - "syn", - "tempfile", - "tonic-build", -] - -[[package]] -name = "tower" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" -dependencies = [ - "futures-core", - "futures-util", - "indexmap", - "pin-project-lite", - "slab", - "sync_wrapper", - "tokio", - "tokio-util", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "tower-layer" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" - -[[package]] -name = "tower-service" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" - -[[package]] -name = "tracing" -version = "0.1.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" -dependencies = [ - "pin-project-lite", - "tracing-attributes", - "tracing-core", -] - -[[package]] -name = "tracing-attributes" -version = "0.1.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - -[[package]] -name = "unicode-ident" -version = "1.0.24" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" - -[[package]] -name = "unicode-linebreak" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" - -[[package]] -name = "unicode-width" -version = "0.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" - -[[package]] -name = "unicode-width" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" - -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "url" -version = "2.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" -dependencies = [ - "form_urlencoded", - "idna", - "percent-encoding", - "serde", -] - -[[package]] -name = "utf8_iter" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" - -[[package]] -name = "utf8parse" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - -[[package]] -name = "want" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" -dependencies = [ - "try-lock", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "winapi-util" -version = "0.1.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-sys" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" -dependencies = [ - "windows-targets", -] - -[[package]] -name = "windows-sys" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - -[[package]] -name = "yoke" -version = "0.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" -dependencies = [ - "stable_deref_trait", - "yoke-derive", - "zerofrom", -] - -[[package]] -name = "yoke-derive" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zerofrom" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" -dependencies = [ - "zerofrom-derive", -] - -[[package]] -name = "zerofrom-derive" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" -dependencies = [ - "proc-macro2", - "quote", - "syn", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - -[[package]] -name = "zerotrie" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", -] - -[[package]] -name = "zerovec" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" -dependencies = [ - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "zmij" -version = "1.0.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/examples/supervisor-middleware-protocol/Cargo.toml b/examples/supervisor-middleware-protocol/Cargo.toml deleted file mode 100644 index 03bea27a78..0000000000 --- a/examples/supervisor-middleware-protocol/Cargo.toml +++ /dev/null @@ -1,27 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[workspace] - -[package] -name = "openshell-supervisor-middleware-protocol" -description = "Example OpenShell supervisor middleware service" -version = "0.0.0" -edition = "2024" -rust-version = "1.90" -license = "Apache-2.0" -publish = false - -[dependencies] -clap = { version = "4.5", features = ["derive"] } -openshell-core = { path = "../../crates/openshell-core", default-features = false } -tokio = { version = "1.43", features = ["macros", "rt-multi-thread", "time"] } -tokio-stream = "0.1" -tonic = { version = "0.14", features = ["transport"] } - -[dev-dependencies] -openshell-policy = { path = "../../crates/openshell-policy" } - -[[bin]] -name = "supervisor-middleware-protocol" -path = "src/main.rs" diff --git a/examples/supervisor-middleware-protocol/README.md b/examples/supervisor-middleware-protocol/README.md deleted file mode 100644 index 987b96533b..0000000000 --- a/examples/supervisor-middleware-protocol/README.md +++ /dev/null @@ -1,100 +0,0 @@ - - -# Supervisor middleware protocol example - -This standalone gRPC service demonstrates all current V1 hooks: -`HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and -`WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. Supervisor middleware is a research preview. - -The example selects canned behavior by path. For configured literal matching, -use [content guard](../supervisor-middleware-content-guard/) instead. - -## Run - -Install Cargo, curl, jq, OpenSSL, and uv with Python 3. A local Docker or Podman -runtime must support OpenShell sandboxes. - -```shell -./examples/supervisor-middleware-protocol/smoke.sh --test-suite -``` - -The launcher builds the gateway, supervisor, CLI, and middleware, starts the -local fixture on port 18081, and creates a sandbox with the included policy. -It checks each behavior below and removes its sandbox and processes on exit. -The fixture port must be free. Run the two middleware examples sequentially. - -Run without flags to keep the stack running. Use `PROTOCOL_SMOKE_HOST` to -override the detected non-loopback IPv4 address and `PROTOCOL_SMOKE_DRIVER` -to select `docker` or `podman`. Both the gateway and sandbox must reach the -service endpoint. `--print-config` prints the generated gateway registration. - -For manual startup: - -```shell -cargo run --manifest-path examples/supervisor-middleware-protocol/Cargo.toml -- --bind 0.0.0.0:50051 -uv run --no-project python examples/supervisor-middleware-protocol/upstream.py -``` - -Register the service before starting the gateway: - -```toml -[[openshell.supervisor.middleware]] -name = "protocol-example" -grpc_endpoint = "http://host.openshell.internal:50051" -allow_insecure_transport = true -max_payload_bytes = 262144 -timeout = "500ms" -``` - -The endpoint uses plaintext without peer authentication for local development. -Adjust its hostname to an address reachable from the gateway and sandbox. -The service takes empty configuration and advertises a 256 KiB payload limit. - -## Behaviors - -All routes use `http://host.openshell.internal:18081`. - -| Route | Hook and behavior | -| --- | --- | -| `POST /request` | Request hook uppercases ASCII bytes; the fixture echoes the changed body. | -| `GET /headers-only` | Response hook adds `x-example-response-mode: headers-only` and preserves content-length framing. | -| `GET /whole-body` | Selects `WHOLE_BODY_BYTES` and prefixes the normalized chunked body with `[whole]`. | -| `GET /stream` | Selects `STREAM_BYTES`, uppercases each unit, and overwrites the supplied `x-example-body-bytes` trailer with `11`. | -| `GET /stream-close` | Uppercases a close-delimited event-stream response. | -| `GET /block` | Blocks the complete body before commitment with typed `BlockDelivery` and reason code `content_match`. | -| `GET /ws` upgrade | WebSocket hook uppercases each complete client text message; the fixture echoes it. | - -The smoke suite checks the canonical 403 for `/block`. Other response paths -return `Skip`, including the request echo path. Request bodies outside -`/request` pass unchanged. - -Stream transformations act only on the current unit. Unit boundaries have no -application meaning, so this example neither matches cross-unit terms nor -retains bytes for a future result. Header-only inspection preserves transport -framing. Body modes receive normalized bytes and finish with a trailer exchange, -including an empty trailer set. Selecting an unavailable mode returns a -middleware failure, handled by the policy's `fail_closed` setting. - -WebSocket preflight chooses inspection, session start/end are notifications, -and each message result echoes its sequence number. Only client text messages -are inspected. Binary, control, and upstream messages are outside this hook. -The fixture and client perform one text exchange; they are not general WebSocket -implementations. - -## Source and tests - -`src/request.rs`, `src/response.rs`, and `src/websocket.rs` own their hook -behavior. `src/main.rs` owns startup, the manifest, and empty configuration -validation. The policy selects this service by the operator registration -`protocol-example`. - -```shell -cargo test --manifest-path examples/supervisor-middleware-protocol/Cargo.toml -bash -n examples/supervisor-middleware-protocol/smoke.sh -``` - -Malformed framing, HTTP/1.0 edge cases, timeouts, disconnects, and security -boundary tests belong in the runtime crates. diff --git a/examples/supervisor-middleware-protocol/policy.yaml b/examples/supervisor-middleware-protocol/policy.yaml deleted file mode 100644 index cfe5e381a3..0000000000 --- a/examples/supervisor-middleware-protocol/policy.yaml +++ /dev/null @@ -1,49 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -version: 1 - -network_middlewares: - prototype-protocol: - name: Protocol demonstration - middleware: protocol-example - order: 10 - config: {} - on_error: fail_closed - endpoints: - include: - - host.openshell.internal - -network_policies: - response-framing-demo: - name: Response framing demo - endpoints: - - host: host.openshell.internal - port: 18081 - protocol: rest - rules: - - allow: - method: POST - path: /request - - allow: - method: GET - path: /ws - - allow: - method: GET - path: /headers-only - - allow: - method: GET - path: /whole-body - - allow: - method: GET - path: /stream - - allow: - method: GET - path: /stream-close - - allow: - method: GET - path: /block - binaries: - - path: /usr/bin/curl - - path: /usr/bin/python3 - - path: /usr/local/bin/python3 diff --git a/examples/supervisor-middleware-protocol/smoke.sh b/examples/supervisor-middleware-protocol/smoke.sh deleted file mode 100755 index c41bb8469f..0000000000 --- a/examples/supervisor-middleware-protocol/smoke.sh +++ /dev/null @@ -1,536 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -set -euo pipefail - -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" -EXAMPLE_DIR="$ROOT/examples/supervisor-middleware-protocol" -RUN_TEST_SUITE=0 -PRINT_CONFIG=0 - -usage() { - cat <&2 - usage >&2 - exit 2 - ;; - esac -done - -detect_service_host() { - local interface address - - if [[ -n "${PROTOCOL_SMOKE_HOST:-}" ]]; then - printf '%s\n' "$PROTOCOL_SMOKE_HOST" - return - fi - - if [[ "$(uname -s)" == "Darwin" ]] && command -v route >/dev/null 2>&1 && command -v ipconfig >/dev/null 2>&1; then - interface="$(route -n get default 2>/dev/null | awk '/interface:/ { print $2; exit }')" - if [[ -n "$interface" ]]; then - address="$(ipconfig getifaddr "$interface" 2>/dev/null || true)" - if [[ -n "$address" ]]; then - printf '%s\n' "$address" - return - fi - fi - - if command -v ifconfig >/dev/null 2>&1; then - for interface in $(ifconfig -l 2>/dev/null); do - if [[ "$interface" != en* ]]; then - continue - fi - address="$(ipconfig getifaddr "$interface" 2>/dev/null || true)" - if [[ -n "$address" ]]; then - printf '%s\n' "$address" - return - fi - done - fi - fi - - if command -v ip >/dev/null 2>&1; then - address="$(ip route get 1.1.1.1 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i == "src") { print $(i + 1); exit } }')" - if [[ -n "$address" ]]; then - printf '%s\n' "$address" - return - fi - fi - - if command -v hostname >/dev/null 2>&1; then - address="$(hostname -I 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i !~ /^127\./ && $i !~ /:/) { print $i; exit } }')" - if [[ -n "$address" ]]; then - printf '%s\n' "$address" - return - fi - fi - - echo "could not detect a non-loopback host address" >&2 - echo "set PROTOCOL_SMOKE_HOST to an address reachable from sandbox containers" >&2 - exit 1 -} - -SERVICE_HOST="$(detect_service_host)" -COMPUTE_DRIVER="${PROTOCOL_SMOKE_DRIVER:-}" -if [[ "$SERVICE_HOST" == "localhost" || "$SERVICE_HOST" == "::1" || "$SERVICE_HOST" == 127.* || "$SERVICE_HOST" == *:* ]]; then - echo "PROTOCOL_SMOKE_HOST must be a non-loopback IPv4 address: $SERVICE_HOST" >&2 - exit 1 -fi - -SMOKE_TMP_DIR="$(mktemp -d)" -LOG_DIR="$SMOKE_TMP_DIR/logs" -JWT_DIR="$SMOKE_TMP_DIR/jwt" -GATEWAY_CONFIG="$SMOKE_TMP_DIR/gateway.toml" -SETUP_LOG="$LOG_DIR/setup.log" -GATEWAY_LOG="$LOG_DIR/gateway.log" -MIDDLEWARE_LOG="$LOG_DIR/middleware.log" -UPSTREAM_LOG="$LOG_DIR/upstream.log" -SANDBOX_LOG="$LOG_DIR/sandbox.log" -RUN_ID="protocol-smoke-$$-$RANDOM" -# Sandbox names are capped at 19 characters. Use a short prefix with -# the PID for uniqueness; keep the full RUN_ID for gateway identity. -SANDBOX_NAME="cg-$$-$RANDOM" -SANDBOX_CREATED=0 - -mkdir -p "$LOG_DIR" - -cleanup() { - local status=$? - trap - EXIT - - if [[ "$SANDBOX_CREATED" -eq 1 && -n "${CLI+x}" ]]; then - "${CLI[@]}" sandbox delete "$SANDBOX_NAME" >>"$SETUP_LOG" 2>&1 || true - fi - - if [[ -n "${GATEWAY_PID:-}" ]]; then - kill "$GATEWAY_PID" 2>/dev/null || true - wait "$GATEWAY_PID" 2>/dev/null || true - fi - - if [[ -n "${MIDDLEWARE_PID:-}" ]]; then - kill "$MIDDLEWARE_PID" 2>/dev/null || true - wait "$MIDDLEWARE_PID" 2>/dev/null || true - fi - - if [[ -n "${UPSTREAM_PID:-}" ]]; then - kill "$UPSTREAM_PID" 2>/dev/null || true - wait "$UPSTREAM_PID" 2>/dev/null || true - fi - - if [[ "$status" -eq 0 ]]; then - rm -rf "$SMOKE_TMP_DIR" - else - echo "logs retained in $LOG_DIR" >&2 - fi - - exit "$status" -} -trap cleanup EXIT - -port_is_free() { - local port="$1" - - if command -v lsof >/dev/null 2>&1; then - ! lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1 - return - fi - - if command -v nc >/dev/null 2>&1; then - ! nc -z 127.0.0.1 "$port" >/dev/null 2>&1 - return - fi - - return 0 -} - -choose_port_block() { - local count="$1" - local start offset ok - - for _ in {1..200}; do - start=$((20000 + RANDOM % 20000)) - ok=1 - for ((offset = 0; offset < count; offset++)); do - if ! port_is_free "$((start + offset))"; then - ok=0 - break - fi - done - if [[ "$ok" == "1" ]]; then - printf '%s\n' "$start" - return - fi - done - - echo "failed to find free local ports for protocol demo launcher" >&2 - exit 1 -} - -PORT_BASE="$(choose_port_block 3)" -MIDDLEWARE_PORT="$PORT_BASE" -GATEWAY_PORT="$((PORT_BASE + 1))" -HEALTH_PORT="$((PORT_BASE + 2))" -GATEWAY_ENDPOINT="http://127.0.0.1:$GATEWAY_PORT" - -write_gateway_config() { - cat >"$GATEWAY_CONFIG" </dev/null 2>&1; then - echo "openssl is required to generate local smoke-test gateway JWT keys" >&2 - exit 1 - fi - - mkdir -p "$JWT_DIR" - openssl genpkey -algorithm ed25519 -out "$JWT_DIR/signing.pem" >/dev/null 2>&1 - openssl pkey -in "$JWT_DIR/signing.pem" -pubout -out "$JWT_DIR/public.pem" >/dev/null 2>&1 - printf '%s\n' "$RUN_ID" >"$JWT_DIR/kid" -} - -dump_logs() { - local label path - for label in setup gateway middleware upstream sandbox; do - case "$label" in - setup) path="$SETUP_LOG" ;; - gateway) path="$GATEWAY_LOG" ;; - middleware) path="$MIDDLEWARE_LOG" ;; - upstream) path="$UPSTREAM_LOG" ;; - sandbox) path="$SANDBOX_LOG" ;; - esac - printf '\n--- %s log: %s ---\n' "$label" "$path" >&2 - if [[ -f "$path" ]]; then - cat "$path" >&2 - else - printf '(missing)\n' >&2 - fi - done -} - -capture_sandbox_log() { - local container_id - - if [[ "$SANDBOX_CREATED" -ne 1 || "$COMPUTE_DRIVER" != "docker" ]] || - ! command -v docker >/dev/null 2>&1; then - return - fi - - container_id="$(docker ps -aq --filter "name=$SANDBOX_NAME" | head -n 1)" - if [[ -n "$container_id" ]]; then - docker logs "$container_id" >"$SANDBOX_LOG" 2>&1 || true - fi -} - -fail() { - printf 'FAIL %s\n' "$1" >&2 - capture_sandbox_log - dump_logs - exit 1 -} - -require_command() { - command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" -} - -run_setup_step() { - local label="$1" - shift - printf 'INFO %s\n' "$label" - printf '\n== %s ==\n+' "$label" >>"$SETUP_LOG" - printf ' %q' "$@" >>"$SETUP_LOG" - printf '\n' >>"$SETUP_LOG" - if ! "$@" >>"$SETUP_LOG" 2>&1; then - fail "$label" - fi -} - -cargo_target_dir() { - local manifest_path="$1" - - cargo metadata \ - --format-version=1 \ - --no-deps \ - --manifest-path "$manifest_path" \ - | jq -er '.target_directory' -} - -start_middleware() { - printf 'INFO starting protocol demo service at %s:%s\n' "$SERVICE_HOST" "$MIDDLEWARE_PORT" - "$MIDDLEWARE_BIN" \ - --bind "0.0.0.0:$MIDDLEWARE_PORT" >"$MIDDLEWARE_LOG" 2>&1 & - MIDDLEWARE_PID=$! -} - -middleware_port_is_ready() { - if command -v nc >/dev/null 2>&1; then - nc -z "$SERVICE_HOST" "$MIDDLEWARE_PORT" >/dev/null 2>&1 - return - fi - - (exec 3<>"/dev/tcp/$SERVICE_HOST/$MIDDLEWARE_PORT") 2>/dev/null -} - -wait_for_middleware() { - for _ in {1..60}; do - if ! kill -0 "$MIDDLEWARE_PID" 2>/dev/null; then - fail "protocol demo service starts" - fi - if middleware_port_is_ready; then - printf 'INFO protocol demo service is ready\n' - return - fi - sleep 1 - done - fail "protocol demo service is reachable at $SERVICE_HOST:$MIDDLEWARE_PORT" -} - -start_upstream() { - printf 'INFO starting response framing upstream at %s:18081\n' "$SERVICE_HOST" - uv run --no-project python "$EXAMPLE_DIR/upstream.py" >"$UPSTREAM_LOG" 2>&1 & - UPSTREAM_PID=$! -} - -wait_for_upstream() { - for _ in {1..30}; do - if ! kill -0 "$UPSTREAM_PID" 2>/dev/null; then - fail "response framing upstream starts" - fi - if curl -fsS --max-time 1 "http://127.0.0.1:18081/headers-only" >/dev/null 2>&1; then - printf 'INFO response framing upstream is ready\n' - return - fi - sleep 1 - done - fail "response framing upstream is reachable" -} - -start_gateway() { - local -a driver_args=() - if [[ -n "$COMPUTE_DRIVER" ]]; then - driver_args=(--drivers "$COMPUTE_DRIVER") - fi - printf 'INFO starting gateway\n' - env -u OPENSHELL_DRIVERS "$GATEWAY_BIN" \ - "${driver_args[@]}" \ - --config "$GATEWAY_CONFIG" \ - --bind-address 127.0.0.1 \ - --port "$GATEWAY_PORT" \ - --health-port "$HEALTH_PORT" \ - --metrics-port 0 \ - --log-level "${PROTOCOL_SMOKE_LOG_LEVEL:-info}" \ - --disable-tls \ - --db-url "sqlite://$SMOKE_TMP_DIR/gateway.db" >"$GATEWAY_LOG" 2>&1 & - GATEWAY_PID=$! -} - -wait_for_gateway() { - for _ in {1..60}; do - if ! kill -0 "$MIDDLEWARE_PID" 2>/dev/null; then - fail "protocol demo service starts" - fi - if ! kill -0 "$GATEWAY_PID" 2>/dev/null; then - fail "gateway starts with protocol demo" - fi - if curl -fsS "http://127.0.0.1:$HEALTH_PORT/healthz" >/dev/null 2>&1; then - printf 'INFO gateway starts with protocol demo\n' - return - fi - sleep 1 - done - fail "gateway starts with protocol demo" -} - -create_sandbox() { - CLI=( - env - -u OPENSHELL_SANDBOX_POLICY - "$CLI_BIN" - --gateway-endpoint "$GATEWAY_ENDPOINT" - ) - SANDBOX_CREATED=1 - run_setup_step \ - "creating protocol demo sandbox" \ - "${CLI[@]}" sandbox create --name "$SANDBOX_NAME" --policy "$EXAMPLE_DIR/policy.yaml" --no-tty --detach -- sleep infinity -} - -response_request() { - local path="$1" - "${CLI[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- \ - curl -sS -i --max-time 20 "http://host.openshell.internal:18081/$path" -} - -run_suite() { - local response_output="$LOG_DIR/response.out" - if ! "${CLI[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- \ - curl -fsS --max-time 20 http://host.openshell.internal:18081/request --data 'request body' >"$response_output" 2>>"$SETUP_LOG" || - [[ "$(cat "$response_output")" != "REQUEST BODY" ]]; then - fail "request body replacement" - fi - printf 'PASS request body replacement\n' - if ! "${CLI[@]}" sandbox exec --name "$SANDBOX_NAME" --no-tty -- \ - python3 -c "$(cat "$EXAMPLE_DIR/websocket-client.py")" >"$response_output" 2>>"$SETUP_LOG" || - [[ "$(cat "$response_output")" != "HELLO PROTOCOL" ]]; then - fail "WebSocket message replacement" - fi - printf 'PASS WebSocket message replacement\n' - - printf 'INFO exercising HTTP response middleware modes\n' - if ! response_request headers-only >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fiq 'x-example-response-mode: headers-only' "$response_output" || - ! grep -Fiq 'content-length: 12' "$response_output" || - ! grep -Fq 'headers-only' "$response_output"; then - fail "headers-only response middleware" - fi - if ! response_request whole-body >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fq '[whole] whole body' "$response_output"; then - fail "whole-body response middleware" - fi - if ! response_request stream >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fq 'STREAM BODY' "$response_output" || - ! grep -Fiq 'x-example-body-bytes: 11' "$response_output"; then - fail "stream response middleware with trailer mutation" - fi - if ! response_request stream-close >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fq 'DATA: STREAM CLOSE' "$response_output"; then - fail "close-delimited SSE response middleware" - fi - if ! response_request block >"$response_output" 2>>"$SETUP_LOG" || - ! grep -Fq 'HTTP/1.1 403 Forbidden' "$response_output" || - ! grep -Fq 'middleware_denied' "$response_output" || - ! grep -Fq 'content_match' "$response_output"; then - fail "response middleware block" - fi - printf 'PASS HTTP response middleware modes\n' - "${CLI[@]}" sandbox delete "$SANDBOX_NAME" >>"$SETUP_LOG" 2>&1 - SANDBOX_CREATED=0 - echo "ALL PASS protocol demo smoke" -} - -print_ready() { - cat </dev/null; then - fail "gateway process exited" - fi - if ! kill -0 "$MIDDLEWARE_PID" 2>/dev/null; then - fail "protocol demo process exited" - fi - sleep 1 - done -} - -cd "$ROOT" -require_command cargo -require_command curl -require_command jq -require_command openssl -require_command uv -ROOT_TARGET_DIR="$(cargo_target_dir "$ROOT/Cargo.toml")" -EXAMPLE_TARGET_DIR="$(cargo_target_dir "$EXAMPLE_DIR/Cargo.toml")" -GATEWAY_BIN="$ROOT_TARGET_DIR/debug/openshell-gateway" -CLI_BIN="$ROOT_TARGET_DIR/debug/openshell" -MIDDLEWARE_BIN="$EXAMPLE_TARGET_DIR/debug/supervisor-middleware-protocol" -run_setup_step "building gateway" cargo build --quiet -p openshell-gateway --bin openshell-gateway -run_setup_step "building sandbox supervisor" cargo build --quiet -p openshell-sandbox --bin openshell-sandbox -run_setup_step "building protocol demo" cargo build --quiet --manifest-path "$EXAMPLE_DIR/Cargo.toml" -run_setup_step "building CLI" cargo build --quiet -p openshell-cli --bin openshell -generate_gateway_jwt_bundle -start_upstream -wait_for_upstream -start_middleware -wait_for_middleware -start_gateway -wait_for_gateway -create_sandbox - -if [[ "$RUN_TEST_SUITE" -eq 1 ]]; then - run_suite -else - print_ready - wait_until_stopped -fi diff --git a/examples/supervisor-middleware-protocol/src/main.rs b/examples/supervisor-middleware-protocol/src/main.rs deleted file mode 100644 index d1c153eaa2..0000000000 --- a/examples/supervisor-middleware-protocol/src/main.rs +++ /dev/null @@ -1,173 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::net::SocketAddr; - -use clap::Parser; -use openshell_core::middleware::{HttpResponseResultStream, WebSocketResponseStream}; -use openshell_core::proto::middleware::v1::http_response_pre_return_server::{ - HttpResponsePreReturn, HttpResponsePreReturnServer, -}; -use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ - SupervisorMiddleware, SupervisorMiddlewareServer, -}; -use openshell_core::proto::{ - Decision, ExistingHeaderAction, HeaderMutation, HttpRequestEvaluation, HttpRequestResult, - HttpResponseBlockDelivery, HttpResponseBodyMode, HttpResponseBodyResult, - HttpResponseBodyTransform, HttpResponseEvent, HttpResponseEventResult, - HttpResponsePreflightInspect, HttpResponsePreflightResult, HttpResponsePreflightSkip, - HttpResponseTrailersResult, MiddlewareBinding, MiddlewareManifest, - SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, - ValidateConfigResponse, WebSocketMessage, WebSocketMessageResult, WebSocketPreflightAction, - WebSocketPreflightDecision, WebSocketSessionEvent, WebSocketSessionEventResult, WriteHeader, - header_mutation, http_response_body_result, http_response_body_transform, - http_response_body_unit, http_response_event, http_response_event_result, - http_response_preflight_result, web_socket_message, web_socket_message_result, - web_socket_session_event, web_socket_session_event_result, -}; -use tokio::sync::mpsc; -use tokio_stream::wrappers::ReceiverStream; -use tokio_stream::{Stream, StreamExt}; -use tonic::transport::Server; -use tonic::{Request, Response, Status}; - -const MANIFEST_NAME: &str = "example/protocol-service"; -const PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials; -const MAX_PAYLOAD_BYTES: u64 = 256 * 1024; -mod request; -mod response; -mod websocket; - -#[derive(Debug, Parser)] -#[command(about = "Run the example OpenShell supervisor middleware service")] -struct Cli { - /// Address on which to serve plaintext gRPC. - #[arg(long, default_value = "127.0.0.1:50051")] - bind: SocketAddr, -} - -#[derive(Debug, Default)] -struct ProtocolDemo; - -#[tonic::async_trait] -impl SupervisorMiddleware for ProtocolDemo { - type EvaluateWebSocketSessionStream = WebSocketResponseStream; - - async fn describe( - &self, - _request: Request<()>, - ) -> Result, Status> { - Ok(Response::new(MiddlewareManifest { - name: MANIFEST_NAME.into(), - service_version: env!("CARGO_PKG_VERSION").into(), - bindings: vec![ - MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: PHASE as i32, - max_payload_bytes: MAX_PAYLOAD_BYTES, - timeout: String::new(), - }, - MiddlewareBinding { - operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, - phase: PHASE as i32, - max_payload_bytes: MAX_PAYLOAD_BYTES, - timeout: String::new(), - }, - MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpResponse as i32, - phase: SupervisorMiddlewarePhase::PreReturn as i32, - max_payload_bytes: MAX_PAYLOAD_BYTES, - timeout: String::new(), - }, - ], - expected_audience: String::new(), - })) - } - - async fn validate_config( - &self, - request: Request, - ) -> Result, Status> { - let valid = request - .get_ref() - .config - .as_ref() - .is_none_or(|config| config.fields.is_empty()); - Ok(Response::new(ValidateConfigResponse { - valid, - reason: if valid { - String::new() - } else { - "protocol demo takes no configuration".into() - }, - })) - } - - async fn evaluate_http_request( - &self, - request: Request, - ) -> Result, Status> { - request::evaluate(request.into_inner()).map(Response::new) - } - - async fn evaluate_web_socket_session( - &self, - request: Request>, - ) -> Result, Status> { - Ok(Response::new(websocket::stream(request.into_inner()))) - } -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - let cli = Cli::parse(); - println!("serving {MANIFEST_NAME} on http://{}", cli.bind); - Server::builder() - .add_service(SupervisorMiddlewareServer::new(ProtocolDemo)) - .add_service(HttpResponsePreReturnServer::new(ProtocolDemo)) - .serve(cli.bind) - .await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn example_policy_is_valid() { - let policy = openshell_policy::parse_sandbox_policy(include_str!("../policy.yaml")) - .expect("example policy parses"); - openshell_policy::validate_sandbox_policy(&policy).expect("example policy is valid"); - } - - #[tokio::test] - async fn manifest_advertises_all_three_v1_hooks() { - let manifest = SupervisorMiddleware::describe(&ProtocolDemo, Request::new(())) - .await - .unwrap() - .into_inner(); - let bindings: Vec<_> = manifest - .bindings - .iter() - .map(|binding| (binding.operation, binding.phase)) - .collect(); - assert_eq!( - bindings, - vec![ - ( - SupervisorMiddlewareOperation::HttpRequest as i32, - PHASE as i32 - ), - ( - SupervisorMiddlewareOperation::WebsocketMessage as i32, - PHASE as i32 - ), - ( - SupervisorMiddlewareOperation::HttpResponse as i32, - SupervisorMiddlewarePhase::PreReturn as i32 - ), - ] - ); - } -} diff --git a/examples/supervisor-middleware-protocol/src/request.rs b/examples/supervisor-middleware-protocol/src/request.rs deleted file mode 100644 index 0ee19b3945..0000000000 --- a/examples/supervisor-middleware-protocol/src/request.rs +++ /dev/null @@ -1,49 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use super::*; - -pub(crate) fn evaluate(request: HttpRequestEvaluation) -> Result { - if request.phase != PHASE as i32 { - return Err(Status::invalid_argument("expected PRE_CREDENTIALS")); - } - // Only this route demonstrates request replacement. Response routes pass through. - let selected = request - .target - .as_ref() - .is_some_and(|target| target.path == "/request"); - Ok(HttpRequestResult { - decision: Decision::Allow as i32, - body: if selected { - request.body.to_ascii_uppercase() - } else { - Vec::new() - }, - has_body: selected, - ..Default::default() - }) -} - -#[cfg(test)] -mod tests { - use super::*; - #[test] - fn replaces_only_the_selected_request() { - for path in ["/request", "/whole-body"] { - let result = evaluate(HttpRequestEvaluation { - phase: PHASE as i32, - target: Some(openshell_core::proto::HttpRequestTarget { - path: path.into(), - ..Default::default() - }), - body: b"request body".to_vec(), - ..Default::default() - }) - .unwrap(); - assert_eq!(result.has_body, path == "/request"); - if result.has_body { - assert_eq!(result.body, b"REQUEST BODY"); - } - } - } -} diff --git a/examples/supervisor-middleware-protocol/src/response.rs b/examples/supervisor-middleware-protocol/src/response.rs deleted file mode 100644 index e4773f22ba..0000000000 --- a/examples/supervisor-middleware-protocol/src/response.rs +++ /dev/null @@ -1,345 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use super::*; - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum ResponseMode { - HeadersOnly, - WholeBody, - Stream, - StreamClose, - Block, -} - -#[derive(Debug, Default)] -struct ResponseSessionState { - selected: Option, - next_sequence: u64, - body_ended: bool, -} - -impl ResponseSessionState { - fn preflight( - &mut self, - preflight: openshell_core::proto::HttpResponsePreflight, - ) -> Result { - if self.selected.is_some() { - return Err(Status::failed_precondition("duplicate response preflight")); - } - let path = preflight - .target - .as_ref() - .map(|target| target.path.as_str()) - .unwrap_or_default(); - let Some(selected) = response_mode_for_path(path) else { - return Ok(HttpResponseEventResult { - result: Some(http_response_event_result::Result::PreflightResult( - HttpResponsePreflightResult { - action: Some(http_response_preflight_result::Action::Skip( - HttpResponsePreflightSkip {}, - )), - reason_code: "path_not_selected".into(), - ..Default::default() - }, - )), - }); - }; - self.selected = Some(selected); - self.next_sequence = 1; - let body_mode = match selected { - ResponseMode::HeadersOnly => HttpResponseBodyMode::HeadersOnly, - ResponseMode::WholeBody | ResponseMode::Block => HttpResponseBodyMode::WholeBodyBytes, - ResponseMode::Stream | ResponseMode::StreamClose => HttpResponseBodyMode::StreamBytes, - }; - if !preflight.permitted_body_modes.contains(&(body_mode as i32)) { - return Err(Status::failed_precondition( - "selected demo body mode is unavailable", - )); - } - Ok(HttpResponseEventResult { - result: Some(http_response_event_result::Result::PreflightResult( - HttpResponsePreflightResult { - action: Some(http_response_preflight_result::Action::Inspect( - HttpResponsePreflightInspect { - body_mode: body_mode as i32, - header_mutations: vec![write_header( - "x-example-response-mode", - match selected { - ResponseMode::HeadersOnly => "headers-only", - ResponseMode::WholeBody => "whole-body", - ResponseMode::Stream => "stream", - ResponseMode::StreamClose => "stream-close", - ResponseMode::Block => "block", - }, - )], - }, - )), - ..Default::default() - }, - )), - }) - } - - fn body( - &mut self, - body: openshell_core::proto::HttpResponseBodyUnit, - ) -> Result { - let selected = self - .selected - .ok_or_else(|| Status::failed_precondition("body arrived before preflight"))?; - if selected == ResponseMode::HeadersOnly || self.body_ended { - return Err(Status::failed_precondition( - "body event is invalid for the response session state", - )); - } - if body.sequence != self.next_sequence { - return Err(Status::invalid_argument( - "unexpected response body sequence", - )); - } - self.next_sequence = self.next_sequence.saturating_add(1); - self.body_ended = body.end_of_stream; - let Some(http_response_body_unit::Payload::Data(data)) = body.payload else { - return Err(Status::invalid_argument("body data is required")); - }; - let (action, reason_code) = match selected { - ResponseMode::WholeBody => ( - http_response_body_result::Action::Transform(HttpResponseBodyTransform { - replacement: Some(http_response_body_transform::Replacement::Data( - [b"[whole] ".as_slice(), &data].concat(), - )), - }), - String::new(), - ), - ResponseMode::Stream | ResponseMode::StreamClose => ( - http_response_body_result::Action::Transform(HttpResponseBodyTransform { - replacement: Some(http_response_body_transform::Replacement::Data( - data.to_ascii_uppercase(), - )), - }), - String::new(), - ), - ResponseMode::Block => ( - http_response_body_result::Action::BlockDelivery(HttpResponseBlockDelivery {}), - "content_match".into(), - ), - ResponseMode::HeadersOnly => unreachable!(), - }; - Ok(HttpResponseEventResult { - result: Some(http_response_event_result::Result::BodyResult( - HttpResponseBodyResult { - sequence: body.sequence, - action: Some(action), - reason_code, - ..Default::default() - }, - )), - }) - } - - fn trailers(&self) -> Result { - if !self.body_ended { - return Err(Status::failed_precondition( - "trailers arrived before the final body result", - )); - } - let trailer_mutations = if self.selected == Some(ResponseMode::Stream) { - vec![write_header("x-example-body-bytes", "11")] - } else { - Vec::new() - }; - Ok(HttpResponseEventResult { - result: Some(http_response_event_result::Result::TrailersResult( - HttpResponseTrailersResult { - trailer_mutations, - ..Default::default() - }, - )), - }) - } -} - -fn response_mode_for_path(path: &str) -> Option { - match path { - "/headers-only" => Some(ResponseMode::HeadersOnly), - "/whole-body" => Some(ResponseMode::WholeBody), - "/stream" => Some(ResponseMode::Stream), - "/stream-close" => Some(ResponseMode::StreamClose), - "/block" => Some(ResponseMode::Block), - _ => None, - } -} - -fn write_header(name: &str, value: &str) -> HeaderMutation { - HeaderMutation { - operation: Some(header_mutation::Operation::Write(WriteHeader { - name: name.into(), - value: value.into(), - on_existing: ExistingHeaderAction::Overwrite as i32, - })), - } -} - -#[tonic::async_trait] -impl HttpResponsePreReturn for ProtocolDemo { - type EvaluateStream = HttpResponseResultStream; - - async fn evaluate( - &self, - request: Request>, - ) -> Result, Status> { - let mut events = request.into_inner(); - let (sender, receiver) = mpsc::channel(4); - tokio::spawn(async move { - let mut state = ResponseSessionState::default(); - while let Some(event) = events.next().await { - let result = match event { - Ok(event) => match event.event { - Some(http_response_event::Event::Preflight(preflight)) => { - state.preflight(preflight) - } - Some(http_response_event::Event::Body(body)) => state.body(body), - Some(http_response_event::Event::Trailers(_)) => state.trailers(), - Some(http_response_event::Event::SessionEnd(_)) => break, - None => Err(Status::invalid_argument("response event is required")), - }, - Err(error) => Err(error), - }; - match result { - Ok(result) => { - if sender.send(Ok(result)).await.is_err() { - break; - } - } - Err(error) => { - let _ = sender.send(Err(error)).await; - break; - } - } - } - }); - Ok(Response::new(Box::pin(ReceiverStream::new(receiver)))) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use openshell_core::proto::{HttpRequestTarget, HttpResponseBodyUnit, HttpResponsePreflight}; - fn response_preflight(path: &str) -> HttpResponsePreflight { - HttpResponsePreflight { - target: Some(HttpRequestTarget { - path: path.into(), - ..Default::default() - }), - permitted_body_modes: vec![ - HttpResponseBodyMode::HeadersOnly as i32, - HttpResponseBodyMode::WholeBodyBytes as i32, - HttpResponseBodyMode::StreamBytes as i32, - ], - ..Default::default() - } - } - - #[test] - fn response_paths_select_all_modes() { - for (path, expected) in [ - ("/headers-only", HttpResponseBodyMode::HeadersOnly), - ("/whole-body", HttpResponseBodyMode::WholeBodyBytes), - ("/stream", HttpResponseBodyMode::StreamBytes), - ("/stream-close", HttpResponseBodyMode::StreamBytes), - ("/block", HttpResponseBodyMode::WholeBodyBytes), - ] { - let mut state = ResponseSessionState::default(); - let result = state.preflight(response_preflight(path)).unwrap(); - let Some(http_response_event_result::Result::PreflightResult(result)) = result.result - else { - panic!("expected preflight result"); - }; - let Some(http_response_preflight_result::Action::Inspect(inspect)) = result.action - else { - panic!("expected inspect action"); - }; - assert_eq!(inspect.body_mode, expected as i32); - } - } - - #[test] - fn response_whole_body_transforms_and_block_is_typed() { - let mut whole = ResponseSessionState::default(); - whole.preflight(response_preflight("/whole-body")).unwrap(); - let result = whole - .body(HttpResponseBodyUnit { - sequence: 1, - payload: Some(http_response_body_unit::Payload::Data(b"body".to_vec())), - end_of_stream: true, - }) - .unwrap(); - let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { - panic!("expected body result"); - }; - let Some(http_response_body_result::Action::Transform(transform)) = body.action else { - panic!("expected body transform"); - }; - let Some(http_response_body_transform::Replacement::Data(data)) = transform.replacement - else { - panic!("expected data replacement"); - }; - assert_eq!(data, b"[whole] body"); - - let mut block = ResponseSessionState::default(); - block.preflight(response_preflight("/block")).unwrap(); - let result = block - .body(HttpResponseBodyUnit { - sequence: 1, - payload: Some(http_response_body_unit::Payload::Data( - b"prototype-secret".to_vec(), - )), - end_of_stream: true, - }) - .unwrap(); - let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { - panic!("expected body result"); - }; - assert!(matches!( - body.action, - Some(http_response_body_result::Action::BlockDelivery(_)) - )); - assert_eq!(body.reason_code, "content_match"); - } - - #[test] - fn response_stream_returns_the_required_trailer_exchange() { - let mut state = ResponseSessionState::default(); - state.preflight(response_preflight("/stream")).unwrap(); - state - .body(HttpResponseBodyUnit { - sequence: 1, - payload: Some(http_response_body_unit::Payload::Data(b"body".to_vec())), - end_of_stream: true, - }) - .unwrap(); - let result = state.trailers().unwrap(); - let Some(http_response_event_result::Result::TrailersResult(trailers)) = result.result - else { - panic!("expected trailers result"); - }; - assert_eq!(trailers.trailer_mutations.len(), 1); - } - - #[test] - fn response_paths_outside_the_example_are_skipped() { - let mut state = ResponseSessionState::default(); - let result = state.preflight(response_preflight("/outside")).unwrap(); - let Some(http_response_event_result::Result::PreflightResult(result)) = result.result - else { - panic!("expected preflight result"); - }; - assert!(matches!( - result.action, - Some(http_response_preflight_result::Action::Skip(_)) - )); - assert_eq!(result.reason_code, "path_not_selected"); - } -} diff --git a/examples/supervisor-middleware-protocol/src/websocket.rs b/examples/supervisor-middleware-protocol/src/websocket.rs deleted file mode 100644 index 6ce5af1718..0000000000 --- a/examples/supervisor-middleware-protocol/src/websocket.rs +++ /dev/null @@ -1,163 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use super::*; - -pub(crate) fn stream(mut events: S) -> WebSocketResponseStream -where - S: Stream> + Send + Unpin + 'static, -{ - let (results_tx, results_rx) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - let mut config = None; - let mut started = false; - let mut sequence_lower_bound = Some(1_u64); - - while let Some(event) = events.next().await { - let event = match event { - Ok(event) => event, - Err(error) => { - let _ = results_tx.send(Err(error)).await; - break; - } - }; - let result = match event.event { - Some(web_socket_session_event::Event::Preflight(preflight)) - if config.is_none() && !started => - { - if preflight.phase != PHASE as i32 { - Err(Status::invalid_argument("expected PRE_CREDENTIALS")) - } else { - config = Some(()); - Ok(Some(WebSocketSessionEventResult { - result: Some( - web_socket_session_event_result::Result::PreflightDecision( - WebSocketPreflightDecision { - action: WebSocketPreflightAction::Inspect as i32, - ..Default::default() - }, - ), - ), - })) - } - } - Some(web_socket_session_event::Event::SessionStart(_)) - if config.is_some() && !started => - { - started = true; - Ok(None) - } - Some(web_socket_session_event::Event::Message(message)) if started => { - if let Err(error) = - advance_sequence_lower_bound(&mut sequence_lower_bound, message.sequence) - { - Err(error) - } else { - evaluate_message(&message).map(|result| { - Some(WebSocketSessionEventResult { - result: Some( - web_socket_session_event_result::Result::MessageResult(result), - ), - }) - }) - } - } - Some(web_socket_session_event::Event::SessionEnd(_)) if config.is_some() => { - break; - } - _ => Err(Status::failed_precondition( - "invalid protocol demo WebSocket session lifecycle", - )), - }; - - match result { - Ok(Some(result)) => { - if results_tx.send(Ok(result)).await.is_err() { - break; - } - } - Ok(None) => {} - Err(error) => { - let _ = results_tx.send(Err(error)).await; - break; - } - } - } - }); - Box::pin(tokio_stream::wrappers::ReceiverStream::new(results_rx)) -} - -fn evaluate_message(message: &WebSocketMessage) -> Result { - let Some(web_socket_message::Payload::Text(text)) = message.payload.as_ref() else { - return Err(Status::invalid_argument("expected text message")); - }; - Ok(WebSocketMessageResult { - sequence: message.sequence, - decision: Decision::Allow as i32, - replacement: Some(web_socket_message_result::Replacement::Text( - text.to_ascii_uppercase(), - )), - ..Default::default() - }) -} - -fn advance_sequence_lower_bound( - lower_bound: &mut Option, - sequence: u64, -) -> Result<(), Status> { - let Some(current_lower_bound) = *lower_bound else { - return Err(Status::invalid_argument( - "WebSocket message sequence must be strictly increasing", - )); - }; - if sequence < current_lower_bound { - return Err(Status::invalid_argument( - "WebSocket message sequence must be strictly increasing", - )); - } - *lower_bound = sequence.checked_add(1); - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - #[tokio::test] - async fn lifecycle_transforms_a_complete_message() { - tokio::time::timeout(std::time::Duration::from_secs(5), check_lifecycle()) - .await - .expect("WebSocket lifecycle completes"); - } - - async fn check_lifecycle() { - use openshell_core::proto::{ - MiddlewareSessionEnd, WebSocketPreflight, WebSocketSessionStart, - }; - let events = [ - web_socket_session_event::Event::Preflight(WebSocketPreflight { - phase: PHASE as i32, - ..Default::default() - }), - web_socket_session_event::Event::SessionStart(WebSocketSessionStart::default()), - web_socket_session_event::Event::Message(WebSocketMessage { - sequence: 1, - payload: Some(web_socket_message::Payload::Text("hello".into())), - }), - web_socket_session_event::Event::SessionEnd(MiddlewareSessionEnd::default()), - ]; - let mut results = stream(tokio_stream::iter( - events.map(|event| Ok(WebSocketSessionEvent { event: Some(event) })), - )); - assert!(results.next().await.unwrap().is_ok()); - let result = results.next().await.unwrap().unwrap(); - let Some(web_socket_session_event_result::Result::MessageResult(message)) = result.result - else { - panic!("message result") - }; - assert_eq!( - message.replacement, - Some(web_socket_message_result::Replacement::Text("HELLO".into())) - ); - assert!(results.next().await.is_none()); - } -} diff --git a/examples/supervisor-middleware-protocol/upstream.py b/examples/supervisor-middleware-protocol/upstream.py deleted file mode 100644 index 084b3d2896..0000000000 --- a/examples/supervisor-middleware-protocol/upstream.py +++ /dev/null @@ -1,114 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import base64 -import hashlib -import socket -import socketserver -import struct - - -class Handler(socketserver.BaseRequestHandler): - def read_exact(self, count): - data = b"" - while len(data) < count: - block = self.request.recv(count - len(data)) - if not block: - raise ConnectionError("unexpected EOF") - data += block - return data - - def handle(self): - self.request.settimeout(10) - self.request.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - request = b"" - while b"\r\n\r\n" not in request: - block = self.request.recv(4096) - if not block: - return - request += block - path = request.split(b" ", 2)[1] - if path == b"/request": - head, body = request.split(b"\r\n\r\n", 1) - length = next(int(line.split(b":", 1)[1]) for line in head.split(b"\r\n") if line.lower().startswith(b"content-length:")) - while len(body) < length: - block = self.request.recv(length - len(body)) - if not block: - return - body += block - response = b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: " + str(length).encode() + b"\r\n\r\n" + body[:length] - elif path == b"/ws": - key = next(line.split(b":", 1)[1].strip() for line in request.split(b"\r\n") if line.lower().startswith(b"sec-websocket-key:")) - accept = base64.b64encode(hashlib.sha1(key + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest()) - self.request.sendall(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: " + accept + b"\r\n\r\n") - header = self.read_exact(2) - length = header[1] & 127 - if length == 126: - length = struct.unpack("!H", self.read_exact(2))[0] - elif length == 127: - length = struct.unpack("!Q", self.read_exact(8))[0] - if length > 262144 or header[0] != 0x81 or not header[1] & 0x80: - return - mask = self.read_exact(4) - payload = self.read_exact(length) - body = bytes(value ^ mask[index % 4] for index, value in enumerate(payload)) - if len(body) < 126: - framing = bytes([0x81, len(body)]) - elif len(body) < 65536: - framing = b"\x81\x7e" + struct.pack("!H", len(body)) - else: - framing = b"\x81\x7f" + struct.pack("!Q", len(body)) - self.request.sendall(framing + body) - return - elif path == b"/headers-only": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/plain\r\n" - b"Content-Length: 12\r\n\r\n" - b"headers-only" - ) - elif path == b"/whole-body": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/plain\r\n" - b"Transfer-Encoding: chunked\r\n\r\n" - b"6\r\nwhole \r\n4\r\nbody\r\n0\r\n\r\n" - ) - elif path == b"/stream": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/plain\r\n" - b"Trailer: x-example-body-bytes\r\n" - b"Transfer-Encoding: chunked\r\n\r\n" - b"6\r\nstream\r\n5\r\n body\r\n" - b"0\r\nX-Example-Body-Bytes: 0\r\n\r\n" - ) - elif path == b"/stream-close": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/event-stream\r\n" - b"Connection: close\r\n\r\n" - b"data: stream close\n\n" - ) - elif path == b"/block": - response = ( - b"HTTP/1.1 200 OK\r\n" - b"Content-Type: text/plain\r\n" - b"Content-Length: 16\r\n\r\n" - b"prototype-secret" - ) - else: - response = b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\n\r\n" - self.request.sendall(response) - - -class DemoServer(socketserver.ThreadingTCPServer): - allow_reuse_address = True - - -with DemoServer(("0.0.0.0", 18081), Handler) as server: - print("response framing demo upstream listening on 0.0.0.0:18081", flush=True) - try: - server.serve_forever() - except KeyboardInterrupt: - pass diff --git a/examples/supervisor-middleware-protocol/websocket-client.py b/examples/supervisor-middleware-protocol/websocket-client.py deleted file mode 100644 index 260f5a0f1e..0000000000 --- a/examples/supervisor-middleware-protocol/websocket-client.py +++ /dev/null @@ -1,58 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""One text-message exchange for the protocol smoke test.""" - -import base64 -import hashlib -import os -import socket -from urllib.parse import urlsplit - - -def read_exact(stream, count): - data = b"" - while len(data) < count: - block = stream.recv(count - len(data)) - if not block: - raise ConnectionError("unexpected EOF") - data += block - return data - - -proxy_url = os.environ.get("HTTP_PROXY") or os.environ.get("http_proxy") -if not proxy_url: - raise RuntimeError("run this client inside the sandbox with HTTP_PROXY configured") -proxy = urlsplit(proxy_url) -if proxy.scheme != "http" or not proxy.hostname: - raise RuntimeError("expected an HTTP proxy endpoint") - -with socket.create_connection((proxy.hostname, proxy.port or 80), timeout=20) as stream: - stream.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) - stream.sendall( - b"CONNECT host.openshell.internal:18081 HTTP/1.1\r\n" - b"Host: host.openshell.internal:18081\r\n\r\n" - ) - tunnel = b"" - while not tunnel.endswith(b"\r\n\r\n"): - tunnel += read_exact(stream, 1) - assert tunnel.startswith((b"HTTP/1.1 200 ", b"HTTP/1.0 200 ")), tunnel - key = base64.b64encode(os.urandom(16)) - stream.sendall( - b"GET /ws HTTP/1.1\r\nHost: host.openshell.internal:18081\r\n" - b"Upgrade: websocket\r\nConnection: Upgrade\r\n" - b"Sec-WebSocket-Version: 13\r\nSec-WebSocket-Key: " + key + b"\r\n\r\n" - ) - head = b"" - while not head.endswith(b"\r\n\r\n"): - head += read_exact(stream, 1) - assert head.startswith(b"HTTP/1.1 101 ") - expected = base64.b64encode(hashlib.sha1(key + b"258EAFA5-E914-47DA-95CA-C5AB0DC85B11").digest()) - assert expected in head - payload = b"hello protocol" - mask = os.urandom(4) - stream.sendall(bytes([0x81, 0x80 | len(payload)]) + mask + bytes(value ^ mask[index % 4] for index, value in enumerate(payload))) - header = read_exact(stream, 2) - assert header == bytes([0x81, len(payload)]), header - print(read_exact(stream, len(payload)).decode()) - stream.sendall(b"\x88\x80" + os.urandom(4)) diff --git a/tasks/rust.toml b/tasks/rust.toml index 302eb5ab8b..e62e22b3cf 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -16,7 +16,6 @@ run = [ "cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings", "cargo check --manifest-path examples/governance-interceptor/Cargo.toml --all-targets", "cargo check --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets", - "cargo check --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all-targets", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -28,7 +27,6 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all", - "cargo fmt --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all", ] hide = true @@ -39,7 +37,6 @@ run = [ "cargo fmt --manifest-path e2e/rust/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/governance-interceptor/Cargo.toml --all -- --check", "cargo fmt --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all -- --check", - "cargo fmt --manifest-path examples/supervisor-middleware-protocol/Cargo.toml --all -- --check", ] hide = true diff --git a/tasks/test.toml b/tasks/test.toml index db58b1cb66..381ee7d6c0 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -76,7 +76,6 @@ run = [ "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", "cargo test --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", - "cargo test --manifest-path examples/supervisor-middleware-protocol/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true From 555ff32896a225020b670902a601707624bbadc5 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 9 Sep 2026 10:00:53 -0700 Subject: [PATCH 06/13] refactor(middleware): keep response body timeout local Signed-off-by: Piotr Mlocek --- architecture/gateway.md | 5 -- architecture/sandbox.md | 5 +- crates/openshell-core/src/grpc_client.rs | 16 ---- crates/openshell-core/src/lib.rs | 3 - crates/openshell-sandbox/src/lib.rs | 12 --- crates/openshell-server/src/config_file.rs | 73 ------------------- crates/openshell-server/src/grpc/policy.rs | 1 - crates/openshell-server/src/lib.rs | 13 ---- .../src/l7/relay.rs | 2 +- .../src/l7/rest.rs | 6 +- .../openshell-supervisor-network/src/lib.rs | 18 ----- docs/reference/gateway-config.mdx | 10 +-- proto/sandbox.proto | 4 - sdk/go/proto/sandboxv1/sandbox.pb.go | 16 +--- skills/debug-openshell-cluster/SKILL.md | 8 +- skills/generate-sandbox-policy/SKILL.md | 2 +- skills/openshell-cli/SKILL.md | 2 +- 17 files changed, 18 insertions(+), 178 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index df340f6cc0..769f57f6a0 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -509,11 +509,6 @@ 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. -The effective sandbox config also carries the supervisor-wide HTTP response -whole-body timeout. The gateway reads this static value from -`[openshell.supervisor]`, defaults it to 120 seconds, and distributes it as -milliseconds. A zero value from an older gateway maps to the same default. - 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 8336bd4775..cf526b1d26 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -195,8 +195,9 @@ returns to the workload. Response stages select header-only, whole-body, or streaming mode independently. The relay preserves upstream framing for a header-only chain and owns normalized downstream framing only when body bytes can change. Whole-body stages delay commitment and share one non-resetting, -supervisor-wide accumulation deadline. Body stages receive a final body result -and then one trailer exchange; trailer mutations can only change or remove +120-second accumulation deadline per response, defined in the response relay. +Body stages receive a final body result and then one trailer exchange; +trailer mutations can only change or remove existing, non-protected names. Intentional blocks return the canonical 403 before commitment and abort delivery without injected bytes after commitment. Streaming input units flush after bounded coalescing even within a content-length body or transfer chunk. Coalescing cancels only input acquisition; diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 4da38ae1ee..54f0db6902 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -943,8 +943,6 @@ pub struct SettingsPollResult { pub policy_validation_failure_mode: crate::PolicyValidationFailureMode, /// Whether the gateway can mint authenticated extension credentials. pub extension_authentication_enabled: bool, - /// Supervisor-wide response whole-body accumulation timeout. - pub http_response_whole_body_timeout_ms: u64, } fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> SettingsPollResult { @@ -965,11 +963,6 @@ fn settings_poll_result(inner: crate::proto::GetSandboxConfigResponse) -> Settin .parse() .unwrap_or_default(), extension_authentication_enabled: inner.extension_authentication_enabled, - http_response_whole_body_timeout_ms: if inner.http_response_whole_body_timeout_ms == 0 { - crate::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS - } else { - inner.http_response_whole_body_timeout_ms - }, } } @@ -991,15 +984,6 @@ mod settings_poll_tests { ); } - #[test] - fn zero_whole_body_timeout_uses_compatibility_default() { - let result = settings_poll_result(GetSandboxConfigResponse::default()); - assert_eq!( - result.http_response_whole_body_timeout_ms, - crate::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS - ); - } - #[test] fn unknown_validation_failure_mode_fails_closed() { let result = settings_poll_result(GetSandboxConfigResponse { diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index f38ce8d5b2..7acb72dd6f 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -74,9 +74,6 @@ pub const VERSION: &str = match option_env!("OPENSHELL_GIT_VERSION") { None => env!("CARGO_PKG_VERSION"), }; -/// Default wall-clock bound for HTTP response whole-body middleware buffering. -pub const DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS: u64 = 120_000; - #[cfg(test)] #[path = "../build_version.rs"] mod build_version; diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index f92b5ba5fd..7afae200b5 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -2380,9 +2380,6 @@ async fn load_policy( openshell_core::grpc_client::fetch_settings_snapshot(endpoint, id) }) .await?; - openshell_supervisor_network::set_http_response_whole_body_timeout(Duration::from_millis( - snapshot.http_response_whole_body_timeout_ms, - )); let mut proto_policy = if let Some(p) = snapshot.policy.clone() { p @@ -3784,9 +3781,6 @@ async fn run_policy_poll_loop_with_client( // reconciled below instead of being recorded as already applied. match client.poll_settings(&ctx.sandbox_id).await { Ok(result) => { - openshell_supervisor_network::set_http_response_whole_body_timeout( - Duration::from_millis(result.http_response_whole_body_timeout_ms), - ); let _ = ctx.workspace_tx.send(client.workspace()); match initial_poll_disposition(&ctx.loaded_policy_origin, &result) { InitialPollDisposition::Acknowledge(candidate) => { @@ -3873,10 +3867,6 @@ async fn run_policy_poll_loop_with_client( } }; - openshell_supervisor_network::set_http_response_whole_body_timeout(Duration::from_millis( - result.http_response_whole_body_timeout_ms, - )); - // Reuse installed per-service credentials, rotating only when one is // missing or due. Rotation happens on the existing gateway channel and // updates slots in place, so it is independent of config revision and @@ -4973,8 +4963,6 @@ network_policies: workspace: String::new(), policy_validation_failure_mode: PolicyValidationFailureMode::default(), extension_authentication_enabled: false, - http_response_whole_body_timeout_ms: - openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS, } } diff --git a/crates/openshell-server/src/config_file.rs b/crates/openshell-server/src/config_file.rs index 6bfe0b7ae5..64ac953624 100644 --- a/crates/openshell-server/src/config_file.rs +++ b/crates/openshell-server/src/config_file.rs @@ -214,40 +214,12 @@ pub struct OtlpConfig { #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SupervisorFileSection { - /// Wall-clock limit for accumulating and processing a response through - /// whole-body middleware. Accepts a positive integer followed by `ms`, - /// `s`, or `m`. - #[serde(default)] - pub http_response_whole_body_timeout: Option, - /// Statically registered supervisor middleware services. Registration is /// operator-owned and changes require a gateway restart. #[serde(default)] pub middleware: Vec, } -impl SupervisorFileSection { - /// Resolve the configured whole-body timeout to milliseconds. - #[must_use] - pub fn http_response_whole_body_timeout_ms(&self) -> u64 { - self.http_response_whole_body_timeout - .as_deref() - .and_then(parse_positive_duration_ms) - .unwrap_or(openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS) - } -} - -fn parse_positive_duration_ms(value: &str) -> Option { - let value = value.trim(); - let (number, multiplier) = value - .strip_suffix("ms") - .map(|number| (number, 1)) - .or_else(|| value.strip_suffix('s').map(|number| (number, 1_000))) - .or_else(|| value.strip_suffix('m').map(|number| (number, 60_000)))?; - let number = number.parse::().ok()?; - (number > 0).then_some(number.checked_mul(multiplier)?) -} - /// One `[[openshell.supervisor.middleware]]` supervisor middleware registration. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -443,18 +415,6 @@ pub fn load(path: &Path) -> Result { message: "omit the field to use default encrypted gateway credential storage, or specify exactly one external credential driver", }); } - if file - .openshell - .supervisor - .http_response_whole_body_timeout - .as_deref() - .is_some_and(|value| parse_positive_duration_ms(value).is_none()) - { - return Err(ConfigFileError::InvalidValue { - field: "openshell.supervisor.http_response_whole_body_timeout", - message: "expected a positive integer duration ending in ms, s, or m", - }); - } Ok(file) } @@ -644,39 +604,6 @@ service_name = "openshell-gateway-dev" assert_eq!(otlp.service_name.as_deref(), Some("openshell-gateway-dev")); } - #[test] - fn parses_http_response_whole_body_timeout() { - let tmp = write_tmp( - r#" -[openshell.supervisor] -http_response_whole_body_timeout = "2m" -"#, - ); - let file = load(tmp.path()).expect("valid supervisor timeout parses"); - assert_eq!( - file.openshell - .supervisor - .http_response_whole_body_timeout_ms(), - 120_000 - ); - } - - #[test] - fn rejects_invalid_http_response_whole_body_timeout() { - for value in ["0s", "120", "later", "18446744073709551615m"] { - let tmp = write_tmp(&format!( - "[openshell.supervisor]\nhttp_response_whole_body_timeout = \"{value}\"\n" - )); - let error = load(tmp.path()).expect_err("invalid timeout must be rejected"); - assert!( - error - .to_string() - .contains("http_response_whole_body_timeout"), - "{error}" - ); - } - } - #[test] fn otlp_config_requires_only_endpoint() { let toml = r#" diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 1f191a7beb..f1f57c2f5b 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -2524,7 +2524,6 @@ pub(super) async fn handle_get_sandbox_config( .as_str() .to_string(), extension_authentication_enabled: state.sandbox_jwt_issuer.is_some(), - http_response_whole_body_timeout_ms: state.http_response_whole_body_timeout_ms, })) } diff --git a/crates/openshell-server/src/lib.rs b/crates/openshell-server/src/lib.rs index 219d4badd8..a8c8afdf08 100644 --- a/crates/openshell-server/src/lib.rs +++ b/crates/openshell-server/src/lib.rs @@ -304,9 +304,6 @@ pub struct ServerState { /// Validated built-in and operator-registered supervisor middleware. pub middleware_registry: Arc, - /// Supervisor-wide response whole-body accumulation timeout. - pub http_response_whole_body_timeout_ms: u64, - /// OIDC JWKS cache for JWT validation. `None` when OIDC is not configured. pub oidc_cache: Option>, @@ -422,8 +419,6 @@ impl ServerState { gateway_shutting_down: AtomicBool::new(false), extension_mint_limiter: auth::extension_mint_limit::ExtensionMintLimiter::default(), middleware_registry: Arc::new(MiddlewareRegistry::default()), - http_response_whole_body_timeout_ms: - openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS, oidc_cache, sandbox_jwt_issuer: None, sandbox_jwt_authenticator: None, @@ -663,14 +658,6 @@ pub(crate) async fn run_server( oidc_cache, credentials, ); - state.http_response_whole_body_timeout_ms = config_file.as_ref().map_or( - openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS, - |file| { - file.openshell - .supervisor - .http_response_whole_body_timeout_ms() - }, - ); state.middleware_registry = middleware_registry; state.gateway_interceptors = gateway_interceptors; state.provider_profile_sources = provider_profile_sources; diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 2a5de4d6a8..5c193a31c6 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -347,7 +347,7 @@ pub(crate) fn http_response_middleware_relay<'a>( }, policy_name: &ctx.policy_name, generation_guard, - whole_body_timeout: crate::http_response_whole_body_timeout(), + whole_body_timeout: super::rest::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT, } } diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index e918a292cb..6df07cea8e 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -809,6 +809,10 @@ where relay_http_request_with_response_middleware_guarded(req, client, upstream, options, None).await } +/// Default wall-clock bound shared by whole-body stages in one response. +pub(crate) const DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(120); + /// Context retained from request evaluation for the matching response hook. pub(crate) struct HttpResponseMiddlewareRelay<'a> { pub(crate) chain: &'a [openshell_supervisor_middleware::ChainEntry], @@ -7625,7 +7629,7 @@ mod tests { }, policy_name: "test-policy", generation_guard: None, - whole_body_timeout: std::time::Duration::from_secs(120), + whole_body_timeout: DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT, } } diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 592228bc79..4fec48b300 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -8,24 +8,6 @@ //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. -use std::sync::atomic::{AtomicU64, Ordering}; -use std::time::Duration; - -static HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS: AtomicU64 = - AtomicU64::new(openshell_core::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS); - -/// Configure the supervisor-wide wall-clock bound for whole-response buffering. -pub fn set_http_response_whole_body_timeout(timeout: Duration) { - let milliseconds = u64::try_from(timeout.as_millis()) - .unwrap_or(u64::MAX) - .max(1); - HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS.store(milliseconds, Ordering::Relaxed); -} - -pub(crate) fn http_response_whole_body_timeout() -> Duration { - Duration::from_millis(HTTP_RESPONSE_WHOLE_BODY_TIMEOUT_MS.load(Ordering::Relaxed)) -} - pub mod identity; pub mod inference_routes; pub mod l7; diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 878a6665ef..94839065ae 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -131,9 +131,6 @@ provider_profile_sources = [ # Operator-run supervisor middleware. The gRPC endpoint must be reachable from # both the gateway and sandbox supervisors. -[openshell.supervisor] -http_response_whole_body_timeout = "120s" - [[openshell.supervisor.middleware]] name = "local-content-guard" grpc_endpoint = "https://host.openshell.internal:50051" @@ -293,11 +290,6 @@ The gateway flushes buffered spans during shutdown, so spans from in-flight requ Register operator-run supervisor middleware services with one or more `[[openshell.supervisor.middleware]]` entries. Registration is static and operator-owned; changing it requires restarting the gateway. ```toml -[openshell.supervisor] -# One non-resetting wall-clock limit for response accumulation and whole-body -# middleware barriers. Accepts a positive integer followed by ms, s, or m. -http_response_whole_body_timeout = "120s" - [[openshell.supervisor.middleware]] name = "local-content-guard" grpc_endpoint = "https://host.openshell.internal:50051" @@ -317,7 +309,7 @@ The gateway connects to every registered service and validates `Describe` before Response middleware also has an 8 MiB aggregate retained-body limit per response session across stage buffers and pending output. This limit is separate from each service's `max_payload_bytes` and cannot be configured. A replacement that exceeds the available budget follows the stage's `on_error` policy. Fail-open disables that stage and forwards its original input; fail-closed returns 502 before commitment or stops delivery after commitment. Streaming inputs flush after a short coalescing window without waiting for an entire upstream transfer chunk. -`http_response_whole_body_timeout` is a supervisor-wide safety bound, not a middleware RPC timeout. It defaults to `120s` and accepts a positive integer followed by `ms`, `s`, or `m`. The timer starts after ordered response preflight selects a `WHOLE_BODY_BYTES` stage and before OpenShell reads the first response body byte. It does not reset as chunks arrive. It ends after the normalized body reaches end of stream and every whole-body barrier produces output. On expiry, each active whole-body stage follows its configured `on_error` policy: fail-open releases OpenShell-owned bytes through later stages, while fail-closed returns the canonical pre-commit 502 response. Changing this field requires restarting the gateway. +Whole-body response inspection has a fixed 120-second deadline shared by all whole-body stages in one response, separate from middleware RPC timeouts. The timer starts after ordered response preflight selects a `WHOLE_BODY_BYTES` stage and before OpenShell reads the first response body byte. It does not reset as chunks arrive. It ends after the normalized body reaches end of stream and every whole-body barrier produces output. On expiry, each active whole-body stage follows its configured `on_error` policy: fail-open releases OpenShell-owned bytes through later stages, while fail-closed returns the canonical pre-commit 502 response. The service `grpc_endpoint` supports plaintext `http://` and TLS `https://`. HTTPS uses the platform trust store unless `tls_ca_cert_path` names a certificate-only PEM bundle. OpenShell rejects bundles containing private keys, loads the certificates at gateway startup, and distributes only public certificates to sandbox supervisors; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:middleware:`. After authenticated `Describe` succeeds, OpenShell treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest. diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 0fe09899b7..51139ba461 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -394,10 +394,6 @@ message GetSandboxConfigResponse { // False also covers older gateways that do not advertise this capability; // supervisors preserve their legacy unauthenticated connection behavior. bool extension_authentication_enabled = 12; - // Supervisor-wide wall-clock limit for accumulating and processing a response - // through whole-body middleware. Zero means the supervisor default for - // compatibility with older gateways. - uint64 http_response_whole_body_timeout_ms = 13; } // Connection details for one operator-registered supervisor middleware service. diff --git a/sdk/go/proto/sandboxv1/sandbox.pb.go b/sdk/go/proto/sandboxv1/sandbox.pb.go index 9b4fa8a9d7..8da143ebaa 100644 --- a/sdk/go/proto/sandboxv1/sandbox.pb.go +++ b/sdk/go/proto/sandboxv1/sandbox.pb.go @@ -1826,10 +1826,6 @@ type GetSandboxConfigResponse struct { // False also covers older gateways that do not advertise this capability; // supervisors preserve their legacy unauthenticated connection behavior. ExtensionAuthenticationEnabled bool `protobuf:"varint,12,opt,name=extension_authentication_enabled,json=extensionAuthenticationEnabled,proto3" json:"extension_authentication_enabled,omitempty"` - // Supervisor-wide wall-clock limit for accumulating and processing a response - // through whole-body middleware. Zero means the supervisor default for - // compatibility with older gateways. - HttpResponseWholeBodyTimeoutMs uint64 `protobuf:"varint,13,opt,name=http_response_whole_body_timeout_ms,json=httpResponseWholeBodyTimeoutMs,proto3" json:"http_response_whole_body_timeout_ms,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1948,13 +1944,6 @@ func (x *GetSandboxConfigResponse) GetExtensionAuthenticationEnabled() bool { return false } -func (x *GetSandboxConfigResponse) GetHttpResponseWholeBodyTimeoutMs() uint64 { - if x != nil { - return x.HttpResponseWholeBodyTimeoutMs - } - return 0 -} - // Connection details for one operator-registered supervisor middleware service. // V1 supports plaintext and server-authenticated TLS gRPC. type SupervisorMiddlewareService struct { @@ -2219,7 +2208,7 @@ const file_sandbox_proto_rawDesc = "" + "\x05value\"\x86\x01\n" + "\x10EffectiveSetting\x128\n" + "\x05value\x18\x01 \x01(\v2\".openshell.sandbox.v1.SettingValueR\x05value\x128\n" + - "\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\x9e\a\n" + + "\x05scope\x18\x02 \x01(\x0e2\".openshell.sandbox.v1.SettingScopeR\x05scope\"\xd1\x06\n" + "\x18GetSandboxConfigResponse\x12;\n" + "\x06policy\x18\x01 \x01(\v2#.openshell.sandbox.v1.SandboxPolicyR\x06policy\x12\x18\n" + "\aversion\x18\x02 \x01(\rR\aversion\x12\x1f\n" + @@ -2234,8 +2223,7 @@ const file_sandbox_proto_rawDesc = "" + "\tworkspace\x18\n" + " \x01(\tR\tworkspace\x12C\n" + "\x1epolicy_validation_failure_mode\x18\v \x01(\tR\x1bpolicyValidationFailureMode\x12H\n" + - " extension_authentication_enabled\x18\f \x01(\bR\x1eextensionAuthenticationEnabled\x12K\n" + - "#http_response_whole_body_timeout_ms\x18\r \x01(\x04R\x1ehttpResponseWholeBodyTimeoutMs\x1ac\n" + + " extension_authentication_enabled\x18\f \x01(\bR\x1eextensionAuthenticationEnabled\x1ac\n" + "\rSettingsEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12<\n" + "\x05value\x18\x02 \x01(\v2&.openshell.sandbox.v1.EffectiveSettingR\x05value:\x028\x01\"\x99\x02\n" + diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index 4501934ee4..0be85f5255 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -106,15 +106,15 @@ The gateway calls each interceptor's `Describe` RPC and validates its manifest a For operator-run supervisor middleware, inspect `[[openshell.supervisor.middleware]]`, service reachability, and both gateway and supervisor logs: ```bash -rg -n 'supervisor|middleware|grpc_endpoint|tls_ca_cert_path|audience|allow_insecure_transport|max_payload_bytes|timeout|http_response_whole_body_timeout|gateway_jwt' /etc/openshell/gateway.toml +rg -n 'supervisor|middleware|grpc_endpoint|tls_ca_cert_path|audience|allow_insecure_transport|max_payload_bytes|timeout|gateway_jwt' /etc/openshell/gateway.toml journalctl -u --no-pager --lines=200 journalctl -u openshell-gateway --no-pager --lines=200 openshell logs --tail --source sandbox ``` -The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, a manifest exposes duplicate operation/phase bindings, the registration claims the reserved `openshell/` namespace, or payload and timeout limits are invalid. Supported V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. When gateway JWT signing is disabled, supervisors preserve the legacy unauthenticated connector and do not request extension credentials. When signing is enabled, credential acquisition and verification failures are fail closed: check HTTPS trust and hostname validation, audience and issuer agreement, the token `kid`, gateway `RefreshSandboxToken` errors, and middleware logs. Changing a registration or `http_response_whole_body_timeout` requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config. +The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, a manifest exposes duplicate operation/phase bindings, the registration claims the reserved `openshell/` namespace, or payload and timeout limits are invalid. Supported V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. When gateway JWT signing is disabled, supervisors preserve the legacy unauthenticated connector and do not request extension credentials. When signing is enabled, credential acquisition and verification failures are fail closed: check HTTPS trust and hostname validation, audience and issuer agreement, the token `kid`, gateway `RefreshSandboxToken` errors, and middleware logs. Changing a registration requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config. -For response failures, distinguish a deliberate `middleware_denied` decision from `response_delivery_failed`. Before response commitment, they produce canonical 403 and 502 responses respectively. After commitment, OpenShell aborts without adding an error body, final chunk, or trailer. A whole-body accumulation timeout is one non-resetting wall-clock deadline shared across response reads and whole-body barriers; inspect the active stage's `on_error`, the supervisor timeout setting, and `whole_body_accumulation_timeout` diagnostics. Header-only stages preserve upstream body framing. Body-processing stages normalize framing and send a trailer exchange, including an empty trailer set, after the final body result. +For response failures, distinguish a deliberate `middleware_denied` decision from `response_delivery_failed`. Before response commitment, they produce canonical 403 and 502 responses respectively. After commitment, OpenShell aborts without adding an error body, final chunk, or trailer. A whole-body accumulation timeout is one fixed, non-resetting 120-second wall-clock deadline shared across response reads and whole-body barriers; inspect the active stage's `on_error` and `whole_body_accumulation_timeout` diagnostics. Header-only stages preserve upstream body framing. Body-processing stages normalize framing and send a trailer exchange, including an empty trailer set, after the final body result. At request time, distinguish attachment, binding selection, coverage, denial, and failure. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; the connection proceeds under either `on_error` mode and emits `binding_not_selected` coverage. A selected WebSocket stage receives text messages only. Binary messages pass under both modes, emit `unsupported_message_type` coverage, and consume a session sequence without an RPC. An explicit `middleware_denied` result is always enforced. WebSocket preflight returns `INSPECT`, voluntary `SKIP`, or authoritative `DENY`; `DENY` rejects the upgrade before upstream contact under both `on_error` modes. A selected-stage failure follows the policy-local `on_error`: `fail_closed` blocks the HTTP request or closes the WebSocket, while `fail_open` bypasses only that stage and emits a detection finding. A fail-open per-message capacity failure bypasses that message without disabling the stage. A timeout, transport failure, stream closure, missing or invalid response, duplicate or regressed sequence, or other failure that makes an established WebSocket stream unreliable disables that stage for later messages on the connection and emits `openshell.middleware.websocket_stage_disabled`. Confirm preflight, session-start, and session-end in service logs. OpenShell best-effort sends at most one session-end to each still-writable opened stage, including a preflight that terminates before session start; distinguish `MIDDLEWARE_DENIAL` from `MIDDLEWARE_FAILURE`. WebSocket message sequences are allocated session-wide; each stage receives a strictly increasing subset, so gaps are valid when binary messages or other units are not delivered to that stage. Zero, duplicate, or regressed sequences are protocol errors. If a running supervisor cannot install a new registry, it preserves its last-known-good generation and emits a configuration failure event. @@ -698,7 +698,7 @@ configuration — check that the gateway spawned the driver binary you expect | Policy mutation returns `FAILED_PRECONDITION` for endpoint ambiguity | Equally specific effective endpoint selectors disagree on connection or request-processing metadata | CLI error, base and provider-composed policy, affected profile attachments; confirm no new revision was stored | | Supervisor enters policy quarantine | A runtime candidate failed validation while `policy_validation_failure_mode = "fail_closed"` | Sandbox OCSF config/finding events, validation rationale, active generation, `previous_policy_active` | | HTTP request returns `middleware_failed` or `middleware_denied`, or WebSocket closes with `1008` | Selected stage failed or explicitly denied admitted traffic | Sandbox OCSF logs; policy-local middleware config; service availability; binding operation; `on_error` | -| HTTP response becomes canonical `403 middleware_denied`, `502 response_delivery_failed`, or closes mid-body | Response middleware blocked, failed before commitment, or stopped delivery after commitment | Sandbox OCSF response middleware events; `HTTP_RESPONSE/PRE_RETURN` binding; `on_error`; `http_response_whole_body_timeout`; service stream lifecycle | +| HTTP response becomes canonical `403 middleware_denied`, `502 response_delivery_failed`, or closes mid-body | Response middleware blocked, failed before commitment, or stopped delivery after commitment | Sandbox OCSF response middleware events; `HTTP_RESPONSE/PRE_RETURN` binding; `on_error`; `whole_body_accumulation_timeout`; service stream lifecycle | | WebSocket upgrades but a host-matched middleware receives no preflight or message RPC | The implementation did not advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` | `WEBSOCKET_MIDDLEWARE_COVERAGE state=binding_not_selected`; service `Describe`; the upgrade GET may still have used its HTTP binding | | Binary WebSocket message passes without a middleware RPC | Binary is unsupported by the V1 text-message binding under both `on_error` modes | `WEBSOCKET_MIDDLEWARE_COVERAGE state=unsupported_message_type`; the next text RPC may have a valid sequence gap | | WebSocket messages stop reaching middleware after one failure | A fail-open stage stream was disabled for the rest of the connection | `openshell.middleware.websocket_stage_disabled`; middleware timeout/stream/protocol logs. A per-message capacity bypass alone leaves the stage active. Reconnect to create a fresh stream after a genuine stream failure | diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 26cfd736db..2b84d54d87 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -214,7 +214,7 @@ Add `network_middlewares` only when the user asks to inspect, transform, redact, - Use `openshell/regex` without gateway registration for fixed-pattern redaction of UTF-8 HTTP request bodies or complete client-to-upstream WebSocket text messages. - Use an operator-owned middleware name only when it is already registered under `[[openshell.supervisor.middleware]]` and reachable from both the gateway and sandbox supervisors. - Confirm that a requested WebSocket implementation exposes a `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` binding. `openshell/regex` exposes this binding. A host-matched HTTP-only implementation may inspect the upgrade GET but does not join the post-upgrade chain; messages pass and OpenShell emits `binding_not_selected` coverage regardless of `on_error`. -- Confirm that requested response processing exposes `HTTP_RESPONSE/PRE_RETURN`. Response stages choose header-only, whole-body, or streaming inspection independently. Whole-body stages delay response commitment and are bounded by the gateway's supervisor-wide accumulation timeout. Expanding response transformations also share an 8 MiB retained-body budget per session; exhaustion follows the stage's `on_error` policy. Intentional blocks produce a canonical 403 before commitment and abort the connection after commitment. +- Confirm that requested response processing exposes `HTTP_RESPONSE/PRE_RETURN`. Response stages choose header-only, whole-body, or streaming inspection independently. Whole-body stages delay response commitment and are bounded by a fixed 120-second accumulation deadline shared across the response chain. Expanding response transformations also share an 8 MiB retained-body budget per session; exhaustion follows the stage's `on_error` policy. Intentional blocks produce a canonical 403 before commitment and abort the connection after commitment. - WebSocket middleware runs for both `ws://` and `wss://` and receives complete client text messages only. Binary messages pass under both error modes and emit `unsupported_message_type` coverage for active stages. Upstream-to-client messages remain uninspected. Do not claim that V1 provides all-message WebSocket inspection. - Treat `fail_open` on WebSocket as a session-scoped bypass: if the stage stream fails, OpenShell disables it for later messages on that connection and emits a state-change finding. Prefer `fail_closed` for required redaction or authorization. - `on_error` governs failures after an advertised operation binding is selected. It does not apply to an unadvertised WebSocket binding or binary-message pass-through. An explicit HTTP, WebSocket preflight, or WebSocket message denial is authoritative under both `fail_open` and `fail_closed`. diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index dc3466f8bb..34abf8567e 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -504,7 +504,7 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au Middleware can inspect parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. -An operator-run implementation can also advertise `HTTP_RESPONSE/PRE_RETURN`. Each selected response stage chooses header-only, whole-body, or streaming inspection. Whole-body inspection delays downstream commitment and uses the supervisor-wide `http_response_whole_body_timeout` from gateway configuration. A valid middleware block returns the canonical 403 before commitment; after commitment OpenShell aborts the response without adding error bytes. Use sandbox OCSF events to distinguish an intentional block from `response_delivery_failed` and fail-open bypass. +An operator-run implementation can also advertise `HTTP_RESPONSE/PRE_RETURN`. Each selected response stage chooses header-only, whole-body, or streaming inspection. Whole-body inspection delays downstream commitment and uses a fixed 120-second accumulation deadline shared across the response chain. A valid middleware block returns the canonical 403 before commitment; after commitment OpenShell aborts the response without adding error bytes. Use sandbox OCSF events to distinguish an intentional block from `response_delivery_failed` and fail-open bypass. ### Step 5: Push the updated policy From 408382aa75381855ff8df0fdff684a7de7a3a0eb Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Wed, 9 Sep 2026 13:43:09 -0700 Subject: [PATCH 07/13] fix(middleware): honor fail-open for unrepresentable responses Signed-off-by: Piotr Mlocek --- architecture/sandbox.md | 6 ++ .../src/response.rs | 81 +++++++++++++- .../src/l7/rest.rs | 100 +++++++++++++++--- docs/extensibility/supervisor-middleware.mdx | 2 + 4 files changed, 172 insertions(+), 17 deletions(-) diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 8ff12859d1..7a2d707bf1 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -192,6 +192,12 @@ middleware registry validates implementation-owned config. The generic registry and chain runner live in `openshell-supervisor-middleware`; first-party implementations live in `openshell-supervisor-middleware-builtins`. +Valid HTTP that cannot fit the response middleware protocol, including non-UTF-8 +header values or an oversized preflight envelope, fails each selected stage +according to its `on_error` policy. An all-fail-open chain relays the original +bytes; a fail-closed stage prevents delivery. The relay validates HTTP syntax +and protected trailer declarations before allowing this bypass. + The same selected chain can inspect the matching final HTTP response before it returns to the workload. Response stages select header-only, whole-body, or streaming mode independently. The relay preserves upstream framing for a diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index 225df45e82..3e444a48b5 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -835,6 +835,15 @@ impl HttpResponseSession { } impl ChainRunner { + /// Apply selected stages' failure policies when valid HTTP cannot be encoded + /// in the middleware protocol. The caller must validate HTTP safety first. + pub fn http_response_input_unrepresentable( + &self, + entries: &[DescribedChainEntry], + ) -> HttpResponsePreflightOutcome { + response_preflight_input_failure(entries, Vec::new(), "response_input_unrepresentable") + } + pub async fn preflight_http_response( &self, entries: &[ChainEntry], @@ -844,7 +853,13 @@ impl ChainRunner { if described.is_empty() { return Ok(empty_preflight_outcome(input.headers)); } - validate_preflight_input(&input)?; + if validate_preflight_input(&input).is_err() { + return Ok(response_preflight_input_failure( + &described, + input.headers, + "response_input_over_capacity", + )); + } let session_admission = match self.try_reserve_middleware_session() { MiddlewareSessionAdmission::Admitted(admission) => admission, MiddlewareSessionAdmission::AtCapacity => { @@ -1730,6 +1745,7 @@ fn response_failure_category(reason: &str) -> &'static str { } else if matches!( reason, "bodyless_response" + | "response_input_unrepresentable" | "partial_response" | "content_coding_not_identity" | "cache_control_no_transform" @@ -1794,6 +1810,22 @@ fn blocked_preflight_outcome( } } +fn response_preflight_input_failure( + entries: &[DescribedChainEntry], + headers: Vec, + reason: &str, +) -> HttpResponsePreflightOutcome { + let mut outcome = empty_preflight_outcome(headers); + for entry in entries { + if let Some(reason) = collect_preflight_failure(entry, reason, &mut outcome.invocations) { + outcome.allowed = false; + outcome.reason = reason; + break; + } + } + outcome +} + fn response_session_capacity_exhausted( entries: Vec, headers: Vec, @@ -2288,6 +2320,53 @@ mod tests { } } + #[tokio::test] + async fn response_preflight_envelope_limits_obey_selected_stage_policies() { + let runner = ChainRunner::new(Arc::new(ResponseService { + script: Script::HeadersOnly, + })); + for limit in 0..4 { + let mut input = input(200); + match limit { + 0 => input.context.request_id = "x".repeat(MAX_MIDDLEWARE_CONTEXT_BYTES + 1), + 1 => input.target.path = "x".repeat(MAX_MIDDLEWARE_TARGET_BYTES + 1), + 2 => input.headers = vec![input.headers[0].clone(); MAX_MIDDLEWARE_HEADERS + 1], + _ => input.headers[0].value = "x".repeat(MAX_MIDDLEWARE_HEADER_BYTES + 1), + } + for last_policy in [OnError::FailOpen, OnError::FailClosed] { + let entries = [entry(OnError::FailOpen), entry(last_policy)]; + let outcome = runner + .preflight_http_response(&entries, input.clone()) + .await + .unwrap(); + assert_eq!(outcome.allowed, last_policy == OnError::FailOpen); + assert_eq!(outcome.headers, input.headers); + assert!(outcome.session.is_none()); + assert_eq!(outcome.invocations.len(), 2); + assert!( + outcome + .invocations + .iter() + .all(|invocation| invocation.failed && invocation.stage_disabled) + ); + assert_eq!( + outcome.invocations[1].failure_category.as_deref(), + Some("payload_capacity") + ); + } + } + let described = runner + .describe_http_response_chain(&[entry(OnError::FailOpen), entry(OnError::FailClosed)]) + .await + .unwrap(); + let outcome = runner.http_response_input_unrepresentable(&described); + assert!(!outcome.allowed); + assert_eq!(outcome.invocations.len(), 2); + assert!(outcome.invocations.iter().all(|invocation| { + invocation.failure_category.as_deref() == Some("response_not_inspectable") + })); + } + #[test] fn stream_mode_requires_only_one_byte_of_payload_capacity() { let mut described = DescribedChainEntry { diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 91dc05edae..2797916e2f 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -3393,10 +3393,12 @@ where .await { Ok(described) if described.is_empty() => return Ok(None), - Ok(_) => parse_response_head_for_middleware(header_bytes), + Ok(described) => { + parse_response_head_for_middleware(header_bytes).map(|parsed| (described, parsed)) + } Err(error) => Err(error), }; - let parsed = match parsed { + let (described, parsed) = match parsed { Ok(parsed) => parsed, Err(error) => { debug!(error = %error, "HTTP response head normalization failed"); @@ -3430,11 +3432,17 @@ where headers: parsed.headers, connection_nominated_headers: parsed.connection_nominated, }; - let preflight = match middleware - .runner - .preflight_http_response(middleware.chain, input) - .await - { + let preflight_result = if parsed.representable { + middleware + .runner + .preflight_http_response(middleware.chain, input) + .await + } else { + Ok(middleware + .runner + .http_response_input_unrepresentable(&described)) + }; + let preflight = match preflight_result { Ok(preflight) => preflight, Err(error) => { debug!(error = %error, "HTTP response middleware preflight failed"); @@ -3503,11 +3511,11 @@ where &preflight.invocations, ); - let status_line = response_status_line(header_bytes)?; let Some(mut session) = preflight.session else { if preflight.headers == original_headers { return Ok(None); } + let status_line = response_status_line(header_bytes)?; let outcome = relay_headers_only_response( request_method, upstream, @@ -3525,6 +3533,7 @@ where return Ok(Some(outcome)); }; + let status_line = response_status_line(header_bytes)?; let supports_chunked_response = !status_line.starts_with("HTTP/1.0 "); let bodiless = is_bodiless_response(request_method, status_code); if bodiless { @@ -4127,15 +4136,18 @@ fn emit_http_response_middleware_failure( #[derive(Debug)] struct ParsedResponseHead { + representable: bool, headers: Vec, connection_nominated: Vec, declared_trailers: Vec, } fn parse_response_head_for_middleware(header_bytes: &[u8]) -> Result { - let header = std::str::from_utf8(header_bytes) - .map_err(|_| miette!("HTTP response headers contain invalid UTF-8"))?; - if parse_status_code(header).is_none() { + // Lossy decoding preserves ASCII syntax and control bytes for validation. + // Never pass replacement text to middleware or use it for delivery. + let header = String::from_utf8_lossy(header_bytes); + let representable = std::str::from_utf8(header_bytes).is_ok(); + if parse_status_code(&header).is_none() { return Err(miette!("HTTP response status line is malformed")); } let mut nominated = HashSet::new(); @@ -4187,7 +4199,8 @@ fn parse_response_head_for_middleware(header_bytes: &[u8]) -> Result = nominated.into_iter().collect(); connection_nominated.sort(); Ok(ParsedResponseHead { - headers, + representable, + headers: if representable { headers } else { Vec::new() }, connection_nominated, declared_trailers, }) @@ -7634,7 +7647,7 @@ mod tests { } async fn run_response_middleware_relay( - response: &'static [u8], + response: &[u8], method: &str, script: ResponseRelayScript, ) -> (Result, Vec) { @@ -7648,7 +7661,7 @@ mod tests { } async fn run_response_middleware_relay_with_error( - response: &'static [u8], + response: &[u8], method: &str, script: ResponseRelayScript, on_error: openshell_supervisor_middleware::OnError, @@ -7664,7 +7677,7 @@ mod tests { } async fn run_response_middleware_relay_with_timeout( - response: &'static [u8], + response: &[u8], method: &str, script: ResponseRelayScript, on_error: openshell_supervisor_middleware::OnError, @@ -7673,8 +7686,9 @@ mod tests { let (runner, chain) = response_middleware_fixture_with_error(script, on_error); let (mut upstream_read, mut upstream_write) = tokio::io::duplex(16 * 1024); let (mut client_read, mut client_write) = tokio::io::duplex(16 * 1024); + let response = response.to_vec(); tokio::spawn(async move { - upstream_write.write_all(response).await.unwrap(); + upstream_write.write_all(&response).await.unwrap(); upstream_write.shutdown().await.unwrap(); }); let mut middleware = response_middleware_context(&runner, &chain, method); @@ -8253,6 +8267,60 @@ mod tests { assert!(!delivered.contains("502 Bad Gateway"), "{delivered}"); } + #[tokio::test] + async fn response_middleware_unrepresentable_input_obeys_failure_policy() { + let mut many_headers = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n".to_vec(); + for _ in 0..=openshell_supervisor_middleware::MAX_MIDDLEWARE_HEADERS { + many_headers.extend_from_slice(b"X-Extra: value\r\n"); + } + many_headers.extend_from_slice(b"\r\nok"); + for response in [ + b"HTTP/1.1 200 OK\r\nX-Legacy: \xff\r\nContent-Length: 2\r\n\r\nok".as_slice(), + many_headers.as_slice(), + ] { + for on_error in [ + openshell_supervisor_middleware::OnError::FailOpen, + openshell_supervisor_middleware::OnError::FailClosed, + ] { + let (outcome, delivered) = run_response_middleware_relay_with_error( + response, + "GET", + ResponseRelayScript::HeadersOnly, + on_error, + ) + .await; + if on_error == openshell_supervisor_middleware::OnError::FailOpen { + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + assert_eq!(delivered, response); + } else { + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + assert!(delivered.starts_with(b"HTTP/1.1 502 Bad Gateway\r\n")); + assert!( + String::from_utf8(delivered) + .unwrap() + .contains("response_delivery_failed") + ); + } + } + } + } + + #[tokio::test] + async fn response_middleware_obs_text_does_not_bypass_unsafe_headers() { + for response in [ + b"HTTP/1.1 200 OK\r\nX-Legacy: \xff\r\nBad Name: value\r\nContent-Length: 2\r\n\r\nok".as_slice(), + b"HTTP/1.1 200 OK\r\nX-Legacy: \xff\x00\r\nContent-Length: 2\r\n\r\nok".as_slice(), + b"HTTP/1.1 200 OK\r\nX-Legacy: \xff\r\nTrailer: content-length\r\nContent-Length: 2\r\n\r\nok".as_slice(), + ] { + let (outcome, delivered) = run_response_middleware_relay_with_error( + response, "GET", ResponseRelayScript::HeadersOnly, + openshell_supervisor_middleware::OnError::FailOpen, + ).await; + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + assert!(delivered.starts_with(b"HTTP/1.1 502 Bad Gateway\r\n")); + } + } + #[tokio::test] async fn response_middleware_fail_open_preserves_input_before_and_after_commit() { for (script, expected_framing) in [ diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index 989c03bd00..b70f4c7d5f 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -144,6 +144,8 @@ See [Policy Schema](/reference/policy-schema#network-middleware) for the complet | `fail_closed` | Denies the HTTP request or closes the WebSocket when the stage fails. This is the default. | | `fail_open` | Skips the failed HTTP stage. For a broken WebSocket stage stream, disables that stage for the rest of the connection and continues the remaining chain. | +A valid upstream response can exceed the middleware envelope limits or contain header bytes that the middleware protocol cannot represent. OpenShell relays the original response when every selected response stage uses `fail_open`; any selected `fail_closed` stage causes the canonical 502 delivery failure. Malformed or unsafe HTTP does not qualify for this bypass. + Use `fail_open` only when bypassing the middleware preserves the intended security policy. OpenShell emits a detection finding when a failed stage is bypassed and a separate state-change finding when a WebSocket stage is disabled for the session. Capability coverage is separate from failure handling. A host-matched HTTP-only attachment does not join the WebSocket chain, regardless of `on_error`. Binary messages are outside the V1 text-message binding and pass through even when a selected stage is `fail_closed`. OpenShell records both states as informational coverage events so operators do not mistake pass-through traffic for inspected traffic. If a deployment requires all WebSocket message classes to be inspected, V1 cannot express that requirement. From 97bc96618c9b306904b1ba0176909821a042a201 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 10 Sep 2026 11:30:53 -0700 Subject: [PATCH 08/13] docs(middleware): trim runtime docs and extract troubleshooting reference Signed-off-by: Piotr Mlocek --- architecture/sandbox.md | 31 ++--------- docs/reference/gateway-config.mdx | 6 +-- skills/debug-openshell-cluster/SKILL.md | 27 ++-------- .../references/supervisor-middleware.md | 53 +++++++++++++++++++ 4 files changed, 63 insertions(+), 54 deletions(-) create mode 100644 skills/debug-openshell-cluster/references/supervisor-middleware.md diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 7a2d707bf1..38e132b960 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -165,11 +165,6 @@ host selectors choose the chain independently of the network rule that admitted the request. Policy-local map keys identify configs, while built-in names or operator-owned registration names identify implementations. -The configured-literal content-guard example shares matching semantics across -request bodies, complete response bodies, and client WebSocket text messages. -It requires whole-body response inspection and returns a middleware failure -when that mode is unavailable. - Built-ins run in-process against a borrowed view of the chain's current HTTP request state. Operator services retain the bounded protobuf/gRPC contract, and the remote adapter materializes an owned HTTP evaluation only when a request @@ -192,27 +187,11 @@ middleware registry validates implementation-owned config. The generic registry and chain runner live in `openshell-supervisor-middleware`; first-party implementations live in `openshell-supervisor-middleware-builtins`. -Valid HTTP that cannot fit the response middleware protocol, including non-UTF-8 -header values or an oversized preflight envelope, fails each selected stage -according to its `on_error` policy. An all-fail-open chain relays the original -bytes; a fail-closed stage prevents delivery. The relay validates HTTP syntax -and protected trailer declarations before allowing this bypass. - -The same selected chain can inspect the matching final HTTP response before it -returns to the workload. Response stages select header-only, whole-body, or -streaming mode independently. The relay preserves upstream framing for a -header-only chain and owns normalized downstream framing only when body bytes -can change. Whole-body stages delay commitment and share one non-resetting, -120-second accumulation deadline per response, defined in the response relay. -Body stages receive a final body result and then one trailer exchange; -trailer mutations can only change or remove -existing, non-protected names. Intentional blocks return the canonical 403 -before commitment and abort delivery without injected bytes after commitment. Streaming input units flush after bounded coalescing even within a -content-length body or transfer chunk. Coalescing cancels only input acquisition; -deadline transitions and downstream writes finish outside those timeouts. -The response runtime caps aggregate retained body data across stages and pending -output at 8 MiB. A transformation that exceeds the budget follows its stage's -failure policy, preserving its input when failing open. +The selected middleware chain can also inspect the final HTTP response before +it returns to the workload. Stages select header-only, whole-body, or streaming +inspection independently. The relay owns response framing when body bytes can +change. Stage failures follow policy-local `on_error`; explicit denials always +block delivery. Once delivery has started, blocking aborts the response. The supervisor installs policy and middleware registry changes as one runtime generation and preserves the last-known-good generation if preparation fails. diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index fd3d54b7da..5043162f94 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -303,14 +303,10 @@ Each service implements the supervisor middleware gRPC contract and exposes bind 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_payload_bytes` is the shared operator limit for inspectable logical payloads across every binding exposed by the service. It caps HTTP request and response units, replacement bodies, and complete WebSocket text messages and replacements. Whole-response inspection uses it as the stage's total body limit. Streaming response inspection applies it to each self-contained unit, with a platform maximum of 64 KiB per input unit. The value must be greater than zero, no larger than each binding's advertised `max_payload_bytes` capability, and no larger than the 4 MiB platform maximum. OpenShell rejects oversized values instead of silently clamping them. Binary WebSocket messages are not exposed to V1 middleware, so this field does not limit binary pass-through. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size payload and its protobuf envelope fit on the transport. +`max_payload_bytes` is the shared operator limit for inspectable logical payloads across every binding exposed by the service. It caps HTTP request and response units, replacement bodies, and complete WebSocket text messages and replacements. Whole-response inspection uses it as the stage's total body limit. Streaming response inspection applies it to each unit. The value must be greater than zero, no larger than each binding's advertised `max_payload_bytes` capability, and no larger than the 4 MiB platform maximum. OpenShell rejects oversized values instead of silently clamping them. Binary WebSocket messages are not exposed to V1 middleware, so this field does not limit binary pass-through. Middleware gRPC servers should allow messages of at least 4 MiB plus 293 KiB so a maximum-size payload and its protobuf envelope fit on the transport. `timeout` is the operator-configured service-wide RPC timeout. It accepts the same compact duration syntax as gateway interceptors: an integer followed by `ms` or `s`, such as `500ms` or `2s`. Values must be between `10ms` and `30s`, inclusive. Omit the field to use the 500 ms platform default. A binding may advertise a shorter `timeout` in the `Describe` manifest, but it cannot extend the operator-configured deadline; OpenShell uses the smaller value. OpenShell validates both levels before accepting the service. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies to HTTP request evaluation, HTTP response preflight and unit exchanges, WebSocket preflight, and each WebSocket message. Accepted streaming protocols have no connection-wide RPC deadline. -Response middleware also has an 8 MiB aggregate retained-body limit per response session across stage buffers and pending output. This limit is separate from each service's `max_payload_bytes` and cannot be configured. A replacement that exceeds the available budget follows the stage's `on_error` policy. Fail-open disables that stage and forwards its original input; fail-closed returns 502 before commitment or stops delivery after commitment. Streaming inputs flush after a short coalescing window without waiting for an entire upstream transfer chunk. - -Whole-body response inspection has a fixed 120-second deadline shared by all whole-body stages in one response, separate from middleware RPC timeouts. The timer starts after ordered response preflight selects a `WHOLE_BODY_BYTES` stage and before OpenShell reads the first response body byte. It does not reset as chunks arrive. It ends after the normalized body reaches end of stream and every whole-body barrier produces output. On expiry, each active whole-body stage follows its configured `on_error` policy: fail-open releases OpenShell-owned bytes through later stages, while fail-closed returns the canonical pre-commit 502 response. - The service `grpc_endpoint` supports plaintext `http://` and TLS `https://`. HTTPS uses the platform trust store unless `tls_ca_cert_path` names a certificate-only PEM bundle. OpenShell rejects bundles containing private keys, loads the certificates at gateway startup, and distributes only public certificates to sandbox supervisors; normal TLS hostname verification still applies. `audience` sets the exact audience for gateway-minted service tokens and defaults to `urn:openshell:extension:middleware:`. After authenticated `Describe` succeeds, OpenShell treats a non-empty manifest `expected_audience` as a consistency assertion and refuses to start when it differs from the configured audience. A strict verifier may reject an incorrect audience before returning the manifest. When `gateway_jwt` is configured, OpenShell attaches short-lived bearer credentials to gateway and supervisor calls and requires `https://`. A middleware endpoint must be reachable from sandbox supervisors, so Unix sockets are not an option here. Set `allow_insecure_transport = true` on a registration to keep a plaintext `http://` endpoint: OpenShell then attaches no credential, supervisors do not request one, and the gateway logs a warning naming the registration at every startup. mTLS 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. diff --git a/skills/debug-openshell-cluster/SKILL.md b/skills/debug-openshell-cluster/SKILL.md index ae19330520..d5eed8acdc 100644 --- a/skills/debug-openshell-cluster/SKILL.md +++ b/skills/debug-openshell-cluster/SKILL.md @@ -104,20 +104,9 @@ journalctl -u openshell-gateway --no-pager --lines=200 The gateway calls each interceptor's `Describe` RPC and validates its manifest at startup. Check for unreachable endpoints, invalid RPC/phase bindings, strict `allowlist` or `exact` mismatches, and `post_commit` bindings that resolve to `fail_closed`. If gateway JWT signing is enabled, authenticated network interceptors require HTTPS and a valid bearer token; check the private CA path, endpoint hostname, expected audience, issuer, `kid`, and interceptor logs for token rejection. `allow_insecure_transport = true` explicitly preserves unauthenticated plaintext behavior. If `provider_profile_sources` names an interceptor, that interceptor must advertise provider-profile capability and return a valid, duplicate-free catalog. A selected interceptor-only source is authoritative; include `builtin` or `user` sources explicitly when composition is intended. -For operator-run supervisor middleware, inspect `[[openshell.supervisor.middleware]]`, service reachability, and both gateway and supervisor logs: - -```bash -rg -n 'supervisor|middleware|grpc_endpoint|tls_ca_cert_path|audience|allow_insecure_transport|max_payload_bytes|timeout|gateway_jwt' /etc/openshell/gateway.toml -journalctl -u --no-pager --lines=200 -journalctl -u openshell-gateway --no-pager --lines=200 -openshell logs --tail --source sandbox -``` - -The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, a manifest exposes duplicate operation/phase bindings, the registration claims the reserved `openshell/` namespace, or payload and timeout limits are invalid. Supported V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. When gateway JWT signing is disabled, supervisors preserve the legacy unauthenticated connector and do not request extension credentials. When signing is enabled, credential acquisition and verification failures are fail closed: check HTTPS trust and hostname validation, audience and issuer agreement, the token `kid`, gateway `RefreshSandboxToken` errors, and middleware logs. Changing a registration requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config. - -For response failures, distinguish a deliberate `middleware_denied` decision from `response_delivery_failed`. Before response commitment, they produce canonical 403 and 502 responses respectively. After commitment, OpenShell aborts without adding an error body, final chunk, or trailer. A whole-body accumulation timeout is one fixed, non-resetting 120-second wall-clock deadline shared across response reads and whole-body barriers; inspect the active stage's `on_error` and `whole_body_accumulation_timeout` diagnostics. Header-only stages preserve upstream body framing. Body-processing stages normalize framing and send a trailer exchange, including an empty trailer set, after the final body result. - -At request time, distinguish attachment, binding selection, coverage, denial, and failure. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; the connection proceeds under either `on_error` mode and emits `binding_not_selected` coverage. A selected WebSocket stage receives text messages only. Binary messages pass under both modes, emit `unsupported_message_type` coverage, and consume a session sequence without an RPC. An explicit `middleware_denied` result is always enforced. WebSocket preflight returns `INSPECT`, voluntary `SKIP`, or authoritative `DENY`; `DENY` rejects the upgrade before upstream contact under both `on_error` modes. A selected-stage failure follows the policy-local `on_error`: `fail_closed` blocks the HTTP request or closes the WebSocket, while `fail_open` bypasses only that stage and emits a detection finding. A fail-open per-message capacity failure bypasses that message without disabling the stage. A timeout, transport failure, stream closure, missing or invalid response, duplicate or regressed sequence, or other failure that makes an established WebSocket stream unreliable disables that stage for later messages on the connection and emits `openshell.middleware.websocket_stage_disabled`. Confirm preflight, session-start, and session-end in service logs. OpenShell best-effort sends at most one session-end to each still-writable opened stage, including a preflight that terminates before session start; distinguish `MIDDLEWARE_DENIAL` from `MIDDLEWARE_FAILURE`. WebSocket message sequences are allocated session-wide; each stage receives a strictly increasing subset, so gaps are valid when binary messages or other units are not delivered to that stage. Zero, duplicate, or regressed sequences are protocol errors. If a running supervisor cannot install a new registry, it preserves its last-known-good generation and emits a configuration failure event. +If the deployment uses supervisor middleware, follow the +[supervisor middleware troubleshooting reference](references/supervisor-middleware.md) +for startup, authentication, policy validation, and HTTP or WebSocket failures. For network policy validation failures, first distinguish a gateway mutation rejection from a supervisor runtime rejection. Direct policy updates, @@ -694,18 +683,10 @@ configuration — check that the gateway spawned the driver binary you expect | CLI TLS error | Local mTLS bundle does not match server cert/CA | Check `~/.config/openshell/gateways//mtls/` | | Edge or OIDC gateway returns `Unauthenticated` | Stored login expired, audience/scopes mismatch, or gateway auth configuration changed | `openshell gateway info`, `openshell gateway login `, gateway auth logs | | Gateway fails before serving health after enabling an interceptor | Interceptor endpoint unavailable or manifest/binding validation failed | Gateway and interceptor logs; interceptor socket; `binding_policy`, phases, and failure policy | -| Authenticated interceptor or middleware rejects gateway calls | Private CA or hostname mismatch, expected audience or issuer mismatch, stale/unknown `kid`, or malformed extension token | `tls_ca_cert_path`, registration `audience`, service verifier config and logs; fetch well-known metadata only through the already-trusted gateway TLS endpoint | +| Authenticated interceptor rejects gateway calls | Private CA or hostname mismatch, expected audience or issuer mismatch, stale/unknown `kid`, or malformed extension token | `tls_ca_cert_path`, registration `audience`, service verifier config and logs; fetch well-known metadata only through the already-trusted gateway TLS endpoint | | Provider profiles disappear after enabling an interceptor catalog | `provider_profile_sources` selected only an authoritative interceptor or returned invalid/duplicate IDs | Inspect source list and interceptor `Describe`/catalog logs; include `builtin` and `user` when intended | -| Gateway fails after registering supervisor middleware | Service unavailable, invalid manifest, duplicate binding, reserved name, or invalid payload/timeout limit | Middleware service and gateway logs; `[[openshell.supervisor.middleware]]`; `Describe` response | -| Policy update rejects `network_middlewares` | Unknown middleware name, implementation-owned config invalid, duplicate order, broad/invalid host selector, or fail-closed coverage of `tls: skip` | Policy error, gateway logs, middleware `ValidateConfig`, selector and order fields | | Policy mutation returns `FAILED_PRECONDITION` for endpoint ambiguity | Equally specific effective endpoint selectors disagree on connection or request-processing metadata | CLI error, base and provider-composed policy, affected profile attachments; confirm no new revision was stored | | Supervisor enters policy quarantine | A runtime candidate failed validation while `policy_validation_failure_mode = "fail_closed"` | Sandbox OCSF config/finding events, validation rationale, active generation, `previous_policy_active` | -| HTTP request returns `middleware_failed` or `middleware_denied`, or WebSocket closes with `1008` | Selected stage failed or explicitly denied admitted traffic | Sandbox OCSF logs; policy-local middleware config; service availability; binding operation; `on_error` | -| HTTP response becomes canonical `403 middleware_denied`, `502 response_delivery_failed`, or closes mid-body | Response middleware blocked, failed before commitment, or stopped delivery after commitment | Sandbox OCSF response middleware events; `HTTP_RESPONSE/PRE_RETURN` binding; `on_error`; `whole_body_accumulation_timeout`; service stream lifecycle | -| WebSocket upgrades but a host-matched middleware receives no preflight or message RPC | The implementation did not advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` | `WEBSOCKET_MIDDLEWARE_COVERAGE state=binding_not_selected`; service `Describe`; the upgrade GET may still have used its HTTP binding | -| Binary WebSocket message passes without a middleware RPC | Binary is unsupported by the V1 text-message binding under both `on_error` modes | `WEBSOCKET_MIDDLEWARE_COVERAGE state=unsupported_message_type`; the next text RPC may have a valid sequence gap | -| WebSocket messages stop reaching middleware after one failure | A fail-open stage stream was disabled for the rest of the connection | `openshell.middleware.websocket_stage_disabled`; middleware timeout/stream/protocol logs. A per-message capacity bypass alone leaves the stage active. Reconnect to create a fresh stream after a genuine stream failure | -| Supervisor repeatedly fails to install middleware after enabling gateway JWT signing | Extension credential minting, distribution, or authenticated service connection failed; last-known-good registry remains active | Gateway `RefreshSandboxToken` logs, sandbox configuration events, service token-verification logs, registration TLS/audience settings | | Custom compute driver is unavailable | Driver process/socket missing, inaccessible, or selected name does not match its endpoint/config key | Socket ownership/mode, driver service logs, gateway `GetCapabilities` logs | | Sandbox remains `Stopping` or `Starting` | Driver stop/start failed, retained resource is missing, or a fresh supervisor has not connected | Gateway and driver logs; `docker inspect`, `podman inspect`, Agent Sandbox status/PVC, or VM state marker and launcher process | | Image pull failure | Gateway or sandbox image cannot be pulled | Runtime events and image pull credentials | diff --git a/skills/debug-openshell-cluster/references/supervisor-middleware.md b/skills/debug-openshell-cluster/references/supervisor-middleware.md new file mode 100644 index 0000000000..a1d7524662 --- /dev/null +++ b/skills/debug-openshell-cluster/references/supervisor-middleware.md @@ -0,0 +1,53 @@ +# Supervisor middleware troubleshooting + +Use this reference when the deployment registers supervisor middleware or a +sandbox policy attaches it through `network_middlewares`. Start with gateway +reachability and compute-platform checks in the [main skill](../SKILL.md). +Use installed CLI help for command syntax and the published +[gateway configuration reference](https://docs.nvidia.com/openshell/latest/reference/gateway-config.md) +for registration settings. + +## Collect diagnostics + +For operator-run supervisor middleware, inspect `[[openshell.supervisor.middleware]]`, service reachability, and both gateway and supervisor logs: + +```shell +rg -n 'supervisor|middleware|grpc_endpoint|tls_ca_cert_path|audience|allow_insecure_transport|max_payload_bytes|timeout|gateway_jwt' /etc/openshell/gateway.toml +journalctl -u --no-pager --lines=200 +journalctl -u openshell-gateway --no-pager --lines=200 +openshell logs --tail --source sandbox +``` + +## Startup and authentication + +The middleware service must start before the gateway and be reachable from both the gateway and sandbox supervisors. Gateway startup fails if `Describe` is unavailable, a manifest exposes duplicate operation/phase bindings, the registration claims the reserved `openshell/` namespace, or payload and timeout limits are invalid. Supported V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. + +When gateway JWT signing is disabled, supervisors preserve the legacy unauthenticated connector and do not request extension credentials. When signing is enabled, credential acquisition and verification failures are fail closed: check HTTPS trust and hostname validation, audience and issuer agreement, the token `kid`, gateway `RefreshSandboxToken` errors, and middleware logs. Changing a registration requires a gateway restart. A policy update can also fail before persistence if the selected implementation rejects its `network_middlewares` config. + +## HTTP response failures + +For response failures, distinguish a deliberate `middleware_denied` decision from `response_delivery_failed`. Before response commitment, they produce canonical 403 and 502 responses respectively. After commitment, OpenShell aborts without adding an error body, final chunk, or trailer. A whole-body accumulation timeout is one fixed, non-resetting 120-second wall-clock deadline shared across response reads and whole-body barriers; inspect the active stage's `on_error` and `whole_body_accumulation_timeout` diagnostics. Header-only stages preserve upstream body framing. Body-processing stages normalize framing and send a trailer exchange, including an empty trailer set, after the final body result. + +## Request and WebSocket failures + +At request time, distinguish attachment, binding selection, coverage, denial, and failure. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; the connection proceeds under either `on_error` mode and emits `binding_not_selected` coverage. A selected WebSocket stage receives text messages only. Binary messages pass under both modes, emit `unsupported_message_type` coverage, and consume a session sequence without an RPC. + +An explicit `middleware_denied` result is always enforced. WebSocket preflight returns `INSPECT`, voluntary `SKIP`, or authoritative `DENY`; `DENY` rejects the upgrade before upstream contact under both `on_error` modes. A selected-stage failure follows the policy-local `on_error`: `fail_closed` blocks the HTTP request or closes the WebSocket, while `fail_open` bypasses only that stage and emits a detection finding. A fail-open per-message capacity failure bypasses that message without disabling the stage. A timeout, transport failure, stream closure, missing or invalid response, duplicate or regressed sequence, or other failure that makes an established WebSocket stream unreliable disables that stage for later messages on the connection and emits `openshell.middleware.websocket_stage_disabled`. + +Confirm preflight, session-start, and session-end in service logs. OpenShell best-effort sends at most one session-end to each still-writable opened stage, including a preflight that terminates before session start; distinguish `MIDDLEWARE_DENIAL` from `MIDDLEWARE_FAILURE`. + +WebSocket message sequences are allocated session-wide; each stage receives a strictly increasing subset, so gaps are valid when binary messages or other units are not delivered to that stage. Zero, duplicate, or regressed sequences are protocol errors. If a running supervisor cannot install a new registry, it preserves its last-known-good generation and emits a configuration failure event. + +## Common failure patterns + +| Symptom | Likely cause | Check | +|---|---|---| +| Authenticated middleware rejects gateway calls | Private CA or hostname mismatch, expected audience or issuer mismatch, stale/unknown `kid`, or malformed extension token | `tls_ca_cert_path`, registration `audience`, service verifier config and logs; fetch well-known metadata only through the already-trusted gateway TLS endpoint | +| Gateway fails after registering supervisor middleware | Service unavailable, invalid manifest, duplicate binding, reserved name, or invalid payload/timeout limit | Middleware service and gateway logs; `[[openshell.supervisor.middleware]]`; `Describe` response | +| Policy update rejects `network_middlewares` | Unknown middleware name, implementation-owned config invalid, duplicate order, broad/invalid host selector, or fail-closed coverage of `tls: skip` | Policy error, gateway logs, middleware `ValidateConfig`, selector and order fields | +| HTTP request returns `middleware_failed` or `middleware_denied`, or WebSocket closes with `1008` | Selected stage failed or explicitly denied admitted traffic | Sandbox OCSF logs; policy-local middleware config; service availability; binding operation; `on_error` | +| HTTP response becomes canonical `403 middleware_denied`, `502 response_delivery_failed`, or closes mid-body | Response middleware blocked, failed before commitment, or stopped delivery after commitment | Sandbox OCSF response middleware events; `HTTP_RESPONSE/PRE_RETURN` binding; `on_error`; `whole_body_accumulation_timeout`; service stream lifecycle | +| WebSocket upgrades but a host-matched middleware receives no preflight or message RPC | The implementation did not advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` | `WEBSOCKET_MIDDLEWARE_COVERAGE state=binding_not_selected`; service `Describe`; the upgrade GET may still have used its HTTP binding | +| Binary WebSocket message passes without a middleware RPC | Binary is unsupported by the V1 text-message binding under both `on_error` modes | `WEBSOCKET_MIDDLEWARE_COVERAGE state=unsupported_message_type`; the next text RPC may have a valid sequence gap | +| WebSocket messages stop reaching middleware after one failure | A fail-open stage stream was disabled for the rest of the connection | `openshell.middleware.websocket_stage_disabled`; middleware timeout/stream/protocol logs. A per-message capacity bypass alone leaves the stage active. Reconnect to create a fresh stream after a genuine stream failure | +| Supervisor repeatedly fails to install middleware after enabling gateway JWT signing | Extension credential minting, distribution, or authenticated service connection failed; last-known-good registry remains active | Gateway `RefreshSandboxToken` logs, sandbox configuration events, service token-verification logs, registration TLS/audience settings | From c6022c323158f0ff8f92757f80365aae22be885d Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 10 Sep 2026 11:48:18 -0700 Subject: [PATCH 09/13] chore(middleware): split build fix and simplify test and skill guidance Signed-off-by: Piotr Mlocek --- .github/workflows/branch-checks.yml | 2 +- skills/generate-sandbox-policy/SKILL.md | 10 ++++------ skills/openshell-cli/SKILL.md | 8 +++++--- tasks/scripts/stage-prebuilt-binaries.sh | 4 +--- tasks/test.toml | 2 +- 5 files changed, 12 insertions(+), 14 deletions(-) diff --git a/.github/workflows/branch-checks.yml b/.github/workflows/branch-checks.yml index 8427102ce8..e66dd44745 100644 --- a/.github/workflows/branch-checks.yml +++ b/.github/workflows/branch-checks.yml @@ -172,7 +172,7 @@ jobs: OPENSHELL_TELEMETRY_ENABLED: "false" run: | cargo nextest run --profile ci --workspace --features openshell-server/test-support - cargo test --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml + cargo nextest run --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml - name: Verify telemetry can be compiled out run: | diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 2b84d54d87..39ab4cadef 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -213,11 +213,9 @@ Add `network_middlewares` only when the user asks to inspect, transform, redact, - Use `openshell/regex` without gateway registration for fixed-pattern redaction of UTF-8 HTTP request bodies or complete client-to-upstream WebSocket text messages. - Use an operator-owned middleware name only when it is already registered under `[[openshell.supervisor.middleware]]` and reachable from both the gateway and sandbox supervisors. -- Confirm that a requested WebSocket implementation exposes a `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` binding. `openshell/regex` exposes this binding. A host-matched HTTP-only implementation may inspect the upgrade GET but does not join the post-upgrade chain; messages pass and OpenShell emits `binding_not_selected` coverage regardless of `on_error`. -- Confirm that requested response processing exposes `HTTP_RESPONSE/PRE_RETURN`. Response stages choose header-only, whole-body, or streaming inspection independently. Whole-body stages delay response commitment and are bounded by a fixed 120-second accumulation deadline shared across the response chain. Expanding response transformations also share an 8 MiB retained-body budget per session; exhaustion follows the stage's `on_error` policy. Intentional blocks produce a canonical 403 before commitment and abort the connection after commitment. -- WebSocket middleware runs for both `ws://` and `wss://` and receives complete client text messages only. Binary messages pass under both error modes and emit `unsupported_message_type` coverage for active stages. Upstream-to-client messages remain uninspected. Do not claim that V1 provides all-message WebSocket inspection. -- Treat `fail_open` on WebSocket as a session-scoped bypass: if the stage stream fails, OpenShell disables it for later messages on that connection and emits a state-change finding. Prefer `fail_closed` for required redaction or authorization. -- `on_error` governs failures after an advertised operation binding is selected. It does not apply to an unadvertised WebSocket binding or binary-message pass-through. An explicit HTTP, WebSocket preflight, or WebSocket message denial is authoritative under both `fail_open` and `fail_closed`. +- Confirm that the implementation advertises the requested binding: `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, or `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. A host match alone does not enable inspection. +- WebSocket middleware inspects client text messages only, over both `ws://` and `wss://`. Binary and upstream-to-client messages pass without inspection, even with `fail_closed`. +- `on_error` controls selected-stage failures. Explicit denials always block traffic. A failed WebSocket stage with `fail_open` can remain bypassed for the rest of the connection. - Default `on_error` to `fail_closed`. Use `fail_open` only when bypassing the stage preserves the user's stated security requirement. - Assign unique `order` values across the complete policy. Lower values run first, and at most 10 configs may be selected. - Match the narrowest destination hosts possible with `endpoints.include`; use `exclude` when a broad selector has trusted exceptions. @@ -381,7 +379,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] Middleware `order` values are unique and no selected chain exceeds 10 stages - [ ] No fail-closed middleware selector can cover a `tls: skip` endpoint - [ ] Any required WebSocket control advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, and the user understands that V1 does not inspect binary messages -- [ ] Any required response control advertises `HTTP_RESPONSE/PRE_RETURN`, and whole-body buffering fits the registered payload limit and supervisor deadline +- [ ] Any required response control advertises `HTTP_RESPONSE/PRE_RETURN` - [ ] Endpoints contributed by a credentialed provider are not L4-only or `tls: skip` unless `allow_uninspected_credentials: true` explicitly records the exception ### Schema Warnings (log-only, but should be fixed) diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index 9b42632d96..889a4d6baa 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -502,9 +502,11 @@ Edit `current-policy.yaml` to allow the blocked actions. **For policy content au `network_policies` and `network_middlewares` can be modified at runtime when the selected compute driver supports live policy updates. Use `--wait` to verify that the active runtime loaded the revision; do not infer enforcement from the gateway accepting the update. If `filesystem_policy`, `landlock`, or `process` need changes, the sandbox must be recreated. Built-in middleware such as `openshell/regex` needs no gateway registration. An operator-run middleware must already be registered under `[[openshell.supervisor.middleware]]`; changing that static registration requires a gateway restart. -Middleware can inspect parsed HTTP request bodies and complete client-to-upstream WebSocket text messages over both `ws://` and `wss://` when the implementation advertises the matching binding. The built-in `openshell/regex` advertises both bindings and applies its fixed patterns to UTF-8 text. A host-matched HTTP-only attachment can inspect the upgrade GET but does not join the WebSocket chain; look for `binding_not_selected` coverage. Binary messages pass under both `on_error` modes and active stages emit `unsupported_message_type` coverage; upstream-to-client messages remain uninspected. A broken fail-open WebSocket stage is disabled for the rest of that connection; inspect sandbox OCSF logs for `openshell.middleware.websocket_stage_disabled`. - -An operator-run implementation can also advertise `HTTP_RESPONSE/PRE_RETURN`. Each selected response stage chooses header-only, whole-body, or streaming inspection. Whole-body inspection delays downstream commitment and uses a fixed 120-second accumulation deadline shared across the response chain. A valid middleware block returns the canonical 403 before commitment; after commitment OpenShell aborts the response without adding error bytes. Use sandbox OCSF events to distinguish an intentional block from `response_delivery_failed` and fail-open bypass. +Middleware can inspect HTTP requests, HTTP responses, or client WebSocket text +messages when the implementation advertises the matching binding. The built-in +`openshell/regex` supports request bodies and client WebSocket text messages. +Use the `generate-sandbox-policy` skill to choose attachments and failure policy, +and `debug-openshell-cluster` to investigate middleware failures. ### Step 5: Push the updated policy diff --git a/tasks/scripts/stage-prebuilt-binaries.sh b/tasks/scripts/stage-prebuilt-binaries.sh index 8f40788ffa..b3f75bbaba 100755 --- a/tasks/scripts/stage-prebuilt-binaries.sh +++ b/tasks/scripts/stage-prebuilt-binaries.sh @@ -171,7 +171,6 @@ build_component_for_arch() { local current_host_os local current_host_arch local binary_path - local cargo_output_dir local build_rustflags resolve_component "$component" @@ -258,8 +257,7 @@ build_component_for_arch() { CARGO_INCREMENTAL=0 mise x -- ${cargo_env[@]+"${cargo_env[@]}"} "${cargo_subcommand[@]}" "${args[@]}" ) - cargo_output_dir="$(cd "$ROOT" && mise x -- cargo metadata --format-version=1 --no-deps | jq -er '.target_directory')" - binary_path="${cargo_output_dir}/${target}/release/${binary}" + binary_path="${ROOT}/target/${target}/release/${binary}" if [[ "$component" == "gateway" ]]; then "$SCRIPT_DIR/verify-glibc-symbols.sh" 2.28 "$binary_path" elif [[ "$component" == "supervisor" ]]; then diff --git a/tasks/test.toml b/tasks/test.toml index eb61d52257..437d6e7423 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -75,7 +75,7 @@ run = [ # with test-only helpers enabled. "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", - "cargo test --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true From 22a7e8ce1fba824d605ac5ff1e701e4fc452b4d9 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 10 Sep 2026 14:17:12 -0700 Subject: [PATCH 10/13] refactor(middleware): isolate HTTP response processing Signed-off-by: Piotr Mlocek --- architecture/sandbox.md | 15 +- .../src/headers.rs | 19 + .../src/lib.rs | 2 +- .../src/response.rs | 823 +----- .../src/response/preflight.rs | 423 ++++ .../src/response/validation.rs | 328 +++ .../src/l7/middleware.rs | 83 + .../src/l7/relay.rs | 49 +- .../src/l7/rest.rs | 2256 ++--------------- .../src/l7/rest/http_response.rs | 2066 +++++++++++++++ .../openshell-supervisor-network/src/proxy.rs | 158 +- docs/extensibility/supervisor-middleware.mdx | 8 + 12 files changed, 3348 insertions(+), 2882 deletions(-) create mode 100644 crates/openshell-supervisor-middleware/src/response/preflight.rs create mode 100644 crates/openshell-supervisor-middleware/src/response/validation.rs create mode 100644 crates/openshell-supervisor-network/src/l7/rest/http_response.rs diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 38e132b960..edc5c10e0c 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -190,8 +190,19 @@ implementations live in `openshell-supervisor-middleware-builtins`. The selected middleware chain can also inspect the final HTTP response before it returns to the workload. Stages select header-only, whole-body, or streaming inspection independently. The relay owns response framing when body bytes can -change. Stage failures follow policy-local `on_error`; explicit denials always -block delivery. Once delivery has started, blocking aborts the response. +change. Preflight exposes upstream `Content-Length`, `Content-Encoding`, and +`Content-Range` as read-only metadata, while the relay emits final framing +separately from middleware-visible headers. Stage failures follow policy-local +`on_error`; explicit denials always block delivery. Once delivery has started, +blocking aborts the response. + +The network supervisor represents the destination-selected request and response +pair as one `HttpMiddlewareExchange`. It retains the full chain, runner, request +identity, and policy generation while request and response bindings are selected +independently. The HTTP response adapter owns wire parsing, downstream commit +state, generation fences, framing, and transport error classification. The +generic middleware crate owns stage selection, remote stream lifecycle, ordered +body processing, limits, and result validation. The supervisor installs policy and middleware registry changes as one runtime generation and preserves the last-known-good generation if preparation fails. diff --git a/crates/openshell-supervisor-middleware/src/headers.rs b/crates/openshell-supervisor-middleware/src/headers.rs index 1dc37bff20..5e2c6c2807 100644 --- a/crates/openshell-supervisor-middleware/src/headers.rs +++ b/crates/openshell-supervisor-middleware/src/headers.rs @@ -642,6 +642,25 @@ mod tests { } } + #[test] + fn response_authority_keeps_visible_body_metadata_read_only() { + let existing = [ + header("content-length", "5"), + header("content-encoding", "gzip"), + header("content-range", "bytes 0-4/10"), + ]; + for name in ["Content-Length", "Content-Encoding", "Content-Range"] { + for mutation in [ + write(name, "replacement", ExistingHeaderAction::Overwrite), + remove(name), + ] { + let error = apply(HeaderAuthority::Response, &existing, &[], &[mutation]) + .expect_err("read-only response body metadata"); + assert!(matches!(error, HeaderMutationError::Protected { .. })); + } + } + } + #[test] fn response_authority_protects_credential_headers_from_writes_and_removals() { let existing = [header("set-cookie", "session=upstream")]; diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index dcf695539a..95f7609c1e 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -12,7 +12,7 @@ pub use response::{ HttpResponseDiagnostics, HttpResponseFinish, HttpResponseInvocation, HttpResponseInvocationOutcome, HttpResponseMiddlewareFailure, HttpResponsePreflightInput, HttpResponsePreflightOutcome, HttpResponseSession, MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES, - MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES, + MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES, is_stale_http_response_integrity_header, }; pub use websocket::{ diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index 3e444a48b5..02f4a0e61f 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -3,6 +3,16 @@ //! HTTP response pre-return middleware chain execution. +mod preflight; +mod validation; + +#[cfg(test)] +use validation::permitted_body_modes; +use validation::{ + BodyAction, CurrentBodyAction, encoded_header_bytes, strip_stale_integrity, + validate_body_result, validate_trailers_result, +}; + use std::collections::BTreeMap; use std::time::Duration; @@ -31,11 +41,28 @@ use super::{ }; const STREAM_CHANNEL_CAPACITY: usize = 4; +const SESSION_END_TIMEOUT: Duration = Duration::from_millis(10); pub const MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES: usize = 64 * 1024; /// Maximum logical body bytes retained across a session's stage buffers and /// pending output. Temporary exchange copies have the per-binding payload cap. pub const MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES: usize = 8 * 1024 * 1024; +/// Return whether a response metadata field becomes stale after body changes. +#[must_use] +pub fn is_stale_http_response_integrity_header(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "accept-ranges" + | "etag" + | "content-md5" + | "digest" + | "content-digest" + | "repr-digest" + | "signature" + | "signature-input" + ) +} + #[derive(Debug, Clone)] pub struct HttpResponsePreflightInput { pub context: RequestContext, @@ -139,6 +166,31 @@ struct HttpResponseStageTransport { responses: super::HttpResponseResultStream, } +impl HttpResponseStageTransport { + async fn end(self, reason: MiddlewareSessionEndReason) { + let _ = tokio::time::timeout(SESSION_END_TIMEOUT, self.end_inner(reason)).await; + } + + async fn end_inner(self, reason: MiddlewareSessionEndReason) { + if self.sender.send(session_end_event(reason)).await.is_err() { + return; + } + self.drain().await; + } + + async fn drain(self) { + let Self { + sender, + mut responses, + } = self; + // Keep the response stream alive while half-closing the request side. + // Dropping both handles together schedules an HTTP/2 CANCEL and can + // discard the terminal event before remote middleware receives it. + drop(sender); + while responses.next().await.is_some() {} + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum StageMode { HeadersOnly, @@ -165,11 +217,7 @@ impl HttpResponseStage { async fn end(&mut self, reason: MiddlewareSessionEndReason) { if let Some(transport) = self.transport.take() { - let _ = tokio::time::timeout( - Duration::from_millis(10), - transport.sender.send(session_end_event(reason)), - ) - .await; + transport.end(reason).await; } } } @@ -834,741 +882,6 @@ impl HttpResponseSession { } } -impl ChainRunner { - /// Apply selected stages' failure policies when valid HTTP cannot be encoded - /// in the middleware protocol. The caller must validate HTTP safety first. - pub fn http_response_input_unrepresentable( - &self, - entries: &[DescribedChainEntry], - ) -> HttpResponsePreflightOutcome { - response_preflight_input_failure(entries, Vec::new(), "response_input_unrepresentable") - } - - pub async fn preflight_http_response( - &self, - entries: &[ChainEntry], - input: HttpResponsePreflightInput, - ) -> miette::Result { - let described = self.describe_http_response_chain(entries).await?; - if described.is_empty() { - return Ok(empty_preflight_outcome(input.headers)); - } - if validate_preflight_input(&input).is_err() { - return Ok(response_preflight_input_failure( - &described, - input.headers, - "response_input_over_capacity", - )); - } - let session_admission = match self.try_reserve_middleware_session() { - MiddlewareSessionAdmission::Admitted(admission) => admission, - MiddlewareSessionAdmission::AtCapacity => { - return Ok(response_session_capacity_exhausted( - described, - input.headers, - )); - } - }; - let _work = self.reserve_middleware_work_admission().await?; - let original_restriction = body_restriction(&input); - let mut headers = input.headers.clone(); - let mut stages = Vec::new(); - let mut findings = Vec::new(); - let mut metadata = BTreeMap::new(); - let mut invocations = Vec::new(); - - for entry in described { - let Some(service) = entry.service.as_ref() else { - if let Some(reason) = - collect_preflight_failure(&entry, "binding_not_described", &mut invocations) - { - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; - return Ok(failed_preflight_outcome( - headers, - reason, - findings, - metadata, - invocations, - )); - } - continue; - }; - let (sender, receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); - let preflight = HttpResponsePreflight { - context: Some(input.context.clone()), - target: Some(input.target.clone()), - status_code: u32::from(input.status_code), - headers: headers.clone(), - middleware_name: entry.entry.implementation.clone(), - config: Some(entry.entry.config.clone()), - max_payload_bytes: entry.max_payload_bytes as u64, - permitted_body_modes: permitted_body_modes( - &input, - &entry, - original_restriction.as_deref(), - ), - }; - let timeout = entry.timeout; - let opened = tokio::time::timeout(timeout, async { - sender - .send(HttpResponseEvent { - event: Some(http_response_event::Event::Preflight(preflight)), - }) - .await - .map_err(|_| tonic::Status::unavailable("middleware request stream closed"))?; - let mut responses = service - .service - .open_http_response_pre_return(receiver) - .await?; - let response = responses.next().await.ok_or_else(|| { - tonic::Status::unavailable("middleware result stream closed") - })??; - Ok::<_, tonic::Status>((responses, response)) - }) - .await; - let (responses, response) = match opened { - Ok(Ok(opened)) => opened, - Ok(Err(error)) => { - let reason = if error.code() == tonic::Code::DeadlineExceeded { - "middleware_timeout".to_string() - } else { - service.diagnostic_policy.error_reason(&error) - }; - if let Some(reason) = - collect_preflight_failure(&entry, &reason, &mut invocations) - { - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) - .await; - return Ok(failed_preflight_outcome( - headers, - reason, - findings, - metadata, - invocations, - )); - } - continue; - } - Err(_) => { - if let Some(reason) = - collect_preflight_failure(&entry, "middleware_timeout", &mut invocations) - { - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) - .await; - return Ok(failed_preflight_outcome( - headers, - reason, - findings, - metadata, - invocations, - )); - } - continue; - } - }; - let Some(http_response_event_result::Result::PreflightResult(decision)) = - response.result - else { - if let Some(reason) = collect_preflight_failure( - &entry, - "unexpected_response_result", - &mut invocations, - ) { - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; - return Ok(failed_preflight_outcome( - headers, - reason, - findings, - metadata, - invocations, - )); - } - continue; - }; - if let Err(reason) = validate_diagnostics( - &decision.reason, - &decision.reason_code, - &decision.findings, - &decision.metadata, - ) { - if let Some(reason) = collect_preflight_failure(&entry, reason, &mut invocations) { - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; - return Ok(failed_preflight_outcome( - headers, - reason, - findings, - metadata, - invocations, - )); - } - continue; - } - let reason_code = - (!decision.reason_code.is_empty()).then(|| decision.reason_code.clone()); - let decision_findings = decision.findings; - let decision_metadata = decision.metadata; - match decision.action { - Some(http_response_preflight_result::Action::Skip(_)) => { - collect_preflight_diagnostics( - &entry, - decision_findings, - decision_metadata, - &mut findings, - &mut metadata, - ); - invocations.push(HttpResponseInvocation { - config_name: entry.entry.name.clone(), - implementation: entry.entry.implementation.clone(), - outcome: HttpResponseInvocationOutcome::Skip, - sequence: None, - input_size: 0, - output_size: None, - failed: false, - stage_disabled: false, - reason_code, - failure_category: None, - }); - let mut skipped = HttpResponseStage { - entry, - transport: Some(HttpResponseStageTransport { sender, responses }), - mode: StageMode::HeadersOnly, - next_sequence: 1, - whole_body: Vec::new(), - }; - skipped.end(MiddlewareSessionEndReason::StageSkipped).await; - } - Some(http_response_preflight_result::Action::Inspect(inspect)) => { - let permitted_modes = - permitted_body_modes(&input, &entry, original_restriction.as_deref()); - let mode = match validate_inspect(&entry, &inspect, &permitted_modes) { - Ok(mode) => mode, - Err(reason) => { - if let Some(reason) = - collect_preflight_failure(&entry, &reason, &mut invocations) - { - end_stages( - &mut stages, - MiddlewareSessionEndReason::MiddlewareFailure, - ) - .await; - return Ok(failed_preflight_outcome( - headers, - reason, - findings, - metadata, - invocations, - )); - } - continue; - } - }; - let updated = match headers::apply( - headers::HeaderAuthority::Response, - &headers, - &input.connection_nominated_headers, - &inspect.header_mutations, - ) { - Ok(updated) => updated, - Err(error) => { - let reason = service - .diagnostic_policy - .header_mutation_error_reason(&error); - if let Some(reason) = - collect_preflight_failure(&entry, &reason, &mut invocations) - { - end_stages( - &mut stages, - MiddlewareSessionEndReason::MiddlewareFailure, - ) - .await; - return Ok(failed_preflight_outcome( - headers, - reason, - findings, - metadata, - invocations, - )); - } - continue; - } - }; - headers = updated; - if mode == StageMode::Stream { - strip_stale_integrity(&mut headers); - } - collect_preflight_diagnostics( - &entry, - decision_findings, - decision_metadata, - &mut findings, - &mut metadata, - ); - invocations.push(HttpResponseInvocation { - config_name: entry.entry.name.clone(), - implementation: entry.entry.implementation.clone(), - outcome: match mode { - StageMode::HeadersOnly => HttpResponseInvocationOutcome::HeadersOnly, - StageMode::WholeBody => HttpResponseInvocationOutcome::WholeBody, - StageMode::Stream => HttpResponseInvocationOutcome::Stream, - }, - sequence: None, - input_size: 0, - output_size: None, - failed: false, - stage_disabled: false, - reason_code, - failure_category: None, - }); - let mut stage = HttpResponseStage { - entry, - transport: Some(HttpResponseStageTransport { sender, responses }), - mode, - next_sequence: 1, - whole_body: Vec::new(), - }; - if mode == StageMode::HeadersOnly { - stage.end(MiddlewareSessionEndReason::Normal).await; - } else { - stages.push(stage); - } - } - Some(http_response_preflight_result::Action::BlockDelivery(_)) => { - collect_preflight_diagnostics( - &entry, - decision_findings, - decision_metadata, - &mut findings, - &mut metadata, - ); - invocations.push(HttpResponseInvocation { - config_name: entry.entry.name.clone(), - implementation: entry.entry.implementation.clone(), - outcome: HttpResponseInvocationOutcome::BlockDelivery, - sequence: None, - input_size: 0, - output_size: None, - failed: false, - stage_disabled: false, - reason_code: reason_code.clone(), - failure_category: None, - }); - stages.push(HttpResponseStage { - entry: entry.clone(), - transport: Some(HttpResponseStageTransport { sender, responses }), - mode: StageMode::HeadersOnly, - next_sequence: 1, - whole_body: Vec::new(), - }); - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareDenial).await; - return Ok(blocked_preflight_outcome( - headers, - super::MiddlewareDenial { - config_name: entry.entry.name.clone(), - reason_code, - }, - findings, - metadata, - invocations, - )); - } - None => { - if let Some(reason) = collect_preflight_failure( - &entry, - "invalid_preflight_decision", - &mut invocations, - ) { - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) - .await; - return Ok(failed_preflight_outcome( - headers, - reason, - findings, - metadata, - invocations, - )); - } - } - } - } - - if stages.is_empty() { - drop(session_admission); - return Ok(HttpResponsePreflightOutcome { - allowed: true, - reason: String::new(), - denial: None, - headers, - session: None, - findings, - metadata, - invocations, - session_capacity_exhausted: false, - }); - } - let defer_output_until_finish = stages - .iter() - .any(|stage| stage.is_active() && stage.mode == StageMode::WholeBody); - Ok(HttpResponsePreflightOutcome { - allowed: true, - reason: String::new(), - denial: None, - headers, - session: Some(HttpResponseSession { - runner: self.clone(), - stages, - findings: Vec::new(), - metadata: BTreeMap::new(), - invocations: Vec::new(), - session_admission: Some(session_admission), - body_transformed: false, - retained_body_bytes: 0, - defer_output_until_finish, - deferred_output: Vec::new(), - connection_nominated_headers: input.connection_nominated_headers, - whole_body_deadline: None, - }), - findings, - metadata, - invocations, - session_capacity_exhausted: false, - }) - } -} - -enum BodyAction { - PassThrough, - Transform(Vec), - BlockDelivery, - SkipRemaining(CurrentBodyAction), -} - -enum CurrentBodyAction { - PassThrough, - Transform(Vec), -} - -struct BodyDecision { - action: BodyAction, - reason_code: String, - findings: Vec, - metadata: std::collections::HashMap, -} - -struct TrailersDecision { - headers: Vec, - reason_code: String, - findings: Vec, - metadata: std::collections::HashMap, -} - -fn validate_trailers_result( - result: HttpResponseEventResult, - trailers: &[HttpHeader], - entry: &DescribedChainEntry, - connection_nominated_headers: &[String], -) -> Result { - let Some(http_response_event_result::Result::TrailersResult(result)) = result.result else { - return Err("unexpected_response_result".into()); - }; - validate_diagnostics( - &result.reason, - &result.reason_code, - &result.findings, - &result.metadata, - ) - .map_err(str::to_string)?; - if result.trailer_mutations.len() > headers::MAX_HEADER_MUTATIONS { - return Err("header_mutation_count_over_capacity".into()); - } - let encoded_mutations = result - .trailer_mutations - .iter() - .fold(0usize, |total, mutation| { - total.saturating_add(mutation.encoded_len()) - }); - if encoded_mutations > MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES { - return Err("header_mutation_bytes_over_capacity".into()); - } - let headers = headers::apply( - headers::HeaderAuthority::ResponseTrailers, - trailers, - connection_nominated_headers, - &result.trailer_mutations, - ) - .map_err(|error| { - entry.service.as_ref().map_or_else( - || error.to_string(), - |service| { - service - .diagnostic_policy - .header_mutation_error_reason(&error) - }, - ) - })?; - Ok(TrailersDecision { - headers, - reason_code: result.reason_code, - findings: result.findings, - metadata: result.metadata, - }) -} - -fn encoded_header_bytes(headers: &[HttpHeader]) -> usize { - headers.iter().fold(0usize, |total, header| { - total.saturating_add(header.encoded_len()) - }) -} - -fn validate_body_result( - result: HttpResponseEventResult, - sequence: u64, - max_payload_bytes: usize, -) -> Result { - let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { - return Err("unexpected_response_result"); - }; - if body.sequence != sequence { - return Err("response_body_sequence_mismatch"); - } - validate_diagnostics( - &body.reason, - &body.reason_code, - &body.findings, - &body.metadata, - )?; - let action = match body.action { - Some(http_response_body_result::Action::PassThrough(HttpResponseBodyPassThrough {})) => { - BodyAction::PassThrough - } - Some(http_response_body_result::Action::Transform(transform)) => BodyAction::Transform( - validate_replacement(transform.replacement, max_payload_bytes)?, - ), - Some(http_response_body_result::Action::BlockDelivery(_)) => BodyAction::BlockDelivery, - Some(http_response_body_result::Action::SkipRemaining(skip)) => { - let current = match skip.current { - Some(http_response_body_skip_remaining::Current::PassThrough( - HttpResponseBodyPassThrough {}, - )) => CurrentBodyAction::PassThrough, - Some(http_response_body_skip_remaining::Current::Transform(transform)) => { - CurrentBodyAction::Transform(validate_replacement( - transform.replacement, - max_payload_bytes, - )?) - } - None => return Err("invalid_response_body_skip_remaining_action"), - }; - BodyAction::SkipRemaining(current) - } - None => return Err("invalid_response_body_decision"), - }; - Ok(BodyDecision { - action, - reason_code: body.reason_code, - findings: body.findings, - metadata: body.metadata, - }) -} - -fn validate_replacement( - replacement: Option, - max_payload_bytes: usize, -) -> Result, &'static str> { - let Some(http_response_body_transform::Replacement::Data(replacement)) = replacement else { - return Err("response_body_replacement_missing"); - }; - if replacement.len() > max_payload_bytes { - return Err("response_body_replacement_over_capacity"); - } - Ok(replacement) -} - -fn validate_inspect( - entry: &DescribedChainEntry, - inspect: &openshell_core::proto::HttpResponsePreflightInspect, - permitted_modes: &[i32], -) -> Result { - let mode = match HttpResponseBodyMode::try_from(inspect.body_mode) { - Ok(HttpResponseBodyMode::HeadersOnly) => StageMode::HeadersOnly, - Ok(HttpResponseBodyMode::WholeBodyBytes) => StageMode::WholeBody, - Ok(HttpResponseBodyMode::StreamBytes) => StageMode::Stream, - Ok(HttpResponseBodyMode::Unspecified) | Err(_) => { - return Err("invalid_response_body_mode".into()); - } - }; - if !permitted_modes.contains(&inspect.body_mode) { - return Err("response_body_mode_not_permitted".into()); - } - if inspect.header_mutations.len() > headers::MAX_HEADER_MUTATIONS { - return Err("header_mutation_count_over_capacity".into()); - } - let encoded_mutations = inspect - .header_mutations - .iter() - .fold(0usize, |total, mutation| { - total.saturating_add(mutation.encoded_len()) - }); - if encoded_mutations > MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES { - return Err("header_mutation_bytes_over_capacity".into()); - } - if entry.max_payload_bytes == 0 && mode != StageMode::HeadersOnly { - return Err("response_payload_limit_invalid".into()); - } - Ok(mode) -} - -fn validate_preflight_input(input: &HttpResponsePreflightInput) -> miette::Result<()> { - if input.context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES { - return Err(miette::miette!("response context exceeds platform limit")); - } - if input.target.encoded_len() > MAX_MIDDLEWARE_TARGET_BYTES { - return Err(miette::miette!("response target exceeds platform limit")); - } - if input.headers.len() > MAX_MIDDLEWARE_HEADERS { - return Err(miette::miette!( - "response header count exceeds platform limit" - )); - } - if input.headers.iter().fold(0usize, |total, header| { - total.saturating_add(header.encoded_len()) - }) > MAX_MIDDLEWARE_HEADER_BYTES - { - return Err(miette::miette!("response headers exceed platform limit")); - } - Ok(()) -} - -fn validate_diagnostics( - reason: &str, - reason_code: &str, - findings: &[Finding], - metadata: &std::collections::HashMap, -) -> Result<(), &'static str> { - if reason.len() > MAX_MIDDLEWARE_REASON_BYTES { - return Err("response_reason_over_capacity"); - } - if !reason_code.is_empty() - && (reason_code.len() > MAX_MIDDLEWARE_REASON_CODE_BYTES - || !is_stable_reason_code(reason_code)) - { - return Err("response_reason_code_invalid"); - } - if findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE { - return Err("response_findings_over_capacity"); - } - if findings - .iter() - .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) - { - return Err("response_finding_over_capacity"); - } - if metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES { - return Err("response_metadata_count_over_capacity"); - } - if metadata.iter().fold(0usize, |total, (key, value)| { - total.saturating_add(key.len()).saturating_add(value.len()) - }) > MAX_MIDDLEWARE_METADATA_BYTES - { - return Err("response_metadata_bytes_over_capacity"); - } - Ok(()) -} - -fn body_restriction(input: &HttpResponsePreflightInput) -> Option { - if input.target.method.eq_ignore_ascii_case("HEAD") - || input.status_code == 204 - || input.status_code == 304 - { - return Some("bodyless_response".into()); - } - if input.status_code == 206 - || input - .headers - .iter() - .any(|header| header.name.eq_ignore_ascii_case("content-range")) - || input.headers.iter().any(|header| { - header.name.eq_ignore_ascii_case("content-type") - && header - .value - .split(';') - .next() - .is_some_and(|value| value.trim().eq_ignore_ascii_case("multipart/byteranges")) - }) - { - return Some("unsupported_partial_response".into()); - } - if input.headers.iter().any(|header| { - header.name.eq_ignore_ascii_case("cache-control") - && header.value.split(',').any(|directive| { - directive - .split('=') - .next() - .is_some_and(|name| name.trim().eq_ignore_ascii_case("no-transform")) - }) - }) { - return Some("response_no_transform".into()); - } - if input.headers.iter().any(|header| { - header.name.eq_ignore_ascii_case("content-encoding") - && header - .value - .split(',') - .any(|coding| !coding.trim().eq_ignore_ascii_case("identity")) - }) { - return Some("unsupported_content_encoding".into()); - } - None -} - -fn permitted_body_modes( - input: &HttpResponsePreflightInput, - entry: &DescribedChainEntry, - body_restriction: Option<&str>, -) -> Vec { - let mut modes = vec![HttpResponseBodyMode::HeadersOnly as i32]; - if body_restriction.is_some() { - return modes; - } - if input - .declared_body_length - .is_none_or(|length| length <= entry.max_payload_bytes as u64) - && !is_open_ended_response(input) - { - modes.push(HttpResponseBodyMode::WholeBodyBytes as i32); - } - if entry.max_payload_bytes > 0 { - modes.push(HttpResponseBodyMode::StreamBytes as i32); - } - modes -} - -fn is_open_ended_response(input: &HttpResponsePreflightInput) -> bool { - input.headers.iter().any(|header| { - header.name.eq_ignore_ascii_case("content-type") - && matches!( - header.value.split(';').next().map(str::trim), - Some(value) - if value.eq_ignore_ascii_case("text/event-stream") - || value.eq_ignore_ascii_case("multipart/x-mixed-replace") - ) - }) -} - -fn strip_stale_integrity(headers: &mut Vec) { - headers.retain(|header| { - !matches!( - header.name.to_ascii_lowercase().as_str(), - "accept-ranges" - | "etag" - | "content-md5" - | "digest" - | "content-digest" - | "repr-digest" - | "signature" - | "signature-input" - ) - }); -} - async fn exchange( stage: &mut HttpResponseStage, event: HttpResponseEvent, @@ -1901,7 +1214,9 @@ mod tests { } #[derive(Clone)] - struct RemoteResponseService; + struct RemoteResponseService { + session_end_tx: Option>, + } #[tonic::async_trait] impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware @@ -1970,6 +1285,7 @@ mod tests { }))); let mut requests = futures::stream::iter([first]).chain(requests); let (sender, receiver) = mpsc::channel(4); + let session_end_tx = self.session_end_tx.clone(); tokio::spawn(async move { while let Some(Ok(event)) = requests.next().await { match event.event { @@ -1999,7 +1315,15 @@ mod tests { break; } } - Some(http_response_event::Event::SessionEnd(_)) | None => break, + Some(http_response_event::Event::SessionEnd(end)) => { + if let Some(sender) = &session_end_tx + && let Ok(reason) = MiddlewareSessionEndReason::try_from(end.reason) + { + let _ = sender.send(reason); + } + break; + } + None => break, _ => {} } } @@ -3021,9 +2345,13 @@ mod tests { .expect("bind response middleware"); let address = listener.local_addr().expect("response middleware address"); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let (session_end_tx, mut session_end_rx) = mpsc::unbounded_channel(); + let service = RemoteResponseService { + session_end_tx: Some(session_end_tx), + }; let server = tonic::transport::Server::builder() - .add_service(SupervisorMiddlewareServer::new(RemoteResponseService)) - .add_service(HttpResponsePreReturnServer::new(RemoteResponseService)) + .add_service(SupervisorMiddlewareServer::new(service.clone())) + .add_service(HttpResponsePreReturnServer::new(service)) .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { let _ = shutdown_rx.await; }); @@ -3065,6 +2393,13 @@ mod tests { Some("remote") ); assert!(outcome.session.is_none()); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), session_end_rx.recv()) + .await + .expect("bounded session end delivery"), + Some(MiddlewareSessionEndReason::Normal) + ); + assert!(session_end_rx.try_recv().is_err()); let _ = shutdown_tx.send(()); tokio::time::timeout(Duration::from_secs(2), server_task) diff --git a/crates/openshell-supervisor-middleware/src/response/preflight.rs b/crates/openshell-supervisor-middleware/src/response/preflight.rs new file mode 100644 index 0000000000..9ae6341054 --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/response/preflight.rs @@ -0,0 +1,423 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! HTTP response preflight and stage selection. + +use super::validation::{ + body_restriction, permitted_body_modes, strip_stale_integrity, validate_diagnostics, + validate_inspect, validate_preflight_input, +}; +use super::*; + +impl ChainRunner { + /// Apply selected stages' failure policies when valid HTTP cannot be encoded + /// in the middleware protocol. The caller must validate HTTP safety first. + pub fn http_response_input_unrepresentable( + &self, + entries: &[DescribedChainEntry], + ) -> HttpResponsePreflightOutcome { + response_preflight_input_failure(entries, Vec::new(), "response_input_unrepresentable") + } + + pub async fn preflight_http_response( + &self, + entries: &[ChainEntry], + input: HttpResponsePreflightInput, + ) -> miette::Result { + let described = self.describe_http_response_chain(entries).await?; + self.preflight_described_http_response(described, input) + .await + } + + /// Run preflight with a response-filtered chain that the caller already + /// described. This keeps response parsing and binding selection on one + /// snapshot without repeating remote capability discovery. + pub async fn preflight_described_http_response( + &self, + described: Vec, + input: HttpResponsePreflightInput, + ) -> miette::Result { + if described.is_empty() { + return Ok(empty_preflight_outcome(input.headers)); + } + if validate_preflight_input(&input).is_err() { + return Ok(response_preflight_input_failure( + &described, + input.headers, + "response_input_over_capacity", + )); + } + let session_admission = match self.try_reserve_middleware_session() { + MiddlewareSessionAdmission::Admitted(admission) => admission, + MiddlewareSessionAdmission::AtCapacity => { + return Ok(response_session_capacity_exhausted( + described, + input.headers, + )); + } + }; + let _work = self.reserve_middleware_work_admission().await?; + let original_restriction = body_restriction(&input); + let mut headers = input.headers.clone(); + let mut stages = Vec::new(); + let mut findings = Vec::new(); + let mut metadata = BTreeMap::new(); + let mut invocations = Vec::new(); + + for entry in described { + let Some(service) = entry.service.as_ref() else { + if let Some(reason) = + collect_preflight_failure(&entry, "binding_not_described", &mut invocations) + { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + }; + let (sender, receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let preflight = HttpResponsePreflight { + context: Some(input.context.clone()), + target: Some(input.target.clone()), + status_code: u32::from(input.status_code), + headers: headers.clone(), + middleware_name: entry.entry.implementation.clone(), + config: Some(entry.entry.config.clone()), + max_payload_bytes: entry.max_payload_bytes as u64, + permitted_body_modes: permitted_body_modes( + &input, + &entry, + original_restriction.as_deref(), + ), + }; + let timeout = entry.timeout; + let opened = tokio::time::timeout(timeout, async { + sender + .send(HttpResponseEvent { + event: Some(http_response_event::Event::Preflight(preflight)), + }) + .await + .map_err(|_| tonic::Status::unavailable("middleware request stream closed"))?; + let mut responses = service + .service + .open_http_response_pre_return(receiver) + .await?; + let response = responses.next().await.ok_or_else(|| { + tonic::Status::unavailable("middleware result stream closed") + })??; + Ok::<_, tonic::Status>((responses, response)) + }) + .await; + let (responses, response) = match opened { + Ok(Ok(opened)) => opened, + Ok(Err(error)) => { + let reason = if error.code() == tonic::Code::DeadlineExceeded { + "middleware_timeout".to_string() + } else { + service.diagnostic_policy.error_reason(&error) + }; + if let Some(reason) = + collect_preflight_failure(&entry, &reason, &mut invocations) + { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + Err(_) => { + if let Some(reason) = + collect_preflight_failure(&entry, "middleware_timeout", &mut invocations) + { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + let Some(http_response_event_result::Result::PreflightResult(decision)) = + response.result + else { + if let Some(reason) = collect_preflight_failure( + &entry, + "unexpected_response_result", + &mut invocations, + ) { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + }; + if let Err(reason) = validate_diagnostics( + &decision.reason, + &decision.reason_code, + &decision.findings, + &decision.metadata, + ) { + if let Some(reason) = collect_preflight_failure(&entry, reason, &mut invocations) { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + let reason_code = + (!decision.reason_code.is_empty()).then(|| decision.reason_code.clone()); + let decision_findings = decision.findings; + let decision_metadata = decision.metadata; + match decision.action { + Some(http_response_preflight_result::Action::Skip(_)) => { + collect_preflight_diagnostics( + &entry, + decision_findings, + decision_metadata, + &mut findings, + &mut metadata, + ); + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: HttpResponseInvocationOutcome::Skip, + sequence: None, + input_size: 0, + output_size: None, + failed: false, + stage_disabled: false, + reason_code, + failure_category: None, + }); + let mut skipped = HttpResponseStage { + entry, + transport: Some(HttpResponseStageTransport { sender, responses }), + mode: StageMode::HeadersOnly, + next_sequence: 1, + whole_body: Vec::new(), + }; + skipped.end(MiddlewareSessionEndReason::StageSkipped).await; + } + Some(http_response_preflight_result::Action::Inspect(inspect)) => { + let permitted_modes = + permitted_body_modes(&input, &entry, original_restriction.as_deref()); + let mode = match validate_inspect(&entry, &inspect, &permitted_modes) { + Ok(mode) => mode, + Err(reason) => { + if let Some(reason) = + collect_preflight_failure(&entry, &reason, &mut invocations) + { + end_stages( + &mut stages, + MiddlewareSessionEndReason::MiddlewareFailure, + ) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + let updated = match headers::apply( + headers::HeaderAuthority::Response, + &headers, + &input.connection_nominated_headers, + &inspect.header_mutations, + ) { + Ok(updated) => updated, + Err(error) => { + let reason = service + .diagnostic_policy + .header_mutation_error_reason(&error); + if let Some(reason) = + collect_preflight_failure(&entry, &reason, &mut invocations) + { + end_stages( + &mut stages, + MiddlewareSessionEndReason::MiddlewareFailure, + ) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + headers = updated; + if mode == StageMode::Stream { + strip_stale_integrity(&mut headers); + } + collect_preflight_diagnostics( + &entry, + decision_findings, + decision_metadata, + &mut findings, + &mut metadata, + ); + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: match mode { + StageMode::HeadersOnly => HttpResponseInvocationOutcome::HeadersOnly, + StageMode::WholeBody => HttpResponseInvocationOutcome::WholeBody, + StageMode::Stream => HttpResponseInvocationOutcome::Stream, + }, + sequence: None, + input_size: 0, + output_size: None, + failed: false, + stage_disabled: false, + reason_code, + failure_category: None, + }); + let mut stage = HttpResponseStage { + entry, + transport: Some(HttpResponseStageTransport { sender, responses }), + mode, + next_sequence: 1, + whole_body: Vec::new(), + }; + if mode == StageMode::HeadersOnly { + stage.end(MiddlewareSessionEndReason::Normal).await; + } else { + stages.push(stage); + } + } + Some(http_response_preflight_result::Action::BlockDelivery(_)) => { + collect_preflight_diagnostics( + &entry, + decision_findings, + decision_metadata, + &mut findings, + &mut metadata, + ); + invocations.push(HttpResponseInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: HttpResponseInvocationOutcome::BlockDelivery, + sequence: None, + input_size: 0, + output_size: None, + failed: false, + stage_disabled: false, + reason_code: reason_code.clone(), + failure_category: None, + }); + stages.push(HttpResponseStage { + entry: entry.clone(), + transport: Some(HttpResponseStageTransport { sender, responses }), + mode: StageMode::HeadersOnly, + next_sequence: 1, + whole_body: Vec::new(), + }); + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareDenial).await; + return Ok(blocked_preflight_outcome( + headers, + crate::MiddlewareDenial { + config_name: entry.entry.name.clone(), + reason_code, + }, + findings, + metadata, + invocations, + )); + } + None => { + if let Some(reason) = collect_preflight_failure( + &entry, + "invalid_preflight_decision", + &mut invocations, + ) { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Ok(failed_preflight_outcome( + headers, + reason, + findings, + metadata, + invocations, + )); + } + } + } + } + + if stages.is_empty() { + drop(session_admission); + return Ok(HttpResponsePreflightOutcome { + allowed: true, + reason: String::new(), + denial: None, + headers, + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }); + } + let defer_output_until_finish = stages + .iter() + .any(|stage| stage.is_active() && stage.mode == StageMode::WholeBody); + Ok(HttpResponsePreflightOutcome { + allowed: true, + reason: String::new(), + denial: None, + headers, + session: Some(HttpResponseSession { + runner: self.clone(), + stages, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + session_admission: Some(session_admission), + body_transformed: false, + retained_body_bytes: 0, + defer_output_until_finish, + deferred_output: Vec::new(), + connection_nominated_headers: input.connection_nominated_headers, + whole_body_deadline: None, + }), + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }) + } +} diff --git a/crates/openshell-supervisor-middleware/src/response/validation.rs b/crates/openshell-supervisor-middleware/src/response/validation.rs new file mode 100644 index 0000000000..03febd4479 --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/response/validation.rs @@ -0,0 +1,328 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! HTTP response middleware protocol and payload validation. + +use super::*; + +pub(super) enum BodyAction { + PassThrough, + Transform(Vec), + BlockDelivery, + SkipRemaining(CurrentBodyAction), +} + +pub(super) enum CurrentBodyAction { + PassThrough, + Transform(Vec), +} + +pub(super) struct BodyDecision { + pub(super) action: BodyAction, + pub(super) reason_code: String, + pub(super) findings: Vec, + pub(super) metadata: std::collections::HashMap, +} + +pub(super) struct TrailersDecision { + pub(super) headers: Vec, + pub(super) reason_code: String, + pub(super) findings: Vec, + pub(super) metadata: std::collections::HashMap, +} + +pub(super) fn validate_trailers_result( + result: HttpResponseEventResult, + trailers: &[HttpHeader], + entry: &DescribedChainEntry, + connection_nominated_headers: &[String], +) -> Result { + let Some(http_response_event_result::Result::TrailersResult(result)) = result.result else { + return Err("unexpected_response_result".into()); + }; + validate_diagnostics( + &result.reason, + &result.reason_code, + &result.findings, + &result.metadata, + ) + .map_err(str::to_string)?; + if result.trailer_mutations.len() > headers::MAX_HEADER_MUTATIONS { + return Err("header_mutation_count_over_capacity".into()); + } + let encoded_mutations = result + .trailer_mutations + .iter() + .fold(0usize, |total, mutation| { + total.saturating_add(mutation.encoded_len()) + }); + if encoded_mutations > MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES { + return Err("header_mutation_bytes_over_capacity".into()); + } + let headers = headers::apply( + headers::HeaderAuthority::ResponseTrailers, + trailers, + connection_nominated_headers, + &result.trailer_mutations, + ) + .map_err(|error| { + entry.service.as_ref().map_or_else( + || error.to_string(), + |service| { + service + .diagnostic_policy + .header_mutation_error_reason(&error) + }, + ) + })?; + Ok(TrailersDecision { + headers, + reason_code: result.reason_code, + findings: result.findings, + metadata: result.metadata, + }) +} + +pub(super) fn encoded_header_bytes(headers: &[HttpHeader]) -> usize { + headers.iter().fold(0usize, |total, header| { + total.saturating_add(header.encoded_len()) + }) +} + +pub(super) fn validate_body_result( + result: HttpResponseEventResult, + sequence: u64, + max_payload_bytes: usize, +) -> Result { + let Some(http_response_event_result::Result::BodyResult(body)) = result.result else { + return Err("unexpected_response_result"); + }; + if body.sequence != sequence { + return Err("response_body_sequence_mismatch"); + } + validate_diagnostics( + &body.reason, + &body.reason_code, + &body.findings, + &body.metadata, + )?; + let action = match body.action { + Some(http_response_body_result::Action::PassThrough(HttpResponseBodyPassThrough {})) => { + BodyAction::PassThrough + } + Some(http_response_body_result::Action::Transform(transform)) => BodyAction::Transform( + validate_replacement(transform.replacement, max_payload_bytes)?, + ), + Some(http_response_body_result::Action::BlockDelivery(_)) => BodyAction::BlockDelivery, + Some(http_response_body_result::Action::SkipRemaining(skip)) => { + let current = match skip.current { + Some(http_response_body_skip_remaining::Current::PassThrough( + HttpResponseBodyPassThrough {}, + )) => CurrentBodyAction::PassThrough, + Some(http_response_body_skip_remaining::Current::Transform(transform)) => { + CurrentBodyAction::Transform(validate_replacement( + transform.replacement, + max_payload_bytes, + )?) + } + None => return Err("invalid_response_body_skip_remaining_action"), + }; + BodyAction::SkipRemaining(current) + } + None => return Err("invalid_response_body_decision"), + }; + Ok(BodyDecision { + action, + reason_code: body.reason_code, + findings: body.findings, + metadata: body.metadata, + }) +} + +fn validate_replacement( + replacement: Option, + max_payload_bytes: usize, +) -> Result, &'static str> { + let Some(http_response_body_transform::Replacement::Data(replacement)) = replacement else { + return Err("response_body_replacement_missing"); + }; + if replacement.len() > max_payload_bytes { + return Err("response_body_replacement_over_capacity"); + } + Ok(replacement) +} + +pub(super) fn validate_inspect( + entry: &DescribedChainEntry, + inspect: &openshell_core::proto::HttpResponsePreflightInspect, + permitted_modes: &[i32], +) -> Result { + let mode = match HttpResponseBodyMode::try_from(inspect.body_mode) { + Ok(HttpResponseBodyMode::HeadersOnly) => StageMode::HeadersOnly, + Ok(HttpResponseBodyMode::WholeBodyBytes) => StageMode::WholeBody, + Ok(HttpResponseBodyMode::StreamBytes) => StageMode::Stream, + Ok(HttpResponseBodyMode::Unspecified) | Err(_) => { + return Err("invalid_response_body_mode".into()); + } + }; + if !permitted_modes.contains(&inspect.body_mode) { + return Err("response_body_mode_not_permitted".into()); + } + if inspect.header_mutations.len() > headers::MAX_HEADER_MUTATIONS { + return Err("header_mutation_count_over_capacity".into()); + } + let encoded_mutations = inspect + .header_mutations + .iter() + .fold(0usize, |total, mutation| { + total.saturating_add(mutation.encoded_len()) + }); + if encoded_mutations > MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES { + return Err("header_mutation_bytes_over_capacity".into()); + } + if entry.max_payload_bytes == 0 && mode != StageMode::HeadersOnly { + return Err("response_payload_limit_invalid".into()); + } + Ok(mode) +} + +pub(super) fn validate_preflight_input(input: &HttpResponsePreflightInput) -> miette::Result<()> { + if input.context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES { + return Err(miette::miette!("response context exceeds platform limit")); + } + if input.target.encoded_len() > MAX_MIDDLEWARE_TARGET_BYTES { + return Err(miette::miette!("response target exceeds platform limit")); + } + if input.headers.len() > MAX_MIDDLEWARE_HEADERS { + return Err(miette::miette!( + "response header count exceeds platform limit" + )); + } + if input.headers.iter().fold(0usize, |total, header| { + total.saturating_add(header.encoded_len()) + }) > MAX_MIDDLEWARE_HEADER_BYTES + { + return Err(miette::miette!("response headers exceed platform limit")); + } + Ok(()) +} + +pub(super) fn validate_diagnostics( + reason: &str, + reason_code: &str, + findings: &[Finding], + metadata: &std::collections::HashMap, +) -> Result<(), &'static str> { + if reason.len() > MAX_MIDDLEWARE_REASON_BYTES { + return Err("response_reason_over_capacity"); + } + if !reason_code.is_empty() + && (reason_code.len() > MAX_MIDDLEWARE_REASON_CODE_BYTES + || !is_stable_reason_code(reason_code)) + { + return Err("response_reason_code_invalid"); + } + if findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE { + return Err("response_findings_over_capacity"); + } + if findings + .iter() + .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) + { + return Err("response_finding_over_capacity"); + } + if metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES { + return Err("response_metadata_count_over_capacity"); + } + if metadata.iter().fold(0usize, |total, (key, value)| { + total.saturating_add(key.len()).saturating_add(value.len()) + }) > MAX_MIDDLEWARE_METADATA_BYTES + { + return Err("response_metadata_bytes_over_capacity"); + } + Ok(()) +} + +pub(super) fn body_restriction(input: &HttpResponsePreflightInput) -> Option { + if input.target.method.eq_ignore_ascii_case("HEAD") + || input.status_code == 204 + || input.status_code == 304 + { + return Some("bodyless_response".into()); + } + if input.status_code == 206 + || input + .headers + .iter() + .any(|header| header.name.eq_ignore_ascii_case("content-range")) + || input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-type") + && header + .value + .split(';') + .next() + .is_some_and(|value| value.trim().eq_ignore_ascii_case("multipart/byteranges")) + }) + { + return Some("unsupported_partial_response".into()); + } + if input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("cache-control") + && header.value.split(',').any(|directive| { + directive + .split('=') + .next() + .is_some_and(|name| name.trim().eq_ignore_ascii_case("no-transform")) + }) + }) { + return Some("response_no_transform".into()); + } + if input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-encoding") + && header + .value + .split(',') + .any(|coding| !coding.trim().eq_ignore_ascii_case("identity")) + }) { + return Some("unsupported_content_encoding".into()); + } + None +} + +pub(super) fn permitted_body_modes( + input: &HttpResponsePreflightInput, + entry: &DescribedChainEntry, + body_restriction: Option<&str>, +) -> Vec { + let mut modes = vec![HttpResponseBodyMode::HeadersOnly as i32]; + if body_restriction.is_some() { + return modes; + } + if input + .declared_body_length + .is_none_or(|length| length <= entry.max_payload_bytes as u64) + && !is_open_ended_response(input) + { + modes.push(HttpResponseBodyMode::WholeBodyBytes as i32); + } + if entry.max_payload_bytes > 0 { + modes.push(HttpResponseBodyMode::StreamBytes as i32); + } + modes +} + +fn is_open_ended_response(input: &HttpResponsePreflightInput) -> bool { + input.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-type") + && matches!( + header.value.split(';').next().map(str::trim), + Some(value) + if value.eq_ignore_ascii_case("text/event-stream") + || value.eq_ignore_ascii_case("multipart/x-mixed-replace") + ) + }) +} + +pub(super) fn strip_stale_integrity(headers: &mut Vec) { + headers.retain(|header| !is_stale_http_response_integrity_header(&header.name)); +} diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index f8a22a9bb9..4884690e1e 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -26,6 +26,89 @@ pub enum MiddlewareApplyResult { AdmissionExhausted, } +/// One destination-selected middleware chain shared by an HTTP request and +/// its matching response. The request and response phases filter bindings +/// independently, so the full chain must remain available until relay ends. +#[derive(Clone)] +pub struct HttpMiddlewareExchange { + request_id: String, + chain: Vec, + runner: openshell_supervisor_middleware::ChainRunner, + generation_guard: PolicyGenerationGuard, +} + +impl HttpMiddlewareExchange { + pub fn new( + request_id: String, + chain: Vec, + runner: openshell_supervisor_middleware::ChainRunner, + generation_guard: PolicyGenerationGuard, + ) -> Self { + Self { + request_id, + chain, + runner, + generation_guard, + } + } + + pub async fn apply_request( + &self, + request: crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + scheme: &str, + transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, + ) -> Result + where + C: AsyncRead + AsyncWrite + Unpin + Send, + { + apply_middleware_chain_for_scheme_with_request_id( + request, + client, + ctx, + scheme, + self.chain.clone(), + &self.runner, + &self.generation_guard, + transformed_body_policy, + &self.request_id, + ) + .await + } + + pub fn response_relay<'a>( + &'a self, + request: &crate::l7::provider::L7Request, + ctx: &'a L7EvalContext, + scheme: &str, + ) -> crate::l7::rest::HttpResponseMiddlewareRelay<'a> { + let sandbox = openshell_ocsf::ctx::ctx(); + crate::l7::rest::HttpResponseMiddlewareRelay { + chain: &self.chain, + runner: &self.runner, + request_context: openshell_core::proto::RequestContext { + request_id: self.request_id.clone(), + sandbox_id: sandbox.sandbox_id.clone(), + sandbox_name: sandbox.sandbox_name.clone(), + workspace: ctx.workspace.clone(), + originating_process: None, + }, + target: openshell_core::proto::HttpRequestTarget { + scheme: scheme.to_string(), + host: ctx.host.clone(), + port: u32::from(ctx.port), + method: request.action.clone(), + path: request.target.clone(), + query: super::relay::policy_safe_response_query(&request.query_params), + }, + policy_name: &ctx.policy_name, + generation_guard: Some(&self.generation_guard), + whole_body_timeout: super::rest::DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT, + } + } +} + /// How traffic a middleware chain can never inspect (h2c, non-HTTP TCP, /// protocols without an L7 relay) must be handled for a matching chain. /// diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 9bb0af5f5b..67223c86b0 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -351,7 +351,7 @@ pub(crate) fn http_response_middleware_relay<'a>( } } -fn policy_safe_response_query( +pub(super) fn policy_safe_response_query( query_params: &std::collections::HashMap>, ) -> String { let mut parameters: Vec<_> = query_params.iter().collect(); @@ -2881,21 +2881,24 @@ where return Ok(()); } let runner = engine.middleware_runner()?; - response_selection = Some((chain.clone(), runner.clone())); + let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( + request_id.clone(), + chain, + runner, + generation_guard.clone(), + ); // The passthrough path enforces no L7 policy, so there is no // body-aware decision to re-check after a transformation. - match apply_middleware_chain_with_request_id( - req, - client, - ctx, - chain, - &runner, - generation_guard, - openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, - &request_id, - ) - .await? - { + let result = exchange + .apply_request( + req, + client, + ctx, + "http", + openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + ) + .await?; + let request = match result { MiddlewareApplyResult::Allowed(request) => request, MiddlewareApplyResult::Denied { denial, .. } => { let denied_request = crate::l7::provider::L7Request { @@ -2932,7 +2935,9 @@ where .await?; return Ok(()); } - } + }; + response_selection = Some(exchange); + request } else { req }; @@ -2954,17 +2959,9 @@ where let scoped_ctx = scoped_context_for_request(ctx, &req_with_auth); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); let resolver = ctx.secret_resolver.as_deref(); - let response_middleware = response_selection.as_ref().map(|(chain, runner)| { - http_response_middleware_relay( - &req_with_auth, - ctx, - "http", - &request_id, - chain, - runner, - Some(generation_guard), - ) - }); + let response_middleware = response_selection + .as_ref() + .map(|exchange| exchange.response_relay(&req_with_auth, ctx, "http")); // Forward request with credential rewriting and relay the response. // relay_http_request_with_resolver handles both directions: it sends diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 2797916e2f..776154168d 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -7,6 +7,19 @@ //! policy, and relays allowed requests to upstream. Handles Content-Length //! and chunked transfer encoding for body framing. +mod http_response; + +pub(crate) use http_response::{ + DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT, HttpResponseMiddlewareRelay, +}; +use http_response::{RelayResponseOptions, relay_response}; +#[cfg(test)] +use http_response::{ + http_response_middleware_fail_open_finding_event, http_response_middleware_invocation_events, + parse_connection_close, parse_status_code, response_is_event_stream, + strip_response_integrity_headers, +}; + use crate::l7::provider::{BodyLength, L7Provider, L7Request, RelayOutcome}; use crate::opa::PolicyGenerationGuard; use aws_sigv4::http_request::SignableBody; @@ -809,21 +822,6 @@ where relay_http_request_with_response_middleware_guarded(req, client, upstream, options, None).await } -/// Default wall-clock bound shared by whole-body stages in one response. -pub(crate) const DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT: std::time::Duration = - std::time::Duration::from_mins(2); - -/// Context retained from request evaluation for the matching response hook. -pub(crate) struct HttpResponseMiddlewareRelay<'a> { - pub(crate) chain: &'a [openshell_supervisor_middleware::ChainEntry], - pub(crate) runner: &'a openshell_supervisor_middleware::ChainRunner, - pub(crate) request_context: RequestContext, - pub(crate) target: HttpRequestTarget, - pub(crate) policy_name: &'a str, - pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>, - pub(crate) whole_body_timeout: std::time::Duration, -} - pub(crate) async fn relay_http_request_with_response_middleware_guarded( req: &L7Request, client: &mut C, @@ -3136,2052 +3134,158 @@ fn find_crlf(buf: &[u8], start: usize) -> Option { .map(|offset| start + offset) } -#[derive(Clone)] -struct RelayResponseOptions { - websocket_extensions: WebSocketExtensionMode, - client_requested_upgrade: bool, - websocket: Option, -} - -impl Default for RelayResponseOptions { - fn default() -> Self { - Self { - websocket_extensions: WebSocketExtensionMode::Preserve, - client_requested_upgrade: true, - websocket: None, - } - } -} - -async fn relay_response( - request_method: &str, - upstream: &mut U, - client: &mut C, - options: RelayResponseOptions, - response_middleware: Option>, -) -> Result -where - U: AsyncRead + Unpin, - C: AsyncWrite + Unpin, -{ - let started_at = std::time::Instant::now(); - let mut buf = Vec::with_capacity(4096); - let mut tmp = [0u8; 1024]; +fn validate_websocket_response( + headers: &str, + mode: WebSocketExtensionMode, + websocket: Option<&WebSocketResponseValidation>, +) -> Result<(bool, Option)> { + let Some(validation) = websocket else { + return validate_websocket_response_extensions_preserved(headers, mode) + .map(|compressed| (compressed, None)); + }; - // Read response headers. Forward interim responses unchanged, but retain - // the final response head until response middleware preflight completes. - loop { - if buf.len() > MAX_HEADER_BYTES { - return Err(miette!("HTTP response headers exceed limit")); - } + let mut upgrade_websocket = false; + let mut connection_upgrade = false; + let mut accept_count = 0usize; + let mut accept_matches = false; + let mut subprotocol_count = 0usize; + let mut selected_subprotocol = None; - let n = upstream.read(&mut tmp).await.into_diagnostic()?; - if n == 0 { - // Upstream closed — forward whatever we have - if !buf.is_empty() { - client.write_all(&buf).await.into_diagnostic()?; + for line in headers.lines().skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + let name = name.trim().to_ascii_lowercase(); + let value = value.trim(); + match name.as_str() { + "upgrade" if header_value_contains_token(value, "websocket") => { + upgrade_websocket = true; } - return Ok(RelayOutcome::Consumed); - } - buf.extend_from_slice(&tmp[..n]); - - while let Some(position) = buf.windows(4).position(|w| w == b"\r\n\r\n") { - let header_end = position + 4; - let header_str = String::from_utf8_lossy(&buf[..header_end]); - let status_code = parse_status_code(&header_str).unwrap_or(200); - if (100..200).contains(&status_code) && status_code != 101 { - client - .write_all(&buf[..header_end]) - .await - .into_diagnostic()?; - client.flush().await.into_diagnostic()?; - buf.drain(..header_end); - continue; + "connection" if header_value_contains_token(value, "upgrade") => { + connection_upgrade = true; } - break; - } - if buf.windows(4).any(|w| w == b"\r\n\r\n") { - break; + "sec-websocket-accept" => { + accept_count += 1; + accept_matches = value == validation.expected_accept; + } + "sec-websocket-protocol" => { + subprotocol_count += 1; + if !is_http_token(value) { + return Err(miette!( + "websocket upgrade response has invalid Sec-WebSocket-Protocol" + )); + } + selected_subprotocol = Some(value.to_string()); + } + _ => {} } } - let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; - - // Parse response framing - let header_str = String::from_utf8_lossy(&buf[..header_end]); - let status_code = parse_status_code(&header_str).unwrap_or(200); - let server_wants_close = parse_connection_close(&header_str); - let event_stream = response_is_event_stream(&header_str); - let body_length = parse_body_length(&header_str)?; - - debug!( - status_code, - ?body_length, - server_wants_close, - request_method, - overflow_bytes = buf.len() - header_end, - "relay_response framing" - ); - - // 101 Switching Protocols: the connection has been upgraded (e.g. to - // WebSocket). Forward the 101 headers to the client and signal the - // caller to switch to raw bidirectional TCP relay. Any bytes read - // from upstream beyond the headers are overflow that belong to the - // upgraded protocol and must be forwarded before switching. - if status_code == 101 { - if !options.client_requested_upgrade { - return Ok(RelayOutcome::Consumed); - } - let (websocket_permessage_deflate, websocket_subprotocol) = validate_websocket_response( - &header_str, - options.websocket_extensions, - options.websocket.as_ref(), - )?; - client - .write_all(&buf[..header_end]) - .await - .into_diagnostic()?; - client.flush().await.into_diagnostic()?; - let overflow = buf[header_end..].to_vec(); - debug!( - request_method, - overflow_bytes = overflow.len(), - "101 Switching Protocols — signaling protocol upgrade" - ); - return Ok(RelayOutcome::Upgraded { - overflow, - websocket_permessage_deflate, - websocket_subprotocol, - }); + if !upgrade_websocket { + return Err(miette!( + "websocket upgrade response missing Upgrade: websocket" + )); } - - if let Some(response_middleware) = response_middleware - && let Some(outcome) = Box::pin(relay_response_through_middleware( - request_method, - upstream, - client, - response_middleware, - &buf, - header_end, - status_code, - body_length, - server_wants_close, - event_stream, - )) - .await? - { - return Ok(outcome); + if !connection_upgrade { + return Err(miette!( + "websocket upgrade response missing Connection: Upgrade" + )); } - - // Bodiless responses (HEAD, 1xx, 204, 304): forward headers only, skip body - if is_bodiless_response(request_method, status_code) { - client - .write_all(&buf[..header_end]) - .await - .into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return if server_wants_close { - Ok(RelayOutcome::Consumed) - } else { - Ok(RelayOutcome::Reusable) - }; + if accept_count != 1 || !accept_matches { + return Err(miette!( + "websocket upgrade response has invalid Sec-WebSocket-Accept" + )); + } + if subprotocol_count > 1 { + return Err(miette!( + "websocket upgrade response has multiple Sec-WebSocket-Protocol headers" + )); + } + if let Some(ref protocol) = selected_subprotocol + && !validation + .offered_subprotocols + .iter() + .any(|offered| offered == protocol) + { + return Err(miette!( + "upstream selected WebSocket subprotocol that was not offered" + )); } - // No explicit framing (no Content-Length, no Transfer-Encoding). - // Per RFC 7230 §3.3.3 the body is delimited by connection close. - if matches!(body_length, BodyLength::None) { - if server_wants_close || event_stream { - // Server indicated it will close, or this is a streaming response - // such as SSE where the body is intentionally delimited by EOF. - let before_end = &buf[..header_end - 2]; - client.write_all(before_end).await.into_diagnostic()?; - if server_wants_close { - client - .write_all(b"Connection: close\r\n\r\n") - .await - .into_diagnostic()?; - } else { - client.write_all(b"\r\n").await.into_diagnostic()?; - } - let overflow = &buf[header_end..]; - if !overflow.is_empty() { - client.write_all(overflow).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - } - if event_stream { - relay_until_eof_without_idle_timeout(upstream, client).await?; - } else { - relay_until_eof(upstream, client).await?; - } - client.flush().await.into_diagnostic()?; - return Ok(RelayOutcome::Consumed); + let actual_extension = normalized_websocket_extension(headers)?; + match (&validation.expected_extension, actual_extension.as_deref()) { + (None, Some(_)) => Err(miette!( + "upstream negotiated WebSocket extension that was not offered" + )), + (None | Some(_), None) => Ok((false, selected_subprotocol)), + (Some(expected), Some(actual)) if expected.eq_ignore_ascii_case(actual) => { + Ok((true, selected_subprotocol)) } - // No Connection: close — an HTTP/1.1 keep-alive server that omits - // framing headers has an empty body. Forward headers and continue - // the relay loop instead of blocking on relay_until_eof. - debug!("BodyLength::None without Connection: close — treating body as empty"); - client - .write_all(&buf[..header_end]) - .await - .into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(RelayOutcome::Reusable); + (Some(_), Some(_)) => Err(miette!( + "upstream negotiated WebSocket extension that does not match the safe offer" + )), } +} - // Forward response headers + any overflow body bytes - client.write_all(&buf).await.into_diagnostic()?; - let overflow_len = (buf.len() - header_end) as u64; - - // Forward remaining response body - match body_length { - BodyLength::ContentLength(len) => { - let remaining = len.saturating_sub(overflow_len); - if remaining > 0 { - relay_fixed(upstream, client, remaining, None).await?; +fn validate_websocket_response_extensions_preserved( + headers: &str, + mode: WebSocketExtensionMode, +) -> Result { + match mode { + WebSocketExtensionMode::Preserve => Ok(false), + WebSocketExtensionMode::PermessageDeflate => { + let offers = websocket_extension_offers(headers)?; + if offers.is_empty() { + Ok(false) + } else { + Err(miette!( + "upstream negotiated WebSocket extension that was not offered" + )) } } - BodyLength::Chunked => { - relay_chunked(upstream, client, &buf[header_end..], None).await?; - } - BodyLength::None => unreachable!(), } - client.flush().await.into_diagnostic()?; - debug!( - request_method, - elapsed_ms = started_at.elapsed().as_millis(), - "relay_response complete (explicit framing)" - ); - - // When body framing is explicit (Content-Length / Chunked), always report - // the connection as reusable so the relay loop continues. If the server - // sent `Connection: close`, the *next* upstream write will fail and the - // loop will exit via the normal error path. Exiting early here would - // tear down the CONNECT tunnel before the client can detect the close, - // causing ~30 s retry delays in clients like `gh`. - Ok(RelayOutcome::Reusable) } -#[allow(clippy::too_many_arguments)] -async fn relay_response_through_middleware( - request_method: &str, - upstream: &mut U, - client: &mut C, - middleware: HttpResponseMiddlewareRelay<'_>, - buffered: &[u8], - header_end: usize, - status_code: u16, - body_length: BodyLength, - server_wants_close: bool, - event_stream: bool, -) -> Result> -where - U: AsyncRead + Unpin, - C: AsyncWrite + Unpin, -{ - if let Some(guard) = middleware.generation_guard { - guard.ensure_current()?; - } - let header_bytes = &buffered[..header_end]; - // Ordinary responses retain the HTTP parser's byte-preserving behavior. - // Response-specific normalization and limits apply only to selected hooks. - if middleware.chain.is_empty() { +fn normalized_websocket_extension(headers: &str) -> Result> { + let offers = websocket_extension_offers(headers)?; + if offers.is_empty() { return Ok(None); } - let parsed = match middleware - .runner - .describe_http_response_chain(middleware.chain) - .await - { - Ok(described) if described.is_empty() => return Ok(None), - Ok(described) => { - parse_response_head_for_middleware(header_bytes).map(|parsed| (described, parsed)) - } - Err(error) => Err(error), - }; - let (described, parsed) = match parsed { - Ok(parsed) => parsed, - Err(error) => { - debug!(error = %error, "HTTP response head normalization failed"); - emit_http_response_middleware_failure( - middleware.policy_name, - &middleware.target, - status_code, - false, - ); - send_response_delivery_failure( - client, - request_method, - middleware.policy_name, - &middleware.target, - ) - .await?; - return Ok(Some(RelayOutcome::Consumed)); - } - }; - let original_headers = parsed.headers.clone(); - let upstream_declared_trailers = parsed.declared_trailers.clone(); - let connection_nominated_headers = parsed.connection_nominated.clone(); - let input = openshell_supervisor_middleware::HttpResponsePreflightInput { - context: middleware.request_context, - target: middleware.target.clone(), - status_code, - declared_body_length: match body_length { - BodyLength::ContentLength(length) => Some(length), - BodyLength::Chunked | BodyLength::None => None, - }, - headers: parsed.headers, - connection_nominated_headers: parsed.connection_nominated, - }; - let preflight_result = if parsed.representable { - middleware - .runner - .preflight_http_response(middleware.chain, input) - .await - } else { - Ok(middleware - .runner - .http_response_input_unrepresentable(&described)) - }; - let preflight = match preflight_result { - Ok(preflight) => preflight, - Err(error) => { - debug!(error = %error, "HTTP response middleware preflight failed"); - emit_http_response_middleware_failure( - middleware.policy_name, - &middleware.target, - status_code, - false, - ); - send_response_delivery_failure( - client, - request_method, - middleware.policy_name, - &middleware.target, - ) - .await?; - return Ok(Some(RelayOutcome::Consumed)); + if offers.len() != 1 { + return Err(miette!("upstream negotiated multiple WebSocket extensions")); + } + let offer = &offers[0]; + if !offer.name.eq_ignore_ascii_case("permessage-deflate") { + return Err(miette!( + "upstream negotiated unsupported WebSocket extension" + )); + } + let mut client_no_context_takeover = false; + let mut server_no_context_takeover = false; + let mut seen = HashSet::new(); + for param in &offer.params { + let name = param.name.to_ascii_lowercase(); + if param.value.is_some() || !seen.insert(name.clone()) { + return Err(miette!( + "upstream negotiated unsupported permessage-deflate parameter" + )); } - }; - debug!( - configured_stage_count = middleware.chain.len(), - active_session = preflight.session.is_some(), - allowed = preflight.allowed, - "HTTP response middleware preflight completed" - ); - for event in crate::l7::middleware::middleware_finding_events(&preflight.findings) { - openshell_ocsf::ocsf_emit!(event); - } - if !preflight.allowed { - emit_http_response_middleware_invocations( - middleware.policy_name, - &middleware.target, - status_code, - &preflight.invocations, - ); - if let Some(denial) = preflight.denial.as_ref() { - send_response_middleware_denial( - client, - request_method, - middleware.policy_name, - &middleware.target, - denial, - ) - .await?; + if name == "client_no_context_takeover" { + client_no_context_takeover = true; + } else if name == "server_no_context_takeover" { + server_no_context_takeover = true; } else { - emit_http_response_middleware_failure( - middleware.policy_name, - &middleware.target, - status_code, - false, - ); - send_response_delivery_failure( - client, - request_method, - middleware.policy_name, - &middleware.target, - ) - .await?; + return Err(miette!( + "upstream negotiated unsupported permessage-deflate parameter" + )); } - return Ok(Some(RelayOutcome::Consumed)); } - emit_http_response_middleware_invocations( - middleware.policy_name, - &middleware.target, - status_code, - &preflight.invocations, - ); - - let Some(mut session) = preflight.session else { - if preflight.headers == original_headers { - return Ok(None); - } - let status_line = response_status_line(header_bytes)?; - let outcome = relay_headers_only_response( - request_method, - upstream, - client, - &status_line, - &preflight.headers, - &upstream_declared_trailers, - &buffered[header_end..], - status_code, - body_length, - server_wants_close, - event_stream, - ) - .await?; - return Ok(Some(outcome)); - }; - - let status_line = response_status_line(header_bytes)?; - let supports_chunked_response = !status_line.starts_with("HTTP/1.0 "); - let bodiless = is_bodiless_response(request_method, status_code); - if bodiless { - let finish = match session.finish(Vec::new()).await { - Ok(finish) => finish, - Err(error) => { - debug!(error = %error, "HTTP response middleware finalization failed"); - emit_http_response_diagnostics( - middleware.policy_name, - &middleware.target, - status_code, - &error.diagnostics, - ); - send_response_delivery_failure( - client, - request_method, - middleware.policy_name, - &middleware.target, - ) - .await?; - return Ok(Some(RelayOutcome::Consumed)); - } - }; - let mut headers = preflight.headers; - emit_http_response_middleware_invocations( - middleware.policy_name, - &middleware.target, - status_code, - &finish.invocations, - ); - for event in crate::l7::middleware::middleware_finding_events(&finish.findings) { - openshell_ocsf::ocsf_emit!(event); - } - if finish.strip_stale_integrity_headers { - strip_response_integrity_headers(&mut headers); - } - let head = serialize_response_head( - &status_line, - &headers, - ResponseFraming::Preserve(body_length), - server_wants_close, - &[], - ); - client.write_all(&head).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(Some(if server_wants_close { - RelayOutcome::Consumed - } else { - RelayOutcome::Reusable - })); + let mut normalized = String::from("permessage-deflate"); + if client_no_context_takeover { + normalized.push_str("; client_no_context_takeover"); } - - let whole_body = session.requires_whole_body(); - if whole_body { - session.start_whole_body_deadline(middleware.whole_body_timeout); - } - let unit_limit = session.stream_unit_limit().max(1); - let chunked_output = supports_chunked_response; - let close_delimited_output = !supports_chunked_response; - let declared_trailers = upstream_declared_trailers; - let downstream_trailers = if chunked_output { - declared_trailers.as_slice() - } else { - &[] - }; - let streaming_head = serialize_response_head( - &status_line, - &preflight.headers, - if chunked_output { - ResponseFraming::Chunked - } else { - ResponseFraming::Preserve(BodyLength::None) - }, - server_wants_close || close_delimited_output, - downstream_trailers, - ); - let mut committed = !whole_body; - if committed { - if let Err(error) = client.write_all(&streaming_head).await { - session - .end(openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect) - .await; - return Err(error).into_diagnostic(); - } - if let Err(error) = client.flush().await { - session - .end(openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect) - .await; - return Err(error).into_diagnostic(); - } - } - - let mut reader = BufferedResponseReader::new(upstream, &buffered[header_end..]); - let body_result = relay_normalized_response_body( - &mut reader, - &mut session, - client, - body_length, - server_wants_close, - event_stream, - &mut committed, - chunked_output, - &streaming_head, - unit_limit, - middleware.generation_guard, - middleware.policy_name, - &middleware.target, - status_code, - &connection_nominated_headers, - ) - .await; - let trailers = match body_result { - Ok(trailers) => trailers, - Err(error) => { - let middleware_stop = error.downcast_ref::(); - let end_reason = if middleware - .generation_guard - .is_some_and(PolicyGenerationGuard::is_stale) - { - openshell_core::proto::MiddlewareSessionEndReason::PolicyReload - } else if error - .to_string() - .starts_with("HTTP response client write failed:") - { - openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect - } else if middleware_stop.is_some_and(|stop| stop.failure.denial.is_some()) { - openshell_core::proto::MiddlewareSessionEndReason::MiddlewareDenial - } else if middleware_stop.is_some() { - openshell_core::proto::MiddlewareSessionEndReason::MiddlewareFailure - } else { - openshell_core::proto::MiddlewareSessionEndReason::UpstreamDisconnect - }; - session.end(end_reason).await; - if committed { - emit_http_response_middleware_failure( - middleware.policy_name, - &middleware.target, - status_code, - true, - ); - return Err(error); - } - debug!(error = %error, "HTTP response processing failed before commitment"); - if let Some(denial) = middleware_stop.and_then(|stop| stop.failure.denial.as_ref()) { - send_response_middleware_denial( - client, - request_method, - middleware.policy_name, - &middleware.target, - denial, - ) - .await?; - } else { - emit_http_response_middleware_failure( - middleware.policy_name, - &middleware.target, - status_code, - false, - ); - send_response_delivery_failure( - client, - request_method, - middleware.policy_name, - &middleware.target, - ) - .await?; - } - return Ok(Some(RelayOutcome::Consumed)); - } - }; - - if let Some(guard) = middleware.generation_guard - && let Err(error) = guard.ensure_current() - { - session - .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) - .await; - return Err(error); - } - - let finish = match session.finish(trailers).await { - Ok(finish) => finish, - Err(error) => { - emit_http_response_diagnostics( - middleware.policy_name, - &middleware.target, - status_code, - &error.diagnostics, - ); - if committed { - emit_http_response_middleware_failure( - middleware.policy_name, - &middleware.target, - status_code, - true, - ); - return Err(miette!( - "HTTP response middleware failed after commitment: {error}" - )); - } - debug!(error = %error, "HTTP response middleware failed before commitment"); - if let Some(denial) = error.denial.as_ref() { - send_response_middleware_denial( - client, - request_method, - middleware.policy_name, - &middleware.target, - denial, - ) - .await?; - } else { - emit_http_response_middleware_failure( - middleware.policy_name, - &middleware.target, - status_code, - false, - ); - send_response_delivery_failure( - client, - request_method, - middleware.policy_name, - &middleware.target, - ) - .await?; - } - return Ok(Some(RelayOutcome::Consumed)); - } - }; - emit_http_response_middleware_invocations( - middleware.policy_name, - &middleware.target, - status_code, - &finish.invocations, - ); - for event in crate::l7::middleware::middleware_finding_events(&finish.findings) { - openshell_ocsf::ocsf_emit!(event); - } - - if whole_body && !committed { - let mut headers = preflight.headers; - if finish.strip_stale_integrity_headers { - strip_response_integrity_headers(&mut headers); - } - let output_length = finish - .body_units - .iter() - .try_fold(0usize, |total, unit| total.checked_add(unit.len())) - .ok_or_else(|| miette!("HTTP response middleware output length overflow"))?; - let framing = if finish.trailers.is_empty() || !supports_chunked_response { - ResponseFraming::ContentLength(output_length as u64) - } else { - ResponseFraming::Chunked - }; - let trailer_names: Vec = if supports_chunked_response { - finish - .trailers - .iter() - .map(|header| header.name.clone()) - .collect() - } else { - Vec::new() - }; - let head = serialize_response_head( - &status_line, - &headers, - framing, - server_wants_close, - &trailer_names, - ); - client.write_all(&head).await.into_diagnostic()?; - if matches!(framing, ResponseFraming::Chunked) { - for unit in &finish.body_units { - write_chunk(client, unit).await?; - } - write_response_trailers(client, &finish.trailers).await?; - } else { - for unit in &finish.body_units { - client.write_all(unit).await.into_diagnostic()?; - } - } - } else { - for unit in &finish.body_units { - if chunked_output { - write_chunk(client, unit).await?; - } else { - client.write_all(unit).await.into_diagnostic()?; - } - } - if chunked_output { - write_response_trailers(client, &finish.trailers).await?; - } - } - client.flush().await.into_diagnostic()?; - Ok(Some( - if (committed && close_delimited_output) - || (matches!(body_length, BodyLength::None) && (server_wants_close || event_stream)) - { - RelayOutcome::Consumed - } else { - RelayOutcome::Reusable - }, - )) -} - -#[allow(clippy::too_many_arguments)] -async fn relay_headers_only_response( - request_method: &str, - upstream: &mut U, - client: &mut C, - status_line: &str, - headers: &[HttpHeader], - declared_trailers: &[String], - overflow: &[u8], - status_code: u16, - body_length: BodyLength, - server_wants_close: bool, - event_stream: bool, -) -> Result -where - U: AsyncRead + Unpin, - C: AsyncWrite + Unpin, -{ - let head = serialize_response_head( - status_line, - headers, - ResponseFraming::Preserve(body_length), - server_wants_close, - declared_trailers, - ); - client.write_all(&head).await.into_diagnostic()?; - - if is_bodiless_response(request_method, status_code) { - client.flush().await.into_diagnostic()?; - return Ok(if server_wants_close { - RelayOutcome::Consumed - } else { - RelayOutcome::Reusable - }); - } - - client.write_all(overflow).await.into_diagnostic()?; - match body_length { - BodyLength::ContentLength(length) => { - let remaining = length.saturating_sub(overflow.len() as u64); - if remaining > 0 { - relay_fixed(upstream, client, remaining, None).await?; - } - } - BodyLength::Chunked => relay_chunked(upstream, client, overflow, None).await?, - BodyLength::None if server_wants_close || event_stream => { - if event_stream { - relay_until_eof_without_idle_timeout(upstream, client).await?; - } else { - relay_until_eof(upstream, client).await?; - } - client.flush().await.into_diagnostic()?; - return Ok(RelayOutcome::Consumed); - } - BodyLength::None => {} - } - client.flush().await.into_diagnostic()?; - Ok(RelayOutcome::Reusable) -} - -fn emit_http_response_middleware_invocations( - policy_name: &str, - target: &HttpRequestTarget, - status_code: u16, - invocations: &[openshell_supervisor_middleware::HttpResponseInvocation], -) { - for event in - http_response_middleware_invocation_events(policy_name, target, status_code, invocations) - { - openshell_ocsf::ocsf_emit!(event); - } - for invocation in invocations { - if let Some(event) = - http_response_middleware_fail_open_finding_event(policy_name, target, invocation) - { - openshell_ocsf::ocsf_emit!(event); - } - if let Some(event) = - http_response_middleware_block_finding_event(policy_name, target, invocation) - { - openshell_ocsf::ocsf_emit!(event); - } - } -} - -fn emit_http_response_diagnostics( - policy_name: &str, - target: &HttpRequestTarget, - status_code: u16, - diagnostics: &openshell_supervisor_middleware::HttpResponseDiagnostics, -) { - emit_http_response_middleware_invocations( - policy_name, - target, - status_code, - &diagnostics.invocations, - ); - for event in crate::l7::middleware::middleware_finding_events(&diagnostics.findings) { - openshell_ocsf::ocsf_emit!(event); - } -} - -fn http_response_middleware_invocation_events( - policy_name: &str, - target: &HttpRequestTarget, - status_code: u16, - invocations: &[openshell_supervisor_middleware::HttpResponseInvocation], -) -> Vec { - invocations - .iter() - .map(|invocation| { - let outcome = format!("{:?}", invocation.outcome).to_ascii_lowercase(); - let failed = invocation.failed; - let blocked = invocation.outcome - == openshell_supervisor_middleware::HttpResponseInvocationOutcome::BlockDelivery; - openshell_ocsf::HttpActivityBuilder::new(ocsf_ctx()) - .activity(openshell_ocsf::ActivityId::Other) - .action(if blocked { - openshell_ocsf::ActionId::Denied - } else if failed { - openshell_ocsf::ActionId::Other - } else { - openshell_ocsf::ActionId::Allowed - }) - .disposition(if blocked { - openshell_ocsf::DispositionId::Blocked - } else if failed { - openshell_ocsf::DispositionId::Error - } else { - openshell_ocsf::DispositionId::Allowed - }) - .severity(if failed || blocked { - openshell_ocsf::SeverityId::Medium - } else { - openshell_ocsf::SeverityId::Informational - }) - .status(if failed || blocked { - openshell_ocsf::StatusId::Failure - } else { - openshell_ocsf::StatusId::Success - }) - .http_request(openshell_ocsf::HttpRequest::new( - &target.method, - openshell_ocsf::Url::new( - &target.scheme, - &target.host, - &target.path, - u16::try_from(target.port).unwrap_or_default(), - ), - )) - .http_response(openshell_ocsf::HttpResponse { code: status_code }) - .dst_endpoint(openshell_ocsf::Endpoint::from_domain( - &target.host, - u16::try_from(target.port).unwrap_or_default(), - )) - .firewall_rule(policy_name, "supervisor-middleware") - .unmapped("middleware_config", invocation.config_name.as_str()) - .unmapped( - "middleware_implementation", - invocation.implementation.as_str(), - ) - .unmapped("response_middleware_outcome", outcome.as_str()) - .unmapped("sequence", invocation.sequence.unwrap_or_default()) - .unmapped("input_bytes", invocation.input_size) - .unmapped("failed", failed) - .message(format!( - "HTTP_RESPONSE_MIDDLEWARE config={} implementation={} outcome={} sequence={} input_bytes={} failed={failed}", - invocation.config_name, - invocation.implementation, - outcome, - invocation.sequence.unwrap_or_default(), - invocation.input_size, - )) - .build() - }) - .collect() -} - -fn http_response_middleware_block_finding_event( - policy_name: &str, - target: &HttpRequestTarget, - invocation: &openshell_supervisor_middleware::HttpResponseInvocation, -) -> Option { - if invocation.outcome - != openshell_supervisor_middleware::HttpResponseInvocationOutcome::BlockDelivery - { - return None; - } - Some( - openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) - .severity(openshell_ocsf::SeverityId::Medium) - .finding_info(openshell_ocsf::FindingInfo::new( - "openshell.middleware.http_response_blocked", - "HTTP response blocked by middleware", - )) - .evidence_pairs(&[ - ("policy", policy_name), - ("middleware_config", invocation.config_name.as_str()), - ( - "middleware_implementation", - invocation.implementation.as_str(), - ), - ("host", target.host.as_str()), - ("phase", "pre_return"), - ]) - .unmapped("middleware_config", invocation.config_name.as_str()) - .unmapped( - "middleware_implementation", - invocation.implementation.as_str(), - ) - .unmapped("phase", "pre_return") - .message("HTTP response delivery blocked by middleware") - .build(), - ) -} - -fn http_response_middleware_fail_open_finding_event( - policy_name: &str, - target: &HttpRequestTarget, - invocation: &openshell_supervisor_middleware::HttpResponseInvocation, -) -> Option { - if !invocation.failed - || invocation.outcome - != openshell_supervisor_middleware::HttpResponseInvocationOutcome::FailOpen - { - return None; - } - let failure_category = invocation - .failure_category - .as_deref() - .unwrap_or("middleware_failure"); - Some( - openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) - .severity(openshell_ocsf::SeverityId::Medium) - .finding_info(openshell_ocsf::FindingInfo::new( - "openshell.middleware.http_response_fail_open", - "HTTP response middleware failed open", - )) - .evidence_pairs(&[ - ("policy", policy_name), - ("middleware_config", invocation.config_name.as_str()), - ( - "middleware_implementation", - invocation.implementation.as_str(), - ), - ("host", target.host.as_str()), - ("phase", "pre_return"), - ("failure_category", failure_category), - ]) - .unmapped("middleware_config", invocation.config_name.as_str()) - .unmapped( - "middleware_implementation", - invocation.implementation.as_str(), - ) - .unmapped("phase", "pre_return") - .unmapped("failure_category", failure_category) - .message("HTTP response middleware failed and response inspection was bypassed") - .build(), - ) -} - -fn emit_http_response_middleware_failure( - policy_name: &str, - target: &HttpRequestTarget, - status_code: u16, - committed: bool, -) { - let status_code = status_code.to_string(); - let event = openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) - .severity(openshell_ocsf::SeverityId::High) - .finding_info(openshell_ocsf::FindingInfo::new( - "openshell.middleware.http_response_failure", - "HTTP response middleware delivery failure", - )) - .evidence_pairs(&[ - ("policy", policy_name), - ("host", target.host.as_str()), - ( - "commitment", - if committed { - "after_commit" - } else { - "before_commit" - }, - ), - ("upstream_status", status_code.as_str()), - ]) - .message(if committed { - "HTTP response middleware failed after response commitment" - } else { - "HTTP response middleware failed before response commitment" - }) - .build(); - openshell_ocsf::ocsf_emit!(event); -} - -#[derive(Debug)] -struct ParsedResponseHead { - representable: bool, - headers: Vec, - connection_nominated: Vec, - declared_trailers: Vec, -} - -fn parse_response_head_for_middleware(header_bytes: &[u8]) -> Result { - // Lossy decoding preserves ASCII syntax and control bytes for validation. - // Never pass replacement text to middleware or use it for delivery. - let header = String::from_utf8_lossy(header_bytes); - let representable = std::str::from_utf8(header_bytes).is_ok(); - if parse_status_code(&header).is_none() { - return Err(miette!("HTTP response status line is malformed")); - } - let mut nominated = HashSet::new(); - let mut declared_trailers = Vec::new(); - for line in header.split("\r\n").skip(1) { - let Some((name, value)) = line.split_once(':') else { - continue; - }; - validate_http_field_name(name)?; - validate_http_field_value(value.trim())?; - if name.eq_ignore_ascii_case("connection") { - for token in value - .split(',') - .map(str::trim) - .filter(|token| !token.is_empty()) - { - nominated.insert(token.to_ascii_lowercase()); - } - } else if name.eq_ignore_ascii_case("trailer") { - for token in parse_http_token_list(value)? { - let token = token.to_ascii_lowercase(); - if !declared_trailers.contains(&token) { - declared_trailers.push(token); - } - } - } - } - for trailer in &declared_trailers { - if is_protected_response_field(trailer) || nominated.contains(trailer) { - return Err(miette!("HTTP response declares a protected trailer field")); - } - } - let mut headers = Vec::new(); - for line in header.split("\r\n").skip(1).filter(|line| !line.is_empty()) { - let (name, value) = line - .split_once(':') - .ok_or_else(|| miette!("Malformed HTTP response header field"))?; - validate_http_field_name(name)?; - validate_http_field_value(value.trim())?; - let name = name.to_ascii_lowercase(); - if nominated.contains(&name) || is_protected_response_field(&name) { - continue; - } - headers.push(HttpHeader { - name, - value: value.trim().to_string(), - }); - } - let mut connection_nominated: Vec<_> = nominated.into_iter().collect(); - connection_nominated.sort(); - Ok(ParsedResponseHead { - representable, - headers: if representable { headers } else { Vec::new() }, - connection_nominated, - declared_trailers, - }) -} - -fn validate_http_field_name(name: &str) -> Result<()> { - if name.is_empty() - || !name.bytes().all(|byte| { - byte.is_ascii_alphanumeric() - || matches!( - byte, - b'!' | b'#' - | b'$' - | b'%' - | b'&' - | b'\'' - | b'*' - | b'+' - | b'-' - | b'.' - | b'^' - | b'_' - | b'`' - | b'|' - | b'~' - ) - }) - { - return Err(miette!("HTTP response field name is malformed")); - } - Ok(()) -} - -fn validate_http_field_value(value: &str) -> Result<()> { - if value - .bytes() - .any(|byte| (byte < 0x20 && byte != b'\t') || byte == 0x7f) - { - return Err(miette!("HTTP response field value contains a control byte")); - } - Ok(()) -} - -fn is_protected_response_field(name: &str) -> bool { - matches!( - name.to_ascii_lowercase().as_str(), - "connection" - | "content-length" - | "keep-alive" - | "proxy-authenticate" - | "proxy-authorization" - | "proxy-connection" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" - ) -} - -fn response_status_line(header_bytes: &[u8]) -> Result { - let line_end = header_bytes - .windows(2) - .position(|window| window == b"\r\n") - .ok_or_else(|| miette!("HTTP response status line is incomplete"))?; - std::str::from_utf8(&header_bytes[..line_end]) - .map(str::to_string) - .map_err(|_| miette!("HTTP response status line contains invalid UTF-8")) -} - -#[derive(Clone, Copy)] -enum ResponseFraming { - Preserve(BodyLength), - ContentLength(u64), - Chunked, -} - -fn serialize_response_head( - status_line: &str, - headers: &[HttpHeader], - framing: ResponseFraming, - connection_close: bool, - trailer_names: &[String], -) -> Vec { - let mut output = format!("{status_line}\r\n"); - for header in headers { - output.push_str(&header.name); - output.push_str(": "); - output.push_str(&header.value); - output.push_str("\r\n"); - } - match framing { - ResponseFraming::Preserve(BodyLength::ContentLength(length)) - | ResponseFraming::ContentLength(length) => { - write!(&mut output, "Content-Length: {length}\r\n") - .expect("writing to a String cannot fail"); - } - ResponseFraming::Preserve(BodyLength::Chunked) | ResponseFraming::Chunked => { - output.push_str("Transfer-Encoding: chunked\r\n"); - } - ResponseFraming::Preserve(BodyLength::None) => {} - } - if !trailer_names.is_empty() { - output.push_str("Trailer: "); - output.push_str(&trailer_names.join(", ")); - output.push_str("\r\n"); - } - if connection_close { - output.push_str("Connection: close\r\n"); - } - output.push_str("\r\n"); - output.into_bytes() -} - -fn strip_response_integrity_headers(headers: &mut Vec) { - headers.retain(|header| { - !matches!( - header.name.to_ascii_lowercase().as_str(), - "accept-ranges" - | "etag" - | "content-md5" - | "digest" - | "content-digest" - | "repr-digest" - | "signature" - | "signature-input" - ) - }); -} - -struct BufferedResponseReader<'a, R> { - upstream: &'a mut R, - buffered: &'a [u8], - position: usize, - exact_buffer: Vec, - exact_target: Option, - line_buffer: Vec, -} - -impl<'a, R: AsyncRead + Unpin> BufferedResponseReader<'a, R> { - fn new(upstream: &'a mut R, buffered: &'a [u8]) -> Self { - Self { - upstream, - buffered, - position: 0, - exact_buffer: Vec::new(), - exact_target: None, - line_buffer: Vec::new(), - } - } - - async fn read_some(&mut self, limit: usize) -> Result>> { - if self.position < self.buffered.len() { - let end = self.position.saturating_add(limit).min(self.buffered.len()); - let data = self.buffered[self.position..end].to_vec(); - self.position = end; - return Ok(Some(data)); - } - let mut data = vec![0u8; limit.max(1)]; - let count = self.upstream.read(&mut data).await.into_diagnostic()?; - if count == 0 { - return Ok(None); - } - data.truncate(count); - Ok(Some(data)) - } - - async fn read_exact_vec(&mut self, length: usize) -> Result> { - match self.exact_target { - Some(target) if target != length => { - return Err(miette!("HTTP response reader exact-read state mismatch")); - } - None => { - self.exact_target = Some(length); - self.exact_buffer.reserve(length); - } - Some(_) => {} - } - while self.exact_buffer.len() < length { - let remaining = length - self.exact_buffer.len(); - let Some(data) = self.read_some(remaining).await? else { - return Err(miette!("HTTP response body ended unexpectedly")); - }; - self.exact_buffer.extend_from_slice(&data); - } - self.exact_target = None; - Ok(std::mem::take(&mut self.exact_buffer)) - } - - async fn read_line(&mut self) -> Result> { - loop { - let Some(byte) = self.read_some(1).await? else { - return Err(miette!("HTTP response ended before line terminator")); - }; - self.line_buffer.push(byte[0]); - if self.line_buffer.len() > MAX_HEADER_BYTES { - return Err(miette!("HTTP response line exceeds limit")); - } - if self.line_buffer.ends_with(b"\r\n") { - self.line_buffer.truncate(self.line_buffer.len() - 2); - return Ok(std::mem::take(&mut self.line_buffer)); - } - } - } -} - -#[allow(clippy::too_many_arguments)] -async fn relay_normalized_response_body( - reader: &mut BufferedResponseReader<'_, R>, - session: &mut openshell_supervisor_middleware::HttpResponseSession, - client: &mut C, - body_length: BodyLength, - server_wants_close: bool, - event_stream: bool, - committed: &mut bool, - chunked_output: bool, - commit_head: &[u8], - unit_limit: usize, - generation_guard: Option<&PolicyGenerationGuard>, - policy_name: &str, - target: &HttpRequestTarget, - status_code: u16, - connection_nominated_headers: &[String], -) -> Result> -where - R: AsyncRead + Unpin, - C: AsyncWrite + Unpin, -{ - let mut pending = Vec::with_capacity(unit_limit); - let mut framing = ResponseOutputState { - committed, - chunked: chunked_output, - commit_head, - policy_name, - target, - status_code, - }; - match body_length { - BodyLength::ContentLength(mut remaining) => { - while remaining > 0 { - let length = usize::try_from(remaining) - .unwrap_or(unit_limit) - .min(unit_limit); - let unit = read_response_payload_with_deadline( - reader, - length, - !pending.is_empty(), - session, - client, - &mut framing, - ) - .await?; - if let Some(guard) = generation_guard { - guard.ensure_current()?; - } - let partial = unit.len() < length; - remaining -= unit.len() as u64; - buffer_normalized_response_bytes( - session, - client, - &mut pending, - unit, - &mut framing, - unit_limit, - ) - .await?; - if partial { - flush_normalized_response_bytes( - session, - client, - std::mem::take(&mut pending), - &mut framing, - ) - .await?; - } - } - flush_normalized_response_bytes(session, client, pending, &mut framing).await?; - Ok(Vec::new()) - } - BodyLength::Chunked => { - let mut size_line = - read_response_line_with_deadline(reader, session, client, &mut framing).await?; - loop { - let size_line_text = std::str::from_utf8(&size_line) - .map_err(|_| miette!("Invalid UTF-8 in response chunk-size line"))?; - let size_token = size_line_text - .split(';') - .next() - .map(str::trim) - .unwrap_or_default(); - let chunk_size = usize::from_str_radix(size_token, 16) - .map_err(|_| miette!("Invalid HTTP response chunk size"))?; - if chunk_size == 0 { - flush_normalized_response_bytes(session, client, pending, &mut framing).await?; - return read_response_trailers( - reader, - session, - client, - &mut framing, - connection_nominated_headers, - ) - .await; - } - let mut remaining = chunk_size; - while remaining > 0 { - let length = remaining.min(unit_limit); - let unit = read_response_payload_with_deadline( - reader, - length, - !pending.is_empty(), - session, - client, - &mut framing, - ) - .await?; - if let Some(guard) = generation_guard { - guard.ensure_current()?; - } - let partial = unit.len() < length; - remaining -= unit.len(); - buffer_normalized_response_bytes( - session, - client, - &mut pending, - unit, - &mut framing, - unit_limit, - ) - .await?; - if partial { - flush_normalized_response_bytes( - session, - client, - std::mem::take(&mut pending), - &mut framing, - ) - .await?; - } - } - let terminator = if let Ok(result) = - tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, reader.read_exact_vec(2)) - .await - { - result? - } else { - flush_normalized_response_bytes( - session, - client, - std::mem::take(&mut pending), - &mut framing, - ) - .await?; - read_exact_response_with_deadline(reader, 2, session, client, &mut framing) - .await? - }; - if terminator != b"\r\n" { - return Err(miette!("HTTP response chunk is missing its terminator")); - } - size_line = if let Ok(line) = - tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, reader.read_line()).await - { - line? - } else { - flush_normalized_response_bytes( - session, - client, - std::mem::take(&mut pending), - &mut framing, - ) - .await?; - read_response_line_with_deadline(reader, session, client, &mut framing).await? - }; - } - } - BodyLength::None if server_wants_close || event_stream => loop { - // Cancel only input acquisition, never expiry or client writes. - let wait = if pending.is_empty() { - if event_stream { - None - } else { - Some(RELAY_EOF_IDLE_TIMEOUT) - } - } else { - Some(RESPONSE_UNIT_COALESCE_TIMEOUT) - }; - let read_deadline = wait.map(|wait| tokio::time::Instant::now() + wait); - let whole_deadline = session.whole_body_deadline(); - let deadline = match (read_deadline, whole_deadline) { - (Some(a), Some(b)) => Some(a.min(b)), - (a, b) => a.or(b), - }; - let next = if let Some(deadline) = deadline { - if let Ok(result) = - tokio::time::timeout_at(deadline, reader.read_some(unit_limit)).await - { - result? - } else { - if whole_deadline.is_some_and(|d| d <= tokio::time::Instant::now()) { - expire_whole_body_deadline(session, client, &mut framing).await?; - } else if pending.is_empty() { - return Ok(Vec::new()); - } else { - flush_normalized_response_bytes( - session, - client, - std::mem::take(&mut pending), - &mut framing, - ) - .await?; - } - continue; - } - } else { - reader.read_some(unit_limit).await? - }; - let Some(unit) = next else { - flush_normalized_response_bytes(session, client, pending, &mut framing).await?; - return Ok(Vec::new()); - }; - if let Some(guard) = generation_guard { - guard.ensure_current()?; - } - buffer_normalized_response_bytes( - session, - client, - &mut pending, - unit, - &mut framing, - unit_limit, - ) - .await?; - }, - BodyLength::None => { - flush_normalized_response_bytes(session, client, pending, &mut framing).await?; - Ok(Vec::new()) - } - } -} - -struct ResponseOutputState<'a> { - committed: &'a mut bool, - chunked: bool, - commit_head: &'a [u8], - policy_name: &'a str, - target: &'a HttpRequestTarget, - status_code: u16, -} - -#[derive(Debug)] -struct ResponseMiddlewareStop { - failure: openshell_supervisor_middleware::HttpResponseMiddlewareFailure, -} - -impl ResponseMiddlewareStop { - fn new(failure: openshell_supervisor_middleware::HttpResponseMiddlewareFailure) -> Self { - Self { failure } - } -} - -impl fmt::Display for ResponseMiddlewareStop { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - formatter, - "HTTP response middleware stopped delivery: {}", - self.failure - ) - } -} - -impl std::error::Error for ResponseMiddlewareStop {} - -impl miette::Diagnostic for ResponseMiddlewareStop {} - -async fn expire_whole_body_deadline( - session: &mut openshell_supervisor_middleware::HttpResponseSession, - client: &mut C, - framing: &mut ResponseOutputState<'_>, -) -> Result<()> { - let output = session.expire_whole_body_deadline().await; - let diagnostics = session.take_diagnostics(); - emit_http_response_diagnostics( - framing.policy_name, - framing.target, - framing.status_code, - &diagnostics, - ); - let output = output.map_err(ResponseMiddlewareStop::new)?; - deliver_response_units(client, output, framing, session.requires_whole_body()).await -} - -// Unit size is a maximum. Coalesce available payload without waiting for -// the rest of a transfer chunk, and finish expiry outside read timeouts. -async fn read_response_payload_with_deadline( - reader: &mut BufferedResponseReader<'_, R>, - limit: usize, - has_pending: bool, - session: &mut openshell_supervisor_middleware::HttpResponseSession, - client: &mut C, - framing: &mut ResponseOutputState<'_>, -) -> Result> -where - R: AsyncRead + Unpin, - C: AsyncWrite + Unpin, -{ - let mut payload = Vec::new(); - let mut coalesce_deadline = - has_pending.then(|| tokio::time::Instant::now() + RESPONSE_UNIT_COALESCE_TIMEOUT); - loop { - let whole_deadline = session.whole_body_deadline(); - let deadline = match (coalesce_deadline, whole_deadline) { - (Some(a), Some(b)) => Some(a.min(b)), - (a, b) => a.or(b), - }; - let read = reader.read_some(limit - payload.len()); - let result = if let Some(deadline) = deadline { - if let Ok(result) = tokio::time::timeout_at(deadline, read).await { - result - } else { - if whole_deadline.is_some_and(|d| d <= tokio::time::Instant::now()) { - expire_whole_body_deadline(session, client, framing).await?; - } - if coalesce_deadline.is_some_and(|d| d <= tokio::time::Instant::now()) { - return Ok(payload); - } - continue; - } - } else { - read.await - }; - let data = result?.ok_or_else(|| miette!("HTTP response body ended unexpectedly"))?; - payload.extend_from_slice(&data); - if payload.len() == limit { - return Ok(payload); - } - coalesce_deadline - .get_or_insert_with(|| tokio::time::Instant::now() + RESPONSE_UNIT_COALESCE_TIMEOUT); - } -} - -async fn read_exact_response_with_deadline( - reader: &mut BufferedResponseReader<'_, R>, - length: usize, - session: &mut openshell_supervisor_middleware::HttpResponseSession, - client: &mut C, - framing: &mut ResponseOutputState<'_>, -) -> Result> -where - R: AsyncRead + Unpin, - C: AsyncWrite + Unpin, -{ - loop { - let Some(deadline) = session.whole_body_deadline() else { - return reader.read_exact_vec(length).await; - }; - match tokio::time::timeout_at(deadline, reader.read_exact_vec(length)).await { - Ok(result) => return result, - Err(_) => expire_whole_body_deadline(session, client, framing).await?, - } - } -} - -async fn read_response_line_with_deadline( - reader: &mut BufferedResponseReader<'_, R>, - session: &mut openshell_supervisor_middleware::HttpResponseSession, - client: &mut C, - framing: &mut ResponseOutputState<'_>, -) -> Result> -where - R: AsyncRead + Unpin, - C: AsyncWrite + Unpin, -{ - loop { - let Some(deadline) = session.whole_body_deadline() else { - return reader.read_line().await; - }; - match tokio::time::timeout_at(deadline, reader.read_line()).await { - Ok(result) => return result, - Err(_) => expire_whole_body_deadline(session, client, framing).await?, - } - } -} - -async fn buffer_normalized_response_bytes( - session: &mut openshell_supervisor_middleware::HttpResponseSession, - client: &mut C, - pending: &mut Vec, - data: Vec, - framing: &mut ResponseOutputState<'_>, - unit_limit: usize, -) -> Result<()> { - pending.extend_from_slice(&data); - while pending.len() >= unit_limit { - let remainder = pending.split_off(unit_limit); - let unit = std::mem::replace(pending, remainder); - process_response_unit(session, client, unit, framing).await?; - } - Ok(()) -} - -async fn flush_normalized_response_bytes( - session: &mut openshell_supervisor_middleware::HttpResponseSession, - client: &mut C, - pending: Vec, - framing: &mut ResponseOutputState<'_>, -) -> Result<()> { - if pending.is_empty() { - return Ok(()); - } - process_response_unit(session, client, pending, framing).await -} - -async fn process_response_unit( - session: &mut openshell_supervisor_middleware::HttpResponseSession, - client: &mut C, - unit: Vec, - framing: &mut ResponseOutputState<'_>, -) -> Result<()> { - let output = session.push_body(unit).await; - let diagnostics = session.take_diagnostics(); - emit_http_response_diagnostics( - framing.policy_name, - framing.target, - framing.status_code, - &diagnostics, - ); - let output = output.map_err(ResponseMiddlewareStop::new)?; - deliver_response_units(client, output, framing, session.requires_whole_body()).await -} - -async fn deliver_response_units( - client: &mut C, - output: Vec>, - framing: &mut ResponseOutputState<'_>, - whole_body_pending: bool, -) -> Result<()> { - if !*framing.committed && !output.is_empty() { - if whole_body_pending { - return Err(miette!( - "whole-body response middleware released output before finalization" - )); - } - // `write_all` may return an error after a partial write. Treat the - // response as committed before the attempt so callers never append a - // canonical error response behind a partially delivered upstream head. - *framing.committed = true; - client - .write_all(framing.commit_head) - .await - .map_err(|error| miette!("HTTP response client write failed: {error}"))?; - client - .flush() - .await - .map_err(|error| miette!("HTTP response client write failed: {error}"))?; - } - if *framing.committed { - for unit in output { - if framing.chunked { - write_chunk(client, &unit) - .await - .map_err(|error| miette!("HTTP response client write failed: {error}"))?; - } else { - client - .write_all(&unit) - .await - .map_err(|error| miette!("HTTP response client write failed: {error}"))?; - } - } - client - .flush() - .await - .map_err(|error| miette!("HTTP response client write failed: {error}"))?; - } - Ok(()) -} - -async fn read_response_trailers( - reader: &mut BufferedResponseReader<'_, R>, - session: &mut openshell_supervisor_middleware::HttpResponseSession, - client: &mut C, - framing: &mut ResponseOutputState<'_>, - connection_nominated_headers: &[String], -) -> Result> -where - R: AsyncRead + Unpin, - C: AsyncWrite + Unpin, -{ - let mut trailers = Vec::new(); - loop { - let line = read_response_line_with_deadline(reader, session, client, framing).await?; - if line.is_empty() { - return Ok(trailers); - } - let line = std::str::from_utf8(&line) - .map_err(|_| miette!("HTTP response trailer contains invalid UTF-8"))?; - let (name, value) = line - .split_once(':') - .ok_or_else(|| miette!("Malformed HTTP response trailer"))?; - validate_http_field_name(name)?; - validate_http_field_value(value.trim())?; - let name = name.to_ascii_lowercase(); - if is_protected_response_field(&name) || connection_nominated_headers.contains(&name) { - return Err(miette!("HTTP response trailer uses a protected field name")); - } - trailers.push(HttpHeader { - name, - value: value.trim().to_string(), - }); - if trailers.len() > openshell_supervisor_middleware::MAX_MIDDLEWARE_HEADERS { - return Err(miette!("HTTP response trailer count exceeds limit")); - } - } -} - -async fn write_response_trailers( - client: &mut C, - trailers: &[HttpHeader], -) -> Result<()> { - client.write_all(b"0\r\n").await.into_diagnostic()?; - for trailer in trailers { - client - .write_all(format!("{}: {}\r\n", trailer.name, trailer.value).as_bytes()) - .await - .into_diagnostic()?; - } - client.write_all(b"\r\n").await.into_diagnostic()?; - Ok(()) -} - -async fn send_response_middleware_denial( - client: &mut C, - request_method: &str, - policy_name: &str, - target: &HttpRequestTarget, - denial: &openshell_supervisor_middleware::MiddlewareDenial, -) -> Result<()> { - let mut body = serde_json::Map::new(); - body.insert("error".into(), serde_json::json!("middleware_denied")); - body.insert( - "detail".into(), - serde_json::json!("Response blocked by configured middleware"), - ); - body.insert("policy".into(), serde_json::json!(policy_name)); - body.insert("middleware".into(), serde_json::json!(denial.config_name)); - if let Some(reason_code) = &denial.reason_code { - body.insert("reason_code".into(), serde_json::json!(reason_code)); - } - body.insert( - "layer".into(), - serde_json::json!("http_response_pre_return"), - ); - body.insert("method".into(), serde_json::json!(target.method)); - body.insert("path".into(), serde_json::json!(target.path)); - body.insert("host".into(), serde_json::json!(target.host)); - body.insert("port".into(), serde_json::json!(target.port)); - let body = serde_json::to_vec(&serde_json::Value::Object(body)).into_diagnostic()?; - let head = format!( - "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-OpenShell-Policy: {policy_name}\r\nConnection: close\r\n\r\n", - body.len() - ); - client.write_all(head.as_bytes()).await.into_diagnostic()?; - if !request_method.eq_ignore_ascii_case("HEAD") { - client.write_all(&body).await.into_diagnostic()?; - } - client.flush().await.into_diagnostic()?; - Ok(()) -} - -async fn send_response_delivery_failure( - client: &mut C, - request_method: &str, - policy_name: &str, - target: &HttpRequestTarget, -) -> Result<()> { - let body = serde_json::to_vec(&serde_json::json!({ - "error": "response_delivery_failed", - "detail": "The upstream request may have completed, but OpenShell could not deliver its response. Retrying may repeat upstream side effects.", - "policy": policy_name, - "layer": "http_response_pre_return", - "method": target.method, - "path": target.path, - "host": target.host, - "port": target.port, - })) - .into_diagnostic()?; - let head = format!( - "HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-OpenShell-Policy: {policy_name}\r\nConnection: close\r\n\r\n", - body.len() - ); - client.write_all(head.as_bytes()).await.into_diagnostic()?; - if !request_method.eq_ignore_ascii_case("HEAD") { - client.write_all(&body).await.into_diagnostic()?; - } - client.flush().await.into_diagnostic()?; - Ok(()) -} - -/// Parse the HTTP status code from a response status line. -/// -/// Expects the first line to look like `HTTP/1.1 200 OK`. -fn parse_status_code(headers: &str) -> Option { - let status_line = headers.lines().next()?; - let code_str = status_line.split_whitespace().nth(1)?; - code_str.parse().ok() -} - -/// Check if the response headers contain `Connection: close`. -fn parse_connection_close(headers: &str) -> bool { - for line in headers.lines().skip(1) { - let lower = line.to_ascii_lowercase(); - if lower.starts_with("connection:") { - let val = lower.split_once(':').map_or("", |(_, v)| v.trim()); - return val.contains("close"); - } - } - false -} - -fn response_is_event_stream(headers: &str) -> bool { - headers.lines().skip(1).any(|line| { - let lower = line.to_ascii_lowercase(); - let Some(value) = lower.strip_prefix("content-type:") else { - return false; - }; - value - .split(';') - .next() - .is_some_and(|mime| mime.trim() == "text/event-stream") - }) -} - -fn validate_websocket_response( - headers: &str, - mode: WebSocketExtensionMode, - websocket: Option<&WebSocketResponseValidation>, -) -> Result<(bool, Option)> { - let Some(validation) = websocket else { - return validate_websocket_response_extensions_preserved(headers, mode) - .map(|compressed| (compressed, None)); - }; - - let mut upgrade_websocket = false; - let mut connection_upgrade = false; - let mut accept_count = 0usize; - let mut accept_matches = false; - let mut subprotocol_count = 0usize; - let mut selected_subprotocol = None; - - for line in headers.lines().skip(1) { - let Some((name, value)) = line.split_once(':') else { - continue; - }; - let name = name.trim().to_ascii_lowercase(); - let value = value.trim(); - match name.as_str() { - "upgrade" if header_value_contains_token(value, "websocket") => { - upgrade_websocket = true; - } - "connection" if header_value_contains_token(value, "upgrade") => { - connection_upgrade = true; - } - "sec-websocket-accept" => { - accept_count += 1; - accept_matches = value == validation.expected_accept; - } - "sec-websocket-protocol" => { - subprotocol_count += 1; - if !is_http_token(value) { - return Err(miette!( - "websocket upgrade response has invalid Sec-WebSocket-Protocol" - )); - } - selected_subprotocol = Some(value.to_string()); - } - _ => {} - } - } - - if !upgrade_websocket { - return Err(miette!( - "websocket upgrade response missing Upgrade: websocket" - )); - } - if !connection_upgrade { - return Err(miette!( - "websocket upgrade response missing Connection: Upgrade" - )); - } - if accept_count != 1 || !accept_matches { - return Err(miette!( - "websocket upgrade response has invalid Sec-WebSocket-Accept" - )); - } - if subprotocol_count > 1 { - return Err(miette!( - "websocket upgrade response has multiple Sec-WebSocket-Protocol headers" - )); - } - if let Some(ref protocol) = selected_subprotocol - && !validation - .offered_subprotocols - .iter() - .any(|offered| offered == protocol) - { - return Err(miette!( - "upstream selected WebSocket subprotocol that was not offered" - )); - } - - let actual_extension = normalized_websocket_extension(headers)?; - match (&validation.expected_extension, actual_extension.as_deref()) { - (None, Some(_)) => Err(miette!( - "upstream negotiated WebSocket extension that was not offered" - )), - (None | Some(_), None) => Ok((false, selected_subprotocol)), - (Some(expected), Some(actual)) if expected.eq_ignore_ascii_case(actual) => { - Ok((true, selected_subprotocol)) - } - (Some(_), Some(_)) => Err(miette!( - "upstream negotiated WebSocket extension that does not match the safe offer" - )), - } -} - -fn validate_websocket_response_extensions_preserved( - headers: &str, - mode: WebSocketExtensionMode, -) -> Result { - match mode { - WebSocketExtensionMode::Preserve => Ok(false), - WebSocketExtensionMode::PermessageDeflate => { - let offers = websocket_extension_offers(headers)?; - if offers.is_empty() { - Ok(false) - } else { - Err(miette!( - "upstream negotiated WebSocket extension that was not offered" - )) - } - } - } -} - -fn normalized_websocket_extension(headers: &str) -> Result> { - let offers = websocket_extension_offers(headers)?; - if offers.is_empty() { - return Ok(None); - } - if offers.len() != 1 { - return Err(miette!("upstream negotiated multiple WebSocket extensions")); - } - let offer = &offers[0]; - if !offer.name.eq_ignore_ascii_case("permessage-deflate") { - return Err(miette!( - "upstream negotiated unsupported WebSocket extension" - )); - } - let mut client_no_context_takeover = false; - let mut server_no_context_takeover = false; - let mut seen = HashSet::new(); - for param in &offer.params { - let name = param.name.to_ascii_lowercase(); - if param.value.is_some() || !seen.insert(name.clone()) { - return Err(miette!( - "upstream negotiated unsupported permessage-deflate parameter" - )); - } - if name == "client_no_context_takeover" { - client_no_context_takeover = true; - } else if name == "server_no_context_takeover" { - server_no_context_takeover = true; - } else { - return Err(miette!( - "upstream negotiated unsupported permessage-deflate parameter" - )); - } - } - let mut normalized = String::from("permessage-deflate"); - if client_no_context_takeover { - normalized.push_str("; client_no_context_takeover"); - } - if server_no_context_takeover { - normalized.push_str("; server_no_context_takeover"); + if server_no_context_takeover { + normalized.push_str("; server_no_context_takeover"); } Ok(Some(normalized)) } @@ -5367,6 +3471,13 @@ mod tests { struct ResponseRelayService { script: ResponseRelayScript, request_only: bool, + body_gate: Option, + } + + #[derive(Clone)] + struct ResponseBodyGate { + entered: Arc, + release: Arc, } #[tonic::async_trait] @@ -5423,6 +3534,7 @@ mod tests { "request-only service received a response" ); let mut script = self.script; + let body_gate = self.body_gate.clone(); let (sender, receiver) = mpsc::channel(4); tokio::spawn(async move { while let Some(event) = requests.recv().await { @@ -5499,6 +3611,10 @@ mod tests { } } http_response_event::Event::Body(body) => { + if let Some(gate) = &body_gate { + gate.entered.notify_one(); + gate.release.notified().await; + } let Some(http_response_body_unit::Payload::Data(data)) = body.payload else { break; @@ -7473,6 +5589,7 @@ mod tests { openshell_supervisor_middleware::ChainRunner::new(Arc::new(ResponseRelayService { script: ResponseRelayScript::HeadersOnly, request_only: true, + body_gate: None, })); let empty_runner = openshell_supervisor_middleware::ChainRunner::default(); for response in [many_headers, opaque_headers] { @@ -7608,6 +5725,7 @@ mod tests { openshell_supervisor_middleware::ChainRunner::new(Arc::new(ResponseRelayService { script, request_only: false, + body_gate: None, })); let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "response".into(), @@ -7860,6 +5978,74 @@ mod tests { ); } + async fn run_response_relay_across_policy_reload( + script: ResponseRelayScript, + ) -> (Result, Vec) { + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(ResponseRelayService { + script, + request_only: false, + body_gate: Some(ResponseBodyGate { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + })); + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "response".into(), + implementation: "test/response-relay".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]; + let engine = OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").unwrap(); + let guard = engine + .generation_guard(engine.current_generation()) + .expect("initial generation guard"); + let mut middleware = response_middleware_context(&runner, &chain, "GET"); + middleware.generation_guard = Some(&guard); + let mut upstream = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello".as_slice(); + let mut delivered = Vec::new(); + let relay = relay_response( + "GET", + &mut upstream, + &mut delivered, + RelayResponseOptions::default(), + Some(middleware), + ); + let reload = async { + entered.notified().await; + engine + .reload(TEST_POLICY, "network_policies: {}\n") + .expect("policy reload"); + release.notify_one(); + }; + let (outcome, ()) = tokio::join!(relay, reload); + (outcome, delivered) + } + + #[tokio::test] + async fn response_middleware_rechecks_generation_after_stream_exchange() { + let (outcome, delivered) = + run_response_relay_across_policy_reload(ResponseRelayScript::Stream).await; + + let error = outcome.expect_err("stale stream output must not be delivered"); + assert!(error.to_string().contains("policy generation is stale")); + assert!(delivered.ends_with(b"\r\n\r\n")); + assert!(!delivered.windows(5).any(|window| window == b"HELLO")); + } + + #[tokio::test] + async fn response_middleware_rechecks_generation_after_whole_body_finish() { + let (outcome, delivered) = + run_response_relay_across_policy_reload(ResponseRelayScript::WholeBody).await; + + let error = outcome.expect_err("stale whole-body output must not be delivered"); + assert!(error.to_string().contains("policy generation is stale")); + assert!(delivered.is_empty()); + } + #[tokio::test] async fn response_middleware_whole_body_timeout_obeys_failure_policy() { let response = b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello"; diff --git a/crates/openshell-supervisor-network/src/l7/rest/http_response.rs b/crates/openshell-supervisor-network/src/l7/rest/http_response.rs new file mode 100644 index 0000000000..026a76b053 --- /dev/null +++ b/crates/openshell-supervisor-network/src/l7/rest/http_response.rs @@ -0,0 +1,2066 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! HTTP response relay and pre-return middleware integration. + +use super::*; + +/// Default wall-clock bound shared by whole-body stages in one response. +pub const DEFAULT_HTTP_RESPONSE_WHOLE_BODY_TIMEOUT: std::time::Duration = + std::time::Duration::from_mins(2); + +/// Context retained from request evaluation for the matching response hook. +pub struct HttpResponseMiddlewareRelay<'a> { + pub(crate) chain: &'a [openshell_supervisor_middleware::ChainEntry], + pub(crate) runner: &'a openshell_supervisor_middleware::ChainRunner, + pub(crate) request_context: RequestContext, + pub(crate) target: HttpRequestTarget, + pub(crate) policy_name: &'a str, + pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>, + pub(crate) whole_body_timeout: std::time::Duration, +} + +#[derive(Clone)] +pub(super) struct RelayResponseOptions { + pub(super) websocket_extensions: WebSocketExtensionMode, + pub(super) client_requested_upgrade: bool, + pub(super) websocket: Option, +} + +impl Default for RelayResponseOptions { + fn default() -> Self { + Self { + websocket_extensions: WebSocketExtensionMode::Preserve, + client_requested_upgrade: true, + websocket: None, + } + } +} + +pub(super) async fn relay_response( + request_method: &str, + upstream: &mut U, + client: &mut C, + options: RelayResponseOptions, + response_middleware: Option>, +) -> Result +where + U: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + let started_at = std::time::Instant::now(); + let mut buf = Vec::with_capacity(4096); + let mut tmp = [0u8; 1024]; + + // Read response headers. Forward interim responses unchanged, but retain + // the final response head until response middleware preflight completes. + loop { + if buf.len() > MAX_HEADER_BYTES { + return Err(miette!("HTTP response headers exceed limit")); + } + + let n = upstream.read(&mut tmp).await.into_diagnostic()?; + if n == 0 { + // Upstream closed — forward whatever we have + if !buf.is_empty() { + client.write_all(&buf).await.into_diagnostic()?; + } + return Ok(RelayOutcome::Consumed); + } + buf.extend_from_slice(&tmp[..n]); + + while let Some(position) = buf.windows(4).position(|w| w == b"\r\n\r\n") { + let header_end = position + 4; + let header_str = String::from_utf8_lossy(&buf[..header_end]); + let status_code = parse_status_code(&header_str).unwrap_or(200); + if (100..200).contains(&status_code) && status_code != 101 { + client + .write_all(&buf[..header_end]) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + buf.drain(..header_end); + continue; + } + break; + } + if buf.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + + let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; + + // Parse response framing + let header_str = String::from_utf8_lossy(&buf[..header_end]); + let status_code = parse_status_code(&header_str).unwrap_or(200); + let server_wants_close = parse_connection_close(&header_str); + let event_stream = response_is_event_stream(&header_str); + let body_length = parse_body_length(&header_str)?; + + debug!( + status_code, + ?body_length, + server_wants_close, + request_method, + overflow_bytes = buf.len() - header_end, + "relay_response framing" + ); + + // 101 Switching Protocols: the connection has been upgraded (e.g. to + // WebSocket). Forward the 101 headers to the client and signal the + // caller to switch to raw bidirectional TCP relay. Any bytes read + // from upstream beyond the headers are overflow that belong to the + // upgraded protocol and must be forwarded before switching. + if status_code == 101 { + if !options.client_requested_upgrade { + return Ok(RelayOutcome::Consumed); + } + let (websocket_permessage_deflate, websocket_subprotocol) = validate_websocket_response( + &header_str, + options.websocket_extensions, + options.websocket.as_ref(), + )?; + client + .write_all(&buf[..header_end]) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + let overflow = buf[header_end..].to_vec(); + debug!( + request_method, + overflow_bytes = overflow.len(), + "101 Switching Protocols — signaling protocol upgrade" + ); + return Ok(RelayOutcome::Upgraded { + overflow, + websocket_permessage_deflate, + websocket_subprotocol, + }); + } + + if let Some(response_middleware) = response_middleware + && let Some(outcome) = Box::pin(relay_response_through_middleware( + request_method, + upstream, + client, + response_middleware, + &buf, + header_end, + status_code, + body_length, + server_wants_close, + event_stream, + )) + .await? + { + return Ok(outcome); + } + + // Bodiless responses (HEAD, 1xx, 204, 304): forward headers only, skip body + if is_bodiless_response(request_method, status_code) { + client + .write_all(&buf[..header_end]) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + return if server_wants_close { + Ok(RelayOutcome::Consumed) + } else { + Ok(RelayOutcome::Reusable) + }; + } + + // No explicit framing (no Content-Length, no Transfer-Encoding). + // Per RFC 7230 §3.3.3 the body is delimited by connection close. + if matches!(body_length, BodyLength::None) { + if server_wants_close || event_stream { + // Server indicated it will close, or this is a streaming response + // such as SSE where the body is intentionally delimited by EOF. + let before_end = &buf[..header_end - 2]; + client.write_all(before_end).await.into_diagnostic()?; + if server_wants_close { + client + .write_all(b"Connection: close\r\n\r\n") + .await + .into_diagnostic()?; + } else { + client.write_all(b"\r\n").await.into_diagnostic()?; + } + let overflow = &buf[header_end..]; + if !overflow.is_empty() { + client.write_all(overflow).await.into_diagnostic()?; + client.flush().await.into_diagnostic()?; + } + if event_stream { + relay_until_eof_without_idle_timeout(upstream, client).await?; + } else { + relay_until_eof(upstream, client).await?; + } + client.flush().await.into_diagnostic()?; + return Ok(RelayOutcome::Consumed); + } + // No Connection: close — an HTTP/1.1 keep-alive server that omits + // framing headers has an empty body. Forward headers and continue + // the relay loop instead of blocking on relay_until_eof. + debug!("BodyLength::None without Connection: close — treating body as empty"); + client + .write_all(&buf[..header_end]) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + return Ok(RelayOutcome::Reusable); + } + + // Forward response headers + any overflow body bytes + client.write_all(&buf).await.into_diagnostic()?; + let overflow_len = (buf.len() - header_end) as u64; + + // Forward remaining response body + match body_length { + BodyLength::ContentLength(len) => { + let remaining = len.saturating_sub(overflow_len); + if remaining > 0 { + relay_fixed(upstream, client, remaining, None).await?; + } + } + BodyLength::Chunked => { + relay_chunked(upstream, client, &buf[header_end..], None).await?; + } + BodyLength::None => unreachable!(), + } + client.flush().await.into_diagnostic()?; + debug!( + request_method, + elapsed_ms = started_at.elapsed().as_millis(), + "relay_response complete (explicit framing)" + ); + + // When body framing is explicit (Content-Length / Chunked), always report + // the connection as reusable so the relay loop continues. If the server + // sent `Connection: close`, the *next* upstream write will fail and the + // loop will exit via the normal error path. Exiting early here would + // tear down the CONNECT tunnel before the client can detect the close, + // causing ~30 s retry delays in clients like `gh`. + Ok(RelayOutcome::Reusable) +} + +#[allow(clippy::too_many_arguments)] +async fn relay_response_through_middleware( + request_method: &str, + upstream: &mut U, + client: &mut C, + middleware: HttpResponseMiddlewareRelay<'_>, + buffered: &[u8], + header_end: usize, + status_code: u16, + body_length: BodyLength, + server_wants_close: bool, + event_stream: bool, +) -> Result> +where + U: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + if let Some(guard) = middleware.generation_guard { + guard.ensure_current()?; + } + let header_bytes = &buffered[..header_end]; + // Ordinary responses retain the HTTP parser's byte-preserving behavior. + // Response-specific normalization and limits apply only to selected hooks. + if middleware.chain.is_empty() { + return Ok(None); + } + let parsed = match middleware + .runner + .describe_http_response_chain(middleware.chain) + .await + { + Ok(described) if described.is_empty() => return Ok(None), + Ok(described) => { + parse_response_head_for_middleware(header_bytes).map(|parsed| (described, parsed)) + } + Err(error) => Err(error), + }; + if let Some(guard) = middleware.generation_guard { + guard.ensure_current()?; + } + let (described, parsed) = match parsed { + Ok(parsed) => parsed, + Err(error) => { + debug!(error = %error, "HTTP response head normalization failed"); + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + let original_headers = parsed.headers.clone(); + let upstream_declared_trailers = parsed.declared_trailers.clone(); + let connection_nominated_headers = parsed.connection_nominated.clone(); + let input = openshell_supervisor_middleware::HttpResponsePreflightInput { + context: middleware.request_context, + target: middleware.target.clone(), + status_code, + declared_body_length: match body_length { + BodyLength::ContentLength(length) => Some(length), + BodyLength::Chunked | BodyLength::None => None, + }, + headers: parsed.headers, + connection_nominated_headers: parsed.connection_nominated, + }; + let preflight_result = if parsed.representable { + middleware + .runner + .preflight_described_http_response(described, input) + .await + } else { + Ok(middleware + .runner + .http_response_input_unrepresentable(&described)) + }; + let mut preflight = match preflight_result { + Ok(preflight) => preflight, + Err(error) => { + debug!(error = %error, "HTTP response middleware preflight failed"); + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + if let Some(guard) = middleware.generation_guard + && let Err(error) = guard.ensure_current() + { + if let Some(session) = preflight.session.take() { + session + .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) + .await; + } + return Err(error); + } + debug!( + configured_stage_count = middleware.chain.len(), + active_session = preflight.session.is_some(), + allowed = preflight.allowed, + "HTTP response middleware preflight completed" + ); + for event in crate::l7::middleware::middleware_finding_events(&preflight.findings) { + openshell_ocsf::ocsf_emit!(event); + } + if !preflight.allowed { + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &preflight.invocations, + ); + if let Some(denial) = preflight.denial.as_ref() { + send_response_middleware_denial( + client, + request_method, + middleware.policy_name, + &middleware.target, + denial, + ) + .await?; + } else { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + } + return Ok(Some(RelayOutcome::Consumed)); + } + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &preflight.invocations, + ); + + let Some(mut session) = preflight.session else { + if preflight.headers == original_headers { + return Ok(None); + } + let status_line = response_status_line(header_bytes)?; + let outcome = relay_headers_only_response( + request_method, + upstream, + client, + &status_line, + &preflight.headers, + &upstream_declared_trailers, + &buffered[header_end..], + status_code, + body_length, + server_wants_close, + event_stream, + ) + .await?; + return Ok(Some(outcome)); + }; + + let status_line = response_status_line(header_bytes)?; + let supports_chunked_response = !status_line.starts_with("HTTP/1.0 "); + let bodiless = is_bodiless_response(request_method, status_code); + if bodiless { + let finish = match session.finish(Vec::new()).await { + Ok(finish) => finish, + Err(error) => { + debug!(error = %error, "HTTP response middleware finalization failed"); + emit_http_response_diagnostics( + middleware.policy_name, + &middleware.target, + status_code, + &error.diagnostics, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + return Ok(Some(RelayOutcome::Consumed)); + } + }; + if let Some(guard) = middleware.generation_guard { + guard.ensure_current()?; + } + let mut headers = preflight.headers; + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &finish.invocations, + ); + for event in crate::l7::middleware::middleware_finding_events(&finish.findings) { + openshell_ocsf::ocsf_emit!(event); + } + if finish.strip_stale_integrity_headers { + strip_response_integrity_headers(&mut headers); + } + let head = serialize_response_head( + &status_line, + &headers, + ResponseFraming::Preserve(body_length), + server_wants_close, + &[], + ); + client.write_all(&head).await.into_diagnostic()?; + client.flush().await.into_diagnostic()?; + return Ok(Some(if server_wants_close { + RelayOutcome::Consumed + } else { + RelayOutcome::Reusable + })); + } + + let whole_body = session.requires_whole_body(); + if whole_body { + session.start_whole_body_deadline(middleware.whole_body_timeout); + } + let unit_limit = session.stream_unit_limit().max(1); + let chunked_output = supports_chunked_response; + let close_delimited_output = !supports_chunked_response; + let declared_trailers = upstream_declared_trailers; + let downstream_trailers = if chunked_output { + declared_trailers.as_slice() + } else { + &[] + }; + let streaming_head = serialize_response_head( + &status_line, + &preflight.headers, + if chunked_output { + ResponseFraming::Chunked + } else { + ResponseFraming::Preserve(BodyLength::None) + }, + server_wants_close || close_delimited_output, + downstream_trailers, + ); + let mut committed = !whole_body; + if committed { + if let Err(error) = client.write_all(&streaming_head).await { + session + .end(openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect) + .await; + return Err(error).into_diagnostic(); + } + if let Err(error) = client.flush().await { + session + .end(openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect) + .await; + return Err(error).into_diagnostic(); + } + } + + let mut reader = BufferedResponseReader::new(upstream, &buffered[header_end..]); + let body_result = relay_normalized_response_body( + &mut reader, + &mut session, + client, + body_length, + server_wants_close, + event_stream, + &mut committed, + chunked_output, + &streaming_head, + unit_limit, + middleware.generation_guard, + middleware.policy_name, + &middleware.target, + status_code, + &connection_nominated_headers, + ) + .await; + let trailers = match body_result { + Ok(trailers) => trailers, + Err(error) => { + let middleware_stop = error.downcast_ref::(); + let end_reason = if middleware + .generation_guard + .is_some_and(PolicyGenerationGuard::is_stale) + { + openshell_core::proto::MiddlewareSessionEndReason::PolicyReload + } else if error.downcast_ref::().is_some() { + openshell_core::proto::MiddlewareSessionEndReason::DownstreamDisconnect + } else if middleware_stop.is_some_and(|stop| stop.failure.denial.is_some()) { + openshell_core::proto::MiddlewareSessionEndReason::MiddlewareDenial + } else if middleware_stop.is_some() { + openshell_core::proto::MiddlewareSessionEndReason::MiddlewareFailure + } else { + openshell_core::proto::MiddlewareSessionEndReason::UpstreamDisconnect + }; + session.end(end_reason).await; + if committed { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + true, + ); + return Err(error); + } + debug!(error = %error, "HTTP response processing failed before commitment"); + if let Some(denial) = middleware_stop.and_then(|stop| stop.failure.denial.as_ref()) { + send_response_middleware_denial( + client, + request_method, + middleware.policy_name, + &middleware.target, + denial, + ) + .await?; + } else { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + } + return Ok(Some(RelayOutcome::Consumed)); + } + }; + + if let Some(guard) = middleware.generation_guard + && let Err(error) = guard.ensure_current() + { + session + .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) + .await; + return Err(error); + } + + let finish = match session.finish(trailers).await { + Ok(finish) => finish, + Err(error) => { + emit_http_response_diagnostics( + middleware.policy_name, + &middleware.target, + status_code, + &error.diagnostics, + ); + if committed { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + true, + ); + return Err(miette!( + "HTTP response middleware failed after commitment: {error}" + )); + } + debug!(error = %error, "HTTP response middleware failed before commitment"); + if let Some(denial) = error.denial.as_ref() { + send_response_middleware_denial( + client, + request_method, + middleware.policy_name, + &middleware.target, + denial, + ) + .await?; + } else { + emit_http_response_middleware_failure( + middleware.policy_name, + &middleware.target, + status_code, + false, + ); + send_response_delivery_failure( + client, + request_method, + middleware.policy_name, + &middleware.target, + ) + .await?; + } + return Ok(Some(RelayOutcome::Consumed)); + } + }; + if let Some(guard) = middleware.generation_guard { + guard.ensure_current()?; + } + emit_http_response_middleware_invocations( + middleware.policy_name, + &middleware.target, + status_code, + &finish.invocations, + ); + for event in crate::l7::middleware::middleware_finding_events(&finish.findings) { + openshell_ocsf::ocsf_emit!(event); + } + + if whole_body && !committed { + let mut headers = preflight.headers; + if finish.strip_stale_integrity_headers { + strip_response_integrity_headers(&mut headers); + } + let output_length = finish + .body_units + .iter() + .try_fold(0usize, |total, unit| total.checked_add(unit.len())) + .ok_or_else(|| miette!("HTTP response middleware output length overflow"))?; + let framing = if finish.trailers.is_empty() || !supports_chunked_response { + ResponseFraming::ContentLength(output_length as u64) + } else { + ResponseFraming::Chunked + }; + let trailer_names: Vec = if supports_chunked_response { + finish + .trailers + .iter() + .map(|header| header.name.clone()) + .collect() + } else { + Vec::new() + }; + let head = serialize_response_head( + &status_line, + &headers, + framing, + server_wants_close, + &trailer_names, + ); + client.write_all(&head).await.into_diagnostic()?; + if matches!(framing, ResponseFraming::Chunked) { + for unit in &finish.body_units { + write_chunk(client, unit).await?; + } + write_response_trailers(client, &finish.trailers).await?; + } else { + for unit in &finish.body_units { + client.write_all(unit).await.into_diagnostic()?; + } + } + } else { + for unit in &finish.body_units { + if chunked_output { + write_chunk(client, unit).await?; + } else { + client.write_all(unit).await.into_diagnostic()?; + } + } + if chunked_output { + write_response_trailers(client, &finish.trailers).await?; + } + } + client.flush().await.into_diagnostic()?; + Ok(Some( + if (committed && close_delimited_output) + || (matches!(body_length, BodyLength::None) && (server_wants_close || event_stream)) + { + RelayOutcome::Consumed + } else { + RelayOutcome::Reusable + }, + )) +} + +#[allow(clippy::too_many_arguments)] +async fn relay_headers_only_response( + request_method: &str, + upstream: &mut U, + client: &mut C, + status_line: &str, + headers: &[HttpHeader], + declared_trailers: &[String], + overflow: &[u8], + status_code: u16, + body_length: BodyLength, + server_wants_close: bool, + event_stream: bool, +) -> Result +where + U: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + let head = serialize_response_head( + status_line, + headers, + ResponseFraming::Preserve(body_length), + server_wants_close, + declared_trailers, + ); + client.write_all(&head).await.into_diagnostic()?; + + if is_bodiless_response(request_method, status_code) { + client.flush().await.into_diagnostic()?; + return Ok(if server_wants_close { + RelayOutcome::Consumed + } else { + RelayOutcome::Reusable + }); + } + + client.write_all(overflow).await.into_diagnostic()?; + match body_length { + BodyLength::ContentLength(length) => { + let remaining = length.saturating_sub(overflow.len() as u64); + if remaining > 0 { + relay_fixed(upstream, client, remaining, None).await?; + } + } + BodyLength::Chunked => relay_chunked(upstream, client, overflow, None).await?, + BodyLength::None if server_wants_close || event_stream => { + if event_stream { + relay_until_eof_without_idle_timeout(upstream, client).await?; + } else { + relay_until_eof(upstream, client).await?; + } + client.flush().await.into_diagnostic()?; + return Ok(RelayOutcome::Consumed); + } + BodyLength::None => {} + } + client.flush().await.into_diagnostic()?; + Ok(RelayOutcome::Reusable) +} + +fn emit_http_response_middleware_invocations( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + invocations: &[openshell_supervisor_middleware::HttpResponseInvocation], +) { + for event in + http_response_middleware_invocation_events(policy_name, target, status_code, invocations) + { + openshell_ocsf::ocsf_emit!(event); + } + for invocation in invocations { + if let Some(event) = + http_response_middleware_fail_open_finding_event(policy_name, target, invocation) + { + openshell_ocsf::ocsf_emit!(event); + } + if let Some(event) = + http_response_middleware_block_finding_event(policy_name, target, invocation) + { + openshell_ocsf::ocsf_emit!(event); + } + } +} + +fn emit_http_response_diagnostics( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + diagnostics: &openshell_supervisor_middleware::HttpResponseDiagnostics, +) { + emit_http_response_middleware_invocations( + policy_name, + target, + status_code, + &diagnostics.invocations, + ); + for event in crate::l7::middleware::middleware_finding_events(&diagnostics.findings) { + openshell_ocsf::ocsf_emit!(event); + } +} + +pub(super) fn http_response_middleware_invocation_events( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + invocations: &[openshell_supervisor_middleware::HttpResponseInvocation], +) -> Vec { + invocations + .iter() + .map(|invocation| { + let outcome = format!("{:?}", invocation.outcome).to_ascii_lowercase(); + let failed = invocation.failed; + let blocked = invocation.outcome + == openshell_supervisor_middleware::HttpResponseInvocationOutcome::BlockDelivery; + openshell_ocsf::HttpActivityBuilder::new(ocsf_ctx()) + .activity(openshell_ocsf::ActivityId::Other) + .action(if blocked { + openshell_ocsf::ActionId::Denied + } else if failed { + openshell_ocsf::ActionId::Other + } else { + openshell_ocsf::ActionId::Allowed + }) + .disposition(if blocked { + openshell_ocsf::DispositionId::Blocked + } else if failed { + openshell_ocsf::DispositionId::Error + } else { + openshell_ocsf::DispositionId::Allowed + }) + .severity(if failed || blocked { + openshell_ocsf::SeverityId::Medium + } else { + openshell_ocsf::SeverityId::Informational + }) + .status(if failed || blocked { + openshell_ocsf::StatusId::Failure + } else { + openshell_ocsf::StatusId::Success + }) + .http_request(openshell_ocsf::HttpRequest::new( + &target.method, + openshell_ocsf::Url::new( + &target.scheme, + &target.host, + &target.path, + u16::try_from(target.port).unwrap_or_default(), + ), + )) + .http_response(openshell_ocsf::HttpResponse { code: status_code }) + .dst_endpoint(openshell_ocsf::Endpoint::from_domain( + &target.host, + u16::try_from(target.port).unwrap_or_default(), + )) + .firewall_rule(policy_name, "supervisor-middleware") + .unmapped("middleware_config", invocation.config_name.as_str()) + .unmapped( + "middleware_implementation", + invocation.implementation.as_str(), + ) + .unmapped("response_middleware_outcome", outcome.as_str()) + .unmapped("sequence", invocation.sequence.unwrap_or_default()) + .unmapped("input_bytes", invocation.input_size) + .unmapped("failed", failed) + .message(format!( + "HTTP_RESPONSE_MIDDLEWARE config={} implementation={} outcome={} sequence={} input_bytes={} failed={failed}", + invocation.config_name, + invocation.implementation, + outcome, + invocation.sequence.unwrap_or_default(), + invocation.input_size, + )) + .build() + }) + .collect() +} + +fn http_response_middleware_block_finding_event( + policy_name: &str, + target: &HttpRequestTarget, + invocation: &openshell_supervisor_middleware::HttpResponseInvocation, +) -> Option { + if invocation.outcome + != openshell_supervisor_middleware::HttpResponseInvocationOutcome::BlockDelivery + { + return None; + } + Some( + openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) + .severity(openshell_ocsf::SeverityId::Medium) + .finding_info(openshell_ocsf::FindingInfo::new( + "openshell.middleware.http_response_blocked", + "HTTP response blocked by middleware", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("middleware_config", invocation.config_name.as_str()), + ( + "middleware_implementation", + invocation.implementation.as_str(), + ), + ("host", target.host.as_str()), + ("phase", "pre_return"), + ]) + .unmapped("middleware_config", invocation.config_name.as_str()) + .unmapped( + "middleware_implementation", + invocation.implementation.as_str(), + ) + .unmapped("phase", "pre_return") + .message("HTTP response delivery blocked by middleware") + .build(), + ) +} + +pub(super) fn http_response_middleware_fail_open_finding_event( + policy_name: &str, + target: &HttpRequestTarget, + invocation: &openshell_supervisor_middleware::HttpResponseInvocation, +) -> Option { + if !invocation.failed + || invocation.outcome + != openshell_supervisor_middleware::HttpResponseInvocationOutcome::FailOpen + { + return None; + } + let failure_category = invocation + .failure_category + .as_deref() + .unwrap_or("middleware_failure"); + Some( + openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) + .severity(openshell_ocsf::SeverityId::Medium) + .finding_info(openshell_ocsf::FindingInfo::new( + "openshell.middleware.http_response_fail_open", + "HTTP response middleware failed open", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("middleware_config", invocation.config_name.as_str()), + ( + "middleware_implementation", + invocation.implementation.as_str(), + ), + ("host", target.host.as_str()), + ("phase", "pre_return"), + ("failure_category", failure_category), + ]) + .unmapped("middleware_config", invocation.config_name.as_str()) + .unmapped( + "middleware_implementation", + invocation.implementation.as_str(), + ) + .unmapped("phase", "pre_return") + .unmapped("failure_category", failure_category) + .message("HTTP response middleware failed and response inspection was bypassed") + .build(), + ) +} + +fn emit_http_response_middleware_failure( + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + committed: bool, +) { + let status_code = status_code.to_string(); + let event = openshell_ocsf::DetectionFindingBuilder::new(ocsf_ctx()) + .severity(openshell_ocsf::SeverityId::High) + .finding_info(openshell_ocsf::FindingInfo::new( + "openshell.middleware.http_response_failure", + "HTTP response middleware delivery failure", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("host", target.host.as_str()), + ( + "commitment", + if committed { + "after_commit" + } else { + "before_commit" + }, + ), + ("upstream_status", status_code.as_str()), + ]) + .message(if committed { + "HTTP response middleware failed after response commitment" + } else { + "HTTP response middleware failed before response commitment" + }) + .build(); + openshell_ocsf::ocsf_emit!(event); +} + +#[derive(Debug)] +struct ParsedResponseHead { + representable: bool, + headers: Vec, + connection_nominated: Vec, + declared_trailers: Vec, +} + +fn parse_response_head_for_middleware(header_bytes: &[u8]) -> Result { + // Lossy decoding preserves ASCII syntax and control bytes for validation. + // Never pass replacement text to middleware or use it for delivery. + let header = String::from_utf8_lossy(header_bytes); + let representable = std::str::from_utf8(header_bytes).is_ok(); + if parse_status_code(&header).is_none() { + return Err(miette!("HTTP response status line is malformed")); + } + let mut nominated = HashSet::new(); + let mut declared_trailers = Vec::new(); + for line in header.split("\r\n").skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + validate_http_field_name(name)?; + validate_http_field_value(value.trim())?; + if name.eq_ignore_ascii_case("connection") { + for token in value + .split(',') + .map(str::trim) + .filter(|token| !token.is_empty()) + { + nominated.insert(token.to_ascii_lowercase()); + } + } else if name.eq_ignore_ascii_case("trailer") { + for token in parse_http_token_list(value)? { + let token = token.to_ascii_lowercase(); + if !declared_trailers.contains(&token) { + declared_trailers.push(token); + } + } + } + } + for trailer in &declared_trailers { + if is_protected_response_field(trailer) || nominated.contains(trailer) { + return Err(miette!("HTTP response declares a protected trailer field")); + } + } + let mut headers = Vec::new(); + for line in header.split("\r\n").skip(1).filter(|line| !line.is_empty()) { + let (name, value) = line + .split_once(':') + .ok_or_else(|| miette!("Malformed HTTP response header field"))?; + validate_http_field_name(name)?; + validate_http_field_value(value.trim())?; + let name = name.to_ascii_lowercase(); + if nominated.contains(&name) || is_hidden_response_field(&name) { + continue; + } + headers.push(HttpHeader { + name, + value: value.trim().to_string(), + }); + } + let mut connection_nominated: Vec<_> = nominated.into_iter().collect(); + connection_nominated.sort(); + Ok(ParsedResponseHead { + representable, + headers: if representable { headers } else { Vec::new() }, + connection_nominated, + declared_trailers, + }) +} + +fn validate_http_field_name(name: &str) -> Result<()> { + if name.is_empty() + || !name.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' + | b'$' + | b'%' + | b'&' + | b'\'' + | b'*' + | b'+' + | b'-' + | b'.' + | b'^' + | b'_' + | b'`' + | b'|' + | b'~' + ) + }) + { + return Err(miette!("HTTP response field name is malformed")); + } + Ok(()) +} + +fn validate_http_field_value(value: &str) -> Result<()> { + if value + .bytes() + .any(|byte| (byte < 0x20 && byte != b'\t') || byte == 0x7f) + { + return Err(miette!("HTTP response field value contains a control byte")); + } + Ok(()) +} + +fn is_protected_response_field(name: &str) -> bool { + name.eq_ignore_ascii_case("content-length") || is_hidden_response_field(name) +} + +fn is_hidden_response_field(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "connection" + | "keep-alive" + | "proxy-authenticate" + | "proxy-authorization" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + +fn response_status_line(header_bytes: &[u8]) -> Result { + let line_end = header_bytes + .windows(2) + .position(|window| window == b"\r\n") + .ok_or_else(|| miette!("HTTP response status line is incomplete"))?; + std::str::from_utf8(&header_bytes[..line_end]) + .map(str::to_string) + .map_err(|_| miette!("HTTP response status line contains invalid UTF-8")) +} + +#[derive(Clone, Copy)] +enum ResponseFraming { + Preserve(BodyLength), + ContentLength(u64), + Chunked, +} + +fn serialize_response_head( + status_line: &str, + headers: &[HttpHeader], + framing: ResponseFraming, + connection_close: bool, + trailer_names: &[String], +) -> Vec { + let mut output = format!("{status_line}\r\n"); + for header in headers { + // Content-Length is read-only middleware metadata. The relay emits the + // final framing exactly once from `framing` below. + if header.name.eq_ignore_ascii_case("content-length") { + continue; + } + output.push_str(&header.name); + output.push_str(": "); + output.push_str(&header.value); + output.push_str("\r\n"); + } + match framing { + ResponseFraming::Preserve(BodyLength::ContentLength(length)) + | ResponseFraming::ContentLength(length) => { + write!(&mut output, "Content-Length: {length}\r\n") + .expect("writing to a String cannot fail"); + } + ResponseFraming::Preserve(BodyLength::Chunked) | ResponseFraming::Chunked => { + output.push_str("Transfer-Encoding: chunked\r\n"); + } + ResponseFraming::Preserve(BodyLength::None) => {} + } + if !trailer_names.is_empty() { + output.push_str("Trailer: "); + output.push_str(&trailer_names.join(", ")); + output.push_str("\r\n"); + } + if connection_close { + output.push_str("Connection: close\r\n"); + } + output.push_str("\r\n"); + output.into_bytes() +} + +pub(super) fn strip_response_integrity_headers(headers: &mut Vec) { + headers.retain(|header| { + !openshell_supervisor_middleware::is_stale_http_response_integrity_header(&header.name) + }); +} + +struct BufferedResponseReader<'a, R> { + upstream: &'a mut R, + buffered: &'a [u8], + position: usize, + exact_buffer: Vec, + exact_target: Option, + line_buffer: Vec, +} + +impl<'a, R: AsyncRead + Unpin> BufferedResponseReader<'a, R> { + fn new(upstream: &'a mut R, buffered: &'a [u8]) -> Self { + Self { + upstream, + buffered, + position: 0, + exact_buffer: Vec::new(), + exact_target: None, + line_buffer: Vec::new(), + } + } + + async fn read_some(&mut self, limit: usize) -> Result>> { + if self.position < self.buffered.len() { + let end = self.position.saturating_add(limit).min(self.buffered.len()); + let data = self.buffered[self.position..end].to_vec(); + self.position = end; + return Ok(Some(data)); + } + let mut data = vec![0u8; limit.max(1)]; + let count = self.upstream.read(&mut data).await.into_diagnostic()?; + if count == 0 { + return Ok(None); + } + data.truncate(count); + Ok(Some(data)) + } + + async fn read_exact_vec(&mut self, length: usize) -> Result> { + match self.exact_target { + Some(target) if target != length => { + return Err(miette!("HTTP response reader exact-read state mismatch")); + } + None => { + self.exact_target = Some(length); + self.exact_buffer.reserve(length); + } + Some(_) => {} + } + while self.exact_buffer.len() < length { + let remaining = length - self.exact_buffer.len(); + let Some(data) = self.read_some(remaining).await? else { + return Err(miette!("HTTP response body ended unexpectedly")); + }; + self.exact_buffer.extend_from_slice(&data); + } + self.exact_target = None; + Ok(std::mem::take(&mut self.exact_buffer)) + } + + async fn read_line(&mut self) -> Result> { + loop { + let Some(byte) = self.read_some(1).await? else { + return Err(miette!("HTTP response ended before line terminator")); + }; + self.line_buffer.push(byte[0]); + if self.line_buffer.len() > MAX_HEADER_BYTES { + return Err(miette!("HTTP response line exceeds limit")); + } + if self.line_buffer.ends_with(b"\r\n") { + self.line_buffer.truncate(self.line_buffer.len() - 2); + return Ok(std::mem::take(&mut self.line_buffer)); + } + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn relay_normalized_response_body( + reader: &mut BufferedResponseReader<'_, R>, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + body_length: BodyLength, + server_wants_close: bool, + event_stream: bool, + committed: &mut bool, + chunked_output: bool, + commit_head: &[u8], + unit_limit: usize, + generation_guard: Option<&PolicyGenerationGuard>, + policy_name: &str, + target: &HttpRequestTarget, + status_code: u16, + connection_nominated_headers: &[String], +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + let mut pending = Vec::with_capacity(unit_limit); + let mut framing = ResponseOutputState { + committed, + chunked: chunked_output, + commit_head, + generation_guard, + policy_name, + target, + status_code, + }; + match body_length { + BodyLength::ContentLength(mut remaining) => { + while remaining > 0 { + let length = usize::try_from(remaining) + .unwrap_or(unit_limit) + .min(unit_limit); + let unit = read_response_payload_with_deadline( + reader, + length, + !pending.is_empty(), + session, + client, + &mut framing, + ) + .await?; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + let partial = unit.len() < length; + remaining -= unit.len() as u64; + buffer_normalized_response_bytes( + session, + client, + &mut pending, + unit, + &mut framing, + unit_limit, + ) + .await?; + if partial { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + } + } + flush_normalized_response_bytes(session, client, pending, &mut framing).await?; + Ok(Vec::new()) + } + BodyLength::Chunked => { + let mut size_line = + read_response_line_with_deadline(reader, session, client, &mut framing).await?; + loop { + let size_line_text = std::str::from_utf8(&size_line) + .map_err(|_| miette!("Invalid UTF-8 in response chunk-size line"))?; + let size_token = size_line_text + .split(';') + .next() + .map(str::trim) + .unwrap_or_default(); + let chunk_size = usize::from_str_radix(size_token, 16) + .map_err(|_| miette!("Invalid HTTP response chunk size"))?; + if chunk_size == 0 { + flush_normalized_response_bytes(session, client, pending, &mut framing).await?; + return read_response_trailers( + reader, + session, + client, + &mut framing, + connection_nominated_headers, + ) + .await; + } + let mut remaining = chunk_size; + while remaining > 0 { + let length = remaining.min(unit_limit); + let unit = read_response_payload_with_deadline( + reader, + length, + !pending.is_empty(), + session, + client, + &mut framing, + ) + .await?; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + let partial = unit.len() < length; + remaining -= unit.len(); + buffer_normalized_response_bytes( + session, + client, + &mut pending, + unit, + &mut framing, + unit_limit, + ) + .await?; + if partial { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + } + } + let terminator = if let Ok(result) = + tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, reader.read_exact_vec(2)) + .await + { + result? + } else { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + read_exact_response_with_deadline(reader, 2, session, client, &mut framing) + .await? + }; + if terminator != b"\r\n" { + return Err(miette!("HTTP response chunk is missing its terminator")); + } + size_line = if let Ok(line) = + tokio::time::timeout(RESPONSE_UNIT_COALESCE_TIMEOUT, reader.read_line()).await + { + line? + } else { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + read_response_line_with_deadline(reader, session, client, &mut framing).await? + }; + } + } + BodyLength::None if server_wants_close || event_stream => loop { + // Cancel only input acquisition, never expiry or client writes. + let wait = if pending.is_empty() { + if event_stream { + None + } else { + Some(RELAY_EOF_IDLE_TIMEOUT) + } + } else { + Some(RESPONSE_UNIT_COALESCE_TIMEOUT) + }; + let read_deadline = wait.map(|wait| tokio::time::Instant::now() + wait); + let whole_deadline = session.whole_body_deadline(); + let deadline = match (read_deadline, whole_deadline) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + let next = if let Some(deadline) = deadline { + if let Ok(result) = + tokio::time::timeout_at(deadline, reader.read_some(unit_limit)).await + { + result? + } else { + if whole_deadline.is_some_and(|d| d <= tokio::time::Instant::now()) { + expire_whole_body_deadline(session, client, &mut framing).await?; + } else if pending.is_empty() { + return Ok(Vec::new()); + } else { + flush_normalized_response_bytes( + session, + client, + std::mem::take(&mut pending), + &mut framing, + ) + .await?; + } + continue; + } + } else { + reader.read_some(unit_limit).await? + }; + let Some(unit) = next else { + flush_normalized_response_bytes(session, client, pending, &mut framing).await?; + return Ok(Vec::new()); + }; + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + buffer_normalized_response_bytes( + session, + client, + &mut pending, + unit, + &mut framing, + unit_limit, + ) + .await?; + }, + BodyLength::None => { + flush_normalized_response_bytes(session, client, pending, &mut framing).await?; + Ok(Vec::new()) + } + } +} + +struct ResponseOutputState<'a> { + committed: &'a mut bool, + chunked: bool, + commit_head: &'a [u8], + generation_guard: Option<&'a PolicyGenerationGuard>, + policy_name: &'a str, + target: &'a HttpRequestTarget, + status_code: u16, +} + +#[derive(Debug)] +struct ResponseMiddlewareStop { + failure: openshell_supervisor_middleware::HttpResponseMiddlewareFailure, +} + +impl ResponseMiddlewareStop { + fn new(failure: openshell_supervisor_middleware::HttpResponseMiddlewareFailure) -> Self { + Self { failure } + } +} + +impl fmt::Display for ResponseMiddlewareStop { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + formatter, + "HTTP response middleware stopped delivery: {}", + self.failure + ) + } +} + +impl std::error::Error for ResponseMiddlewareStop {} + +impl miette::Diagnostic for ResponseMiddlewareStop {} + +#[derive(Debug)] +struct ResponseDownstreamWrite(std::io::Error); + +impl fmt::Display for ResponseDownstreamWrite { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "HTTP response client write failed: {}", self.0) + } +} + +impl std::error::Error for ResponseDownstreamWrite { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + +impl miette::Diagnostic for ResponseDownstreamWrite {} + +async fn expire_whole_body_deadline( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, +) -> Result<()> { + let output = session.expire_whole_body_deadline().await; + if let Some(guard) = framing.generation_guard { + guard.ensure_current()?; + } + let diagnostics = session.take_diagnostics(); + emit_http_response_diagnostics( + framing.policy_name, + framing.target, + framing.status_code, + &diagnostics, + ); + let output = output.map_err(ResponseMiddlewareStop::new)?; + deliver_response_units(client, output, framing, session.requires_whole_body()).await +} + +// Unit size is a maximum. Coalesce available payload without waiting for +// the rest of a transfer chunk, and finish expiry outside read timeouts. +async fn read_response_payload_with_deadline( + reader: &mut BufferedResponseReader<'_, R>, + limit: usize, + has_pending: bool, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + let mut payload = Vec::new(); + let mut coalesce_deadline = + has_pending.then(|| tokio::time::Instant::now() + RESPONSE_UNIT_COALESCE_TIMEOUT); + loop { + let whole_deadline = session.whole_body_deadline(); + let deadline = match (coalesce_deadline, whole_deadline) { + (Some(a), Some(b)) => Some(a.min(b)), + (a, b) => a.or(b), + }; + let read = reader.read_some(limit - payload.len()); + let result = if let Some(deadline) = deadline { + if let Ok(result) = tokio::time::timeout_at(deadline, read).await { + result + } else { + if whole_deadline.is_some_and(|d| d <= tokio::time::Instant::now()) { + expire_whole_body_deadline(session, client, framing).await?; + } + if coalesce_deadline.is_some_and(|d| d <= tokio::time::Instant::now()) { + return Ok(payload); + } + continue; + } + } else { + read.await + }; + let data = result?.ok_or_else(|| miette!("HTTP response body ended unexpectedly"))?; + payload.extend_from_slice(&data); + if payload.len() == limit { + return Ok(payload); + } + coalesce_deadline + .get_or_insert_with(|| tokio::time::Instant::now() + RESPONSE_UNIT_COALESCE_TIMEOUT); + } +} + +async fn read_exact_response_with_deadline( + reader: &mut BufferedResponseReader<'_, R>, + length: usize, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + loop { + let Some(deadline) = session.whole_body_deadline() else { + return reader.read_exact_vec(length).await; + }; + match tokio::time::timeout_at(deadline, reader.read_exact_vec(length)).await { + Ok(result) => return result, + Err(_) => expire_whole_body_deadline(session, client, framing).await?, + } + } +} + +async fn read_response_line_with_deadline( + reader: &mut BufferedResponseReader<'_, R>, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + loop { + let Some(deadline) = session.whole_body_deadline() else { + return reader.read_line().await; + }; + match tokio::time::timeout_at(deadline, reader.read_line()).await { + Ok(result) => return result, + Err(_) => expire_whole_body_deadline(session, client, framing).await?, + } + } +} + +async fn buffer_normalized_response_bytes( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + pending: &mut Vec, + data: Vec, + framing: &mut ResponseOutputState<'_>, + unit_limit: usize, +) -> Result<()> { + pending.extend_from_slice(&data); + while pending.len() >= unit_limit { + let remainder = pending.split_off(unit_limit); + let unit = std::mem::replace(pending, remainder); + process_response_unit(session, client, unit, framing).await?; + } + Ok(()) +} + +async fn flush_normalized_response_bytes( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + pending: Vec, + framing: &mut ResponseOutputState<'_>, +) -> Result<()> { + if pending.is_empty() { + return Ok(()); + } + process_response_unit(session, client, pending, framing).await +} + +async fn process_response_unit( + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + unit: Vec, + framing: &mut ResponseOutputState<'_>, +) -> Result<()> { + let output = session.push_body(unit).await; + if let Some(guard) = framing.generation_guard { + guard.ensure_current()?; + } + let diagnostics = session.take_diagnostics(); + emit_http_response_diagnostics( + framing.policy_name, + framing.target, + framing.status_code, + &diagnostics, + ); + let output = output.map_err(ResponseMiddlewareStop::new)?; + deliver_response_units(client, output, framing, session.requires_whole_body()).await +} + +async fn deliver_response_units( + client: &mut C, + output: Vec>, + framing: &mut ResponseOutputState<'_>, + whole_body_pending: bool, +) -> Result<()> { + if !*framing.committed && !output.is_empty() { + if whole_body_pending { + return Err(miette!( + "whole-body response middleware released output before finalization" + )); + } + // `write_all` may return an error after a partial write. Treat the + // response as committed before the attempt so callers never append a + // canonical error response behind a partially delivered upstream head. + *framing.committed = true; + client + .write_all(framing.commit_head) + .await + .map_err(ResponseDownstreamWrite)?; + client.flush().await.map_err(ResponseDownstreamWrite)?; + } + if *framing.committed { + for unit in output { + if framing.chunked { + write_downstream_response_chunk(client, &unit).await?; + } else { + client + .write_all(&unit) + .await + .map_err(ResponseDownstreamWrite)?; + } + } + client.flush().await.map_err(ResponseDownstreamWrite)?; + } + Ok(()) +} + +async fn write_downstream_response_chunk( + client: &mut C, + payload: &[u8], +) -> Result<()> { + if payload.is_empty() { + return Ok(()); + } + client + .write_all(format!("{:X}\r\n", payload.len()).as_bytes()) + .await + .map_err(ResponseDownstreamWrite)?; + client + .write_all(payload) + .await + .map_err(ResponseDownstreamWrite)?; + client + .write_all(b"\r\n") + .await + .map_err(ResponseDownstreamWrite)?; + Ok(()) +} + +async fn read_response_trailers( + reader: &mut BufferedResponseReader<'_, R>, + session: &mut openshell_supervisor_middleware::HttpResponseSession, + client: &mut C, + framing: &mut ResponseOutputState<'_>, + connection_nominated_headers: &[String], +) -> Result> +where + R: AsyncRead + Unpin, + C: AsyncWrite + Unpin, +{ + let mut trailers = Vec::new(); + loop { + let line = read_response_line_with_deadline(reader, session, client, framing).await?; + if line.is_empty() { + return Ok(trailers); + } + let line = std::str::from_utf8(&line) + .map_err(|_| miette!("HTTP response trailer contains invalid UTF-8"))?; + let (name, value) = line + .split_once(':') + .ok_or_else(|| miette!("Malformed HTTP response trailer"))?; + validate_http_field_name(name)?; + validate_http_field_value(value.trim())?; + let name = name.to_ascii_lowercase(); + if is_protected_response_field(&name) || connection_nominated_headers.contains(&name) { + return Err(miette!("HTTP response trailer uses a protected field name")); + } + trailers.push(HttpHeader { + name, + value: value.trim().to_string(), + }); + if trailers.len() > openshell_supervisor_middleware::MAX_MIDDLEWARE_HEADERS { + return Err(miette!("HTTP response trailer count exceeds limit")); + } + } +} + +async fn write_response_trailers( + client: &mut C, + trailers: &[HttpHeader], +) -> Result<()> { + client.write_all(b"0\r\n").await.into_diagnostic()?; + for trailer in trailers { + client + .write_all(format!("{}: {}\r\n", trailer.name, trailer.value).as_bytes()) + .await + .into_diagnostic()?; + } + client.write_all(b"\r\n").await.into_diagnostic()?; + Ok(()) +} + +async fn send_response_middleware_denial( + client: &mut C, + request_method: &str, + policy_name: &str, + target: &HttpRequestTarget, + denial: &openshell_supervisor_middleware::MiddlewareDenial, +) -> Result<()> { + let mut body = serde_json::Map::new(); + body.insert("error".into(), serde_json::json!("middleware_denied")); + body.insert( + "detail".into(), + serde_json::json!("Response blocked by configured middleware"), + ); + body.insert("policy".into(), serde_json::json!(policy_name)); + body.insert("middleware".into(), serde_json::json!(denial.config_name)); + if let Some(reason_code) = &denial.reason_code { + body.insert("reason_code".into(), serde_json::json!(reason_code)); + } + body.insert( + "layer".into(), + serde_json::json!("http_response_pre_return"), + ); + body.insert("method".into(), serde_json::json!(target.method)); + body.insert("path".into(), serde_json::json!(target.path)); + body.insert("host".into(), serde_json::json!(target.host)); + body.insert("port".into(), serde_json::json!(target.port)); + let body = serde_json::to_vec(&serde_json::Value::Object(body)).into_diagnostic()?; + let head = format!( + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-OpenShell-Policy: {policy_name}\r\nConnection: close\r\n\r\n", + body.len() + ); + client.write_all(head.as_bytes()).await.into_diagnostic()?; + if !request_method.eq_ignore_ascii_case("HEAD") { + client.write_all(&body).await.into_diagnostic()?; + } + client.flush().await.into_diagnostic()?; + Ok(()) +} + +async fn send_response_delivery_failure( + client: &mut C, + request_method: &str, + policy_name: &str, + target: &HttpRequestTarget, +) -> Result<()> { + let body = serde_json::to_vec(&serde_json::json!({ + "error": "response_delivery_failed", + "detail": "The upstream request may have completed, but OpenShell could not deliver its response. Retrying may repeat upstream side effects.", + "policy": policy_name, + "layer": "http_response_pre_return", + "method": target.method, + "path": target.path, + "host": target.host, + "port": target.port, + })) + .into_diagnostic()?; + let head = format!( + "HTTP/1.1 502 Bad Gateway\r\nContent-Type: application/json\r\nContent-Length: {}\r\nX-OpenShell-Policy: {policy_name}\r\nConnection: close\r\n\r\n", + body.len() + ); + client.write_all(head.as_bytes()).await.into_diagnostic()?; + if !request_method.eq_ignore_ascii_case("HEAD") { + client.write_all(&body).await.into_diagnostic()?; + } + client.flush().await.into_diagnostic()?; + Ok(()) +} + +/// Parse the HTTP status code from a response status line. +/// +/// Expects the first line to look like `HTTP/1.1 200 OK`. +pub(super) fn parse_status_code(headers: &str) -> Option { + let status_line = headers.lines().next()?; + let code_str = status_line.split_whitespace().nth(1)?; + code_str.parse().ok() +} + +/// Check if the response headers contain `Connection: close`. +pub(super) fn parse_connection_close(headers: &str) -> bool { + for line in headers.lines().skip(1) { + let lower = line.to_ascii_lowercase(); + if lower.starts_with("connection:") { + let val = lower.split_once(':').map_or("", |(_, v)| v.trim()); + return val.contains("close"); + } + } + false +} + +pub(super) fn response_is_event_stream(headers: &str) -> bool { + headers.lines().skip(1).any(|line| { + let lower = line.to_ascii_lowercase(); + let Some(value) = lower.strip_prefix("content-type:") else { + return false; + }; + value + .split(';') + .next() + .is_some_and(|mime| mime.trim() == "text/event-stream") + }) +} + +#[cfg(test)] +mod tests { + use super::{ResponseFraming, parse_response_head_for_middleware, serialize_response_head}; + use crate::l7::provider::BodyLength; + use openshell_core::proto::HttpHeader; + + #[test] + fn response_middleware_preflight_keeps_read_only_content_length() { + let parsed = parse_response_head_for_middleware( + b"HTTP/1.1 206 Partial Content\r\n\ + Content-Length: 5\r\n\ + Content-Encoding: gzip\r\n\ + Content-Range: bytes 0-4/10\r\n\ + Connection: x-hop\r\n\ + X-Hop: omitted\r\n\r\n", + ) + .expect("parse response head"); + + assert_eq!( + parsed.headers, + vec![ + HttpHeader { + name: "content-length".into(), + value: "5".into(), + }, + HttpHeader { + name: "content-encoding".into(), + value: "gzip".into(), + }, + HttpHeader { + name: "content-range".into(), + value: "bytes 0-4/10".into(), + }, + ] + ); + + let nominated = parse_response_head_for_middleware( + b"HTTP/1.1 200 OK\r\nConnection: Content-Length\r\nContent-Length: 5\r\n\r\n", + ) + .expect("parse nominated response head"); + assert!(nominated.headers.is_empty()); + } + + #[test] + fn response_middleware_serializes_only_relay_owned_framing() { + let headers = vec![ + HttpHeader { + name: "content-length".into(), + value: "999".into(), + }, + HttpHeader { + name: "Content-Length".into(), + value: "998".into(), + }, + HttpHeader { + name: "content-type".into(), + value: "text/plain".into(), + }, + ]; + + for (framing, expected) in [ + ( + ResponseFraming::Preserve(BodyLength::ContentLength(5)), + Some("Content-Length: 5\r\n"), + ), + ( + ResponseFraming::ContentLength(9), + Some("Content-Length: 9\r\n"), + ), + (ResponseFraming::Chunked, None), + (ResponseFraming::Preserve(BodyLength::None), None), + ] { + let serialized = String::from_utf8(serialize_response_head( + "HTTP/1.1 200 OK", + &headers, + framing, + false, + &[], + )) + .expect("serialized response head"); + + assert_eq!( + serialized + .lines() + .filter(|line| line.to_ascii_lowercase().starts_with("content-length:")) + .count(), + usize::from(expected.is_some()) + ); + if let Some(expected) = expected { + assert!(serialized.contains(expected)); + } + if matches!(framing, ResponseFraming::Chunked) { + assert!(serialized.contains("Transfer-Encoding: chunked\r\n")); + } + } + } +} diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 58579d8b58..b47b59a0c1 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1257,8 +1257,7 @@ struct ForwardL7Reevaluation<'a> { struct ForwardMiddlewarePipeline<'a> { ctx: &'a crate::l7::relay::L7EvalContext, scheme: &'a str, - runner: &'a openshell_supervisor_middleware::ChainRunner, - generation_guard: &'a PolicyGenerationGuard, + exchange: &'a crate::l7::middleware::HttpMiddlewareExchange, l7_reevaluation: Option>, } @@ -1271,8 +1270,6 @@ impl ForwardMiddlewarePipeline<'_> { &self, request: crate::l7::provider::L7Request, client: &mut C, - chain: Vec, - request_id: &str, ) -> Result where C: TokioAsyncRead + TokioAsyncWrite + Unpin + Send, @@ -1291,18 +1288,15 @@ impl ForwardMiddlewarePipeline<'_> { None => openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, }; - crate::l7::middleware::apply_middleware_chain_for_scheme_with_request_id( - request, - client, - self.ctx, - self.scheme, - chain, - self.runner, - self.generation_guard, - transformed_body_policy, - request_id, - ) - .await + self.exchange + .apply_request( + request, + client, + self.ctx, + self.scheme, + transformed_body_policy, + ) + .await } } @@ -4735,9 +4729,7 @@ struct ForwardRelayOptions<'a> { struct ForwardResponseMiddleware<'a> { ctx: &'a crate::l7::relay::L7EvalContext, scheme: &'a str, - request_id: &'a str, - chain: &'a [openshell_supervisor_middleware::ChainEntry], - runner: &'a openshell_supervisor_middleware::ChainRunner, + exchange: &'a crate::l7::middleware::HttpMiddlewareExchange, } async fn relay_rewritten_forward_request( @@ -4768,15 +4760,9 @@ where }; let response_middleware = options.response_middleware.map(|middleware| { - crate::l7::relay::http_response_middleware_relay( - &req, - middleware.ctx, - middleware.scheme, - middleware.request_id, - middleware.chain, - middleware.runner, - Some(options.generation_guard), - ) + middleware + .exchange + .response_relay(&req, middleware.ctx, middleware.scheme) }); crate::l7::rest::relay_http_request_with_response_middleware_guarded( @@ -5725,8 +5711,9 @@ async fn handle_forward_proxy( } let request_id = uuid::Uuid::new_v4().to_string(); let websocket_chain = forward_websocket_request.then(|| chain.clone()); - let mut response_selection = None; - if !chain.is_empty() { + let response_selection = if chain.is_empty() { + None + } else { let middleware_runner = opa_engine.middleware_runner()?; let request = crate::l7::rest::request_from_buffered_http( method, @@ -5742,15 +5729,19 @@ async fn handle_forward_proxy( }), _ => None, }; + let middleware_exchange = crate::l7::middleware::HttpMiddlewareExchange::new( + request_id.clone(), + chain, + middleware_runner, + forward_generation_guard.clone(), + ); let pipeline = ForwardMiddlewarePipeline { ctx: &l7_ctx, scheme: &scheme, - runner: &middleware_runner, - generation_guard: &forward_generation_guard, + exchange: &middleware_exchange, l7_reevaluation, }; - response_selection = Some((chain.clone(), middleware_runner.clone())); - forward_request_bytes = match pipeline.apply(request, client, chain, &request_id).await? { + forward_request_bytes = match pipeline.apply(request, client).await? { crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request.raw_header, crate::l7::middleware::MiddlewareApplyResult::Denied { denial, .. } => { emit_activity_simple(activity_tx, true, "middleware"); @@ -5768,7 +5759,8 @@ async fn handle_forward_proxy( return Ok(()); } }; - } + Some(middleware_exchange) + }; let mut middleware_session = if let Some(chain) = websocket_chain.as_deref() { let request = crate::l7::rest::request_from_buffered_http( method, @@ -6058,13 +6050,11 @@ async fn handle_forward_proxy( signing_region, host: &host_lc, port, - response_middleware: response_selection.as_ref().map(|(chain, runner)| { + response_middleware: response_selection.as_ref().map(|exchange| { ForwardResponseMiddleware { ctx: &l7_ctx, scheme: &scheme, - request_id: &request_id, - chain, - runner, + exchange, } }), }, @@ -7812,28 +7802,33 @@ network_policies: .next() .expect("built-in middleware service"), ); + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "redactor".into(), + implementation: openshell_supervisor_middleware_builtins::BUILTIN_REGEX.into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]; + let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( + "test-request-id".into(), + chain, + runner, + tunnel_engine.generation_guard().clone(), + ); let pipeline = ForwardMiddlewarePipeline { ctx: &ctx, scheme: "http", - runner: &runner, - generation_guard: tunnel_engine.generation_guard(), + exchange: &exchange, 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_REGEX.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, "test-request-id") + .apply(request, &mut client) .await .expect("forward middleware pipeline"); @@ -7910,13 +7905,6 @@ network_policies: canonicalize_forward_host_header(raw, "api.example.test").unwrap(), ) .unwrap(); - let pipeline = ForwardMiddlewarePipeline { - ctx: &ctx, - scheme: "http", - runner: &runner, - generation_guard: &guard, - l7_reevaluation: None, - }; let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "blocker".into(), implementation: "test/blocking-forward".into(), @@ -7924,16 +7912,25 @@ network_policies: config: prost_types::Struct::default(), on_error: openshell_supervisor_middleware::OnError::FailClosed, }]; + let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( + "test-request-id".into(), + chain, + runner, + guard.clone(), + ); + let pipeline = ForwardMiddlewarePipeline { + ctx: &ctx, + scheme: "http", + exchange: &exchange, + l7_reevaluation: None, + }; let (_app, mut client) = tokio::io::duplex(8192); let revoke = async { entered.notified().await; state.revoke_static_provider_environment(2); release.notify_one(); }; - let (outcome, ()) = tokio::join!( - pipeline.apply(request, &mut client, chain, "test-request-id"), - revoke - ); + let (outcome, ()) = tokio::join!(pipeline.apply(request, &mut client), revoke); let request = match outcome.expect("middleware pipeline") { crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request, crate::l7::middleware::MiddlewareApplyResult::Denied { .. } => { @@ -8072,6 +8069,12 @@ network_policies: config: prost_types::Struct::default(), on_error: openshell_supervisor_middleware::OnError::FailClosed, }]; + let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( + "correlated-request-id".into(), + chain, + runner, + guard.clone(), + ); let request = b"GET /demo HTTP/1.1\r\nHost: api.example.test\r\n\r\n".to_vec(); let (mut proxy_to_upstream, mut upstream) = tokio::io::duplex(8192); let (mut app, mut proxy_to_client) = tokio::io::duplex(8192); @@ -8106,9 +8109,7 @@ network_policies: response_middleware: Some(ForwardResponseMiddleware { ctx: &ctx, scheme: "http", - request_id: "correlated-request-id", - chain: &chain, - runner: &runner, + exchange: &exchange, }), }, ) @@ -8153,6 +8154,12 @@ network_policies: config: prost_types::Struct::default(), on_error: openshell_supervisor_middleware::OnError::FailClosed, }]; + let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( + "correlated-request-id".into(), + chain, + runner, + guard.clone(), + ); let target = format!("/demo?access_token={SECRET}"); let request = format!("GET {target} HTTP/1.1\r\nHost: api.example.test\r\n\r\n").into_bytes(); @@ -8189,9 +8196,7 @@ network_policies: response_middleware: Some(ForwardResponseMiddleware { ctx: &ctx, scheme: "http", - request_id: "correlated-request-id", - chain: &chain, - runner: &runner, + exchange: &exchange, }), }, ) @@ -11402,13 +11407,6 @@ network_policies: .expect("built-in middleware service"), ); let guard = forward_test_guard(); - let pipeline = ForwardMiddlewarePipeline { - ctx: &ctx, - scheme: "http", - runner: &runner, - generation_guard: &guard, - l7_reevaluation: None, - }; let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "redactor".into(), implementation: openshell_supervisor_middleware_builtins::BUILTIN_REGEX.into(), @@ -11416,10 +11414,22 @@ network_policies: config: prost_types::Struct::default(), on_error: openshell_supervisor_middleware::OnError::FailClosed, }]; + let exchange = crate::l7::middleware::HttpMiddlewareExchange::new( + "test-request-id".into(), + chain, + runner, + guard, + ); + let pipeline = ForwardMiddlewarePipeline { + ctx: &ctx, + scheme: "http", + exchange: &exchange, + l7_reevaluation: None, + }; let (_app, mut client) = tokio::io::duplex(8192); let allowed = pipeline - .apply(request, &mut client, chain, "test-request-id") + .apply(request, &mut client) .await .expect("middleware pipeline"); let crate::l7::middleware::MiddlewareApplyResult::Allowed(request) = allowed else { diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index b70f4c7d5f..eade980dc0 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -22,6 +22,14 @@ For each inspected HTTP request, the supervisor: 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. +Response middleware advertising `HTTP_RESPONSE/PRE_RETURN` inspects the final +upstream response before OpenShell returns it to the workload. Its preflight +includes upstream `Content-Length`, `Content-Encoding`, and `Content-Range` +fields as read-only metadata, except when `Connection` nominates the field as +hop-by-hop. Middleware cannot write or remove these fields. OpenShell computes +downstream framing separately and emits the final `Content-Length` or +`Transfer-Encoding` exactly once after middleware processing. + For an RFC 6455 upgrade over `ws://` or `wss://`, the supervisor first finds every host-matched attachment, then selects only implementations that advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. It opens one ordered, phase-specific `EvaluateWebSocketSession` stream per selected stage. OpenShell sends `WebSocketSessionEvent` values, while the service returns `WebSocketSessionEventResult` values only for preflight and message events; session start and end are notifications. Future upstream-to-client inspection uses the same RPC with `PRE_RETURN`; an implementation that advertises both phases receives two independent streams for the WebSocket session. An attachment without the selected binding can still inspect the HTTP upgrade request when it advertises the HTTP binding, but it is not a failed WebSocket stage. OpenShell allows post-upgrade traffic and emits an informational `binding_not_selected` coverage event for that attachment. 1. A preflight before the upgrade is sent upstream. The stage chooses `INSPECT`, voluntary `SKIP`, or authoritative `DENY` and may return a bounded diagnostic reason, stable reason code, findings, and metadata. OpenShell runs selected preflights concurrently; any `DENY` rejects the upgrade regardless of `on_error`. From b570b7c584cce51f36573e6de47d09deb21cf1ea Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 10 Sep 2026 16:59:10 -0700 Subject: [PATCH 11/13] fix(middleware): hide response credential headers Signed-off-by: Piotr Mlocek --- .../src/headers.rs | 35 ++++++--- .../src/l7/rest.rs | 78 ++++++++++++++++++- .../src/l7/rest/http_response.rs | 31 +++++++- 3 files changed, 130 insertions(+), 14 deletions(-) diff --git a/crates/openshell-supervisor-middleware/src/headers.rs b/crates/openshell-supervisor-middleware/src/headers.rs index 5e2c6c2807..056ad32cf7 100644 --- a/crates/openshell-supervisor-middleware/src/headers.rs +++ b/crates/openshell-supervisor-middleware/src/headers.rs @@ -305,28 +305,39 @@ fn is_request_protected(name: &str) -> bool { || name.starts_with("x-openshell-credential") } -fn is_response_protected(name: &str) -> bool { +/// Return whether a response header can carry authentication material and must +/// never be exposed to middleware. +#[must_use] +pub fn is_response_credential_header(name: &str) -> bool { + let name = name.to_ascii_lowercase(); matches!( - name, + name.as_str(), "authentication-info" - | "connection" - | "content-encoding" - | "content-length" - | "content-range" - | "keep-alive" | "proxy-authenticate" | "proxy-authentication-info" | "proxy-authorization" - | "proxy-connection" | "set-cookie" - | "te" - | "trailer" - | "transfer-encoding" - | "upgrade" | "www-authenticate" ) || name.starts_with("x-openshell-credential") } +fn is_response_protected(name: &str) -> bool { + is_response_credential_header(name) + || matches!( + name, + "connection" + | "content-encoding" + | "content-length" + | "content-range" + | "keep-alive" + | "proxy-connection" + | "te" + | "trailer" + | "transfer-encoding" + | "upgrade" + ) +} + fn is_response_remove_only(name: &str) -> bool { matches!( name, diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 776154168d..987fce891d 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -3472,6 +3472,7 @@ mod tests { script: ResponseRelayScript, request_only: bool, body_gate: Option, + captured_preflight_headers: Option>>>, } #[derive(Clone)] @@ -3535,6 +3536,7 @@ mod tests { ); let mut script = self.script; let body_gate = self.body_gate.clone(); + let captured_preflight_headers = self.captured_preflight_headers.clone(); let (sender, receiver) = mpsc::channel(4); tokio::spawn(async move { while let Some(event) = requests.recv().await { @@ -3543,6 +3545,10 @@ mod tests { }; let result = match event { http_response_event::Event::Preflight(preflight) => { + if let Some(captured) = &captured_preflight_headers { + *captured.lock().expect("preflight capture lock") = + preflight.headers.clone(); + } if preflight .config .as_ref() @@ -5590,6 +5596,7 @@ mod tests { script: ResponseRelayScript::HeadersOnly, request_only: true, body_gate: None, + captured_preflight_headers: None, })); let empty_runner = openshell_supervisor_middleware::ChainRunner::default(); for response in [many_headers, opaque_headers] { @@ -5624,7 +5631,7 @@ mod tests { async fn response_middleware_selected_hook_enforces_header_limits() { let mut response = b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n".to_vec(); for _ in 0..129 { - response.extend_from_slice(b"Set-Cookie: a=b\r\n"); + response.extend_from_slice(b"X-Ordinary: value\r\n"); } response.extend_from_slice(b"\r\n"); let (runner, chain) = response_middleware_fixture(ResponseRelayScript::HeadersOnly); @@ -5643,6 +5650,73 @@ mod tests { assert!(delivered.starts_with(b"HTTP/1.1 502 ")); } + #[tokio::test] + async fn response_middleware_omits_credentials_and_preserves_downstream_fields() { + let captured = Arc::new(std::sync::Mutex::new(Vec::new())); + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(ResponseRelayService { + script: ResponseRelayScript::HeadersOnly, + request_only: false, + body_gate: None, + captured_preflight_headers: Some(Arc::clone(&captured)), + })); + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "response".into(), + implementation: "test/response-relay".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]; + let response = b"HTTP/1.1 200 OK\r\n\ + Content-Length: 5\r\n\ + Cache-Control: public\r\n\ + Set-Cookie: session=secret; HttpOnly\r\n\ + WWW-Authenticate: Bearer realm=private\r\n\ + Authentication-Info: nextnonce=secret\r\n\ + Proxy-Authenticate: Basic realm=proxy\r\n\ + Proxy-Authentication-Info: nextnonce=proxy-secret\r\n\ + Proxy-Authorization: Basic proxy-secret\r\n\ + X-OpenShell-Credential-Token: injected-secret\r\n\r\nhello"; + let mut upstream = response.as_slice(); + let mut delivered = Vec::new(); + + let outcome = relay_response( + "GET", + &mut upstream, + &mut delivered, + RelayResponseOptions::default(), + Some(response_middleware_context(&runner, &chain, "GET")), + ) + .await + .expect("response relay"); + + assert!(matches!(outcome, RelayOutcome::Reusable)); + let observed = captured.lock().expect("preflight capture lock"); + assert_eq!( + observed + .iter() + .map(|header| header.name.as_str()) + .collect::>(), + vec!["content-length", "cache-control"] + ); + drop(observed); + + let delivered = String::from_utf8(delivered).expect("UTF-8 response"); + for credential_field in [ + "Set-Cookie: session=secret; HttpOnly\r\n", + "WWW-Authenticate: Bearer realm=private\r\n", + "Authentication-Info: nextnonce=secret\r\n", + "Proxy-Authenticate: Basic realm=proxy\r\n", + "Proxy-Authentication-Info: nextnonce=proxy-secret\r\n", + "Proxy-Authorization: Basic proxy-secret\r\n", + "X-OpenShell-Credential-Token: injected-secret\r\n", + ] { + assert!(delivered.contains(credential_field), "{delivered}"); + } + assert!(delivered.contains("cache-control: private\r\n")); + assert!(delivered.ends_with("\r\n\r\nhello")); + } + #[tokio::test] async fn response_middleware_flushes_partial_framed_payload_promptly() { for chunked in [false, true] { @@ -5726,6 +5800,7 @@ mod tests { script, request_only: false, body_gate: None, + captured_preflight_headers: None, })); let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "response".into(), @@ -5991,6 +6066,7 @@ mod tests { entered: Arc::clone(&entered), release: Arc::clone(&release), }), + captured_preflight_headers: None, })); let chain = vec![openshell_supervisor_middleware::ChainEntry { name: "response".into(), diff --git a/crates/openshell-supervisor-network/src/l7/rest/http_response.rs b/crates/openshell-supervisor-network/src/l7/rest/http_response.rs index 026a76b053..da9c18008b 100644 --- a/crates/openshell-supervisor-network/src/l7/rest/http_response.rs +++ b/crates/openshell-supervisor-network/src/l7/rest/http_response.rs @@ -306,6 +306,7 @@ where } }; let original_headers = parsed.headers.clone(); + let preserved_credential_headers = parsed.preserved_credential_headers; let upstream_declared_trailers = parsed.declared_trailers.clone(); let connection_nominated_headers = parsed.connection_nominated.clone(); let input = openshell_supervisor_middleware::HttpResponsePreflightInput { @@ -419,6 +420,7 @@ where client, &status_line, &preflight.headers, + &preserved_credential_headers, &upstream_declared_trailers, &buffered[header_end..], status_code, @@ -473,6 +475,7 @@ where let head = serialize_response_head( &status_line, &headers, + &preserved_credential_headers, ResponseFraming::Preserve(body_length), server_wants_close, &[], @@ -502,6 +505,7 @@ where let streaming_head = serialize_response_head( &status_line, &preflight.headers, + &preserved_credential_headers, if chunked_output { ResponseFraming::Chunked } else { @@ -699,6 +703,7 @@ where let head = serialize_response_head( &status_line, &headers, + &preserved_credential_headers, framing, server_wants_close, &trailer_names, @@ -745,6 +750,7 @@ async fn relay_headers_only_response( client: &mut C, status_line: &str, headers: &[HttpHeader], + preserved_credential_headers: &[String], declared_trailers: &[String], overflow: &[u8], status_code: u16, @@ -759,6 +765,7 @@ where let head = serialize_response_head( status_line, headers, + preserved_credential_headers, ResponseFraming::Preserve(body_length), server_wants_close, declared_trailers, @@ -1038,6 +1045,7 @@ fn emit_http_response_middleware_failure( struct ParsedResponseHead { representable: bool, headers: Vec, + preserved_credential_headers: Vec, connection_nominated: Vec, declared_trailers: Vec, } @@ -1081,6 +1089,7 @@ fn parse_response_head_for_middleware(header_bytes: &[u8]) -> Result Result Result Result<()> { } fn is_protected_response_field(name: &str) -> bool { - name.eq_ignore_ascii_case("content-length") || is_hidden_response_field(name) + name.eq_ignore_ascii_case("content-length") + || is_hidden_response_field(name) + || openshell_supervisor_middleware::headers::is_response_credential_header(name) } fn is_hidden_response_field(name: &str) -> bool { @@ -1183,6 +1206,7 @@ enum ResponseFraming { fn serialize_response_head( status_line: &str, headers: &[HttpHeader], + preserved_credential_headers: &[String], framing: ResponseFraming, connection_close: bool, trailer_names: &[String], @@ -1199,6 +1223,10 @@ fn serialize_response_head( output.push_str(&header.value); output.push_str("\r\n"); } + for header in preserved_credential_headers { + output.push_str(header); + output.push_str("\r\n"); + } match framing { ResponseFraming::Preserve(BodyLength::ContentLength(length)) | ResponseFraming::ContentLength(length) => { @@ -2042,6 +2070,7 @@ mod tests { let serialized = String::from_utf8(serialize_response_head( "HTTP/1.1 200 OK", &headers, + &[], framing, false, &[], From c2da21b0a6c5f366d0536fb5f2e1b5c95bab67ab Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 10 Sep 2026 16:59:09 -0700 Subject: [PATCH 12/13] fix(middleware): end invalid preflight streams Signed-off-by: Piotr Mlocek --- .../src/response.rs | 204 ++++++++++++++++++ .../src/response/preflight.rs | 98 +++++---- 2 files changed, 255 insertions(+), 47 deletions(-) diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index 02f4a0e61f..bf7b61a454 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -1175,6 +1175,23 @@ async fn end_stages(stages: &mut [HttpResponseStage], reason: MiddlewareSessionE } } +async fn handle_opened_preflight_failure( + entry: &DescribedChainEntry, + current_stage: &mut HttpResponseStage, + prior_stages: &mut [HttpResponseStage], + reason: &str, + invocations: &mut Vec, +) -> Option { + current_stage + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + let failure = collect_preflight_failure(entry, reason, invocations); + if failure.is_some() { + end_stages(prior_stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + } + failure +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -1213,6 +1230,10 @@ mod tests { script: Script, } + struct PreflightLifecycleService { + completion_tx: mpsc::UnboundedSender<(String, Vec)>, + } + #[derive(Clone)] struct RemoteResponseService { session_end_tx: Option>, @@ -1566,6 +1587,115 @@ mod tests { } } + #[tonic::async_trait] + impl InProcessMiddleware for PreflightLifecycleService { + async fn describe(&self) -> MiddlewareManifest { + response_manifest("test/preflight-lifecycle") + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> miette::Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + _request: HttpRequestView<'_>, + ) -> miette::Result { + unreachable!() + } + + async fn open_http_response_pre_return( + &self, + mut requests: mpsc::Receiver, + ) -> Result { + let (sender, receiver) = mpsc::channel(4); + let completion_tx = self.completion_tx.clone(); + tokio::spawn(async move { + let Some(HttpResponseEvent { + event: Some(http_response_event::Event::Preflight(preflight)), + }) = requests.recv().await + else { + return; + }; + let config_value = |name: &str| { + preflight + .config + .as_ref() + .and_then(|config| config.fields.get(name)) + .and_then(|value| value.kind.as_ref()) + .and_then(|kind| match kind { + prost_types::value::Kind::StringValue(value) => Some(value.clone()), + _ => None, + }) + .unwrap_or_default() + }; + let label = config_value("label"); + let behavior = config_value("behavior"); + let inspect = |body_mode, header_mutations| HttpResponsePreflightResult { + action: Some(http_response_preflight_result::Action::Inspect( + HttpResponsePreflightInspect { + body_mode, + header_mutations, + }, + )), + ..Default::default() + }; + let result = match behavior.as_str() { + "stream" => http_response_event_result::Result::PreflightResult(inspect( + HttpResponseBodyMode::StreamBytes as i32, + Vec::new(), + )), + "wrong-envelope" => http_response_event_result::Result::BodyResult( + HttpResponseBodyResult::default(), + ), + "invalid-diagnostics" => { + let mut result = + inspect(HttpResponseBodyMode::HeadersOnly as i32, Vec::new()); + result.reason = "x".repeat(MAX_MIDDLEWARE_REASON_BYTES + 1); + http_response_event_result::Result::PreflightResult(result) + } + "unsupported-body-mode" => http_response_event_result::Result::PreflightResult( + inspect(i32::MAX, Vec::new()), + ), + "invalid-header-mutation" => { + http_response_event_result::Result::PreflightResult(inspect( + HttpResponseBodyMode::HeadersOnly as i32, + vec![write_header("content-length", "1")], + )) + } + "no-action" => http_response_event_result::Result::PreflightResult( + HttpResponsePreflightResult::default(), + ), + behavior => panic!("unknown lifecycle test behavior: {behavior}"), + }; + if sender + .send(Ok(HttpResponseEventResult { + result: Some(result), + })) + .await + .is_err() + { + return; + } + + let mut terminal_reasons = Vec::new(); + while let Some(event) = requests.recv().await { + if let Some(http_response_event::Event::SessionEnd(end)) = event.event + && let Ok(reason) = MiddlewareSessionEndReason::try_from(end.reason) + { + terminal_reasons.push(reason); + } + } + let _ = completion_tx.send((label, terminal_reasons)); + }); + Ok(Box::pin(ReceiverStream::new(receiver))) + } + } + fn write_header(name: &str, value: &str) -> HeaderMutation { HeaderMutation { operation: Some(header_mutation::Operation::Write(WriteHeader { @@ -1619,6 +1749,25 @@ mod tests { } } + fn lifecycle_entry(name: &str, order: i32, behavior: &str, on_error: OnError) -> ChainEntry { + let string_value = |value: &str| prost_types::Value { + kind: Some(prost_types::value::Kind::StringValue(value.into())), + }; + ChainEntry { + name: name.into(), + implementation: "test/preflight-lifecycle".into(), + order, + config: prost_types::Struct { + fields: [ + ("label".into(), string_value(name)), + ("behavior".into(), string_value(behavior)), + ] + .into(), + }, + on_error, + } + } + fn input(status_code: u16) -> HttpResponsePreflightInput { HttpResponsePreflightInput { context: RequestContext { @@ -1691,6 +1840,61 @@ mod tests { })); } + #[tokio::test] + async fn invalid_opened_preflight_stages_receive_one_failure_terminal_event() { + for behavior in [ + "wrong-envelope", + "invalid-diagnostics", + "unsupported-body-mode", + "invalid-header-mutation", + "no-action", + ] { + for on_error in [OnError::FailOpen, OnError::FailClosed] { + let (completion_tx, mut completion_rx) = mpsc::unbounded_channel(); + let runner = + ChainRunner::new(Arc::new(PreflightLifecycleService { completion_tx })); + let entries = [ + lifecycle_entry("prior", 0, "stream", OnError::FailClosed), + lifecycle_entry("invalid", 1, behavior, on_error), + ]; + let mut outcome = runner + .preflight_http_response(&entries, input(200)) + .await + .expect("invalid preflight response"); + assert_eq!(outcome.allowed, on_error == OnError::FailOpen); + if let Some(session) = outcome.session.take() { + session.end(MiddlewareSessionEndReason::Normal).await; + } + + let mut completions = BTreeMap::new(); + for _ in 0..2 { + let (label, reasons) = + tokio::time::timeout(Duration::from_secs(1), completion_rx.recv()) + .await + .expect("bounded terminal event delivery") + .expect("opened stage completion"); + assert!(completions.insert(label, reasons).is_none()); + } + assert_eq!( + completions.get("invalid").map(Vec::as_slice), + Some([MiddlewareSessionEndReason::MiddlewareFailure].as_slice()), + "invalid behavior: {behavior}, policy: {on_error:?}" + ); + let prior_reason = if on_error == OnError::FailOpen { + MiddlewareSessionEndReason::Normal + } else { + MiddlewareSessionEndReason::MiddlewareFailure + }; + assert_eq!( + completions.get("prior").map(Vec::as_slice), + Some([prior_reason].as_slice()), + "invalid behavior: {behavior}, policy: {on_error:?}" + ); + assert!(completion_rx.try_recv().is_err()); + } + } + } + #[test] fn stream_mode_requires_only_one_byte_of_payload_capacity() { let mut described = DescribedChainEntry { diff --git a/crates/openshell-supervisor-middleware/src/response/preflight.rs b/crates/openshell-supervisor-middleware/src/response/preflight.rs index 9ae6341054..e2e53e9bbe 100644 --- a/crates/openshell-supervisor-middleware/src/response/preflight.rs +++ b/crates/openshell-supervisor-middleware/src/response/preflight.rs @@ -153,15 +153,25 @@ impl ChainRunner { continue; } }; + let mut current_stage = HttpResponseStage { + entry: entry.clone(), + transport: Some(HttpResponseStageTransport { sender, responses }), + mode: StageMode::HeadersOnly, + next_sequence: 1, + whole_body: Vec::new(), + }; let Some(http_response_event_result::Result::PreflightResult(decision)) = response.result else { - if let Some(reason) = collect_preflight_failure( + if let Some(reason) = handle_opened_preflight_failure( &entry, + &mut current_stage, + &mut stages, "unexpected_response_result", &mut invocations, - ) { - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + ) + .await + { return Ok(failed_preflight_outcome( headers, reason, @@ -178,8 +188,15 @@ impl ChainRunner { &decision.findings, &decision.metadata, ) { - if let Some(reason) = collect_preflight_failure(&entry, reason, &mut invocations) { - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + if let Some(reason) = handle_opened_preflight_failure( + &entry, + &mut current_stage, + &mut stages, + reason, + &mut invocations, + ) + .await + { return Ok(failed_preflight_outcome( headers, reason, @@ -215,14 +232,9 @@ impl ChainRunner { reason_code, failure_category: None, }); - let mut skipped = HttpResponseStage { - entry, - transport: Some(HttpResponseStageTransport { sender, responses }), - mode: StageMode::HeadersOnly, - next_sequence: 1, - whole_body: Vec::new(), - }; - skipped.end(MiddlewareSessionEndReason::StageSkipped).await; + current_stage + .end(MiddlewareSessionEndReason::StageSkipped) + .await; } Some(http_response_preflight_result::Action::Inspect(inspect)) => { let permitted_modes = @@ -230,14 +242,15 @@ impl ChainRunner { let mode = match validate_inspect(&entry, &inspect, &permitted_modes) { Ok(mode) => mode, Err(reason) => { - if let Some(reason) = - collect_preflight_failure(&entry, &reason, &mut invocations) + if let Some(reason) = handle_opened_preflight_failure( + &entry, + &mut current_stage, + &mut stages, + &reason, + &mut invocations, + ) + .await { - end_stages( - &mut stages, - MiddlewareSessionEndReason::MiddlewareFailure, - ) - .await; return Ok(failed_preflight_outcome( headers, reason, @@ -260,14 +273,15 @@ impl ChainRunner { let reason = service .diagnostic_policy .header_mutation_error_reason(&error); - if let Some(reason) = - collect_preflight_failure(&entry, &reason, &mut invocations) + if let Some(reason) = handle_opened_preflight_failure( + &entry, + &mut current_stage, + &mut stages, + &reason, + &mut invocations, + ) + .await { - end_stages( - &mut stages, - MiddlewareSessionEndReason::MiddlewareFailure, - ) - .await; return Ok(failed_preflight_outcome( headers, reason, @@ -306,17 +320,11 @@ impl ChainRunner { reason_code, failure_category: None, }); - let mut stage = HttpResponseStage { - entry, - transport: Some(HttpResponseStageTransport { sender, responses }), - mode, - next_sequence: 1, - whole_body: Vec::new(), - }; + current_stage.mode = mode; if mode == StageMode::HeadersOnly { - stage.end(MiddlewareSessionEndReason::Normal).await; + current_stage.end(MiddlewareSessionEndReason::Normal).await; } else { - stages.push(stage); + stages.push(current_stage); } } Some(http_response_preflight_result::Action::BlockDelivery(_)) => { @@ -339,13 +347,7 @@ impl ChainRunner { reason_code: reason_code.clone(), failure_category: None, }); - stages.push(HttpResponseStage { - entry: entry.clone(), - transport: Some(HttpResponseStageTransport { sender, responses }), - mode: StageMode::HeadersOnly, - next_sequence: 1, - whole_body: Vec::new(), - }); + stages.push(current_stage); end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareDenial).await; return Ok(blocked_preflight_outcome( headers, @@ -359,13 +361,15 @@ impl ChainRunner { )); } None => { - if let Some(reason) = collect_preflight_failure( + if let Some(reason) = handle_opened_preflight_failure( &entry, + &mut current_stage, + &mut stages, "invalid_preflight_decision", &mut invocations, - ) { - end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) - .await; + ) + .await + { return Ok(failed_preflight_outcome( headers, reason, From 2d79bde84ec999d20330ffda2fd3220fd6d934c9 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Thu, 10 Sep 2026 19:35:09 -0700 Subject: [PATCH 13/13] docs(middleware): remove hard-wrapped prose Signed-off-by: Piotr Mlocek --- docs/extensibility/supervisor-middleware.mdx | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index eade980dc0..13935436c4 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -22,13 +22,7 @@ For each inspected HTTP request, the supervisor: 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. -Response middleware advertising `HTTP_RESPONSE/PRE_RETURN` inspects the final -upstream response before OpenShell returns it to the workload. Its preflight -includes upstream `Content-Length`, `Content-Encoding`, and `Content-Range` -fields as read-only metadata, except when `Connection` nominates the field as -hop-by-hop. Middleware cannot write or remove these fields. OpenShell computes -downstream framing separately and emits the final `Content-Length` or -`Transfer-Encoding` exactly once after middleware processing. +Response middleware advertising `HTTP_RESPONSE/PRE_RETURN` inspects the final upstream response before OpenShell returns it to the workload. Its preflight includes upstream `Content-Length`, `Content-Encoding`, and `Content-Range` fields as read-only metadata, except when `Connection` nominates the field as hop-by-hop. Middleware cannot write or remove these fields. OpenShell computes downstream framing separately and emits the final `Content-Length` or `Transfer-Encoding` exactly once after middleware processing. For an RFC 6455 upgrade over `ws://` or `wss://`, the supervisor first finds every host-matched attachment, then selects only implementations that advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. It opens one ordered, phase-specific `EvaluateWebSocketSession` stream per selected stage. OpenShell sends `WebSocketSessionEvent` values, while the service returns `WebSocketSessionEventResult` values only for preflight and message events; session start and end are notifications. Future upstream-to-client inspection uses the same RPC with `PRE_RETURN`; an implementation that advertises both phases receives two independent streams for the WebSocket session. An attachment without the selected binding can still inspect the HTTP upgrade request when it advertises the HTTP binding, but it is not a failed WebSocket stage. OpenShell allows post-upgrade traffic and emits an informational `binding_not_selected` coverage event for that attachment. @@ -232,11 +226,7 @@ See [Logging](/observability/logging) for log access and [OCSF JSON Export](/obs ## Runnable example -The [content guard example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-content-guard) -matches configured literal terms in UTF-8 request bodies, complete response -bodies, and client WebSocket text messages. It supports redaction and denial. -Responses require whole-body inspection; unavailable inspection or invalid -UTF-8 invokes the configured `on_error` policy. It is not a general PII detector. +The [content guard example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-content-guard) matches configured literal terms in UTF-8 request bodies, complete response bodies, and client WebSocket text messages. It supports redaction and denial. Responses require whole-body inspection; unavailable inspection or invalid UTF-8 invokes the configured `on_error` policy. It is not a general PII detector. The example includes a policy, local fixture, and smoke launcher.