From b2ee8f495fcdca6466da232d3bd442248bd47800 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 18 Sep 2026 01:04:59 -0700 Subject: [PATCH 1/2] feat(middleware)!: add streaming request hooks Replace the unary HTTP request evaluator with a bidirectional streaming protocol and move SigV4 signing into the built-in middleware stage. BREAKING CHANGE: replace the unary HTTP request middleware RPC with the streaming HttpRequestPreCredentials Evaluate contract. Signed-off-by: Piotr Mlocek --- Cargo.lock | 6 +- architecture/sandbox-limits.md | 44 +- architecture/sandbox.md | 42 +- crates/openshell-core/src/middleware.rs | 149 +- .../Cargo.toml | 3 + .../src/lib.rs | 193 +- .../src/regex.rs | 38 +- .../src/sigv4.rs | 216 +- .../src/headers.rs | 15 +- .../src/lib.rs | 2443 +++++++++------- .../src/remote.rs | 48 +- .../src/request.rs | 1917 +++++++++++++ .../src/response.rs | 44 +- .../openshell-supervisor-network/Cargo.toml | 5 +- .../src/l7/middleware.rs | 806 +++++- .../src/l7/mod.rs | 1 + .../src/l7/post_credentials.rs | 371 +++ .../src/l7/relay.rs | 987 ++++++- .../src/l7/rest.rs | 1595 +++++++--- .../src/l7/websocket.rs | 8 - .../openshell-supervisor-network/src/lib.rs | 1 - .../openshell-supervisor-network/src/proxy.rs | 164 +- .../tests/sigv4_localstack.rs | 4 +- docs/extensibility/supervisor-middleware.mdx | 60 +- docs/providers/aws-sigv4.mdx | 4 +- docs/reference/gateway-config.mdx | 4 +- .../src/main.rs | 327 ++- .../Cargo.lock | 2555 +++++++++++++++++ .../Cargo.toml | 28 + .../README.md | 100 + .../policy.yaml | 48 + .../src/main.rs | 1022 +++++++ .../src/signer.rs | 1180 ++++++++ proto/supervisor_middleware.proto | 272 +- rfc/0009-supervisor-middleware/README.md | 174 +- .../appendices/extension-authentication.md | 2 +- .../appendices/protocol-extensions.md | 52 +- .../references/supervisor-middleware.md | 7 +- skills/generate-sandbox-policy/SKILL.md | 4 + skills/openshell-cli/SKILL.md | 2 + tasks/rust.toml | 3 + tasks/test.toml | 1 + 42 files changed, 12770 insertions(+), 2175 deletions(-) rename crates/{openshell-supervisor-network => openshell-supervisor-middleware-builtins}/src/sigv4.rs (74%) create mode 100644 crates/openshell-supervisor-middleware/src/request.rs create mode 100644 crates/openshell-supervisor-network/src/l7/post_credentials.rs create mode 100644 examples/supervisor-middleware-git-signing/Cargo.lock create mode 100644 examples/supervisor-middleware-git-signing/Cargo.toml create mode 100644 examples/supervisor-middleware-git-signing/README.md create mode 100644 examples/supervisor-middleware-git-signing/policy.yaml create mode 100644 examples/supervisor-middleware-git-signing/src/main.rs create mode 100644 examples/supervisor-middleware-git-signing/src/signer.rs diff --git a/Cargo.lock b/Cargo.lock index 109e6df8d5..861c142253 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4764,6 +4764,9 @@ name = "openshell-supervisor-middleware-builtins" version = "0.0.0" dependencies = [ "async-trait", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-runtime-api", "miette", "openshell-core", "prost-types", @@ -4781,9 +4784,6 @@ version = "0.0.0" dependencies = [ "apollo-parser", "async-trait", - "aws-credential-types", - "aws-sigv4", - "aws-smithy-runtime-api", "base64", "bytes", "flate2", diff --git a/architecture/sandbox-limits.md b/architecture/sandbox-limits.md index 9045ddc359..2cafb75669 100644 --- a/architecture/sandbox-limits.md +++ b/architecture/sandbox-limits.md @@ -60,12 +60,14 @@ budgets as new activity. |---|---:|---| | Concurrent buffered work | 32 | Shared by HTTP requests, WebSocket messages, and WebSocket preflight. One permit covers one complete unit of work. | | Admission waiters | 64 | Additional work is shed when both the active budget and waiter budget are full. HTTP receives a complete 503 response before its body is buffered. | -| Persistent middleware sessions | 32 | Shared process-wide session budget for streaming middleware protocols. WebSocket preflight uses immediate admission before opening streams and retains one permit while any stage remains active. | -| HTTP body or WebSocket text message | 4 MiB | Platform maximum for input and replacement payloads. Service, operator, and stage limits may narrow it. | +| Persistent middleware sessions | 32 | Shared process-wide session budget for HTTP request and WebSocket streams. Admission is retained while any stage remains active. | +| Middleware payload or unit | 4 MiB | Platform maximum for a complete whole-body payload, WebSocket text message, or advertised stream unit. Request stream units are further capped at 64 KiB. | +| Owned request representation | 1 GiB input and 1 GiB output | Logical deferred-storage limit for fail-closed owned streams. Implementations spool rather than retain this representation in memory. | | Middleware configs and stages | 10 | At most 10 configs in policy and 10 selected stages in one chain. | | Selector patterns | 32 | Combined include and exclude patterns per middleware config. | | Per-stage RPC | 500 ms default, 10 ms–30 s | An operator timeout caps a binding timeout. | -| Complete message chain | 30 s | Starts after work admission; admission backpressure does not consume the chain budget. | +| Middleware unit or message chain | 30 s | Bounds one HTTP body-unit pass, one HTTP finalization pass, or one WebSocket message across all selected stages. It is not an accepted-stream lifetime. | +| HTTP request body processing | 2 min total | Bounds request-body receipt, middleware processing, and output delivery. Pure lockstep chains may forward approved units during this interval; withholding modes spool first. Timeout cancels the session and terminates the request. | | WebSocket preflight | 1 s maximum | Caps handshake delay independently of the message RPC timeout. | | Remote service connect | 5 s | Applies while establishing a middleware gRPC channel. | @@ -75,19 +77,19 @@ request headers totaling 64 KiB, 64 header mutations, 32 findings per stage, and 64 metadata entries. The detailed external contract lives in [Supervisor Middleware](../docs/extensibility/supervisor-middleware.mdx). -The work semaphore bounds aggregate buffered middleware input to approximately -`32 × 4 MiB`, plus bounded envelope and parser overhead. It is a concurrency -safety valve, not rate limiting or a promise that 32 simultaneous maximum-size -messages are inexpensive. +The work semaphore bounds concurrent middleware progress and caps aggregate +in-memory whole-body or WebSocket input at approximately `32 × 4 MiB`, plus +bounded envelope and parser overhead. Streaming HTTP bodies use bounded units +and storage-backed output instead. This is a concurrency safety valve, not rate +limiting or a promise that maximum-sized work is inexpensive. The persistent session semaphore is independent from the work semaphore. One -WebSocket middleware session consumes one permit regardless of its active-stage -fan-out, which is separately capped at 10 stages. All-skip preflight releases -the permit immediately. A retained session releases it at connection end or as -soon as its last active stage is disabled. Session admission does not wait: -capacity exhaustion follows each selected config's `on_error` behavior before -any stream opens. The protocol-neutral registry ownership allows future -streaming HTTP middleware to use the same process-wide budget. +HTTP request or WebSocket middleware session consumes one permit regardless of +its active-stage fan-out, which is separately capped at 10 stages. All-skip +preflight releases the permit immediately. A retained session releases it at +request or connection end, or as soon as its last active stage is disabled. +WebSocket session admission does not wait; capacity exhaustion follows each +selected config's `on_error` behavior before any stream opens. ## Egress Framing and Inspection @@ -114,12 +116,14 @@ buffer only when it owns an explicit bound. Every parsed WebSocket text message acquires network-owned assembly capacity before payload allocation or reading, including relays used only for native policy, credential rewriting, compression, or a disabled fail-open middleware session. The process-lifetime budget survives policy reloads, and the assembly retains its permit through decompression, policy and middleware evaluation, credential rewriting, and upstream forwarding. Active middleware sessions additionally acquire shared middleware work before buffering. Input progress resets only the idle deadline. Forwarding uses one total deadline across the complete frame header, payload, and flush. Every timeout and terminal parser error releases both permits through ordinary ownership. Queue exhaustion emits a payload-free network denial event. -The operator middleware `max_payload_bytes` ceiling applies to payloads exposed -through HTTP-body and WebSocket text-message bindings. It does not replace the -raw binary frame safety bound because binary messages are never delivered to V1 -middleware. A passed binary logical message still advances the active -middleware session sequence and emits coverage telemetry, so a later text RPC -can contain a valid sequence gap. +The operator middleware `max_payload_bytes` ceiling applies to complete +whole-body payloads, stream units, and WebSocket text messages. The HTTP request +runtime further caps units at 64 KiB and advertises the separate owned-stream +deferred limit during preflight. Neither limit replaces the raw binary frame +safety bound because binary messages are never delivered to V1 middleware. A +passed binary logical message still advances the active middleware session +sequence and emits coverage telemetry, so a later text RPC can contain a valid +sequence gap. ## Network and Upstream Proxying diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 74bbfb4aea..85e702079b 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -283,12 +283,27 @@ 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. -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 -crosses that transport boundary. Both paths support bounded bidirectional -WebSocket sessions, so a manifest advertises capabilities independently of -transport. +Built-ins and operator services use the same event-oriented request contract. +Each selected `HTTP_REQUEST/PRE_CREDENTIALS` stage opens a bidirectional stream, +receives preflight, and selects header-only, whole-body, lockstep stream, or +owned-stream processing. The HTTP/1 relay normalizes fixed and chunked bodies +into bounded units. A chain made only of lockstep stream stages can forward each +approved unit upstream immediately with bounded channel and socket backpressure. +The relay switches the upstream request to chunked framing when transformations +can change unit sizes and concurrently watches for an early upstream response. +It cancels the middleware session and never replays the request if the upstream +responds before upload completes. Chains containing whole-body or owned stages, +body-aware policy re-evaluation, request-body credential rewriting, or signing +that needs the complete payload retain a hold barrier and spool output first. +Whole-body stages receive one data-bearing final unit; streaming and owned +stages receive nonempty data units followed by one empty terminal unit. Body +receipt, middleware processing, and output delivery share a two-minute +wall-clock deadline. +Owned streams transfer replay responsibility to a fail-closed stage, allowing +whole-request transformations larger than the per-message protobuf limit while +keeping input, output, and backpressure bounded. Body-aware GraphQL, JSON-RPC, +and MCP paths retain a hold barrier so policy can re-evaluate every accepted +replacement before later stages or upstream delivery. When a stage ends, the remote adapter sends its terminal event, half-closes the request stream, and briefly drains the response stream before releasing the transport. This keeps a queued terminal event from being canceled with the @@ -305,6 +320,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`. +The restricted `HTTP_REQUEST/POST_CREDENTIALS` phase is available only to +trusted in-process built-ins. External manifests advertising that phase are +rejected because it can observe resolved credentials. `openshell/sigv4` owns +AWS request signing at this phase while existing endpoint policy fields select +its signing mode and target. + 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 @@ -530,10 +551,11 @@ subject, and gateway SPIFFE subject, and their cache lifetime is capped by the intermediate token response, stored subject-token expiry, and supervisor SVID expiry. -For AWS endpoints that require request-level signing, the proxy supports SigV4 -re-signing. When `credential_signing: sigv4` is set on an L7 endpoint, the proxy -strips the client's placeholder-based AWS auth headers, re-signs with real -credentials from the provider, and forwards the request upstream. The signing +For AWS endpoints that require request-level signing, the restricted in-process +`openshell/sigv4` middleware performs SigV4 re-signing after provider credential +resolution. When `credential_signing: sigv4` is set on an L7 endpoint, it strips +the client's placeholder-based AWS auth headers, re-signs with real credentials +from the provider, and forwards the request upstream. The signing endpoint must have a credential source before the policy generation activates: an attached endpoint-bearing AWS profile whose boundary covers the endpoint, or an attached endpointless AWS profile explicitly named by the endpoint's diff --git a/crates/openshell-core/src/middleware.rs b/crates/openshell-core/src/middleware.rs index a4023bfb1c..e0cd2f2bce 100644 --- a/crates/openshell-core/src/middleware.rs +++ b/crates/openshell-core/src/middleware.rs @@ -11,12 +11,16 @@ use tokio::sync::mpsc; use tonic::{Request, Response, Status}; use crate::proto::{ - HttpHeader, HttpRequestEvaluation, HttpRequestResult, HttpRequestTarget, HttpResponseEvent, - HttpResponseEventResult, MiddlewareManifest, RequestContext, SupervisorMiddlewarePhase, - ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, + HttpRequestEvent, HttpRequestEventResult, HttpResponseEvent, HttpResponseEventResult, + MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, WebSocketSessionEventResult, }; +/// Transport-neutral result stream for one HTTP request middleware stage. +pub type HttpRequestResultStream = Pin< + Box> + Send + 'static>, +>; + /// Transport-neutral result stream for one HTTP response middleware stage. pub type HttpResponseResultStream = Pin< Box> + Send + 'static>, @@ -44,15 +48,23 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync { request: Request, ) -> Result, Status>; - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: Request, - ) -> Result, Status>; + _requests: mpsc::Receiver, + ) -> Result { + Err(Status::unimplemented( + "middleware does not implement HTTP request pre-credentials evaluation", + )) + } async fn open_websocket_session( &self, - requests: mpsc::Receiver, - ) -> Result; + _requests: mpsc::Receiver, + ) -> Result { + Err(Status::unimplemented( + "middleware does not implement WebSocket sessions", + )) + } async fn open_http_response_pre_return( &self, @@ -64,95 +76,11 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync { } } -/// Borrowed request state exposed to one in-process middleware invocation. -/// -/// The view reflects every transformation applied by earlier stages. It is valid -/// only for the current invocation and cannot be retained by the middleware. -#[derive(Clone, Copy)] -pub struct HttpRequestView<'a> { - phase: SupervisorMiddlewarePhase, - context: &'a RequestContext, - config: &'a prost_types::Struct, - target: &'a HttpRequestTarget, - headers: &'a [HttpHeader], - body: &'a [u8], - middleware_name: &'a str, -} - -impl<'a> HttpRequestView<'a> { - /// Create a view over the chain's current request state for one stage. - #[must_use] - pub fn new( - phase: SupervisorMiddlewarePhase, - context: &'a RequestContext, - config: &'a prost_types::Struct, - target: &'a HttpRequestTarget, - headers: &'a [HttpHeader], - body: &'a [u8], - middleware_name: &'a str, - ) -> Self { - Self { - phase, - context, - config, - target, - headers, - body, - middleware_name, - } - } - - /// Return the typed middleware phase selected for this invocation. - #[must_use] - pub fn phase(self) -> SupervisorMiddlewarePhase { - self.phase - } - - /// Return the request and sandbox identity shared by every chain stage. - #[must_use] - pub fn context(self) -> &'a RequestContext { - self.context - } - - /// Return the validated configuration for this policy-selected stage. - #[must_use] - pub fn config(self) -> &'a prost_types::Struct { - self.config - } - - /// Return the admitted destination and HTTP request target. - #[must_use] - pub fn target(self) -> &'a HttpRequestTarget { - self.target - } - - /// Return visible request headers in wire order, including repeated names. - #[must_use] - pub fn headers(self) -> &'a [HttpHeader] { - self.headers - } - - /// Return the current body, including replacements made by earlier stages. - #[must_use] - pub fn body(self) -> &'a [u8] { - self.body - } - - /// Return the in-process middleware manifest or attachment name selected by - /// policy, including custom implementation names. - #[must_use] - pub fn middleware_name(self) -> &'a str { - self.middleware_name - } -} - /// Asynchronous contract for supervisor middleware that runs in-process. /// /// Remote services use the protobuf `SupervisorMiddleware` contract instead. -/// The borrowed view remains valid for the evaluation future, so implementations -/// can yield without constructing an owned protobuf request envelope. -/// WebSocket sessions already use bounded channel and stream ownership, so that -/// operation is shared with the transport-neutral endpoint contract. +/// HTTP and WebSocket operations use bounded channels and streams shared with +/// the transport-neutral endpoint contract. /// /// Downstream implementations must apply `#[async_trait::async_trait]` to each /// `impl InProcessMiddleware` block. The macro's default expansion creates @@ -171,10 +99,10 @@ impl<'a> HttpRequestView<'a> { /// use std::sync::Arc; /// /// use miette::Result; -/// use openshell_core::middleware::{HttpRequestView, InProcessMiddleware}; +/// use openshell_core::middleware::InProcessMiddleware; /// use openshell_core::proto::{ -/// Decision, HttpRequestResult, MiddlewareBinding, MiddlewareManifest, -/// SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, +/// MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, +/// SupervisorMiddlewarePhase, /// }; /// use prost_types::Struct; /// @@ -203,16 +131,6 @@ impl<'a> HttpRequestView<'a> { /// ) -> Result<()> { /// Ok(()) /// } -/// -/// async fn evaluate_http_request( -/// &self, -/// _request: HttpRequestView<'_>, -/// ) -> Result { -/// Ok(HttpRequestResult { -/// decision: Decision::Allow as i32, -/// ..Default::default() -/// }) -/// } /// } /// /// let service: Arc = Arc::new(Service); @@ -235,16 +153,15 @@ pub trait InProcessMiddleware: Send + Sync { config: &prost_types::Struct, ) -> Result<()>; - /// Evaluate one request using borrowed chain state. - /// - /// # Errors - /// - /// Returns an error when the selected implementation cannot evaluate the - /// request or its validated configuration. - async fn evaluate_http_request( + /// Open one HTTP request pre-credentials stream. + async fn open_http_request_pre_credentials( &self, - request: HttpRequestView<'_>, - ) -> Result; + _requests: mpsc::Receiver, + ) -> std::result::Result { + Err(Status::unimplemented( + "middleware does not implement HTTP request pre-credentials evaluation", + )) + } /// Open one persistent WebSocket middleware session. /// diff --git a/crates/openshell-supervisor-middleware-builtins/Cargo.toml b/crates/openshell-supervisor-middleware-builtins/Cargo.toml index 8e38a05ad2..9142fe13df 100644 --- a/crates/openshell-supervisor-middleware-builtins/Cargo.toml +++ b/crates/openshell-supervisor-middleware-builtins/Cargo.toml @@ -14,6 +14,9 @@ rust-version.workspace = true openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" +aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } +aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } +aws-smithy-runtime-api = { version = "1", features = ["client"] } miette = { workspace = true } prost-types = { workspace = true } regex = { workspace = true } diff --git a/crates/openshell-supervisor-middleware-builtins/src/lib.rs b/crates/openshell-supervisor-middleware-builtins/src/lib.rs index f87afafc3c..9ee70a9db6 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/lib.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/lib.rs @@ -4,20 +4,29 @@ //! First-party in-process supervisor middleware implementations. mod regex; +pub mod sigv4; use std::sync::Arc; use miette::{Result, miette}; -use openshell_core::middleware::{HttpRequestView, InProcessMiddleware, WebSocketResponseStream}; +use openshell_core::middleware::{ + HttpRequestResultStream, InProcessMiddleware, WebSocketResponseStream, +}; use openshell_core::proto::{ - HttpRequestResult, MiddlewareManifest, SupervisorMiddlewarePhase, WebSocketPreflightAction, + HttpRequestBodyMode, HttpRequestBodyPassThrough, HttpRequestBodyResult, + HttpRequestBodyTransform, HttpRequestEvent, HttpRequestEventResult, + HttpRequestPreflightInspect, HttpRequestPreflightResult, HttpRequestTrailersResult, + MiddlewareManifest, SupervisorMiddlewarePhase, WebSocketPreflightAction, WebSocketPreflightDecision, WebSocketSessionEvent, WebSocketSessionEventResult, + http_request_body_result, http_request_body_transform, http_request_body_unit, + http_request_event, http_request_event_result, http_request_preflight_result, web_socket_message, web_socket_session_event, web_socket_session_event_result, }; use tokio_stream::{Stream, StreamExt}; use tonic::Status; pub use regex::{NAME as BUILTIN_REGEX, RegexConfig, RegexMode}; +pub use sigv4::NAME as BUILTIN_SIGV4; /// Return the first-party services that the gateway and supervisor install. pub fn services() -> Vec> { @@ -35,20 +44,144 @@ pub fn validate_config(implementation: &str, config: &prost_types::Struct) -> Re } } -fn evaluate_http_request(request: HttpRequestView<'_>) -> Result { - match request.middleware_name() { - BUILTIN_REGEX => regex::evaluate_http_request(request.config(), request.body()), - other => Err(miette!( - "middleware implementation '{other}' is not a registered OpenShell built-in" - )), - } -} - -/// Aggregate service exposing first-party middleware through the borrowed in-process contract. +/// Aggregate service exposing first-party middleware through the in-process contract. #[derive(Debug, Default)] pub struct BuiltinMiddlewareService; impl BuiltinMiddlewareService { + fn request_stream( + mut requests: tokio::sync::mpsc::Receiver, + ) -> HttpRequestResultStream { + let (responses_tx, responses_rx) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + let mut config = None; + let mut inspected_body = false; + while let Some(request) = requests.recv().await { + let Some(event) = request.event else { + let _ = responses_tx + .send(Err(Status::invalid_argument("empty request event"))) + .await; + break; + }; + let result = match event { + http_request_event::Event::Preflight(preflight) if config.is_none() => { + if preflight.middleware_name != BUILTIN_REGEX { + Err(Status::invalid_argument(format!( + "middleware implementation '{}' is not a registered OpenShell built-in", + preflight.middleware_name + ))) + } else if !preflight + .permitted_body_modes + .contains(&(HttpRequestBodyMode::WholeBodyBytes as i32)) + { + Err(Status::failed_precondition( + "openshell/regex requires whole-body request inspection", + )) + } else { + let selected = preflight.config.unwrap_or_default(); + match regex::validate_config(&selected) { + Ok(()) => { + config = Some(selected); + Ok(HttpRequestEventResult { + result: Some( + http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some( + http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::WholeBodyBytes as i32, + header_mutations: Vec::new(), + }, + ), + ), + ..Default::default() + }, + ), + ), + }) + } + Err(error) => Err(Status::invalid_argument(error.to_string())), + } + } + } + http_request_event::Event::Body(body) + if config.is_some() && !inspected_body && body.end_of_stream => + { + let Some(http_request_body_unit::Payload::Data(data)) = body.payload else { + let _ = responses_tx + .send(Err(Status::invalid_argument( + "missing request body payload", + ))) + .await; + break; + }; + inspected_body = true; + match regex::evaluate_http_body( + config.as_ref().expect("validated request config"), + &data, + ) { + Ok(evaluation) => { + let action = evaluation.replacement.map_or_else( + || { + http_request_body_result::Action::PassThrough( + HttpRequestBodyPassThrough {}, + ) + }, + |replacement| { + http_request_body_result::Action::Transform( + HttpRequestBodyTransform { + replacement: Some( + http_request_body_transform::Replacement::Data( + replacement, + ), + ), + }, + ) + }, + ); + Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyResult( + HttpRequestBodyResult { + sequence: body.sequence, + action: Some(action), + findings: evaluation.findings, + metadata: evaluation.metadata, + ..Default::default() + }, + )), + }) + } + Err(error) => Err(Status::invalid_argument(error.to_string())), + } + } + http_request_event::Event::Trailers(_) if inspected_body => { + Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::TrailersResult( + HttpRequestTrailersResult::default(), + )), + }) + } + http_request_event::Event::SessionEnd(_) if config.is_some() => break, + _ => Err(Status::failed_precondition( + "invalid built-in HTTP request lifecycle", + )), + }; + match result { + Ok(result) => { + if responses_tx.send(Ok(result)).await.is_err() { + break; + } + } + Err(error) => { + let _ = responses_tx.send(Err(error)).await; + break; + } + } + } + }); + Box::pin(tokio_stream::wrappers::ReceiverStream::new(responses_rx)) + } + fn websocket_stream(mut requests: S) -> WebSocketResponseStream where S: Stream> @@ -204,11 +337,11 @@ impl InProcessMiddleware for BuiltinMiddlewareService { validate_config(middleware_name, config) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: HttpRequestView<'_>, - ) -> Result { - evaluate_http_request(request) + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + Ok(Self::request_stream(requests)) } async fn open_websocket_session( @@ -225,8 +358,7 @@ impl InProcessMiddleware for BuiltinMiddlewareService { mod tests { use super::*; use openshell_core::proto::{ - Decision, HttpRequestTarget, RequestContext, SupervisorMiddlewareOperation, - SupervisorMiddlewarePhase, WebSocketPreflight, + Decision, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, WebSocketPreflight, }; fn string_config(key: &str, value: &str) -> prost_types::Struct { @@ -241,18 +373,11 @@ mod tests { } } - fn evaluate_body(body: &[u8], config: &prost_types::Struct) -> Result { - let context = RequestContext::default(); - let target = HttpRequestTarget::default(); - evaluate_http_request(HttpRequestView::new( - SupervisorMiddlewarePhase::PreCredentials, - &context, - config, - &target, - &[], - body, - BUILTIN_REGEX, - )) + fn evaluate_body( + body: &[u8], + config: &prost_types::Struct, + ) -> Result { + regex::evaluate_http_body(config, body) } #[tokio::test] @@ -329,9 +454,7 @@ mod tests { ) .expect("evaluate regex binding"); - assert_eq!(result.decision, Decision::Allow as i32); - assert!(result.has_body); - let body = String::from_utf8(result.body).unwrap(); + let body = String::from_utf8(result.replacement.expect("replacement")).unwrap(); assert!(body.contains("top-secret")); assert!(!body.contains("sk-ABCDEFGHIJKLMNOP")); assert!( @@ -389,9 +512,7 @@ mod tests { let result = evaluate_body(body.as_bytes(), &prost_types::Struct::default()) .expect("evaluate regex binding"); - assert_eq!(result.decision, Decision::Allow as i32); - assert!(!result.has_body); - assert!(result.body.is_empty()); + assert!(result.replacement.is_none()); assert!(result.findings.is_empty()); } diff --git a/crates/openshell-supervisor-middleware-builtins/src/regex.rs b/crates/openshell-supervisor-middleware-builtins/src/regex.rs index 3b16524567..99176c7123 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/regex.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/regex.rs @@ -14,8 +14,8 @@ use std::sync::LazyLock; use miette::{Result, miette}; use openshell_core::proto::{ - Decision, Finding, HttpRequestResult, MiddlewareBinding, SupervisorMiddlewareOperation, - SupervisorMiddlewarePhase, WebSocketMessageResult, web_socket_message_result, + Decision, Finding, MiddlewareBinding, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, + WebSocketMessageResult, web_socket_message_result, }; use regex::Regex; use serde::Deserialize; @@ -91,31 +91,29 @@ pub fn validate_config(config: &prost_types::Struct) -> Result<()> { RegexConfig::from_struct(config).map(|_| ()) } -/// Evaluate a borrowed HTTP body and return a replacement only when a pattern matches. -pub fn evaluate_http_request( - config: &prost_types::Struct, - body: &[u8], -) -> Result { +#[derive(Debug)] +pub struct HttpBodyEvaluation { + pub replacement: Option>, + pub findings: Vec, + pub metadata: HashMap, +} + +/// Evaluate a complete HTTP body and return a replacement only when a pattern matches. +pub fn evaluate_http_body(config: &prost_types::Struct, body: &[u8]) -> Result { validate_config(config)?; let text = std::str::from_utf8(body).map_err(|_| miette!("{NAME} requires UTF-8 request bodies"))?; let (body, matches) = apply_replacements(text); let (findings, metadata) = findings_and_metadata(&matches); - let has_body = !matches.is_empty(); - let result = HttpRequestResult { - decision: Decision::Allow as i32, - reason: String::new(), - body: match body { - Cow::Borrowed(_) => Vec::new(), - Cow::Owned(body) => body.into_bytes(), - }, - has_body, - header_mutations: Vec::new(), + let replacement = match body { + Cow::Borrowed(_) => None, + Cow::Owned(body) => Some(body.into_bytes()), + }; + Ok(HttpBodyEvaluation { + replacement, findings, metadata, - reason_code: String::new(), - }; - Ok(result) + }) } pub fn evaluate_websocket_text( diff --git a/crates/openshell-supervisor-network/src/sigv4.rs b/crates/openshell-supervisor-middleware-builtins/src/sigv4.rs similarity index 74% rename from crates/openshell-supervisor-network/src/sigv4.rs rename to crates/openshell-supervisor-middleware-builtins/src/sigv4.rs index 79e4c32e18..eccf320a04 100644 --- a/crates/openshell-supervisor-network/src/sigv4.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/sigv4.rs @@ -8,8 +8,161 @@ use aws_sigv4::http_request::{ use aws_sigv4::sign::v4; use aws_smithy_runtime_api::client::identity::Identity; use miette::{Result, miette}; +use std::fmt; use std::time::SystemTime; +/// Built-in name used for the trusted post-credential signing stage. +pub const NAME: &str = "openshell/sigv4"; + +/// Maximum body retained by the built-in when the AWS signature covers the +/// complete payload. +pub const MAX_BODY_BYTES: usize = 10 * 1024 * 1024; + +/// Endpoint-selected signing behavior. This mirrors the existing policy +/// values without exposing network-policy types to the built-in crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RequestedPayloadMode { + /// Preserve the payload mode selected by the AWS client when possible. + Auto, + /// Hash and sign the complete request body. + SignBody, + /// Sign only the request head with `UNSIGNED-PAYLOAD`. + UnsignedPayload, +} + +/// Normalized request framing relevant to payload-mode selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyFraming { + None, + ContentLength, + Chunked, +} + +/// Payload representation covered by the generated signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PayloadMode { + /// Hash the complete request body. + SignBody, + /// Sign headers with the AWS `UNSIGNED-PAYLOAD` sentinel. + UnsignedPayload, + /// Sign an `aws-chunked` stream that carries an unsigned trailer. + StreamingUnsignedTrailer, +} + +impl fmt::Display for PayloadMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SignBody => formatter.write_str("sign_body"), + Self::UnsignedPayload => formatter.write_str("unsigned_payload"), + Self::StreamingUnsignedTrailer => formatter.write_str("streaming_unsigned_trailer"), + } + } +} + +/// Resolve the concrete AWS payload mode from endpoint policy and the +/// caller's original request head. +pub fn resolve_payload_mode( + requested: RequestedPayloadMode, + original_headers: &str, + framing: BodyFraming, +) -> Result { + match requested { + RequestedPayloadMode::SignBody => return Ok(PayloadMode::SignBody), + RequestedPayloadMode::UnsignedPayload => return Ok(PayloadMode::UnsignedPayload), + RequestedPayloadMode::Auto => {} + } + + for line in original_headers.lines().skip(1) { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + if !name.eq_ignore_ascii_case("x-amz-content-sha256") { + continue; + } + let value = value.trim().to_ascii_lowercase(); + return match value.as_str() { + "streaming-unsigned-payload-trailer" => Ok(PayloadMode::StreamingUnsignedTrailer), + "unsigned-payload" => Ok(PayloadMode::UnsignedPayload), + value if value.starts_with("streaming-") => Err(miette!( + "SigV4 auto-detect does not support chunk-signed streaming mode \ + '{value}'; use credential_signing: sigv4:no_body to stream \ + with UNSIGNED-PAYLOAD instead" + )), + _ => Ok(PayloadMode::SignBody), + }; + } + + Ok(if framing == BodyFraming::ContentLength { + PayloadMode::SignBody + } else { + PayloadMode::UnsignedPayload + }) +} + +/// Credential values kept inside the trusted built-in boundary. +#[derive(Debug, Clone, Copy)] +pub struct SigningCredentials<'a> { + pub access_key: &'a str, + pub secret_key: &'a str, + pub session_token: Option<&'a str>, +} + +/// Non-secret signing target selected from the admitted endpoint policy. +#[derive(Debug, Clone, Copy)] +pub struct SigningTarget<'a> { + pub host: &'a str, + pub region: &'a str, + pub service: &'a str, +} + +/// Restricted in-process signer invoked only after credential resolution. +#[derive(Debug, Default)] +pub struct SigV4Middleware; + +impl SigV4Middleware { + /// Remove caller-provided AWS authorization fields before credential + /// placeholder rewriting and trusted re-signing. + pub fn strip_existing_auth(raw: &[u8]) -> Result> { + strip_aws_headers(raw) + } + + /// Sign a complete request, including its body hash. + pub fn sign_body( + raw: &[u8], + target: SigningTarget<'_>, + credentials: SigningCredentials<'_>, + ) -> Result> { + apply_sigv4_to_request( + raw, + target.host, + target.region, + target.service, + credentials.access_key, + credentials.secret_key, + credentials.session_token, + ) + } + + /// Sign a request head without retaining the request body. + pub fn sign_headers( + raw_headers: &[u8], + target: SigningTarget<'_>, + credentials: SigningCredentials<'_>, + payload_mode: PayloadMode, + ) -> Result> { + apply_sigv4_headers_only_with_body( + raw_headers, + target.host, + target.region, + target.service, + credentials.access_key, + credentials.secret_key, + credentials.session_token, + payload_mode, + ) + } +} + /// AWS regions contain a hyphen followed by a digit (e.g., `us-east-1`). /// Service names like `s3` or `bedrock-runtime` do not. fn looks_like_region(s: &str) -> bool { @@ -326,7 +479,7 @@ pub fn apply_sigv4_headers_only( access_key, secret_key, session_token, - SignableBody::UnsignedPayload, + PayloadMode::UnsignedPayload, ) } @@ -344,7 +497,7 @@ pub fn apply_sigv4_headers_only_with_body( access_key: &str, secret_key: &str, session_token: Option<&str>, - body: SignableBody<'_>, + body: PayloadMode, ) -> Result> { let header_str = std::str::from_utf8(raw_headers) .map_err(|e| miette!("SigV4 signing: request headers are not valid UTF-8: {e}"))?; @@ -353,6 +506,15 @@ pub fn apply_sigv4_headers_only_with_body( let identity = build_identity(access_key, secret_key, session_token); let signing_params = build_signing_params(&identity, region, service)?; + let body = match body { + PayloadMode::SignBody => { + return Err(miette!( + "headers-only SigV4 signing cannot select full body hashing" + )); + } + PayloadMode::UnsignedPayload => SignableBody::UnsignedPayload, + PayloadMode::StreamingUnsignedTrailer => SignableBody::StreamingUnsignedPayloadTrailer, + }; let signable_request = SignableRequest::new( parts.method, &uri, @@ -375,6 +537,56 @@ pub fn apply_sigv4_headers_only_with_body( mod tests { use super::*; + #[test] + fn auto_payload_mode_preserves_unsigned_payload() { + assert_eq!( + resolve_payload_mode( + RequestedPayloadMode::Auto, + "PUT / HTTP/1.1\r\nX-Amz-Content-Sha256: UNSIGNED-PAYLOAD\r\n\r\n", + BodyFraming::ContentLength, + ) + .unwrap(), + PayloadMode::UnsignedPayload + ); + } + + #[test] + fn auto_payload_mode_preserves_streaming_unsigned_trailer() { + assert_eq!( + resolve_payload_mode( + RequestedPayloadMode::Auto, + "PUT / HTTP/1.1\r\nX-Amz-Content-Sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER\r\n\r\n", + BodyFraming::Chunked, + ) + .unwrap(), + PayloadMode::StreamingUnsignedTrailer + ); + } + + #[test] + fn auto_payload_mode_uses_body_hash_for_content_length() { + assert_eq!( + resolve_payload_mode( + RequestedPayloadMode::Auto, + "POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n", + BodyFraming::ContentLength, + ) + .unwrap(), + PayloadMode::SignBody + ); + } + + #[test] + fn auto_payload_mode_rejects_chunk_signed_streams() { + let error = resolve_payload_mode( + RequestedPayloadMode::Auto, + "PUT / HTTP/1.1\r\nX-Amz-Content-Sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD\r\n\r\n", + BodyFraming::Chunked, + ) + .unwrap_err(); + assert!(error.to_string().contains("chunk-signed streaming mode")); + } + #[test] fn extract_region_from_hostname() { let region = extract_aws_region("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); diff --git a/crates/openshell-supervisor-middleware/src/headers.rs b/crates/openshell-supervisor-middleware/src/headers.rs index 056ad32cf7..654498daba 100644 --- a/crates/openshell-supervisor-middleware/src/headers.rs +++ b/crates/openshell-supervisor-middleware/src/headers.rs @@ -15,6 +15,7 @@ pub const MAX_HEADER_MUTATION_BYTES: usize = 32 * 1024; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HeaderAuthority { Request, + RequestTrailers, Response, ResponseTrailers, } @@ -140,10 +141,12 @@ pub fn apply( Some(header_mutation::Operation::Write(write)) => { let name = validate_name(&write.name)?; validate_authority(authority, MutationKind::Write, &write.name, &name)?; - if authority == HeaderAuthority::ResponseTrailers - && !existing_headers - .iter() - .any(|existing| existing.name.eq_ignore_ascii_case(&name)) + if matches!( + authority, + HeaderAuthority::RequestTrailers | HeaderAuthority::ResponseTrailers + ) && !existing_headers + .iter() + .any(|existing| existing.name.eq_ignore_ascii_case(&name)) { return Err(HeaderMutationError::AbsentTrailerName { name: write.name.clone(), @@ -240,7 +243,9 @@ fn validate_authority( normalized_name: &str, ) -> Result<(), HeaderMutationError> { let protected = match authority { - HeaderAuthority::Request => is_request_protected(normalized_name), + HeaderAuthority::Request | HeaderAuthority::RequestTrailers => { + is_request_protected(normalized_name) + } HeaderAuthority::Response => { is_response_protected(normalized_name) || (kind == MutationKind::Write && is_response_remove_only(normalized_name)) diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index d9926678c4..17d0d99f52 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -5,9 +5,16 @@ pub mod headers; mod remote; +mod request; mod response; mod websocket; +pub use request::{ + HttpRequestDiagnostics, HttpRequestFinish, HttpRequestInvocation, HttpRequestInvocationOutcome, + HttpRequestMiddlewareFailure, HttpRequestPreflightInput, HttpRequestPreflightOutcome, + HttpRequestSession, MAX_HTTP_REQUEST_DEFERRED_BYTES, MAX_HTTP_REQUEST_STREAM_UNIT_BYTES, +}; + pub use response::{ HttpResponseDiagnostics, HttpResponseFinish, HttpResponseInvocation, HttpResponseInvocationOutcome, HttpResponseMiddlewareFailure, HttpResponsePreflightInput, @@ -30,113 +37,19 @@ use std::time::Duration; use miette::{Result, miette}; use prost::Message; -use openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware; use openshell_core::proto::{ - Decision, Finding, HeaderMutation, HttpHeader, HttpRequestEvaluation, HttpRequestTarget, - MiddlewareBinding, MiddlewareManifest, NetworkMiddlewareConfig, RequestContext, SandboxPolicy, + Decision, Finding, HeaderMutation, HttpHeader, HttpRequestTarget, MiddlewareBinding, + MiddlewareManifest, NetworkMiddlewareConfig, RequestContext, SandboxPolicy, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, SupervisorMiddlewareService, ValidateConfigRequest, ValidateConfigResponse, }; use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore}; -use tonic::{Request, Response as TonicResponse, Status as TonicStatus}; +use tonic::Request; pub use openshell_core::middleware::{ - HttpRequestView, HttpResponseResultStream, InProcessMiddleware, SupervisorMiddlewareEndpoint, - WebSocketResponseStream, + HttpRequestResultStream, HttpResponseResultStream, InProcessMiddleware, + SupervisorMiddlewareEndpoint, WebSocketResponseStream, }; -pub type MiddlewareService = - dyn SupervisorMiddleware; - -struct GeneratedMiddlewareEndpoint { - service: Arc, -} - -#[tonic::async_trait] -impl SupervisorMiddlewareEndpoint for GeneratedMiddlewareEndpoint { - async fn describe( - &self, - request: Request<()>, - ) -> std::result::Result, TonicStatus> { - self.service.describe(request).await - } - - async fn validate_config( - &self, - request: Request, - ) -> std::result::Result, TonicStatus> { - self.service.validate_config(request).await - } - - async fn evaluate_http_request( - &self, - request: Request, - ) -> std::result::Result, TonicStatus> - { - self.service.evaluate_http_request(request).await - } - - async fn open_websocket_session( - &self, - _receiver: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - Err(TonicStatus::unimplemented( - "middleware service does not expose an in-process WebSocket stream", - )) - } -} - -#[tonic::async_trait] -impl InProcessMiddleware for GeneratedMiddlewareEndpoint { - async fn describe(&self) -> MiddlewareManifest { - self.service - .describe(Request::new(())) - .await - .expect("generated in-process Describe failed") - .into_inner() - } - - async fn validate_config( - &self, - middleware_name: &str, - config: &prost_types::Struct, - ) -> Result<()> { - let response = self - .service - .validate_config(Request::new(ValidateConfigRequest { - config: Some(config.clone()), - middleware_name: middleware_name.to_string(), - })) - .await - .map_err(|error| miette!("{error}"))? - .into_inner(); - if response.valid { - Ok(()) - } else { - Err(miette!("{}", response.reason)) - } - } - - async fn evaluate_http_request( - &self, - request: HttpRequestView<'_>, - ) -> Result { - self.service - .evaluate_http_request(Request::new(request_view_to_evaluation(request))) - .await - .map(tonic::Response::into_inner) - .map_err(|error| miette!("{error}")) - } -} - -/// Adapt a generated HTTP-only service to the borrowed in-process contract. -/// -/// This compatibility adapter is intended for tests and downstream HTTP-only -/// implementations. First-party built-ins implement [`InProcessMiddleware`] -/// directly so their HTTP path remains allocation-free. -pub fn http_only_endpoint(service: Arc) -> Arc { - Arc::new(GeneratedMiddlewareEndpoint { service }) -} - struct EndpointInProcessAdapter { endpoint: Arc, } @@ -172,15 +85,13 @@ impl InProcessMiddleware for EndpointInProcessAdapter { } } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: HttpRequestView<'_>, - ) -> Result { + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { self.endpoint - .evaluate_http_request(Request::new(request_view_to_evaluation(request))) + .open_http_request_pre_credentials(requests) .await - .map(tonic::Response::into_inner) - .map_err(|error| miette!("{error}")) } async fn open_websocket_session( @@ -201,8 +112,8 @@ impl InProcessMiddleware for EndpointInProcessAdapter { /// Adapt a transport-neutral endpoint to the in-process registry contract. /// /// Prefer implementing [`InProcessMiddleware`] directly. This compatibility -/// path materializes an owned HTTP request, but preserves direct WebSocket -/// streams for endpoint implementations that predate the borrowed contract. +/// path preserves the request, response, and WebSocket event streams while +/// adapting configuration calls to the transport-neutral endpoint surface. pub fn in_process_endpoint( endpoint: Arc, ) -> Arc { @@ -262,8 +173,8 @@ impl MiddlewareWorkAdmissionOutcome { /// /// Protocol-specific session runners retain this guard while at least one /// streaming stage remains active. Registry replacement preserves the shared -/// admission state so future streaming HTTP middleware can use the same -/// process-wide bound. +/// admission state so HTTP and WebSocket streams use the same process-wide +/// bound across registry replacement. #[derive(Debug)] struct MiddlewareSessionPermit { _session: OwnedSemaphorePermit, @@ -334,6 +245,7 @@ const EXTERNAL_FINDING_LABEL: &str = "External middleware finding"; #[cfg(test)] const HTTP_REQUEST_OPERATION: SupervisorMiddlewareOperation = SupervisorMiddlewareOperation::HttpRequest; +#[cfg(test)] const PRE_CREDENTIALS_PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OnError { @@ -515,56 +427,6 @@ pub struct MiddlewareInvocation { pub failed: bool, } -enum OnErrorAction { - /// `fail_open`: skip this middleware, leaving the request unchanged. - FailOpen, - /// `fail_closed`: short-circuit the chain and deny with the given reason. - FailClosed(String), -} - -/// Apply a middleware entry's `on_error` policy after a failure (service error or -/// malformed response). Records a `failed` invocation for telemetry in both cases. -fn apply_on_error( - entry: &DescribedChainEntry, - reason: &str, - applied: &mut Vec, -) -> OnErrorAction { - match entry.entry.on_error { - OnError::FailOpen => { - applied.push(MiddlewareInvocation { - name: entry.entry.name.clone(), - implementation: entry.entry.implementation.clone(), - decision: Decision::Allow, - transformed: false, - failed: true, - }); - OnErrorAction::FailOpen - } - OnError::FailClosed => { - applied.push(MiddlewareInvocation { - name: entry.entry.name.clone(), - implementation: entry.entry.implementation.clone(), - decision: Decision::Deny, - transformed: false, - failed: true, - }); - OnErrorAction::FailClosed(format!("middleware_failed: {reason}")) - } - } -} - -fn request_view_to_evaluation(request: HttpRequestView<'_>) -> HttpRequestEvaluation { - HttpRequestEvaluation { - phase: request.phase() as i32, - context: Some(request.context().clone()), - config: Some(request.config().clone()), - target: Some(request.target().clone()), - headers: request.headers().to_vec(), - body: request.body().to_vec(), - middleware_name: request.middleware_name().to_string(), - } -} - #[derive(Clone)] pub struct ChainRunner { registry: Arc, @@ -610,18 +472,13 @@ impl MiddlewareDispatch { } } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: HttpRequestView<'_>, - ) -> std::result::Result, tonic::Status> - { + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { match self { - Self::InProcess(service) => service - .evaluate_http_request(request) - .await - .map(tonic::Response::new) - .map_err(|error| tonic::Status::invalid_argument(error.to_string())), - Self::Grpc(service) => service.evaluate_http_request(request).await, + Self::InProcess(service) => service.open_http_request_pre_credentials(receiver).await, + Self::Grpc(service) => service.open_http_request_pre_credentials(receiver).await, } } @@ -694,16 +551,6 @@ impl MiddlewareDiagnosticPolicy { } } - fn process_result( - self, - middleware_name: &str, - result: &mut openshell_core::proto::HttpRequestResult, - ) { - if self == Self::Normalize { - normalize_untrusted_diagnostics(middleware_name, result); - } - } - fn header_mutation_error_reason(self, error: &headers::HeaderMutationError) -> String { match self { Self::Preserve => safe_reason(&error.to_string()), @@ -854,6 +701,7 @@ fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result Result Ok(SupportedBinding::HttpPreCredentials), + ( + Some(SupervisorMiddlewareOperation::HttpRequest), + Some(SupervisorMiddlewarePhase::PostCredentials), + ) => Ok(SupportedBinding::HttpPostCredentials), ( Some(SupervisorMiddlewareOperation::HttpResponse), Some(SupervisorMiddlewarePhase::PreReturn), @@ -948,6 +800,15 @@ fn validate_external_manifest( operator_max_payload_bytes: usize, authenticated: bool, ) -> Result<()> { + if manifest.bindings.iter().any(|binding| { + SupervisorMiddlewarePhase::try_from(binding.phase) + .is_ok_and(|phase| phase == SupervisorMiddlewarePhase::PostCredentials) + }) { + return Err(miette!( + "external middleware registration '{}' advertises POST_CREDENTIALS, which is reserved for trusted in-process built-ins", + registration.name + )); + } validate_manifest_bindings( &format!("external middleware registration '{}'", registration.name), manifest, @@ -985,113 +846,6 @@ fn validate_expected_audience( Ok(()) } -/// External diagnostic text is untrusted and may contain request data. Keep -/// only values derived from the validated, operator-owned registration name -/// and numeric finding counts; do not carry per-request free-form text into -/// logs. -fn normalize_untrusted_diagnostics( - middleware_name: &str, - result: &mut openshell_core::proto::HttpRequestResult, -) { - let reason_id: String = middleware_name - .chars() - .map(|character| { - if character.is_ascii_alphanumeric() || matches!(character, '_' | '-') { - character - } else { - '_' - } - }) - .collect(); - result.reason = format!("middleware_denied:{reason_id}"); - result.metadata.clear(); - for finding in &mut result.findings { - finding.r#type = format!("{middleware_name}.finding"); - finding.label = EXTERNAL_FINDING_LABEL.to_string(); - finding.confidence.clear(); - finding.severity = match finding.severity.as_str() { - "low" => "low", - "high" => "high", - _ => "medium", - } - .to_string(); - } -} - -fn validate_request_view(request: HttpRequestView<'_>) -> std::result::Result<(), &'static str> { - if request.body().len() > MAX_MIDDLEWARE_PAYLOAD_BYTES { - return Err("request_body_over_capacity"); - } - if request.config().encoded_len() > MAX_MIDDLEWARE_CONFIG_BYTES { - return Err("request_config_over_capacity"); - } - if request.context().encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES { - return Err("request_context_over_capacity"); - } - if request.target().encoded_len() > MAX_MIDDLEWARE_TARGET_BYTES { - return Err("request_target_over_capacity"); - } - if request.headers().len() > MAX_MIDDLEWARE_HEADERS { - return Err("request_header_count_over_capacity"); - } - let header_bytes = request.headers().iter().fold(0usize, |total, header| { - total.saturating_add(header.encoded_len()) - }); - if header_bytes > MAX_MIDDLEWARE_HEADER_BYTES { - return Err("request_header_bytes_over_capacity"); - } - Ok(()) -} - -fn validate_response_envelope( - result: &openshell_core::proto::HttpRequestResult, -) -> std::result::Result<(), &'static str> { - if result.body.len() > MAX_MIDDLEWARE_PAYLOAD_BYTES { - return Err("response_body_over_capacity"); - } - if result.reason.len() > MAX_MIDDLEWARE_REASON_BYTES { - return Err("response_reason_over_capacity"); - } - if !result.reason_code.is_empty() && !is_stable_reason_code(&result.reason_code) { - return Err("response_reason_code_invalid"); - } - if result.header_mutations.len() > headers::MAX_HEADER_MUTATIONS { - return Err("header_mutation_count_over_capacity"); - } - let mutation_bytes = result - .header_mutations - .iter() - .fold(0usize, |total, mutation| { - total.saturating_add(mutation.encoded_len()) - }); - if mutation_bytes > MAX_MIDDLEWARE_HEADER_MUTATION_WIRE_BYTES { - return Err("header_mutation_bytes_over_capacity"); - } - if result.findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE { - return Err("response_findings_over_capacity"); - } - if result - .findings - .iter() - .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) - { - return Err("response_finding_over_capacity"); - } - if result.metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES { - return Err("response_metadata_count_over_capacity"); - } - let metadata_bytes = result.metadata.iter().fold(0usize, |total, (key, value)| { - total.saturating_add(key.len()).saturating_add(value.len()) - }); - if metadata_bytes > MAX_MIDDLEWARE_METADATA_BYTES { - return Err("response_metadata_bytes_over_capacity"); - } - if result.encoded_len() > MIDDLEWARE_GRPC_MESSAGE_BYTES { - return Err("response_envelope_over_capacity"); - } - Ok(()) -} - impl MiddlewareRegistry { /// Describe in-process services, then connect and validate every /// operator-provided service registration. @@ -1346,9 +1100,7 @@ impl ChainRunner { } #[cfg(test)] - fn new_protobuf_for_tests(service: Arc) -> Self { - let endpoint: Arc = - Arc::new(GeneratedMiddlewareEndpoint { service }); + fn new_protobuf_for_tests(endpoint: Arc) -> Self { Self::from_service(MiddlewareDispatch::Grpc( remote::GrpcMiddlewareService::from_service(endpoint), )) @@ -1663,9 +1415,6 @@ impl ChainRunner { connection_nominated_headers, body, } = input; - // The request envelope is moved into one stable chain state. Built-ins - // borrow these values for every stage; only the gRPC adapter clones them - // when an operator service requires an owned protobuf message. let context = RequestContext { request_id, sandbox_id, @@ -1681,305 +1430,143 @@ impl ChainRunner { path, query, }; - let mut headers: Vec = headers + let mut headers = headers .into_iter() .map(|(name, value)| HttpHeader { name, value }) - .collect(); + .collect::>(); let mut body = body; let mut header_mutations = Vec::new(); let mut findings = Vec::new(); let mut metadata = BTreeMap::new(); let mut applied = Vec::new(); - let _admission = admission; - let chain_deadline = tokio::time::Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; - + // The request session budget now bounds the streaming lifecycle, so a + // compatibility caller's pre-buffer admission can be released before + // opening the stage stream. + drop(admission); + + // The compatibility collector evaluates one stage at a time so + // body-aware protocols can re-run policy after every accepted + // replacement. The HTTP relay uses the streaming session API directly + // and does not collect a complete request in memory. for entry in entries { - let Some(_binding) = entry.binding.as_ref() else { - match apply_on_error(entry, "binding_not_described", &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { - return Ok(ChainOutcome { - allowed: false, - reason, - body, - header_mutations, - findings, - metadata, - applied, - denial: None, - }); - } - } - }; - if body.len() > entry.max_payload_bytes { - match apply_on_error(entry, "request_body_over_capacity", &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { - return Ok(ChainOutcome { - allowed: false, - reason, - body, - header_mutations, - findings, - metadata, - applied, - denial: None, - }); - } - } - } - let request = HttpRequestView::new( - PRE_CREDENTIALS_PHASE, - &context, - &entry.entry.config, - &target, - &headers, - &body, - &entry.entry.implementation, - ); - if let Err(reason) = validate_request_view(request) { - match apply_on_error(entry, reason, &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { - return Ok(ChainOutcome { - allowed: false, - reason, - body, - header_mutations, - findings, - metadata, - applied, - denial: None, - }); - } - } - } - let Some(service) = entry.service.as_ref() else { - unreachable!("described binding always has a service") - }; - let remaining = chain_deadline.saturating_duration_since(tokio::time::Instant::now()); - if remaining.is_zero() { - match apply_on_error(entry, "middleware_chain_timeout", &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { - return Ok(ChainOutcome { - allowed: false, - reason, - body, - header_mutations, - findings, - metadata, - applied, - denial: None, - }); - } - } - } - let mut result = match call_with_timeout( - entry.timeout.min(remaining), - "EvaluateHttpRequest", - service.service.evaluate_http_request(request), - ) - .await - { - Ok(result) => result.into_inner(), - Err(err) => { - let reason = if err.code() == tonic::Code::DeadlineExceeded { - "middleware_timeout".to_string() - } else { - service.diagnostic_policy.error_reason(&err) - }; - match apply_on_error(entry, &reason, &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { - return Ok(ChainOutcome { - allowed: false, - reason, - body, - header_mutations, - findings, - metadata, - applied, - denial: None, - }); - } - } - } - }; - - if let Err(reason) = validate_response_envelope(&result) { - match apply_on_error(entry, reason, &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { - return Ok(ChainOutcome { - allowed: false, - reason, - body, - header_mutations, - findings, - metadata, - applied, - denial: None, - }); - } - } - } + let preflight = self + .preflight_described_http_request_with_owned( + vec![entry.clone()], + HttpRequestPreflightInput { + context: context.clone(), + target: target.clone(), + declared_body_length: Some(body.len() as u64), + headers: headers.clone(), + connection_nominated_headers: connection_nominated_headers.clone(), + }, + false, + ) + .await?; - service - .diagnostic_policy - .process_result(&entry.entry.implementation, &mut result); - - let decision = match Decision::try_from(result.decision) { - Ok(decision @ (Decision::Allow | Decision::Deny)) => decision, - Ok(Decision::Unspecified) | Err(_) => { - match apply_on_error(entry, "invalid_response_decision", &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { - return Ok(ChainOutcome { - allowed: false, - reason, - body, - header_mutations, - findings, - metadata, - applied, - denial: None, - }); - } - } - } - }; + findings.extend(preflight.findings.clone()); + metadata.extend(preflight.metadata.clone()); + let mut stage_failed = preflight.invocations.iter().any(|item| item.failed); + let headers_transformed = preflight.headers != headers; + headers = preflight.headers; + header_mutations.extend(preflight.header_mutations); - if decision == Decision::Deny { - let reason_code = - (!result.reason_code.is_empty()).then(|| result.reason_code.clone()); - let denial = MiddlewareDenial { - config_name: entry.entry.name.clone(), - reason_code, - }; - for finding in result.findings { - findings.push(NamespacedFinding { - middleware: entry.entry.name.clone(), - finding, - }); - } - if !result.metadata.is_empty() { - metadata.insert( - entry.entry.name.clone(), - result.metadata.into_iter().collect(), - ); - } + if !preflight.allowed { applied.push(MiddlewareInvocation { name: entry.entry.name.clone(), implementation: entry.entry.implementation.clone(), - decision, + decision: Decision::Deny, transformed: false, - failed: false, + failed: preflight.denial.is_none(), }); return Ok(ChainOutcome { allowed: false, - reason: middleware_denial_reason( - &denial.config_name, - denial.reason_code.as_deref(), - ), + reason: preflight.reason, body, header_mutations, findings, metadata, applied, - denial: Some(denial), + denial: preflight.denial, }); } - if result.has_body && result.body.len() > entry.max_payload_bytes { - match apply_on_error(entry, "response_body_over_capacity", &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { + let mut body_transformed = false; + if let Some(mut session) = preflight.session { + let original_body = body.clone(); + let mut output = Vec::new(); + let unit_limit = session.stream_unit_limit(); + let mut failed = None; + for chunk in body.chunks(unit_limit) { + match session.push_body(chunk.to_vec()).await { + Ok(units) => output.extend(units), + Err(error) => { + failed = Some(error); + break; + } + } + } + if let Some(error) = failed { + findings.extend(error.diagnostics.findings); + metadata.extend(error.diagnostics.metadata); + applied.push(MiddlewareInvocation { + name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + decision: Decision::Deny, + transformed: false, + failed: error.denial.is_none(), + }); + return Ok(ChainOutcome { + allowed: false, + reason: error.reason, + body: original_body, + header_mutations, + findings, + metadata, + applied, + denial: error.denial, + }); + } + match session.finish(Vec::new()).await { + Ok(finish) => { + output.extend(finish.body_units); + body_transformed = finish.body_transformed; + stage_failed |= finish.invocations.iter().any(|item| item.failed); + findings.extend(finish.findings); + metadata.extend(finish.metadata); + body = output.concat(); + } + Err(error) => { + findings.extend(error.diagnostics.findings); + metadata.extend(error.diagnostics.metadata); + applied.push(MiddlewareInvocation { + name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + decision: Decision::Deny, + transformed: false, + failed: error.denial.is_none(), + }); return Ok(ChainOutcome { allowed: false, - reason, - body, + reason: error.reason, + body: original_body, header_mutations, findings, metadata, applied, - denial: None, + denial: error.denial, }); } } } - // Validate and apply the entire stage atomically. Under fail-open, - // one malformed mutation must not leave earlier mutations from the - // same response visible to later middleware. - let updated_headers = if result.header_mutations.is_empty() { - None - } else { - match headers::apply( - headers::HeaderAuthority::Request, - &headers, - &connection_nominated_headers, - &result.header_mutations, - ) { - Ok(updated) => Some(updated), - Err(error) => { - let reason = service - .diagnostic_policy - .header_mutation_error_reason(&error); - match apply_on_error(entry, &reason, &mut applied) { - OnErrorAction::FailOpen => continue, - OnErrorAction::FailClosed(reason) => { - return Ok(ChainOutcome { - allowed: false, - reason, - body, - header_mutations, - findings, - metadata, - applied, - denial: None, - }); - } - } - } - } - }; - let headers_transformed = updated_headers - .as_ref() - .is_some_and(|updated| updated != &headers); - if let Some(updated) = updated_headers { - headers = updated; - } - header_mutations.extend(std::mem::take(&mut result.header_mutations)); - - let body_transformed = result.has_body; - if body_transformed { - body = std::mem::take(&mut result.body); - } - for finding in result.findings { - findings.push(NamespacedFinding { - middleware: entry.entry.name.clone(), - finding, - }); - } - if !result.metadata.is_empty() { - metadata.insert( - entry.entry.name.clone(), - result.metadata.into_iter().collect(), - ); - } applied.push(MiddlewareInvocation { name: entry.entry.name.clone(), implementation: entry.entry.implementation.clone(), - decision, + decision: Decision::Allow, transformed: body_transformed || headers_transformed, - failed: false, + failed: stage_failed, }); - // The stage ran successfully but its output must still satisfy the - // sandbox policy the original body was admitted under. Re-check now, - // before the next stage or the upstream sees the replaced body. A - // policy deny here is a hard deny, independent of `on_error`. if body_transformed && let TransformedBodyPolicy::Reevaluate(validate) = transformed_body_policy { @@ -2058,24 +1645,211 @@ pub(crate) fn safe_reason(reason: &str) -> String { mod tests { use super::*; use futures::{FutureExt, Stream, StreamExt}; + use openshell_core::proto::middleware::v1::http_request_pre_credentials_server::{ + HttpRequestPreCredentials, HttpRequestPreCredentialsServer, + }; use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ SupervisorMiddleware, SupervisorMiddlewareServer, }; use openshell_core::proto::{ExistingHeaderAction, header_mutation}; use openshell_supervisor_middleware_builtins::{BUILTIN_REGEX, services}; - use tokio_stream::wrappers::TcpListenerStream; + use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream}; - fn proto_duration(value: &str) -> prost_types::Duration { - let duration = match (value.strip_suffix("ms"), value.strip_suffix('s')) { - (Some(milliseconds), _) => { - Duration::from_millis(milliseconds.parse().expect("integer milliseconds")) - } - (_, Some(seconds)) => Duration::from_secs(seconds.parse().expect("integer seconds")), - (None, None) => panic!("test duration must use ms or s"), + #[derive(Clone, Default)] + struct TestRequestEvaluation { + phase: i32, + context: Option, + config: Option, + target: Option, + headers: Vec, + body: Vec, + middleware_name: String, + } + + #[derive(Clone, Default)] + struct TestRequestResult { + decision: i32, + reason: String, + body: Vec, + has_body: bool, + header_mutations: Vec, + findings: Vec, + metadata: HashMap, + reason_code: String, + } + + type TestBodyHandler = + Box, TestRequestEvaluation) -> TestRequestResult + Send + 'static>; + + struct TestRequestPlan { + preflight: TestRequestResult, + body: Option, + } + + fn constant_request_plan(result: TestRequestResult) -> TestRequestPlan { + if result.decision != Decision::Allow as i32 { + return TestRequestPlan { + preflight: result, + body: None, + }; + } + let preflight = TestRequestResult { + decision: result.decision, + header_mutations: result.header_mutations.clone(), + ..Default::default() }; - openshell_core::time::duration_from_std(duration) - .expect("test duration is in protobuf range") + TestRequestPlan { + preflight, + body: Some(Box::new(move |_body, _evaluation| result)), + } + } + + fn open_test_request_stream( + mut requests: tokio::sync::mpsc::Receiver, + plan: F, + ) -> HttpRequestResultStream + where + F: FnOnce(openshell_core::proto::HttpRequestPreflight) -> TestRequestPlan + Send + 'static, + { + use openshell_core::proto::{ + HttpRequestBlock, HttpRequestBodyPassThrough, HttpRequestBodyResult, + HttpRequestBodyTransform, HttpRequestEventResult, HttpRequestPreflightInspect, + HttpRequestPreflightResult, HttpRequestTrailersResult, http_request_body_result, + http_request_body_transform, http_request_event, http_request_event_result, + http_request_preflight_result, + }; + + let (sender, receiver) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + let Some(openshell_core::proto::HttpRequestEvent { + event: Some(http_request_event::Event::Preflight(preflight)), + }) = requests.recv().await + else { + return; + }; + let mut evaluation = TestRequestEvaluation { + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + context: preflight.context.clone(), + config: preflight.config.clone(), + target: preflight.target.clone(), + headers: preflight.headers.clone(), + body: Vec::new(), + middleware_name: preflight.middleware_name.clone(), + }; + let TestRequestPlan { + preflight: initial, + body, + } = plan(preflight); + let action = match Decision::try_from(initial.decision) { + Ok(Decision::Deny) => Some(http_request_preflight_result::Action::BlockRequest( + HttpRequestBlock {}, + )), + Ok(Decision::Allow) => Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: if body.is_some() { + openshell_core::proto::HttpRequestBodyMode::WholeBodyBytes as i32 + } else { + openshell_core::proto::HttpRequestBodyMode::HeadersOnly as i32 + }, + header_mutations: initial.header_mutations, + }, + )), + Ok(Decision::Unspecified) | Err(_) => None, + }; + if sender + .send(Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action, + reason: initial.reason, + reason_code: initial.reason_code, + findings: initial.findings, + metadata: initial.metadata, + }, + )), + })) + .await + .is_err() + { + return; + } + + let Some(body_handler) = body else { + while requests.recv().await.is_some() {} + return; + }; + let Some(openshell_core::proto::HttpRequestEvent { + event: Some(http_request_event::Event::Body(body)), + }) = requests.recv().await + else { + return; + }; + evaluation.body = match body.payload { + Some(openshell_core::proto::http_request_body_unit::Payload::Data(data)) => data, + None => Vec::new(), + }; + let sequence = body.sequence; + let result = body_handler(evaluation.body.clone(), evaluation); + let action = if result.decision == Decision::Deny as i32 { + http_request_body_result::Action::BlockRequest(HttpRequestBlock {}) + } else if result.has_body { + http_request_body_result::Action::Transform(HttpRequestBodyTransform { + replacement: Some(http_request_body_transform::Replacement::Data(result.body)), + }) + } else { + http_request_body_result::Action::PassThrough(HttpRequestBodyPassThrough {}) + }; + if sender + .send(Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyResult( + HttpRequestBodyResult { + sequence, + action: Some(action), + reason: result.reason, + reason_code: result.reason_code, + findings: result.findings, + metadata: result.metadata, + }, + )), + })) + .await + .is_err() + { + return; + } + while let Some(event) = requests.recv().await { + match event.event { + Some(http_request_event::Event::Trailers(_)) => { + if sender + .send(Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::TrailersResult( + HttpRequestTrailersResult::default(), + )), + })) + .await + .is_err() + { + break; + } + } + _ => break, + } + } + }); + Box::pin(ReceiverStream::new(receiver)) + } + + fn proto_duration(value: &str) -> prost_types::Duration { + let duration = match (value.strip_suffix("ms"), value.strip_suffix('s')) { + (Some(milliseconds), _) => { + Duration::from_millis(milliseconds.parse().expect("integer milliseconds")) + } + (_, Some(seconds)) => Duration::from_secs(seconds.parse().expect("integer seconds")), + (None, None) => panic!("test duration must use ms or s"), + }; + openshell_core::time::duration_from_std(duration) + .expect("test duration is in protobuf range") } #[test] @@ -2161,38 +1935,66 @@ mod tests { } } - #[derive(Debug, Clone, PartialEq, Eq)] - struct RequestAddresses { - phase: SupervisorMiddlewarePhase, - context: usize, - request_id: usize, - config: usize, - target: usize, - host: usize, - headers: usize, - first_header_name: usize, - body: usize, - originating_process_present: bool, - middleware_name: String, + /// An in-process service that yields forever so the runtime must enforce + /// the binding timeout around borrowed validation and evaluation futures. + struct PendingInProcessService; + + #[tonic::async_trait] + impl InProcessMiddleware for PendingInProcessService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/pending".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: 4096, + request_timeout: Some(proto_duration("10ms")), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + std::future::pending().await + } + + async fn open_http_request_pre_credentials( + &self, + _requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + std::future::pending().await + } } - /// Records borrowed addresses so the test can detect an owned envelope - /// being reconstructed between otherwise no-op in-process stages. - struct BorrowedRecordingService { - manifest_name: String, - received: std::sync::Mutex>, + struct OwnedStreamService { + invalid_finalization: bool, + } + + #[derive(Default)] + struct StreamSequenceRecorder { + streams: std::sync::Mutex>>, + } + + struct StreamSequenceService { + recorder: Arc, + findings_per_body: bool, } #[tonic::async_trait] - impl InProcessMiddleware for BorrowedRecordingService { + impl InProcessMiddleware for StreamSequenceService { async fn describe(&self) -> MiddlewareManifest { MiddlewareManifest { - name: self.manifest_name.clone(), + name: "test/stream-sequence".into(), service_version: "test".into(), bindings: vec![MiddlewareBinding { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, + max_payload_bytes: MAX_HTTP_REQUEST_STREAM_UNIT_BYTES as u64, request_timeout: None, }], expected_audience: String::new(), @@ -2207,128 +2009,225 @@ mod tests { Ok(()) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: HttpRequestView<'_>, - ) -> Result { - let addresses = RequestAddresses { - phase: request.phase(), - context: std::ptr::from_ref(request.context()).addr(), - request_id: request.context().request_id.as_ptr().addr(), - config: std::ptr::from_ref(request.config()).addr(), - target: std::ptr::from_ref(request.target()).addr(), - host: request.target().host.as_ptr().addr(), - headers: request.headers().as_ptr().addr(), - first_header_name: request - .headers() - .first() - .map_or(0, |header| header.name.as_ptr().addr()), - body: request.body().as_ptr().addr(), - originating_process_present: request.context().originating_process.is_some(), - middleware_name: request.middleware_name().to_string(), + mut requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + use openshell_core::proto::{ + HttpRequestBodyMode, HttpRequestBodyPassThrough, HttpRequestBodyResult, + HttpRequestEventResult, HttpRequestPreflightInspect, HttpRequestPreflightResult, + HttpRequestTrailersResult, http_request_body_result, http_request_body_unit, + http_request_event, http_request_event_result, http_request_preflight_result, }; - self.received - .lock() - .expect("borrowed request recorder lock") - .push(addresses); - Ok(allow_result()) + + let stream_index = { + let mut streams = self.recorder.streams.lock().expect("stream recorder lock"); + streams.push(Vec::new()); + streams.len() - 1 + }; + let recorder = Arc::clone(&self.recorder); + let findings_per_body = self.findings_per_body; + let (sender, receiver) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + while let Some(event) = requests.recv().await { + let result = match event.event { + Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::StreamBytes as i32, + header_mutations: Vec::new(), + }, + )), + ..Default::default() + }, + )), + }, + Some(http_request_event::Event::Body(body)) => { + let size = match body.payload { + Some(http_request_body_unit::Payload::Data(ref data)) => data.len(), + None => break, + }; + recorder.streams.lock().expect("stream recorder lock")[stream_index] + .push((size, body.end_of_stream)); + HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyResult( + HttpRequestBodyResult { + sequence: body.sequence, + action: Some( + http_request_body_result::Action::PassThrough( + HttpRequestBodyPassThrough {}, + ), + ), + findings: findings_per_body + .then(|| Finding { + r#type: "test.stream".into(), + label: "Stream finding".into(), + count: 1, + confidence: "high".into(), + severity: "informational".into(), + }) + .into_iter() + .collect(), + ..Default::default() + }, + )), + } + } + Some(http_request_event::Event::Trailers(_)) => HttpRequestEventResult { + result: Some(http_request_event_result::Result::TrailersResult( + HttpRequestTrailersResult::default(), + )), + }, + Some(http_request_event::Event::SessionEnd(_)) | None => break, + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + }); + Ok(Box::pin(ReceiverStream::new(receiver))) } } - #[tokio::test] - async fn in_process_stages_share_one_borrowed_request_envelope() { - let service = Arc::new(BorrowedRecordingService { - manifest_name: "acme/redactor".into(), - received: std::sync::Mutex::new(Vec::new()), - }); - let runner = ChainRunner::new(service.clone()); - let entries = [ - ChainEntry { - name: "first".into(), - implementation: "acme/redactor".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ChainEntry { - name: "second".into(), - implementation: "acme/redactor".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ]; - let described = runner - .describe_chain(&entries) - .await - .expect("describe chain"); - let expected_configs: Vec<_> = described - .iter() - .map(|entry| std::ptr::from_ref(&entry.entry.config).addr()) - .collect(); - let mut request = input("payload"); - request.headers = vec![("x-test".into(), "value".into())]; - let expected_body = request.body.as_ptr().addr(); - let expected_request_id = request.request_id.as_ptr().addr(); - let expected_host = request.host.as_ptr().addr(); - let expected_header_name = request.headers[0].0.as_ptr().addr(); - - let outcome = runner - .evaluate_described(&described, request) - .await - .expect("evaluate borrowed chain"); - let received = service.received.lock().expect("borrowed requests"); + struct CancellationRecordingService { + terminal: std::sync::Mutex< + Option>, + >, + } - assert!(outcome.allowed); - assert_eq!(outcome.body.as_ptr().addr(), expected_body); - assert_eq!(received.len(), 2); - assert_eq!(received[0].phase, SupervisorMiddlewarePhase::PreCredentials); - assert!(!received[0].originating_process_present); - assert_eq!(received[0].request_id, expected_request_id); - assert_eq!(received[0].host, expected_host); - assert_eq!(received[0].first_header_name, expected_header_name); - assert_eq!(received[0].body, expected_body); - assert_eq!(received[0].config, expected_configs[0]); - assert_eq!(received[1].config, expected_configs[1]); - assert_eq!(received[0].context, received[1].context); - assert_eq!(received[0].target, received[1].target); - assert_eq!(received[0].headers, received[1].headers); - assert_eq!(received[0].body, received[1].body); - assert!( - received - .iter() - .all(|request| request.middleware_name == "acme/redactor") - ); + struct BodyDenialService { + manifest_name: String, + deny: bool, + session_ends: tokio::sync::mpsc::UnboundedSender<( + String, + openshell_core::proto::MiddlewareSessionEndReason, + )>, } - const TEST_REPLACEMENT_BODY: &[u8] = b"stage-one-replacement"; + #[tonic::async_trait] + impl InProcessMiddleware for BodyDenialService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: self.manifest_name.clone(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: MAX_HTTP_REQUEST_STREAM_UNIT_BYTES as u64, + request_timeout: None, + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } - /// Records both sides of a successful body replacement so the test can - /// distinguish ownership transfer from a content-preserving body copy. - #[derive(Debug, Default)] - struct ReplacementTransferRecord { - invocations: usize, - returned_body: Option, - second_body: Option, - second_body_bytes: Vec, - } + async fn open_http_request_pre_credentials( + &self, + mut requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + use openshell_core::proto::{ + HttpRequestBlock, HttpRequestBodyMode, HttpRequestBodyPassThrough, + HttpRequestBodyResult, HttpRequestEventResult, HttpRequestPreflightInspect, + HttpRequestPreflightResult, http_request_body_result, http_request_event, + http_request_event_result, http_request_preflight_result, + }; - /// Replaces the first request body and observes the body borrowed by the - /// second stage without replacing it again. - struct ReplacementTransferService { - record: std::sync::Mutex, + let name = self.manifest_name.clone(); + let deny = self.deny; + let session_ends = self.session_ends.clone(); + let (sender, receiver) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + while let Some(event) = requests.recv().await { + let result = match event.event { + Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::StreamBytes as i32, + header_mutations: Vec::new(), + }, + )), + ..Default::default() + }, + )), + }, + Some(http_request_event::Event::Body(body)) => HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyResult( + HttpRequestBodyResult { + sequence: body.sequence, + action: Some(if deny { + http_request_body_result::Action::BlockRequest( + HttpRequestBlock {}, + ) + } else { + http_request_body_result::Action::PassThrough( + HttpRequestBodyPassThrough {}, + ) + }), + reason_code: if deny { + "body_denied".into() + } else { + String::new() + }, + findings: deny + .then(|| Finding { + r#type: "test.request-body".into(), + label: "Request body denied".into(), + count: 1, + confidence: "high".into(), + severity: "medium".into(), + }) + .into_iter() + .collect(), + metadata: deny + .then(|| ("rule".into(), "deny-body".into())) + .into_iter() + .collect(), + ..Default::default() + }, + )), + }, + Some(http_request_event::Event::SessionEnd(end)) => { + if let Ok(reason) = + openshell_core::proto::MiddlewareSessionEndReason::try_from( + end.reason, + ) + { + let _ = session_ends.send((name.clone(), reason)); + } + break; + } + Some(http_request_event::Event::Trailers(_)) | None => break, + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + }); + Ok(Box::pin(ReceiverStream::new(receiver))) + } } #[tonic::async_trait] - impl InProcessMiddleware for ReplacementTransferService { + impl InProcessMiddleware for CancellationRecordingService { async fn describe(&self) -> MiddlewareManifest { MiddlewareManifest { - name: "test/replacement-transfer".into(), + name: "test/cancellation-recorder".into(), service_version: "test".into(), bindings: vec![MiddlewareBinding { operation: SupervisorMiddlewareOperation::HttpRequest as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, + max_payload_bytes: MAX_HTTP_REQUEST_STREAM_UNIT_BYTES as u64, request_timeout: None, }], expected_audience: String::new(), @@ -2343,103 +2242,578 @@ mod tests { Ok(()) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: HttpRequestView<'_>, - ) -> Result { - let mut record = self.record.lock().expect("replacement transfer record"); - let invocation = record.invocations; - record.invocations += 1; - - if invocation == 0 { - let replacement = TEST_REPLACEMENT_BODY.to_vec(); - record.returned_body = Some(replacement.as_ptr().addr()); - let mut result = allow_result(); - result.body = replacement; - result.has_body = true; - Ok(result) - } else { - record.second_body = Some(request.body().as_ptr().addr()); - record.second_body_bytes = request.body().to_vec(); - Ok(allow_result()) + mut requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + use openshell_core::proto::{ + HttpRequestBodyMode, HttpRequestEventResult, HttpRequestPreflightInspect, + HttpRequestPreflightResult, http_request_event, http_request_event_result, + http_request_preflight_result, + }; + + let terminal = self.terminal.lock().expect("terminal sender lock").take(); + let (sender, receiver) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + let Some(openshell_core::proto::HttpRequestEvent { + event: Some(http_request_event::Event::Preflight(_)), + }) = requests.recv().await + else { + return; + }; + if sender + .send(Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::StreamBytes as i32, + header_mutations: Vec::new(), + }, + )), + ..Default::default() + }, + )), + })) + .await + .is_err() + { + return; + } + while let Some(event) = requests.recv().await { + if let Some(http_request_event::Event::SessionEnd(end)) = event.event { + if let (Some(terminal), Ok(reason)) = ( + terminal, + openshell_core::proto::MiddlewareSessionEndReason::try_from(end.reason), + ) { + let _ = terminal.send(reason); + } + break; + } + } + }); + Ok(Box::pin(ReceiverStream::new(receiver))) + } + } + + #[tonic::async_trait] + impl InProcessMiddleware for OwnedStreamService { + async fn describe(&self) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/owned-stream".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: MAX_HTTP_REQUEST_STREAM_UNIT_BYTES as u64, + request_timeout: None, + }], + expected_audience: String::new(), } } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn open_http_request_pre_credentials( + &self, + mut requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + use openshell_core::proto::{ + HttpRequestBodyFinalize, HttpRequestBodyMode, HttpRequestBodyOutput, + HttpRequestBodyResult, HttpRequestBodyTakeOwnership, HttpRequestEventResult, + HttpRequestPreflightInspect, HttpRequestPreflightResult, HttpRequestTrailersResult, + http_request_body_result, http_request_event, http_request_event_result, + http_request_preflight_result, + }; + + let invalid_finalization = self.invalid_finalization; + let (sender, receiver) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + while let Some(event) = requests.recv().await { + let result = match event.event { + Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::OwnedStreamBytes as i32, + header_mutations: Vec::new(), + }, + )), + ..Default::default() + }, + )), + }, + Some(http_request_event::Event::Body(body)) => { + let sequence = body.sequence; + let end_of_stream = body.end_of_stream; + let result = HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyResult( + HttpRequestBodyResult { + sequence, + action: Some( + http_request_body_result::Action::TakeOwnership( + HttpRequestBodyTakeOwnership {}, + ), + ), + ..Default::default() + }, + )), + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + if end_of_stream { + if sender + .send(Ok(HttpRequestEventResult { + result: Some( + http_request_event_result::Result::BodyOutput( + HttpRequestBodyOutput { + sequence: 1, + data: b"signed".to_vec(), + }, + ), + ), + })) + .await + .is_err() + { + break; + } + if sender + .send(Ok(HttpRequestEventResult { + result: Some( + http_request_event_result::Result::BodyFinalize( + HttpRequestBodyFinalize { + through_input_sequence: if invalid_finalization + { + sequence + 1 + } else { + sequence + }, + through_output_sequence: 1, + reason_code: "owned_complete".into(), + findings: vec![Finding { + r#type: "test.owned".into(), + label: "Owned transformation complete" + .into(), + count: 1, + confidence: "high".into(), + severity: "informational".into(), + }], + metadata: HashMap::from([( + "mode".into(), + "owned".into(), + )]), + ..Default::default() + }, + ), + ), + })) + .await + .is_err() + { + break; + } + } + continue; + } + Some(http_request_event::Event::Trailers(_)) => HttpRequestEventResult { + result: Some(http_request_event_result::Result::TrailersResult( + HttpRequestTrailersResult::default(), + )), + }, + Some(http_request_event::Event::SessionEnd(_)) | None => break, + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + }); + Ok(Box::pin(ReceiverStream::new(receiver))) + } + } + + fn owned_entry(on_error: OnError) -> ChainEntry { + ChainEntry { + name: "owned".into(), + implementation: "test/owned-stream".into(), + order: 0, + config: prost_types::Struct::default(), + on_error, + } + } + + #[tokio::test] + async fn owned_request_stream_replaces_a_body_larger_than_the_former_unary_limit() { + let runner = ChainRunner::new(Arc::new(OwnedStreamService { + invalid_finalization: false, + })); + let body = "x".repeat(4 * 1024 * 1024 + 17); + let mut preflight = runner + .preflight_http_request( + &[owned_entry(OnError::FailClosed)], + HttpRequestPreflightInput { + context: RequestContext::default(), + target: HttpRequestTarget::default(), + declared_body_length: Some(body.len() as u64), + headers: Vec::new(), + connection_nominated_headers: Vec::new(), + }, + ) + .await + .expect("owned request preflight"); + let mut session = preflight.session.take().expect("owned request session"); + for chunk in body.as_bytes().chunks(session.stream_unit_limit()) { + assert!(session.push_body(chunk.to_vec()).await.unwrap().is_empty()); + } + let finish = session + .finish(Vec::new()) + .await + .expect("owned finalization"); + assert_eq!(finish.body_units.concat(), b"signed"); + assert!(finish.body_transformed); + assert_eq!(finish.findings[0].middleware, "owned"); + assert_eq!(finish.findings[0].finding.r#type, "test.owned"); + assert_eq!(finish.metadata["owned"]["mode"], "owned"); + } + + #[tokio::test] + async fn owned_request_stream_requires_fail_closed_and_valid_finalization() { + let fail_open_runner = ChainRunner::new(Arc::new(OwnedStreamService { + invalid_finalization: false, + })); + let fail_open = fail_open_runner + .evaluate(&[owned_entry(OnError::FailOpen)], input("original")) + .await + .expect("fail-open evaluation"); + assert!(fail_open.allowed); + assert_eq!(fail_open.body, b"original"); + assert!(fail_open.applied[0].failed); + + let buffered_fail_closed = ChainRunner::new(Arc::new(OwnedStreamService { + invalid_finalization: false, + })) + .evaluate(&[owned_entry(OnError::FailClosed)], input("original")) + .await + .expect("buffered compatibility evaluation"); + assert!(!buffered_fail_closed.allowed); + assert_eq!( + buffered_fail_closed.reason, + "middleware_failed: request_body_mode_not_permitted" + ); + + let invalid_runner = ChainRunner::new(Arc::new(OwnedStreamService { + invalid_finalization: true, + })); + let mut preflight = invalid_runner + .preflight_http_request( + &[owned_entry(OnError::FailClosed)], + HttpRequestPreflightInput { + context: RequestContext::default(), + target: HttpRequestTarget::default(), + declared_body_length: Some(8), + headers: Vec::new(), + connection_nominated_headers: Vec::new(), + }, + ) + .await + .expect("invalid finalization preflight"); + let mut session = preflight.session.take().expect("owned request session"); + assert!( + session + .push_body(b"original".to_vec()) + .await + .unwrap() + .is_empty() + ); + let invalid = session.finish(Vec::new()).await.unwrap_err(); + assert_eq!( + invalid.reason, + "middleware_failed: invalid_owned_finalization" + ); + assert!(invalid.denial.is_none()); + } + + #[tokio::test] + async fn request_stream_stages_receive_one_empty_final_unit_only() { + let recorder = Arc::new(StreamSequenceRecorder::default()); + let runner = ChainRunner::new(Arc::new(StreamSequenceService { + recorder: Arc::clone(&recorder), + findings_per_body: false, + })); + let entries = [ + ChainEntry { + name: "first".into(), + implementation: "test/stream-sequence".into(), + order: 1, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ChainEntry { + name: "second".into(), + implementation: "test/stream-sequence".into(), + order: 2, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }, + ]; + let preflight = runner + .preflight_http_request( + &entries, + HttpRequestPreflightInput { + context: RequestContext::default(), + target: HttpRequestTarget::default(), + declared_body_length: Some(3), + headers: Vec::new(), + connection_nominated_headers: Vec::new(), + }, + ) + .await + .expect("request preflight"); + let mut session = preflight.session.expect("streaming request session"); + assert_eq!( + session.push_body(b"abc".to_vec()).await.unwrap(), + vec![b"abc".to_vec()] + ); + let finish = session.finish(Vec::new()).await.expect("request finish"); + assert!(finish.body_units.is_empty()); + + let streams = recorder.streams.lock().expect("stream recorder lock"); + assert_eq!( + streams.as_slice(), + [vec![(3, false), (0, true)], vec![(3, false), (0, true)]] + ); + } + + #[tokio::test] + async fn request_stream_bounds_findings_across_body_results() { + let runner = ChainRunner::new(Arc::new(StreamSequenceService { + recorder: Arc::new(StreamSequenceRecorder::default()), + findings_per_body: true, + })); + let entry = ChainEntry { + name: "finding-stream".into(), + implementation: "test/stream-sequence".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let preflight = runner + .preflight_http_request( + &[entry], + HttpRequestPreflightInput { + context: RequestContext::default(), + target: HttpRequestTarget::default(), + declared_body_length: Some(33), + headers: Vec::new(), + connection_nominated_headers: Vec::new(), + }, + ) + .await + .expect("request preflight"); + let mut session = preflight.session.expect("streaming request session"); + for _ in 0..MAX_MIDDLEWARE_FINDINGS_PER_STAGE { + assert_eq!( + session.push_body(vec![b'x']).await.unwrap(), + vec![vec![b'x']] + ); + } + let failure = session + .push_body(vec![b'x']) + .await + .expect_err("the aggregate finding limit must fail closed"); + + assert_eq!( + failure.reason, + "middleware_failed: request_findings_over_capacity" + ); + assert_eq!( + failure.diagnostics.findings.len(), + MAX_MIDDLEWARE_FINDINGS_PER_STAGE + ); + } + + #[tokio::test] + async fn request_stream_bounds_retained_invocation_records() { + let runner = ChainRunner::new(Arc::new(StreamSequenceService { + recorder: Arc::new(StreamSequenceRecorder::default()), + findings_per_body: false, + })); + let entry = ChainEntry { + name: "long-stream".into(), + implementation: "test/stream-sequence".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let preflight = runner + .preflight_http_request( + &[entry], + HttpRequestPreflightInput { + context: RequestContext::default(), + target: HttpRequestTarget::default(), + declared_body_length: Some(1025), + headers: Vec::new(), + connection_nominated_headers: Vec::new(), + }, + ) + .await + .expect("request preflight"); + let mut session = preflight.session.expect("streaming request session"); + for _ in 0..1025 { + assert_eq!( + session.push_body(vec![b'x']).await.unwrap(), + vec![vec![b'x']] + ); + } + let finish = session.finish(Vec::new()).await.expect("request finish"); + + assert_eq!(finish.invocations.len(), 1024); + assert!(!finish.invocations[0].failed); + assert!(finish.invocations[0].input_size > 1); } #[tokio::test] - async fn replacement_body_allocation_moves_through_next_stage_and_outcome() { - let service = Arc::new(ReplacementTransferService { - record: std::sync::Mutex::new(ReplacementTransferRecord::default()), - }); - let runner = ChainRunner::new(service.clone()); + async fn dropped_request_session_sends_best_effort_cancellation() { + let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel(); + let runner = ChainRunner::new(Arc::new(CancellationRecordingService { + terminal: std::sync::Mutex::new(Some(terminal_tx)), + })); + let entry = ChainEntry { + name: "recorder".into(), + implementation: "test/cancellation-recorder".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: OnError::FailClosed, + }; + let preflight = runner + .preflight_http_request( + &[entry], + HttpRequestPreflightInput { + context: RequestContext::default(), + target: HttpRequestTarget::default(), + declared_body_length: Some(1), + headers: Vec::new(), + connection_nominated_headers: Vec::new(), + }, + ) + .await + .expect("request preflight"); + + drop(preflight.session.expect("streaming request session")); + let reason = tokio::time::timeout(Duration::from_secs(1), terminal_rx) + .await + .expect("cancellation delivery timeout") + .expect("cancellation sender dropped"); + assert_eq!( + reason, + openshell_core::proto::MiddlewareSessionEndReason::Cancellation + ); + } + + #[tokio::test] + async fn body_denial_preserves_diagnostics_and_ends_every_open_stage() { + use openshell_core::proto::MiddlewareSessionEndReason; + + let (session_ends_tx, mut session_ends_rx) = tokio::sync::mpsc::unbounded_channel(); + let endpoints: Vec> = vec![ + Arc::new(BodyDenialService { + manifest_name: "test/body-denier".into(), + deny: true, + session_ends: session_ends_tx.clone(), + }), + Arc::new(BodyDenialService { + manifest_name: "test/body-observer".into(), + deny: false, + session_ends: session_ends_tx, + }), + ]; + let runner = ChainRunner::from_registry( + MiddlewareRegistry::connect_services(endpoints, Vec::new()) + .await + .expect("connect body middleware services"), + ); let entries = [ ChainEntry { - name: "replace".into(), - implementation: "test/replacement-transfer".into(), + name: "denier".into(), + implementation: "test/body-denier".into(), order: 0, config: prost_types::Struct::default(), on_error: OnError::FailClosed, }, ChainEntry { - name: "observe".into(), - implementation: "test/replacement-transfer".into(), - order: 10, + name: "observer".into(), + implementation: "test/body-observer".into(), + order: 1, config: prost_types::Struct::default(), on_error: OnError::FailClosed, }, ]; - - let outcome = runner - .evaluate(&entries, input("original-body")) + let preflight = runner + .preflight_http_request( + &entries, + HttpRequestPreflightInput { + context: RequestContext::default(), + target: HttpRequestTarget::default(), + declared_body_length: Some(7), + headers: Vec::new(), + connection_nominated_headers: Vec::new(), + }, + ) .await - .expect("evaluate replacement transfer chain"); - let record = service.record.lock().expect("replacement transfer record"); - let returned_body = record - .returned_body - .expect("first-stage replacement pointer"); - - assert!(outcome.allowed); - assert_eq!(record.invocations, 2); - assert_eq!(record.second_body_bytes, TEST_REPLACEMENT_BODY); - assert_eq!(record.second_body, Some(returned_body)); - assert_eq!(outcome.body, TEST_REPLACEMENT_BODY); - assert_eq!(outcome.body.as_ptr().addr(), returned_body); - } - - /// An in-process service that yields forever so the runtime must enforce - /// the binding timeout around borrowed validation and evaluation futures. - struct PendingInProcessService; - - #[tonic::async_trait] - impl InProcessMiddleware for PendingInProcessService { - async fn describe(&self) -> MiddlewareManifest { - MiddlewareManifest { - name: "test/pending".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, - request_timeout: Some(proto_duration("10ms")), - }], - expected_audience: String::new(), - } - } + .expect("request preflight"); + let mut session = preflight.session.expect("streaming request session"); + let failure = session + .push_body(b"blocked".to_vec()) + .await + .expect_err("body denial must stop the request"); - async fn validate_config( - &self, - _middleware_name: &str, - _config: &prost_types::Struct, - ) -> Result<()> { - std::future::pending().await - } + assert_eq!(failure.reason, "middleware_denied:denier:body_denied"); + assert_eq!( + failure + .denial + .as_ref() + .map(|denial| denial.config_name.as_str()), + Some("denier") + ); + assert_eq!(failure.diagnostics.invocations.len(), 1); + assert_eq!( + failure.diagnostics.invocations[0].outcome, + HttpRequestInvocationOutcome::BlockRequest + ); + assert_eq!(failure.diagnostics.findings.len(), 1); + assert_eq!(failure.diagnostics.findings[0].middleware, "denier"); + assert_eq!( + failure.diagnostics.findings[0].finding.r#type, + "test.request-body" + ); + assert_eq!(failure.diagnostics.metadata["denier"]["rule"], "deny-body"); - async fn evaluate_http_request( - &self, - _request: HttpRequestView<'_>, - ) -> Result { - std::future::pending().await + let mut ended = BTreeMap::new(); + for _ in 0..2 { + let (name, reason) = + tokio::time::timeout(Duration::from_secs(1), session_ends_rx.recv()) + .await + .expect("session end delivery timeout") + .expect("session end sender dropped"); + ended.insert(name, reason); } + assert_eq!( + ended.get("test/body-denier"), + Some(&MiddlewareSessionEndReason::MiddlewareDenial) + ); + assert_eq!( + ended.get("test/body-observer"), + Some(&MiddlewareSessionEndReason::MiddlewareDenial) + ); + assert!(session_ends_rx.try_recv().is_err()); } #[tokio::test] @@ -2629,14 +3003,8 @@ mod tests { #[tokio::test] async fn injected_services_cannot_duplicate_middleware_names() { - let first: Arc = Arc::new(BorrowedRecordingService { - manifest_name: "openshell/test".into(), - received: std::sync::Mutex::new(Vec::new()), - }); - let second: Arc = Arc::new(BorrowedRecordingService { - manifest_name: "openshell/test".into(), - received: std::sync::Mutex::new(Vec::new()), - }); + let first: Arc = Arc::new(PendingInProcessService); + let second: Arc = Arc::new(PendingInProcessService); let error = MiddlewareRegistry::connect_services(vec![first, second], Vec::new()) .await @@ -2651,24 +3019,15 @@ mod tests { /// A mock middleware that returns a fixed, caller-supplied result for every /// evaluation. Used to exercise chain behavior the built-in cannot produce /// (explicit deny, metadata, findings, unsafe header mutations). + #[derive(Clone)] struct ScriptedService { manifest_name: String, max_body_bytes: u64, - result: openshell_core::proto::HttpRequestResult, + result: TestRequestResult, } #[tonic::async_trait] - impl SupervisorMiddleware for ScriptedService { - type EvaluateWebSocketSessionStream = WebSocketResponseStream; - - async fn evaluate_web_socket_session( - &self, - _request: Request>, - ) -> std::result::Result, tonic::Status> - { - Err(tonic::Status::unimplemented("HTTP-only test middleware")) - } - + impl SupervisorMiddlewareEndpoint for ScriptedService { async fn describe( &self, _request: Request<()>, @@ -2696,26 +3055,35 @@ mod tests { })) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - _request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Ok(tonic::Response::new(self.result.clone())) + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + let result = self.result.clone(); + Ok(open_test_request_stream(requests, move |_| { + constant_request_plan(result) + })) } } - struct SlowService { - delay: Duration, - binding_timeout: Option, - } - #[tonic::async_trait] - impl SupervisorMiddleware for SlowService { + impl SupervisorMiddleware for ScriptedService { type EvaluateWebSocketSessionStream = WebSocketResponseStream; + async fn describe( + &self, + request: Request<()>, + ) -> std::result::Result, tonic::Status> { + SupervisorMiddlewareEndpoint::describe(self, request).await + } + + async fn validate_config( + &self, + request: Request, + ) -> std::result::Result, tonic::Status> { + SupervisorMiddlewareEndpoint::validate_config(self, request).await + } + async fn evaluate_web_socket_session( &self, _request: Request>, @@ -2723,7 +3091,43 @@ mod tests { { Err(tonic::Status::unimplemented("HTTP-only test middleware")) } + } + + #[tonic::async_trait] + impl HttpRequestPreCredentials for ScriptedService { + type EvaluateStream = HttpRequestResultStream; + + async fn evaluate( + &self, + request: Request>, + ) -> std::result::Result, tonic::Status> { + let (sender, receiver) = tokio::sync::mpsc::channel(4); + let mut requests = request.into_inner(); + tokio::spawn(async move { + while let Some(request) = requests.next().await { + let Ok(request) = request else { + break; + }; + if sender.send(request).await.is_err() { + break; + } + } + }); + let result = self.result.clone(); + Ok(tonic::Response::new(open_test_request_stream( + receiver, + move |_| constant_request_plan(result), + ))) + } + } + + struct SlowService { + delay: Duration, + binding_timeout: Option, + } + #[tonic::async_trait] + impl SupervisorMiddlewareEndpoint for SlowService { async fn describe( &self, _request: Request<()>, @@ -2752,15 +3156,14 @@ mod tests { })) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - _request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { tokio::time::sleep(self.delay).await; - Ok(tonic::Response::new(allow_result())) + Ok(open_test_request_stream(requests, |_| { + constant_request_plan(allow_result()) + })) } } @@ -2772,17 +3175,7 @@ mod tests { } #[tonic::async_trait] - impl SupervisorMiddleware for TwoStageService { - type EvaluateWebSocketSessionStream = WebSocketResponseStream; - - async fn evaluate_web_socket_session( - &self, - _request: Request>, - ) -> std::result::Result, tonic::Status> - { - Err(tonic::Status::unimplemented("HTTP-only test middleware")) - } - + impl SupervisorMiddlewareEndpoint for TwoStageService { async fn describe( &self, _request: Request<()>, @@ -2810,30 +3203,34 @@ mod tests { })) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - let evaluation = request.into_inner(); - let mut result = allow_result(); - if evaluation.config.as_ref().is_some_and(|config| { - config.fields.get("transform").is_some_and(|value| { - matches!( - value.kind.as_ref(), - Some(prost_types::value::Kind::BoolValue(true)) - ) - }) - }) { - result.body = b"TRANSFORMED".to_vec(); - result.has_body = true; - } else { - self.second_ran - .store(true, std::sync::atomic::Ordering::SeqCst); - } - Ok(tonic::Response::new(result)) + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + let second_ran = Arc::clone(&self.second_ran); + Ok(open_test_request_stream(requests, move |preflight| { + let transform = preflight.config.as_ref().is_some_and(|config| { + config.fields.get("transform").is_some_and(|value| { + matches!( + value.kind.as_ref(), + Some(prost_types::value::Kind::BoolValue(true)) + ) + }) + }); + TestRequestPlan { + preflight: allow_result(), + body: Some(Box::new(move |_body, _evaluation| { + let mut result = allow_result(); + if transform { + result.body = b"TRANSFORMED".to_vec(); + result.has_body = true; + } else { + second_ran.store(true, std::sync::atomic::Ordering::SeqCst); + } + result + })), + } + })) } } @@ -2843,7 +3240,7 @@ mod tests { // must stop there: the second stage never runs, so it never sees a // payload the policy would reject. let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let service: Arc = Arc::new(TwoStageService { + let service: Arc = Arc::new(TwoStageService { second_ran: Arc::clone(&second_ran), }); let runner = ChainRunner::new_protobuf_for_tests(service); @@ -2905,7 +3302,7 @@ mod tests { // A validator that accepts every body lets both stages run; the second // stage sees the first stage's output. let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let service: Arc = Arc::new(TwoStageService { + let service: Arc = Arc::new(TwoStageService { second_ran: Arc::clone(&second_ran), }); let runner = ChainRunner::new_protobuf_for_tests(service); @@ -2957,7 +3354,7 @@ mod tests { #[tokio::test] async fn per_stage_validator_error_becomes_structured_denial() { let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let service: Arc = Arc::new(TwoStageService { + let service: Arc = Arc::new(TwoStageService { second_ran: Arc::clone(&second_ran), }); let runner = ChainRunner::new_protobuf_for_tests(service); @@ -3013,7 +3410,7 @@ mod tests { assert!(!second_ran.load(std::sync::atomic::Ordering::SeqCst)); } - fn scripted_service(result: openshell_core::proto::HttpRequestResult) -> ScriptedService { + fn scripted_service(result: TestRequestResult) -> ScriptedService { ScriptedService { manifest_name: BUILTIN_REGEX.into(), max_body_bytes: 256 * 1024, @@ -3021,8 +3418,8 @@ mod tests { } } - fn allow_result() -> openshell_core::proto::HttpRequestResult { - openshell_core::proto::HttpRequestResult { + fn allow_result() -> TestRequestResult { + TestRequestResult { decision: Decision::Allow as i32, reason: String::new(), body: Vec::new(), @@ -3037,22 +3434,12 @@ mod tests { /// A middleware that records every evaluation it receives and allows the /// request, for asserting what the supervisor actually sends to services. struct RecordingService { - validated: std::sync::Mutex>, - received: std::sync::Mutex>, + validated: Arc>>, + received: Arc>>, } #[tonic::async_trait] - impl SupervisorMiddleware for RecordingService { - type EvaluateWebSocketSessionStream = WebSocketResponseStream; - - async fn evaluate_web_socket_session( - &self, - _request: Request>, - ) -> std::result::Result, tonic::Status> - { - Err(tonic::Status::unimplemented("HTTP-only test middleware")) - } - + impl SupervisorMiddlewareEndpoint for RecordingService { async fn describe( &self, _request: Request<()>, @@ -3084,18 +3471,20 @@ mod tests { })) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - self.received - .lock() - .expect("recording lock") - .push(request.into_inner()); - Ok(tonic::Response::new(allow_result())) + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + let received = Arc::clone(&self.received); + Ok(open_test_request_stream(requests, move |_| { + TestRequestPlan { + preflight: allow_result(), + body: Some(Box::new(move |_body, evaluation| { + received.lock().expect("recording lock").push(evaluation); + allow_result() + })), + } + })) } } @@ -3103,11 +3492,11 @@ mod tests { /// state produced by all preceding stages. struct HeaderChainService { second_action: ExistingHeaderAction, - received: std::sync::Mutex>, + received: Arc>>, } struct InProcessHeaderChainService { - received: std::sync::Mutex>>, + received: Arc>>>, } #[tonic::async_trait] @@ -3134,40 +3523,36 @@ mod tests { Ok(()) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: HttpRequestView<'_>, - ) -> Result { - let invocation = { - let mut received = self.received.lock().expect("in-process header chain lock"); - let invocation = received.len(); - received.push(request.headers().to_vec()); - invocation - }; - let mut result = allow_result(); - if invocation == 0 { - result.header_mutations.push(write_header( - "cache-control", - "no-store", - ExistingHeaderAction::Overwrite, - )); - } - Ok(result) + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + let received = Arc::clone(&self.received); + Ok(open_test_request_stream(requests, move |preflight| { + let invocation = { + let mut received = received.lock().expect("in-process header chain lock"); + let invocation = received.len(); + received.push(preflight.headers); + invocation + }; + let mut result = allow_result(); + if invocation == 0 { + result.header_mutations.push(write_header( + "cache-control", + "no-store", + ExistingHeaderAction::Overwrite, + )); + } + TestRequestPlan { + preflight: result, + body: None, + } + })) } } #[tonic::async_trait] - impl SupervisorMiddleware for HeaderChainService { - type EvaluateWebSocketSessionStream = WebSocketResponseStream; - - async fn evaluate_web_socket_session( - &self, - _request: Request>, - ) -> std::result::Result, tonic::Status> - { - Err(tonic::Status::unimplemented("HTTP-only test middleware")) - } - + impl SupervisorMiddlewareEndpoint for HeaderChainService { async fn describe( &self, _request: Request<()>, @@ -3195,35 +3580,47 @@ mod tests { })) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - let evaluation = request.into_inner(); - let invocation = { - let mut received = self.received.lock().expect("header chain lock"); - let invocation = received.len(); - received.push(evaluation); - invocation - }; - let mut result = allow_result(); - if invocation == 0 { - result.header_mutations.push(write_header( - "cache-control", - "first", - ExistingHeaderAction::Overwrite, - )); - } else if invocation == 1 { - result.header_mutations.push(write_header( - "cache-control", - "second", - self.second_action, - )); - } - Ok(tonic::Response::new(result)) + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + let received = Arc::clone(&self.received); + let second_action = self.second_action; + Ok(open_test_request_stream(requests, move |preflight| { + let evaluation = TestRequestEvaluation { + phase: SupervisorMiddlewarePhase::PreCredentials as i32, + context: preflight.context, + config: preflight.config, + target: preflight.target, + headers: preflight.headers, + body: Vec::new(), + middleware_name: preflight.middleware_name, + }; + let invocation = { + let mut received = received.lock().expect("header chain lock"); + let invocation = received.len(); + received.push(evaluation); + invocation + }; + let mut result = allow_result(); + if invocation == 0 { + result.header_mutations.push(write_header( + "cache-control", + "first", + ExistingHeaderAction::Overwrite, + )); + } else if invocation == 1 { + result.header_mutations.push(write_header( + "cache-control", + "second", + second_action, + )); + } + TestRequestPlan { + preflight: result, + body: None, + } + })) } } @@ -3236,7 +3633,7 @@ mod tests { ] { let service = Arc::new(HeaderChainService { second_action: action, - received: std::sync::Mutex::new(Vec::new()), + received: Arc::new(std::sync::Mutex::new(Vec::new())), }); let runner = ChainRunner::new_protobuf_for_tests(service.clone()); let entries = [ @@ -3282,7 +3679,7 @@ mod tests { #[tokio::test] async fn in_process_request_middleware_writes_end_to_end_header_without_namespace() { let service = Arc::new(InProcessHeaderChainService { - received: std::sync::Mutex::new(Vec::new()), + received: Arc::new(std::sync::Mutex::new(Vec::new())), }); let runner = ChainRunner::new(service.clone()); let entries = [ @@ -3329,10 +3726,10 @@ mod tests { // inspection differential. The service must see each entry in wire // order. let service = Arc::new(RecordingService { - validated: std::sync::Mutex::new(Vec::new()), - received: std::sync::Mutex::new(Vec::new()), + validated: Arc::new(std::sync::Mutex::new(Vec::new())), + received: Arc::new(std::sync::Mutex::new(Vec::new())), }); - let recorder: Arc = service.clone(); + let recorder: Arc = service.clone(); let runner = ChainRunner::new_protobuf_for_tests(recorder); let validation_config = prost_types::Struct { fields: std::iter::once(( @@ -3386,7 +3783,8 @@ mod tests { let received = service.received.lock().expect("recorded evaluations"); assert_eq!(received.len(), 1); - assert_eq!(outcome.body.as_ptr().addr(), original_body); + assert_eq!(outcome.body, b"payload"); + assert_ne!(outcome.body.as_ptr().addr(), original_body); assert_ne!(received[0].body.as_ptr().addr(), original_body); assert_eq!(received[0].body, b"payload"); assert_eq!( @@ -3433,7 +3831,7 @@ mod tests { } async fn registry_with_external( - service: Arc, + service: Arc, registration: SupervisorMiddlewareService, ) -> MiddlewareRegistry { let builtin_service = services() @@ -3474,7 +3872,7 @@ mod tests { Arc::new(MiddlewareServiceState { attachment_name: Some(registration_name.clone()), service: MiddlewareDispatch::Grpc(remote::GrpcMiddlewareService::from_service( - Arc::new(GeneratedMiddlewareEndpoint { service }), + service, )), manifest: manifest_cell, diagnostic_policy: MiddlewareDiagnosticPolicy::Normalize, @@ -3656,7 +4054,7 @@ mod tests { assert!(!outcome.allowed); assert_eq!( outcome.reason, - "middleware_failed: request_body_over_capacity" + "middleware_failed: request_body_mode_not_permitted" ); assert_eq!(outcome.applied.len(), 2); assert!( @@ -3759,6 +4157,30 @@ mod tests { .expect("HTTP response pre-return binding is supported"); } + #[test] + fn external_manifest_rejects_post_credentials_binding() { + let registration = external_registration(4096); + let manifest = MiddlewareManifest { + name: "example/credential-visible".into(), + service_version: "test".into(), + bindings: vec![MiddlewareBinding { + operation: SupervisorMiddlewareOperation::HttpRequest as i32, + phase: SupervisorMiddlewarePhase::PostCredentials as i32, + max_payload_bytes: 4096, + request_timeout: None, + }], + expected_audience: String::new(), + }; + + let error = validate_external_manifest(®istration, &manifest, 4096, true) + .expect_err("external middleware must never observe resolved credentials"); + assert!( + error + .to_string() + .contains("reserved for trusted in-process") + ); + } + #[test] fn manifest_accepts_forward_websocket_binding_and_reserves_return_phase() { let binding = |phase| MiddlewareBinding { @@ -4033,12 +4455,14 @@ mod tests { .expect("bind test middleware"); let address = listener.local_addr().expect("test middleware address"); let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); + let service = ScriptedService { + manifest_name: "test/middleware".into(), + max_body_bytes: 4096, + result: allow_result(), + }; let server = tonic::transport::Server::builder() - .add_service(SupervisorMiddlewareServer::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: allow_result(), - })) + .add_service(SupervisorMiddlewareServer::new(service.clone())) + .add_service(HttpRequestPreCredentialsServer::new(service)) .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { let _ = shutdown_rx.await; }); @@ -4128,32 +4552,38 @@ mod tests { severity: "medium".into(), }) .collect(); + let service = ScriptedService { + manifest_name: "test/middleware".into(), + max_body_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, + result: TestRequestResult { + reason: "r".repeat(MAX_MIDDLEWARE_REASON_BYTES - 128), + reason_code: "r".repeat(MAX_MIDDLEWARE_REASON_CODE_BYTES), + body: vec![b'x'; MAX_MIDDLEWARE_PAYLOAD_BYTES], + has_body: true, + header_mutations: vec![write_header( + "x-openshell-middleware-envelope", + &"h".repeat(headers::MAX_HEADER_MUTATION_BYTES - 128), + ExistingHeaderAction::Append, + )], + findings: response_findings, + metadata: std::iter::once(( + "diagnostic".into(), + "m".repeat(MAX_MIDDLEWARE_METADATA_BYTES - 128), + )) + .collect(), + ..allow_result() + }, + }; let server = tonic::transport::Server::builder() .add_service( - SupervisorMiddlewareServer::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - result: openshell_core::proto::HttpRequestResult { - reason: "r".repeat(MAX_MIDDLEWARE_REASON_BYTES - 128), - reason_code: "r".repeat(MAX_MIDDLEWARE_REASON_CODE_BYTES), - body: vec![b'x'; MAX_MIDDLEWARE_PAYLOAD_BYTES], - has_body: true, - header_mutations: vec![write_header( - "x-openshell-middleware-envelope", - &"h".repeat(headers::MAX_HEADER_MUTATION_BYTES - 128), - ExistingHeaderAction::Append, - )], - findings: response_findings, - metadata: std::iter::once(( - "diagnostic".into(), - "m".repeat(MAX_MIDDLEWARE_METADATA_BYTES - 128), - )) - .collect(), - ..allow_result() - }, - }) - .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) - .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), + SupervisorMiddlewareServer::new(service.clone()) + .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) + .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), + ) + .add_service( + HttpRequestPreCredentialsServer::new(service) + .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) + .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), ) .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { let _ = shutdown_rx.await; @@ -4226,7 +4656,7 @@ mod tests { let service = Arc::new(ScriptedService { manifest_name: "test/middleware".into(), max_body_bytes: 4096, - result: openshell_core::proto::HttpRequestResult { + result: TestRequestResult { decision: Decision::Deny as i32, reason: format!("denied body={secret}\nFINDING:FORGED"), reason_code: "content_match".into(), @@ -4277,13 +4707,12 @@ mod tests { #[tokio::test] async fn invalid_reason_code_is_a_middleware_failure() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( - openshell_core::proto::HttpRequestResult { + let runner = + ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { decision: Decision::Deny as i32, reason_code: "Secret value!".into(), ..allow_result() - }, - ))); + }))); let outcome = runner .evaluate( &[entry("content-guard", OnError::FailClosed)], @@ -4295,7 +4724,7 @@ mod tests { assert!(!outcome.allowed); assert_eq!( outcome.reason, - "middleware_failed: response_reason_code_invalid" + "middleware_failed: request_reason_code_invalid" ); assert!(outcome.denial.is_none()); assert!(outcome.applied[0].failed); @@ -4308,7 +4737,7 @@ mod tests { let service = Arc::new(ScriptedService { manifest_name: "test/middleware".into(), max_body_bytes: 4096, - result: openshell_core::proto::HttpRequestResult { + result: TestRequestResult { header_mutations: vec![write_header( &format!("x-openshell-middleware-invalid\n{secret}"), "value", @@ -4347,7 +4776,7 @@ mod tests { let service = Arc::new(ScriptedService { manifest_name: "test/middleware".into(), max_body_bytes: 4096, - result: openshell_core::proto::HttpRequestResult { + result: TestRequestResult { header_mutations: vec![write_header( "x-api-key", placeholder, @@ -4409,7 +4838,7 @@ mod tests { let service = Arc::new(ScriptedService { manifest_name: "test/middleware".into(), max_body_bytes: 4096, - result: openshell_core::proto::HttpRequestResult { + result: TestRequestResult { header_mutations: vec![mutation], ..allow_result() }, @@ -4446,7 +4875,7 @@ mod tests { let service = Arc::new(ScriptedService { manifest_name: "test/middleware".into(), max_body_bytes: 4096, - result: openshell_core::proto::HttpRequestResult { + result: TestRequestResult { findings: vec![Finding::default(); MAX_MIDDLEWARE_FINDINGS_PER_STAGE + 1], ..allow_result() }, @@ -4476,7 +4905,7 @@ mod tests { if !allowed { assert_eq!( outcome.reason, - "middleware_failed: response_findings_over_capacity" + "middleware_failed: request_findings_over_capacity" ); } } @@ -4487,7 +4916,7 @@ mod tests { let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { manifest_name: "test/middleware".into(), max_body_bytes: 4096, - result: openshell_core::proto::HttpRequestResult { + result: TestRequestResult { findings: vec![ Finding { r#type: "example.finding".into(), @@ -4534,13 +4963,12 @@ mod tests { #[tokio::test] async fn deny_decision_short_circuits_chain() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( - openshell_core::proto::HttpRequestResult { + let runner = + ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { decision: Decision::Deny as i32, reason: "blocked_by_policy".into(), ..allow_result() - }, - ))); + }))); let outcome = runner .evaluate( &[ @@ -4569,8 +4997,8 @@ mod tests { #[tokio::test] async fn deny_decision_ignores_unsafe_mutations_under_fail_open() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( - openshell_core::proto::HttpRequestResult { + let runner = + ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { decision: Decision::Deny as i32, reason: "blocked_by_policy".into(), header_mutations: vec![write_header( @@ -4579,8 +5007,7 @@ mod tests { ExistingHeaderAction::Append, )], ..allow_result() - }, - ))); + }))); let outcome = runner .evaluate(&[entry("guard", OnError::FailOpen)], input("hello")) @@ -4600,7 +5027,7 @@ mod tests { let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { manifest_name: BUILTIN_REGEX.into(), max_body_bytes: 4, - result: openshell_core::proto::HttpRequestResult { + result: TestRequestResult { decision: Decision::Deny as i32, reason: "blocked_by_policy".into(), body: b"too large".to_vec(), @@ -4625,8 +5052,8 @@ mod tests { #[tokio::test] async fn metadata_and_findings_are_namespaced_per_config() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( - openshell_core::proto::HttpRequestResult { + let runner = + ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { findings: vec![Finding { r#type: "pii.email".into(), label: "email address".into(), @@ -4637,8 +5064,7 @@ mod tests { metadata: std::iter::once(("sensitivity".to_string(), "high".to_string())) .collect(), ..allow_result() - }, - ))); + }))); let outcome = runner .evaluate( &[ @@ -4663,7 +5089,7 @@ mod tests { } fn unsafe_header_service() -> ScriptedService { - scripted_service(openshell_core::proto::HttpRequestResult { + scripted_service(TestRequestResult { header_mutations: vec![ write_header( "x-openshell-middleware-safe", @@ -4721,7 +5147,7 @@ mod tests { let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { manifest_name: BUILTIN_REGEX.into(), max_body_bytes: 4, - result: openshell_core::proto::HttpRequestResult { + result: TestRequestResult { body: b"too large".to_vec(), has_body: true, ..allow_result() @@ -4746,7 +5172,7 @@ mod tests { assert!(!closed_outcome.allowed); assert_eq!( closed_outcome.reason, - "middleware_failed: response_body_over_capacity" + "middleware_failed: body_replacement_over_capacity" ); assert!(closed_outcome.applied[0].failed); } @@ -4777,19 +5203,18 @@ mod tests { assert!(!closed_outcome.allowed); assert_eq!( closed_outcome.reason, - "middleware_failed: request_body_over_capacity" + "middleware_failed: request_body_mode_not_permitted" ); assert!(closed_outcome.applied[0].failed); } #[tokio::test] async fn unspecified_decision_uses_fail_closed() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service( - openshell_core::proto::HttpRequestResult { + let runner = + ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { decision: Decision::Unspecified as i32, ..allow_result() - }, - ))); + }))); let outcome = runner .evaluate(&[entry("redact", OnError::FailClosed)], input("hello")) @@ -4799,7 +5224,7 @@ mod tests { assert!(!outcome.allowed); assert_eq!( outcome.reason, - "middleware_failed: invalid_response_decision" + "middleware_failed: missing_preflight_action" ); assert!(outcome.applied[0].failed); } @@ -4925,7 +5350,7 @@ mod tests { } } }); - Box::pin(tokio_stream::wrappers::ReceiverStream::new(responses_rx)) + Box::pin(ReceiverStream::new(responses_rx)) } } @@ -4984,18 +5409,6 @@ mod tests { })) } - async fn evaluate_http_request( - &self, - _request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Err(tonic::Status::unimplemented( - "WebSocket-only test middleware", - )) - } - async fn evaluate_web_socket_session( &self, request: Request>, @@ -5023,25 +5436,11 @@ mod tests { SupervisorMiddleware::validate_config(self, request).await } - async fn evaluate_http_request( - &self, - request: Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - SupervisorMiddleware::evaluate_http_request(self, request).await - } - async fn open_websocket_session( &self, receiver: tokio::sync::mpsc::Receiver, ) -> std::result::Result { - Ok( - self.websocket_stream( - tokio_stream::wrappers::ReceiverStream::new(receiver).map(Ok), - ), - ) + Ok(self.websocket_stream(ReceiverStream::new(receiver).map(Ok))) } } diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index 80049b69dc..80f43ffd69 100644 --- a/crates/openshell-supervisor-middleware/src/remote.rs +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -3,14 +3,15 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::middleware::{ - HttpRequestView, HttpResponseResultStream, SupervisorMiddlewareEndpoint, + HttpRequestResultStream, HttpResponseResultStream, SupervisorMiddlewareEndpoint, WebSocketResponseStream, }; +use openshell_core::proto::middleware::v1::http_request_pre_credentials_client::HttpRequestPreCredentialsClient; use openshell_core::proto::middleware::v1::http_response_pre_return_client::HttpResponsePreReturnClient; use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; use openshell_core::proto::{ - HttpRequestEvaluation, HttpRequestResult, HttpResponseEvent, MiddlewareManifest, - ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, + HttpRequestEvent, HttpResponseEvent, MiddlewareManifest, ValidateConfigRequest, + ValidateConfigResponse, WebSocketSessionEvent, }; use openshell_extension_core::{ BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, ExtensionServerTrust, @@ -25,8 +26,7 @@ use crate::MIDDLEWARE_GRPC_MESSAGE_BYTES; type ExtensionChannel = InterceptedService; -/// Adapts the borrowed runtime request contract to the owned protobuf service -/// contract only when dispatch crosses a gRPC-shaped boundary. +/// Adapts transport-neutral middleware streams to a gRPC-shaped boundary. #[derive(Clone)] pub struct GrpcMiddlewareService { service: Arc, @@ -78,21 +78,13 @@ impl GrpcMiddlewareService { .await } - /// Materialize an owned protobuf evaluation immediately before transport. - pub async fn evaluate_http_request( + /// Open a remote HTTP request pre-credentials stream through the adapter. + pub async fn open_http_request_pre_credentials( &self, - request: HttpRequestView<'_>, - ) -> std::result::Result, Status> { + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { self.service - .evaluate_http_request(Request::new(HttpRequestEvaluation { - phase: request.phase() as i32, - context: Some(request.context().clone()), - config: Some(request.config().clone()), - target: Some(request.target().clone()), - headers: request.headers().to_vec(), - body: request.body().to_vec(), - middleware_name: request.middleware_name().to_string(), - })) + .open_http_request_pre_credentials(receiver) .await } @@ -116,6 +108,7 @@ impl GrpcMiddlewareService { #[derive(Clone)] pub struct RemoteMiddlewareService { client: SupervisorMiddlewareClient, + request_client: HttpRequestPreCredentialsClient, response_client: HttpResponsePreReturnClient, } @@ -147,6 +140,9 @@ impl RemoteMiddlewareService { client: SupervisorMiddlewareClient::new(channel.clone()) .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), + request_client: HttpRequestPreCredentialsClient::new(channel.clone()) + .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) + .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), response_client: HttpResponsePreReturnClient::new(channel) .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), @@ -172,12 +168,18 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService { client.validate_config(request).await } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - request: Request, - ) -> std::result::Result, Status> { - let mut client = self.client.clone(); - client.evaluate_http_request(request).await + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { + let mut client = self.request_client.clone(); + let responses = client + .evaluate(Request::new(tokio_stream::wrappers::ReceiverStream::new( + receiver, + ))) + .await? + .into_inner(); + Ok(Box::pin(responses)) } async fn open_websocket_session( diff --git a/crates/openshell-supervisor-middleware/src/request.rs b/crates/openshell-supervisor-middleware/src/request.rs new file mode 100644 index 0000000000..33d83452e6 --- /dev/null +++ b/crates/openshell-supervisor-middleware/src/request.rs @@ -0,0 +1,1917 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! HTTP request pre-credentials 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, HeaderMutation, HttpHeader, HttpRequestBodyMode, HttpRequestBodyOutput, + HttpRequestBodyUnit, HttpRequestEvent, HttpRequestEventResult, HttpRequestPreflight, + HttpRequestTarget, HttpRequestTrailers, MiddlewareSessionEnd, MiddlewareSessionEndReason, + RequestContext, http_request_body_result, http_request_body_skip_remaining, + http_request_body_transform, http_request_body_unit, http_request_event, + http_request_event_result, http_request_preflight_result, +}; + +use super::{ + ChainEntry, ChainRunner, DescribedChainEntry, EXTERNAL_FINDING_LABEL, + MAX_MIDDLEWARE_CHAIN_TIMEOUT, MAX_MIDDLEWARE_CONTEXT_BYTES, MAX_MIDDLEWARE_FINDING_BYTES, + MAX_MIDDLEWARE_FINDINGS_PER_STAGE, MAX_MIDDLEWARE_HEADER_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; +const SESSION_END_TIMEOUT: Duration = Duration::from_millis(10); +const MAX_RECORDED_REQUEST_INVOCATIONS: usize = 1024; + +/// Largest normalized request body unit sent in streaming modes. +pub const MAX_HTTP_REQUEST_STREAM_UNIT_BYTES: usize = 64 * 1024; +/// Largest input or output representation an owned stage may retain. +/// +/// The limit bounds logical storage, not memory. Implementations are expected +/// to spool large representations instead of retaining them in RAM. +pub const MAX_HTTP_REQUEST_DEFERRED_BYTES: usize = 1024 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub struct HttpRequestPreflightInput { + pub context: RequestContext, + pub target: HttpRequestTarget, + pub declared_body_length: Option, + pub headers: Vec, + pub connection_nominated_headers: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HttpRequestInvocationOutcome { + Skip, + BlockRequest, + HeadersOnly, + WholeBody, + Stream, + OwnedStream, + Trailers, + PassThrough, + Transform, + SkipRemaining, + TakeOwnership, + FailOpen, + FailClosed, +} + +#[derive(Debug, Clone)] +pub struct HttpRequestInvocation { + pub config_name: String, + pub implementation: String, + pub outcome: HttpRequestInvocationOutcome, + 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 HttpRequestPreflightOutcome { + pub allowed: bool, + pub reason: String, + pub denial: Option, + pub headers: Vec, + /// Ordered mutations to replay against the original raw header block. + pub header_mutations: Vec, + pub session: Option, + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, + pub session_capacity_exhausted: bool, +} + +#[derive(Debug)] +pub struct HttpRequestMiddlewareFailure { + pub reason: String, + pub denial: Option, + pub diagnostics: Box, +} + +impl std::fmt::Display for HttpRequestMiddlewareFailure { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(&self.reason) + } +} + +impl std::error::Error for HttpRequestMiddlewareFailure {} + +impl HttpRequestMiddlewareFailure { + fn with_diagnostics(mut self, mut diagnostics: HttpRequestDiagnostics) -> Self { + let existing = *std::mem::take(&mut self.diagnostics); + diagnostics.findings.extend(existing.findings); + diagnostics.metadata.extend(existing.metadata); + diagnostics.invocations.extend(existing.invocations); + self.diagnostics = Box::new(diagnostics); + self + } +} + +#[derive(Debug)] +pub struct HttpRequestFinish { + pub body_units: Vec>, + pub trailers: Vec, + pub body_transformed: bool, + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, +} + +#[derive(Debug, Default)] +pub struct HttpRequestDiagnostics { + pub findings: Vec, + pub metadata: BTreeMap>, + pub invocations: Vec, +} + +struct HttpRequestStageTransport { + sender: mpsc::Sender, + responses: super::HttpRequestResultStream, + terminal_sent: bool, +} + +impl HttpRequestStageTransport { + async fn end(mut self, reason: MiddlewareSessionEndReason) { + let _ = tokio::time::timeout(SESSION_END_TIMEOUT, self.end_inner(reason)).await; + } + + async fn end_inner(&mut self, reason: MiddlewareSessionEndReason) { + if self.sender.send(session_end_event(reason)).await.is_err() { + self.terminal_sent = true; + return; + } + self.terminal_sent = true; + self.drain().await; + } + + async fn drain(&mut self) { + while self.responses.next().await.is_some() {} + } +} + +impl Drop for HttpRequestStageTransport { + fn drop(&mut self) { + if !self.terminal_sent { + let _ = self + .sender + .try_send(session_end_event(MiddlewareSessionEndReason::Cancellation)); + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum StageMode { + HeadersOnly, + WholeBody, + Stream, + OwnedStream, +} + +struct HttpRequestStage { + entry: DescribedChainEntry, + transport: Option, + mode: StageMode, + next_sequence: u64, + whole_body: Vec, + owned_input_bytes: usize, + owned_output_bytes: usize, + next_output_sequence: u64, + finding_count: usize, +} + +impl HttpRequestStage { + fn is_active(&self) -> bool { + self.transport.is_some() + } + + async fn end(&mut self, reason: MiddlewareSessionEndReason) { + if let Some(transport) = self.transport.take() { + transport.end(reason).await; + } + } +} + +pub struct HttpRequestSession { + runner: ChainRunner, + stages: Vec, + findings: Vec, + metadata: BTreeMap>, + invocations: Vec, + session_admission: Option, + connection_nominated_headers: Vec, + body_transformed: bool, +} + +impl HttpRequestSession { + pub fn take_diagnostics(&mut self) -> HttpRequestDiagnostics { + HttpRequestDiagnostics { + 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 stream_unit_limit(&self) -> usize { + self.stages + .iter() + .filter(|stage| { + stage.is_active() + && matches!(stage.mode, StageMode::Stream | StageMode::OwnedStream) + }) + .map(|stage| { + stage + .entry + .max_payload_bytes + .clamp(1, MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) + }) + .min() + .unwrap_or(MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) + } + + /// Whether any active stage must see the complete representation before + /// the supervisor may disclose request bytes upstream. + #[must_use] + pub fn requires_withholding(&self) -> bool { + self.stages.iter().any(|stage| { + stage.is_active() && matches!(stage.mode, StageMode::WholeBody | StageMode::OwnedStream) + }) + } + + /// Process one non-final normalized body unit through the active chain. + pub async fn push_body( + &mut self, + data: Vec, + ) -> Result>, HttpRequestMiddlewareFailure> { + if data.is_empty() { + return Err(Self::failure("request_stream_unit_empty", None)); + } + if data.len() > self.stream_unit_limit() { + return Err(Self::failure("request_stream_unit_over_capacity", None)); + } + let _work = self + .runner + .reserve_middleware_work_admission() + .await + .map_err(|error| Self::failure(&format!("middleware_failed: {error}"), None))?; + let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; + match self.process_units_from(0, vec![data], deadline).await { + Ok(units) => Ok(units), + Err(error) => { + let reason = if error.denial.is_some() { + MiddlewareSessionEndReason::MiddlewareDenial + } else { + MiddlewareSessionEndReason::MiddlewareFailure + }; + self.end_all(reason).await; + Err(error.with_diagnostics(self.take_diagnostics())) + } + } + } + + /// Finalize all body stages and collect output in memory. + /// + /// Network relays should prefer [`Self::finish_to`] so owned output remains + /// bounded by channel and storage backpressure. + pub async fn finish( + self, + trailers: Vec, + ) -> Result { + let (sender, mut receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let finish = self.finish_to(trailers, sender); + let collect = async move { + let mut units = Vec::new(); + while let Some(unit) = receiver.recv().await { + units.push(unit); + } + units + }; + let (finish, units) = tokio::join!(finish, collect); + let mut finish = finish?; + finish.body_units = units; + Ok(finish) + } + + /// Finalize all stages while sending normalized output through a bounded + /// channel. The receiver controls backpressure and may spool to storage. + pub async fn finish_to( + mut self, + mut trailers: Vec, + output: mpsc::Sender>, + ) -> Result { + let _work = match self.runner.reserve_middleware_work_admission().await { + Ok(work) => work, + Err(error) => { + return Err(HttpRequestMiddlewareFailure { + reason: format!("middleware_failed: {error}"), + denial: None, + diagnostics: Box::new(self.take_diagnostics()), + }); + } + }; + let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; + for index in 0..self.stages.len() { + let result = self.finish_stage_to(index, deadline, &output).await; + if let Err(failure) = result { + self.end_all(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + return Err(failure.with_diagnostics(self.take_diagnostics())); + } + } + + 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(); + drop(output); + Ok(HttpRequestFinish { + body_units: Vec::new(), + trailers, + body_transformed: 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>, HttpRequestMiddlewareFailure> { + // An empty replacement deletes the current unit. Streaming and owned + // stages receive only nonempty data units followed by their own single + // empty end-of-stream unit during `finish_stage_to`. + units.retain(|unit| !unit.is_empty()); + for index in start..self.stages.len() { + let mut next = Vec::new(); + for unit in units { + let chunk_limit = if matches!( + self.stages[index].mode, + StageMode::Stream | StageMode::OwnedStream + ) { + self.stages[index] + .entry + .max_payload_bytes + .min(MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) + } else { + unit.len().max(1) + }; + 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| { + !matches!(stage.mode, StageMode::WholeBody | StageMode::OwnedStream) + }) + { + break; + } + } + Ok(units) + } + + async fn process_stage_unit( + &mut self, + index: usize, + data: Vec, + deadline: Instant, + ) -> Result>, HttpRequestMiddlewareFailure> { + if !self.stages[index].is_active() || self.stages[index].mode == StageMode::HeadersOnly { + return Ok(vec![data]); + } + if self.stages[index].mode == StageMode::WholeBody { + if self.stages[index] + .whole_body + .len() + .saturating_add(data.len()) + > self.stages[index].entry.max_payload_bytes + { + let mut original = std::mem::take(&mut self.stages[index].whole_body); + original.extend_from_slice(&data); + return self + .handle_stage_failure(index, "whole_body_over_capacity", None, original) + .await; + } + self.stages[index].whole_body.extend_from_slice(&data); + return Ok(Vec::new()); + } + + let sequence = self.stages[index].next_sequence; + self.stages[index].next_sequence += 1; + if self.stages[index].mode == StageMode::OwnedStream + && self.stages[index] + .owned_input_bytes + .saturating_add(data.len()) + > MAX_HTTP_REQUEST_DEFERRED_BYTES + { + return self + .handle_stage_failure( + index, + "owned_input_over_capacity", + Some(sequence), + Vec::new(), + ) + .await; + } + let event = body_event(sequence, data.clone(), false); + let result = match exchange(&mut self.stages[index], event, deadline).await { + Ok(result) => result, + Err(reason) => { + return self + .handle_stage_failure(index, &reason, Some(sequence), data) + .await; + } + }; + self.apply_body_result(index, result, sequence, data, false) + .await + } + + async fn finish_stage_to( + &mut self, + index: usize, + deadline: Instant, + output: &mpsc::Sender>, + ) -> Result<(), HttpRequestMiddlewareFailure> { + if !self.stages[index].is_active() || self.stages[index].mode == StageMode::HeadersOnly { + return Ok(()); + } + let mode = self.stages[index].mode; + let data = if mode == StageMode::WholeBody { + std::mem::take(&mut self.stages[index].whole_body) + } else { + Vec::new() + }; + let sequence = self.stages[index].next_sequence; + self.stages[index].next_sequence += 1; + let result = match exchange( + &mut self.stages[index], + body_event(sequence, data.clone(), true), + deadline, + ) + .await + { + Ok(result) => result, + Err(reason) => { + let original = if mode == StageMode::OwnedStream { + Vec::new() + } else { + data + }; + let recovered = self + .handle_stage_failure(index, &reason, Some(sequence), original) + .await?; + return self + .emit_downstream(index + 1, recovered, deadline, output) + .await; + } + }; + let recovered = self + .apply_body_result(index, result, sequence, data, true) + .await?; + self.emit_downstream(index + 1, recovered, deadline, output) + .await?; + + if mode == StageMode::OwnedStream && self.stages[index].is_active() { + self.drain_owned_output(index, sequence, deadline, output) + .await?; + } + Ok(()) + } + + async fn drain_owned_output( + &mut self, + index: usize, + final_input_sequence: u64, + deadline: Instant, + output: &mpsc::Sender>, + ) -> Result<(), HttpRequestMiddlewareFailure> { + loop { + let result = next_result(&mut self.stages[index], deadline) + .await + .map_err(|reason| { + Self::failure(&format!("middleware_failed: {reason}"), Some(index)) + })?; + match result.result { + Some(http_request_event_result::Result::BodyOutput(body_output)) => { + self.validate_owned_output(index, &body_output)?; + let downstream = self + .process_units_from(index + 1, vec![body_output.data], deadline) + .await?; + Self::send_output(output, downstream).await?; + } + Some(http_request_event_result::Result::BodyFinalize(finalize)) => { + let stage = &self.stages[index]; + let final_output_sequence = stage.next_output_sequence.saturating_sub(1); + if finalize.through_input_sequence != final_input_sequence + || finalize.through_output_sequence != final_output_sequence + { + return Err(Self::failure( + "middleware_failed: invalid_owned_finalization", + Some(index), + )); + } + if let Err(reason) = validate_stage_diagnostics( + self.stages[index].finding_count, + &finalize.reason, + &finalize.reason_code, + &finalize.findings, + &finalize.metadata, + ) { + return Err(Self::failure( + &format!("middleware_failed: {reason}"), + Some(index), + )); + } + let reason_code = + (!finalize.reason_code.is_empty()).then(|| finalize.reason_code.clone()); + self.stages[index].finding_count += finalize.findings.len(); + collect_diagnostics( + &self.stages[index].entry, + finalize.findings, + finalize.metadata, + &mut self.findings, + &mut self.metadata, + ); + record_request_invocation( + &mut self.invocations, + body_invocation( + &self.stages[index], + HttpRequestInvocationOutcome::Transform, + final_input_sequence, + self.stages[index].owned_input_bytes, + self.stages[index].owned_output_bytes, + reason_code, + ), + ); + return Ok(()); + } + _ => { + return Err(Self::failure( + "middleware_failed: unexpected_owned_output_result", + Some(index), + )); + } + } + } + } + + fn validate_owned_output( + &mut self, + index: usize, + output: &HttpRequestBodyOutput, + ) -> Result<(), HttpRequestMiddlewareFailure> { + let stage = &mut self.stages[index]; + if output.sequence != stage.next_output_sequence { + return Err(HttpRequestMiddlewareFailure { + reason: "middleware_failed: invalid_owned_output_sequence".into(), + denial: None, + diagnostics: Box::default(), + }); + } + if output.data.len() > stage.entry.max_payload_bytes { + return Err(HttpRequestMiddlewareFailure { + reason: "middleware_failed: owned_output_unit_over_capacity".into(), + denial: None, + diagnostics: Box::default(), + }); + } + stage.owned_output_bytes = stage.owned_output_bytes.saturating_add(output.data.len()); + if stage.owned_output_bytes > MAX_HTTP_REQUEST_DEFERRED_BYTES { + return Err(HttpRequestMiddlewareFailure { + reason: "middleware_failed: owned_output_over_capacity".into(), + denial: None, + diagnostics: Box::default(), + }); + } + stage.next_output_sequence += 1; + Ok(()) + } + + async fn emit_downstream( + &mut self, + start: usize, + units: Vec>, + deadline: Instant, + output: &mpsc::Sender>, + ) -> Result<(), HttpRequestMiddlewareFailure> { + let units = self.process_units_from(start, units, deadline).await?; + Self::send_output(output, units).await + } + + async fn send_output( + output: &mpsc::Sender>, + units: Vec>, + ) -> Result<(), HttpRequestMiddlewareFailure> { + for unit in units { + output + .send(unit) + .await + .map_err(|_| HttpRequestMiddlewareFailure { + reason: "request_output_consumer_closed".into(), + denial: None, + diagnostics: Box::default(), + })?; + } + Ok(()) + } + + async fn apply_body_result( + &mut self, + index: usize, + result: HttpRequestEventResult, + sequence: u64, + original: Vec, + end_of_stream: bool, + ) -> Result>, HttpRequestMiddlewareFailure> { + let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { + return self + .handle_stage_failure(index, "unexpected_body_result", Some(sequence), original) + .await; + }; + if result.sequence != sequence { + return self + .handle_stage_failure(index, "invalid_body_sequence", Some(sequence), original) + .await; + } + if let Err(reason) = validate_stage_diagnostics( + self.stages[index].finding_count, + &result.reason, + &result.reason_code, + &result.findings, + &result.metadata, + ) { + return self + .handle_stage_failure(index, reason, Some(sequence), original) + .await; + } + let reason_code = (!result.reason_code.is_empty()).then(|| result.reason_code.clone()); + self.stages[index].finding_count += result.findings.len(); + collect_diagnostics( + &self.stages[index].entry, + result.findings, + result.metadata, + &mut self.findings, + &mut self.metadata, + ); + + let owned = self.stages[index].mode == StageMode::OwnedStream; + let action = result.action; + if owned { + if !matches!( + action, + Some(http_request_body_result::Action::TakeOwnership(_)) + ) { + return self + .handle_stage_failure( + index, + "owned_stage_did_not_take_ownership", + Some(sequence), + Vec::new(), + ) + .await; + } + self.stages[index].owned_input_bytes = self.stages[index] + .owned_input_bytes + .saturating_add(original.len()); + self.body_transformed = true; + record_request_invocation( + &mut self.invocations, + body_invocation( + &self.stages[index], + HttpRequestInvocationOutcome::TakeOwnership, + sequence, + original.len(), + 0, + reason_code, + ), + ); + return Ok(Vec::new()); + } + + let (outcome, replacement, skip_remaining) = match action { + Some(http_request_body_result::Action::PassThrough(_)) => ( + HttpRequestInvocationOutcome::PassThrough, + original.clone(), + false, + ), + Some(http_request_body_result::Action::Transform(transform)) => { + let Some(http_request_body_transform::Replacement::Data(data)) = + transform.replacement + else { + return self + .handle_stage_failure( + index, + "missing_body_replacement", + Some(sequence), + original, + ) + .await; + }; + if data.len() > self.stages[index].entry.max_payload_bytes { + return self + .handle_stage_failure( + index, + "body_replacement_over_capacity", + Some(sequence), + original, + ) + .await; + } + self.body_transformed = true; + (HttpRequestInvocationOutcome::Transform, data, false) + } + Some(http_request_body_result::Action::SkipRemaining(skip)) => { + let replacement = match skip.current { + Some(http_request_body_skip_remaining::Current::PassThrough(_)) => { + original.clone() + } + Some(http_request_body_skip_remaining::Current::Transform(transform)) => { + let Some(http_request_body_transform::Replacement::Data(data)) = + transform.replacement + else { + return self + .handle_stage_failure( + index, + "missing_body_replacement", + Some(sequence), + original, + ) + .await; + }; + if data.len() > self.stages[index].entry.max_payload_bytes { + return self + .handle_stage_failure( + index, + "body_replacement_over_capacity", + Some(sequence), + original, + ) + .await; + } + self.body_transformed = true; + data + } + None => { + return self + .handle_stage_failure( + index, + "missing_skip_remaining_action", + Some(sequence), + original, + ) + .await; + } + }; + ( + HttpRequestInvocationOutcome::SkipRemaining, + replacement, + true, + ) + } + Some(http_request_body_result::Action::BlockRequest(_)) => { + let denial = super::MiddlewareDenial { + config_name: self.stages[index].entry.entry.name.clone(), + reason_code: reason_code.clone(), + }; + record_request_invocation( + &mut self.invocations, + body_invocation( + &self.stages[index], + HttpRequestInvocationOutcome::BlockRequest, + sequence, + original.len(), + 0, + reason_code, + ), + ); + self.end_all(MiddlewareSessionEndReason::MiddlewareDenial) + .await; + return Err(HttpRequestMiddlewareFailure { + reason: middleware_denial_reason( + &denial.config_name, + denial.reason_code.as_deref(), + ), + denial: Some(denial), + diagnostics: Box::default(), + }); + } + Some(http_request_body_result::Action::TakeOwnership(_)) | None => { + return self + .handle_stage_failure(index, "invalid_body_action", Some(sequence), original) + .await; + } + }; + record_request_invocation( + &mut self.invocations, + body_invocation( + &self.stages[index], + outcome, + sequence, + original.len(), + replacement.len(), + reason_code, + ), + ); + if skip_remaining { + self.stages[index] + .end(MiddlewareSessionEndReason::StageSkipped) + .await; + self.release_admission_if_idle(); + } else if end_of_stream { + // Keep the transport open for the trailers event. + } + Ok(vec![replacement]) + } + + async fn process_trailers( + &mut self, + mut trailers: Vec, + deadline: Instant, + ) -> Result, HttpRequestMiddlewareFailure> { + for index in 0..self.stages.len() { + if !self.stages[index].is_active() || self.stages[index].mode == StageMode::HeadersOnly + { + continue; + } + let event = HttpRequestEvent { + event: Some(http_request_event::Event::Trailers(HttpRequestTrailers { + 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 Some(http_request_event_result::Result::TrailersResult(result)) = result.result + else { + trailers = self + .handle_trailer_failure(index, "unexpected_trailers_result", trailers) + .await?; + continue; + }; + if let Err(reason) = validate_stage_diagnostics( + self.stages[index].finding_count, + &result.reason, + &result.reason_code, + &result.findings, + &result.metadata, + ) { + trailers = self.handle_trailer_failure(index, reason, trailers).await?; + continue; + } + self.stages[index].finding_count += result.findings.len(); + let updated = match headers::apply( + headers::HeaderAuthority::RequestTrailers, + &trailers, + &self.connection_nominated_headers, + &result.trailer_mutations, + ) { + Ok(updated) => updated, + Err(error) => { + let reason = self.stages[index].entry.service.as_ref().map_or_else( + || error.to_string(), + |service| { + service + .diagnostic_policy + .header_mutation_error_reason(&error) + }, + ); + trailers = self + .handle_trailer_failure(index, &reason, trailers) + .await?; + continue; + } + }; + collect_diagnostics( + &self.stages[index].entry, + result.findings, + result.metadata, + &mut self.findings, + &mut self.metadata, + ); + record_request_invocation( + &mut self.invocations, + HttpRequestInvocation { + config_name: self.stages[index].entry.entry.name.clone(), + implementation: self.stages[index].entry.entry.implementation.clone(), + outcome: HttpRequestInvocationOutcome::Trailers, + sequence: None, + input_size: encoded_header_bytes(&trailers), + output_size: Some(encoded_header_bytes(&updated)), + failed: false, + stage_disabled: false, + reason_code: (!result.reason_code.is_empty()).then_some(result.reason_code), + failure_category: None, + }, + ); + trailers = updated; + } + Ok(trailers) + } + + async fn handle_trailer_failure( + &mut self, + index: usize, + reason: &str, + original: Vec, + ) -> Result, HttpRequestMiddlewareFailure> { + let stage = &mut self.stages[index]; + let fail_open = stage.entry.on_error() == OnError::FailOpen; + record_request_invocation( + &mut self.invocations, + HttpRequestInvocation { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + outcome: if fail_open { + HttpRequestInvocationOutcome::FailOpen + } else { + HttpRequestInvocationOutcome::FailClosed + }, + sequence: None, + input_size: encoded_header_bytes(&original), + output_size: None, + failed: true, + stage_disabled: true, + reason_code: None, + failure_category: Some(request_failure_category(reason).into()), + }, + ); + stage + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + self.release_admission_if_idle(); + if fail_open { + Ok(original) + } else { + Err(HttpRequestMiddlewareFailure { + reason: format!("middleware_failed: {reason}"), + denial: None, + diagnostics: Box::default(), + }) + } + } + + async fn handle_stage_failure( + &mut self, + index: usize, + reason: &str, + sequence: Option, + original: Vec, + ) -> Result>, HttpRequestMiddlewareFailure> { + let stage = &mut self.stages[index]; + let fail_open = + stage.entry.on_error() == OnError::FailOpen && stage.mode != StageMode::OwnedStream; + record_request_invocation( + &mut self.invocations, + HttpRequestInvocation { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + outcome: if fail_open { + HttpRequestInvocationOutcome::FailOpen + } else { + HttpRequestInvocationOutcome::FailClosed + }, + sequence, + input_size: original.len(), + output_size: None, + failed: true, + stage_disabled: true, + reason_code: None, + failure_category: Some(request_failure_category(reason).into()), + }, + ); + stage + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + self.release_admission_if_idle(); + if fail_open { + Ok(vec![original]) + } else { + Err(HttpRequestMiddlewareFailure { + reason: format!("middleware_failed: {reason}"), + denial: None, + diagnostics: Box::default(), + }) + } + } + + async fn end_all(&mut self, reason: MiddlewareSessionEndReason) { + for stage in &mut self.stages { + stage.end(reason).await; + } + self.session_admission.take(); + } + + fn release_admission_if_idle(&mut self) { + if self.stages.iter().all(|stage| !stage.is_active()) { + self.session_admission.take(); + } + } + + fn failure(reason: &str, _index: Option) -> HttpRequestMiddlewareFailure { + HttpRequestMiddlewareFailure { + reason: reason.to_string(), + // Transport and protocol failures are not authoritative service + // denials. The caller still fails closed when policy requires it, + // but must not present the failure as an accepted block decision. + denial: None, + diagnostics: Box::default(), + } + } +} + +impl ChainRunner { + pub async fn preflight_http_request( + &self, + entries: &[ChainEntry], + input: HttpRequestPreflightInput, + ) -> miette::Result { + let described = self.describe_chain(entries).await?; + self.preflight_described_http_request(described, input) + .await + } + + pub async fn preflight_described_http_request( + &self, + described: Vec, + input: HttpRequestPreflightInput, + ) -> miette::Result { + self.preflight_described_http_request_with_owned(described, input, true) + .await + } + + /// Open a request stream with explicit control over storage-backed owned + /// mode. Complete-body compatibility callers disable owned mode because + /// their result is necessarily materialized in memory. + pub(crate) async fn preflight_described_http_request_with_owned( + &self, + described: Vec, + input: HttpRequestPreflightInput, + allow_owned: bool, + ) -> miette::Result { + if described.is_empty() { + return Ok(empty_preflight_outcome(input.headers)); + } + if validate_preflight_input(&input).is_err() { + return Ok(preflight_input_failure( + &described, + input.headers, + "request_input_over_capacity", + )); + } + let session_admission = match self.try_reserve_middleware_session() { + MiddlewareSessionAdmission::Admitted(admission) => admission, + MiddlewareSessionAdmission::AtCapacity => { + return Ok(session_capacity_exhausted(described, input.headers)); + } + }; + let work_admission = self.reserve_middleware_work().await?; + let _work = match work_admission { + super::MiddlewareWorkAdmissionOutcome::Admitted(admission) => admission, + super::MiddlewareWorkAdmissionOutcome::QueueExhausted => { + return Ok(session_capacity_exhausted(described, input.headers)); + } + }; + let mut headers = input.headers.clone(); + let mut header_mutations = Vec::new(); + 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, + header_mutations, + reason, + findings, + metadata, + invocations, + )); + } + continue; + }; + let permitted_modes = permitted_body_modes(&input, &entry, allow_owned); + let (sender, receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let preflight = HttpRequestPreflight { + context: Some(input.context.clone()), + target: Some(input.target.clone()), + 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_modes.clone(), + max_deferred_bytes: if entry.on_error() == OnError::FailClosed { + MAX_HTTP_REQUEST_DEFERRED_BYTES as u64 + } else { + 0 + }, + declared_body_length: input.declared_body_length, + }; + let timeout = entry.timeout; + let opened = tokio::time::timeout(timeout, async { + sender + .send(HttpRequestEvent { + event: Some(http_request_event::Event::Preflight(preflight)), + }) + .await + .map_err(|_| tonic::Status::unavailable("middleware request stream closed"))?; + let mut responses = service + .service + .open_http_request_pre_credentials(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 = 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, + header_mutations, + 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, + header_mutations, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + let mut current_stage = HttpRequestStage { + entry: entry.clone(), + transport: Some(HttpRequestStageTransport { + sender, + responses, + terminal_sent: false, + }), + mode: StageMode::HeadersOnly, + next_sequence: 1, + whole_body: Vec::new(), + owned_input_bytes: 0, + owned_output_bytes: 0, + next_output_sequence: 1, + finding_count: 0, + }; + let Some(http_request_event_result::Result::PreflightResult(result)) = response.result + else { + if let Some(reason) = handle_opened_preflight_failure( + &entry, + &mut current_stage, + &mut stages, + "unexpected_preflight_result", + &mut invocations, + ) + .await + { + return Ok(failed_preflight_outcome( + headers, + header_mutations, + reason, + findings, + metadata, + invocations, + )); + } + continue; + }; + if let Err(reason) = validate_diagnostics( + &result.reason, + &result.reason_code, + &result.findings, + &result.metadata, + ) { + if let Some(reason) = handle_opened_preflight_failure( + &entry, + &mut current_stage, + &mut stages, + reason, + &mut invocations, + ) + .await + { + return Ok(failed_preflight_outcome( + headers, + header_mutations, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + let reason_code = (!result.reason_code.is_empty()).then(|| result.reason_code.clone()); + current_stage.finding_count = result.findings.len(); + let result_findings = result.findings; + let result_metadata = result.metadata; + match result.action { + Some(http_request_preflight_result::Action::Skip(_)) => { + collect_diagnostics( + &entry, + result_findings, + result_metadata, + &mut findings, + &mut metadata, + ); + invocations.push(preflight_invocation( + &entry, + HttpRequestInvocationOutcome::Skip, + reason_code, + )); + current_stage + .end(MiddlewareSessionEndReason::StageSkipped) + .await; + } + Some(http_request_preflight_result::Action::Inspect(inspect)) => { + let mode = match validate_inspect(&inspect, &permitted_modes) { + Ok(mode) => mode, + Err(reason) => { + if let Some(reason) = handle_opened_preflight_failure( + &entry, + &mut current_stage, + &mut stages, + reason, + &mut invocations, + ) + .await + { + return Ok(failed_preflight_outcome( + headers, + header_mutations, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + let updated = match headers::apply( + headers::HeaderAuthority::Request, + &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) = handle_opened_preflight_failure( + &entry, + &mut current_stage, + &mut stages, + &reason, + &mut invocations, + ) + .await + { + return Ok(failed_preflight_outcome( + headers, + header_mutations, + reason, + findings, + metadata, + invocations, + )); + } + continue; + } + }; + headers = updated; + header_mutations.extend(inspect.header_mutations); + collect_diagnostics( + &entry, + result_findings, + result_metadata, + &mut findings, + &mut metadata, + ); + invocations.push(preflight_invocation( + &entry, + match mode { + StageMode::HeadersOnly => HttpRequestInvocationOutcome::HeadersOnly, + StageMode::WholeBody => HttpRequestInvocationOutcome::WholeBody, + StageMode::Stream => HttpRequestInvocationOutcome::Stream, + StageMode::OwnedStream => HttpRequestInvocationOutcome::OwnedStream, + }, + reason_code, + )); + current_stage.mode = mode; + if mode == StageMode::HeadersOnly { + current_stage.end(MiddlewareSessionEndReason::Normal).await; + } else { + stages.push(current_stage); + } + } + Some(http_request_preflight_result::Action::BlockRequest(_)) => { + collect_diagnostics( + &entry, + result_findings, + result_metadata, + &mut findings, + &mut metadata, + ); + invocations.push(preflight_invocation( + &entry, + HttpRequestInvocationOutcome::BlockRequest, + reason_code.clone(), + )); + stages.push(current_stage); + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareDenial).await; + let denial = super::MiddlewareDenial { + config_name: entry.entry.name.clone(), + reason_code, + }; + return Ok(HttpRequestPreflightOutcome { + allowed: false, + reason: middleware_denial_reason( + &denial.config_name, + denial.reason_code.as_deref(), + ), + denial: Some(denial), + headers, + header_mutations, + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }); + } + None => { + if let Some(reason) = handle_opened_preflight_failure( + &entry, + &mut current_stage, + &mut stages, + "missing_preflight_action", + &mut invocations, + ) + .await + { + return Ok(failed_preflight_outcome( + headers, + header_mutations, + reason, + findings, + metadata, + invocations, + )); + } + } + } + } + + let session = (!stages.is_empty()).then(|| HttpRequestSession { + runner: self.clone(), + stages, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + session_admission: Some(session_admission), + connection_nominated_headers: input.connection_nominated_headers, + body_transformed: false, + }); + Ok(HttpRequestPreflightOutcome { + allowed: true, + reason: String::new(), + denial: None, + headers, + header_mutations, + session, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }) + } +} + +async fn exchange( + stage: &mut HttpRequestStage, + event: HttpRequestEvent, + 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()), + } +} + +async fn next_result( + stage: &mut HttpRequestStage, + 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, transport.responses.next()).await { + Ok(Some(Ok(result))) => Ok(result), + Ok(Some(Err(error))) => Err(stage + .entry + .service + .as_ref() + .map_or(MiddlewareDiagnosticPolicy::Preserve, |service| { + service.diagnostic_policy + }) + .error_reason(&error)), + Ok(None) => Err("middleware_result_stream_closed".into()), + Err(_) => Err("middleware_timeout".into()), + } +} + +fn body_event(sequence: u64, data: Vec, end_of_stream: bool) -> HttpRequestEvent { + HttpRequestEvent { + event: Some(http_request_event::Event::Body(HttpRequestBodyUnit { + sequence, + payload: Some(http_request_body_unit::Payload::Data(data)), + end_of_stream, + })), + } +} + +fn session_end_event(reason: MiddlewareSessionEndReason) -> HttpRequestEvent { + HttpRequestEvent { + event: Some(http_request_event::Event::SessionEnd( + MiddlewareSessionEnd { + reason: reason as i32, + protocol_error: None, + }, + )), + } +} + +fn validate_preflight_input(input: &HttpRequestPreflightInput) -> miette::Result<()> { + if input.context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES { + return Err(miette::miette!("request context exceeds platform limit")); + } + if input.target.encoded_len() > MAX_MIDDLEWARE_TARGET_BYTES { + return Err(miette::miette!("request target exceeds platform limit")); + } + if input.headers.len() > MAX_MIDDLEWARE_HEADERS { + return Err(miette::miette!( + "request header count exceeds platform limit" + )); + } + if encoded_header_bytes(&input.headers) > MAX_MIDDLEWARE_HEADER_BYTES { + return Err(miette::miette!("request 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("request_reason_over_capacity"); + } + if !reason_code.is_empty() + && (reason_code.len() > MAX_MIDDLEWARE_REASON_CODE_BYTES + || !is_stable_reason_code(reason_code)) + { + return Err("request_reason_code_invalid"); + } + if findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE { + return Err("request_findings_over_capacity"); + } + if findings + .iter() + .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) + { + return Err("request_finding_over_capacity"); + } + if metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES { + return Err("request_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("request_metadata_bytes_over_capacity"); + } + Ok(()) +} + +fn validate_stage_diagnostics( + existing_findings: usize, + reason: &str, + reason_code: &str, + findings: &[Finding], + metadata: &std::collections::HashMap, +) -> Result<(), &'static str> { + validate_diagnostics(reason, reason_code, findings, metadata)?; + if existing_findings.saturating_add(findings.len()) > MAX_MIDDLEWARE_FINDINGS_PER_STAGE { + return Err("request_findings_over_capacity"); + } + Ok(()) +} + +fn permitted_body_modes( + input: &HttpRequestPreflightInput, + entry: &DescribedChainEntry, + allow_owned: bool, +) -> Vec { + let mut modes = vec![HttpRequestBodyMode::HeadersOnly as i32]; + if input + .declared_body_length + .is_none_or(|length| length <= entry.max_payload_bytes as u64) + { + modes.push(HttpRequestBodyMode::WholeBodyBytes as i32); + } + if entry.max_payload_bytes > 0 { + modes.push(HttpRequestBodyMode::StreamBytes as i32); + if allow_owned && entry.on_error() == OnError::FailClosed { + modes.push(HttpRequestBodyMode::OwnedStreamBytes as i32); + } + } + modes +} + +fn validate_inspect( + inspect: &openshell_core::proto::HttpRequestPreflightInspect, + permitted_modes: &[i32], +) -> Result { + if !permitted_modes.contains(&inspect.body_mode) { + return Err("request_body_mode_not_permitted"); + } + match HttpRequestBodyMode::try_from(inspect.body_mode) { + Ok(HttpRequestBodyMode::HeadersOnly) => Ok(StageMode::HeadersOnly), + Ok(HttpRequestBodyMode::WholeBodyBytes) => Ok(StageMode::WholeBody), + Ok(HttpRequestBodyMode::StreamBytes) => Ok(StageMode::Stream), + Ok(HttpRequestBodyMode::OwnedStreamBytes) => Ok(StageMode::OwnedStream), + Ok(HttpRequestBodyMode::Unspecified) | Err(_) => Err("invalid_request_body_mode"), + } +} + +fn encoded_header_bytes(headers: &[HttpHeader]) -> usize { + headers.iter().fold(0usize, |total, header| { + total.saturating_add(header.encoded_len()) + }) +} + +fn collect_diagnostics( + entry: &DescribedChainEntry, + mut findings: Vec, + mut metadata: std::collections::HashMap, + all_findings: &mut Vec, + all_metadata: &mut BTreeMap>, +) { + if 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", entry.entry.implementation); + finding.label = EXTERNAL_FINDING_LABEL.to_string(); + finding.confidence.clear(); + finding.severity = "medium".into(); + } + } + all_findings.extend(findings.into_iter().map(|finding| NamespacedFinding { + middleware: entry.entry.name.clone(), + finding, + })); + if !metadata.is_empty() { + all_metadata.insert(entry.entry.name.clone(), metadata.into_iter().collect()); + } +} + +fn body_invocation( + stage: &HttpRequestStage, + outcome: HttpRequestInvocationOutcome, + sequence: u64, + input_size: usize, + output_size: usize, + reason_code: Option, +) -> HttpRequestInvocation { + HttpRequestInvocation { + 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 record_request_invocation( + invocations: &mut Vec, + invocation: HttpRequestInvocation, +) { + if invocations.len() < MAX_RECORDED_REQUEST_INVOCATIONS { + invocations.push(invocation); + return; + } + + let index = invocations + .iter() + .position(|existing| existing.config_name == invocation.config_name) + .unwrap_or(invocations.len() - 1); + let existing = &mut invocations[index]; + existing.sequence = invocation.sequence.or(existing.sequence); + existing.input_size = existing.input_size.saturating_add(invocation.input_size); + existing.output_size = match (existing.output_size, invocation.output_size) { + (_, None) if invocation.failed => None, + (Some(existing), Some(incoming)) => Some(existing.saturating_add(incoming)), + (None, Some(incoming)) => Some(incoming), + (existing, None) => existing, + }; + existing.failed |= invocation.failed; + existing.stage_disabled |= invocation.stage_disabled; + if invocation.reason_code.is_some() { + existing.reason_code = invocation.reason_code; + } + if invocation.failure_category.is_some() { + existing.failure_category = invocation.failure_category; + } + if request_invocation_priority(invocation.outcome) + >= request_invocation_priority(existing.outcome) + { + existing.outcome = invocation.outcome; + } +} + +fn request_invocation_priority(outcome: HttpRequestInvocationOutcome) -> u8 { + match outcome { + HttpRequestInvocationOutcome::BlockRequest | HttpRequestInvocationOutcome::FailClosed => 5, + HttpRequestInvocationOutcome::FailOpen => 4, + HttpRequestInvocationOutcome::Transform => 3, + HttpRequestInvocationOutcome::SkipRemaining + | HttpRequestInvocationOutcome::TakeOwnership => 2, + HttpRequestInvocationOutcome::Skip + | HttpRequestInvocationOutcome::HeadersOnly + | HttpRequestInvocationOutcome::WholeBody + | HttpRequestInvocationOutcome::Stream + | HttpRequestInvocationOutcome::OwnedStream + | HttpRequestInvocationOutcome::Trailers + | HttpRequestInvocationOutcome::PassThrough => 1, + } +} + +fn preflight_invocation( + entry: &DescribedChainEntry, + outcome: HttpRequestInvocationOutcome, + reason_code: Option, +) -> HttpRequestInvocation { + HttpRequestInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome, + sequence: None, + input_size: 0, + output_size: None, + failed: false, + stage_disabled: false, + reason_code, + failure_category: None, + } +} + +fn collect_preflight_failure( + entry: &DescribedChainEntry, + reason: &str, + invocations: &mut Vec, +) -> Option { + let fail_closed = entry.on_error() == OnError::FailClosed; + invocations.push(HttpRequestInvocation { + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), + outcome: if fail_closed { + HttpRequestInvocationOutcome::FailClosed + } else { + HttpRequestInvocationOutcome::FailOpen + }, + sequence: None, + input_size: 0, + output_size: None, + failed: true, + stage_disabled: true, + reason_code: None, + failure_category: Some(request_failure_category(reason).into()), + }); + fail_closed.then(|| format!("middleware_failed: {reason}")) +} + +fn empty_preflight_outcome(headers: Vec) -> HttpRequestPreflightOutcome { + HttpRequestPreflightOutcome { + allowed: true, + reason: String::new(), + denial: None, + headers, + header_mutations: Vec::new(), + session: None, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + session_capacity_exhausted: false, + } +} + +fn failed_preflight_outcome( + headers: Vec, + header_mutations: Vec, + reason: String, + findings: Vec, + metadata: BTreeMap>, + invocations: Vec, +) -> HttpRequestPreflightOutcome { + HttpRequestPreflightOutcome { + allowed: false, + reason, + denial: None, + headers, + header_mutations, + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + } +} + +fn preflight_input_failure( + entries: &[DescribedChainEntry], + headers: Vec, + reason: &str, +) -> HttpRequestPreflightOutcome { + let mut invocations = Vec::new(); + let denied = entries + .iter() + .find_map(|entry| collect_preflight_failure(entry, reason, &mut invocations)); + if let Some(reason) = denied { + failed_preflight_outcome( + headers, + Vec::new(), + reason, + Vec::new(), + BTreeMap::new(), + invocations, + ) + } else { + HttpRequestPreflightOutcome { + invocations, + ..empty_preflight_outcome(headers) + } + } +} + +fn session_capacity_exhausted( + entries: Vec, + headers: Vec, +) -> HttpRequestPreflightOutcome { + let mut outcome = preflight_input_failure(&entries, headers, "session_capacity_exhausted"); + outcome.session_capacity_exhausted = true; + outcome +} + +async fn end_stages(stages: &mut [HttpRequestStage], reason: MiddlewareSessionEndReason) { + for stage in stages { + stage.end(reason).await; + } +} + +async fn handle_opened_preflight_failure( + entry: &DescribedChainEntry, + current_stage: &mut HttpRequestStage, + prior_stages: &mut [HttpRequestStage], + 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 +} + +fn request_failure_category(reason: &str) -> &'static str { + if reason.contains("timeout") { + "timeout" + } else if reason.contains("capacity") { + "capacity" + } else if reason.contains("header") { + "header_mutation" + } else if reason.contains("sequence") || reason.contains("result") { + "protocol" + } else { + "service" + } +} diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index 073bebd88d..a179eaabcd 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -1196,12 +1196,12 @@ async fn handle_opened_preflight_failure( mod tests { use std::sync::Arc; - use openshell_core::middleware::{HttpRequestView, InProcessMiddleware}; + use openshell_core::middleware::InProcessMiddleware; use openshell_core::proto::{ - Decision, ExistingHeaderAction, HeaderMutation, HttpRequestResult, HttpResponseBodyResult, - HttpResponseBodyTransform, HttpResponsePreflightInspect, HttpResponsePreflightResult, - HttpResponsePreflightSkip, HttpResponseTrailersResult, MiddlewareBinding, - MiddlewareManifest, WriteHeader, header_mutation, http_response_preflight_result, + ExistingHeaderAction, HeaderMutation, 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; @@ -1267,16 +1267,6 @@ mod tests { )) } - 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< @@ -1388,16 +1378,6 @@ mod tests { 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, @@ -1600,13 +1580,6 @@ mod tests { Ok(()) } - async fn evaluate_http_request( - &self, - _request: HttpRequestView<'_>, - ) -> miette::Result { - unreachable!() - } - async fn open_http_response_pre_return( &self, mut requests: mpsc::Receiver, @@ -1931,13 +1904,6 @@ mod tests { Ok(()) } - async fn evaluate_http_request( - &self, - _: HttpRequestView<'_>, - ) -> miette::Result { - unreachable!() - } - async fn open_http_response_pre_return( &self, mut requests: mpsc::Receiver, diff --git a/crates/openshell-supervisor-network/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index d4aa9d1ee9..a8f0561701 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -17,13 +17,11 @@ openshell-isolation-interface = { path = "../openshell-isolation-interface" } openshell-ocsf = { path = "../openshell-ocsf" } openshell-policy = { path = "../openshell-policy" } openshell-supervisor-middleware = { path = "../openshell-supervisor-middleware" } +openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } async-trait = "0.1" apollo-parser = { workspace = true } -aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } -aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } -aws-smithy-runtime-api = { version = "1", features = ["client"] } http = { workspace = true } base64 = { workspace = true } bytes = { workspace = true } @@ -62,7 +60,6 @@ bundled-ca-roots = ["dep:webpki-roots"] [dev-dependencies] openshell-ocsf = { path = "../openshell-ocsf", features = ["test-support"] } -openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-middleware-builtins" } tonic = { workspace = true } temp-env = "0.3" tokio = { workspace = true, features = ["test-util"] } diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index c469ab5233..8f3a4b3b37 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -5,17 +5,29 @@ use crate::l7::relay::L7EvalContext; use crate::opa::PolicyGenerationGuard; -use miette::{Result, miette}; +use miette::{IntoDiagnostic, Result, miette}; use openshell_ocsf::{ ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; +use std::io::SeekFrom; use std::path::PathBuf; -use tokio::io::{AsyncRead, AsyncWrite}; +use std::time::Duration; +use tokio::io::{AsyncRead, AsyncSeekExt, AsyncWrite, AsyncWriteExt}; + +/// Maximum wall-clock time spent receiving, evaluating, and spooling one +/// request body before the supervisor cancels the middleware session. +// Keep `from_secs` while the workspace MSRV predates `Duration::from_mins`. +#[allow(clippy::duration_suboptimal_units)] +pub const DEFAULT_HTTP_REQUEST_BODY_TIMEOUT: Duration = Duration::from_secs(120); pub enum MiddlewareApplyResult { Allowed(crate::l7::provider::L7Request), + Streamed { + request: crate::l7::provider::L7Request, + body: MiddlewareRequestBody, + }, Denied { denial: Option, }, @@ -26,6 +38,43 @@ pub enum MiddlewareApplyResult { AdmissionExhausted, } +/// Storage-backed body produced by request middleware. The file is rewound and +/// contains normalized bytes without HTTP transfer framing. +pub struct RequestBodySpool { + pub(crate) file: tokio::fs::File, + pub(crate) len: u64, + pub(crate) trailers: Vec, +} + +/// Body delivery selected after all request-middleware preflights complete. +pub enum MiddlewareRequestBody { + /// A whole-body or ownership barrier completed before upstream contact. + Spool(RequestBodySpool), + /// Every active body stage selected unit-local streaming, so approved + /// units can be released under network backpressure. + Live(Box), +} + +/// Whether otherwise incremental request middleware must retain the complete +/// representation for a later body-dependent policy or credential stage. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum RequestBodyDelivery { + #[default] + Incremental, + Withhold, +} + +pub struct RequestBodyStream { + pub(crate) reader: crate::l7::rest::RequestBodyReader, + session: Option, + preflight: openshell_supervisor_middleware::HttpRequestPreflightOutcome, + request: crate::l7::provider::L7Request, + context: L7EvalContext, + generation_guard: PolicyGenerationGuard, + deadline: tokio::time::Instant, + terminal_emitted: bool, +} + /// 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. @@ -63,7 +112,30 @@ impl HttpMiddlewareExchange { where C: AsyncRead + AsyncWrite + Unpin + Send, { - apply_middleware_chain_for_scheme_with_request_id( + self.apply_request_with_delivery( + request, + client, + ctx, + scheme, + transformed_body_policy, + RequestBodyDelivery::Incremental, + ) + .await + } + + pub async fn apply_request_with_delivery( + &self, + request: crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + scheme: &str, + transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, + delivery: RequestBodyDelivery, + ) -> Result + where + C: AsyncRead + AsyncWrite + Unpin + Send, + { + apply_middleware_chain_for_scheme_with_request_id_and_delivery( request, client, ctx, @@ -73,6 +145,7 @@ impl HttpMiddlewareExchange { &self.generation_guard, transformed_body_policy, &self.request_id, + delivery, ) .await } @@ -517,7 +590,35 @@ pub async fn apply_middleware_chain_with_request_id, request_id: &str, ) -> Result { - apply_middleware_chain_for_scheme_with_request_id( + apply_middleware_chain_with_request_id_and_delivery( + req, + client, + ctx, + chain, + runner, + generation_guard, + transformed_body_policy, + request_id, + RequestBodyDelivery::Incremental, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_middleware_chain_with_request_id_and_delivery< + C: AsyncRead + AsyncWrite + Unpin + Send, +>( + req: crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + chain: Vec, + runner: &openshell_supervisor_middleware::ChainRunner, + generation_guard: &PolicyGenerationGuard, + transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, + request_id: &str, + delivery: RequestBodyDelivery, +) -> Result { + apply_middleware_chain_for_scheme_with_request_id_and_delivery( req, client, ctx, @@ -527,11 +628,13 @@ pub async fn apply_middleware_chain_with_request_id( @@ -544,16 +647,90 @@ pub async fn apply_middleware_chain_for_scheme_with_request_id< generation_guard: &PolicyGenerationGuard, transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, request_id: &str, +) -> Result { + apply_middleware_chain_for_scheme_with_request_id_and_delivery( + req, + client, + ctx, + scheme, + chain, + runner, + generation_guard, + transformed_body_policy, + request_id, + RequestBodyDelivery::Incremental, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +pub async fn apply_middleware_chain_for_scheme_with_request_id_and_delivery< + C: AsyncRead + AsyncWrite + Unpin + Send, +>( + req: crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + scheme: &str, + chain: Vec, + runner: &openshell_supervisor_middleware::ChainRunner, + generation_guard: &PolicyGenerationGuard, + transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, + request_id: &str, + delivery: RequestBodyDelivery, ) -> Result { if chain.is_empty() { return Ok(MiddlewareApplyResult::Allowed(req)); } let chain = runner.describe_chain(&chain).await?; + if matches!( + transformed_body_policy, + openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(_) + ) { + return apply_buffered_middleware_chain( + req, + client, + ctx, + scheme, + chain, + runner, + generation_guard, + transformed_body_policy, + request_id, + ) + .await; + } + + apply_streaming_middleware_chain( + req, + client, + ctx, + scheme, + chain, + runner, + generation_guard, + request_id, + delivery, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn apply_buffered_middleware_chain( + req: crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + scheme: &str, + chain: Vec, + runner: &openshell_supervisor_middleware::ChainRunner, + generation_guard: &PolicyGenerationGuard, + transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, + request_id: &str, +) -> Result { let admission = if chain.is_empty() { None } else { - let outcome = runner.reserve_middleware_work().await?; - match outcome { + let work_admission = runner.reserve_middleware_work().await?; + match work_admission { openshell_supervisor_middleware::MiddlewareWorkAdmissionOutcome::Admitted( admission, ) => Some(admission), @@ -564,10 +741,6 @@ pub async fn apply_middleware_chain_for_scheme_with_request_id< } }; let Some(max_body_bytes) = middleware_chain_body_limit(&chain) else { - // No entry resolved to a registered binding, so nothing inspects the - // body. Apply each entry's `on_error` policy without buffering (an - // unresolved binding is handled before the body is read) and forward - // the original request unchanged if the chain allows. let input = middleware_request_input_with_id( openshell_ocsf::ctx::ctx(), scheme, @@ -596,18 +769,15 @@ pub async fn apply_middleware_chain_for_scheme_with_request_id< } }); }; - // Admission was reserved above, before reading any request body. Keeping - // the guard through evaluation bounds aggregate buffered input across HTTP - // requests and WebSocket messages. let admission = admission.expect("resolved middleware chain reserved work admission"); - let buffered = match crate::l7::rest::buffer_request_body_for_middleware( + let buffer_result = crate::l7::rest::buffer_request_body_for_middleware( &req, client, Some(generation_guard), max_body_bytes, ) - .await? - { + .await?; + let buffered = match buffer_result { crate::l7::rest::BufferResult::Buffered(buffered) => buffered, crate::l7::rest::BufferResult::OverCapacity { recoverable } => { return Ok(resolve_unbuffered_body(ctx, req, &chain, recoverable)); @@ -626,9 +796,6 @@ pub async fn apply_middleware_chain_for_scheme_with_request_id< buffered.body, request_id, ); - // The explicitly selected transformation policy either re-checks every - // replacement or documents that this protocol's policy is body-independent. - // An ALLOW outcome therefore means the final body is policy-compliant. let outcome = runner .evaluate_described_with_policy_admitted( &chain, @@ -652,6 +819,607 @@ pub async fn apply_middleware_chain_for_scheme_with_request_id< Ok(MiddlewareApplyResult::Allowed(rebuilt)) } +#[allow(clippy::too_many_arguments)] +async fn apply_streaming_middleware_chain( + req: crate::l7::provider::L7Request, + client: &mut C, + ctx: &L7EvalContext, + scheme: &str, + chain: Vec, + runner: &openshell_supervisor_middleware::ChainRunner, + generation_guard: &PolicyGenerationGuard, + request_id: &str, + delivery: RequestBodyDelivery, +) -> Result { + let header_end = req + .raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map_or(req.raw_header.len(), |position| position + 4); + let original_headers = &req.raw_header[..header_end]; + let headers = safe_middleware_headers(original_headers)?; + let query = raw_query_from_request_headers(original_headers)?; + let sandbox = openshell_ocsf::ctx::ctx(); + let input = openshell_supervisor_middleware::HttpRequestPreflightInput { + 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: req.action.clone(), + path: req.target.clone(), + query, + }, + declared_body_length: match req.body_length { + crate::l7::provider::BodyLength::ContentLength(length) => Some(length), + crate::l7::provider::BodyLength::None => Some(0), + crate::l7::provider::BodyLength::Chunked => None, + }, + headers: headers + .visible + .into_iter() + .map(|(name, value)| openshell_core::proto::HttpHeader { name, value }) + .collect(), + connection_nominated_headers: headers.connection_nominated, + }; + let mut preflight = runner + .preflight_described_http_request(chain, input) + .await?; + if preflight.session_capacity_exhausted { + emit_middleware_admission_exhausted(ctx); + return Ok(MiddlewareApplyResult::AdmissionExhausted); + } + if !preflight.allowed { + emit_streaming_middleware_events( + ctx, + &req, + false, + &preflight.reason, + preflight.denial.as_ref(), + &preflight.findings, + &preflight.metadata, + &preflight.invocations, + false, + ); + return Ok(MiddlewareApplyResult::Denied { + denial: preflight.denial, + }); + } + + let Some(mut session) = preflight.session.take() else { + let rebuilt = + crate::l7::rest::rebuild_request_headers_only(&req, &preflight.header_mutations)?; + emit_streaming_middleware_events( + ctx, + &req, + true, + "", + None, + &preflight.findings, + &preflight.metadata, + &preflight.invocations, + !preflight.header_mutations.is_empty(), + ); + return Ok(MiddlewareApplyResult::Allowed(rebuilt)); + }; + + let (prepared_headers, mut body_reader) = + crate::l7::rest::prepare_request_body_stream(&req, client).await?; + + if delivery == RequestBodyDelivery::Incremental + && !session.requires_withholding() + && !matches!(req.body_length, crate::l7::provider::BodyLength::None) + { + let rebuilt = crate::l7::rest::rebuild_request_for_incremental_stream( + &req, + &prepared_headers, + &preflight.header_mutations, + )?; + let request = crate::l7::provider::L7Request { + action: req.action.clone(), + target: req.target.clone(), + query_params: req.query_params.clone(), + raw_header: req.raw_header.clone(), + body_length: req.body_length, + }; + return Ok(MiddlewareApplyResult::Streamed { + request: rebuilt, + body: MiddlewareRequestBody::Live(Box::new(RequestBodyStream { + reader: body_reader, + session: Some(session), + preflight, + request, + context: ctx.clone(), + generation_guard: generation_guard.clone(), + deadline: tokio::time::Instant::now() + DEFAULT_HTTP_REQUEST_BODY_TIMEOUT, + terminal_emitted: false, + })), + }); + } + + let std_file = tempfile::tempfile().into_diagnostic()?; + let mut file = tokio::fs::File::from_std(std_file); + let mut output_len = 0u64; + let mut body_invocations = Vec::new(); + let mut body_findings = Vec::new(); + let mut body_metadata = std::collections::BTreeMap::new(); + let body_deadline = tokio::time::Instant::now() + DEFAULT_HTTP_REQUEST_BODY_TIMEOUT; + + loop { + let next_unit = if let Ok(result) = tokio::time::timeout_at( + body_deadline, + body_reader.next_unit(client, Some(generation_guard), session.stream_unit_limit()), + ) + .await + { + result? + } else { + let diagnostics = session.take_diagnostics(); + session + .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) + .await; + emit_request_body_timeout(ctx, &req, &preflight, diagnostics); + return Ok(MiddlewareApplyResult::Denied { denial: None }); + }; + let Some(unit) = next_unit else { + break; + }; + let pushed = tokio::time::timeout_at(body_deadline, session.push_body(unit)).await; + match pushed { + Err(_) => { + let diagnostics = session.take_diagnostics(); + session + .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) + .await; + emit_request_body_timeout(ctx, &req, &preflight, diagnostics); + return Ok(MiddlewareApplyResult::Denied { denial: None }); + } + Ok(Ok(units)) => { + write_spooled_units(&mut file, &mut output_len, units).await?; + } + Ok(Err(error)) => { + body_invocations.extend(error.diagnostics.invocations); + body_findings.extend(error.diagnostics.findings); + body_metadata.extend(error.diagnostics.metadata); + let mut invocations = preflight.invocations; + invocations.extend(body_invocations); + let mut findings = preflight.findings; + findings.extend(body_findings); + let mut metadata = preflight.metadata; + metadata.extend(body_metadata); + emit_streaming_middleware_events( + ctx, + &req, + false, + &error.reason, + error.denial.as_ref(), + &findings, + &metadata, + &invocations, + false, + ); + return Ok(MiddlewareApplyResult::Denied { + denial: error.denial, + }); + } + } + } + let trailers = body_reader.take_trailers(); + let (output_tx, mut output_rx) = tokio::sync::mpsc::channel(4); + let finish_future = session.finish_to(trailers, output_tx); + let writer_future = async { + while let Some(unit) = output_rx.recv().await { + write_spooled_units(&mut file, &mut output_len, vec![unit]).await?; + } + Ok::<(), miette::Report>(()) + }; + let completed = tokio::time::timeout_at(body_deadline, async { + let (finish, writer) = tokio::join!(finish_future, writer_future); + writer?; + Ok::<_, miette::Report>(finish) + }) + .await; + let finish = if let Ok(completed) = completed { + completed? + } else { + emit_request_body_timeout( + ctx, + &req, + &preflight, + openshell_supervisor_middleware::HttpRequestDiagnostics::default(), + ); + return Ok(MiddlewareApplyResult::Denied { denial: None }); + }; + let finish = match finish { + Ok(finish) => finish, + Err(error) => { + let mut invocations = preflight.invocations; + invocations.extend(error.diagnostics.invocations); + let mut findings = preflight.findings; + findings.extend(error.diagnostics.findings); + let mut metadata = preflight.metadata; + metadata.extend(error.diagnostics.metadata); + emit_streaming_middleware_events( + ctx, + &req, + false, + &error.reason, + error.denial.as_ref(), + &findings, + &metadata, + &invocations, + false, + ); + return Ok(MiddlewareApplyResult::Denied { + denial: error.denial, + }); + } + }; + generation_guard.ensure_current()?; + file.flush().await.into_diagnostic()?; + file.seek(SeekFrom::Start(0)).await.into_diagnostic()?; + + let rebuilt = crate::l7::rest::rebuild_request_with_streamed_body( + &req, + &prepared_headers, + output_len, + &finish.trailers, + &preflight.header_mutations, + )?; + let mut invocations = preflight.invocations; + invocations.extend(finish.invocations); + let mut findings = preflight.findings; + findings.extend(finish.findings); + let mut metadata = preflight.metadata; + metadata.extend(finish.metadata); + emit_streaming_middleware_events( + ctx, + &req, + true, + "", + None, + &findings, + &metadata, + &invocations, + finish.body_transformed || !preflight.header_mutations.is_empty(), + ); + Ok(MiddlewareApplyResult::Streamed { + request: rebuilt, + body: MiddlewareRequestBody::Spool(RequestBodySpool { + file, + len: output_len, + trailers: finish.trailers, + }), + }) +} + +impl RequestBodyStream { + /// Run an incremental request session into a bounded output channel. + /// + /// The HTTP relay drains the channel directly into the upstream socket, + /// so channel and socket backpressure bound supervisor memory without a + /// whole-request storage cap. + pub(crate) async fn run_to( + &mut self, + client: &mut C, + output: tokio::sync::mpsc::Sender>, + ) -> Result + where + C: AsyncRead + Unpin, + { + loop { + let unit_limit = self.session.as_ref().map_or( + 1, + openshell_supervisor_middleware::HttpRequestSession::stream_unit_limit, + ); + let next = tokio::time::timeout_at( + self.deadline, + self.reader + .next_unit(client, Some(&self.generation_guard), unit_limit), + ) + .await; + let unit = match next { + Err(_) => { + self.cancel_with_diagnostics( + "middleware_failed: request_body_timeout", + openshell_supervisor_middleware::HttpRequestDiagnostics::default(), + ) + .await; + return Err(miette!("request middleware body deadline exceeded")); + } + Ok(Err(error)) => { + self.cancel_with_diagnostics( + "middleware_failed: request_body_read_failed", + openshell_supervisor_middleware::HttpRequestDiagnostics::default(), + ) + .await; + return Err(error); + } + Ok(Ok(None)) => break, + Ok(Ok(Some(unit))) => unit, + }; + + let pushed = { + let session = self + .session + .as_mut() + .ok_or_else(|| miette!("request middleware session is unavailable"))?; + tokio::time::timeout_at(self.deadline, session.push_body(unit)).await + }; + let units = match pushed { + Err(_) => { + let diagnostics = self + .session + .as_mut() + .map(openshell_supervisor_middleware::HttpRequestSession::take_diagnostics) + .unwrap_or_default(); + self.cancel_with_diagnostics( + "middleware_failed: request_body_timeout", + diagnostics, + ) + .await; + return Err(miette!("request middleware body deadline exceeded")); + } + Ok(Err(error)) => { + let reason = error.reason.clone(); + let denial = error.denial.clone(); + self.emit_failure(&reason, denial.as_ref(), *error.diagnostics); + self.session.take(); + return Err(miette!("{reason}")); + } + Ok(Ok(units)) => units, + }; + for unit in units { + match tokio::time::timeout_at(self.deadline, output.send(unit)).await { + Err(_) => { + let diagnostics = self + .session + .as_mut() + .map( + openshell_supervisor_middleware::HttpRequestSession::take_diagnostics, + ) + .unwrap_or_default(); + self.cancel_with_diagnostics( + "middleware_failed: request_body_timeout", + diagnostics, + ) + .await; + return Err(miette!("request middleware body deadline exceeded")); + } + Ok(Err(_)) => { + self.cancel_with_diagnostics( + "middleware_failed: request_output_closed", + openshell_supervisor_middleware::HttpRequestDiagnostics::default(), + ) + .await; + return Err(miette!("request middleware output consumer closed")); + } + Ok(Ok(())) => {} + } + } + } + + let trailers = self.reader.take_trailers(); + let session = self + .session + .take() + .ok_or_else(|| miette!("request middleware session is unavailable"))?; + let finish = + tokio::time::timeout_at(self.deadline, session.finish_to(trailers, output)).await; + match finish { + Err(_) => { + self.emit_failure( + "middleware_failed: request_body_timeout", + None, + openshell_supervisor_middleware::HttpRequestDiagnostics::default(), + ); + Err(miette!("request middleware body deadline exceeded")) + } + Ok(Err(error)) => { + let reason = error.reason.clone(); + let denial = error.denial.clone(); + self.emit_failure(&reason, denial.as_ref(), *error.diagnostics); + Err(miette!("{reason}")) + } + Ok(Ok(finish)) => { + self.emit_success(&finish); + Ok(finish) + } + } + } + + /// Terminate a live session after an upstream response wins the race with + /// request upload. Unread client bytes make the downstream connection + /// non-reusable, and no request replay is attempted. + pub(crate) async fn cancel_for_early_response(&mut self) { + let diagnostics = self + .session + .as_mut() + .map(openshell_supervisor_middleware::HttpRequestSession::take_diagnostics) + .unwrap_or_default(); + self.cancel_with_diagnostics("middleware_cancelled: upstream_response", diagnostics) + .await; + } + + async fn cancel_with_diagnostics( + &mut self, + reason: &str, + diagnostics: openshell_supervisor_middleware::HttpRequestDiagnostics, + ) { + if let Some(session) = self.session.take() { + session + .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) + .await; + } + self.emit_failure(reason, None, diagnostics); + } + + fn emit_success(&mut self, finish: &openshell_supervisor_middleware::HttpRequestFinish) { + if self.terminal_emitted { + return; + } + let mut invocations = self.preflight.invocations.clone(); + invocations.extend(finish.invocations.clone()); + let mut findings = self.preflight.findings.clone(); + findings.extend(finish.findings.clone()); + let mut metadata = self.preflight.metadata.clone(); + metadata.extend(finish.metadata.clone()); + emit_streaming_middleware_events( + &self.context, + &self.request, + true, + "", + None, + &findings, + &metadata, + &invocations, + finish.body_transformed || !self.preflight.header_mutations.is_empty(), + ); + self.terminal_emitted = true; + } + + fn emit_failure( + &mut self, + reason: &str, + denial: Option<&openshell_supervisor_middleware::MiddlewareDenial>, + diagnostics: openshell_supervisor_middleware::HttpRequestDiagnostics, + ) { + if self.terminal_emitted { + return; + } + let mut invocations = self.preflight.invocations.clone(); + invocations.extend(diagnostics.invocations); + let mut findings = self.preflight.findings.clone(); + findings.extend(diagnostics.findings); + let mut metadata = self.preflight.metadata.clone(); + metadata.extend(diagnostics.metadata); + emit_streaming_middleware_events( + &self.context, + &self.request, + false, + reason, + denial, + &findings, + &metadata, + &invocations, + false, + ); + self.terminal_emitted = true; + } +} + +fn emit_request_body_timeout( + ctx: &L7EvalContext, + req: &crate::l7::provider::L7Request, + preflight: &openshell_supervisor_middleware::HttpRequestPreflightOutcome, + diagnostics: openshell_supervisor_middleware::HttpRequestDiagnostics, +) { + let mut invocations = preflight.invocations.clone(); + invocations.extend(diagnostics.invocations); + let mut findings = preflight.findings.clone(); + findings.extend(diagnostics.findings); + let mut metadata = preflight.metadata.clone(); + metadata.extend(diagnostics.metadata); + emit_streaming_middleware_events( + ctx, + req, + false, + "middleware_failed: request_body_timeout", + None, + &findings, + &metadata, + &invocations, + false, + ); +} + +async fn write_spooled_units( + file: &mut tokio::fs::File, + output_len: &mut u64, + units: Vec>, +) -> Result<()> { + for unit in units { + *output_len = output_len + .checked_add(unit.len() as u64) + .ok_or_else(|| miette!("middleware request output length overflow"))?; + if *output_len > openshell_supervisor_middleware::MAX_HTTP_REQUEST_DEFERRED_BYTES as u64 { + return Err(miette!( + "middleware request output exceeds platform storage limit" + )); + } + file.write_all(&unit).await.into_diagnostic()?; + } + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +fn emit_streaming_middleware_events( + ctx: &L7EvalContext, + req: &crate::l7::provider::L7Request, + allowed: bool, + reason: &str, + denial: Option<&openshell_supervisor_middleware::MiddlewareDenial>, + findings: &[openshell_supervisor_middleware::NamespacedFinding], + metadata: &std::collections::BTreeMap>, + invocations: &[openshell_supervisor_middleware::HttpRequestInvocation], + transformed: bool, +) { + let mut applied = Vec::::new(); + for invocation in invocations { + if let Some(existing) = applied + .iter_mut() + .find(|existing| existing.name == invocation.config_name) + { + existing.failed |= invocation.failed; + existing.transformed |= matches!( + invocation.outcome, + openshell_supervisor_middleware::HttpRequestInvocationOutcome::Transform + | openshell_supervisor_middleware::HttpRequestInvocationOutcome::OwnedStream + ); + if matches!( + invocation.outcome, + openshell_supervisor_middleware::HttpRequestInvocationOutcome::BlockRequest + | openshell_supervisor_middleware::HttpRequestInvocationOutcome::FailClosed + ) { + existing.decision = openshell_core::proto::Decision::Deny; + } + continue; + } + applied.push(openshell_supervisor_middleware::MiddlewareInvocation { + name: invocation.config_name.clone(), + implementation: invocation.implementation.clone(), + decision: if matches!( + invocation.outcome, + openshell_supervisor_middleware::HttpRequestInvocationOutcome::BlockRequest + | openshell_supervisor_middleware::HttpRequestInvocationOutcome::FailClosed + ) { + openshell_core::proto::Decision::Deny + } else { + openshell_core::proto::Decision::Allow + }, + transformed, + failed: invocation.failed, + }); + } + let outcome = openshell_supervisor_middleware::ChainOutcome { + allowed, + reason: reason.to_string(), + body: Vec::new(), + header_mutations: Vec::new(), + findings: findings.to_vec(), + metadata: metadata.clone(), + applied, + denial: denial.cloned(), + }; + emit_middleware_events(ctx, req, &outcome); +} + pub async fn send_middleware_rejection_response( req: &crate::l7::provider::L7Request, client: &mut C, diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index e51422dbc6..7fd7ba5ee4 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -14,6 +14,7 @@ pub mod jsonrpc; pub(crate) mod mcp; pub(crate) mod middleware; pub mod path; +pub(crate) mod post_credentials; pub mod provider; pub mod relay; pub mod rest; diff --git a/crates/openshell-supervisor-network/src/l7/post_credentials.rs b/crates/openshell-supervisor-network/src/l7/post_credentials.rs new file mode 100644 index 0000000000..aadf991330 --- /dev/null +++ b/crates/openshell-supervisor-network/src/l7/post_credentials.rs @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Restricted in-process request middleware that runs after credential +//! resolution. External middleware can never enter this phase. + +use std::io::SeekFrom; + +use miette::{IntoDiagnostic as _, Result, miette}; +use openshell_core::secrets::SecretResolver; +use openshell_supervisor_middleware_builtins::sigv4::{ + BodyFraming as SigV4BodyFraming, PayloadMode as SigV4PayloadMode, RequestedPayloadMode, + SigV4Middleware, SigningCredentials, SigningTarget, +}; +use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncSeekExt as _, AsyncWrite, AsyncWriteExt as _}; + +use crate::l7::middleware::RequestBodySpool; +use crate::l7::provider::{BodyLength, L7Request}; +use crate::opa::PolicyGenerationGuard; + +/// A trusted built-in synthesized from endpoint policy. It is deliberately not +/// representable by an operator-run middleware registration. +#[derive(Clone, Copy, Debug)] +pub enum PostCredentialsMiddleware<'a> { + SigV4 { + requested_mode: RequestedPayloadMode, + service: &'a str, + region: &'a str, + host: &'a str, + port: u16, + }, +} + +/// Result of the post-credentials stage. A complete request already contains +/// its body; a signed head leaves normalized body relay to the HTTP owner. +pub enum PostCredentialsOutput { + Complete { + request: Vec, + payload_mode: SigV4PayloadMode, + }, + Head { + headers: Vec, + payload_mode: SigV4PayloadMode, + }, +} + +impl<'a> PostCredentialsMiddleware<'a> { + pub(crate) fn from_endpoint( + signing: crate::l7::CredentialSigning, + service: &'a str, + region: &'a str, + host: &'a str, + port: u16, + ) -> Option { + let requested_mode = match signing { + crate::l7::CredentialSigning::None => return None, + crate::l7::CredentialSigning::SigV4 => RequestedPayloadMode::Auto, + crate::l7::CredentialSigning::SigV4Body => RequestedPayloadMode::SignBody, + crate::l7::CredentialSigning::SigV4NoBody => RequestedPayloadMode::UnsignedPayload, + }; + Some(Self::SigV4 { + requested_mode, + service, + region, + host, + port, + }) + } + + /// Remove caller-provided authorization fields before placeholder + /// rewriting. The trusted stage regenerates them after credentials resolve. + pub(crate) fn strip_caller_authorization(self, raw_headers: &[u8]) -> Result> { + match self { + Self::SigV4 { .. } => SigV4Middleware::strip_existing_auth(raw_headers), + } + } + + #[allow(clippy::too_many_arguments)] + pub(crate) async fn evaluate( + self, + request: &L7Request, + original_headers: &str, + rewritten_headers: &[u8], + client: &mut C, + prepared_body: Option<&mut RequestBodySpool>, + resolver: Option<&SecretResolver>, + generation_guard: Option<&PolicyGenerationGuard>, + ) -> Result + where + C: AsyncRead + AsyncWrite + Unpin, + { + match self { + Self::SigV4 { + requested_mode, + service, + region, + host, + .. + } => { + let resolver = resolver.ok_or_else(|| { + miette::Report::new(super::rest::CredentialUnavailableError::new( + "SigV4 signing configured but no secret resolver available", + )) + })?; + let access_key = resolver + .resolve_current_env_key_checked("AWS_ACCESS_KEY_ID", "sigv4") + .map_err(miette::Report::new)?; + let secret_key = resolver + .resolve_current_env_key_checked("AWS_SECRET_ACCESS_KEY", "sigv4") + .map_err(miette::Report::new)?; + let session_token = resolver + .resolve_current_env_key_checked("AWS_SESSION_TOKEN", "sigv4") + .map_err(miette::Report::new)?; + let (Some(access_key), Some(secret_key)) = (access_key, secret_key) else { + return Err(miette::Report::new( + super::rest::CredentialUnavailableError::new( + "SigV4 signing configured but AWS credentials not found in provider", + ), + )); + }; + + if service.is_empty() { + return Err(miette!( + "SigV4 signing configured but signing_service not set in policy" + )); + } + let resolved_region = if region.is_empty() { + openshell_supervisor_middleware_builtins::sigv4::extract_aws_region(host) + .ok_or_else(|| { + miette!( + "SigV4 signing: cannot extract AWS region from hostname \ + '{host}'; set signing_region in the policy endpoint" + ) + })? + } else { + region.to_string() + }; + + let framing = sigv4_body_framing(request.body_length); + let payload_mode = + openshell_supervisor_middleware_builtins::sigv4::resolve_payload_mode( + requested_mode, + original_headers, + framing, + )?; + let target = SigningTarget { + host, + region: &resolved_region, + service, + }; + let credentials = SigningCredentials { + access_key, + secret_key, + session_token, + }; + + if payload_mode == SigV4PayloadMode::SignBody { + if matches!(request.body_length, BodyLength::Chunked) { + return Err(miette!( + "SigV4 body signing requires Content-Length; chunked transfer \ + encoding is not supported in this mode" + )); + } + let body = + collect_body_for_signing(request, client, prepared_body, generation_guard) + .await?; + let mut complete = Vec::with_capacity(rewritten_headers.len() + body.len()); + complete.extend_from_slice(rewritten_headers); + complete.extend_from_slice(&body); + let request = SigV4Middleware::sign_body(&complete, target, credentials)?; + Ok(PostCredentialsOutput::Complete { + request, + payload_mode, + }) + } else { + let headers = SigV4Middleware::sign_headers( + rewritten_headers, + target, + credentials, + payload_mode, + )?; + Ok(PostCredentialsOutput::Head { + headers, + payload_mode, + }) + } + } + } + } + + pub(crate) fn emit_success(self, payload_mode: SigV4PayloadMode) { + match self { + Self::SigV4 { + service, + region, + host, + port, + .. + } => { + let resolved_region = if region.is_empty() { + openshell_supervisor_middleware_builtins::sigv4::extract_aws_region(host) + .unwrap_or_else(|| "unknown".into()) + } else { + region.to_string() + }; + let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(openshell_ocsf::ActivityId::Traffic) + .action(openshell_ocsf::ActionId::Allowed) + .disposition(openshell_ocsf::DispositionId::Allowed) + .severity(openshell_ocsf::SeverityId::Informational) + .status(openshell_ocsf::StatusId::Success) + .dst_endpoint(openshell_ocsf::Endpoint::from_domain(host, port)) + .message(format!( + "openshell/sigv4 signed {host}:{port} service={service} \ + region={resolved_region} mode={payload_mode}" + )) + .build(); + openshell_ocsf::ocsf_emit!(event); + } + } + } +} + +fn sigv4_body_framing(body_length: BodyLength) -> SigV4BodyFraming { + match body_length { + BodyLength::None => SigV4BodyFraming::None, + BodyLength::ContentLength(_) => SigV4BodyFraming::ContentLength, + BodyLength::Chunked => SigV4BodyFraming::Chunked, + } +} + +async fn collect_body_for_signing( + request: &L7Request, + client: &mut C, + prepared_body: Option<&mut RequestBodySpool>, + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result> +where + C: AsyncRead + AsyncWrite + Unpin, +{ + use openshell_supervisor_middleware_builtins::sigv4::MAX_BODY_BYTES; + + if let Some(body) = prepared_body { + if body.len > MAX_BODY_BYTES as u64 { + return Err(miette!( + "SigV4 body signing buffers at most {MAX_BODY_BYTES} bytes" + )); + } + if !body.trailers.is_empty() { + return Err(miette!( + "SigV4 body signing does not support request trailers" + )); + } + body.file.seek(SeekFrom::Start(0)).await.into_diagnostic()?; + let capacity = usize::try_from(body.len) + .map_err(|_| miette!("SigV4 middleware body does not fit addressable memory"))?; + let mut bytes = Vec::with_capacity(capacity); + body.file.read_to_end(&mut bytes).await.into_diagnostic()?; + if bytes.len() as u64 != body.len { + return Err(miette!("middleware request spool ended early")); + } + return Ok(bytes); + } + + if has_expect_continue(original_header_text(request)?) { + client + .write_all(b"HTTP/1.1 100 Continue\r\n\r\n") + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + } + + let header_end = request_header_end(request); + let overflow = &request.raw_header[header_end..]; + match request.body_length { + BodyLength::None => { + if !overflow.is_empty() { + return Err(miette!("bodyless SigV4 request contains read-ahead bytes")); + } + Ok(Vec::new()) + } + BodyLength::ContentLength(body_len) => { + if body_len > MAX_BODY_BYTES as u64 { + return Err(miette!( + "SigV4 body signing buffers at most {MAX_BODY_BYTES} bytes" + )); + } + if overflow.len() as u64 > body_len { + return Err(miette!( + "SigV4 request read-ahead exceeds its declared Content-Length" + )); + } + let body_len = usize::try_from(body_len) + .map_err(|_| miette!("SigV4 request body does not fit addressable memory"))?; + let mut body = Vec::with_capacity(body_len); + body.extend_from_slice(overflow); + let remaining = body_len - overflow.len(); + if remaining > 0 { + let start = body.len(); + body.resize(body_len, 0); + client + .read_exact(&mut body[start..]) + .await + .into_diagnostic()?; + } + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + Ok(body) + } + BodyLength::Chunked => Err(miette!( + "SigV4 body signing requires Content-Length; chunked transfer encoding is not supported" + )), + } +} + +fn request_header_end(request: &L7Request) -> usize { + request + .raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map_or(request.raw_header.len(), |position| position + 4) +} + +fn original_header_text(request: &L7Request) -> Result<&str> { + std::str::from_utf8(&request.raw_header[..request_header_end(request)]) + .map_err(|_| miette!("SigV4 request headers are not valid UTF-8")) +} + +fn has_expect_continue(headers: &str) -> bool { + headers.lines().skip(1).any(|line| { + line.split_once(':').is_some_and(|(name, value)| { + name.eq_ignore_ascii_case("expect") + && value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("100-continue")) + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn synthesizes_only_configured_post_credentials_middleware() { + assert!( + PostCredentialsMiddleware::from_endpoint( + crate::l7::CredentialSigning::None, + "", + "", + "example.com", + 443, + ) + .is_none() + ); + assert!(matches!( + PostCredentialsMiddleware::from_endpoint( + crate::l7::CredentialSigning::SigV4NoBody, + "s3", + "us-east-1", + "s3.us-east-1.amazonaws.com", + 443, + ), + Some(PostCredentialsMiddleware::SigV4 { + requested_mode: RequestedPayloadMode::UnsignedPayload, + .. + }) + )); + } +} diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 2842562d90..ca6e1be3b2 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -8,7 +8,8 @@ //! and either forwards or denies the request. use crate::l7::middleware::{ - MiddlewareApplyResult, UninspectableTrafficGate, apply_middleware_chain_with_request_id, + MiddlewareApplyResult, RequestBodyDelivery, UninspectableTrafficGate, + apply_middleware_chain_with_request_id, apply_middleware_chain_with_request_id_and_delivery, emit_middleware_uninspectable, middleware_network_input, uninspectable_traffic_gate, }; #[cfg(test)] @@ -446,6 +447,7 @@ async fn relay_http_request_with_credential_rejection( upstream: &mut U, options: crate::l7::rest::RelayRequestOptions<'_>, ctx: &L7EvalContext, + prepared_body: Option<&mut crate::l7::middleware::MiddlewareRequestBody>, response_middleware: Option>, ) -> Result> where @@ -458,18 +460,21 @@ where upstream, options, ctx, + prepared_body, response_middleware, None, ) .await } +#[allow(clippy::too_many_arguments)] async fn relay_http_request_with_credential_rejection_observed( request: &crate::l7::provider::L7Request, client: &mut C, upstream: &mut U, options: crate::l7::rest::RelayRequestOptions<'_>, ctx: &L7EvalContext, + prepared_body: Option<&mut crate::l7::middleware::MiddlewareRequestBody>, response_middleware: Option>, observer: Option<&EndpointObserver>, ) -> Result> @@ -483,6 +488,7 @@ where client, upstream, options, + prepared_body, response_middleware, observer, ), @@ -1085,8 +1091,14 @@ where &request_id, ) .await; - let req = match middleware_result? { + let middleware_result = middleware_result?; + let req = match middleware_result { MiddlewareApplyResult::Allowed(request) => request, + MiddlewareApplyResult::Streamed { .. } => { + return Err(miette!( + "body-aware middleware unexpectedly returned a streamed request" + )); + } MiddlewareApplyResult::Denied { denial, .. } => { if let Some(observer) = observer.as_ref() { observer.observe(EndpointResult::PolicyDenied); @@ -1200,13 +1212,19 @@ where && config.request_body_credential_rewrite, deny_uninspected_credentials: config .deny_uninspected_body_credentials(ctx.secret_resolver.is_some()), - credential_signing: config.credential_signing, - signing_service: &config.signing_service, - signing_region: &config.signing_region, + post_credentials: + crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( + config.credential_signing, + &config.signing_service, + &config.signing_region, + &ctx.host, + ctx.port, + ), host: &ctx.host, port: ctx.port, }, ctx, + None, Some(http_response_middleware_relay( &req, ctx, @@ -1681,6 +1699,25 @@ fn jsonrpc_engine_type(protocol: L7Protocol) -> &'static str { } /// REST relay loop: parse request -> evaluate -> allow/deny -> relay response -> repeat. +fn request_body_delivery( + config: &L7EndpointConfig, + request: &crate::l7::provider::L7Request, +) -> RequestBodyDelivery { + let signing_needs_body = match config.credential_signing { + crate::l7::CredentialSigning::SigV4Body => true, + crate::l7::CredentialSigning::SigV4 => matches!( + request.body_length, + crate::l7::provider::BodyLength::ContentLength(_) + ), + crate::l7::CredentialSigning::None | crate::l7::CredentialSigning::SigV4NoBody => false, + }; + if signing_needs_body || config.request_body_credential_rewrite { + RequestBodyDelivery::Withhold + } else { + RequestBodyDelivery::Incremental + } +} + async fn relay_rest( config: &L7EndpointConfig, engine: &TunnelPolicyEngine, @@ -1839,7 +1876,8 @@ where // 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_with_request_id( + let request_delivery = request_body_delivery(config, &req); + let middleware_result = apply_middleware_chain_with_request_id_and_delivery( req, client, ctx, @@ -1848,10 +1886,13 @@ where engine.generation_guard(), openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, &request_id, + request_delivery, ) .await; - let req = match middleware_result? { - MiddlewareApplyResult::Allowed(request) => request, + let middleware_result = middleware_result?; + let (req, mut prepared_body) = match middleware_result { + MiddlewareApplyResult::Allowed(request) => (request, None), + MiddlewareApplyResult::Streamed { request, body } => (request, Some(body)), MiddlewareApplyResult::Denied { denial, .. } => { let denied_request = crate::l7::provider::L7Request { action: request_info.action.clone(), @@ -1956,13 +1997,19 @@ where && config.request_body_credential_rewrite, deny_uninspected_credentials: config .deny_uninspected_body_credentials(ctx.secret_resolver.is_some()), - credential_signing: config.credential_signing, - signing_service: &config.signing_service, - signing_region: &config.signing_region, + post_credentials: + crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( + config.credential_signing, + &config.signing_service, + &config.signing_region, + &ctx.host, + ctx.port, + ), host: &ctx.host, port: ctx.port, }, ctx, + prepared_body.as_mut(), Some(http_response_middleware_relay( &req_with_auth, ctx, @@ -2296,7 +2343,7 @@ where // 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_with_request_id( + let middleware_result = apply_middleware_chain_with_request_id( req, client, ctx, @@ -2306,9 +2353,14 @@ where openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), &request_id, ) - .await? - { + .await?; + let req = match middleware_result { MiddlewareApplyResult::Allowed(request) => request, + MiddlewareApplyResult::Streamed { .. } => { + return Err(miette!( + "body-aware middleware unexpectedly returned a streamed request" + )); + } MiddlewareApplyResult::Denied { denial, .. } => { if let Some(observer) = observer.as_ref() { observer.observe(EndpointResult::PolicyDenied); @@ -2389,6 +2441,7 @@ where ..Default::default() }, ctx, + None, Some(http_response_middleware_relay( &req, ctx, @@ -2578,7 +2631,7 @@ where // 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_with_request_id( + let middleware_result = apply_middleware_chain_with_request_id( req, client, ctx, @@ -2588,9 +2641,14 @@ where openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate), &request_id, ) - .await? - { + .await?; + let req = match middleware_result { MiddlewareApplyResult::Allowed(request) => request, + MiddlewareApplyResult::Streamed { .. } => { + return Err(miette!( + "body-aware middleware unexpectedly returned a streamed request" + )); + } MiddlewareApplyResult::Denied { denial, .. } => { let denied_request = crate::l7::provider::L7Request { action: request_info.action.clone(), @@ -2641,6 +2699,7 @@ where ..Default::default() }, ctx, + None, Some(http_response_middleware_relay( &req, ctx, @@ -3204,6 +3263,7 @@ where let request_id = uuid::Uuid::new_v4().to_string(); let mut response_selection = None; + let mut prepared_body = 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)?; @@ -3230,6 +3290,10 @@ where .await?; let request = match result { MiddlewareApplyResult::Allowed(request) => request, + MiddlewareApplyResult::Streamed { request, body } => { + prepared_body = Some(body); + request + } MiddlewareApplyResult::Denied { denial, .. } => { let denied_request = crate::l7::provider::L7Request { action: "HTTP".into(), @@ -3307,6 +3371,7 @@ where ..Default::default() }, ctx, + prepared_body.as_mut(), response_middleware, ) .await? @@ -3361,6 +3426,83 @@ mod tests { use std::path::PathBuf; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; + fn whole_body_request_stream( + mut requests: tokio::sync::mpsc::Receiver, + replacement: Option>, + gate: Option<(Arc, Arc)>, + ) -> openshell_core::middleware::HttpRequestResultStream { + use openshell_core::proto::{ + HttpRequestBodyMode, HttpRequestBodyPassThrough, HttpRequestBodyResult, + HttpRequestBodyTransform, HttpRequestEventResult, HttpRequestPreflightInspect, + HttpRequestPreflightResult, HttpRequestTrailersResult, http_request_body_result, + http_request_body_transform, http_request_event, http_request_event_result, + http_request_preflight_result, + }; + let (sender, receiver) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + while let Some(event) = requests.recv().await { + let result = match event.event { + Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::WholeBodyBytes as i32, + header_mutations: Vec::new(), + }, + )), + ..Default::default() + }, + )), + }, + Some(http_request_event::Event::Body(body)) => { + if let Some((entered, release)) = &gate { + entered.notify_one(); + release.notified().await; + } + let action = replacement.as_ref().map_or_else( + || { + http_request_body_result::Action::PassThrough( + HttpRequestBodyPassThrough {}, + ) + }, + |replacement| { + http_request_body_result::Action::Transform( + HttpRequestBodyTransform { + replacement: Some( + http_request_body_transform::Replacement::Data( + replacement.clone(), + ), + ), + }, + ) + }, + ); + HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyResult( + HttpRequestBodyResult { + sequence: body.sequence, + action: Some(action), + ..Default::default() + }, + )), + } + } + Some(http_request_event::Event::Trailers(_)) => HttpRequestEventResult { + result: Some(http_request_event_result::Result::TrailersResult( + HttpRequestTrailersResult::default(), + )), + }, + Some(http_request_event::Event::SessionEnd(_)) | None => break, + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + }); + Box::pin(tokio_stream::wrappers::ReceiverStream::new(receiver)) + } + #[tokio::test] async fn body_denial_returns_actionable_local_json() { let req = crate::l7::provider::L7Request { @@ -3380,6 +3522,7 @@ mod tests { }, &L7EvalContext::default(), None, + None, ) .await .unwrap(); @@ -3468,6 +3611,7 @@ mod tests { }, &L7EvalContext::default(), None, + None, Some(&observer), ) .await; @@ -3825,6 +3969,7 @@ mod tests { }, &ctx, None, + None, ) .await .expect("typed credential denial"); @@ -3918,9 +4063,14 @@ mod tests { request, resolver.as_ref(), crate::l7::rest::RelayRequestOptions { - credential_signing: crate::l7::CredentialSigning::SigV4NoBody, - signing_service: "execute-api", - signing_region: "us-west-2", + post_credentials: + crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( + crate::l7::CredentialSigning::SigV4NoBody, + "execute-api", + "us-west-2", + "denied.example.test", + 443, + ), host: "denied.example.test", port: 443, ..Default::default() @@ -4150,6 +4300,63 @@ network_policies: (config, tunnel_engine, ctx) } + fn middleware_relay_context_with_runner( + middleware_impl: &str, + runner: openshell_supervisor_middleware::ChainRunner, + ) -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + let data = format!( + r#" +network_middlewares: + request-middleware: + middleware: {middleware_impl} + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.test + port: 8080 + protocol: rest + enforcement: enforce + rules: + - allow: + method: POST + path: "/v1/**" + binaries: + - {{ path: /usr/bin/curl }} +"# + ); + let engine = OpaEngine::from_strings(TEST_POLICY, &data).expect("test middleware policy"); + engine.set_middleware_runner_for_tests(runner); + let input = NetworkInput { + host: "api.example.test".into(), + port: 8080, + binary_path: PathBuf::from("/usr/bin/curl"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let config = crate::l7::parse_l7_config(&endpoint_config.expect("REST endpoint")) + .expect("parse REST config"); + let tunnel_engine = engine + .clone_engine_for_tunnel(generation) + .expect("tunnel engine"); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 8080, + request_default_port: Some(8080), + policy_name: "rest_api".into(), + binary_path: "/usr/bin/curl".into(), + ..Default::default() + }; + (config, tunnel_engine, ctx) + } + fn passthrough_token_grant_relay_context( resolver_response: std::result::Result<&str, &str>, ) -> ( @@ -4252,16 +4459,6 @@ network_policies: )) } - async fn evaluate_http_request( - &self, - _request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Err(tonic::Status::unimplemented("WebSocket-only middleware")) - } - async fn evaluate_web_socket_session( &self, request: tonic::Request>, @@ -5049,15 +5246,13 @@ network_policies: ); app.write_all(request.as_bytes()).await.unwrap(); - let mut upstream_request = [0u8; 1024]; - let n = tokio::time::timeout( + let upstream_request = tokio::time::timeout( std::time::Duration::from_secs(1), - upstream.read(&mut upstream_request), + read_http_request(&mut upstream), ) .await - .expect("request should reach upstream") - .unwrap(); - let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + .expect("request should reach upstream"); + let upstream_request = String::from_utf8_lossy(&upstream_request); assert!(upstream_request.contains(r#""api_key":"[REDACTED]""#)); assert!(!upstream_request.contains("sk-1234567890abcdef")); @@ -5115,15 +5310,13 @@ network_policies: app.write_all(body).await.unwrap(); - let mut upstream_request = [0u8; 1024]; - let n = tokio::time::timeout( + let upstream_request = tokio::time::timeout( std::time::Duration::from_secs(1), - upstream.read(&mut upstream_request), + read_http_request(&mut upstream), ) .await - .expect("request should reach upstream after the body is released") - .unwrap(); - let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + .expect("request should reach upstream after the body is released"); + let upstream_request = String::from_utf8_lossy(&upstream_request); assert!(upstream_request.contains(r#""api_key":"[REDACTED]""#)); assert!(!upstream_request.contains("Expect: 100-continue")); @@ -6057,16 +6250,16 @@ network_policies: Ok(()) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - _request: openshell_core::middleware::HttpRequestView<'_>, - ) -> Result { - self.entered.notify_one(); - self.release.notified().await; - Ok(openshell_core::proto::HttpRequestResult { - decision: openshell_core::proto::Decision::Allow as i32, - ..Default::default() - }) + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result + { + Ok(whole_body_request_stream( + requests, + None, + Some((Arc::clone(&self.entered), Arc::clone(&self.release))), + )) } } @@ -6204,16 +6397,16 @@ network_policies: Ok(()) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - _request: openshell_core::middleware::HttpRequestView<'_>, - ) -> Result { - Ok(openshell_core::proto::HttpRequestResult { - decision: openshell_core::proto::Decision::Allow as i32, - body: self.replacement.to_vec(), - has_body: true, - ..Default::default() - }) + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result + { + Ok(whole_body_request_stream( + requests, + Some(self.replacement.to_vec()), + None, + )) } } @@ -6647,6 +6840,180 @@ network_policies: replacement: Option<&'static [u8]>, } + #[derive(Clone, Copy)] + enum RequestRelayMode { + HeadersOnly, + AppendPerUnit, + CredentialMarkerPerUnit, + WholeBodyAppend, + } + + struct RequestRelayService { + mode: RequestRelayMode, + rewrite_trace_trailer: bool, + session_end: Option>, + } + + #[tonic::async_trait] + impl openshell_core::middleware::InProcessMiddleware for RequestRelayService { + async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { + openshell_core::proto::MiddlewareManifest { + name: "test/request-relay".into(), + service_version: "test".into(), + bindings: vec![openshell_core::proto::MiddlewareBinding { + operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpRequest + as i32, + phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, + max_payload_bytes: + (openshell_supervisor_middleware::MAX_HTTP_REQUEST_STREAM_UNIT_BYTES + 1) + as u64, + request_timeout: None, + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn open_http_request_pre_credentials( + &self, + mut requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result + { + use openshell_core::proto::{ + ExistingHeaderAction, HeaderMutation, HttpRequestBodyMode, + HttpRequestBodyPassThrough, HttpRequestBodyResult, HttpRequestBodyTransform, + HttpRequestEventResult, HttpRequestPreflightInspect, HttpRequestPreflightResult, + HttpRequestTrailersResult, WriteHeader, header_mutation, http_request_body_result, + http_request_body_transform, http_request_body_unit, http_request_event, + http_request_event_result, http_request_preflight_result, + }; + + let mode = self.mode; + let rewrite_trace_trailer = self.rewrite_trace_trailer; + let session_end = self.session_end.clone(); + let (sender, receiver) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + while let Some(event) = requests.recv().await { + let result = match event.event { + Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: match mode { + RequestRelayMode::HeadersOnly => { + HttpRequestBodyMode::HeadersOnly as i32 + } + RequestRelayMode::AppendPerUnit + | RequestRelayMode::CredentialMarkerPerUnit => { + HttpRequestBodyMode::StreamBytes as i32 + } + RequestRelayMode::WholeBodyAppend => { + HttpRequestBodyMode::WholeBodyBytes as i32 + } + }, + header_mutations: Vec::new(), + }, + )), + ..Default::default() + }, + )), + }, + Some(http_request_event::Event::Body(body)) => { + let data = match body.payload { + Some(http_request_body_unit::Payload::Data(data)) => data, + None => Vec::new(), + }; + let replacement = match mode { + RequestRelayMode::AppendPerUnit + | RequestRelayMode::WholeBodyAppend + if !data.is_empty() => + { + let mut replacement = data; + replacement.push(b'!'); + Some(replacement) + } + RequestRelayMode::CredentialMarkerPerUnit if !data.is_empty() => { + Some(b"openshell:resolve:env:v1_API_TOKEN".to_vec()) + } + _ => None, + }; + let action = replacement.map_or_else( + || { + http_request_body_result::Action::PassThrough( + HttpRequestBodyPassThrough {}, + ) + }, + |replacement| { + http_request_body_result::Action::Transform( + HttpRequestBodyTransform { + replacement: Some( + http_request_body_transform::Replacement::Data( + replacement, + ), + ), + }, + ) + }, + ); + HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyResult( + HttpRequestBodyResult { + sequence: body.sequence, + action: Some(action), + ..Default::default() + }, + )), + } + } + Some(http_request_event::Event::Trailers(_)) => { + let trailer_mutations = rewrite_trace_trailer + .then(|| HeaderMutation { + operation: Some(header_mutation::Operation::Write( + WriteHeader { + name: "x-trace".into(), + value: "rewritten".into(), + on_existing: ExistingHeaderAction::Overwrite as i32, + }, + )), + }) + .into_iter() + .collect(); + HttpRequestEventResult { + result: Some(http_request_event_result::Result::TrailersResult( + HttpRequestTrailersResult { + trailer_mutations, + ..Default::default() + }, + )), + } + } + Some(http_request_event::Event::SessionEnd(end)) => { + if let Some(session_end) = &session_end { + let _ = session_end.send(end.reason); + } + break; + } + None => break, + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + }); + Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new( + receiver, + ))) + } + } + #[tonic::async_trait] impl openshell_core::middleware::InProcessMiddleware for LimitService { async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { @@ -6675,19 +7042,16 @@ network_policies: Ok(()) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - _request: openshell_core::middleware::HttpRequestView<'_>, - ) -> Result { - let mut result = openshell_core::proto::HttpRequestResult { - decision: openshell_core::proto::Decision::Allow as i32, - ..Default::default() - }; - if let Some(replacement) = self.replacement { - result.body = replacement.to_vec(); - result.has_body = true; - } - Ok(result) + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result + { + Ok(whole_body_request_stream( + requests, + self.replacement.map(<[u8]>::to_vec), + None, + )) } } @@ -6776,6 +7140,19 @@ network_policies: "redactor must replace the body: {raw}" ); } + MiddlewareApplyResult::Streamed { request, mut body } => { + let crate::l7::middleware::MiddlewareRequestBody::Spool(body) = &mut body else { + panic!("whole-body middleware must retain output before forwarding") + }; + let mut rewritten = Vec::new(); + body.file + .read_to_end(&mut rewritten) + .await + .expect("read spooled middleware output"); + assert_eq!(rewritten, b"[SCRUBBED BY TEST REDACTOR]"); + assert_eq!(body.len, rewritten.len() as u64); + assert!(request.raw_header.ends_with(b"\r\n\r\n")); + } MiddlewareApplyResult::Denied { .. } => { panic!("body within the largest stage limit must not fail the chain") } @@ -6785,6 +7162,403 @@ network_policies: } } + #[tokio::test] + async fn header_only_request_middleware_forwards_large_body_without_collecting_it() { + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(RequestRelayService { + mode: RequestRelayMode::HeadersOnly, + rewrite_trace_trailer: false, + session_end: None, + })); + let (mut config, tunnel_engine, ctx) = + middleware_relay_context_with_runner("test/request-relay", runner); + config.provider_credentialed = true; + let (mut app, mut relay_client) = tokio::io::duplex(128 * 1024); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(128 * 1024); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let body_len = 4 * 1024 * 1024 + 17; + let headers = format!( + "POST /v1/large HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n" + ); + app.write_all(headers.as_bytes()).await.unwrap(); + + // Receiving the upstream head before the client sends any body proves + // the header-only mode did not collect the representation first. + let upstream_headers = tokio::time::timeout( + std::time::Duration::from_secs(1), + read_http_headers(&mut upstream), + ) + .await + .expect("header-only request should forward before its body arrives"); + let upstream_headers = String::from_utf8(upstream_headers).unwrap(); + assert!(upstream_headers.contains(&format!("Content-Length: {body_len}\r\n"))); + + let upstream_task = tokio::spawn(async move { + let mut body = vec![0; body_len]; + upstream.read_exact(&mut body).await.unwrap(); + assert!(body.iter().all(|byte| *byte == b'x')); + upstream + .write_all( + b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + }); + app.write_all(&vec![b'x'; body_len]).await.unwrap(); + upstream_task.await.unwrap(); + + let response = read_http_headers(&mut app).await; + assert!(String::from_utf8_lossy(&response).contains("204 No Content")); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(2), relay) + .await + .expect("large request relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn streaming_request_middleware_forwards_size_changing_units_before_eos() { + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(RequestRelayService { + mode: RequestRelayMode::AppendPerUnit, + rewrite_trace_trailer: false, + session_end: None, + })); + let (mut config, tunnel_engine, ctx) = + middleware_relay_context_with_runner("test/request-relay", runner); + config.provider_credentialed = true; + let (mut app, mut relay_client) = tokio::io::duplex(128 * 1024); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(128 * 1024); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let first_len = openshell_supervisor_middleware::MAX_HTTP_REQUEST_STREAM_UNIT_BYTES; + let body_len = first_len + 5; + app.write_all( + format!( + "POST /v1/grow HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {body_len}\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + let upstream_headers = read_http_headers(&mut upstream).await; + let upstream_headers = String::from_utf8(upstream_headers).unwrap(); + assert!(upstream_headers.contains("Transfer-Encoding: chunked\r\n")); + assert!( + !upstream_headers + .to_ascii_lowercase() + .contains("content-length:") + ); + + app.write_all(&vec![b'a'; first_len]).await.unwrap(); + let first = tokio::time::timeout( + std::time::Duration::from_secs(1), + read_http_chunk(&mut upstream), + ) + .await + .expect("approved output should reach upstream before client EOS") + .expect("early data chunk"); + assert!(!first.is_empty()); + assert!(first.iter().all(|byte| matches!(byte, b'a' | b'!'))); + + app.write_all(b"hello").await.unwrap(); + let mut transformed = first; + while let Some(chunk) = read_http_chunk(&mut upstream).await { + transformed.extend_from_slice(&chunk); + } + let original = transformed + .iter() + .copied() + .filter(|byte| *byte != b'!') + .collect::>(); + let mut expected = vec![b'a'; first_len]; + expected.extend_from_slice(b"hello"); + assert_eq!(original, expected); + assert!(transformed.contains(&b'!')); + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let response = read_http_headers(&mut app).await; + assert!(String::from_utf8_lossy(&response).contains("204 No Content")); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("size-changing request relay should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn provider_guard_rejects_a_marker_created_by_streaming_middleware() { + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(RequestRelayService { + mode: RequestRelayMode::CredentialMarkerPerUnit, + rewrite_trace_trailer: false, + session_end: None, + })); + let (mut config, tunnel_engine, ctx) = + middleware_relay_context_with_runner("test/request-relay", runner); + config.provider_credentialed = true; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"POST /v1/marker HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: 4\r\nConnection: close\r\n\r\nsafe", + ) + .await + .unwrap(); + let response = tokio::time::timeout( + std::time::Duration::from_secs(1), + read_http_headers(&mut app), + ) + .await + .expect("credential marker denial should reach the client"); + assert!(String::from_utf8_lossy(&response).contains("403 Forbidden")); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("credential marker relay should finish") + .unwrap() + .unwrap(); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + assert!( + !forwarded + .windows(b"openshell:resolve:".len()) + .any(|window| window == b"openshell:resolve:"), + "middleware-created credential marker reached upstream" + ); + } + + #[tokio::test] + async fn withheld_middleware_output_remains_usable_with_provider_credentials() { + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(RequestRelayService { + mode: RequestRelayMode::WholeBodyAppend, + rewrite_trace_trailer: false, + session_end: None, + })); + let (mut config, tunnel_engine, ctx) = + middleware_relay_context_with_runner("test/request-relay", runner); + config.provider_credentialed = true; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"POST /v1/held HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: 5\r\nConnection: close\r\n\r\nhello", + ) + .await + .unwrap(); + let headers = String::from_utf8(read_http_headers(&mut upstream).await).unwrap(); + assert!(headers.contains("Content-Length: 6\r\n")); + let mut body = [0; 6]; + upstream.read_exact(&mut body).await.unwrap(); + assert_eq!(&body, b"hello!"); + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + assert!( + String::from_utf8_lossy(&read_http_headers(&mut app).await).contains("204 No Content") + ); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("withheld provider-backed request should finish") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn streaming_request_stops_on_early_upstream_response_without_replay() { + let (session_end_tx, mut session_end_rx) = tokio::sync::mpsc::unbounded_channel(); + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(RequestRelayService { + mode: RequestRelayMode::AppendPerUnit, + rewrite_trace_trailer: false, + session_end: Some(session_end_tx), + })); + let (config, tunnel_engine, ctx) = + middleware_relay_context_with_runner("test/request-relay", runner); + let (mut app, mut relay_client) = tokio::io::duplex(128 * 1024); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(128 * 1024); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + let unit_len = openshell_supervisor_middleware::MAX_HTTP_REQUEST_STREAM_UNIT_BYTES; + let declared_len = 4 * 1024 * 1024 + 17; + app.write_all( + format!( + "POST /v1/early HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {declared_len}\r\nConnection: close\r\n\r\n" + ) + .as_bytes(), + ) + .await + .unwrap(); + let headers = String::from_utf8(read_http_headers(&mut upstream).await).unwrap(); + assert!(headers.contains("Transfer-Encoding: chunked\r\n")); + app.write_all(&vec![b'a'; unit_len]).await.unwrap(); + assert_eq!( + read_http_chunk(&mut upstream).await.unwrap().len(), + unit_len + 1 + ); + + upstream + .write_all( + b"HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + let response = tokio::time::timeout( + std::time::Duration::from_secs(1), + read_http_headers(&mut app), + ) + .await + .expect("early upstream response should not wait for client EOS"); + assert!(String::from_utf8_lossy(&response).contains("413 Payload Too Large")); + let reason = tokio::time::timeout(std::time::Duration::from_secs(1), session_end_rx.recv()) + .await + .expect("middleware should receive cancellation") + .expect("session-end reason"); + assert_eq!( + reason, + openshell_core::proto::MiddlewareSessionEndReason::Cancellation as i32 + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("early-response relay should terminate") + .unwrap() + .unwrap(); + } + + #[tokio::test] + async fn streaming_request_middleware_preserves_trailers_and_handles_expect_continue() { + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(RequestRelayService { + mode: RequestRelayMode::AppendPerUnit, + rewrite_trace_trailer: true, + session_end: None, + })); + let (config, tunnel_engine, ctx) = + middleware_relay_context_with_runner("test/request-relay", runner); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"POST /v1/chunked HTTP/1.1\r\nHost: api.example.test\r\nTransfer-Encoding: chunked\r\nTrailer: X-Trace\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + let mut interim = [0; 25]; + tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read_exact(&mut interim), + ) + .await + .expect("middleware should acknowledge Expect") + .unwrap(); + assert_eq!(&interim, b"HTTP/1.1 100 Continue\r\n\r\n"); + app.write_all(b"4\r\nWiki\r\n5\r\npedia\r\n0\r\nX-Trace: original\r\n\r\n") + .await + .unwrap(); + + let upstream_headers = read_http_headers(&mut upstream).await; + let upstream_headers = String::from_utf8(upstream_headers).unwrap(); + assert!(upstream_headers.contains("Transfer-Encoding: chunked\r\n")); + assert!( + upstream_headers + .to_ascii_lowercase() + .contains("trailer: x-trace\r\n") + ); + assert!(!upstream_headers.to_ascii_lowercase().contains("expect:")); + assert!( + !upstream_headers + .to_ascii_lowercase() + .contains("content-length:") + ); + assert_eq!(read_http_chunk(&mut upstream).await.unwrap(), b"Wiki!"); + assert_eq!(read_http_chunk(&mut upstream).await.unwrap(), b"pedia!"); + assert!(read_http_chunk(&mut upstream).await.is_none()); + let trailers = read_http_chunk_trailers(&mut upstream).await; + assert_eq!(trailers, vec!["x-trace: rewritten"]); + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + + let response = read_http_headers(&mut app).await; + assert!(String::from_utf8_lossy(&response).contains("204 No Content")); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("chunked request relay should finish") + .unwrap() + .unwrap(); + } + #[tokio::test] async fn all_unresolved_fail_open_forwards_body_unbuffered() { // A chain whose only entry is an unregistered binding has no resolvable @@ -7167,15 +7941,13 @@ network_policies: ); app.write_all(request.as_bytes()).await.unwrap(); - let mut upstream_request = [0u8; 1024]; - let n = tokio::time::timeout( + let upstream_request = tokio::time::timeout( std::time::Duration::from_secs(1), - upstream.read(&mut upstream_request), + read_http_request(&mut upstream), ) .await - .expect("request should reach upstream") - .unwrap(); - let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); + .expect("request should reach upstream"); + let upstream_request = String::from_utf8_lossy(&upstream_request); assert!( upstream_request.contains(r#""api_key":"[REDACTED]""#), "unexpected upstream request: {upstream_request:?}" @@ -9012,6 +9784,65 @@ network_policies: } } + async fn read_http_line(reader: &mut R) -> Vec { + let mut line = Vec::new(); + let mut byte = [0u8; 1]; + loop { + reader.read_exact(&mut byte).await.unwrap(); + line.push(byte[0]); + if line.ends_with(b"\r\n") { + line.truncate(line.len() - 2); + return line; + } + } + } + + async fn read_http_chunk(reader: &mut R) -> Option> { + let size = String::from_utf8(read_http_line(reader).await).unwrap(); + let size = usize::from_str_radix(size.split(';').next().unwrap(), 16).unwrap(); + if size == 0 { + return None; + } + let mut payload = vec![0; size]; + reader.read_exact(&mut payload).await.unwrap(); + let mut terminator = [0; 2]; + reader.read_exact(&mut terminator).await.unwrap(); + assert_eq!(&terminator, b"\r\n"); + Some(payload) + } + + async fn read_http_chunk_trailers(reader: &mut R) -> Vec { + let mut trailers = Vec::new(); + loop { + let line = read_http_line(reader).await; + if line.is_empty() { + return trailers; + } + trailers.push(String::from_utf8(line).unwrap()); + } + } + + async fn read_http_request(reader: &mut R) -> Vec { + let mut request = read_http_headers(reader).await; + let headers = std::str::from_utf8(&request).expect("HTTP request headers"); + let content_length = headers + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().expect("Content-Length")) + }) + }) + .unwrap_or_default(); + let header_len = request.len(); + request.resize(header_len + content_length, 0); + reader + .read_exact(&mut request[header_len..]) + .await + .expect("HTTP request body"); + request + } + async fn read_text_frame( reader: &mut R, ) -> std::io::Result<(bool, String)> { diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 4c65c983e6..16e723a215 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -23,7 +23,6 @@ use http_response::{ use crate::l7::EndpointObserver; use crate::l7::provider::{BodyLength, L7Provider, L7Request, RelayOutcome}; use crate::opa::PolicyGenerationGuard; -use aws_sigv4::http_request::SignableBody; use base64::Engine as _; use miette::{IntoDiagnostic, Result, miette}; use openshell_core::endpoint_status::EndpointResult; @@ -39,14 +38,12 @@ use openshell_ocsf::ctx::ctx as ocsf_ctx; use sha1::{Digest, Sha1}; use std::collections::{HashMap, HashSet}; use std::fmt::{self, Write as _}; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use std::io::SeekFrom; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt}; use tracing::debug; const MAX_HEADER_BYTES: usize = 16384; // 16 KiB for HTTP headers const MAX_REWRITE_BODY_BYTES: usize = 256 * 1024; -/// Maximum body bytes for `SigV4` body-signing mode. Larger than the credential -/// rewrite limit because Bedrock payloads can be several megabytes. -const MAX_SIGV4_BODY_BYTES: usize = 10 * 1024 * 1024; #[cfg(test)] async fn max_middleware_body_bytes() -> usize { let chain = openshell_supervisor_middleware::ChainRunner::new( @@ -748,9 +745,7 @@ where websocket_extensions: WebSocketExtensionMode::Preserve, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", + post_credentials: None, host: "", port: 0, }, @@ -774,9 +769,7 @@ pub(crate) struct RelayRequestOptions<'a> { pub(crate) websocket_extensions: WebSocketExtensionMode, pub(crate) request_body_credential_rewrite: bool, pub(crate) deny_uninspected_credentials: bool, - pub(crate) credential_signing: crate::l7::CredentialSigning, - pub(crate) signing_service: &'a str, - pub(crate) signing_region: &'a str, + pub(crate) post_credentials: Option>, pub(crate) host: &'a str, pub(crate) port: u16, } @@ -797,7 +790,7 @@ pub(crate) struct CredentialUnavailableError { } impl CredentialUnavailableError { - fn new(reason: &'static str) -> Self { + pub(crate) fn new(reason: &'static str) -> Self { Self { reason } } } @@ -849,7 +842,7 @@ where U: AsyncRead + AsyncWrite + Unpin, { relay_http_request_with_response_middleware_guarded_observed( - req, client, upstream, options, None, None, + req, client, upstream, options, None, None, None, ) .await } @@ -859,7 +852,8 @@ pub(crate) async fn relay_http_request_with_response_middleware_guarded_observed client: &mut C, upstream: &mut U, options: RelayRequestOptions<'_>, - response_middleware: Option>, + mut prepared_body: Option<&mut crate::l7::middleware::MiddlewareRequestBody>, + mut response_middleware: Option>, observer: Option<&EndpointObserver>, ) -> Result where @@ -884,12 +878,12 @@ where parse_websocket_upgrade_request(&req.raw_header[..header_end])? }; - // When SigV4 signing is configured, strip AWS auth headers before credential - // rewriting so the fail-closed placeholder scan doesn't reject the SigV4 - // Authorization header (which embeds placeholder strings). + // A restricted post-credentials built-in may remove caller authorization + // before placeholder rewriting, then regenerate it from trusted provider + // credentials below. let raw_for_rewrite; - let header_source = if options.credential_signing.is_sigv4() { - raw_for_rewrite = crate::sigv4::strip_aws_headers(&req.raw_header[..header_end])?; + let header_source = if let Some(middleware) = options.post_credentials { + raw_for_rewrite = middleware.strip_caller_authorization(&req.raw_header[..header_end])?; &raw_for_rewrite[..] } else { &req.raw_header[..header_end] @@ -917,256 +911,161 @@ where guard.ensure_current()?; } - // Apply SigV4 signing if configured. - if options.credential_signing.is_sigv4() { - // Defense-in-depth: credential_signing and request_body_credential_rewrite - // are mutually exclusive (validated at policy load time). + if let Some(middleware) = options.post_credentials { + // Defense-in-depth: post-credential signing and request body credential + // rewriting are mutually exclusive (validated at policy load time). if options.request_body_credential_rewrite { return Err(miette!( "credential_signing and request_body_credential_rewrite are \ mutually exclusive on the same endpoint" )); } - // SigV4 re-signing needs the body before forwarding. If the client - // sent `Expect: 100-continue`, acknowledge it so the client transmits - // the body. Scoped to SigV4 paths only — non-SigV4 traffic forwards - // the Expect header to upstream for normal handling. - if has_expect_continue(header_str) { - client - .write_all(b"HTTP/1.1 100 Continue\r\n\r\n") - .await - .into_diagnostic()?; - client.flush().await.into_diagnostic()?; - } - if let Some(resolver) = options.resolver { - let access_key = resolver - .resolve_current_env_key_checked("AWS_ACCESS_KEY_ID", "sigv4") - .map_err(miette::Report::new)?; - let secret_key = resolver - .resolve_current_env_key_checked("AWS_SECRET_ACCESS_KEY", "sigv4") - .map_err(miette::Report::new)?; - let session_token = resolver - .resolve_current_env_key_checked("AWS_SESSION_TOKEN", "sigv4") - .map_err(miette::Report::new)?; - - match (access_key, secret_key) { - (Some(access_key), Some(secret_key)) => { - // Use explicit signing_region from policy if set, - // otherwise extract from hostname. - let region = if options.signing_region.is_empty() { - match crate::sigv4::extract_aws_region(options.host) { - Some(r) => r, - None => { - return Err(miette!( - "SigV4 signing: cannot extract AWS region from \ - hostname '{host}'; set signing_region in the \ - policy endpoint", - host = options.host, - )); - } - } - } else { - options.signing_region.to_string() - }; - let service = &options.signing_service; - if service.is_empty() { - return Err(miette!( - "SigV4 signing configured but signing_service not set in policy" - )); - } - - let payload_mode = match options.credential_signing { - crate::l7::CredentialSigning::SigV4Body => SigV4PayloadMode::SignBody, - crate::l7::CredentialSigning::SigV4NoBody => { - SigV4PayloadMode::UnsignedPayload - } - crate::l7::CredentialSigning::SigV4 => detect_payload_mode(header_str)?, - crate::l7::CredentialSigning::None => unreachable!(), - }; - - if payload_mode == SigV4PayloadMode::SignBody { - // Buffer body and include its hash in the signature. - // This requires Content-Length — chunked bodies cannot - // be buffered for signing. detect_payload_mode() should - // route chunked requests to the streaming path, but - // guard here as defense-in-depth. - let body_length = parse_body_length(header_str)?; - match body_length { - BodyLength::ContentLength(_) | BodyLength::None => { - // ContentLength: buffer and sign the body. - // None: no body (e.g. GET/HEAD/DELETE) — sign - // with the empty-body hash. boto3 sends - // x-amz-content-sha256 set to the SHA-256 of - // "" on all requests, which routes here via - // detect_payload_mode's catch-all. - } - BodyLength::Chunked => { - return Err(miette!( - "SigV4 body signing requires Content-Length; \ - chunked transfer encoding is not supported in this mode" - )); - } - } - // NOTE(defense-in-depth): Build the full request from - // rewritten headers + body. `rewrite_result.rewritten` - // has already had AWS auth headers stripped by - // `strip_aws_headers`; `apply_sigv4_to_request` strips - // them again internally via `parse_request_parts` — - // the redundancy is intentional. - let overflow = &req.raw_header[header_end..]; - let mut full_request = rewrite_result.rewritten.clone(); - full_request.extend_from_slice(overflow); - if let BodyLength::ContentLength(body_len) = body_length { - if body_len > MAX_SIGV4_BODY_BYTES as u64 { - return Err(miette!( - "SigV4 body signing buffers at most {MAX_SIGV4_BODY_BYTES} bytes" - )); - } - let already_have = overflow.len() as u64; - if body_len > already_have { - let remaining = - usize::try_from(body_len - already_have).unwrap_or(usize::MAX); - let mut body_buf = vec![0u8; remaining]; - client.read_exact(&mut body_buf).await.into_diagnostic()?; - full_request.extend_from_slice(&body_buf); - } - } - - // Re-check policy after body buffering — a slow upload - // may have outlived a policy reload. - if let Some(guard) = options.generation_guard { - guard.ensure_current()?; - } - - let signed = crate::sigv4::apply_sigv4_to_request( - &full_request, - options.host, - ®ion, - service, - access_key, - secret_key, - session_token, - )?; - ensure_credential_generation_current(options)?; - upstream.write_all(&signed).await.into_diagnostic()?; - } else { - // Sign headers only, stream body through. - let signable_body = match payload_mode { - SigV4PayloadMode::StreamingUnsignedTrailer => { - SignableBody::StreamingUnsignedPayloadTrailer - } - _ => SignableBody::UnsignedPayload, - }; - let signed_headers = crate::sigv4::apply_sigv4_headers_only_with_body( - &rewrite_result.rewritten, - options.host, - ®ion, - service, - access_key, - secret_key, - session_token, - signable_body, - )?; - ensure_credential_generation_current(options)?; - upstream - .write_all(&signed_headers) - .await - .into_diagnostic()?; - - let overflow = &req.raw_header[header_end..]; - if !overflow.is_empty() { - if let Some(guard) = options.generation_guard { - guard.ensure_current()?; - } - upstream.write_all(overflow).await.into_diagnostic()?; - } - let overflow_len = overflow.len() as u64; - - match req.body_length { - BodyLength::ContentLength(len) => { - let remaining = len.saturating_sub(overflow_len); - if remaining > 0 { - relay_fixed( - client, - upstream, - remaining, - options.generation_guard, - ) - .await?; - } - } - BodyLength::Chunked => { - relay_chunked( - client, - upstream, - &req.raw_header[header_end..], - options.generation_guard, - ) - .await?; - } - BodyLength::None => {} - } - } - - // OCSF event after successful signing and upstream write. - let event = openshell_ocsf::NetworkActivityBuilder::new( - ocsf_ctx(), + let prepared_spool = prepared_body.as_deref_mut().and_then(|body| match body { + crate::l7::middleware::MiddlewareRequestBody::Spool(spool) => Some(spool), + crate::l7::middleware::MiddlewareRequestBody::Live(_) => None, + }); + let output = middleware + .evaluate( + req, + header_str, + &rewrite_result.rewritten, + client, + prepared_spool, + options.resolver, + options.generation_guard, + ) + .await?; + ensure_credential_generation_current(options)?; + if let Some(guard) = options.generation_guard { + guard.ensure_current()?; + } + let payload_mode = match output { + crate::l7::post_credentials::PostCredentialsOutput::Complete { + request, + payload_mode, + } => { + upstream.write_all(&request).await.into_diagnostic()?; + payload_mode + } + crate::l7::post_credentials::PostCredentialsOutput::Head { + headers, + payload_mode, + } => { + if let Some(crate::l7::middleware::MiddlewareRequestBody::Live(body)) = + prepared_body.as_deref_mut() + { + let outcome = relay_live_request_and_response( + req, + client, + upstream, + &headers, + body, + options, + false, + RelayResponseOptions { + websocket_extensions: options.websocket_extensions, + websocket: websocket_response, + client_requested_upgrade, + observer, + }, + response_middleware.take(), ) - .activity(openshell_ocsf::ActivityId::Traffic) - .action(openshell_ocsf::ActionId::Allowed) - .disposition(openshell_ocsf::DispositionId::Allowed) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .dst_endpoint(openshell_ocsf::Endpoint::from_domain( - options.host, - options.port, - )) - .message(format!( - "SigV4 re-signed {host}:{port} service={service} region={region} mode={payload_mode}", - host = options.host, - port = options.port, - )) - .build(); - openshell_ocsf::ocsf_emit!(event); - } - _ => { - return Err(miette::Report::new(CredentialUnavailableError::new( - "SigV4 signing configured but AWS credentials not found in provider", - ))); + .await?; + middleware.emit_success(payload_mode); + return Ok(outcome); } + upstream.write_all(&headers).await.into_diagnostic()?; + relay_request_body( + req, + client, + upstream, + prepared_body.as_deref_mut(), + options.generation_guard, + ) + .await?; + payload_mode } - } else { - return Err(miette::Report::new(CredentialUnavailableError::new( - "SigV4 signing configured but no secret resolver available", - ))); - } + }; + middleware.emit_success(payload_mode); } else if options.request_body_credential_rewrite { - let body = collect_and_rewrite_request_body( - req, - client, - &rewrite_result.rewritten, - header_str, - &req.raw_header[header_end..], - options.resolver, - options.generation_guard, - ) - .await?; + let body = match prepared_body.as_deref_mut() { + Some(crate::l7::middleware::MiddlewareRequestBody::Spool(body)) => { + collect_and_rewrite_spooled_request_body( + body, + &rewrite_result.rewritten, + header_str, + options.resolver, + options.generation_guard, + ) + .await? + } + Some(crate::l7::middleware::MiddlewareRequestBody::Live(_)) => { + return Err(miette!( + "request body credential rewriting requires withheld middleware output" + )); + } + None => { + collect_and_rewrite_request_body( + req, + client, + &rewrite_result.rewritten, + header_str, + &req.raw_header[header_end..], + options.resolver, + options.generation_guard, + ) + .await? + } + }; ensure_credential_generation_current(options)?; upstream.write_all(&body.headers).await.into_diagnostic()?; if !body.body.is_empty() { upstream.write_all(&body.body).await.into_diagnostic()?; } } else if options.deny_uninspected_credentials { - if let Err(error) = relay_request_body_with_marker_guard( - req, - client, - upstream, - &rewrite_result.rewritten, - &req.raw_header[header_end..], - options, - ) - .await + if let Some(crate::l7::middleware::MiddlewareRequestBody::Live(body)) = + prepared_body.as_deref_mut() { + return relay_live_request_and_response( + req, + client, + upstream, + &rewrite_result.rewritten, + body, + options, + true, + RelayResponseOptions { + websocket_extensions: options.websocket_extensions, + websocket: websocket_response, + client_requested_upgrade, + observer, + }, + response_middleware.take(), + ) + .await; + } + let guarded = if let Some(body) = prepared_body.as_deref_mut() { + relay_middleware_body_with_marker_guard( + req, + client, + upstream, + &rewrite_result.rewritten, + body, + options, + ) + .await + } else { + relay_request_body_with_marker_guard( + req, + client, + upstream, + &rewrite_result.rewritten, + &req.raw_header[header_end..], + options, + ) + .await + }; + if let Err(error) = guarded { if let Some(reason) = error.downcast_ref::() { emit_uninspected_body_credential_denial(req, &options, *reason); } @@ -1174,38 +1073,39 @@ where } } else { ensure_credential_generation_current(options)?; + if let Some(crate::l7::middleware::MiddlewareRequestBody::Live(body)) = + prepared_body.as_deref_mut() + { + return relay_live_request_and_response( + req, + client, + upstream, + &rewrite_result.rewritten, + body, + options, + false, + RelayResponseOptions { + websocket_extensions: options.websocket_extensions, + websocket: websocket_response, + client_requested_upgrade, + observer, + }, + response_middleware.take(), + ) + .await; + } upstream .write_all(&rewrite_result.rewritten) .await .into_diagnostic()?; - - let overflow = &req.raw_header[header_end..]; - if !overflow.is_empty() { - if let Some(guard) = options.generation_guard { - guard.ensure_current()?; - } - upstream.write_all(overflow).await.into_diagnostic()?; - } - let overflow_len = overflow.len() as u64; - - match req.body_length { - BodyLength::ContentLength(len) => { - let remaining = len.saturating_sub(overflow_len); - if remaining > 0 { - relay_fixed(client, upstream, remaining, options.generation_guard).await?; - } - } - BodyLength::Chunked => { - relay_chunked( - client, - upstream, - &req.raw_header[header_end..], - options.generation_guard, - ) - .await?; - } - BodyLength::None => {} - } + relay_request_body( + req, + client, + upstream, + prepared_body, + options.generation_guard, + ) + .await?; } upstream.flush().await.into_diagnostic()?; @@ -1226,6 +1126,260 @@ where Ok(outcome) } +#[allow(clippy::too_many_arguments)] +async fn relay_live_request_and_response( + req: &L7Request, + client: &mut C, + upstream: &mut U, + headers: &[u8], + body: &mut crate::l7::middleware::RequestBodyStream, + options: RelayRequestOptions<'_>, + inspect_credential_markers: bool, + response_options: RelayResponseOptions<'_>, + response_middleware: Option>, +) -> Result +where + C: AsyncRead + AsyncWrite + Unpin, + U: AsyncRead + AsyncWrite + Unpin, +{ + ensure_body_generation_current(options)?; + upstream.write_all(headers).await.into_diagnostic()?; + upstream.flush().await.into_diagnostic()?; + + let (mut client_reader, mut client_writer) = tokio::io::split(&mut *client); + let (mut upstream_reader, mut upstream_writer) = tokio::io::split(&mut *upstream); + let mut upload = Box::pin(relay_live_middleware_request_body( + body, + &mut client_reader, + &mut upstream_writer, + options, + inspect_credential_markers, + )); + let mut response = Box::pin(relay_response( + &req.action, + &mut upstream_reader, + &mut client_writer, + response_options, + response_middleware, + )); + + tokio::select! { + upload_result = upload.as_mut() => { + drop(upload); + if let Err(error) = upload_result { + drop(response); + let _ = upstream_writer.shutdown().await; + return Err(error); + } + upstream_writer.flush().await.into_diagnostic()?; + response.await + } + response_result = response.as_mut() => { + drop(response); + drop(upload); + body.cancel_for_early_response().await; + let _ = upstream_writer.shutdown().await; + response_result?; + // The client may still have unread request bytes, so neither side + // can safely reuse this HTTP/1 connection after an early response. + Ok(RelayOutcome::Consumed) + } + } +} + +async fn relay_request_body( + req: &L7Request, + client: &mut C, + upstream: &mut U, + prepared_body: Option<&mut crate::l7::middleware::MiddlewareRequestBody>, + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result<()> +where + C: AsyncRead + Unpin, + U: AsyncWrite + Unpin, +{ + if let Some(body) = prepared_body { + return match body { + crate::l7::middleware::MiddlewareRequestBody::Spool(body) => { + relay_spooled_request_body(req, upstream, body, generation_guard).await + } + crate::l7::middleware::MiddlewareRequestBody::Live(body) => { + relay_live_middleware_request_body( + body, + client, + upstream, + RelayRequestOptions { + generation_guard, + ..Default::default() + }, + false, + ) + .await + } + }; + } + + let header_end = req + .raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map_or(req.raw_header.len(), |position| position + 4); + let overflow = &req.raw_header[header_end..]; + match req.body_length { + BodyLength::None => { + if !overflow.is_empty() { + return Err(miette!("bodyless request contains read-ahead bytes")); + } + } + BodyLength::ContentLength(length) => { + let overflow_len = overflow.len() as u64; + if overflow_len > length { + return Err(miette!( + "request read-ahead exceeds its declared Content-Length" + )); + } + if !overflow.is_empty() { + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + upstream.write_all(overflow).await.into_diagnostic()?; + } + let remaining = length - overflow_len; + if remaining > 0 { + relay_fixed(client, upstream, remaining, generation_guard).await?; + } + } + BodyLength::Chunked => { + relay_chunked(client, upstream, overflow, generation_guard).await?; + } + } + Ok(()) +} + +async fn relay_spooled_request_body( + req: &L7Request, + upstream: &mut U, + body: &mut crate::l7::middleware::RequestBodySpool, + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result<()> { + body.file.seek(SeekFrom::Start(0)).await.into_diagnostic()?; + let mut remaining = body.len; + let mut buffer = [0u8; RELAY_BUF_SIZE]; + match req.body_length { + BodyLength::None => { + if remaining != 0 || !body.trailers.is_empty() { + return Err(miette!("bodyless middleware request has spooled payload")); + } + } + BodyLength::ContentLength(expected) => { + if expected != remaining || !body.trailers.is_empty() { + return Err(miette!("middleware request spool framing mismatch")); + } + while remaining > 0 { + let limit = usize::try_from(remaining.min(buffer.len() as u64)) + .expect("relay buffer length fits usize"); + let read = body + .file + .read(&mut buffer[..limit]) + .await + .into_diagnostic()?; + if read == 0 { + return Err(miette!("middleware request spool ended early")); + } + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + upstream + .write_all(&buffer[..read]) + .await + .into_diagnostic()?; + remaining -= read as u64; + } + } + BodyLength::Chunked => { + while remaining > 0 { + let limit = usize::try_from(remaining.min(buffer.len() as u64)) + .expect("relay buffer length fits usize"); + let read = body + .file + .read(&mut buffer[..limit]) + .await + .into_diagnostic()?; + if read == 0 { + return Err(miette!("middleware request spool ended early")); + } + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + upstream + .write_all(format!("{read:X}\r\n").as_bytes()) + .await + .into_diagnostic()?; + upstream + .write_all(&buffer[..read]) + .await + .into_diagnostic()?; + upstream.write_all(b"\r\n").await.into_diagnostic()?; + remaining -= read as u64; + } + upstream.write_all(b"0\r\n").await.into_diagnostic()?; + for trailer in &body.trailers { + upstream + .write_all(format!("{}: {}\r\n", trailer.name, trailer.value).as_bytes()) + .await + .into_diagnostic()?; + } + upstream.write_all(b"\r\n").await.into_diagnostic()?; + } + } + Ok(()) +} + +async fn relay_live_middleware_request_body( + body: &mut crate::l7::middleware::RequestBodyStream, + client: &mut C, + upstream: &mut U, + options: RelayRequestOptions<'_>, + inspect_credential_markers: bool, +) -> Result<()> +where + C: AsyncRead + Unpin, + U: AsyncWrite + Unpin, +{ + let (sender, mut receiver) = tokio::sync::mpsc::channel(4); + let run = body.run_to(client, sender); + let write = async { + let mut scanner = inspect_credential_markers + .then(|| ReservedMarkerStreamGuard::new(options.body_classifier)); + while let Some(unit) = receiver.recv().await { + let output = match scanner.as_mut() { + Some(scanner) => scanner.push(&unit)?, + None => unit, + }; + write_guarded_chunk(upstream, &output, options).await?; + } + Ok::<_, miette::Report>(scanner) + }; + let (finish, scanner) = tokio::join!(run, write); + let mut scanner = scanner?; + let finish = finish?; + if let Some(scanner) = scanner.take() { + write_guarded_chunk(upstream, &scanner.finish()?, options).await?; + } + write_body_bytes(upstream, b"0\r\n", options).await?; + for trailer in finish.trailers { + let encoded = format!("{}: {}", trailer.name, trailer.value); + if inspect_credential_markers + && contains_reserved_credential_marker_bytes(encoded.as_bytes()) + { + return Err(BodyCredentialError::Trailer.into()); + } + write_body_bytes(upstream, encoded.as_bytes(), options).await?; + write_body_bytes(upstream, b"\r\n", options).await?; + } + write_body_bytes(upstream, b"\r\n", options).await +} + use openshell_core::secrets::body::{ BodyCredentialError, BodyPlaceholderGuard as ReservedMarkerStreamGuard, }; @@ -1384,6 +1538,93 @@ where Ok(()) } +async fn relay_middleware_body_with_marker_guard( + req: &L7Request, + client: &mut C, + upstream: &mut U, + headers: &[u8], + body: &mut crate::l7::middleware::MiddlewareRequestBody, + options: RelayRequestOptions<'_>, +) -> Result<()> +where + C: AsyncRead + Unpin, + U: AsyncWrite + Unpin, +{ + ensure_body_generation_current(options)?; + upstream.write_all(headers).await.into_diagnostic()?; + match body { + crate::l7::middleware::MiddlewareRequestBody::Live(body) => { + relay_live_middleware_request_body(body, client, upstream, options, true).await + } + crate::l7::middleware::MiddlewareRequestBody::Spool(body) => { + relay_spooled_request_body_with_marker_guard(req, upstream, body, options).await + } + } +} + +async fn relay_spooled_request_body_with_marker_guard( + req: &L7Request, + upstream: &mut U, + body: &mut crate::l7::middleware::RequestBodySpool, + options: RelayRequestOptions<'_>, +) -> Result<()> { + body.file.seek(SeekFrom::Start(0)).await.into_diagnostic()?; + let mut remaining = body.len; + let mut buffer = [0u8; RELAY_BUF_SIZE]; + let mut scanner = ReservedMarkerStreamGuard::new(options.body_classifier); + + while remaining > 0 { + let limit = usize::try_from(remaining.min(buffer.len() as u64)) + .expect("relay buffer length fits usize"); + let read = body + .file + .read(&mut buffer[..limit]) + .await + .into_diagnostic()?; + if read == 0 { + return Err(miette!("middleware request spool ended early")); + } + let safe = scanner.push(&buffer[..read])?; + match req.body_length { + BodyLength::None => { + return Err(miette!("bodyless middleware request has spooled payload")); + } + BodyLength::ContentLength(_) => write_body_bytes(upstream, &safe, options).await?, + BodyLength::Chunked => write_guarded_chunk(upstream, &safe, options).await?, + } + remaining -= read as u64; + } + + let tail = scanner.finish()?; + match req.body_length { + BodyLength::None => { + if body.len != 0 || !body.trailers.is_empty() { + return Err(miette!("bodyless middleware request has spooled payload")); + } + } + BodyLength::ContentLength(expected) => { + if expected != body.len || !body.trailers.is_empty() { + return Err(miette!("middleware request spool framing mismatch")); + } + write_body_bytes(upstream, &tail, options).await?; + } + BodyLength::Chunked => { + write_guarded_chunk(upstream, &tail, options).await?; + write_body_bytes(upstream, b"0\r\n", options).await?; + for trailer in &body.trailers { + let encoded = format!("{}: {}", trailer.name, trailer.value); + if contains_reserved_credential_marker_bytes(encoded.as_bytes()) { + return Err(BodyCredentialError::Trailer.into()); + } + write_body_bytes(upstream, encoded.as_bytes(), options).await?; + write_body_bytes(upstream, b"\r\n", options).await?; + } + write_body_bytes(upstream, b"\r\n", options).await?; + } + } + Ok(()) +} + async fn relay_chunked_with_marker_guard( client: &mut C, upstream: &mut U, @@ -1512,23 +1753,294 @@ struct PreparedRequestBody { body: Vec, } -#[derive(Debug)] -pub(crate) struct BufferedRequestBody { - pub(crate) headers: Vec, - pub(crate) body: Vec, +#[derive(Debug)] +pub(crate) struct BufferedRequestBody { + pub(crate) headers: Vec, + pub(crate) body: Vec, +} + +/// Result of attempting to buffer a request body for middleware inspection. +#[derive(Debug)] +pub(crate) enum BufferResult { + /// The full body was buffered within the size cap. + Buffered(BufferedRequestBody), + /// The body exceeded the inspection cap. `recoverable` is true when no body + /// bytes were consumed yet (a declared `Content-Length` over the cap), so the + /// request can still be streamed through unprocessed under fail-open. It is + /// false once bytes have been consumed (chunked overflow), where denying is + /// the only safe outcome. + OverCapacity { recoverable: bool }, +} + +/// Incremental decoder for one normalized HTTP/1 request body. +pub(crate) enum RequestBodyReader { + None, + Fixed { + buffered: Vec, + buffered_pos: usize, + remaining: u64, + }, + Chunked(ChunkedRequestBodyReader), +} + +pub(crate) struct ChunkedRequestBodyReader { + buffered: Vec, + read_state: ChunkedReadState, + chunk_remaining: usize, + finished: bool, + trailers: Vec, + trailer_bytes: usize, +} + +/// Prepare headers and an incremental normalized body reader for request +/// middleware. This consumes `Expect: 100-continue` locally because middleware +/// must receive the body before `OpenShell` can contact the upstream. +pub(crate) async fn prepare_request_body_stream( + req: &L7Request, + client: &mut C, +) -> Result<(Vec, RequestBodyReader)> { + let header_end = req + .raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map_or(req.raw_header.len(), |position| position + 4); + let mut headers = req.raw_header[..header_end].to_vec(); + let already_read = req.raw_header[header_end..].to_vec(); + let reader = match req.body_length { + BodyLength::None => { + if !already_read.is_empty() { + return Err(miette!( + "HTTP request with no body framing has {} unread byte(s) after headers", + already_read.len() + )); + } + handle_buffered_expect_continue(client, &mut headers, false).await?; + RequestBodyReader::None + } + BodyLength::ContentLength(length) => { + if already_read.len() as u64 > length { + return Err(miette!( + "HTTP request read-ahead exceeds its declared Content-Length" + )); + } + let needs_client_read = already_read.len() as u64 != length; + handle_buffered_expect_continue(client, &mut headers, needs_client_read).await?; + RequestBodyReader::Fixed { + buffered: already_read, + buffered_pos: 0, + remaining: length, + } + } + BodyLength::Chunked => { + let needs_client_read = !chunked_body_is_fully_buffered(&already_read); + handle_buffered_expect_continue(client, &mut headers, needs_client_read).await?; + RequestBodyReader::Chunked(ChunkedRequestBodyReader { + buffered: already_read, + read_state: ChunkedReadState { + buffered_pos: 0, + wire_bytes: 0, + max_wire_bytes: None, + }, + chunk_remaining: 0, + finished: false, + trailers: Vec::new(), + trailer_bytes: 0, + }) + } + }; + Ok((headers, reader)) +} + +impl RequestBodyReader { + pub(crate) async fn next_unit( + &mut self, + client: &mut C, + generation_guard: Option<&PolicyGenerationGuard>, + limit: usize, + ) -> Result>> { + if limit == 0 { + return Err(miette!("request middleware stream unit limit is zero")); + } + match self { + Self::None => Ok(None), + Self::Fixed { + buffered, + buffered_pos, + remaining, + } => { + if *remaining == 0 { + return Ok(None); + } + let length = usize::try_from((*remaining).min(limit as u64)) + .expect("stream unit limit fits usize"); + let mut unit = Vec::with_capacity(length); + let available = length.min(buffered.len().saturating_sub(*buffered_pos)); + if available > 0 { + let end = *buffered_pos + available; + unit.extend_from_slice(&buffered[*buffered_pos..end]); + *buffered_pos = end; + } + while unit.len() < length { + let start = unit.len(); + unit.resize(length, 0); + let read = client.read(&mut unit[start..]).await.into_diagnostic()?; + if read == 0 { + return Err(miette!( + "connection closed with {} request body bytes remaining", + remaining.saturating_sub(start as u64) + )); + } + unit.truncate(start + read); + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + } + *remaining -= unit.len() as u64; + Ok(Some(unit)) + } + Self::Chunked(reader) => reader.next_unit(client, generation_guard, limit).await, + } + } + + pub(crate) fn take_trailers(&mut self) -> Vec { + match self { + Self::Chunked(reader) => std::mem::take(&mut reader.trailers), + Self::None | Self::Fixed { .. } => Vec::new(), + } + } +} + +impl ChunkedRequestBodyReader { + async fn next_unit( + &mut self, + client: &mut C, + generation_guard: Option<&PolicyGenerationGuard>, + limit: usize, + ) -> Result>> { + if self.finished { + return Ok(None); + } + if self.chunk_remaining == 0 { + let size_line = read_chunked_line( + client, + &self.buffered, + &mut self.read_state, + generation_guard, + ) + .await + .map_err(CollectChunkedError::into_report)?; + let size_line = std::str::from_utf8(&size_line) + .map_err(|_| miette!("invalid UTF-8 in chunk-size line"))?; + let size_token = size_line + .split(';') + .next() + .map(str::trim) + .unwrap_or_default(); + self.chunk_remaining = usize::from_str_radix(size_token, 16) + .map_err(|_| miette!("invalid chunk size token: {size_token:?}"))?; + if self.chunk_remaining == 0 { + self.read_trailers(client, generation_guard).await?; + self.finished = true; + return Ok(None); + } + } + + let length = self.chunk_remaining.min(limit); + let mut unit = Vec::with_capacity(length); + read_buffered_exact( + client, + &self.buffered, + &mut self.read_state, + length, + &mut unit, + generation_guard, + ) + .await + .map_err(CollectChunkedError::into_report)?; + self.chunk_remaining -= length; + if self.chunk_remaining == 0 { + let mut terminator = Vec::with_capacity(2); + read_buffered_exact( + client, + &self.buffered, + &mut self.read_state, + 2, + &mut terminator, + generation_guard, + ) + .await + .map_err(CollectChunkedError::into_report)?; + if terminator.as_slice() != b"\r\n" { + return Err(miette!("chunk missing terminating CRLF")); + } + } + Ok(Some(unit)) + } + + async fn read_trailers( + &mut self, + client: &mut C, + generation_guard: Option<&PolicyGenerationGuard>, + ) -> Result<()> { + loop { + let line = read_chunked_line( + client, + &self.buffered, + &mut self.read_state, + generation_guard, + ) + .await + .map_err(CollectChunkedError::into_report)?; + if line.is_empty() { + return Ok(()); + } + self.trailer_bytes = self.trailer_bytes.saturating_add(line.len()); + if self.trailer_bytes > openshell_supervisor_middleware::MAX_MIDDLEWARE_HEADER_BYTES { + return Err(miette!("request trailers exceed platform limit")); + } + if self.trailers.len() >= openshell_supervisor_middleware::MAX_MIDDLEWARE_HEADERS { + return Err(miette!("request trailer count exceeds platform limit")); + } + self.trailers.push(parse_request_trailer(&line)?); + } + } } -/// Result of attempting to buffer a request body for middleware inspection. -#[derive(Debug)] -pub(crate) enum BufferResult { - /// The full body was buffered within the size cap. - Buffered(BufferedRequestBody), - /// The body exceeded the inspection cap. `recoverable` is true when no body - /// bytes were consumed yet (a declared `Content-Length` over the cap), so the - /// request can still be streamed through unprocessed under fail-open. It is - /// false once bytes have been consumed (chunked overflow), where denying is - /// the only safe outcome. - OverCapacity { recoverable: bool }, +fn parse_request_trailer(line: &[u8]) -> Result { + let Some(separator) = line.iter().position(|byte| *byte == b':') else { + return Err(miette!("request trailer is missing ':'")); + }; + let name = &line[..separator]; + let value = &line[separator + 1..]; + if name.is_empty() || !name.iter().copied().all(is_http_field_name_byte) { + return Err(miette!("request trailer has an invalid field name")); + } + if !value.iter().copied().all(is_http_field_value_byte) { + return Err(miette!("request trailer has an invalid field value")); + } + let name = std::str::from_utf8(name) + .expect("validated HTTP field names are ASCII") + .to_ascii_lowercase(); + if matches!( + name.as_str(), + "authorization" + | "content-length" + | "cookie" + | "host" + | "proxy-authorization" + | "te" + | "trailer" + | "transfer-encoding" + ) || name.starts_with("x-amz-") + || name.starts_with("x-openshell-credential") + { + return Err(miette!("request trailer uses protected field '{name}'")); + } + let value = std::str::from_utf8(value) + .map_err(|_| miette!("request trailer value is not valid UTF-8"))? + .trim() + .to_string(); + Ok(HttpHeader { name, value }) } pub(crate) async fn buffer_request_body_for_middleware( @@ -1666,7 +2178,17 @@ fn chunked_body_is_fully_buffered(bytes: &[u8]) -> bool { }; pos = line_end + 2; if chunk_size == 0 { - return bytes.get(pos..pos.saturating_add(2)) == Some(b"\r\n"); + loop { + let Some(trailer_end) = + bytes[pos..].windows(2).position(|window| window == b"\r\n") + else { + return false; + }; + if trailer_end == 0 { + return true; + } + pos = pos.saturating_add(trailer_end + 2); + } } let Some(chunk_end) = pos.checked_add(chunk_size) else { return false; @@ -1720,6 +2242,93 @@ pub(crate) fn rebuild_request_with_buffered_body( }) } +/// Apply request-head mutations without consuming or changing body framing. +pub(crate) fn rebuild_request_headers_only( + req: &L7Request, + header_mutations: &[HeaderMutation], +) -> Result { + let header_end = req + .raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map_or(req.raw_header.len(), |position| position + 4); + let mut raw_header = apply_header_mutations(&req.raw_header[..header_end], header_mutations)?; + raw_header.extend_from_slice(&req.raw_header[header_end..]); + Ok(L7Request { + action: req.action.clone(), + target: req.target.clone(), + query_params: req.query_params.clone(), + raw_header, + body_length: req.body_length, + }) +} + +/// Reframe an incrementally transformed request as HTTP/1.1 chunked transfer. +/// The middleware may change every unit's length, so the supervisor cannot +/// preserve a caller-provided `Content-Length` before the stream completes. +pub(crate) fn rebuild_request_for_incremental_stream( + req: &L7Request, + headers: &[u8], + header_mutations: &[HeaderMutation], +) -> Result { + let mut header_bytes = strip_header(headers, "content-length")?; + header_bytes = strip_header(&header_bytes, "transfer-encoding")?; + header_bytes = append_header(&header_bytes, "Transfer-Encoding", "chunked"); + header_bytes = apply_header_mutations(&header_bytes, header_mutations)?; + Ok(L7Request { + action: req.action.clone(), + target: req.target.clone(), + query_params: req.query_params.clone(), + raw_header: header_bytes, + body_length: BodyLength::Chunked, + }) +} + +/// Rebuild a request whose normalized middleware output lives in a separate +/// spool. The raw request contains only the new head; relay writes the spool. +pub(crate) fn rebuild_request_with_streamed_body( + req: &L7Request, + headers: &[u8], + body_length: u64, + trailers: &[HttpHeader], + header_mutations: &[HeaderMutation], +) -> Result { + let (mut header_bytes, framing) = + if matches!(req.body_length, BodyLength::None) && body_length == 0 && trailers.is_empty() { + let mut headers = strip_header(headers, "content-length")?; + headers = strip_header(&headers, "transfer-encoding")?; + headers = strip_header(&headers, "trailer")?; + (headers, BodyLength::None) + } else if trailers.is_empty() { + let length = usize::try_from(body_length) + .map_err(|_| miette!("middleware request body length is not representable"))?; + let mut headers = set_content_length(headers, length)?; + headers = strip_header(&headers, "transfer-encoding")?; + headers = strip_header(&headers, "trailer")?; + (headers, BodyLength::ContentLength(body_length)) + } else { + let mut headers = strip_header(headers, "content-length")?; + headers = strip_header(&headers, "transfer-encoding")?; + headers = append_header(&headers, "Transfer-Encoding", "chunked"); + headers = strip_header(&headers, "trailer")?; + let names = trailers + .iter() + .map(|trailer| trailer.name.as_str()) + .collect::>() + .join(", "); + headers = append_header(&headers, "Trailer", &names); + (headers, BodyLength::Chunked) + }; + header_bytes = apply_header_mutations(&header_bytes, header_mutations)?; + Ok(L7Request { + action: req.action.clone(), + target: req.target.clone(), + query_params: req.query_params.clone(), + raw_header: header_bytes, + body_length: framing, + }) +} + async fn collect_and_rewrite_request_body( req: &L7Request, client: &mut C, @@ -1791,6 +2400,46 @@ async fn collect_and_rewrite_request_body( } } +async fn collect_and_rewrite_spooled_request_body( + spool: &mut crate::l7::middleware::RequestBodySpool, + rewritten_headers: &[u8], + original_header_str: &str, + resolver: Option<&SecretResolver>, + generation_guard: Option<&PolicyGenerationGuard>, +) -> Result { + if spool.len > MAX_REWRITE_BODY_BYTES as u64 { + return Err(miette!( + "request body credential rewrite buffers at most {MAX_REWRITE_BODY_BYTES} bytes" + )); + } + if !spool.trailers.is_empty() { + return Err(miette!( + "request body credential rewrite does not support transformed request trailers" + )); + } + if let Some(guard) = generation_guard { + guard.ensure_current()?; + } + spool + .file + .seek(SeekFrom::Start(0)) + .await + .into_diagnostic()?; + let capacity = usize::try_from(spool.len) + .map_err(|_| miette!("middleware request body is too large for credential rewrite"))?; + let mut bytes = Vec::with_capacity(capacity); + spool.file.read_to_end(&mut bytes).await.into_diagnostic()?; + if bytes.len() as u64 != spool.len { + return Err(miette!("middleware request spool ended early")); + } + let (mut headers, body) = + rewrite_buffered_body(rewritten_headers, original_header_str, bytes, resolver)?; + headers = set_content_length(&headers, body.len())?; + headers = strip_header(&headers, "transfer-encoding")?; + headers = strip_header(&headers, "trailer")?; + Ok(PreparedRequestBody { headers, body }) +} + fn rewrite_buffered_body( headers: &[u8], original_header_str: &str, @@ -2957,65 +3606,6 @@ fn has_expect_continue(headers: &str) -> bool { }) } -/// Resolved payload signing mode for a `SigV4` request. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SigV4PayloadMode { - /// Buffer body and include its SHA-256 hash in the signature. - SignBody, - /// Use literal `UNSIGNED-PAYLOAD` — no body buffering needed. - UnsignedPayload, - /// Use `STREAMING-UNSIGNED-PAYLOAD-TRAILER` for `aws-chunked` streams. - StreamingUnsignedTrailer, -} - -impl fmt::Display for SigV4PayloadMode { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::SignBody => write!(f, "sign_body"), - Self::UnsignedPayload => write!(f, "unsigned_payload"), - Self::StreamingUnsignedTrailer => write!(f, "streaming_unsigned_trailer"), - } - } -} - -/// Auto-detect the payload signing mode from the client's original headers. -/// -/// Mirrors the mode the client SDK chose by inspecting `x-amz-content-sha256`: -/// - `STREAMING-UNSIGNED-PAYLOAD-TRAILER` → `StreamingUnsignedTrailer` -/// - `UNSIGNED-PAYLOAD` → `UnsignedPayload` -/// - Hex hash → `SignBody` (buffer + hash, requires `Content-Length`) -/// - `STREAMING-AWS4-HMAC-SHA256-PAYLOAD` → **rejected** (the proxy cannot -/// reproduce per-chunk signatures; use `sigv4:no_body` instead) -/// - Other `STREAMING-*` values → **rejected** (unsupported streaming mode) -/// - Absent → `SignBody` if `Content-Length` present, else `UnsignedPayload` -fn detect_payload_mode(headers: &str) -> Result { - for line in headers.lines().skip(1) { - let lower = line.to_ascii_lowercase(); - if lower.starts_with("x-amz-content-sha256:") { - let val = lower.split_once(':').map_or("", |(_, v)| v.trim()); - return match val { - "streaming-unsigned-payload-trailer" => { - Ok(SigV4PayloadMode::StreamingUnsignedTrailer) - } - "unsigned-payload" => Ok(SigV4PayloadMode::UnsignedPayload), - v if v.starts_with("streaming-") => Err(miette!( - "SigV4 auto-detect does not support chunk-signed streaming mode \ - '{v}'; use credential_signing: sigv4:no_body to stream \ - with UNSIGNED-PAYLOAD instead" - )), - _ => Ok(SigV4PayloadMode::SignBody), - }; - } - } - Ok( - if matches!(parse_body_length(headers)?, BodyLength::ContentLength(_)) { - SigV4PayloadMode::SignBody - } else { - SigV4PayloadMode::UnsignedPayload - }, - ) -} - /// Parse Content-Length or Transfer-Encoding from HTTP headers. /// /// Per RFC 7230 Section 3.3.3, rejects requests containing both @@ -3539,13 +4129,13 @@ mod tests { use openshell_core::endpoint_status::{EndpointStatusCommand, EndpointStatusReceiver}; 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, + 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; @@ -3560,6 +4150,119 @@ mod tests { const VALID_WS_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; const TEXT_OPCODE: u8 = 0x1; + fn request_with_body(raw_header: Vec, body_length: BodyLength) -> L7Request { + L7Request { + action: "POST".into(), + target: "/push".into(), + query_params: HashMap::new(), + raw_header, + body_length, + } + } + + #[tokio::test] + async fn request_body_stream_reads_fixed_body_larger_than_former_unary_limit() { + let body_len = 4 * 1024 * 1024 + 17; + let headers = format!( + "POST /push HTTP/1.1\r\nHost: example.com\r\nContent-Length: {body_len}\r\n\r\n" + ) + .into_bytes(); + let request = request_with_body(headers, BodyLength::ContentLength(body_len as u64)); + let (mut client, mut writer) = tokio::io::duplex(1024); + let write = tokio::spawn(async move { + let chunk = vec![b'x'; 64 * 1024]; + let mut remaining = body_len; + while remaining > 0 { + let length = remaining.min(chunk.len()); + writer.write_all(&chunk[..length]).await.unwrap(); + remaining -= length; + } + }); + + let (_headers, mut reader) = prepare_request_body_stream(&request, &mut client) + .await + .unwrap(); + let mut received = 0usize; + while let Some(unit) = reader + .next_unit(&mut client, None, 64 * 1024) + .await + .unwrap() + { + assert!(unit.len() <= 64 * 1024); + assert!(unit.iter().all(|byte| *byte == b'x')); + received += unit.len(); + } + write.await.unwrap(); + assert_eq!(received, body_len); + assert!(reader.take_trailers().is_empty()); + } + + #[tokio::test] + async fn request_body_stream_normalizes_chunks_and_preserves_safe_trailers() { + let headers = b"POST /push HTTP/1.1\r\nHost: example.com\r\nTransfer-Encoding: chunked\r\nTrailer: X-Trace\r\n\r\n".to_vec(); + let request = request_with_body(headers, BodyLength::Chunked); + let (mut client, mut writer) = tokio::io::duplex(128); + let write = tokio::spawn(async move { + writer + .write_all(b"4\r\nWiki\r\n5;sample=yes\r\npedia\r\n0\r\nX-Trace: complete\r\n\r\n") + .await + .unwrap(); + }); + + let (prepared_headers, mut reader) = prepare_request_body_stream(&request, &mut client) + .await + .unwrap(); + let mut normalized = Vec::new(); + while let Some(unit) = reader.next_unit(&mut client, None, 3).await.unwrap() { + assert!(unit.len() <= 3); + normalized.extend_from_slice(&unit); + } + write.await.unwrap(); + assert_eq!(normalized, b"Wikipedia"); + let trailers = reader.take_trailers(); + assert_eq!( + trailers, + [HttpHeader { + name: "x-trace".into(), + value: "complete".into(), + }] + ); + + let rebuilt = rebuild_request_with_streamed_body( + &request, + &prepared_headers, + normalized.len() as u64, + &trailers, + &[], + ) + .unwrap(); + assert!(matches!(rebuilt.body_length, BodyLength::Chunked)); + let rebuilt_headers = String::from_utf8(rebuilt.raw_header.clone()).unwrap(); + assert!(rebuilt_headers.contains("Transfer-Encoding: chunked\r\n")); + assert!(rebuilt_headers.contains("Trailer: x-trace\r\n")); + assert!( + !rebuilt_headers + .to_ascii_lowercase() + .contains("content-length:") + ); + + let mut file = tokio::fs::File::from_std(tempfile::tempfile().unwrap()); + file.write_all(&normalized).await.unwrap(); + let mut spool = crate::l7::middleware::RequestBodySpool { + file, + len: normalized.len() as u64, + trailers: trailers.clone(), + }; + let mut upstream = Vec::new(); + relay_spooled_request_body(&rebuilt, &mut upstream, &mut spool, None) + .await + .unwrap(); + assert_eq!( + upstream, + b"9\r\nWikipedia\r\n0\r\nx-trace: complete\r\n\r\n" + ); + } + #[derive(Clone, Copy)] enum ResponseRelayScript { HeadersOnly, @@ -3620,14 +4323,53 @@ mod tests { Ok(()) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - _request: openshell_supervisor_middleware::HttpRequestView<'_>, - ) -> Result { - Ok(HttpRequestResult { - decision: Decision::Allow as i32, - ..Default::default() - }) + mut requests: mpsc::Receiver, + ) -> std::result::Result< + openshell_supervisor_middleware::HttpRequestResultStream, + tonic::Status, + > { + assert!( + self.request_only, + "response-only service received a request" + ); + let (sender, receiver) = mpsc::channel(2); + tokio::spawn(async move { + use openshell_core::proto::{ + HttpRequestBodyMode, HttpRequestEventResult, HttpRequestPreflightInspect, + HttpRequestPreflightResult, http_request_event, http_request_event_result, + http_request_preflight_result, + }; + while let Some(event) = requests.recv().await { + match event.event { + Some(http_request_event::Event::Preflight(_)) => { + let result = HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some( + http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::HeadersOnly + as i32, + header_mutations: Vec::new(), + }, + ), + ), + ..Default::default() + }, + )), + }; + if sender.send(Ok(result)).await.is_err() { + break; + } + } + Some(http_request_event::Event::SessionEnd(_)) | None => break, + Some(_) => panic!("headers-only request received an unexpected event"), + } + } + }); + Ok(Box::pin(ReceiverStream::new(receiver))) } async fn open_http_response_pre_return( @@ -4980,6 +5722,46 @@ mod tests { ); } + #[tokio::test] + async fn credential_rewrite_uses_spooled_middleware_output() { + let (child_env, resolver) = SecretResolver::from_provider_env( + [("API_TOKEN".to_string(), "provider-real-token".to_string())] + .into_iter() + .collect(), + ); + let resolver = resolver.expect("resolver"); + let body = format!(r#"{{"token":"{}"}}"#, child_env["API_TOKEN"]); + let headers = format!( + "POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", + body.len() + ); + let mut file = tokio::fs::File::from_std(tempfile::tempfile().unwrap()); + file.write_all(body.as_bytes()).await.unwrap(); + file.flush().await.unwrap(); + let mut spool = crate::l7::middleware::RequestBodySpool { + file, + len: body.len() as u64, + trailers: Vec::new(), + }; + + let rewritten = collect_and_rewrite_spooled_request_body( + &mut spool, + headers.as_bytes(), + &headers, + Some(&resolver), + None, + ) + .await + .expect("rewrite middleware output"); + + assert_eq!(rewritten.body, br#"{"token":"provider-real-token"}"#); + assert!( + String::from_utf8(rewritten.headers) + .unwrap() + .contains(&format!("Content-Length: {}\r\n", rewritten.body.len())) + ); + } + #[tokio::test] async fn collect_chunked_body_reads_payload_in_blocks() { let payload_len = 64 * 1024; @@ -9556,9 +10338,14 @@ mod tests { &mut proxy_to_upstream, RelayRequestOptions { resolver: Some(&resolver), - credential_signing: crate::l7::CredentialSigning::SigV4Body, - signing_service: "s3", - signing_region: "us-east-1", + post_credentials: + crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( + crate::l7::CredentialSigning::SigV4Body, + "s3", + "us-east-1", + "s3.us-east-1.amazonaws.com", + 443, + ), host: "s3.us-east-1.amazonaws.com", port: 443, ..Default::default() @@ -9724,70 +10511,6 @@ mod tests { ); } - #[test] - fn detect_payload_mode_unsigned_payload() { - let headers = "PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nX-Amz-Content-Sha256: UNSIGNED-PAYLOAD\r\n\r\n"; - assert_eq!( - detect_payload_mode(headers).unwrap(), - SigV4PayloadMode::UnsignedPayload - ); - } - - #[test] - fn detect_payload_mode_streaming_unsigned_trailer() { - let headers = "PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nX-Amz-Content-Sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER\r\n\r\n"; - assert_eq!( - detect_payload_mode(headers).unwrap(), - SigV4PayloadMode::StreamingUnsignedTrailer - ); - } - - #[test] - fn detect_payload_mode_hex_hash_is_sign_body() { - let headers = "POST /model/invoke HTTP/1.1\r\nHost: bedrock.amazonaws.com\r\nX-Amz-Content-Sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\r\nContent-Length: 10\r\n\r\n"; - assert_eq!( - detect_payload_mode(headers).unwrap(), - SigV4PayloadMode::SignBody - ); - } - - #[test] - fn detect_payload_mode_rejects_chunk_signed_streaming() { - let headers = "PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nX-Amz-Content-Sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD\r\n\r\n"; - let result = detect_payload_mode(headers); - assert!(result.is_err()); - let msg = result.unwrap_err().to_string(); - assert!( - msg.contains("sigv4:no_body"), - "error should suggest sigv4:no_body, got: {msg}" - ); - } - - #[test] - fn detect_payload_mode_rejects_unknown_streaming() { - let headers = "PUT /bucket/key HTTP/1.1\r\nHost: s3.us-east-1.amazonaws.com\r\nX-Amz-Content-Sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD-TRAILER\r\n\r\n"; - let result = detect_payload_mode(headers); - assert!(result.is_err()); - } - - #[test] - fn detect_payload_mode_absent_with_content_length() { - let headers = "POST /model/invoke HTTP/1.1\r\nHost: bedrock.amazonaws.com\r\nContent-Length: 42\r\n\r\n"; - assert_eq!( - detect_payload_mode(headers).unwrap(), - SigV4PayloadMode::SignBody - ); - } - - #[test] - fn detect_payload_mode_absent_without_content_length() { - let headers = "GET /bucket HTTP/1.1\r\nHost: s3.amazonaws.com\r\n\r\n"; - assert_eq!( - detect_payload_mode(headers).unwrap(), - SigV4PayloadMode::UnsignedPayload - ); - } - #[test] fn response_body_transform_strips_stale_integrity_headers() { let mut headers = [ diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index 77ed825088..ac6b65f76c 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -3587,14 +3587,6 @@ network_policies: )) } - async fn evaluate_http_request( - &self, - _request: Request, - ) -> std::result::Result, Status> - { - Err(Status::unimplemented("WebSocket-only test middleware")) - } - async fn evaluate_web_socket_session( &self, request: Request>, diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 458e236f01..810d683dd0 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -19,7 +19,6 @@ pub mod policy_local; pub mod procfs; pub mod proxy; pub mod run; -pub mod sigv4; mod spiffe_endpoint; mod token_grant; pub mod upstream_proxy; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 02206063a2..bbcb9c8c56 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1548,6 +1548,7 @@ impl ForwardMiddlewarePipeline<'_> { C: TokioAsyncRead + TokioAsyncWrite + Unpin + Send, { let validate; + let accept_without_reevaluation = |_body: &[u8]| Ok(None); let transformed_body_policy = match &self.l7_reevaluation { Some(l7) => { validate = crate::l7::relay::transformed_body_validator( @@ -1558,7 +1559,12 @@ impl ForwardMiddlewarePipeline<'_> { ); openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate(&validate) } - None => openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, + // The forward-proxy request is already fully buffered before this + // pipeline runs. Keep it on the compatibility path until the + // forward relay can carry a storage-backed request body. + None => openshell_supervisor_middleware::TransformedBodyPolicy::Reevaluate( + &accept_without_reevaluation, + ), }; self.exchange @@ -4708,9 +4714,7 @@ struct ForwardRelayOptions<'a> { secret_resolver: Option<&'a SecretResolver>, request_body_credential_rewrite: bool, deny_uninspected_credentials: bool, - credential_signing: crate::l7::CredentialSigning, - signing_service: &'a str, - signing_region: &'a str, + post_credentials: Option>, host: &'a str, port: u16, response_middleware: Option>, @@ -4768,12 +4772,11 @@ where websocket_extensions: options.websocket_extensions, request_body_credential_rewrite: options.request_body_credential_rewrite, deny_uninspected_credentials: options.deny_uninspected_credentials, - credential_signing: options.credential_signing, - signing_service: options.signing_service, - signing_region: options.signing_region, + post_credentials: options.post_credentials, host: options.host, port: options.port, }, + None, response_middleware, options.endpoint_observer, ) @@ -5768,8 +5771,14 @@ async fn handle_forward_proxy( exchange: &middleware_exchange, l7_reevaluation, }; - forward_request_bytes = match pipeline.apply(request, client).await? { + let middleware_result = pipeline.apply(request, client).await?; + forward_request_bytes = match middleware_result { crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request.raw_header, + crate::l7::middleware::MiddlewareApplyResult::Streamed { .. } => { + return Err(miette::miette!( + "forward middleware unexpectedly returned a storage-backed request body" + )); + } crate::l7::middleware::MiddlewareApplyResult::Denied { denial, .. } => { emit_activity_simple(activity_tx, true, "middleware"); let response = denial.as_ref().map_or_else( @@ -6094,17 +6103,15 @@ async fn handle_forward_proxy( return Ok(()); } - let credential_signing = forward_upgrade_config - .as_ref() - .map_or(crate::l7::CredentialSigning::None, |config| { - config.credential_signing - }); - let signing_service = forward_upgrade_config - .as_ref() - .map_or("", |config| config.signing_service.as_str()); - let signing_region = forward_upgrade_config - .as_ref() - .map_or("", |config| config.signing_region.as_str()); + let post_credentials = forward_upgrade_config.as_ref().and_then(|config| { + crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( + config.credential_signing, + &config.signing_service, + &config.signing_region, + &host_lc, + port, + ) + }); let outcome_result = relay_rewritten_forward_request( method, &upstream_target, @@ -6119,9 +6126,7 @@ async fn handle_forward_proxy( body_classifier: endpoint_credentials.body_classifier.as_deref(), request_body_credential_rewrite, deny_uninspected_credentials, - credential_signing, - signing_service, - signing_region, + post_credentials, host: &host_lc, port, response_middleware: response_selection.as_ref().map(|exchange| { @@ -6709,16 +6714,6 @@ process: )) } - async fn evaluate_http_request( - &self, - _request: tonic::Request, - ) -> std::result::Result< - tonic::Response, - tonic::Status, - > { - Err(tonic::Status::unimplemented("WebSocket-only test service")) - } - async fn open_websocket_session( &self, mut receiver: mpsc::Receiver, @@ -6791,16 +6786,6 @@ process: 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, @@ -6897,16 +6882,48 @@ process: Ok(()) } - async fn evaluate_http_request( + async fn open_http_request_pre_credentials( &self, - _request: openshell_core::middleware::HttpRequestView<'_>, - ) -> Result { - self.entered.notify_one(); - self.release.notified().await; - Ok(openshell_core::proto::HttpRequestResult { - decision: openshell_core::proto::Decision::Allow as i32, - ..Default::default() - }) + mut requests: mpsc::Receiver, + ) -> std::result::Result + { + let entered = Arc::clone(&self.entered); + let release = Arc::clone(&self.release); + let (sender, receiver) = mpsc::channel(2); + tokio::spawn(async move { + use openshell_core::proto::{ + HttpRequestBodyMode, HttpRequestEventResult, HttpRequestPreflightInspect, + HttpRequestPreflightResult, http_request_event, http_request_event_result, + http_request_preflight_result, + }; + let Some(openshell_core::proto::HttpRequestEvent { + event: Some(http_request_event::Event::Preflight(_)), + }) = requests.recv().await + else { + return; + }; + entered.notify_one(); + release.notified().await; + let _ = sender + .send(Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::HeadersOnly as i32, + header_mutations: Vec::new(), + }, + )), + ..Default::default() + }, + )), + })) + .await; + while requests.recv().await.is_some() {} + }); + Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new( + receiver, + ))) } } @@ -8350,6 +8367,9 @@ network_policies: crate::l7::middleware::MiddlewareApplyResult::Denied { denial } => { assert!(denial.is_none()); } + crate::l7::middleware::MiddlewareApplyResult::Streamed { .. } => { + panic!("body-aware forward middleware must use the buffered policy path") + } crate::l7::middleware::MiddlewareApplyResult::Allowed(_) => { panic!("policy-invalid transformed request must be denied") } @@ -8447,6 +8467,9 @@ network_policies: 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::Streamed { .. } => { + panic!("forward middleware compatibility path must stay buffered") + } crate::l7::middleware::MiddlewareApplyResult::Denied { .. } => { panic!("blocking middleware should allow after release") } @@ -8615,9 +8638,7 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", + post_credentials: None, host: "api.example.test", port: 80, response_middleware: Some(ForwardResponseMiddleware { @@ -8704,9 +8725,7 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", + post_credentials: None, host: "api.example.test", port: 80, response_middleware: Some(ForwardResponseMiddleware { @@ -8826,9 +8845,7 @@ network_policies: secret_resolver: resolver, request_body_credential_rewrite, deny_uninspected_credentials: body_classifier.is_some(), - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", + post_credentials: None, host: "", port: 0, response_middleware: None, @@ -9093,9 +9110,7 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", + post_credentials: None, host: "", port: 0, response_middleware: None, @@ -11641,9 +11656,7 @@ network_policies: secret_resolver: Some(&resolver), request_body_credential_rewrite: true, deny_uninspected_credentials: false, - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", + post_credentials: None, host: "", port: 0, response_middleware: None, @@ -11725,9 +11738,14 @@ network_policies: secret_resolver: Some(&resolver), request_body_credential_rewrite: false, deny_uninspected_credentials: false, - credential_signing: crate::l7::CredentialSigning::SigV4NoBody, - signing_service: "execute-api", - signing_region: "us-west-2", + post_credentials: + crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( + crate::l7::CredentialSigning::SigV4NoBody, + "execute-api", + "us-west-2", + "api.example.com", + 80, + ), host: "api.example.com", port: 80, response_middleware: None, @@ -11816,9 +11834,7 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", + post_credentials: None, host: "", port: 0, response_middleware: None, @@ -11869,9 +11885,7 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", + post_credentials: None, host: "", port: 0, response_middleware: None, diff --git a/crates/openshell-supervisor-network/tests/sigv4_localstack.rs b/crates/openshell-supervisor-network/tests/sigv4_localstack.rs index 2d76b7e2f7..d5d103f6e5 100644 --- a/crates/openshell-supervisor-network/tests/sigv4_localstack.rs +++ b/crates/openshell-supervisor-network/tests/sigv4_localstack.rs @@ -5,7 +5,9 @@ // Requires LocalStack running on localhost:4566. // Run with: cargo test -p openshell-supervisor-network --test sigv4_localstack -- --ignored --nocapture -use openshell_supervisor_network::sigv4::{apply_sigv4_headers_only, apply_sigv4_to_request}; +use openshell_supervisor_middleware_builtins::sigv4::{ + apply_sigv4_headers_only, apply_sigv4_to_request, +}; use std::sync::atomic::{AtomicU32, Ordering}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index 13935436c4..d04044aecd 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -17,10 +17,13 @@ For each inspected HTTP request, the supervisor: 1. Evaluates network and L7 policy. 2. Selects middleware whose host selectors match the admitted destination. -3. Buffers the request body using the largest body limit in the selected chain. -4. Runs matching middleware by ascending `order`. Policy validation rejects duplicate order values. -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. +3. Opens one `HttpRequestPreCredentials.Evaluate` bidirectional stream for each matching request binding, in ascending `order`. +4. Sends preflight with the admitted target, safe headers, config, limits, and permitted body modes. The stage skips, blocks, or selects header-only, whole-body, lockstep streaming, or owned streaming inspection. +5. Streams normalized body bytes and trailers through the active chain with bounded units and backpressure. A chain made only of `STREAM_BYTES` stages can forward each approved unit upstream immediately. Whole-body, owned, and other withholding paths spool transformed output outside the RPC envelope first. +6. Re-checks body-aware protocol policy (GraphQL, JSON-RPC, MCP) after each stage that replaces the body. These protocols retain a bounded hold barrier so every later stage and the upstream receive a payload admitted by policy. +7. Injects provider credentials and forwards the request. + +The request stream carries HTTP/1.x payload bytes without transfer framing. OpenShell decodes chunked input before evaluation, validates request trailers, and computes the final `Content-Length` or chunked framing after the chain completes. A stage never receives credential, routing, framing, hop-by-hop, or `Connection`-nominated headers. 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. @@ -35,7 +38,7 @@ The protobuf represents each logical message with a `text` or `binary` payload v The network supervisor reserves process-wide assembly capacity before buffering every parsed WebSocket text message, even when no middleware is selected. At most 32 assemblies run while 64 additional callers wait without buffering payload bytes. When both bounds are full, OpenShell closes the WebSocket with code `1013` before reading the new message payload. A text message may contain at most 4,096 fragments, must make input progress within 30 seconds, and must finish assembly within 2 minutes. Forwarding the completed text frame must finish within another 2 minutes. The assembly budget lasts for the supervisor process lifetime, so policy reloads do not reset its capacity. -Active middleware sessions additionally reserve shared middleware capacity before buffering WebSocket text, and HTTP middleware reserves the same capacity before buffering request bodies; at most 32 evaluations run and 64 additional unbuffered callers wait for capacity. When both middleware bounds are full, OpenShell sheds an HTTP request with `503 Service Unavailable` before reading its body. Persistent middleware streams use a separate process-wide budget of 32 sessions. WebSocket session admission does not wait: if the budget is full, OpenShell applies each selected config's `on_error` behavior before opening a stream. +Active middleware sessions additionally reserve shared middleware capacity before buffering WebSocket text or advancing an HTTP request stream; at most 32 evaluations run and 64 additional callers wait without buffering payload bytes. When both middleware bounds are full, OpenShell sheds an HTTP request with `503 Service Unavailable` before reading its body. Persistent middleware streams use a separate process-wide budget of 32 sessions. WebSocket session admission does not wait: if the budget is full, OpenShell applies each selected config's `on_error` behavior before opening a stream. Because each transformed body is re-checked before the next stage runs, a middleware hook always receives a request that satisfies the sandbox policy. A stage whose output the policy rejects stops the chain; under `enforcement: audit` the rejection is logged and the request proceeds. @@ -54,7 +57,9 @@ The request context identifies the originating sandbox to operator-run services. `openshell/regex` is an example built-in middleware. It replaces only simple, self-contained token patterns in UTF-8 HTTP bodies and client WebSocket text messages; the initial pattern recognizes `sk-` tokens. It does not infer values from keyword assignments such as JSON `password` fields. This best-effort text transformation is not parser-aware and does not guarantee that it will detect or fully remove sensitive values. Its `config` accepts one field, `mode: redact`, which is also the default when the field is omitted. Unknown config fields and non-string values are rejected at policy validation. Custom expressions are not configurable yet. -Operator-run services expose bindings for supported operation and phase pairs. A binding is identified by its operation and phase. V1 supports `HttpRequest/pre_credentials` and `WebSocketMessage/pre_credentials`; a service may expose either or both. Policies attach the complete middleware by its operator-owned gateway registration name. +Operator-run services expose bindings for supported operation and phase pairs. A binding is identified by its operation and phase. V1 supports external `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` bindings. Policies attach the complete middleware by its operator-owned gateway registration name. + +`openshell/sigv4` is a restricted in-process built-in at `HTTP_REQUEST/POST_CREDENTIALS`. The REST endpoint's `credential_signing` fields configure it; do not attach it through `network_middlewares`. External registrations that advertise `POST_CREDENTIALS` are rejected because that phase can see supervisor-resolved credentials. ## Register a Middleware Service @@ -77,15 +82,40 @@ timeout = "500ms" | `tls_ca_cert_path` | Optional PEM trust roots for a private HTTPS service. Custom roots replace platform roots and retain hostname verification. | | `audience` | Exact audience expected by the service. Defaults to `urn:openshell:extension:middleware:`. | | `allow_insecure_transport` | Opt this registration out of extension authentication, permitting a plaintext `http://` endpoint with no bearer credential. Defaults to `false`. Development and trusted-network deployments only. | -| `max_payload_bytes` | Shared operator limit applied to inspectable logical payloads across every binding exposed by the service, up to the 4 MiB platform maximum. It caps HTTP bodies and complete WebSocket text messages. | +| `max_payload_bytes` | Shared operator limit applied across every binding, up to the 4 MiB platform maximum. For streamed HTTP request and response modes it caps each unit; for whole-body modes and WebSocket text it caps the complete logical payload. | | `timeout` | Optional service-wide RPC timeout using an integer with an `ms` or `s` suffix. Defaults to `500ms`; valid values range from `10ms` through `30s`. | -Each binding returned by `Describe` may advertise a shorter `timeout` using the same syntax and bounds. The operator-configured service timeout is a ceiling: OpenShell uses the smaller of the binding and service values. An omitted binding timeout inherits the service setting, and an omitted service setting uses the 500 ms platform default. OpenShell rejects an invalid timeout before accepting the manifest. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies only to `EvaluateHttpRequest`, WebSocket preflight, and each WebSocket message. WebSocket streams have no connection-wide deadline. +Each binding returned by `Describe` may advertise a shorter `timeout` using the same syntax and bounds. The operator-configured service timeout is a ceiling: OpenShell uses the smaller of the binding and service values. An omitted binding timeout inherits the service setting, and an omitted service setting uses the 500 ms platform default. OpenShell rejects an invalid timeout before accepting the manifest. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies to HTTP stream open and each request or response exchange, WebSocket preflight, and each WebSocket message. A 30-second chain deadline separately bounds one HTTP body-unit pass, one HTTP finalization pass, or one WebSocket message across all stages; it is not a lifetime limit on an accepted stream. Request body receipt, middleware processing, and output delivery share a 2-minute wall-clock deadline. WebSocket streams have no connection-wide middleware deadline. The gateway connects to every registered service and verifies its capabilities before accepting traffic. Gateway startup fails when a service is unavailable, reports an invalid capability, or exposes more than one binding for the same operation and phase. The manifest `name` is diagnostic metadata and does not need to match the operator registration name. Operator-run registration names cannot claim the reserved `openshell/` namespace. Registration is static. Restart the gateway after adding, removing, or changing a service. See [Gateway Configuration](/reference/gateway-config#supervisor-middleware-services) for the complete gateway TOML context. +### Implement the HTTP request stream + +HTTP request middleware implements the `HttpRequestPreCredentials` service. Its `Evaluate` RPC is bidirectional streaming and follows this lifecycle: + +1. OpenShell sends one `HttpRequestPreflight`. Return `skip`, `block_request`, or `inspect`. An inspecting stage applies any request-header mutations and selects one permitted body mode. +2. For a body mode, OpenShell sends contiguous `HttpRequestBodyUnit` sequences. `WHOLE_BODY_BYTES` receives the complete body in its final unit. Streaming and owned modes receive nonempty data units followed by one empty unit with `end_of_stream`; an empty body also produces that empty final unit. +3. Return one matching `HttpRequestBodyResult` per input unit. `STREAM_BYTES` must account for the current unit without retaining bytes. `OWNED_STREAM_BYTES` acknowledges each unit with `take_ownership`, then returns contiguous `body_output` units and exactly one `body_finalize` after final input. +4. OpenShell sends one trailers event to active body stages, including an empty trailer set. Return ordered mutations only for safe, existing trailer fields. +5. OpenShell sends a best-effort `session_end`, half-closes the request stream, and briefly drains results. + +The body modes are: + +| Mode | Contract | Failure recovery | +| --- | --- | --- | +| `HEADERS_ONLY` | Inspect preflight and mutate safe request headers. | Follows `on_error`. | +| `WHOLE_BODY_BYTES` | Receive one final normalized body unit. The complete body and replacement must fit `max_payload_bytes`. | Follows `on_error`; the original remains available. | +| `STREAM_BYTES` | Receive and return bounded units in lockstep. A result cannot retain input across units. | Follows `on_error`; the current unit remains available. | +| `OWNED_STREAM_BYTES` | Durably accept each input unit, then emit a new bounded representation and final accounting. Intended for transformations such as Git pack signing. | Permitted only for `fail_closed`; the original is no longer replayable. | + +OpenShell caps request stream units at 64 KiB even when the binding advertises more. Owned input and output are each capped at 1 GiB and should be spooled rather than retained in memory. The service must respect the `max_deferred_bytes` advertised in preflight. + +For a chain made only of `STREAM_BYTES` stages, OpenShell may send each approved unit upstream before receiving the rest of the request. A later block or failure terminates the upstream upload but cannot retract bytes already sent. OpenShell does not retry or replay the request. It also reads the upstream response concurrently; an early response cancels the middleware session, stops upload, and makes the downstream HTTP/1 connection non-reusable. Any whole-body or owned stage forces withholding for the complete chain. Body-aware policy re-evaluation, request-body credential rewriting, and body-dependent request signing also force withholding. + +The former unary `SupervisorMiddleware.EvaluateHttpRequest` RPC and its `HttpRequestEvaluation` and `HttpRequestResult` messages have been removed. Services must expose `HttpRequestPreCredentials` alongside `SupervisorMiddleware`; OpenShell does not fall back to the unary API. + ### Authenticate OpenShell Callers When gateway JWT signing is configured, OpenShell attaches a short-lived EdDSA bearer token to every remote middleware RPC. Gateway calls use `caller_kind: gateway`; sandbox supervisor calls use `caller_kind: supervisor` and include the sandbox ID. Supervisors request credentials by registration name through `RefreshSandboxToken`. The gateway derives the audience from operator-owned configuration and authorizes each name against the sandbox's effective policy. @@ -170,16 +200,18 @@ Middleware decisions are enforced regardless of the endpoint's `enforcement` mod ## Set Payload Limits -Every middleware binding declares the largest logical payload or replacement it supports through `max_payload_bytes`. For `HTTP_REQUEST`, that payload is one request body. For `WEBSOCKET_MESSAGE`, it is one complete message rather than the whole session. +Every middleware binding declares the largest payload unit or complete buffered payload it supports through `max_payload_bytes`. - Built-in middleware uses its OpenShell-defined limit. - Each operator-run registration sets one `max_payload_bytes` ceiling no higher than any binding's advertised `max_payload_bytes` capability. -- A selected chain buffers using its largest stage limit, so every stage that can process the body receives it. -- The same per-stage limit applies to request bodies and replacement bodies. +- Whole-body request and response modes apply it to the complete body and replacement. +- Streaming request and response modes apply it to each input or output unit. OpenShell further caps request units at 64 KiB. +- WebSocket mode applies it to one complete text message or replacement, not the whole session. +- Owned request streams separately advertise a 1 GiB deferred input/output limit and require `fail_closed`. -The gateway rejects a registration whose operator limit exceeds the service capability or the 4 MiB platform maximum instead of silently clamping it. OpenShell also bounds the non-payload protobuf components: 64 KiB for service config, 4 KiB for request context, 32 KiB for the target, and 128 request header lines totaling at most 64 KiB encoded. Results allow a 4 KiB discarded free-form reason, a 64-byte validated reason code, 64 header mutations totaling at most 64 KiB encoded, 32 findings of at most 4 KiB encoded each, and 64 metadata entries totaling at most 32 KiB. Middleware gRPC servers should configure request and response message limits to at least 4 MiB plus 293 KiB so every platform-valid envelope fits. +The gateway rejects a registration whose operator limit exceeds the service capability or the 4 MiB platform maximum instead of silently clamping it. OpenShell also bounds the non-payload protobuf components: 64 KiB for service config, 4 KiB for request context, 32 KiB for the target, and 128 request header lines totaling at most 64 KiB encoded. Results allow a 4 KiB discarded free-form reason, a 64-byte validated reason code, 64 header mutations totaling at most 64 KiB encoded, 32 findings of at most 4 KiB encoded each, and 64 metadata entries totaling at most 32 KiB. Middleware gRPC servers should configure messages for their advertised limit plus the bounded envelope. Request streaming needs only the 64 KiB unit plus that envelope even when the complete body is larger than 4 MiB. -At request time, exceeding a selected stage's limit is a middleware failure for that stage alone and follows that config's `on_error` behavior; other stages in the chain still run against their own limits. OpenShell can apply `fail_open` to an oversized `Content-Length` before consuming body bytes. A chunked body can cross the limit only after bytes have been consumed, so OpenShell denies that request because it cannot safely resume the original stream. +At request time, an unavailable selected mode, oversized unit, invalid sequence, or incomplete finalization is a middleware failure. A non-owned stage follows its config's `on_error`; an owned stage always fails closed after ownership begins. Whole-body stages can fail independently when the complete body exceeds their limit, while streaming stages continue with bounded units. For a WebSocket binding, `max_payload_bytes` covers complete client text messages and replacements. Exceeding a selected stage's effective text-message limit follows that stage's `on_error`. The 4 MiB parsed-text platform cap and other protocol-safety limits are independent of middleware failure policy. Binary messages are not delivered to middleware, so the operator ceiling does not become a binary relay limit; individual raw binary frames retain the 16 MiB relay-safety bound. Oversized parsed text closes the connection with code `1009`; invalid UTF-8 uses `1007`; protocol errors use `1002`; middleware or policy denials use `1008`; and policy reload uses `1012`. @@ -230,6 +262,8 @@ The [content guard example](https://github.com/NVIDIA/OpenShell/tree/main/exampl The example includes a policy, local fixture, and smoke launcher. +The [Git signing example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-git-signing) uses owned request streaming to spool a Git smart-HTTP push, sign rewritten commit objects with a host-held SSH key, and stream the replacement pack back. Its tests exercise a receive-pack body larger than 4 MiB. It is a proof of concept, not a production signing service. + ## 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. diff --git a/docs/providers/aws-sigv4.mdx b/docs/providers/aws-sigv4.mdx index 101d796e5c..cc5b98b102 100644 --- a/docs/providers/aws-sigv4.mdx +++ b/docs/providers/aws-sigv4.mdx @@ -7,7 +7,9 @@ description: "Configure proxy-side AWS SigV4 request signing so sandbox agents c keywords: "Generative AI, Cybersecurity, AI Agents, AWS, SigV4, Bedrock, S3, Credential Signing, Sandbox" --- -AWS SigV4 credential signing lets sandbox agents call AWS services (Bedrock, S3, STS, and others) through the proxy's CONNECT tunnel. The proxy intercepts outbound requests, strips the sandbox client's placeholder `Authorization` header, and re-signs the request with real AWS credentials from the provider. The sandbox never sees the real credentials. +AWS SigV4 credential signing lets sandbox agents call AWS services (Bedrock, S3, STS, and others) through the proxy's CONNECT tunnel. The restricted in-process `openshell/sigv4` middleware runs at `HTTP_REQUEST/POST_CREDENTIALS`, strips the sandbox client's placeholder `Authorization` header, and re-signs the request with real AWS credentials from the provider. The sandbox and external middleware never see the real credentials. + +Configure this built-in with the endpoint fields below. Do not attach it through `network_middlewares`; OpenShell reserves the credential-visible `POST_CREDENTIALS` phase for trusted in-process implementations. ## Prerequisites diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 75599ae19f..380a545836 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -356,11 +356,11 @@ 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`, `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. +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. Operator-run services may expose `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. `HTTP_REQUEST/POST_CREDENTIALS` is reserved for trusted in-process built-ins such as `openshell/sigv4`; the gateway rejects an external manifest that advertises it. 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 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. +`max_payload_bytes` is the shared operator limit across every binding exposed by the service. Whole-body request and response inspection uses it as the stage's total body and replacement limit. Streaming request and response inspection applies it to each unit; OpenShell caps request units at 64 KiB. Complete WebSocket text messages and replacements also use it. Owned request streams can process a larger complete representation under a separately advertised, fail-closed 1 GiB deferred-storage bound. The value must be greater than zero, no larger than each binding's advertised 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. Configure gRPC message limits for the advertised payload plus the bounded protobuf envelope. `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. diff --git a/examples/supervisor-middleware-content-guard/src/main.rs b/examples/supervisor-middleware-content-guard/src/main.rs index c6489d742a..81eedb97bf 100644 --- a/examples/supervisor-middleware-content-guard/src/main.rs +++ b/examples/supervisor-middleware-content-guard/src/main.rs @@ -6,7 +6,12 @@ use std::net::SocketAddr; use std::ops::Range; use clap::Parser; -use openshell_core::middleware::{HttpResponseResultStream, WebSocketResponseStream}; +use openshell_core::middleware::{ + HttpRequestResultStream, HttpResponseResultStream, WebSocketResponseStream, +}; +use openshell_core::proto::middleware::v1::http_request_pre_credentials_server::{ + HttpRequestPreCredentials, HttpRequestPreCredentialsServer, +}; use openshell_core::proto::middleware::v1::http_response_pre_return_server::{ HttpResponsePreReturn, HttpResponsePreReturnServer, }; @@ -14,17 +19,21 @@ use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ SupervisorMiddleware, SupervisorMiddlewareServer, }; use openshell_core::proto::{ - 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, - 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, + Decision, Finding, HttpRequestBlock, HttpRequestBodyMode, HttpRequestBodyPassThrough, + HttpRequestBodyResult, HttpRequestBodyTransform, HttpRequestEvent, HttpRequestEventResult, + HttpRequestPreflightInspect, HttpRequestPreflightResult, HttpRequestTrailersResult, + HttpResponseBlockDelivery, HttpResponseBodyMode, HttpResponseBodyResult, + HttpResponseBodyTransform, HttpResponseEvent, HttpResponseEventResult, + HttpResponsePreflightInspect, HttpResponsePreflightResult, HttpResponseTrailersResult, + MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, + SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse, WebSocketMessage, + WebSocketMessageResult, WebSocketPreflightAction, WebSocketPreflightDecision, + WebSocketSessionEvent, WebSocketSessionEventResult, http_request_body_result, + http_request_body_transform, http_request_body_unit, http_request_event, + http_request_event_result, http_request_preflight_result, 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; @@ -277,24 +286,152 @@ impl SupervisorMiddleware for ContentGuard { })) } - async fn evaluate_http_request( + async fn evaluate_web_socket_session( &self, - request: Request, - ) -> Result, Status> { - let request = request.into_inner(); - validate_phase(request.phase).map_err(Status::invalid_argument)?; + request: Request>, + ) -> Result, Status> { + Ok(Response::new(Self::websocket_stream(request.into_inner()))) + } +} + +#[derive(Debug, Default)] +struct RequestSessionState { + config: Option, + body_ended: bool, + trailers_seen: bool, +} + +impl RequestSessionState { + fn preflight( + &mut self, + preflight: openshell_core::proto::HttpRequestPreflight, + ) -> Result { + if self.config.is_some() { + return Err(Status::failed_precondition("duplicate preflight")); + } let config = - GuardConfig::parse(request.config.as_ref()).map_err(Status::invalid_argument)?; - let body = String::from_utf8(request.body) + GuardConfig::parse(preflight.config.as_ref()).map_err(Status::invalid_argument)?; + if !preflight + .permitted_body_modes + .contains(&(HttpRequestBodyMode::WholeBodyBytes as i32)) + { + return Err(Status::failed_precondition( + "content guard requires WHOLE_BODY_BYTES", + )); + } + self.config = Some(config); + Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::WholeBodyBytes as i32, + header_mutations: vec![], + }, + )), + ..Default::default() + }, + )), + }) + } + + fn body( + &mut self, + body: openshell_core::proto::HttpRequestBodyUnit, + ) -> Result { + 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( + "expected one complete request body", + )); + } + let Some(http_request_body_unit::Payload::Data(data)) = body.payload else { + return Err(Status::invalid_argument("body data required")); + }; + let text = std::str::from_utf8(&data) .map_err(|_| Status::invalid_argument("content guard requires a UTF-8 body"))?; - Ok(Response::new(evaluate(&config, &body))) + let result = inspect(config, text); + let action = if result.denied { + http_request_body_result::Action::BlockRequest(HttpRequestBlock {}) + } else if let Some(replacement) = result.replacement { + http_request_body_result::Action::Transform(HttpRequestBodyTransform { + replacement: Some(http_request_body_transform::Replacement::Data( + replacement.into_bytes(), + )), + }) + } else { + http_request_body_result::Action::PassThrough(HttpRequestBodyPassThrough {}) + }; + self.body_ended = true; + Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyResult( + HttpRequestBodyResult { + sequence: body.sequence, + action: Some(action), + reason: result.reason, + reason_code: result.reason_code, + findings: result.findings, + metadata: result.metadata, + }, + )), + }) } - async fn evaluate_web_socket_session( + fn trailers(&mut self) -> Result { + if !self.body_ended || self.trailers_seen { + return Err(Status::failed_precondition("expected trailers after body")); + } + self.trailers_seen = true; + Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::TrailersResult( + HttpRequestTrailersResult::default(), + )), + }) + } +} + +#[tonic::async_trait] +impl HttpRequestPreCredentials for ContentGuard { + type EvaluateStream = HttpRequestResultStream; + + async fn evaluate( &self, - request: Request>, - ) -> Result, Status> { - Ok(Response::new(Self::websocket_stream(request.into_inner()))) + request: Request>, + ) -> Result, Status> { + let mut events = request.into_inner(); + let (sender, receiver) = mpsc::channel(4); + tokio::spawn(async move { + let mut state = RequestSessionState::default(); + while let Some(event) = events.next().await { + let result = match event { + Ok(event) => match event.event { + Some(http_request_event::Event::Preflight(preflight)) => { + state.preflight(preflight) + } + Some(http_request_event::Event::Body(body)) => state.body(body), + Some(http_request_event::Event::Trailers(_)) => state.trailers(), + Some(http_request_event::Event::SessionEnd(_)) => break, + None => Err(Status::invalid_argument("request 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)))) } } @@ -454,23 +591,6 @@ struct GuardOutcome { 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); @@ -632,6 +752,7 @@ async fn main() -> Result<(), Box> { println!("serving {MANIFEST_NAME} on http://{}", cli.bind); Server::builder() .add_service(SupervisorMiddlewareServer::new(ContentGuard)) + .add_service(HttpRequestPreCredentialsServer::new(ContentGuard)) .add_service(HttpResponsePreReturnServer::new(ContentGuard)) .serve(cli.bind) .await?; @@ -642,8 +763,8 @@ async fn main() -> Result<(), Box> { mod tests { use super::*; use openshell_core::proto::{ - HttpResponseBodyUnit, HttpResponsePreflight, MiddlewareSessionEnd, WebSocketPreflight, - WebSocketSessionStart, + HttpRequestBodyUnit, HttpRequestPreflight, HttpResponseBodyUnit, HttpResponsePreflight, + MiddlewareSessionEnd, WebSocketPreflight, WebSocketSessionStart, }; use prost_types::{ListValue, Value}; use std::collections::BTreeMap; @@ -711,6 +832,89 @@ mod tests { ..Default::default() } } + + fn request_preflight(mode: &str) -> HttpRequestPreflight { + HttpRequestPreflight { + config: Some(config(mode, &["prototype-secret", "秘密"], None)), + permitted_body_modes: vec![HttpRequestBodyMode::WholeBodyBytes as i32], + ..Default::default() + } + } + + #[test] + fn request_guard_uses_streamed_whole_body_contract() { + let mut state = RequestSessionState::default(); + let preflight = state.preflight(request_preflight("redact")).unwrap(); + assert!(matches!( + preflight.result, + Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { body_mode, .. } + )), + .. + } + )) if body_mode == HttpRequestBodyMode::WholeBodyBytes as i32 + )); + + let result = state + .body(HttpRequestBodyUnit { + sequence: 1, + payload: Some(http_request_body_unit::Payload::Data( + b"contains prototype-secret".to_vec(), + )), + end_of_stream: true, + }) + .unwrap(); + let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { + panic!("body result"); + }; + let Some(http_request_body_result::Action::Transform(transform)) = result.action else { + panic!("transform"); + }; + assert_eq!( + transform.replacement, + Some(http_request_body_transform::Replacement::Data( + b"contains [REDACTED]".to_vec() + )) + ); + assert!(matches!( + state.trailers().unwrap().result, + Some(http_request_event_result::Result::TrailersResult(_)) + )); + } + + #[test] + fn request_guard_denies_and_rejects_unavailable_whole_body_mode() { + let mut unavailable = request_preflight("redact"); + unavailable.permitted_body_modes = vec![HttpRequestBodyMode::HeadersOnly as i32]; + assert!( + RequestSessionState::default() + .preflight(unavailable) + .is_err() + ); + + let mut state = RequestSessionState::default(); + state.preflight(request_preflight("deny")).unwrap(); + let result = state + .body(HttpRequestBodyUnit { + sequence: 1, + payload: Some(http_request_body_unit::Payload::Data( + b"prototype-secret".to_vec(), + )), + end_of_stream: true, + }) + .unwrap(); + let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { + panic!("body result"); + }; + assert!(matches!( + result.action, + Some(http_request_body_result::Action::BlockRequest(_)) + )); + assert_eq!(result.reason_code, "content_match"); + } + #[test] fn response_guard_passes_redacts_and_denies() { for (mode, input, expected) in [ @@ -887,17 +1091,16 @@ mod tests { Some("[FILTERED]"), ))) .expect("valid config"); - let result = evaluate( + let result = inspect( &config, "prototype-secret then internal-only then prototype-secret", ); - assert_eq!(result.decision, Decision::Allow as i32); assert_eq!( - String::from_utf8(result.body).unwrap(), - "[FILTERED] then [FILTERED] then [FILTERED]" + result.replacement.as_deref(), + Some("[FILTERED] then [FILTERED] then [FILTERED]") ); - assert!(result.has_body); + assert!(!result.denied); assert_eq!(result.findings[0].count, 3); } @@ -907,9 +1110,9 @@ mod tests { GuardConfig::parse(Some(&config("redact", &["aba", "bab"], Some("[FILTERED]")))) .expect("valid config"); - let result = evaluate(&config, "abab"); + let result = inspect(&config, "abab"); - assert_eq!(String::from_utf8(result.body).unwrap(), "[FILTERED]"); + assert_eq!(result.replacement.as_deref(), Some("[FILTERED]")); assert_eq!(result.findings[0].count, 2); assert_eq!(result.metadata["matched_term_count"], "2"); } @@ -919,9 +1122,9 @@ mod tests { let config = GuardConfig::parse(Some(&config("redact", &["aba"], Some("[FILTERED]")))) .expect("valid config"); - let result = evaluate(&config, "ababa"); + let result = inspect(&config, "ababa"); - assert_eq!(String::from_utf8(result.body).unwrap(), "[FILTERED]"); + assert_eq!(result.replacement.as_deref(), Some("[FILTERED]")); assert_eq!(result.findings[0].count, 2); assert_eq!(result.metadata["matched_term_count"], "1"); } @@ -931,12 +1134,9 @@ mod tests { let config = GuardConfig::parse(Some(&config("redact", &["abc"], Some("[FILTERED]")))) .expect("valid config"); - let result = evaluate(&config, "abcabc"); + let result = inspect(&config, "abcabc"); - assert_eq!( - String::from_utf8(result.body).unwrap(), - "[FILTERED][FILTERED]" - ); + assert_eq!(result.replacement.as_deref(), Some("[FILTERED][FILTERED]")); assert_eq!(result.findings[0].count, 2); } @@ -944,23 +1144,22 @@ mod tests { fn deny_returns_a_generic_reason_without_echoing_the_term() { let config = GuardConfig::parse(Some(&config("deny", &["prototype-secret"], None))) .expect("valid config"); - let result = evaluate(&config, "contains prototype-secret"); + let result = inspect(&config, "contains prototype-secret"); - assert_eq!(result.decision, Decision::Deny as i32); + assert!(result.denied); assert!(!result.reason.contains("prototype-secret")); assert_eq!(result.reason_code, "content_match"); - assert!(!result.has_body); + assert!(result.replacement.is_none()); } #[test] fn no_match_allows_without_replacing_the_body() { let config = GuardConfig::parse(Some(&config("redact", &["blocked"], None))).expect("valid config"); - let result = evaluate(&config, "safe content"); + let result = inspect(&config, "safe content"); - assert_eq!(result.decision, Decision::Allow as i32); - assert!(!result.has_body); - assert!(result.body.is_empty()); + assert!(!result.denied); + assert!(result.replacement.is_none()); } #[test] diff --git a/examples/supervisor-middleware-git-signing/Cargo.lock b/examples/supervisor-middleware-git-signing/Cargo.lock new file mode 100644 index 0000000000..f5ad14da36 --- /dev/null +++ b/examples/supervisor-middleware-git-signing/Cargo.lock @@ -0,0 +1,2555 @@ +# 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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +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.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +dependencies = [ + "aws-lc-sys", + "untrusted 0.7.1", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "clap" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "clap_lex" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[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.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[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 = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "h2" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" +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 = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "serde", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +dependencies = [ + "displaydoc", + "icu_locale_core", + "serde", + "stable_deref_trait", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_segmenter" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" +dependencies = [ + "icu_collections", + "icu_locale_fallback", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "smallvec", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[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 = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "10.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" +dependencies = [ + "aws-lc-rs", + "base64", + "getrandom 0.2.17", + "js-sys", + "pem", + "serde", + "serde_json", + "signature", + "simple_asn1", + "zeroize", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[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 2.0.119", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +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 = "noyalib" +version = "0.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f075ef19fa3bcf8697c0ef96c37d5c435d339a40ab8081cae3aac3a4e7fee9a" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "libm", + "memchr", + "rustc-hash", + "serde", + "serde_core", + "smallvec", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[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" + +[[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", + "openshell-policy-schema", + "prost", + "prost-types", + "protoc-bin-vendored", + "rustix", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tonic-prost-build", + "tonic-types", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "openshell-extension-core" +version = "0.0.0" +dependencies = [ + "hyper-util", + "serde", + "thiserror", + "tokio", + "tonic", + "tower", +] + +[[package]] +name = "openshell-policy-schema" +version = "0.0.0" +dependencies = [ + "miette", + "noyalib", + "serde", + "serde_json", +] + +[[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.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" + +[[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 = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[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 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "serde_core", + "writeable", + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +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 2.0.119", + "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 2.0.119", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +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_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[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.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +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 0.9.0", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "aws-lc-rs", + "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.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted 0.9.0", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[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 = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +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 = "supervisor-middleware-git-signing" +version = "0.0.0" +dependencies = [ + "clap", + "jsonwebtoken", + "openshell-core", + "openshell-extension-core", + "prost-types", + "tempfile", + "tokio", + "tokio-stream", + "tonic", + "tracing", + "tracing-subscriber", +] + +[[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.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" +dependencies = [ + "icu_segmenter", + "unicode-width 0.2.2", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "serde_core", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "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 2.0.119", +] + +[[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 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tonic-types" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" +dependencies = [ + "prost", + "prost-types", + "tonic", +] + +[[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 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[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.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[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.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[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 = "uuid" +version = "1.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[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 = "wasm-bindgen" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.128" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +dependencies = [ + "unicode-ident", +] + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "serde", + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/supervisor-middleware-git-signing/Cargo.toml b/examples/supervisor-middleware-git-signing/Cargo.toml new file mode 100644 index 0000000000..d5b63eeb75 --- /dev/null +++ b/examples/supervisor-middleware-git-signing/Cargo.toml @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[workspace] + +[package] +name = "supervisor-middleware-git-signing" +version = "0.0.0" +edition = "2024" +rust-version = "1.90" +license = "Apache-2.0" +publish = false + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } +openshell-core = { path = "../../crates/openshell-core", default-features = false } +openshell-extension-core = { path = "../../crates/openshell-extension-core" } +prost-types = "0.14" +tempfile = "3" +tokio = { version = "1.43", features = ["fs", "io-util", "macros", "rt-multi-thread"] } +tokio-stream = "0.1" +tonic = { version = "0.14", features = ["transport", "tls-ring"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[lints.rust] +unsafe_code = "forbid" diff --git a/examples/supervisor-middleware-git-signing/README.md b/examples/supervisor-middleware-git-signing/README.md new file mode 100644 index 0000000000..ff0ab3bfad --- /dev/null +++ b/examples/supervisor-middleware-git-signing/README.md @@ -0,0 +1,100 @@ + + +# Git commit signing supervisor middleware prototype + +This example tests whether an operator-run OpenShell supervisor middleware can sign commits without mounting a signing key into the sandbox. It uses the streaming `HttpRequestPreCredentials.Evaluate` API to intercept Git smart-HTTP `git-receive-pack` requests after network policy admission and before provider credential injection. The service selects `OWNED_STREAM_BYTES`, accepts bounded 64 KiB units into an unlinked temporary file, rewrites the pushed commit objects with SSH signatures, and streams the replacement request back in bounded units. + +The sandbox sees neither the private key nor the signature operation. Its Git client creates ordinary unsigned commits and pushes over HTTPS. + +## What the prototype does + +For each direct branch update in a push, the service: + +1. Derives a bounded `github.com//.git` upstream URL from the policy-admitted request target. +2. Shallow-fetches the upstream default branch and updated branch tips into a temporary bare repository. This supplies bases omitted from normal thin pushes. +3. Decodes the receive-pack command pkt-lines and packfile, then walks only commits that are not already reachable from the fetched upstream refs. +4. Removes any existing commit signature and signs the exact rewritten commit payload with `ssh-keygen -Y sign -n git`. +5. Rewrites parent object IDs, creates a replacement thin pack, and substitutes the new branch-tip object ID in the receive-pack command. +6. Returns the modified body to the supervisor, which forwards it with the sandbox's normal GitHub credential. + +The signing key is a service startup argument, not policy-controlled middleware configuration. A sandbox cannot select an arbitrary host file to sign with. + +## Build and test + +The test constructs a two-commit push larger than the former 4 MiB unary limit, transforms it, sends the replacement request through Git's real `receive-pack --stateless-rpc`, checks the updated branch tip, and verifies both SSH signatures with `git verify-commit`. + +```shell +cargo test --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml +``` + +The example requires `git` and `ssh-keygen` on the host. + +## Run the service + +Use an SSH key that is also registered as a signing key with the Git forge. GitHub distinguishes signing keys from authentication keys even when the same public key material is used. + +The service requires TLS and verifies every OpenShell caller with an exact-audience extension JWT. Provision a service certificate and private key whose DNS name matches the middleware endpoint, the issuing CA certificate for the gateway registration, and the Ed25519 public key configured in `[openshell.gateway.gateway_jwt]`. + +```shell +cargo run \ + --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml \ + -- \ + --bind 0.0.0.0:50051 \ + --signing-key /run/secrets/git-signing-key \ + --tls-cert /run/secrets/git-signer.pem \ + --tls-key /run/secrets/git-signer-key.pem \ + --extension-public-key /run/secrets/openshell-extension-public.pem \ + --expected-gateway-id openshell \ + --audience urn:openshell:extension:middleware:local-git-signer \ + --max-concurrent-signings 2 \ + --signing-timeout-seconds 25 +``` + +Register the local service in `gateway.toml`. A container-backed gateway can reach a host service through `host.openshell.internal`; a host-native gateway can use `127.0.0.1`. + +```toml +[[openshell.supervisor.middleware]] +name = "local-git-signer" +grpc_endpoint = "https://host.openshell.internal:50051" +tls_ca_cert_path = "/etc/openshell/certs/git-signer-ca.pem" +audience = "urn:openshell:extension:middleware:local-git-signer" +max_payload_bytes = 65536 +timeout = "30s" + +[openshell.gateway.gateway_jwt] +signing_key_path = "/etc/openshell/jwt/signing.pem" +public_key_path = "/etc/openshell/jwt/public.pem" +kid_path = "/etc/openshell/jwt/kid" +gateway_id = "openshell" +ttl_secs = 3600 +``` + +The `public_key_path` file must be the same key supplied to the service with `--extension-public-key`. The registration audience must exactly match `--audience`, and `gateway_id` must exactly match `--expected-gateway-id`. The service authorizes gateway callers for discovery and configuration validation and sandbox supervisor callers for request evaluation. + +Set `max_payload_bytes` to 65536 as shown for full-size stream units. A lower positive value uses smaller output units; the value does not cap the complete push. OpenShell separately enforces a bounded deferred-storage limit for owned streams. Keep the registration timeout above the service's signing timeout so OpenShell can receive either the signed output or a controlled failure. + +Restart the gateway after adding the static registration, then replace `` and `` in [policy.yaml](policy.yaml) and create a sandbox with that policy. Keep the endpoint on `fail_closed`: reject a push rather than forwarding it unsigned when signing fails. + +The service skips unrelated GitHub HTTP requests during preflight. It inspects only `POST` requests whose path ends in `/git-receive-pack` and whose content type is `application/x-git-receive-pack-request`. This example accepts only validated HTTPS targets on `github.com:443` with a two-segment repository path. + +The signer implements `SupervisorMiddleware` for discovery and configuration validation, and serves `HttpRequestPreCredentials` alongside it. The request stream is the only HTTP request middleware API. + +## Resource bounds and cancellation + +Input and replacement packs stay in unlinked temporary files. The service parses only a bounded 1 MiB receive-pack prefix in memory, feeds the input pack directly to `git index-pack`, and writes the replacement pack directly to its output file. It emits the replacement to OpenShell in bounded units. + +`--max-concurrent-signings` limits active Git rewrite workers. `--signing-timeout-seconds` covers upstream fetches, graph rewriting, signing, and pack generation. If the OpenShell request is canceled or the deadline expires, the service kills the active `git` or `ssh-keygen` subprocess and waits for it to exit. Subprocess diagnostic capture is bounded and is not returned to the sandbox. + +## Prototype limits + +- Owned streams are available only with `on_error: fail_closed`. Once the service acknowledges ownership, OpenShell has no replay copy and cannot safely fail open. +- The implementation supports SHA-1 repositories and direct `refs/heads/*` updates. It rejects SHA-256 repositories, annotated-tag pushes, push certificates, and other ref types. +- Each push shallow-fetches upstream objects before signing. The host must be able to reach the repository, and private repositories need a non-interactive Git credential helper available to the local middleware user. A production deployment should use a bounded object cache and explicit credential plumbing. +- The service shells out to the host's `git` and `ssh-keygen`. Run it with OS-level CPU, memory, process, temporary-storage, and network limits in addition to its own concurrency and time bounds. +- Rewriting commit object IDs means the sandbox's local branch still points to the unsigned commit after a successful push. A subsequent fetch updates the remote-tracking ref, but the local branch must be reconciled with the rewritten history. This is the largest workflow issue for transparent push-time signing. +- The example has protocol and local end-to-end Git coverage, not a live GitHub push. Test against a disposable repository before using a real signing key. + +These constraints make the approach viable as a focused deployment or proof of concept, but not yet transparent enough for general production pushes. A first-class commit-signing operation invoked before Git creates the final local object would avoid the local/remote object-ID split while still keeping the private key outside the sandbox. diff --git a/examples/supervisor-middleware-git-signing/policy.yaml b/examples/supervisor-middleware-git-signing/policy.yaml new file mode 100644 index 0000000000..45c861bfc2 --- /dev/null +++ b/examples/supervisor-middleware-git-signing/policy.yaml @@ -0,0 +1,48 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /etc] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_middlewares: + sign-git-commits: + name: Sign outgoing Git commits + middleware: local-git-signer + order: 10 + config: {} + on_error: fail_closed + endpoints: + include: [github.com] + +network_policies: + github_git: + name: github-git + endpoints: + - host: github.com + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "//.git/info/refs*" + - allow: + method: POST + path: "//.git/git-upload-pack" + - allow: + method: POST + path: "//.git/git-receive-pack" + binaries: + - { path: /usr/bin/git } + - { path: /usr/lib/git-core/git-remote-http } diff --git a/examples/supervisor-middleware-git-signing/src/main.rs b/examples/supervisor-middleware-git-signing/src/main.rs new file mode 100644 index 0000000000..71d52bde8b --- /dev/null +++ b/examples/supervisor-middleware-git-signing/src/main.rs @@ -0,0 +1,1022 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +mod signer; + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use clap::Parser; +use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; +use openshell_core::middleware::{HttpRequestResultStream, WebSocketResponseStream}; +use openshell_core::proto::middleware::v1::http_request_pre_credentials_server::{ + HttpRequestPreCredentials, HttpRequestPreCredentialsServer, +}; +use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ + SupervisorMiddleware, SupervisorMiddlewareServer, +}; +use openshell_core::proto::{ + Finding, HttpRequestBodyFinalize, HttpRequestBodyMode, HttpRequestBodyOutput, + HttpRequestBodyResult, HttpRequestBodyTakeOwnership, HttpRequestEvent, HttpRequestEventResult, + HttpRequestPreflight, HttpRequestPreflightInspect, HttpRequestPreflightResult, + HttpRequestPreflightSkip, HttpRequestTrailersResult, MiddlewareBinding, MiddlewareManifest, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, + ValidateConfigResponse, WebSocketSessionEvent, http_request_body_result, + http_request_body_unit, http_request_event, http_request_event_result, + http_request_preflight_result, +}; +use openshell_extension_core::{ + EXTENSION_JWT_TYP, ExtensionCallerKind, ExtensionJwtClaims, MAX_EXTENSION_TOKEN_TTL, +}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio_stream::StreamExt as _; +use tokio_stream::wrappers::ReceiverStream; +use tonic::transport::{Identity, Server, ServerTlsConfig}; +use tonic::{Request, Response, Status}; +use tracing::{info, warn}; +use tracing_subscriber::EnvFilter; + +use crate::signer::{GitSigner, SignControl}; + +const MANIFEST_NAME: &str = "example/git-commit-signing"; +const OPERATION: SupervisorMiddlewareOperation = SupervisorMiddlewareOperation::HttpRequest; +const PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials; +const MAX_UNIT_BYTES: usize = 64 * 1024; +const JWT_CLOCK_SKEW_SECONDS: i64 = 60; + +#[derive(Debug, Parser)] +#[command(about = "Sign commits in Git smart-HTTP pushes outside an OpenShell sandbox")] +struct Cli { + /// Address on which to serve authenticated TLS gRPC. + #[arg(long, default_value = "127.0.0.1:50051")] + bind: SocketAddr, + + /// Local SSH private key used by ssh-keygen. This path is never sent to the sandbox. + #[arg(long)] + signing_key: PathBuf, + + /// PEM certificate presented by this middleware service. + #[arg(long)] + tls_cert: PathBuf, + + /// PEM private key for the middleware TLS certificate. + #[arg(long)] + tls_key: PathBuf, + + /// PEM Ed25519 public key used to verify OpenShell extension JWTs. + #[arg(long)] + extension_public_key: PathBuf, + + /// Gateway ID expected in extension-token issuer claims. + #[arg(long)] + expected_gateway_id: String, + + /// Exact extension JWT audience configured for this registration. + #[arg(long)] + audience: String, + + /// Maximum number of concurrent Git rewrite workers. + #[arg(long, default_value_t = 2)] + max_concurrent_signings: usize, + + /// Total deadline for fetch, rewrite, signing, and pack generation. + #[arg(long, default_value_t = 25)] + signing_timeout_seconds: u64, +} + +struct ExtensionAuth { + decoding_key: DecodingKey, + issuer: String, + audience: String, +} + +impl ExtensionAuth { + fn new(public_key_pem: &[u8], gateway_id: &str, audience: String) -> Result { + if gateway_id.is_empty() || audience.is_empty() { + return Err("expected gateway ID and audience must be nonempty".into()); + } + let decoding_key = DecodingKey::from_ed_pem(public_key_pem) + .map_err(|error| format!("invalid extension public key: {error}"))?; + Ok(Self { + decoding_key, + issuer: format!("openshell-gateway:{gateway_id}"), + audience, + }) + } + + fn authenticate( + &self, + request: &Request, + required_caller: Option, + ) -> Result<(), Status> { + let authorization = request + .metadata() + .get("authorization") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .ok_or_else(|| Status::unauthenticated("extension bearer token is required"))?; + let header = decode_header(authorization) + .map_err(|_| Status::unauthenticated("extension bearer token is invalid"))?; + if header.alg != Algorithm::EdDSA + || header.typ.as_deref() != Some(EXTENSION_JWT_TYP) + || header.kid.as_deref().is_none_or(str::is_empty) + { + return Err(Status::unauthenticated( + "extension bearer token header is invalid", + )); + } + let mut validation = Validation::new(Algorithm::EdDSA); + validation.set_issuer(&[self.issuer.as_str()]); + validation.set_audience(&[self.audience.as_str()]); + validation.set_required_spec_claims(&["iss", "aud", "sub", "iat", "exp", "jti"]); + let claims = decode::(authorization, &self.decoding_key, &validation) + .map_err(|_| Status::unauthenticated("extension bearer token is invalid"))? + .claims; + if claims.jti.is_empty() + || claims.iat < 0 + || claims.exp <= claims.iat + || u64::try_from(claims.exp - claims.iat) + .ok() + .is_none_or(|ttl| ttl > MAX_EXTENSION_TOKEN_TTL.as_secs()) + { + return Err(Status::unauthenticated( + "extension bearer token lifetime is invalid", + )); + } + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Status::internal("system clock is before the Unix epoch"))? + .as_secs() as i64; + if claims.iat > now.saturating_add(JWT_CLOCK_SKEW_SECONDS) { + return Err(Status::unauthenticated( + "extension bearer token issue time is invalid", + )); + } + if required_caller.is_some_and(|required| claims.caller_kind != required) { + return Err(Status::permission_denied( + "extension caller kind is not authorized for this RPC", + )); + } + match claims.caller_kind { + ExtensionCallerKind::Gateway + if claims.sandbox_id.is_none() && claims.sub == self.issuer => {} + ExtensionCallerKind::Supervisor => { + let sandbox_id = claims.sandbox_id.as_deref().filter(|id| !id.is_empty()); + if sandbox_id + .is_none_or(|id| claims.sub != format!("spiffe://openshell/sandbox/{id}")) + { + return Err(Status::permission_denied( + "extension supervisor identity is invalid", + )); + } + } + _ => { + return Err(Status::permission_denied( + "extension gateway identity is invalid", + )); + } + } + Ok(()) + } +} + +#[derive(Clone)] +struct GitSigningMiddleware { + signer: Arc, + auth: Arc, + signing_slots: Arc, + signing_timeout: Duration, + expected_audience: String, +} + +impl GitSigningMiddleware { + fn new( + signing_key: PathBuf, + max_concurrent_signings: usize, + signing_timeout: Duration, + auth: ExtensionAuth, + ) -> Result { + if max_concurrent_signings == 0 { + return Err("max concurrent signings must be positive".into()); + } + Ok(Self { + signer: Arc::new(GitSigner::new(signing_key)?), + expected_audience: auth.audience.clone(), + auth: Arc::new(auth), + signing_slots: Arc::new(tokio::sync::Semaphore::new(max_concurrent_signings)), + signing_timeout, + }) + } + + #[cfg(test)] + fn new_for_test(signing_key: PathBuf) -> Result { + Ok(Self { + signer: Arc::new(GitSigner::new(signing_key)?), + auth: Arc::new(ExtensionAuth { + decoding_key: DecodingKey::from_secret(b"unused-test-key"), + issuer: "openshell-gateway:test".into(), + audience: "urn:openshell:extension:test:git-signing".into(), + }), + signing_slots: Arc::new(tokio::sync::Semaphore::new(1)), + signing_timeout: Duration::from_secs(60), + expected_audience: "urn:openshell:extension:test:git-signing".into(), + }) + } + + fn request_stream(&self, mut events: S) -> HttpRequestResultStream + where + S: tokio_stream::Stream> + Send + Unpin + 'static, + { + let signer = Arc::clone(&self.signer); + let signing_slots = Arc::clone(&self.signing_slots); + let signing_timeout = self.signing_timeout; + let (results_tx, results_rx) = tokio::sync::mpsc::channel(4); + tokio::spawn(async move { + let mut selection = None; + let mut input = None; + let mut input_bytes = 0u64; + let mut next_input_sequence = 1u64; + let mut final_input_sequence = None; + + while let Some(event) = events.next().await { + let event = match event { + Ok(event) => event, + Err(error) => { + let _ = results_tx.send(Err(error)).await; + break; + } + }; + match event.event { + Some(http_request_event::Event::Preflight(preflight)) + if selection.is_none() => + { + match select_request(&preflight) { + Ok(None) => { + selection = Some(Selection::Skipped); + if results_tx.send(Ok(preflight_skip())).await.is_err() { + break; + } + } + Ok(Some(selected)) => { + if !preflight + .permitted_body_modes + .contains(&(HttpRequestBodyMode::OwnedStreamBytes as i32)) + || preflight.max_deferred_bytes == 0 + { + send_stream_error( + &results_tx, + Status::failed_precondition( + "Git signing requires fail-closed owned request streaming", + ), + ) + .await; + break; + } + match tempfile::tempfile() { + Ok(file) => { + input = Some(tokio::fs::File::from_std(file)); + selection = Some(selected); + if results_tx.send(Ok(preflight_owned())).await.is_err() { + break; + } + } + Err(_) => { + send_stream_error( + &results_tx, + Status::resource_exhausted( + "Git signing temporary storage is unavailable", + ), + ) + .await; + break; + } + } + } + Err(status) => { + send_stream_error(&results_tx, status).await; + break; + } + } + } + Some(http_request_event::Event::Body(body)) + if matches!(selection, Some(Selection::Sign { .. })) + && final_input_sequence.is_none() => + { + if body.sequence != next_input_sequence { + send_stream_error( + &results_tx, + Status::invalid_argument( + "Git signing input sequence is not contiguous", + ), + ) + .await; + break; + } + let Some(http_request_body_unit::Payload::Data(data)) = body.payload else { + send_stream_error( + &results_tx, + Status::invalid_argument("Git signing body data is required"), + ) + .await; + break; + }; + input_bytes = input_bytes.saturating_add(data.len() as u64); + let max_deferred_bytes = match selection.as_ref() { + Some(Selection::Sign { limits, .. }) => limits.deferred_bytes, + _ => 0, + }; + if input_bytes > max_deferred_bytes { + send_stream_error( + &results_tx, + Status::resource_exhausted( + "Git signing request exceeds deferred storage limit", + ), + ) + .await; + break; + } + let Some(file) = input.as_mut() else { + send_stream_error( + &results_tx, + Status::internal("Git signing temporary storage was lost"), + ) + .await; + break; + }; + if file.write_all(&data).await.is_err() { + send_stream_error( + &results_tx, + Status::resource_exhausted( + "Git signing temporary storage write failed", + ), + ) + .await; + break; + } + if results_tx + .send(Ok(body_take_ownership(body.sequence))) + .await + .is_err() + { + break; + } + next_input_sequence = next_input_sequence.saturating_add(1); + if body.end_of_stream { + final_input_sequence = Some(body.sequence); + let Some(Selection::Sign { + upstream_url, + request_id, + limits, + .. + }) = selection.as_ref() + else { + break; + }; + let upstream_url = upstream_url.clone(); + let request_id = request_id.clone(); + let limits = *limits; + if let Err(error) = finish_signing( + Arc::clone(&signer), + SigningRequest { + input: input + .take() + .expect("selected request has temporary storage"), + upstream_url, + request_id, + final_input_sequence: body.sequence, + limits, + }, + Arc::clone(&signing_slots), + signing_timeout, + &results_tx, + ) + .await + { + send_stream_error(&results_tx, error).await; + break; + } + } + } + Some(http_request_event::Event::Trailers(_)) + if final_input_sequence.is_some() => + { + if results_tx + .send(Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::TrailersResult( + HttpRequestTrailersResult::default(), + )), + })) + .await + .is_err() + { + break; + } + } + Some(http_request_event::Event::SessionEnd(_)) if selection.is_some() => break, + _ => { + send_stream_error( + &results_tx, + Status::failed_precondition( + "invalid Git signing request stream lifecycle", + ), + ) + .await; + break; + } + } + } + }); + Box::pin(ReceiverStream::new(results_rx)) + } +} + +enum Selection { + Skipped, + Sign { + upstream_url: String, + request_id: String, + limits: OwnedOutputLimits, + }, +} + +#[derive(Clone, Copy)] +struct OwnedOutputLimits { + deferred_bytes: u64, + unit_bytes: usize, +} + +struct SigningRequest { + input: tokio::fs::File, + upstream_url: String, + request_id: String, + final_input_sequence: u64, + limits: OwnedOutputLimits, +} + +#[tonic::async_trait] +impl SupervisorMiddleware for GitSigningMiddleware { + type EvaluateWebSocketSessionStream = WebSocketResponseStream; + + async fn describe(&self, request: Request<()>) -> Result, Status> { + self.auth.authenticate(&request, None)?; + Ok(Response::new(MiddlewareManifest { + name: MANIFEST_NAME.into(), + service_version: env!("CARGO_PKG_VERSION").into(), + bindings: vec![MiddlewareBinding { + operation: OPERATION as i32, + phase: PHASE as i32, + max_payload_bytes: MAX_UNIT_BYTES as u64, + request_timeout: Some(prost_types::Duration { + seconds: 30, + nanos: 0, + }), + }], + expected_audience: self.expected_audience.clone(), + })) + } + + async fn validate_config( + &self, + request: Request, + ) -> Result, Status> { + self.auth + .authenticate(&request, Some(ExtensionCallerKind::Gateway))?; + let request = request.into_inner(); + let unknown = request + .config + .as_ref() + .and_then(|config| config.fields.keys().next()) + .cloned(); + Ok(Response::new(match unknown { + None => ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + Some(field) => ValidateConfigResponse { + valid: false, + reason: format!("unsupported config field '{field}'"), + }, + })) + } + + async fn evaluate_web_socket_session( + &self, + request: Request>, + ) -> Result, Status> { + self.auth + .authenticate(&request, Some(ExtensionCallerKind::Supervisor))?; + Err(Status::unimplemented( + "WebSocket middleware is not supported", + )) + } +} + +#[tonic::async_trait] +impl HttpRequestPreCredentials for GitSigningMiddleware { + type EvaluateStream = HttpRequestResultStream; + + async fn evaluate( + &self, + request: Request>, + ) -> Result, Status> { + self.auth + .authenticate(&request, Some(ExtensionCallerKind::Supervisor))?; + Ok(Response::new(self.request_stream(request.into_inner()))) + } +} + +fn select_request(preflight: &HttpRequestPreflight) -> Result, Status> { + if !is_receive_pack_request(preflight) { + return Ok(None); + } + let target = preflight + .target + .as_ref() + .ok_or_else(|| Status::invalid_argument("Git push request has no target"))?; + let max_output_unit_bytes = usize::try_from(preflight.max_payload_bytes) + .ok() + .filter(|limit| *limit > 0) + .map(|limit| limit.min(MAX_UNIT_BYTES)) + .ok_or_else(|| Status::failed_precondition("Git signing output unit limit is invalid"))?; + Ok(Some(Selection::Sign { + upstream_url: github_upstream_url(target)?, + request_id: preflight + .context + .as_ref() + .map(|context| context.request_id.clone()) + .unwrap_or_default(), + limits: OwnedOutputLimits { + deferred_bytes: preflight.max_deferred_bytes, + unit_bytes: max_output_unit_bytes, + }, + })) +} + +fn preflight_skip() -> HttpRequestEventResult { + HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Skip( + HttpRequestPreflightSkip {}, + )), + reason_code: "not_git_receive_pack".into(), + ..Default::default() + }, + )), + } +} + +fn preflight_owned() -> HttpRequestEventResult { + HttpRequestEventResult { + result: Some(http_request_event_result::Result::PreflightResult( + HttpRequestPreflightResult { + action: Some(http_request_preflight_result::Action::Inspect( + HttpRequestPreflightInspect { + body_mode: HttpRequestBodyMode::OwnedStreamBytes as i32, + header_mutations: Vec::new(), + }, + )), + reason_code: "git_receive_pack_selected".into(), + ..Default::default() + }, + )), + } +} + +fn body_take_ownership(sequence: u64) -> HttpRequestEventResult { + HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyResult( + HttpRequestBodyResult { + sequence, + action: Some(http_request_body_result::Action::TakeOwnership( + HttpRequestBodyTakeOwnership {}, + )), + ..Default::default() + }, + )), + } +} + +async fn finish_signing( + signer: Arc, + request: SigningRequest, + signing_slots: Arc, + signing_timeout: Duration, + sender: &tokio::sync::mpsc::Sender>, +) -> Result<(), Status> { + let SigningRequest { + mut input, + upstream_url, + request_id, + final_input_sequence, + limits, + } = request; + input + .flush() + .await + .map_err(|_| Status::internal("Git signing temporary storage flush failed"))?; + let input = input.into_std().await; + let log_upstream = upstream_url.clone(); + let log_request_id = request_id.clone(); + let _permit = signing_slots + .acquire_owned() + .await + .map_err(|_| Status::unavailable("Git signing service is shutting down"))?; + let cancelled = Arc::new(AtomicBool::new(false)); + let control = SignControl::new(Arc::clone(&cancelled), Instant::now() + signing_timeout); + let mut worker = tokio::task::spawn_blocking(move || { + signer.sign_receive_pack(input, Some(&upstream_url), &control) + }); + let signed = tokio::select! { + result = &mut worker => { + result.map_err(|_| Status::internal("Git signing worker failed"))? + } + () = sender.closed() => { + cancelled.store(true, Ordering::Release); + let _ = worker.await; + return Err(Status::cancelled("Git signing request was cancelled")); + } + () = tokio::time::sleep(signing_timeout) => { + cancelled.store(true, Ordering::Release); + let _ = worker.await; + return Err(Status::deadline_exceeded("Git signing deadline exceeded")); + } + } + .map_err(|error| { + let status = if error.is_cancelled() { + Status::cancelled("Git signing request was cancelled") + } else if error.is_timed_out() { + Status::deadline_exceeded("Git signing deadline exceeded") + } else { + Status::failed_precondition(error.public_message()) + }; + warn!( + request_id = log_request_id, + upstream = %log_upstream, + category = if error.is_cancelled() { "cancelled" } else if error.is_timed_out() { "timeout" } else { "invalid_push" }, + "outgoing Git push could not be signed" + ); + status + })?; + + if signed.body_len > limits.deferred_bytes { + return Err(Status::resource_exhausted( + "signed Git request exceeds deferred storage limit", + )); + } + + let signed_commits = signed.signed_commits; + let mut body = tokio::fs::File::from_std(signed.body); + let mut chunk = vec![0; limits.unit_bytes]; + let mut output_sequence = 0u64; + loop { + let read = body + .read(&mut chunk) + .await + .map_err(|_| Status::internal("signed Git temporary storage read failed"))?; + if read == 0 { + break; + } + output_sequence += 1; + sender + .send(Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyOutput( + HttpRequestBodyOutput { + sequence: output_sequence, + data: chunk[..read].to_vec(), + }, + )), + })) + .await + .map_err(|_| Status::cancelled("Git signing request was cancelled"))?; + } + sender + .send(Ok(HttpRequestEventResult { + result: Some(http_request_event_result::Result::BodyFinalize( + HttpRequestBodyFinalize { + through_input_sequence: final_input_sequence, + through_output_sequence: output_sequence, + reason_code: "git_commits_signed".into(), + findings: vec![Finding { + r#type: "git.commits_signed".into(), + label: "Git commits signed".into(), + count: signed_commits, + confidence: "high".into(), + severity: "informational".into(), + }], + metadata: HashMap::from([( + "signed_commit_count".into(), + signed_commits.to_string(), + )]), + ..Default::default() + }, + )), + })) + .await + .map_err(|_| Status::cancelled("Git signing request was cancelled"))?; + info!( + request_id, + upstream = %log_upstream, + signed_commits, + "signed outgoing Git push" + ); + Ok(()) +} + +async fn send_stream_error( + sender: &tokio::sync::mpsc::Sender>, + status: Status, +) { + let _ = sender.send(Err(status)).await; +} + +fn github_upstream_url( + target: &openshell_core::proto::HttpRequestTarget, +) -> Result { + if target.scheme != "https" || target.host != "github.com" || target.port != 443 { + return Err(Status::invalid_argument( + "prototype supports HTTPS pushes to github.com only", + )); + } + let repository_path = target + .path + .strip_suffix("/git-receive-pack") + .ok_or_else(|| Status::invalid_argument("invalid Git receive-pack path"))?; + let segments = repository_path + .strip_prefix('/') + .and_then(|path| path.strip_suffix(".git")) + .map(|path| path.split('/').collect::>()) + .ok_or_else(|| Status::invalid_argument("invalid GitHub repository path"))?; + if segments.len() != 2 + || segments.iter().any(|segment| { + segment.is_empty() + || !segment + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) + }) + { + return Err(Status::invalid_argument("invalid GitHub repository path")); + } + Ok(format!("https://github.com{repository_path}")) +} + +fn is_receive_pack_request(request: &HttpRequestPreflight) -> bool { + let Some(target) = request.target.as_ref() else { + return false; + }; + target.method == "POST" + && target.path.ends_with("/git-receive-pack") + && request.headers.iter().any(|header| { + header.name.eq_ignore_ascii_case("content-type") + && header + .value + .split(';') + .next() + .is_some_and(|value| value.trim() == "application/x-git-receive-pack-request") + }) +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::from_default_env()) + .init(); + let cli = Cli::parse(); + if cli.signing_timeout_seconds == 0 { + return Err("signing timeout must be positive".into()); + } + let tls_cert = std::fs::read(&cli.tls_cert)?; + let tls_key = std::fs::read(&cli.tls_key)?; + let extension_public_key = std::fs::read(&cli.extension_public_key)?; + let auth = ExtensionAuth::new( + &extension_public_key, + &cli.expected_gateway_id, + cli.audience, + ) + .map_err(|error| format!("invalid extension authentication configuration: {error}"))?; + let middleware = GitSigningMiddleware::new( + cli.signing_key, + cli.max_concurrent_signings, + Duration::from_secs(cli.signing_timeout_seconds), + auth, + ) + .map_err(|error| format!("invalid signing configuration: {error}"))?; + info!(bind = %cli.bind, "starting Git commit signing middleware"); + Server::builder() + .tls_config(ServerTlsConfig::new().identity(Identity::from_pem(tls_cert, tls_key)))? + .add_service(SupervisorMiddlewareServer::new(middleware.clone())) + .add_service(HttpRequestPreCredentialsServer::new(middleware)) + .serve(cli.bind) + .await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use jsonwebtoken::{EncodingKey, Header, encode}; + use openshell_core::proto::{ + HttpHeader, HttpRequestBodyUnit, HttpRequestTarget, MiddlewareSessionEnd, + http_request_event_result, + }; + + const TEST_PRIVATE_KEY: &[u8] = br#"-----BEGIN PRIVATE KEY----- +MC4CAQAwBQYDK2VwBCIEIAR9CeOXmiSU6YscHZWTYbW7DUc5uhdO3/OeXZg3j1+u +-----END PRIVATE KEY----- +"#; + const TEST_PUBLIC_KEY: &[u8] = br#"-----BEGIN PUBLIC KEY----- +MCowBQYDK2VwAyEAuEbM0q6xP8hFxwY5kd/fD/mwr3ZpA/T7zhx6TN0DKMM= +-----END PUBLIC KEY----- +"#; + + fn authenticated_request(claims: ExtensionJwtClaims) -> Request<()> { + let mut header = Header::new(Algorithm::EdDSA); + header.typ = Some(EXTENSION_JWT_TYP.into()); + header.kid = Some("test-key".into()); + let token = encode( + &header, + &claims, + &EncodingKey::from_ed_pem(TEST_PRIVATE_KEY).unwrap(), + ) + .unwrap(); + let mut request = Request::new(()); + request + .metadata_mut() + .insert("authorization", format!("Bearer {token}").parse().unwrap()); + request + } + + fn test_claims(caller_kind: ExtensionCallerKind) -> ExtensionJwtClaims { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + let (sub, sandbox_id) = match caller_kind { + ExtensionCallerKind::Gateway => ("openshell-gateway:test".into(), None), + ExtensionCallerKind::Supervisor => ( + "spiffe://openshell/sandbox/sandbox-test".into(), + Some("sandbox-test".into()), + ), + }; + ExtensionJwtClaims { + iss: "openshell-gateway:test".into(), + aud: "urn:openshell:extension:test:git-signing".into(), + sub, + iat: now, + exp: now + 300, + jti: "unique-test-token".into(), + caller_kind, + sandbox_id, + } + } + + #[test] + fn extension_auth_enforces_rpc_caller_kind() { + let auth = ExtensionAuth::new( + TEST_PUBLIC_KEY, + "test", + "urn:openshell:extension:test:git-signing".into(), + ) + .unwrap(); + + assert!( + auth.authenticate( + &authenticated_request(test_claims(ExtensionCallerKind::Gateway)), + Some(ExtensionCallerKind::Gateway), + ) + .is_ok() + ); + assert_eq!( + auth.authenticate( + &authenticated_request(test_claims(ExtensionCallerKind::Gateway)), + Some(ExtensionCallerKind::Supervisor), + ) + .unwrap_err() + .code(), + tonic::Code::PermissionDenied + ); + assert!( + auth.authenticate( + &authenticated_request(test_claims(ExtensionCallerKind::Supervisor)), + Some(ExtensionCallerKind::Supervisor), + ) + .is_ok() + ); + } + + #[test] + fn extension_auth_rejects_mismatched_supervisor_identity() { + let auth = ExtensionAuth::new( + TEST_PUBLIC_KEY, + "test", + "urn:openshell:extension:test:git-signing".into(), + ) + .unwrap(); + let mut claims = test_claims(ExtensionCallerKind::Supervisor); + claims.sub = "spiffe://openshell/sandbox/another-sandbox".into(); + + assert_eq!( + auth.authenticate( + &authenticated_request(claims), + Some(ExtensionCallerKind::Supervisor), + ) + .unwrap_err() + .code(), + tonic::Code::PermissionDenied + ); + } + + fn receive_pack_preflight() -> HttpRequestPreflight { + HttpRequestPreflight { + target: Some(HttpRequestTarget { + scheme: "https".into(), + host: "github.com".into(), + port: 443, + method: "POST".into(), + path: "/NVIDIA/OpenShell.git/git-receive-pack".into(), + ..Default::default() + }), + headers: vec![HttpHeader { + name: "content-type".into(), + value: "application/x-git-receive-pack-request".into(), + }], + permitted_body_modes: vec![HttpRequestBodyMode::OwnedStreamBytes as i32], + max_payload_bytes: MAX_UNIT_BYTES as u64, + max_deferred_bytes: 16 * 1024 * 1024, + ..Default::default() + } + } + + #[test] + fn recognizes_git_receive_pack_only() { + let request = receive_pack_preflight(); + assert!(is_receive_pack_request(&request)); + + let mut fetch = request; + fetch.target.as_mut().unwrap().path = "/NVIDIA/OpenShell.git/git-upload-pack".into(); + assert!(!is_receive_pack_request(&fetch)); + } + + #[test] + fn derives_a_bounded_github_upstream_url() { + let target = receive_pack_preflight().target.unwrap(); + assert_eq!( + github_upstream_url(&target).unwrap(), + "https://github.com/NVIDIA/OpenShell.git" + ); + + let mut traversal = target; + traversal.path = "/NVIDIA/../OpenShell.git/git-receive-pack".into(); + assert!(github_upstream_url(&traversal).is_err()); + } + + #[tokio::test] + async fn owned_stream_accepts_more_than_former_unary_limit() { + let key = tempfile::NamedTempFile::new().unwrap(); + let middleware = GitSigningMiddleware::new_for_test(key.path().to_path_buf()).unwrap(); + let mut events = vec![Ok(HttpRequestEvent { + event: Some(http_request_event::Event::Preflight( + receive_pack_preflight(), + )), + })]; + for sequence in 1..=65u64 { + events.push(Ok(HttpRequestEvent { + event: Some(http_request_event::Event::Body(HttpRequestBodyUnit { + sequence, + payload: Some(http_request_body_unit::Payload::Data(vec![ + b'x'; + MAX_UNIT_BYTES + ])), + end_of_stream: false, + })), + })); + } + events.push(Ok(HttpRequestEvent { + event: Some(http_request_event::Event::SessionEnd( + MiddlewareSessionEnd::default(), + )), + })); + + let mut results = middleware.request_stream(tokio_stream::iter(events)); + assert!(matches!( + results.next().await.unwrap().unwrap().result, + Some(http_request_event_result::Result::PreflightResult(_)) + )); + for sequence in 1..=65u64 { + let result = results.next().await.unwrap().unwrap(); + let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { + panic!("expected body ownership result"); + }; + assert_eq!(result.sequence, sequence); + assert!(matches!( + result.action, + Some(http_request_body_result::Action::TakeOwnership(_)) + )); + } + assert!(results.next().await.is_none()); + } +} diff --git a/examples/supervisor-middleware-git-signing/src/signer.rs b/examples/supervisor-middleware-git-signing/src/signer.rs new file mode 100644 index 0000000000..e36f92aa97 --- /dev/null +++ b/examples/supervisor-middleware-git-signing/src/signer.rs @@ -0,0 +1,1180 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use std::collections::{HashMap, HashSet}; +use std::fmt; +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{Duration, Instant}; + +use tempfile::TempDir; + +const SHA1_HEX_LEN: usize = 40; +const ZERO_SHA1: &str = "0000000000000000000000000000000000000000"; +const MAX_RECEIVE_PACK_PREFIX_BYTES: usize = 1024 * 1024; +const MAX_CAPTURE_BYTES: usize = 64 * 1024 * 1024; +const CHILD_POLL_INTERVAL: Duration = Duration::from_millis(10); + +pub struct GitSigner { + signing_key: PathBuf, + upstream_override: Option, +} + +pub struct SignedPush { + pub body: File, + pub body_len: u64, + pub signed_commits: u32, +} + +#[derive(Clone)] +pub struct SignControl { + cancelled: Arc, + deadline: Instant, +} + +impl SignControl { + pub fn new(cancelled: Arc, deadline: Instant) -> Self { + Self { + cancelled, + deadline, + } + } + + fn check(&self) -> Result<(), SignError> { + if self.cancelled.load(Ordering::Acquire) { + return Err(SignError::cancelled()); + } + if Instant::now() >= self.deadline { + return Err(SignError::timed_out()); + } + Ok(()) + } +} + +#[derive(Debug)] +pub struct SignError { + message: String, + kind: SignErrorKind, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SignErrorKind { + InvalidRequest, + Cancelled, + TimedOut, +} + +impl SignError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + kind: SignErrorKind::InvalidRequest, + } + } + + fn cancelled() -> Self { + Self { + message: "Git signing was cancelled".into(), + kind: SignErrorKind::Cancelled, + } + } + + fn timed_out() -> Self { + Self { + message: "Git signing exceeded its deadline".into(), + kind: SignErrorKind::TimedOut, + } + } + + pub fn is_cancelled(&self) -> bool { + self.kind == SignErrorKind::Cancelled + } + + pub fn is_timed_out(&self) -> bool { + self.kind == SignErrorKind::TimedOut + } + + pub fn public_message(&self) -> &'static str { + "outgoing Git push could not be signed" + } +} + +impl fmt::Display for SignError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl std::error::Error for SignError {} + +impl GitSigner { + pub fn new(signing_key: PathBuf) -> Result { + if !signing_key.is_file() { + return Err("the signing key must name a readable file".into()); + } + Ok(Self { + signing_key, + upstream_override: None, + }) + } + + #[cfg(test)] + fn new_with_upstream_override( + signing_key: PathBuf, + upstream_override: String, + ) -> Result { + let mut signer = Self::new(signing_key)?; + signer.upstream_override = Some(upstream_override); + Ok(signer) + } + + pub fn sign_receive_pack( + &self, + mut body: File, + upstream_url: Option<&str>, + control: &SignControl, + ) -> Result { + control.check()?; + let mut parsed = ReceivePackRequest::parse_file(&mut body)?; + if parsed.updates.is_empty() { + return Err(SignError::new( + "receive-pack request contains no ref updates", + )); + } + + let workspace = TempDir::new().map_err(|error| SignError::new(error.to_string()))?; + let repo = workspace.path().join("objects.git"); + run_git_controlled( + None, + &["init", "--bare", repo_string(&repo)?], + None, + control, + )?; + if let Some(upstream_url) = self.upstream_override.as_deref().or(upstream_url) { + hydrate_upstream(&repo, upstream_url, &parsed.updates, control)?; + } + body.seek(SeekFrom::Start(parsed.pack_offset)) + .map_err(|error| SignError::new(error.to_string()))?; + run_git_file_input( + Some(&repo), + &["index-pack", "--stdin", "--fix-thin"], + body, + control, + )?; + + let mut commit_ids = HashSet::new(); + for update in &parsed.updates { + if update.new_oid == ZERO_SHA1 { + continue; + } + control.check()?; + let output = run_git_controlled( + Some(&repo), + &["rev-list", &update.new_oid, "--not", "--all"], + None, + control, + )?; + let output = String::from_utf8(output) + .map_err(|_| SignError::new("git returned a non-UTF-8 commit list"))?; + commit_ids.extend(output.lines().map(str::to_string)); + } + let mut rewriter = CommitRewriter { + repo: &repo, + signing_key: &self.signing_key, + commit_ids, + rewritten: HashMap::new(), + active: HashSet::new(), + signed_count: 0, + workspace: workspace.path(), + control, + }; + + for update in &mut parsed.updates { + if update.new_oid == ZERO_SHA1 { + continue; + } + if !update.ref_name.starts_with("refs/heads/") { + return Err(SignError::new( + "prototype supports direct branch updates only", + )); + } + if !rewriter.commit_ids.contains(&update.new_oid) { + return Err(SignError::new( + "branch tip commit is not self-contained in the push pack", + )); + } + update.new_oid = rewriter.rewrite(&update.new_oid)?; + } + + if rewriter.signed_count == 0 { + return Err(SignError::new( + "receive-pack request contains no commits to sign", + )); + } + + let base_oids = list_ref_oids(&repo, "refs/middleware", control)?; + let mut revisions = parsed + .updates + .iter() + .filter(|update| update.new_oid != ZERO_SHA1) + .map(|update| update.new_oid.clone()) + .collect::>(); + revisions.extend(base_oids.into_iter().map(|oid| format!("^{oid}"))); + let revision_input = revisions.join("\n") + "\n"; + let mut prefix = parsed.prefix; + for update in &parsed.updates { + prefix[update.new_oid_range.clone()].copy_from_slice(update.new_oid.as_bytes()); + } + let mut result = tempfile::tempfile().map_err(|error| SignError::new(error.to_string()))?; + result + .write_all(&prefix) + .map_err(|error| SignError::new(error.to_string()))?; + run_git_to_file( + Some(&repo), + &["pack-objects", "--stdout", "--revs", "--thin"], + Some(revision_input.as_bytes()), + &mut result, + control, + )?; + let body_len = result + .metadata() + .map_err(|error| SignError::new(error.to_string()))? + .len(); + result + .seek(SeekFrom::Start(0)) + .map_err(|error| SignError::new(error.to_string()))?; + Ok(SignedPush { + body: result, + body_len, + signed_commits: rewriter.signed_count, + }) + } +} + +struct ReceivePackRequest { + prefix: Vec, + pack_offset: u64, + updates: Vec, +} + +struct RefUpdate { + old_oid: String, + new_oid: String, + ref_name: String, + new_oid_range: std::ops::Range, +} + +impl ReceivePackRequest { + fn parse_file(body: &mut File) -> Result { + body.seek(SeekFrom::Start(0)) + .map_err(|error| SignError::new(error.to_string()))?; + let mut prefix = Vec::new(); + let mut updates = Vec::new(); + let mut offset = 0; + let mut command_section = true; + let pack_offset = loop { + let mut marker = [0u8; 4]; + body.read_exact(&mut marker) + .map_err(|_| SignError::new("receive-pack request has no packfile"))?; + if !command_section && marker == *b"PACK" { + body.seek(SeekFrom::Current(-4)) + .map_err(|error| SignError::new(error.to_string()))?; + break offset as u64; + } + let length = parse_pkt_length(&marker)?; + prefix.extend_from_slice(&marker); + if prefix.len() > MAX_RECEIVE_PACK_PREFIX_BYTES { + return Err(SignError::new("receive-pack command prefix is too large")); + } + if length == 0 { + offset += 4; + command_section = false; + continue; + } + if length < 4 { + return Err(SignError::new("invalid receive-pack pkt-line length")); + } + let payload_start = offset + 4; + let mut payload = vec![0; length - 4]; + body.read_exact(&mut payload) + .map_err(|_| SignError::new("truncated receive-pack pkt-line"))?; + prefix.extend_from_slice(&payload); + if prefix.len() > MAX_RECEIVE_PACK_PREFIX_BYTES { + return Err(SignError::new("receive-pack command prefix is too large")); + } + if !command_section { + // Push options, when negotiated, are pkt-lines between the + // command flush and the packfile. Preserve them unchanged. + offset += length; + continue; + } + let command = payload.split(|byte| *byte == 0).next().unwrap_or(&payload); + let command = command.strip_suffix(b"\n").unwrap_or(command); + let first_space = command + .iter() + .position(|byte| *byte == b' ') + .ok_or_else(|| SignError::new("invalid receive-pack ref command"))?; + let second_space = command[first_space + 1..] + .iter() + .position(|byte| *byte == b' ') + .map(|index| first_space + 1 + index) + .ok_or_else(|| SignError::new("invalid receive-pack ref command"))?; + if first_space != SHA1_HEX_LEN || second_space - first_space - 1 != SHA1_HEX_LEN { + return Err(SignError::new( + "prototype supports SHA-1 Git repositories only", + )); + } + let new_start = payload_start + first_space + 1; + let old_oid = ascii_oid(&prefix[payload_start..payload_start + SHA1_HEX_LEN])?; + let new_oid = ascii_oid(&prefix[new_start..new_start + SHA1_HEX_LEN])?; + let ref_name = std::str::from_utf8(&command[second_space + 1..]) + .map_err(|_| SignError::new("receive-pack ref name is not UTF-8"))? + .to_string(); + updates.push(RefUpdate { + old_oid, + new_oid, + ref_name, + new_oid_range: new_start..new_start + SHA1_HEX_LEN, + }); + offset += length; + }; + + Ok(Self { + prefix, + pack_offset, + updates, + }) + } +} + +fn hydrate_upstream( + repo: &Path, + upstream_url: &str, + updates: &[RefUpdate], + control: &SignControl, +) -> Result<(), SignError> { + run_git_controlled( + Some(repo), + &[ + "-c", + "credential.interactive=false", + "fetch", + "--no-tags", + "--depth=1", + upstream_url, + "+HEAD:refs/middleware/upstream-head", + ], + None, + control, + )?; + for (index, old_oid) in updates + .iter() + .map(|update| update.old_oid.as_str()) + .filter(|oid| *oid != ZERO_SHA1) + .collect::>() + .into_iter() + .enumerate() + { + let destination = format!("+{old_oid}:refs/middleware/base-{index}"); + run_git_controlled( + Some(repo), + &[ + "-c", + "credential.interactive=false", + "fetch", + "--no-tags", + "--depth=1", + upstream_url, + &destination, + ], + None, + control, + )?; + } + Ok(()) +} + +fn list_ref_oids( + repo: &Path, + prefix: &str, + control: &SignControl, +) -> Result, SignError> { + let output = run_git_controlled( + Some(repo), + &["for-each-ref", "--format=%(objectname)", prefix], + None, + control, + )?; + let output = String::from_utf8(output) + .map_err(|_| SignError::new("git returned a non-UTF-8 ref list"))?; + Ok(output.lines().map(str::to_string).collect()) +} + +fn parse_pkt_length(bytes: &[u8]) -> Result { + let text = + std::str::from_utf8(bytes).map_err(|_| SignError::new("pkt-line length is not ASCII"))?; + usize::from_str_radix(text, 16).map_err(|_| SignError::new("invalid pkt-line length")) +} + +fn ascii_oid(bytes: &[u8]) -> Result { + if bytes.len() != SHA1_HEX_LEN || !bytes.iter().all(u8::is_ascii_hexdigit) { + return Err(SignError::new("invalid SHA-1 object id")); + } + String::from_utf8(bytes.to_vec()).map_err(|_| SignError::new("invalid SHA-1 object id")) +} + +struct CommitRewriter<'a> { + repo: &'a Path, + signing_key: &'a Path, + commit_ids: HashSet, + rewritten: HashMap, + active: HashSet, + signed_count: u32, + workspace: &'a Path, + control: &'a SignControl, +} + +impl CommitRewriter<'_> { + fn rewrite(&mut self, oid: &str) -> Result { + self.control.check()?; + if let Some(rewritten) = self.rewritten.get(oid) { + return Ok(rewritten.clone()); + } + if !self.commit_ids.contains(oid) { + return Ok(oid.to_string()); + } + if !self.active.insert(oid.to_string()) { + return Err(SignError::new("commit graph contains a cycle")); + } + + let raw = run_git_controlled( + Some(self.repo), + &["cat-file", "commit", oid], + None, + self.control, + )?; + let parsed = ParsedCommit::parse(&raw)?; + let mut parents = Vec::with_capacity(parsed.parents.len()); + for parent in &parsed.parents { + parents.push(self.rewrite(parent)?); + } + let unsigned = parsed.unsigned_with_parents(&parents); + let signature = self.sign_payload(oid, &unsigned)?; + let signed = insert_signature(&unsigned, &signature)?; + let new_oid = String::from_utf8(run_git_controlled( + Some(self.repo), + &["hash-object", "-t", "commit", "-w", "--stdin"], + Some(&signed), + self.control, + )?) + .map_err(|_| SignError::new("git returned a non-UTF-8 object id"))? + .trim() + .to_string(); + + self.active.remove(oid); + self.rewritten.insert(oid.to_string(), new_oid.clone()); + self.signed_count = self.signed_count.saturating_add(1); + Ok(new_oid) + } + + fn sign_payload(&self, oid: &str, payload: &[u8]) -> Result, SignError> { + let payload_path = self.workspace.join(format!("commit-{oid}")); + fs::write(&payload_path, payload).map_err(|error| SignError::new(error.to_string()))?; + let mut command = Command::new("ssh-keygen"); + command + .args(["-Y", "sign", "-n", "git", "-f"]) + .arg(self.signing_key) + .arg(&payload_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + run_command_controlled(command, None, "ssh-keygen", self.control)?; + // ssh-keygen appends `.sig` to the input path. + let signature_path = PathBuf::from(format!("{}.sig", payload_path.display())); + fs::read(signature_path).map_err(|error| SignError::new(error.to_string())) + } +} + +struct ParsedCommit { + headers: Vec
, + parents: Vec, + message: Vec, +} + +struct Header { + name: Vec, + block: Vec, +} + +impl ParsedCommit { + fn parse(raw: &[u8]) -> Result { + let separator = raw + .windows(2) + .position(|window| window == b"\n\n") + .ok_or_else(|| SignError::new("commit object has no header separator"))?; + let header_bytes = &raw[..separator]; + let mut headers: Vec
= Vec::new(); + for line in header_bytes.split(|byte| *byte == b'\n') { + if line.starts_with(b" ") { + let previous = headers + .last_mut() + .ok_or_else(|| SignError::new("commit starts with a continuation header"))?; + previous.block.push(b'\n'); + previous.block.extend_from_slice(line); + continue; + } + let name_end = line + .iter() + .position(|byte| *byte == b' ') + .ok_or_else(|| SignError::new("invalid commit header"))?; + headers.push(Header { + name: line[..name_end].to_vec(), + block: line.to_vec(), + }); + } + let parents = headers + .iter() + .filter(|header| header.name == b"parent") + .map(|header| ascii_oid(&header.block[b"parent ".len()..])) + .collect::, _>>()?; + if !headers.iter().any(|header| header.name == b"tree") { + return Err(SignError::new("commit object has no tree")); + } + Ok(Self { + headers, + parents, + message: raw[separator + 2..].to_vec(), + }) + } + + fn unsigned_with_parents(&self, parents: &[String]) -> Vec { + let mut result = Vec::new(); + let mut parent_index = 0; + for header in &self.headers { + if header.name == b"gpgsig" || header.name == b"gpgsig-sha256" { + continue; + } + if header.name == b"parent" { + result.extend_from_slice(b"parent "); + result.extend_from_slice(parents[parent_index].as_bytes()); + parent_index += 1; + } else { + result.extend_from_slice(&header.block); + } + result.push(b'\n'); + } + result.push(b'\n'); + result.extend_from_slice(&self.message); + result + } +} + +fn insert_signature(unsigned: &[u8], signature: &[u8]) -> Result, SignError> { + let separator = unsigned + .windows(2) + .position(|window| window == b"\n\n") + .ok_or_else(|| SignError::new("unsigned commit has no header separator"))?; + let signature = signature.strip_suffix(b"\n").unwrap_or(signature); + let mut result = Vec::with_capacity(unsigned.len() + signature.len() + 16); + result.extend_from_slice(&unsigned[..separator + 1]); + for (index, line) in signature.split(|byte| *byte == b'\n').enumerate() { + result.extend_from_slice(if index == 0 { b"gpgsig " } else { b" " }); + result.extend_from_slice(line); + result.push(b'\n'); + } + result.extend_from_slice(&unsigned[separator + 1..]); + Ok(result) +} + +fn repo_string(path: &Path) -> Result<&str, SignError> { + path.to_str() + .ok_or_else(|| SignError::new("temporary repository path is not UTF-8")) +} + +fn run_git_controlled( + repo: Option<&Path>, + args: &[&str], + input: Option<&[u8]>, + control: &SignControl, +) -> Result, SignError> { + let mut command = Command::new("git"); + if let Some(repo) = repo { + command.arg("-C").arg(repo); + } + command + .args(args) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + run_command_controlled( + command, + input, + args.first().copied().unwrap_or("command"), + control, + ) +} + +fn run_git_file_input( + repo: Option<&Path>, + args: &[&str], + input: File, + control: &SignControl, +) -> Result, SignError> { + let mut command = Command::new("git"); + if let Some(repo) = repo { + command.arg("-C").arg(repo); + } + command + .args(args) + .stdin(Stdio::from(input)) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + run_command_controlled( + command, + None, + args.first().copied().unwrap_or("command"), + control, + ) +} + +fn run_git_to_file( + repo: Option<&Path>, + args: &[&str], + input: Option<&[u8]>, + output: &mut File, + control: &SignControl, +) -> Result<(), SignError> { + output + .seek(SeekFrom::End(0)) + .map_err(|error| SignError::new(error.to_string()))?; + let output_handle = output + .try_clone() + .map_err(|error| SignError::new(error.to_string()))?; + let mut command = Command::new("git"); + if let Some(repo) = repo { + command.arg("-C").arg(repo); + } + command + .args(args) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::from(output_handle)) + .stderr(Stdio::piped()); + run_command_controlled( + command, + input, + args.first().copied().unwrap_or("command"), + control, + )?; + Ok(()) +} + +fn run_command_controlled( + mut command: Command, + input: Option<&[u8]>, + command_name: &str, + control: &SignControl, +) -> Result, SignError> { + control.check()?; + let mut child = command + .spawn() + .map_err(|error| SignError::new(format!("could not run {command_name}: {error}")))?; + let stdout = child.stdout.take().map(spawn_pipe_drain); + let stderr = child.stderr.take().map(spawn_pipe_drain); + if let Some(input) = input { + let write_result = child + .stdin + .take() + .ok_or_else(|| SignError::new(format!("{command_name} stdin was unavailable")))? + .write_all(input); + if let Err(error) = write_result { + let _ = child.kill(); + let _ = child.wait(); + return Err(SignError::new(error.to_string())); + } + } + drop(child.stdin.take()); + + let status = loop { + if let Err(error) = control.check() { + let _ = child.kill(); + let _ = child.wait(); + join_pipe(stdout)?; + join_pipe(stderr)?; + return Err(error); + } + match child + .try_wait() + .map_err(|error| SignError::new(error.to_string()))? + { + Some(status) => break status, + None => std::thread::sleep(CHILD_POLL_INTERVAL), + } + }; + let stdout = join_pipe(stdout)?; + let stderr = join_pipe(stderr)?; + if !status.success() { + let stderr = String::from_utf8_lossy(&stderr); + return Err(SignError::new(format!( + "{command_name} failed: {}", + stderr.trim() + ))); + } + Ok(stdout) +} + +type PipeDrain = std::thread::JoinHandle>>; + +fn spawn_pipe_drain(mut pipe: R) -> PipeDrain +where + R: Read + Send + 'static, +{ + std::thread::spawn(move || { + let mut captured = Vec::new(); + let mut buffer = [0u8; 8192]; + loop { + let read = pipe.read(&mut buffer)?; + if read == 0 { + return Ok(captured); + } + if captured.len() < MAX_CAPTURE_BYTES { + let keep = read.min(MAX_CAPTURE_BYTES - captured.len()); + captured.extend_from_slice(&buffer[..keep]); + } + } + }) +} + +fn join_pipe(drain: Option) -> Result, SignError> { + drain.map_or_else( + || Ok(Vec::new()), + |drain| { + drain + .join() + .map_err(|_| SignError::new("subprocess output worker failed"))? + .map_err(|error| SignError::new(error.to_string())) + }, + ) +} + +#[cfg(test)] +fn run_git(repo: Option<&Path>, args: &[&str], input: Option<&[u8]>) -> Result, SignError> { + let control = SignControl::new( + Arc::new(AtomicBool::new(false)), + Instant::now() + Duration::from_secs(300), + ); + run_git_controlled(repo, args, input, &control) +} + +#[cfg(test)] +mod tests { + use super::*; + use openshell_core::proto::{ + HttpHeader, HttpRequestBodyUnit, HttpRequestEvent, HttpRequestPreflight, HttpRequestTarget, + HttpRequestTrailers, MiddlewareSessionEnd, RequestContext, http_request_body_result, + http_request_body_unit, http_request_event, http_request_event_result, + }; + use tokio_stream::StreamExt as _; + + #[cfg(unix)] + #[test] + fn cancellation_terminates_an_active_subprocess() { + let cancelled = Arc::new(AtomicBool::new(false)); + let control = SignControl::new( + Arc::clone(&cancelled), + Instant::now() + Duration::from_secs(30), + ); + let started = Instant::now(); + let worker = std::thread::spawn(move || { + let mut command = Command::new("sleep"); + command + .arg("30") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + run_command_controlled(command, None, "sleep", &control) + }); + + std::thread::sleep(Duration::from_millis(50)); + cancelled.store(true, Ordering::Release); + let error = worker + .join() + .expect("subprocess worker must not panic") + .expect_err("cancellation must terminate the subprocess"); + + assert!(error.is_cancelled()); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[tokio::test] + async fn signs_every_commit_and_rewrites_the_branch_tip() { + let fixture = TempDir::new().unwrap(); + let source = fixture.path().join("source.git"); + run_git( + None, + &["init", "--bare", repo_string(&source).unwrap()], + None, + ) + .unwrap(); + let large_blob = pseudo_random_bytes(5 * 1024 * 1024); + let blob = object(&source, "blob", &large_blob); + let tree_line = format!("100644 blob {blob}\tREADME.md\n"); + let tree = String::from_utf8( + run_git(Some(&source), &["mktree"], Some(tree_line.as_bytes())).unwrap(), + ) + .unwrap() + .trim() + .to_string(); + let first = unsigned_commit(&source, &tree, None, "first"); + let second = unsigned_commit(&source, &tree, Some(&first), "second"); + let upstream_head = unsigned_commit(&source, &tree, None, "upstream"); + run_git( + Some(&source), + &["update-ref", "refs/heads/main", &upstream_head], + None, + ) + .unwrap(); + run_git( + Some(&source), + &["symbolic-ref", "HEAD", "refs/heads/main"], + None, + ) + .unwrap(); + let objects = format!("{blob}\n{tree}\n{first}\n{second}\n"); + let pack = run_git( + Some(&source), + &["pack-objects", "--stdout"], + Some(objects.as_bytes()), + ) + .unwrap(); + let body = receive_pack_body(&second, &pack); + assert!(body.len() > 4 * 1024 * 1024); + + let key = fixture.path().join("signing-key"); + let status = Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-f"]) + .arg(&key) + .status() + .unwrap(); + assert!(status.success()); + let signed = sign_through_owned_stream(&key, &source, &body).await; + assert_eq!(signed.signed_commits, 2); + + let mut signed_file = file_with_bytes(&signed.body); + let parsed = ReceivePackRequest::parse_file(&mut signed_file).unwrap(); + let new_tip = parsed.updates[0].new_oid.clone(); + assert_ne!(new_tip, second); + let verify = fixture.path().join("verify.git"); + run_git( + None, + &["init", "--bare", repo_string(&verify).unwrap()], + None, + ) + .unwrap(); + run_git( + None, + &[ + "receive-pack", + "--stateless-rpc", + repo_string(&verify).unwrap(), + ], + Some(&signed.body), + ) + .unwrap(); + let received_tip = String::from_utf8( + run_git(Some(&verify), &["rev-parse", "refs/heads/main"], None).unwrap(), + ) + .unwrap(); + assert_eq!(received_tip.trim(), new_tip); + let tip = run_git(Some(&verify), &["cat-file", "commit", &new_tip], None).unwrap(); + assert!(tip.windows(7).any(|window| window == b"gpgsig ")); + let tip = ParsedCommit::parse(&tip).unwrap(); + assert_eq!(tip.parents.len(), 1); + assert_ne!(tip.parents[0], first); + let parent = run_git( + Some(&verify), + &["cat-file", "commit", &tip.parents[0]], + None, + ) + .unwrap(); + assert!(parent.windows(7).any(|window| window == b"gpgsig ")); + + let allowed = fixture.path().join("allowed-signers"); + let public_key = fs::read_to_string(key.with_extension("pub")).unwrap(); + fs::write(&allowed, format!("agent@example.com {}", public_key.trim())).unwrap(); + verify_commit(&verify, &allowed, &new_tip); + verify_commit(&verify, &allowed, &tip.parents[0]); + } + + struct CollectedSignedPush { + body: Vec, + signed_commits: u32, + } + + async fn sign_through_owned_stream( + key: &Path, + upstream: &Path, + body: &[u8], + ) -> CollectedSignedPush { + let signer = GitSigner::new_with_upstream_override( + key.to_path_buf(), + repo_string(upstream).unwrap().to_string(), + ) + .unwrap(); + let mut middleware = crate::GitSigningMiddleware::new_for_test(key.to_path_buf()).unwrap(); + middleware.signer = std::sync::Arc::new(signer); + let mut events = vec![Ok(HttpRequestEvent { + event: Some(http_request_event::Event::Preflight(HttpRequestPreflight { + context: Some(RequestContext { + request_id: "large-push".into(), + ..Default::default() + }), + target: Some(HttpRequestTarget { + scheme: "https".into(), + host: "github.com".into(), + port: 443, + method: "POST".into(), + path: "/NVIDIA/OpenShell.git/git-receive-pack".into(), + ..Default::default() + }), + headers: vec![HttpHeader { + name: "content-type".into(), + value: "application/x-git-receive-pack-request".into(), + }], + permitted_body_modes: vec![ + openshell_core::proto::HttpRequestBodyMode::OwnedStreamBytes as i32, + ], + max_payload_bytes: crate::MAX_UNIT_BYTES as u64, + max_deferred_bytes: 16 * 1024 * 1024, + ..Default::default() + })), + })]; + let mut final_input_sequence = 1u64; + for (index, chunk) in body.chunks(crate::MAX_UNIT_BYTES).enumerate() { + final_input_sequence = index as u64 + 1; + events.push(Ok(HttpRequestEvent { + event: Some(http_request_event::Event::Body(HttpRequestBodyUnit { + sequence: final_input_sequence, + payload: Some(http_request_body_unit::Payload::Data(chunk.to_vec())), + end_of_stream: false, + })), + })); + } + final_input_sequence += 1; + events.push(Ok(HttpRequestEvent { + event: Some(http_request_event::Event::Body(HttpRequestBodyUnit { + sequence: final_input_sequence, + payload: Some(http_request_body_unit::Payload::Data(Vec::new())), + end_of_stream: true, + })), + })); + events.push(Ok(HttpRequestEvent { + event: Some(http_request_event::Event::Trailers( + HttpRequestTrailers::default(), + )), + })); + events.push(Ok(HttpRequestEvent { + event: Some(http_request_event::Event::SessionEnd( + MiddlewareSessionEnd::default(), + )), + })); + + let mut results = middleware.request_stream(tokio_stream::iter(events)); + assert!(matches!( + results.next().await.unwrap().unwrap().result, + Some(http_request_event_result::Result::PreflightResult(_)) + )); + for sequence in 1..=final_input_sequence { + let result = results.next().await.unwrap().unwrap(); + let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { + panic!("expected body ownership result"); + }; + assert_eq!(result.sequence, sequence); + assert!(matches!( + result.action, + Some(http_request_body_result::Action::TakeOwnership(_)) + )); + } + + let mut output = Vec::new(); + let mut next_output_sequence = 1u64; + let signed_commits = loop { + let result = results.next().await.unwrap().unwrap(); + match result.result { + Some(http_request_event_result::Result::BodyOutput(unit)) => { + assert_eq!(unit.sequence, next_output_sequence); + next_output_sequence += 1; + output.extend_from_slice(&unit.data); + } + Some(http_request_event_result::Result::BodyFinalize(finalize)) => { + assert_eq!(finalize.through_input_sequence, final_input_sequence); + assert_eq!(finalize.through_output_sequence, next_output_sequence - 1); + break finalize.findings[0].count; + } + other => panic!("unexpected owned output result: {other:?}"), + } + }; + assert!(matches!( + results.next().await.unwrap().unwrap().result, + Some(http_request_event_result::Result::TrailersResult(_)) + )); + assert!(results.next().await.is_none()); + CollectedSignedPush { + body: output, + signed_commits, + } + } + + #[test] + fn rejects_non_branch_updates() { + let payload = format!("{ZERO_SHA1} {ZERO_SHA1} refs/tags/v1\n"); + let length = payload.len() + 4; + let mut body = format!("{length:04x}{payload}0000").into_bytes(); + body.extend_from_slice(b"PACK"); + let mut file = file_with_bytes(&body); + let parsed = ReceivePackRequest::parse_file(&mut file).unwrap(); + assert_eq!(parsed.updates[0].ref_name, "refs/tags/v1"); + } + + #[test] + fn resolves_a_thin_pack_from_the_upstream_repository() { + let fixture = TempDir::new().unwrap(); + let source = fixture.path().join("source.git"); + run_git( + None, + &["init", "--bare", repo_string(&source).unwrap()], + None, + ) + .unwrap(); + let blob = object(&source, "blob", b"base\n"); + let tree_line = format!("100644 blob {blob}\tREADME.md\n"); + let tree = String::from_utf8( + run_git(Some(&source), &["mktree"], Some(tree_line.as_bytes())).unwrap(), + ) + .unwrap() + .trim() + .to_string(); + let base = unsigned_commit(&source, &tree, None, "base"); + run_git( + Some(&source), + &["update-ref", "refs/heads/main", &base], + None, + ) + .unwrap(); + run_git( + Some(&source), + &["symbolic-ref", "HEAD", "refs/heads/main"], + None, + ) + .unwrap(); + let tip = unsigned_commit(&source, &tree, Some(&base), "tip"); + let revisions = format!("{tip}\n^{base}\n"); + let pack = run_git( + Some(&source), + &["pack-objects", "--stdout", "--revs", "--thin"], + Some(revisions.as_bytes()), + ) + .unwrap(); + let body = receive_pack_body(&tip, &pack); + + let key = fixture.path().join("signing-key"); + assert!( + Command::new("ssh-keygen") + .args(["-q", "-t", "ed25519", "-N", "", "-f"]) + .arg(&key) + .status() + .unwrap() + .success() + ); + let signed = GitSigner::new(key) + .unwrap() + .sign_receive_pack(file_with_bytes(&body), source.to_str(), &test_control()) + .unwrap(); + assert_eq!(signed.signed_commits, 1); + } + + fn file_with_bytes(bytes: &[u8]) -> File { + let mut file = tempfile::tempfile().unwrap(); + file.write_all(bytes).unwrap(); + file.seek(SeekFrom::Start(0)).unwrap(); + file + } + + fn test_control() -> SignControl { + SignControl::new( + Arc::new(AtomicBool::new(false)), + Instant::now() + Duration::from_secs(300), + ) + } + + fn object(repo: &Path, kind: &str, body: &[u8]) -> String { + String::from_utf8( + run_git( + Some(repo), + &["hash-object", "-t", kind, "-w", "--stdin"], + Some(body), + ) + .unwrap(), + ) + .unwrap() + .trim() + .to_string() + } + + fn unsigned_commit(repo: &Path, tree: &str, parent: Option<&str>, subject: &str) -> String { + let parent = parent.map_or(String::new(), |oid| format!("parent {oid}\n")); + let raw = format!( + "tree {tree}\n{parent}author Agent 1700000000 +0000\ncommitter Agent 1700000000 +0000\n\n{subject}\n" + ); + object(repo, "commit", raw.as_bytes()) + } + + fn receive_pack_body(new_oid: &str, pack: &[u8]) -> Vec { + let payload = format!( + "{ZERO_SHA1} {new_oid} refs/heads/main\0 report-status side-band-64k object-format=sha1\n" + ); + let length = payload.len() + 4; + let mut body = format!("{length:04x}{payload}0000").into_bytes(); + body.extend_from_slice(pack); + body + } + + fn pseudo_random_bytes(len: usize) -> Vec { + let mut state = 0x4d59_5df4_d0f3_3173_u64; + (0..len) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state as u8 + }) + .collect() + } + + fn verify_commit(repo: &Path, allowed: &Path, oid: &str) { + let output = Command::new("git") + .arg("-C") + .arg(repo) + .args(["-c", "gpg.format=ssh", "-c"]) + .arg(format!("gpg.ssh.allowedSignersFile={}", allowed.display())) + .args(["verify-commit", oid]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + } +} diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 81e1c72f86..76059153f4 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -19,10 +19,6 @@ service SupervisorMiddleware { // ValidateConfig checks service-specific configuration for one binding. rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse); - // EvaluateHttpRequest returns an allow, deny, or mutation decision for one - // buffered HTTP request. - rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); - // EvaluateWebSocketSession opens one ordered, phase-specific stream for a // single middleware stage and WebSocket upgrade attempt. The current // implementation supports client-to-upstream text messages at @@ -34,6 +30,17 @@ service SupervisorMiddleware { returns (stream WebSocketSessionEventResult); } +// HttpRequestPreCredentials begins after network-policy admission and before +// OpenShell injects credentials. A STREAM_BYTES stage may remain active while +// OpenShell forwards already-approved units upstream. +service HttpRequestPreCredentials { + // Evaluate starts with preflight and may continue with selected body units + // and trailers. A body unit marked end_of_stream ends body input, not the + // event stream. Trailers and one best-effort session_end may follow. + rpc Evaluate(stream HttpRequestEvent) + returns (stream HttpRequestEventResult); +} + // HttpResponsePreReturn evaluates one response for one middleware stage before // OpenShell returns it to the sandbox. service HttpResponsePreReturn { @@ -96,30 +103,6 @@ message ValidateConfigResponse { string reason = 2; } -// HttpRequestEvaluation contains one buffered HTTP request to evaluate. -message HttpRequestEvaluation { - // Evaluation phase selected for this request. - SupervisorMiddlewarePhase phase = 1; - // Sandbox and request identity available to the supervisor. - // The encoded context is limited to 4 KiB. - RequestContext context = 2; - // Validated service-specific policy configuration. - // The encoded configuration is limited to 64 KiB. - google.protobuf.Struct config = 3; - // Destination and HTTP request target. - // The encoded target is limited to 32 KiB. - HttpRequestTarget target = 4; - // HTTP request headers before OpenShell injects credentials, in wire - // order. Repeated header names are preserved as separate entries. Protected - // credential, routing, framing, and hop-by-hop headers are omitted. - // At most 128 lines and 64 KiB of encoded headers are included. - repeated HttpHeader headers = 5; - // Buffered request body, limited to 4 MiB. Empty for a bodyless request. - bytes body = 6; - // Built-in middleware name or operator-owned registration name. - string middleware_name = 7; -} - // HttpHeader is one HTTP header line. message HttpHeader { // Lowercased header name. @@ -128,6 +111,192 @@ message HttpHeader { string value = 2; } +// One ordered request event. A stream starts with preflight, may continue with +// body units ending in end_of_stream, may then include trailers, and may end +// with one best-effort session_end. +message HttpRequestEvent { + oneof event { + HttpRequestPreflight preflight = 1; + HttpRequestBodyUnit body = 2; + MiddlewareSessionEnd session_end = 3; + HttpRequestTrailers trailers = 4; + } +} + +// Each preflight, body, and trailers event requires one ordered result. +// session_end has no result. An owned stage may additionally produce output +// units and one finalization after accepting its final body unit. +message HttpRequestEventResult { + oneof result { + HttpRequestPreflightResult preflight_result = 1; + HttpRequestBodyResult body_result = 2; + HttpRequestTrailersResult trailers_result = 3; + HttpRequestBodyOutput body_output = 4; + HttpRequestBodyFinalize body_finalize = 5; + } +} + +// HttpRequestPreflight exposes the current admitted request head to one stage. +message HttpRequestPreflight { + // Request identity. Limited to 4 KiB encoded. + RequestContext context = 1; + // Admitted destination and request target. Limited to 32 KiB encoded. + HttpRequestTarget target = 2; + // Current headers after earlier stages and before credential injection, in + // wire order. Protected fields are omitted. Limited to 128 lines and 64 KiB. + repeated HttpHeader headers = 3; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 4; + // Validated service configuration. Limited to 64 KiB encoded. + google.protobuf.Struct config = 5; + // Effective per-unit or whole-body input/replacement limit. + uint64 max_payload_bytes = 6; + // Modes OpenShell permits for this request and binding. HEADERS_ONLY is + // always present. OWNED_STREAM_BYTES is present only for fail-closed stages + // with a non-zero deferred-byte limit. + repeated HttpRequestBodyMode permitted_body_modes = 7; + // Effective per-representation input and output limit for + // OWNED_STREAM_BYTES. Input and output are each bounded by this value. + uint64 max_deferred_bytes = 8; + // Present when the normalized body length is known before inspection. + optional uint64 declared_body_length = 9; +} + +// Selects skip, inspect, or block. Invalid diagnostics make the complete +// result a middleware failure handled according to on_error. +message HttpRequestPreflightResult { + oneof action { + HttpRequestPreflightSkip skip = 1; + HttpRequestPreflightInspect inspect = 2; + HttpRequestBlock block_request = 7; + } + // Service diagnostic, never sent to the sandbox or security logs. + string reason = 3; + // Optional stable audit code, returned only for an accepted block. + string reason_code = 4; + repeated Finding findings = 5; + map metadata = 6; +} + +message HttpRequestPreflightSkip {} + +// Selects body inspection and request-header mutations. +message HttpRequestPreflightInspect { + HttpRequestBodyMode body_mode = 1; + repeated HeaderMutation header_mutations = 2; +} + +// Authoritatively rejects the request. A preflight rejection occurs before +// upstream contact; a streaming body rejection may terminate a partial upload. +message HttpRequestBlock {} + +// Controls which request body units a stage receives. +enum HttpRequestBodyMode { + HTTP_REQUEST_BODY_MODE_UNSPECIFIED = 0; + // Inspect only the request head. + HTTP_REQUEST_BODY_MODE_HEADERS_ONLY = 1; + // Receive the complete normalized body as one final unit. Input and + // replacement must fit max_payload_bytes. + HTTP_REQUEST_BODY_MODE_WHOLE_BODY_BYTES = 2; + // Receive normalized units in lockstep. Each result fully accounts for its + // input; the stage cannot retain bytes across units. + HTTP_REQUEST_BODY_MODE_STREAM_BYTES = 3; + // Take ownership of normalized input units without requiring OpenShell to + // retain a replay copy. The stage must accept every unit with take_ownership, + // then emit bounded output units and one body_finalize after the final input. + // Failure is always fail closed because the original bytes are no longer + // available for replay. + HTTP_REQUEST_BODY_MODE_OWNED_STREAM_BYTES = 4; +} + +// One normalized body unit. Boundaries have no transport or application +// meaning. +message HttpRequestBodyUnit { + // Contiguous and stage-local, starting at 1. + uint64 sequence = 1; + oneof payload { + bytes data = 2; + } + // Marks the final input unit. WHOLE_BODY_BYTES receives the complete body in + // this unit. STREAM_BYTES and OWNED_STREAM_BYTES receive nonempty data units + // with this unset, followed by one empty final unit. A body-capable request + // with no bytes also receives one empty final unit. Trailers and session_end + // may follow. + bool end_of_stream = 3; +} + +// Result for one body unit. +message HttpRequestBodyResult { + uint64 sequence = 1; + oneof action { + HttpRequestBodyPassThrough pass_through = 2; + HttpRequestBodyTransform transform = 3; + HttpRequestBlock block_request = 8; + HttpRequestBodySkipRemaining skip_remaining = 9; + HttpRequestBodyTakeOwnership take_ownership = 10; + } + string reason = 4; + string reason_code = 5; + repeated Finding findings = 6; + map metadata = 7; +} + +message HttpRequestBodyPassThrough {} + +message HttpRequestBodyTransform { + oneof replacement { + bytes data = 1; + } +} + +// Finalizes the current unit and ends inspection for this stage. +message HttpRequestBodySkipRemaining { + oneof current { + HttpRequestBodyPassThrough pass_through = 1; + HttpRequestBodyTransform transform = 2; + } +} + +// Acknowledges that an owned stage durably accepted the complete input unit. +message HttpRequestBodyTakeOwnership {} + +// One owned-stage output unit. Output sequence is contiguous from 1 and each +// unit fits max_payload_bytes. The complete output fits max_deferred_bytes. +message HttpRequestBodyOutput { + uint64 sequence = 1; + bytes data = 2; +} + +// Completes owned output and proves how much input and output it accounts for. +message HttpRequestBodyFinalize { + // Must equal the final accepted input sequence. + uint64 through_input_sequence = 1; + // Must equal the final output sequence, or zero for empty output. + uint64 through_output_sequence = 2; + // Final owned-stage diagnostic, never sent to the sandbox or security logs. + string reason = 3; + // Optional stable audit code for the completed transformation. + string reason_code = 4; + repeated Finding findings = 5; + map metadata = 6; +} + +// Current normalized request trailers in wire order. An inspecting body stage +// receives exactly one trailers event, including when the set is empty. +message HttpRequestTrailers { + repeated HttpHeader headers = 1; +} + +// Applies ordered mutations to existing request trailers. V1 cannot create a +// trailer name that was not announced by the sender. +message HttpRequestTrailersResult { + repeated HeaderMutation trailer_mutations = 1; + string reason = 2; + string reason_code = 3; + repeated Finding findings = 4; + map metadata = 5; +} + // One ordered response event. A stream starts with preflight, may continue with // body units ending in end_of_stream, may then include trailers, and may end // with one best-effort session_end. @@ -209,7 +378,7 @@ message HttpResponsePreflightResult { // Service diagnostic, never sent to the sandbox or security logs. Maximum // 4 KiB. string reason = 3; - // Optional audit code using the HttpRequestResult.reason_code format and + // Optional audit code using the HttpRequestPreflightResult.reason_code format and // 64-byte maximum. Returned to the sandbox only for block_delivery. string reason_code = 4; // Up to 32 audit-safe findings, each limited to 4 KiB encoded. @@ -318,7 +487,7 @@ message HttpResponseBodyResult { // Service diagnostic, never sent to the sandbox or security logs. Maximum // 4 KiB. string reason = 4; - // Optional audit code using the HttpRequestResult.reason_code format and + // Optional audit code using the HttpRequestPreflightResult.reason_code format and // 64-byte maximum. When OpenShell accepts block_delivery before response // commitment, it includes this code in the canonical denial response. It is // never returned after commitment. @@ -380,7 +549,7 @@ message HttpResponseTrailersResult { // Service diagnostic, never sent to the sandbox or security logs. Maximum // 4 KiB. string reason = 2; - // Optional audit code using the HttpRequestResult.reason_code format and + // Optional audit code using the HttpRequestPreflightResult.reason_code format and // 64-byte maximum. Never sent to the sandbox. string reason_code = 3; // Up to 32 audit-safe findings, each limited to 4 KiB encoded. @@ -456,6 +625,9 @@ enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN = 2; + // Restricted to trusted in-process middleware. External services cannot + // advertise this phase because credentials must not cross the extension API. + SUPERVISOR_MIDDLEWARE_PHASE_POST_CREDENTIALS = 3; } // WebSocketSessionEvent is one ordered event in a stage-local stream. @@ -530,7 +702,7 @@ message WebSocketPreflightDecision { // Optional stable machine-readable code for a deny decision. Because // preflight runs before the HTTP upgrade completes, OpenShell may return // this code to the requester. Codes follow the same format and 64-byte - // maximum as HttpRequestResult.reason_code. + // maximum as HttpRequestPreflightResult.reason_code. string reason_code = 3; // Audit-safe findings produced during preflight. At most 32 findings of at // most 4 KiB encoded each are accepted. @@ -674,39 +846,3 @@ message HeaderMutation { RemoveHeader remove = 2; } } - -// HttpRequestResult contains the decision and optional request mutations. -message HttpRequestResult { - // Allow or deny decision for this request. - Decision decision = 1; - // Free-form service diagnostic. OpenShell does not relay this text into - // denied responses or security logs. Limited to 4 KiB before discarding. - string reason = 2; - // Replacement request body when has_body is true. Limited to 4 MiB. - bytes body = 3; - // True when body should replace the request body, including with an empty body. - bool has_body = 4; - // Ordered request-header mutations applied before the next middleware and - // before forwarding. Writes and removals may target visible end-to-end - // request headers, but credential, routing, framing, and hop-by-hop headers - // are always protected. Written values cannot contain OpenShell credential - // placeholder syntax. A violating result is a middleware failure handled - // according to the policy failure mode. At most 64 operations, 32 KiB of - // validated name/value data, and 64 KiB encoded are accepted. - repeated HeaderMutation header_mutations = 5; - // Audit-safe findings produced during evaluation. For operator-run services, - // OpenShell logs platform-owned fields derived from the operator-owned - // registration name rather than service-provided type, label, confidence, - // or metadata text. - // At most 32 findings of at most 4 KiB encoded each are accepted per stage. - // A policy selects at most 10 stages, so one chain retains at most 320. - repeated Finding findings = 6; - // Non-secret service-defined metadata included in diagnostics. At most 64 - // entries and 32 KiB of combined key/value data are accepted. - map metadata = 7; - // Optional stable machine-readable code for a deny decision. Codes must - // start with a lowercase ASCII letter and contain only lowercase ASCII - // letters, digits, and underscores, with a maximum length of 64 bytes. - // OpenShell may return this code to the requester, unlike free-form reason. - string reason_code = 8; -} diff --git a/rfc/0009-supervisor-middleware/README.md b/rfc/0009-supervisor-middleware/README.md index 1f6eacde7b..4f60b0058e 100644 --- a/rfc/0009-supervisor-middleware/README.md +++ b/rfc/0009-supervisor-middleware/README.md @@ -17,8 +17,9 @@ links: | Date | References | Change | |------|------------|--------| -| 2026-07-17 | [#2010](https://github.com/NVIDIA/OpenShell/issues/2010) | Added unary HTTP request middleware with built-in and operator-run services. | +| 2026-07-17 | [#2010](https://github.com/NVIDIA/OpenShell/issues/2010) | Added HTTP request middleware with built-in and operator-run services. | | 2026-07-28 | [#2428](https://github.com/NVIDIA/OpenShell/issues/2428) | Added WebSocket preflight and text-message evaluation, and aligned the middleware API names, limits, and diagnostics. | +| 2026-09-17 | [#2431](https://github.com/NVIDIA/OpenShell/issues/2431), [#3307](https://github.com/NVIDIA/OpenShell/issues/3307) | Replaced the unary request hook with a bounded bidirectional stream, added owned transformations and request trailers, and moved SigV4 into a restricted post-credentials built-in. | ## Summary @@ -57,13 +58,13 @@ This RFC uses the following terms with specific meanings. - **Egress.** An outbound request a sandbox sends to an upstream destination through the supervisor proxy. The v1 middleware hook acts on the parsed request the supervisor has already admitted and is about to forward, not on raw packets or arbitrary network activity. - **Middleware.** A service that inspects, transforms, blocks, or annotates supervisor operations through the contract defined in this RFC. In the v1 egress hook, a middleware owns its detection and transformation logic and never makes the upstream call itself; the supervisor always owns the upstream call. -- **Registered middleware.** An external middleware service an operator declares in gateway configuration as a diagnostic name plus a gRPC endpoint. Registration is an administrative action that establishes which endpoints may receive raw request content. The service exposes stable binding IDs through `Describe`, and policy refers to those binding IDs rather than to the registration name. -- **Built-in middleware.** A middleware that ships inside the supervisor binary and runs in-process, with no network hop and no gateway registration. Built-in binding IDs use the reserved `openshell/` namespace, for example `openshell/regex`. +- **Registered middleware.** An external middleware service an operator declares in gateway configuration under a stable registration name plus a gRPC endpoint. Registration is an administrative action that establishes which endpoints may receive raw request content. Policy attaches the complete service by this operator-owned name; `Describe` reports its supported operation and phase bindings. +- **Built-in middleware.** A middleware that ships inside the supervisor binary and runs in-process, with no network hop and no gateway registration. Built-in names use the reserved `openshell/` namespace, for example `openshell/regex`. - **Operation.** The typed method plus typed phase that identifies the point where OpenShell invokes middleware. This RFC's v1 middleware evaluates `method=HTTP_REQUEST, phase=PRE_CREDENTIALS`. - **Hook.** A named middleware API contract for one operation. Middleware hook names are part of the middleware API, not arbitrary strings supplied by the caller. The v1 hook is `HTTP_REQUEST/PRE_CREDENTIALS`, which runs in the HTTP relay once the request is parsed and admitted by policy and before credential injection. The design allows more typed operations later without changing the v1 hook's request shape. -- **Evaluation.** One invocation of middleware for a specific operation, request context, bounded body, and middleware config. Middleware keeps operation-specific methods such as `EvaluateHttpRequest` because inputs and outputs differ by protocol or operation type. +- **Evaluation.** One invocation of middleware for a specific operation, request context, bounded unit or body, and middleware config. Middleware keeps operation-specific streaming services because inputs and outputs differ by protocol or operation type. - **Result.** The response to an evaluation. For the v1 HTTP request hook, the result carries an allow/deny decision, optional replacement content and safe header mutations, findings, metadata, and safe error information. -- **Middleware config.** A policy entry stored under a stable policy-local map key that namespaces metadata and diagnostics. The optional `name` field is a human-readable label and defaults to the map key. The `middleware` field binds the entry to a service-owned binding ID, while the remaining fields define service-specific configuration, endpoint selectors, failure behavior, and ordering. +- **Middleware config.** A policy entry stored under a stable policy-local map key that namespaces metadata and diagnostics. The optional `name` field is a human-readable label and defaults to the map key. The `middleware` field selects a built-in or operator-owned registration name, while the remaining fields define service-specific configuration, endpoint selectors, failure behavior, and ordering. - **Manifest.** The self-description a middleware returns from `Describe`: its service version and service-owned bindings for the hooks it supports. The protobuf package `openshell.middleware.v1` defines the wire-version boundary; requests and manifests do not carry a duplicate API-version string. - **Decision.** The allow-or-deny outcome a middleware returns for a request. `allow` lets the request proceed (possibly transformed); `deny` short-circuits it. This vocabulary matches the rest of the OpenShell policy system. - **Failure policy.** The configured `on_error` behavior when middleware cannot return a valid result: `fail_closed` denies the request, while `fail_open` lets it continue without that middleware's transformation while recording an enforcement failure. `fail_closed` is the default whenever processing is required. @@ -112,7 +113,7 @@ graph LR ### Operation phases and placement -A middleware service provides hook implementations that the supervisor invokes at defined operation phases in the proxy flow. This version defines a single typed middleware operation, `HTTP_REQUEST/PRE_CREDENTIALS`, and is structured so more operations can be added later. The supervisor invokes the hook in the HTTP relay once the request has been parsed and admitted by policy, and before OpenShell injects upstream credentials. +A middleware service provides hook implementations that the supervisor invokes at defined operation phases in the proxy flow. V1 defines `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. It also reserves `HTTP_REQUEST/POST_CREDENTIALS` for trusted in-process built-ins. The supervisor invokes the external request hook after policy admission and before credential injection. ```mermaid graph LR @@ -126,7 +127,8 @@ graph LR RECHECK -->|"deny"| DENY RECHECK -->|"next stage or complete"| ROUTE["Route selection"] ROUTE --> CRED["Credential injection"] - CRED --> UP["Upstream forwarding"] + CRED --> SIGN["Restricted POST_CREDENTIALS
built-ins"] + SIGN --> UP["Upstream forwarding"] ``` This ordering is deliberate: @@ -145,27 +147,28 @@ The hook operates on a parsed HTTP request, so it runs wherever OpenShell can pa If a selected operation chain becomes uninspectable at runtime, OpenShell examines that chain. If any selected stage is `fail_closed`, the request is denied. If every selected stage is `fail_open`, OpenShell relays the request and emits a bypass `DetectionFinding`. This chain-level rule prevents one permissive selected stage from overriding a required stage. -Attachment and operation selection are separate. A destination host selector attaches a policy config, then the implementation manifest decides whether that config participates in `HTTP_REQUEST/PRE_CREDENTIALS`, `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, or both. The absence of an operation binding is a declared capability boundary rather than a middleware failure, so `on_error` does not apply. OpenShell records informational coverage when an attached config does not join the WebSocket chain. +Attachment and operation selection are separate. A destination host selector attaches a policy config, then the implementation manifest decides whether that config participates in `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, or multiple phases. The absence of an operation binding is a declared capability boundary rather than a middleware failure, so `on_error` does not apply. OpenShell records informational coverage when an attached config does not join the WebSocket chain. WebSocket sits on this boundary. The upgrade request is a normal HTTP/1.1 request that an HTTP binding can inspect, allow, or deny. A separate V1 operation covers complete client-to-upstream text messages after upgrade. Binary messages, control frames, and upstream-to-client messages remain outside that operation. To keep the v1 boundary unambiguous: **In scope for v1:** - Inspectable HTTP/1.x requests that OpenShell terminates and parses, after L4 and SSRF admit them (and L7 policy too, where the endpoint declares a `protocol`). +- Final HTTP/1.x responses before delivery, using header-only, whole-body, or streaming inspection. - WebSocket upgrade (handshake) requests - the HTTP request that initiates the upgrade. - Complete client-to-upstream WebSocket text messages for implementations that advertise `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. -- Bounded request bodies: a `Content-Length` or bounded chunked body OpenShell can buffer within the applicable chain cap. +- Fixed-length and chunked request bodies normalized into bounded units, including bodies larger than one gRPC message. - Safe metadata output for later routing or audit. **Out of scope for v1:** - HTTP/2 and HTTP/3. The proxy's TLS termination pins ALPN to `http/1.1` today, so these are not introspected. -- Binary and control WebSocket messages, upstream-to-client WebSocket messages, and response-body scanning. +- Binary and control WebSocket messages and upstream-to-client WebSocket messages. - Opaque TCP streams and endpoints with `tls: skip`. -- Unbounded streaming uploads or full-duplex request processing. +- Unbounded or full-duplex request processing. Owned streams have a finite deferred-storage limit and complete before upstream contact. - Multipart or compressed body semantics, unless a selected service's manifest and policy explicitly support them within the size limits. -The request hook is synchronous and runs once for every selected stage. Timeout, failure behavior, and body buffering are therefore load-bearing parts of the design. The supervisor buffers up to the largest resolved stage limit, bounded by the 4 MiB platform maximum. A stage whose smaller limit is exceeded applies its own `on_error`, and later stages may still run when that result is `fail_open`. If an oversized `Content-Length` is known before body consumption, the supervisor may preserve streaming and fail open only when every affected stage permits it. If a chunked body crosses the cap after bytes have been consumed, the request is denied because the raw stream can no longer be resumed safely. The hook remains before any credential rewrite, which keeps OpenShell-managed credentials away from external middleware. Other operation phases such as pre-policy classification, a credential-visible `HttpRequest/post_credentials` hook for request signing (built-in-only, for example `openshell/sigv4`), response inspection, route selection for OpenShell-managed destinations, and streaming message hooks are possible future extensions and are out of scope for v1. +The request hook is synchronous and opens one bidirectional stream for every selected stage. Timeout, failure behavior, sequencing, backpressure, and ownership are therefore load-bearing parts of the design. A preflight selects headers-only, whole-body, lockstep stream, or owned-stream processing. Whole-body input and each stream unit use the binding limit; OpenShell further caps request units at 64 KiB. Whole-body mode receives its complete body in one final unit. Lockstep and owned streams receive nonempty data units followed by an empty final unit. Owned streams durably accept input without a platform replay copy, are available only to `fail_closed` stages, and cap deferred input and output at 1 GiB each. A pure lockstep chain may forward approved units upstream as they complete; any whole-body, owned, policy re-evaluation, body credential rewrite, or body-signing requirement holds the complete representation. OpenShell never replays a partially forwarded request. Request body receipt, middleware processing, and output delivery share a two-minute wall-clock deadline. The hook remains before credential rewrite, which keeps OpenShell-managed credentials away from external middleware. A restricted in-process `HTTP_REQUEST/POST_CREDENTIALS` phase hosts `openshell/sigv4`; external manifests cannot advertise that credential-visible phase. ### The middleware contract @@ -178,8 +181,9 @@ Configuration-time: Request-time: -- `EvaluateHttpRequest` carries the selected binding ID and typed operation phase (`PRE_CREDENTIALS`) plus the request context, middleware configuration from policy, HTTP request target, repeated safe headers in wire order, and bounded body. -- `HttpRequestResult` is a response OpenShell can apply directly: `allow` or `deny`, a reason, optional replacement content, ordered header mutations, findings, and namespaced metadata. +- `HttpRequestPreCredentials.Evaluate` is a bidirectional stream. Preflight carries the request context, policy config, admitted target, repeated safe headers, permitted modes, and effective limits. +- Body events carry contiguous bounded normalized units. Results pass, transform, block, skip remaining inspection, or take ownership of the corresponding unit. +- An owned stage acknowledges every input unit, then emits contiguous output units and final input/output accounting. Trailer and terminal events complete the lifecycle. A simplified sketch of the gRPC contract: @@ -188,9 +192,11 @@ service SupervisorMiddleware { // Configuration-time rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); rpc ValidateConfig(ValidateConfigRequest) returns (ValidateConfigResponse); +} - // operation=HTTP_REQUEST, phase=PRE_CREDENTIALS. - rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); +// operation=HTTP_REQUEST, phase=PRE_CREDENTIALS. +service HttpRequestPreCredentials { + rpc Evaluate(stream HttpRequestEvent) returns (stream HttpRequestEventResult); } message MiddlewareManifest { @@ -200,29 +206,47 @@ message MiddlewareManifest { } message MiddlewareBinding { - string id = 1; // service-owned stable ID - SupervisorMiddlewareOperation operation = 2; - SupervisorMiddlewarePhase phase = 3; - uint64 max_payload_bytes = 4; // one logical request or message payload - string timeout = 5; // optional binding-specific RPC timeout + SupervisorMiddlewareOperation operation = 1; + SupervisorMiddlewarePhase phase = 2; + uint64 max_payload_bytes = 3; // whole body, message, or stream unit + google.protobuf.Duration request_timeout = 104; } -message HttpRequestEvaluation { - string binding_id = 1; // selected manifest binding - SupervisorMiddlewarePhase phase = 2; +message HttpRequestEvent { + oneof event { + HttpRequestPreflight preflight = 1; + HttpRequestBodyUnit body = 2; + MiddlewareSessionEnd session_end = 3; + HttpRequestTrailers trailers = 4; + } +} - RequestContext context = 3; - google.protobuf.Struct config = 4; // service-specific, from policy +message HttpRequestPreflight { + RequestContext context = 1; + HttpRequestTarget target = 2; + repeated HttpHeader headers = 3; + string middleware_name = 4; + google.protobuf.Struct config = 5; + uint64 max_payload_bytes = 6; + repeated HttpRequestBodyMode permitted_body_modes = 7; + uint64 max_deferred_bytes = 8; + optional uint64 declared_body_length = 9; +} - HttpRequestTarget target = 5; - repeated HttpHeader headers = 6; // safe subset, duplicates and wire order preserved - bytes body = 7; // bounded +message HttpRequestBodyUnit { + uint64 sequence = 1; + oneof payload { + bytes data = 2; + } + bool end_of_stream = 3; } message RequestContext { string request_id = 1; string sandbox_id = 2; Process originating_process = 3; // optional, per-connection + string sandbox_name = 4; // display and logging only + string workspace = 5; // display and logging only } message HttpRequestTarget { @@ -271,32 +295,30 @@ message RemoveHeader { string name = 1; } -message HttpRequestResult { - Decision decision = 1; // ALLOW or DENY - string reason = 2; // normalized by OpenShell before security output - - bytes body = 3; // replacement content when transformed - bool has_body = 4; // distinguishes no replacement from an empty replacement - - repeated HeaderMutation header_mutations = 5; - repeated Finding findings = 6; - map metadata = 7; +message HttpRequestEventResult { + oneof result { + HttpRequestPreflightResult preflight_result = 1; + HttpRequestBodyResult body_result = 2; + HttpRequestTrailersResult trailers_result = 3; + HttpRequestBodyOutput body_output = 4; + HttpRequestBodyFinalize body_finalize = 5; + } } ``` -The evaluation and result are shaped so middleware composes cleanly in a chain. The allow/deny decision is a first-class result field rather than being mixed into content. If `has_body` is true, the transformed content a middleware returns (`HttpRequestResult.body`) becomes the request body the next middleware receives as `HttpRequestEvaluation.body`; if `has_body` is false, the supervisor keeps the previous body. The supervisor also feeds allowed header mutations into the next stage, so a chain is effectively a fold over a single request representation; a `deny` from any stage short-circuits the rest. See [Middleware ordering](#middleware-ordering) for how chains are assembled and ordered. +The event and result streams compose as a chain over one request representation. A stage's accepted body units and safe header or trailer mutations feed the next stage; an explicit block short-circuits the rest. Whole-body mode produces one data-bearing final unit. Lockstep and owned streams receive nonempty units without `end_of_stream`, followed by one empty terminal unit. Lockstep streaming cannot retain bytes across results. Owned streaming transfers replay responsibility to the stage, which must accept every unit before emitting a bounded replacement and final accounting. See [Middleware ordering](#middleware-ordering) for how chains are assembled and ordered. -Headers use a repeated representation so duplicate lines and wire order survive evaluation and chaining. Before an external call, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. A result may return ordered writes and removals. Writes support append, overwrite, and skip modes but may target only the `x-openshell-middleware-*` namespace. Removals may target other headers visible to middleware, except credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. Header values containing control characters are invalid. OpenShell validates and applies a stage's mutations atomically. If any mutation is invalid, none are applied and the stage follows its configured `on_error` behavior. +Headers use a repeated representation so duplicate lines and wire order survive evaluation and chaining. Before an external call, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. A result may return ordered writes and removals for middleware-visible end-to-end headers. Writes support append, overwrite, and skip modes. Credential-bearing, routing, framing, hop-by-hop, `Connection`-nominated, and OpenShell credential headers remain protected. Header values containing control characters or credential placeholders are invalid. OpenShell validates and applies a stage's mutations atomically. If any mutation is invalid, none are applied and the stage follows its configured `on_error` behavior. -> **Update in PR #2477 - WebSocket middleware:** The following contract text adds the bidirectional `EvaluateWebSocketSession` RPC, WebSocket preflight, message limits, and the WebSocket binding for the built-in regex middleware. The unary HTTP contract does not change. +> **Update in PR #2477 - WebSocket middleware:** The following contract text adds the bidirectional `EvaluateWebSocketSession` RPC, WebSocket preflight, message limits, and the WebSocket binding for the built-in regex middleware. -The interface is gRPC. The protobuf package `openshell.middleware.v1` is the protocol version boundary, so manifests and evaluation messages do not repeat an API-version string. HTTP evaluation remains unary: the supervisor buffers the bounded body, sends one `HttpRequestEvaluation`, and receives one `HttpRequestResult`. Complete client-to-upstream WebSocket text messages use the separate bidirectional-streaming `EvaluateWebSocketSession` RPC. The supervisor sends `WebSocketSessionEvent` values; the service returns `WebSocketSessionEventResult` values for preflight and message events, while session start and end are notifications without corresponding results. Streaming is not baked into `EvaluateHttpRequest`; future chunked HTTP transport should add another operation-specific method rather than changing the existing method's cardinality. Possible extensions are collected in the [protocol-extensions appendix](appendices/protocol-extensions.md). Built-in middleware uses the same logical contracts in-process; the `openshell/regex` built-in advertises both V1 operations. +The interface is gRPC. The protobuf package `openshell.middleware.v1` is the protocol version boundary, so manifests and evaluation messages do not repeat an API-version string. HTTP requests use `HttpRequestPreCredentials.Evaluate`; client-to-upstream WebSocket text messages use the separate `EvaluateWebSocketSession` RPC. Both are bidirectional streams with operation-specific events. Built-in middleware uses the same logical contracts in-process. `openshell/regex` advertises request and WebSocket bindings. Endpoint `credential_signing` fields synthesize the restricted `openshell/sigv4` stage at `HTTP_REQUEST/POST_CREDENTIALS`; it is not attachable through `network_middlewares`. -V1 applies explicit public envelope limits before invoking a service or accepting its result: 64 KiB for encoded config, 4 KiB for request context, 32 KiB for the target, 128 header lines and 64 KiB of encoded headers, 4 MiB for the logical payload, 4 KiB for a reason, 64 header mutations with at most 32 KiB of validated name/value data and 64 KiB encoded, 32 findings per stage with each finding at most 4 KiB encoded, and 64 metadata entries totaling at most 32 KiB. A chain has at most 10 stages and therefore at most 320 findings. Middleware gRPC servers configure request and response message limits to cover the 4 MiB payload plus at least 292 KiB for the remaining envelope. +V1 applies explicit public envelope limits before invoking a service or accepting its result: 64 KiB for encoded config, 4 KiB for request context, 32 KiB for the target, 128 header lines and 64 KiB of encoded headers, 4 MiB for a whole-body payload or advertised unit, 64 KiB for each request stream unit, 4 KiB for a reason, 64 header mutations with at most 32 KiB of validated name/value data and 64 KiB encoded, 32 findings per stage with each finding at most 4 KiB encoded, and 64 metadata entries totaling at most 32 KiB. Owned request streams separately cap deferred input and output at 1 GiB. A chain has at most 10 stages and therefore at most 320 findings. -For WebSocket traffic, a service advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` with `max_payload_bytes`, which limits one complete message or replacement rather than the whole session. HTTP bindings use the same field for one request body or replacement. An attached service without that exact binding does not join the chain, does not apply `on_error`, and produces internal `binding_not_selected` coverage. OpenShell opens one phase-specific `EvaluateWebSocketSession` stream per selected stage and upgrade attempt. Future upstream-to-client inspection uses the same RPC with `PRE_RETURN`; a service selected for both phases receives two independent streams for the WebSocket session. A bounded preflight exposes only the admitted destination through `HttpRequestTarget`, with its path separated from query data, requested subprotocols, sandbox context, the attached API middleware name, and validated implementation config. Policy-local config identity remains internal for audit and denial metadata. OpenShell evaluates selected preflights concurrently. Each stage returns `inspect`, voluntary `skip`, or authoritative `deny` before upstream contact, plus optional bounded reason, reason code, findings, and metadata. `deny` is a successful decision enforced independently of `on_error` and takes precedence over concurrent failures; failures alone follow each stage's `on_error`. OpenShell sends the terminal reason to every still-writable stream whose preflight opened successfully, at most once per stage. Inspecting stages that continue receive `session_start`, then complete logical text messages with monotonic sequence numbers. Stages run in global policy order and each sees the prior stage's accepted replacement. Binary logical messages pass through without middleware inspection under both error modes, consume a session-global sequence, and emit `unsupported_message_type` coverage for active stages; a later text RPC may therefore contain a valid sequence gap. `PRE_RETURN` and upstream-to-client inspection are reserved for a later implementation. +For WebSocket traffic, a service advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` with `max_payload_bytes`, which limits one complete message or replacement rather than the whole session. For HTTP request traffic, the field limits a whole-body representation or individual stream unit. An attached service without the exact operation binding does not join that chain and does not apply `on_error`. OpenShell opens one phase-specific stream per selected stage. Preflight exposes only the admitted destination, sandbox context, attached middleware name, validated config, and bounded safe headers. Each stage returns inspect, voluntary skip, or authoritative deny before body processing begins. Explicit denial is a successful decision enforced independently of `on_error`; failures follow the stage's failure policy. OpenShell sends a terminal reason to each still-writable opened stream at most once. -Inspectable WebSocket text input and replacements share the 4 MiB platform cap. The operator's `max_payload_bytes` is the shared HTTP-body and WebSocket-text ceiling, further constrained by each operation binding's capability. It does not bound binary pass-through, which retains a separate raw-frame safety limit. Logical messages use a protobuf `oneof` with `string text` and `bytes binary` variants; results use an optional matching replacement `oneof`, whose presence also represents an empty replacement without a separate boolean. Protobuf decoding enforces UTF-8 for text, and OpenShell rejects replacement variants that would change the message type. A complete text message holds one process-wide admission permit for its entire chain; preflight fan-out holds one permit until every stage resolves. Permit waiting is backpressure and does not consume the per-message deadline. Per-stage timeouts are also bounded by a 30-second total chain budget, which applies to HTTP chains too. This bound controls concurrency and peak buffered inspection memory; it is not rate limiting. +Inspectable WebSocket text input and replacements share the 4 MiB platform cap. The operator's `max_payload_bytes` is the shared HTTP-body and WebSocket-text ceiling, further constrained by each operation binding's capability. It does not bound binary pass-through, which retains a separate raw-frame safety limit. Logical messages use a protobuf `oneof` with `string text` and `bytes binary` variants; results use an optional matching replacement `oneof`, whose presence also represents an empty replacement without a separate boolean. Protobuf decoding enforces UTF-8 for text, and OpenShell rejects replacement variants that would change the message type. A complete text message holds one process-wide admission permit for its entire chain; preflight fan-out holds one permit until every stage resolves. Permit waiting is backpressure and does not consume the per-message deadline. Per-stage timeouts are also bounded by a 30-second chain budget for one WebSocket message, one HTTP body-unit pass, or one HTTP finalization pass. This is not an accepted-stream lifetime or rate limit. Request body receipt, middleware processing, and output delivery have a separate two-minute total deadline. The `originating_process` is the same identity OpenShell resolves on the egress path - the binary, pid, and ancestor chain it uses for binary-scoped network policy and OCSF audit. It is per-connection rather than strictly per-request and is optional. Middleware must treat missing process data as unavailable rather than as an authorization failure. The initial implementation leaves this field unset until reliable propagation is available. @@ -309,7 +331,7 @@ Shared mechanics: - **Endpoint exposure and auth.** Both extension systems use gRPC network endpoints. Their stable transport contract requires confidentiality and service authentication. During phase 1 only, supervisor middleware may explicitly opt into plaintext for trusted local or isolated research environments. Endpoint declaration, identity binding, credential material, and rotation should use shared mechanics where practical. - **Manifest description.** Both extension systems use `Describe` to return a manifest that declares a diagnostic service name, implementation version, and service-owned bindings for supported hook points. - **Operation phases.** Both systems hook into a named operation plus phase. The phase sets differ by system, but the concept is the same: `method=CreateSandbox, phase=pre_request` for a gateway interceptor, and `HTTP_REQUEST/PRE_CREDENTIALS` for v1 supervisor middleware. -- **Evaluation and result.** Both systems run an evaluate-style request and return a result. Middleware keeps operation-specific methods such as `EvaluateHttpRequest` because inputs and outputs differ by protocol or operation type; interceptor methods and messages are defined by RFC 0010. +- **Evaluation and result.** Both systems run evaluate-style exchanges. Middleware keeps operation-specific streaming services such as `HttpRequestPreCredentials` because inputs and outputs differ by protocol or operation type; interceptor methods and messages are defined by RFC 0010. - **Failure policy.** Both systems use `on_error: fail_closed|fail_open`, with fail-closed as the safe default for required enforcement. - **Observability.** Both systems emit OCSF events with the details relevant to the extension point, while preserving the same no-secrets logging rules. - **Ordering.** Both systems apply multiple configured extensions in deterministic order. @@ -317,18 +339,18 @@ Shared mechanics: Intentional differences: - **Selection model.** Supervisor middleware is selected per sandbox at runtime through policy and API state. Gateway interceptors are selected for the gateway at deploy time by operators in `gateway.toml`. -- **Method naming.** Gateway interceptors register against RPC method strings that are not themselves part of the interceptor API. Supervisor middleware exposes named operation-specific hook methods such as `EvaluateHttpRequest`; those names are part of the middleware API contract. +- **Method naming.** Gateway interceptors register against RPC method strings that are not themselves part of the interceptor API. Supervisor middleware exposes named operation-specific services such as `HttpRequestPreCredentials`; those names are part of the middleware API contract. - **Control responsibility.** Gateway interceptors may enforce that sandbox requests include approved middleware configuration, but they do not replace the per-sandbox middleware selection model. ### Contract versioning The middleware gRPC contract lives under a major-versioned protobuf package (`openshell.middleware.v1`), the same convention the compute-driver contract uses in [RFC 0001](../0001-core-architecture/README.md). Within a stable major version, changes stay additive and backward compatible - new fields, RPCs, operation phases, and manifest fields can be added - while breaking wire or semantic changes require a new major version. The research preview may still make intentional breaking changes before the contract is declared stable. -The protobuf package is the wire-version handshake. `Describe` reports a diagnostic service name, implementation version, and stable binding IDs for supported hook points; it does not carry a second API-version field. Manifest validation is mandatory: if OpenShell cannot fetch the manifest, bindings conflict, a service claims the reserved `openshell/` namespace, or policy asks for an unsupported binding or invalid config, the gateway rejects the relevant configuration before traffic can depend on it. Runtime invocation failures are handled through `on_error` and use `fail_closed` by default. +The protobuf package is the wire-version handshake. `Describe` reports a diagnostic service name, implementation version, and operation/phase bindings for supported hook points; it does not carry a second API-version field. Manifest validation is mandatory: if OpenShell cannot fetch the manifest, bindings conflict, an external service advertises restricted `POST_CREDENTIALS`, or policy asks for an unsupported implementation or invalid config, the gateway rejects the relevant configuration before traffic can depend on it. Runtime invocation failures are handled through `on_error` and use `fail_closed` by default. ### Registration and delivery -The operator registers available external middleware services in gateway configuration under `openshell.supervisor.middleware`. The namespace identifies the subsystem whose behavior is extended, not the process that reads the configuration. The gateway still loads, validates, and distributes these registrations to supervisors. Each entry has a diagnostic name, gRPC endpoint, maximum logical payload size, optional RPC timeout, and transport settings. The diagnostic name identifies the configured connection in logs but is not a policy key. Policy authors select stable binding IDs returned by `Describe`, so they cannot point traffic at an arbitrary endpoint and do not depend on an operator-local registration name. +The operator registers available external middleware services in gateway configuration under `openshell.supervisor.middleware`. The namespace identifies the subsystem whose behavior is extended, not the process that reads the configuration. The gateway loads, validates, and distributes these registrations to supervisors. Each entry has an operator-owned name, gRPC endpoint, maximum payload size, optional RPC timeout, and transport settings. Policy authors select that registration name; they cannot point traffic at an arbitrary endpoint. The service-reported manifest name remains diagnostic metadata. The v1 transport is gRPC over a network endpoint reachable from every supervisor across Docker, Podman, VM, and Kubernetes drivers. In local single-player deployments, a loopback endpoint such as `127.0.0.1:1234` may be translated to `host.openshell.internal` so a supervisor can reach a service running on the local host. That loopback shorthand is not an HA deployment model: Kubernetes and other shared deployments should register a routable service DNS name or address that every supervisor can reach directly. Other deployment shapes are deferred until OpenShell has a universal way to make those endpoints reachable from the relevant supervisor environments. @@ -338,7 +360,7 @@ name = "anonymizer" grpc_endpoint = "http://127.0.0.1:1234" max_payload_bytes = 4194304 timeout = "500ms" -allow_insecure = true +allow_insecure_transport = true [[openshell.supervisor.middleware]] name = "agent-traces-exporter" @@ -346,19 +368,19 @@ grpc_endpoint = "https://middleware.example.internal:443" max_payload_bytes = 1048576 ``` -The stable transport requirement is confidentiality plus authentication of the intended middleware service. Phase 1 may temporarily accept a plaintext `http://` endpoint only when the same entry explicitly sets `allow_insecure = true`. OpenShell rejects plaintext without that opt-in, warns prominently, and records the insecure registration as auditable configuration state. This escape hatch is limited to trusted local development and isolated research environments. Phase 2 removes plaintext support and the `allow_insecure` field, requiring authenticated encrypted transport. That removal is an intentional research-preview breaking change with no long-term compatibility obligation. The exact phase 2 mechanism, such as mTLS or TLS plus explicit caller authentication, is follow-up protocol work (see [appendices/protocol-extensions.md](appendices/protocol-extensions.md#middleware-authentication)). +The stable transport requirement is confidentiality plus authentication of the intended middleware service. Phase 1 may temporarily accept a plaintext `http://` endpoint only when the same entry explicitly sets `allow_insecure_transport = true`. OpenShell rejects plaintext without that opt-in, warns prominently, and records the insecure registration as auditable configuration state. This escape hatch is limited to trusted local development and isolated research environments. Phase 2 removes plaintext support and the opt-out field, requiring authenticated encrypted transport. That removal is an intentional research-preview breaking change with no long-term compatibility obligation. The exact phase 2 mechanism, such as mTLS or TLS plus caller authentication, is follow-up protocol work. -For each binding, the operator's `max_payload_bytes` must not exceed the binding capability returned by `Describe` or the 4 MiB platform maximum. The gateway rejects an invalid registration rather than silently clamping it. The resulting operator limit applies to every binding exposed by that registration. +For each binding, the operator's `max_payload_bytes` must not exceed the binding capability returned by `Describe` or the 4 MiB platform maximum. The gateway rejects an invalid registration rather than silently clamping it. Whole-body modes use the effective limit for the complete representation; stream modes use it per unit, with request units further capped at 64 KiB. Owned request streams separately advertise the fail-closed deferred-storage limit. -RPC timeouts use an integer with an `ms` or `s` suffix, range from 10 ms through 30 s, and default to 500 ms. A binding may advertise its own timeout through `Describe`; that value overrides the service registration timeout. The service timeout applies to `Describe`, while the effective binding timeout applies to `ValidateConfig` and `EvaluateHttpRequest`. +RPC timeouts use an integer with an `ms` or `s` suffix, range from 10 ms through 30 s, and default to 500 ms. A binding may advertise its own timeout through `Describe`; that value overrides the service registration timeout. The service timeout applies to `Describe`, while the effective binding timeout applies to `ValidateConfig`, stream open, and individual request exchanges. The external-service endpoint is trusted operator infrastructure in v1. The auth design must make both directions explicit: the supervisor proves to the middleware that the call is authorized for the specific middleware identity, and the supervisor verifies it is calling the intended middleware service. -Binding IDs may be bare (`anonymizer`) or namespaced with `/` (`nvidia/anonymizer`, `acme/security/pii-redactor`). Empty path segments are invalid, so `/foo`, `foo/`, and `foo//bar` are rejected. The `openshell/` namespace is reserved for built-in OpenShell middleware, such as `openshell/regex` or `openshell/sigv4`. Policy config map keys remain stable local identities for metadata namespacing and diagnostics; the `middleware` field selects the binding. +Middleware names may be bare (`anonymizer`) or namespaced with `/` (`nvidia/anonymizer`, `acme/security/pii-redactor`). Empty path segments are invalid, so `/foo`, `foo/`, and `foo//bar` are rejected. The `openshell/` namespace is reserved for built-in OpenShell middleware, such as `openshell/regex` or `openshell/sigv4`. Policy config map keys remain stable local identities for metadata namespacing and diagnostics; the `middleware` field selects the built-in or operator registration. Built-in middleware ships in the supervisor binary and needs no external registration. Supervisors install built-in bindings before attempting external connections. -At gateway startup, OpenShell connects to every registered service and calls `Describe`. Startup rejects unavailable or invalid services, duplicate binding IDs across services, and external claims in the reserved `openshell/` namespace. Sandbox policy creation and update call the owning service's `ValidateConfig` before persistence. +At gateway startup, OpenShell connects to every registered service and calls `Describe`. Startup rejects unavailable or invalid services, duplicate registration names, conflicting operation/phase bindings, restricted external phases, and external claims in the reserved `openshell/` namespace. Sandbox policy creation and update call the owning service's `ValidateConfig` before persistence. Supervisors receive policy plus the external service registrations required by the effective policy through the existing `GetSandboxConfig` response. Built-in registrations are not delivered because they are already installed in-process. The gateway stays off the request hot path; supervisors connect to the required services and invoke them directly. @@ -376,7 +398,7 @@ Multitenancy is handled by OpenShell policy selection, not by giving middleware Policy decides which middleware runs for which traffic, how it is configured, and what happens on failure. Middleware configs live once in the top-level `network_middlewares` map, represented as `map` in `SandboxPolicy`. Each map key is the stable policy-local identity. Each config selects destination hosts directly through `endpoints.include` and `endpoints.exclude`; network policies and endpoints do not carry middleware attachment lists. -A middleware config may include an optional human-readable `name`, which defaults to the map key and does not replace that key as the config identity. `middleware` is the stable binding ID exposed by a built-in or by an external service's `Describe` response. Different map keys may reference the same binding and run as separate stages with different selectors or configuration. +A middleware config may include an optional human-readable `name`, which defaults to the map key and does not replace that key as the config identity. `middleware` is a stable built-in or operator-owned registration name. Different map keys may reference the same implementation and run as separate stages with different selectors or configuration. Each entry supplies implementation-owned configuration, `on_error` behavior, numeric `order`, and endpoint selectors. `fail_closed` is the default. `order` defaults to `0` and must be unique across the complete policy, even when selectors do not overlap, so policies with multiple configs normally set it explicitly. OpenShell validates the structure and asks the owning implementation to `ValidateConfig` before the gateway persists a policy. @@ -384,7 +406,7 @@ Selection occurs after network and L7 admission and depends only on the admitted Every config requires a non-empty `include` list. `exclude` is optional and takes precedence over `include`. Matching is case-insensitive and uses the same host-pattern implementation as network endpoints: `*` matches exactly one DNS label, `**` matches one or more DNS labels, and intra-label wildcards such as `*-api.example.com` are supported. Brace alternates are rejected; authors list each alternative explicitly. A config accepts at most 32 combined include and exclude patterns. A policy accepts at most 10 middleware configs, and runtime selection defensively rejects a chain longer than 10 stages. -The hook is a supervisor-side Rust enforcement stage selected by policy data, not a Rego rule. L4 policy admits the connection and, where the endpoint declares a `protocol`, L7 policy admits the parsed request. The supervisor then selects the chain, buffers the bounded body, invokes stages, applies valid results, and re-evaluates body-aware protocol policy after each body replacement. Request bodies do not otherwise become a new general Rego input surface. +The hook is a supervisor-side Rust enforcement stage selected by policy data, not a Rego rule. L4 policy admits the connection and, where the endpoint declares a `protocol`, L7 policy admits the parsed request. The supervisor then selects the chain, opens event streams, applies valid results, and re-evaluates body-aware protocol policy after each body replacement. Body-aware protocols retain a bounded hold barrier for this re-evaluation. Other HTTP/1 paths either forward pure lockstep output incrementally or spool when a stage takes ownership or needs the complete body. Request bodies do not otherwise become a new general Rego input surface. ```yaml network_middlewares: @@ -436,7 +458,7 @@ Beyond allow/deny and transformation, middleware emits string metadata (for exam Because `HTTP_REQUEST/PRE_CREDENTIALS` runs before route selection and credential injection, v1 does not guarantee that metadata visible at this hook includes the final routed model or upstream route. Budget-style middleware that needs post-call status, final route/model, content length, or token usage needs a later metadata-only notification hook such as `HttpResponse/completed`; that hook is listed as a future extension in the [protocol-extensions appendix](appendices/protocol-extensions.md#additional-operation-phases), not part of the v1 request hook. -The namespace is the policy-local middleware config map key, not the optional human-readable `name` or the binding ID. This means two configs that use the same binding still produce separate metadata buckets, and changing a display label or the registered service behind a binding does not rename downstream annotations. +The namespace is the policy-local middleware config map key, not the optional human-readable `name` or registered implementation name. This means two configs that use the same implementation still produce separate metadata buckets, and changing a display label or the registered service behind a config does not rename downstream annotations. ### Audit and logging @@ -444,7 +466,7 @@ A middleware decision is observable sandbox behavior, so it is recorded as an OC > **Update in PR #2477 - WebSocket middleware:** The coverage-boundary event below is new. It distinguishes an unsupported operation or message type from a middleware invocation or failure. -- **Per-invocation decisions** are `HttpActivity` events, since middleware is an L7 enforcement point. Each stage records the policy-local config key, validated binding ID, decision, transformation state, latency, and policy and endpoint context. Allowed requests are `Informational`; denials are `Medium`. +- **Per-invocation decisions** are `HttpActivity` events, since middleware is an L7 enforcement point. Each stage records the policy-local config key, registered implementation name, decision, transformation state, latency, and policy and endpoint context. Allowed requests are `Informational`; denials are `Medium`. - **Enforcement failures and bypasses** also emit `DetectionFinding` events. Required-stage failures, invalid responses, uninspectable traffic with a required stage, and body-aware policy evaluation failures are `High`. A `fail_open` bypass and uninspectable traffic allowed because every matching stage is `fail_open` are still findings so operators can alert on reduced enforcement. - **Coverage boundaries** emit informational `NetworkActivity` events separately from invocations and failures. `binding_not_selected` records an attached config whose manifest lacks the WebSocket binding. `unsupported_message_type` records binary pass-through for an active stage with its internal config identity, logical sequence, message class, and size. - **Configuration events** are `ConfigStateChange` events: middleware registration validation, registry reload success or failure, and policy validation outcome. @@ -453,7 +475,7 @@ These events must never leak the content they describe. The OCSF JSONL may be sh - Raw request content, matched values, redacted spans, and service-config secrets are never logged. - Built-ins may preserve contract-defined audit-safe reasons and finding fields. Operator-run reason text, finding text, mutation errors, and diagnostic metadata are untrusted input. OpenShell replaces or omits them in denied responses and security logs, using stable platform-owned messages derived from the validated binding and failure category. -- Events carry only safe summaries: policy-local config keys, validated binding IDs, decisions, latency, platform-owned failure categories, and aggregate counts. +- Events carry only safe summaries: policy-local config keys, validated implementation names, decisions, latency, platform-owned failure categories, and aggregate counts. This mirrors the middleware response contract, which already forbids the service from returning raw matched values. @@ -463,13 +485,13 @@ Supervisor egress middleware stays opt-in throughout: until a policy declares a > **Update in PR #2477 - WebSocket middleware:** Phase 1 now also includes the forward-text WebSocket operation, bounded WebSocket messages, and the WebSocket binding for the built-in regex middleware. -**Phase 1 - research-preview contract and execution.** Define `openshell.middleware.v1` with `Describe`, `ValidateConfig`, unary `EvaluateHttpRequest`, and forward-text `EvaluateWebSocketSession`; ship the example `openshell/regex` built-in; and support statically registered operator-run services. Policy uses a top-level selector-based `network_middlewares` map with stable config keys, unique numeric `order`, per-stage `on_error`, bounded bodies and messages, bounded RPC timeouts, atomic header mutations, post-transformation policy re-evaluation, and OCSF observability. Gateway startup validates external manifests, policy writes validate implementation-owned config, effective sandbox config carries only required external registrations, and supervisors install policy plus registry as one last-known-good runtime generation. Phase 1 requires encrypted authenticated transport for normal use but temporarily permits plaintext `http://` only with explicit `allow_insecure = true` for trusted local development or isolated research. OpenShell warns and emits auditable configuration state whenever that exception is used. +**Phase 1 - research-preview contract and execution.** Define `openshell.middleware.v1` with `Describe`, `ValidateConfig`, bidirectional `HttpRequestPreCredentials.Evaluate`, HTTP response streams, and forward-text `EvaluateWebSocketSession`; ship `openshell/regex` and restricted `openshell/sigv4` built-ins; and support statically registered operator-run services. Policy uses a top-level selector-based `network_middlewares` map with stable config keys, unique numeric `order`, per-stage `on_error`, bounded units and bodies, bounded RPC timeouts, atomic header mutations, post-transformation policy re-evaluation, and OCSF observability. Gateway startup validates external manifests, policy writes validate implementation-owned config, effective sandbox config carries only required external registrations, and supervisors install policy plus registry as one last-known-good runtime generation. Phase 1 requires encrypted authenticated transport for normal use but temporarily permits plaintext `http://` only with explicit `allow_insecure_transport = true` for trusted local development or isolated research. OpenShell warns and emits auditable configuration state whenever that exception is used. -**Phase 2 - mandatory authenticated encryption.** Remove plaintext middleware transport and remove `allow_insecure`. Every external connection must provide transport confidentiality and authenticate the intended service, with the final mechanism and credential delivery model defined by follow-up protocol work. Because phase 1 is explicitly a research preview, removing its insecure escape hatch is an intentional breaking change and does not create a long-term compatibility obligation. Operator-run service deployment otherwise keeps the same binding, policy, validation, delivery, reload, and invocation model. +**Phase 2 - mandatory authenticated encryption.** Remove plaintext middleware transport and remove `allow_insecure_transport`. Every external connection must provide transport confidentiality and authenticate the intended service, with the final mechanism and credential delivery model defined by follow-up protocol work. Because phase 1 is explicitly a research preview, removing its insecure escape hatch is an intentional breaking change and does not create a long-term compatibility obligation. Operator-run service deployment otherwise keeps the same binding, policy, validation, delivery, reload, and invocation model. ### Backwards compatibility and migration -Existing sandbox policies and gateway configs that declare no middleware remain valid and pay no per-request cost. Middleware configs that opt into phase 1 plaintext are intentionally temporary: they must migrate to authenticated encrypted endpoints before phase 2 because `allow_insecure` and plaintext support will be removed. The research-preview contract may make other breaking changes before stability. +Existing sandbox policies and gateway configs that declare no middleware remain valid and pay no per-request cost. The request API change is intentionally breaking within the research preview: services must replace the removed unary request method and messages with `HttpRequestPreCredentials.Evaluate`, implement preflight/body/trailers/session-end sequencing, and register that gRPC service beside `SupervisorMiddleware`. There is no unary fallback. Middleware configs that opt into phase 1 plaintext are intentionally temporary and must migrate to authenticated encrypted endpoints before phase 2. The research-preview contract may make other breaking changes before stability. ### Research preview @@ -483,11 +505,11 @@ Adding a synchronous, content-aware hook to the egress path has real costs. The - **Hot-path latency and a new per-request dependency.** Each selected external stage makes a synchronous call and blocks on its reply, so middleware latency becomes request latency and the service becomes a new failure surface on the data plane. This is bounded by opt-in host selectors, per-middleware timeouts, and built-ins running in-process with no network hop, but for matching traffic the tax is unavoidable. - **Fail-closed breaks workloads.** Denying traffic when a required middleware is unavailable, times out, or returns a malformed response is the safe default, but it converts a middleware outage into a sandbox outage. The opposite default leaks the very content the middleware exists to control. There is no choice that is both safe and always available; `on_error` makes the tradeoff explicit per middleware, but operators can still pick a default that surprises them. -- **Body buffering and size limits.** Inspecting content means buffering a bounded request body instead of streaming it, which adds memory cost and interacts badly with growing payloads (for example inference requests whose context expands each turn until it exceeds the cap). An over-cap request is treated as an `on_error` event for the middleware that needs the body, so it follows the same `fail_closed` default: it is denied unless the operator has explicitly set `on_error: fail_open` for that middleware. Passing an over-cap request through unprocessed is therefore never the default - it is an opt-in choice made per middleware, and one a security-critical middleware would deliberately leave off so that oversized content is denied rather than silently egressed. +- **Storage and size limits.** Whole-body inspection still buffers a bounded body. Streaming reduces protobuf message and relay-memory pressure but does not remove finite limits. Owned transformations spool input and output and can consume substantial disk; they therefore have a 1 GiB bound and require `fail_closed` because the original cannot be replayed after ownership transfer. Operators must size storage and middleware capacity for selected traffic. - **No OpenShell-side rate limiting.** OpenShell bounds concurrent middleware work and buffered memory, but does not throttle fast calls. A middleware that is slow, overloaded, or unavailable is handled by admission backpressure, its timeout, and `on_error`, so operators must still size, scale, and protect the service. - **Trusting an unsandboxed service with raw content.** Middleware receives raw request payloads, and OpenShell does not sandbox it, verify its behavior, or prevent it from mishandling or exfiltrating what it inspects. A buggy or malicious middleware is a direct data-exposure path. Trust in the middleware is the operator's responsibility, the same as trust in a sandbox image, but the blast radius here is in-flight request content. - **A false sense of coverage.** The hook runs only on traffic OpenShell terminates and parses. Opaque TCP or TLS passthrough, encrypted or otherwise opaque bodies, endpoints outside every selector, and content the middleware fails to detect can still leave without effective inspection. Policy validation rejects selector overlap with `tls: skip`, and runtime uninspectability follows the matching chain's failure policy, but detection correctness and traffic outside the selected host set remain inherent limitations. -- **Phase 1 plaintext is risky.** The research-preview exception permits plaintext gRPC only with explicit `allow_insecure = true`. Because middleware can allow, deny, or transform egress, an impersonated or eavesdropped service is a policy-enforcement bypass, not just an observability gap. The exception is unsuitable for shared or untrusted networks, produces an explicit warning and audit event, and is removed in phase 2. See [appendices/protocol-extensions.md](appendices/protocol-extensions.md#middleware-authentication). +- **Phase 1 plaintext is risky.** The research-preview exception permits plaintext gRPC only with explicit `allow_insecure_transport = true`. Because middleware can allow, deny, or transform egress, an impersonated or eavesdropped service is a policy-enforcement bypass, not just an observability gap. The exception is unsuitable for shared or untrusted networks, produces an explicit warning and audit event, and is removed in phase 2. - **Added surface to build, version, and maintain.** A new gRPC contract, policy schema, gateway configuration table, and manifest handshake are all long-lived surfaces with compatibility obligations, and middleware chains add ordering semantics operators must reason about. The research-preview framing keeps the contract provisional for now, but the long-term maintenance cost is real and is the main argument for keeping v1 deliberately small. The cost of *not* doing this is leaving content-level egress control entirely outside OpenShell: operators who need to redact, block, or annotate outbound content based on what it contains would have to build bespoke proxies around the sandbox, losing the policy integration, audit, and trust boundary the supervisor already provides. @@ -495,7 +517,7 @@ The cost of *not* doing this is leaving content-level egress control entirely ou ## Alternatives - **Build content checks into OpenShell directly.** A fixed, built-in set of DLP/redaction rules avoids a contract and an external service. Rejected as the primary model: OpenShell cannot embed every useful detection and transformation approach, and a stable contract lets dedicated tools and research scanners iterate without changing OpenShell. First-party built-in middleware still ships for narrow cases, over the same contract. -- **REST instead of gRPC.** A REST/JSON hook is simpler to call, and with OpenAPI it could still offer a manifest handshake and a typed contract. Rejected because gRPC's typing is stronger, OpenShell already uses gRPC across its service contracts, and gRPC leaves room for future streaming operations if large or incremental payload processing becomes necessary. Staying on a single toolchain avoids a second RPC stack to build, secure, and maintain. +- **REST instead of gRPC.** A REST/JSON hook is simpler to call, and with OpenAPI it could still offer a manifest handshake and a typed contract. Rejected because gRPC's typing and bidirectional streaming support match the lifecycle, and OpenShell already uses gRPC across its service contracts. Staying on a single toolchain avoids a second RPC stack to build, secure, and maintain. - **Other deployment modes (WASM, sidecar, in-sandbox).** In-process WASM filters or sidecars avoid a network hop and can tighten the trust boundary. Deferred rather than rejected: v1 supports native built-ins and statically registered external services, while other shapes remain open. See [appendices/deployment-options.md](appendices/deployment-options.md). - **Doing nothing.** The cost of declining is covered at the end of Risks: content-level egress control stays outside OpenShell, and operators must build bespoke proxies that lose the policy integration, audit, and trust boundary the supervisor already provides. @@ -503,9 +525,9 @@ The cost of *not* doing this is leaving content-level egress control entirely ou Calling an external service from a proxy to inspect, transform, or block in-flight traffic is well-established. The closest analogs: -- **Envoy `ext_proc` (External Processing).** The primary model for this RFC. Envoy streams request headers and body to an external gRPC service that can mutate the body (for example redaction), allow, or deny, and the proxy and the processing service scale independently. Our `HttpRequest/pre_credentials` hook is a buffered, single-hook v1 of the same boundary; if OpenShell later needs `ext_proc`-style streaming, it should add a separate streaming operation. +- **Envoy `ext_proc` (External Processing).** The primary model for this RFC. Envoy streams request headers and body to an external gRPC service that can mutate the body (for example redaction), allow, or deny, and the proxy and the processing service scale independently. `HTTP_REQUEST/PRE_CREDENTIALS` follows the same event-oriented boundary while adding OpenShell-specific modes for whole bodies, lockstep units, and ownership transfer. - **Envoy `ext_authz` (External Authorization).** A narrower sibling: an external service returns an allow/deny decision per request. It validates the "delegate the per-request decision to an external service in the hot path" pattern, without the content-transformation half that this RFC needs. -- **ICAP (RFC 3507).** HTTP proxies offload content adaptation, virus scanning, DLP, and content filtering to external ICAP servers that can modify or block request/response content. It is the closest *functional* precedent for content-aware egress control. Two details map directly onto our design: ICAP supports **pipelining** multiple servers (our middleware chain) and a **content preview** of the first bytes before full processing (our bounded-body buffering). What we avoid is its dated, text-based wire protocol; gRPC gives us typed contracts and room for future operation-specific streaming if we need it. +- **ICAP (RFC 3507).** HTTP proxies offload content adaptation, virus scanning, DLP, and content filtering to external ICAP servers that can modify or block request/response content. It is the closest *functional* precedent for content-aware egress control. ICAP's pipelining and preview concepts map to our ordered chain and preflight. We avoid its dated text protocol; gRPC provides typed event streams and explicit ownership accounting. - **HashiCorp `go-plugin` (Terraform, Vault).** Third-party plugins run as separate processes and communicate with the core exclusively over gRPC. It shows a strictly typed gRPC contract is a robust way to manage cross-language third-party extensions, which informs our registration plus manifest handshake (`Describe`, `ValidateConfig`). - **Kubernetes CSI / KMS.** Vendor-specific integrations are offloaded to external gRPC services rather than compiled into the core. Same "core defines the contract; integrators implement it out-of-process" split we use for middleware. - **Proxy-Wasm (Envoy/Istio Wasm filters).** In-process WebAssembly extensions with strong default-deny sandboxing and no IPC latency. Relevant to the future WASM deployment mode (see the deployment-options appendix); set aside for v1 because it is currently weak for GPU-backed or memory-heavy semantic guards. @@ -519,23 +541,23 @@ This section closes the current review themes. > **Update in PR #2477 - WebSocket middleware:** The operation-scope and failure-behavior decisions below now include WebSocket bindings, client text messages, binary pass-through, and capability coverage. - **Middleware naming.** Use the feature name "supervisor middleware." The first operation family is egress middleware, but the higher-level feature name stays extensible for future supervisor hooks. The service can inspect, transform, deny, and annotate, so narrower names such as "transformer" or "request processor" describe only part of the contract. -- **Middleware binding IDs.** Services own stable binding IDs and policy selects them through the `middleware` field. Gateway registration names are diagnostic only. Binding IDs use `/` for namespaces, `openshell/` is reserved for built-ins, and empty path segments are invalid. +- **Middleware names.** Policy selects built-ins or operator-owned external registrations through the `middleware` field. The service manifest name is diagnostic. Names use `/` for namespaces, `openshell/` is reserved for built-ins, and empty path segments are invalid. - **Operation naming.** Use typed operation and phase enums such as `HTTP_REQUEST/PRE_CREDENTIALS`. The operation describes the middleware API payload, and the phase describes the proxy position. Later protocols can add typed operations such as WebSocket message or TCP connect without renaming the v1 hook. - **Operation scope of v1.** `HTTP_REQUEST/PRE_CREDENTIALS` applies to every HTTP/1.x request that OpenShell terminates and parses, whether or not the endpoint declares a `protocol`; WebSocket upgrade requests are included. `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` applies only to complete client-to-upstream text messages for attachments whose manifest advertises it. Binary and return-path messages, HTTP/2, HTTP/3, opaque TCP, and `tls: skip` traffic are excluded from those operation bindings. - **Route selection and forwarding.** V1 has no `forward_to` decision. Middleware never makes the upstream call. Future route-selection hooks may choose among OpenShell-managed destinations, such as model routes, but must not become arbitrary external endpoint rewrites. -- **SigV4/request signing.** AWS SigV4 belongs to a restricted built-in `HttpRequest/post_credentials` hook, not external `HttpRequest/pre_credentials` middleware. The middleware can be configured by policy, but it must run in-process with supervisor host capabilities so it can strip placeholder signatures and sign with real supervisor-resolved credentials without exposing those credentials over the external middleware contract. +- **SigV4/request signing.** AWS SigV4 belongs to a restricted built-in `HTTP_REQUEST/POST_CREDENTIALS` hook, not external `HTTP_REQUEST/PRE_CREDENTIALS` middleware. Endpoint policy synthesizes the stage, but it must run in-process with supervisor host capabilities so it can strip placeholder signatures and sign with real supervisor-resolved credentials without exposing those credentials over the external middleware contract. - **Composability and ordering.** Middleware is chainable and ordered by ascending numeric `order`. Order values must be unique across the policy. A stage receives the previous stage's transformed body and header mutations; `deny` short-circuits the chain; and different config map keys may invoke the same binding as separate stages. -- **Header mutation.** Headers preserve duplicates and wire order. External writes are limited to `x-openshell-middleware-*` and support append, overwrite, or skip. Removes may target other visible headers. Credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers remain protected. Each stage's mutations are atomic. -- **Finding shape.** Findings never include matched values or raw content. Built-ins may provide contract-defined audit-safe labels. Operator-run text and metadata are untrusted and are replaced or omitted in security outputs in favor of validated binding IDs, platform labels, and aggregate counts. +- **Header mutation.** Headers preserve duplicates and wire order. External writes and removes may target middleware-visible end-to-end headers and writes support append, overwrite, or skip. Credential-bearing, routing, framing, hop-by-hop, `Connection`-nominated, and OpenShell credential headers remain protected. Each stage's mutations are atomic. +- **Finding shape.** Findings never include matched values or raw content. Built-ins may provide contract-defined audit-safe labels. Operator-run text and metadata are untrusted and are replaced or omitted in security outputs in favor of validated implementation names, platform labels, and aggregate counts. - **Actor data.** Actor process data is optional and per-connection. Middleware must treat it as context, not a reliable per-request identity or authorization input. - **Metadata namespacing.** Metadata is stored under the policy-local middleware config map key rather than the optional human-readable name. This prevents collisions without a central key registry and lets two configs using the same implementation emit independent metadata. - **Selector-only placement.** V1 uses only config-level `endpoints.include` and `endpoints.exclude` selectors. Policy-level and endpoint-level attachment lists are not part of the schema. Selection is independent of the network rule that admitted the request and therefore remains stable after effective-policy composition. - **Failure behavior.** Middleware errors, timeouts, malformed responses, and over-cap inspectable payloads use `on_error` after an operation binding is selected; `fail_closed` is the default. An absent operation binding and binary WebSocket messages are capability coverage states, not failures, and pass with informational telemetry under both error modes. -- **Limits.** V1 caps policies at 10 middleware configs, selectors at 32 combined patterns per config, bodies at 4 MiB, findings at 32 per stage, and all non-body request and result fields at the public envelope limits in the contract section. +- **Limits.** V1 caps policies at 10 middleware configs, selectors at 32 combined patterns per config, complete buffered bodies and advertised units at 4 MiB, request stream units at 64 KiB, owned deferred input and output at 1 GiB each, findings at 32 per stage, and all non-body fields at the public envelope limits in the contract section. - **Delivery and reload.** `GetSandboxConfig` delivers only external registrations required by the effective policy. Built-ins are installed locally. Supervisors prepare candidate policy and registry state off-path, swap them as one generation, reuse connections for policy-only changes, and preserve the complete last-known-good runtime on failure. -- **Chunked and compressed bodies.** V1 operates on bounded bytes OpenShell can buffer safely. Known over-cap content length may fail open before consumption when every affected stage permits it. Chunked overflow after consumption is denied because the raw stream cannot be resumed. Compressed bodies remain opaque unless a binding explicitly supports them. +- **Chunked and compressed bodies.** V1 normalizes fixed and chunked HTTP/1 request bodies into bounded units and preserves validated trailers. Whole-body mode remains bounded by the stage limit. Owned mode permits larger finite transformations only under fail-closed ownership and deferred-storage limits. Compressed bodies remain opaque unless a binding explicitly supports them. - **Post-transformation enforcement.** Every body replacement is re-evaluated by body-aware GraphQL, JSON-RPC, or MCP policy before the next stage or upstream. An enforced denial blocks. In audit mode, a denial is logged, the remaining chain stops, and the transformed request is forwarded. Evaluation failure or an unparseable replacement is a hard denial. Middleware deny and `fail_closed` remain blocking regardless of endpoint audit mode. -- **Trust boundary and phases.** Stable external middleware transport requires confidentiality and service authentication. Phase 1 may temporarily allow plaintext only with explicit `allow_insecure = true`, a warning, and an audit event in trusted local or isolated research environments. Phase 2 removes plaintext and `allow_insecure` as an intentional research-preview breaking change. +- **Trust boundary and phases.** Stable external middleware transport requires confidentiality and service authentication. Phase 1 may temporarily allow plaintext only with explicit `allow_insecure_transport = true`, a warning, and an audit event in trusted local or isolated research environments. Phase 2 removes plaintext and `allow_insecure_transport` as an intentional research-preview breaking change. - **Multitenancy.** OpenShell controls middleware application through policy selection. A middleware may receive sandbox and policy context for audit, but OpenShell does not define a middleware-owned tenant grouping model in v1. - **API maturity qualifier.** Use `openshell.middleware.v1`, not `v1alpha1`. The project is already alpha-stage; the RFC labels this contract as a research preview, so an additional per-contract alpha package adds little. diff --git a/rfc/0009-supervisor-middleware/appendices/extension-authentication.md b/rfc/0009-supervisor-middleware/appendices/extension-authentication.md index d73a7aa9bf..bb03a748bd 100644 --- a/rfc/0009-supervisor-middleware/appendices/extension-authentication.md +++ b/rfc/0009-supervisor-middleware/appendices/extension-authentication.md @@ -10,7 +10,7 @@ Related: [protocol-extensions.md](protocol-extensions.md#middleware-authenticati Transport is HTTPS with either platform trust roots or an operator-provided CA bundle, with normal certificate and endpoint-hostname verification. A middleware endpoint must be reachable from every sandbox supervisor as well as the gateway, so a gateway-local Unix socket is not an option for this mechanism. -Caller identity is a short-lived Ed25519 JWT minted by the gateway's existing sandbox signing authority. The gateway attaches one to its own `Describe` and `ValidateConfig` calls; sandbox supervisors attach one to `Describe` and `EvaluateHttpRequest`. Both directions of the RFC's stated requirement are covered: TLS and the configured trust roots authenticate the middleware service to OpenShell, and the exact-audience JWT proves to the middleware that a gateway or a policy-authorized sandbox supervisor made the call. +Caller identity is a short-lived Ed25519 JWT minted by the gateway's existing sandbox signing authority. The gateway attaches one to its own `Describe` and `ValidateConfig` calls; sandbox supervisors attach one to `Describe` and operation-specific stream RPCs such as `HttpRequestPreCredentials.Evaluate`. Both directions of the RFC's stated requirement are covered: TLS and the configured trust roots authenticate the middleware service to OpenShell, and the exact-audience JWT proves to the middleware that a gateway or a policy-authorized sandbox supervisor made the call. ## Claim contract diff --git a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md index 63115d9e52..8ca74362ca 100644 --- a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md +++ b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md @@ -2,65 +2,55 @@ > This is an appendix to the [RFC](../README.md). Please familiarize yourself with the RFC before reading this. -**Update in PR #2477 - WebSocket middleware:** V1 now includes a forward-text WebSocket operation. The updated text below separates this implemented operation from future HTTP streaming and WebSocket return-path operations. +V1 includes event-oriented HTTP request and response streams plus a forward-text WebSocket operation. This appendix records remaining extensions the protocol should not preclude. -The v1 contract is intentionally minimal: one buffered unary HTTP request hook and one forward-text WebSocket message hook, each with an `allow`/`deny` decision plus optional transformed content, findings, and metadata. This appendix records extensions the proto should not preclude, so v1 stays small without painting future work into a corner. None of these are committed; they exist to validate that the v1 shape is forward-compatible. +## Request streaming -## Streaming - -The v1 `EvaluateHttpRequest` RPC is unary. The supervisor buffers the bounded request body, sends one `HttpRequestEvaluation`, and receives one `HttpRequestResult`. Streaming is deliberately left out of that method: if OpenShell later needs chunked payload transport or incremental processing, it should add a separate operation-specific method rather than changing `EvaluateHttpRequest` cardinality. - -This section records what such a future streaming operation would need to consider, and importantly what streaming does and does not buy, since the distinction is easy to get wrong. +`HttpRequestPreCredentials.Evaluate` is bidirectional streaming. Preflight selects header-only, whole-body, lockstep stream, or owned-stream processing. OpenShell normalizes HTTP/1 fixed and chunked bodies into bounded units, carries validated trailers as a separate event, and rebuilds transport framing after evaluation. ### Transport streaming vs processing streaming These are different concepts and are easy to conflate: -- **Transport streaming** - a separate gRPC operation carries multiple messages (chunks). This is what a service would advertise in its manifest and what the supervisor would negotiate. +- **Transport streaming** - the gRPC operation carries multiple bounded messages. Every body-aware stage uses this transport. - **Processing streaming** - the middleware can act on partial content before it has the whole body. -The manifest field would govern only the transport. It would not promise the middleware can process incrementally. +Selecting a stream mode governs processing semantics; using the streaming RPC alone does not promise incremental processing. ### Full-body guards still buffer -Many guards need the entire body to do anything: a JSON-aware redactor must parse the whole document, and a PII scan must see all of it. Such a guard, even over a streaming transport, accumulates every chunk internally, then parses, then emits a single response at end-of-stream - the decision still arrives after the last byte. Incremental processing only helps narrower cases such as byte-level regex redaction or secret scanning over a text stream. - -### Why add a streaming operation later - -Even when the middleware must buffer the full body, a separate chunked transport operation would buy two things: - -- It moves the large buffer off the supervisor. The supervisor does not hold a multi-MB body to put in a single message; the middleware, which needs it anyway and can be resourced for it, accumulates it. -- It avoids gRPC's per-message size limit (4 MB by default). A 20 MB inference request cannot fit in one message without raising limits, but it can be chunked. +Many guards need the entire body to do anything: a JSON-aware redactor must parse the whole document, and a PII scan may need all of it. Such a guard selects `WHOLE_BODY_BYTES` when the body fits the advertised limit. A transformation such as Git signing that needs complete input larger than one protobuf message selects fail-closed `OWNED_STREAM_BYTES`, spools accepted units, and emits output only after final input. Incremental guards select `STREAM_BYTES` and account for each unit without retaining input across results. -This is the strongest reason to keep the door open for a streaming operation, more so than incremental parsing. +### What streaming provides -### How it would work +The request stream provides two important properties: -A service would advertise chunked-transport support (and limits) in `Describe`. When supported, the supervisor could use the streaming operation and send the body as a sequence of messages. When not supported, it would continue to use the unary v1 operation, and a body over the unary cap would use the middleware config's `on_error` behavior. +- It removes the requirement that a complete request fit in one gRPC message. OpenShell caps request units at 64 KiB. +- Owned mode lets the service spool a larger finite transformation without OpenShell retaining a replay copy. Input and output are each bounded at 1 GiB and failure is always closed after ownership begins. -The streaming method should have its own messages instead of reusing `HttpRequestEvaluation` directly. Within a single streamed request, the first message would carry the request context plus the first body bytes, and subsequent messages would carry only further body chunks that the middleware appends; stream close would mark end of request. This keeps the v1 unary messages flat and gives streaming its own cleaner shape. +For a chain made only of `STREAM_BYTES` stages, OpenShell may forward each approved unit upstream before the request ends. A later denial or failure terminates that upload but cannot retract prior bytes, so the relay never retries or replays it. Whole-body and owned stages, body-aware policy re-evaluation, request-body credential rewriting, and body-dependent signing retain a hold barrier. OpenShell rebuilds framing for both paths and can stop a live upload when the upstream responds early. -A cleaner phased design using a `oneof` over `context` and `body_chunk`, in the style of Envoy `ext_proc`, is available for a future streaming operation because it would not need to preserve the unary v1 message shape. V1 keeps the flat unary request because it is simpler for bounded bodies and avoids making every middleware implement streaming mechanics before the need is proven. +The state machine requires one preflight, contiguous input sequences, one result for each input unit, an optional owned-output phase with contiguous sequences and exact final accounting, one trailers exchange for active body stages, and a terminal notification. Invalid modes, sequences, actions, diagnostics, or finalization follow `on_error`, except that owned stages cannot fail open. ## Additional operation phases > **Update in PR #2477 - WebSocket middleware:** This section now records `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` as implemented. It keeps `WEBSOCKET_MESSAGE/PRE_RETURN` as a reserved future operation. -V1 supports `HTTP_REQUEST/PRE_CREDENTIALS` and forward-text `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. The same service interface can host more operations, each advertised through the `Describe` manifest and invoked through an operation-specific method. Each operation and phase pair encodes a different position in the proxy flow: +V1 supports `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and forward-text `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. It also implements restricted built-in-only `HTTP_REQUEST/POST_CREDENTIALS`. Each operation and phase pair encodes a different position in the proxy flow: - `Connection/before_policy` / `HttpRequest/before_policy` - *before* network/L7 policy admits the request, for earlier classification. Riskier, because request content reaches a service before policy has allowed the request. - `HTTP_REQUEST/PRE_CREDENTIALS` (v1) - after policy admits the request, before credential injection. -- `HttpRequest/post_credentials` - after credential injection, immediately before the relay writes the request upstream. This hook is credential-visible, so it is built-in-only: OpenShell marks it as a restricted hook and rejects any externally registered middleware that advertises it during manifest validation. The motivating use is request signing that must run after credentials are injected - for example a built-in `openshell/sigv4` that strips placeholder-signed AWS headers and signs the finalized request with supervisor-resolved credentials just before it is sent upstream. +- `HTTP_REQUEST/POST_CREDENTIALS` (v1 restricted) - after credential resolution, immediately before the relay writes the request upstream. This hook is credential-visible, so it is built-in-only: OpenShell rejects any externally registered middleware that advertises it. `openshell/sigv4` strips placeholder-signed AWS headers and signs the finalized request with supervisor-resolved credentials. - `HttpResponse/completed` - after an upstream request completes, emit metadata such as status, content length, selected route, selected model, and model usage if available. This is notification-only: no body, no transformation, and no allow/deny verdict. It would let reservation-style budget middleware reconcile a pre-dispatch decision without introducing response-body inspection. -- `HttpResponse/before_return` - on the return path, after the upstream responds and before the response reaches the sandbox; inspect or redact upstream responses. +- `HTTP_RESPONSE/PRE_RETURN` (v1) - on the return path, after the upstream responds and before the response reaches the sandbox; inspect, redact, or block upstream responses. - `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` (v1 forward text) - after a WebSocket upgrade, on each complete client text message before credential placeholder rewriting. Before upstream contact, a concurrent preflight lets each selected stage inspect, voluntarily skip, or authoritatively deny the upgrade. Explicit denial takes precedence over failures and is enforced independently of `on_error`; OpenShell best-effort ends every still-writable opened stage stream with the typed terminal reason. An attached implementation without this binding is not selected and records coverage rather than applying `on_error`. Binary messages pass without inspection, consume a logical sequence, and record unsupported-message coverage for active stages. - `WEBSOCKET_MESSAGE/PRE_RETURN` - on complete upstream messages before they return to the workload. The enum value is reserved, but manifests advertising it are rejected until return-path inspection is implemented. -Pre-policy phases run earliest, the two request phases bracket credential injection, response notifications and response phases run after the upstream call, and message phases run later on the parsed relay. V1 implements only the two pre-credentials pairs above. `HttpRequest/post_credentials` is the nearest planned request-path follow-up and is kept built-in-only because it sees injected credentials; `HttpResponse/completed` is a separate future notification hook for metadata-only post-call reconciliation. +Pre-policy phases would run earliest, the two request phases bracket credential resolution, response phases run after the upstream call, and message phases run later on the parsed relay. `HttpResponse/completed` remains a future metadata-only notification hook. ## Semantic context -v1 sends the full request and lets the middleware interpret it. A future version can carry parsed semantic context (request category, semantic protocol such as OpenAI chat completions or Anthropic messages, and modalities) on `HttpRequestEvaluation`, and let policy target a semantic scope (latest user message, image parts, tool inputs). This also requires corresponding manifest fields so OpenShell can validate that a policy only references scopes and protocols the service supports. +V1 sends normalized request bytes and lets the middleware interpret them. A future version can carry parsed semantic context (request category, semantic protocol such as OpenAI chat completions or Anthropic messages, and modalities) on request preflight, and let policy target a semantic scope (latest user message, image parts, tool inputs). This also requires corresponding manifest fields so OpenShell can validate that a policy only references scopes and protocols the service supports. ## Content preview @@ -68,23 +58,23 @@ ICAP-style previewing: send only the first N bytes so the service can decide whe ## Portable feature contracts and binding -A future version can introduce named feature contracts, such as `pii-redaction`, with a mapping from that portable contract to a concrete service binding. Policy would then stay portable across interchangeable implementations. V1 references a service-owned binding ID directly and defers this additional indirection. +A future version can introduce named feature contracts, such as `pii-redaction`, with a mapping from that portable contract to a concrete registered implementation. Policy would then stay portable across interchangeable implementations. V1 references a built-in or operator-owned registration name directly and defers this additional indirection. ## Header mutation rules -V1 preserves duplicate request headers and their wire order. Before an external invocation, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. Results return ordered `write` and `remove` mutations. Writes support append, overwrite, and skip modes but may target only `x-openshell-middleware-*`. Removes may target other visible headers except the protected categories. OpenShell validates and applies a stage's mutations atomically, so one invalid mutation discards the whole set and follows that config's `on_error` behavior. +V1 preserves duplicate request headers and their wire order. Before an external invocation, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. Results return ordered `write` and `remove` mutations. Writes support append, overwrite, and skip modes. Mutations may target middleware-visible end-to-end headers except the protected categories. OpenShell validates and applies a stage's mutations atomically, so one invalid mutation discards the whole set and follows that config's `on_error` behavior. ## Middleware authentication Supervisor middleware exposes gRPC services over network endpoints. The stable transport contract requires confidentiality and authentication of the intended middleware service. Endpoint declaration, identity binding, credential material, and rotation must be explicit rather than left as deployment-specific conventions. -Phase 1 may temporarily support unauthenticated plaintext gRPC only when the operator explicitly sets `allow_insecure = true` on the middleware entry. A plaintext `http://` endpoint without this opt-in is rejected. OpenShell emits a prominent warning and records auditable configuration state whenever the exception is enabled, so insecure operation is always deliberate and visible. +Phase 1 may temporarily support unauthenticated plaintext gRPC only when the operator explicitly sets `allow_insecure_transport = true` on the middleware entry. A plaintext `http://` endpoint without this opt-in is rejected. OpenShell emits a prominent warning and records auditable configuration state whenever the exception is enabled, so insecure operation is always deliberate and visible. This mode is suitable only for trusted local development, loopback services, or isolated research environments where the middleware endpoint is not reachable by untrusted clients. It is not suitable for shared clusters, multi-tenant deployments, public networks, or any environment where inspected request content needs transport confidentiality. Without middleware authentication and transport security, network observers can read inspected request content, active attackers can impersonate the middleware service, and unauthorized clients can call the middleware directly if it is reachable. Because the middleware can allow, deny, or transform egress, service impersonation is a policy-enforcement bypass, not just an observability risk. -Phase 2 removes plaintext endpoint support and removes `allow_insecure`. Every external middleware connection must then provide authenticated encrypted transport. This is an intentional research-preview breaking change, so phase 1 plaintext configurations have no long-term compatibility guarantee and must migrate before phase 2. +Phase 2 removes plaintext endpoint support and removes `allow_insecure_transport`. Every external middleware connection must then provide authenticated encrypted transport. This is an intentional research-preview breaking change, so phase 1 plaintext configurations have no long-term compatibility guarantee and must migrate before phase 2. The exact phase 2 mechanism is deferred. Follow-up protocol work should choose and specify mTLS, TLS plus explicit caller authentication, or an equivalent design, including trust roots, client identity, credential delivery, certificate or key rotation, middleware identity binding, and how supervisors receive authentication material. diff --git a/skills/debug-openshell-cluster/references/supervisor-middleware.md b/skills/debug-openshell-cluster/references/supervisor-middleware.md index a1d7524662..cbaab71a73 100644 --- a/skills/debug-openshell-cluster/references/supervisor-middleware.md +++ b/skills/debug-openshell-cluster/references/supervisor-middleware.md @@ -20,7 +20,7 @@ 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`. +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 external V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. External manifests advertising `POST_CREDENTIALS` are rejected. `openshell/sigv4` is synthesized internally from endpoint credential-signing fields and must not appear in `network_middlewares`. 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. @@ -30,6 +30,8 @@ For response failures, distinguish a deliberate `middleware_denied` decision fro ## Request and WebSocket failures +For request streams, confirm the service receives preflight, contiguous body sequences, one trailers event, and one best-effort terminal event. Whole-body mode receives the complete body in its final unit. `STREAM_BYTES` and `OWNED_STREAM_BYTES` receive nonempty units of at most 64 KiB followed by one empty unit with `end_of_stream`. Owned mode is available only to `fail_closed` stages; the stage must acknowledge every input, emit contiguous output, and return exact final input/output accounting. Deferred input and output are each capped at 1 GiB. A pure `STREAM_BYTES` chain can forward approved units upstream immediately; a later denial terminates the upload without replay. Whole-body, owned, policy re-evaluation, body credential rewrite, and body-signing paths spool first. Request body receipt, evaluation, and output delivery share a fixed 120-second wall-clock deadline; expiry cancels the stage and terminates the request. + 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`. @@ -45,7 +47,10 @@ WebSocket message sequences are allocated session-wide; each stage receives a st | 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 | +| Policy names `openshell/sigv4` in `network_middlewares` or an external service advertises `POST_CREDENTIALS` | SigV4 is an endpoint-synthesized trusted stage; credential-visible phases are unavailable to external middleware | Remove the attachment; set endpoint `credential_signing`, `signing_service`, and `signing_region`; inspect provider endpoint bindings | | 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` | +| Large Git push fails after ownership transfer | Owned stage used fail-open, exceeded its 1 GiB deferred bound, returned noncontiguous output, or reported incorrect final accounting | Require `fail_closed`; inspect input/output sequences, `body_finalize`, spool capacity, and service logs | +| Request stalls and fails near 120 seconds | Client body receipt, middleware processing, or output delivery exceeded the fixed request deadline | Client upload progress; per-stage timeout logs; middleware and upstream backpressure; disk capacity and latency for withholding modes | | 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 | diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 89e6223ac2..44ccfe9bb7 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -218,8 +218,10 @@ Is L7 inspection needed? 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. +- Do not add `openshell/sigv4` to `network_middlewares`. Endpoint `credential_signing`, `signing_service`, and optional `signing_region` fields synthesize this trusted in-process `HTTP_REQUEST/POST_CREDENTIALS` stage after provider credentials resolve. External services cannot advertise that phase. - 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 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. +- Use `fail_closed` for an implementation that selects `OWNED_STREAM_BYTES`. Ownership removes the platform replay copy, so an owned-stage failure cannot fail open. Request stream units are at most 64 KiB; the advertised deferred input and output limit is 1 GiB each. - 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. @@ -386,6 +388,8 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] 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` +- [ ] SigV4 uses endpoint credential-signing fields rather than a `network_middlewares` attachment, and the endpoint's provider binding covers the signed destination +- [ ] Any middleware that requires `OWNED_STREAM_BYTES` uses `on_error: fail_closed` - [ ] 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 057874fa3a..ce67543274 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -533,6 +533,8 @@ 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. +`openshell/sigv4` is not a `network_middlewares` attachment. The endpoint's `credential_signing`, `signing_service`, and optional `signing_region` fields synthesize a trusted in-process `HTTP_REQUEST/POST_CREDENTIALS` stage after AWS credentials resolve. External middleware cannot advertise this credential-visible phase. Middleware that selects `OWNED_STREAM_BYTES`, such as a Git signing service, must use `fail_closed`; the original request is no longer replayable after ownership transfer. + 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. diff --git a/tasks/rust.toml b/tasks/rust.toml index 32928b8f69..df80185be1 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -32,6 +32,7 @@ run = [ "cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings", + "cargo clippy --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml --all-targets -- -D warnings", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -43,6 +44,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-git-signing/Cargo.toml --all", ] hide = true @@ -53,6 +55,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-git-signing/Cargo.toml --all -- --check", ] hide = true diff --git a/tasks/test.toml b/tasks/test.toml index 2b0feb2bba..d19c1676de 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -86,6 +86,7 @@ run = [ "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", + "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true From 99f00db46863a524a8ae74564c88dba51cc00617 Mon Sep 17 00:00:00 2001 From: Piotr Mlocek Date: Fri, 18 Sep 2026 23:13:04 -0700 Subject: [PATCH 2/2] refactor(middleware): adopt two-mode HTTP body protocol Signed-off-by: Piotr Mlocek --- Cargo.lock | 6 +- architecture/sandbox-limits.md | 21 +- architecture/sandbox.md | 37 +- crates/openshell-core/src/middleware.rs | 37 +- .../Cargo.toml | 3 - .../src/lib.rs | 146 +- .../src/regex.rs | 10 +- .../src/lib.rs | 5097 +---------------- .../src/remote.rs | 33 +- .../src/request.rs | 2524 ++++---- .../src/response.rs | 3082 +++------- .../src/response/preflight.rs | 427 -- .../src/response/validation.rs | 328 -- .../openshell-supervisor-network/Cargo.toml | 3 + .../src/l7/middleware.rs | 526 +- .../src/l7/mod.rs | 1 - .../src/l7/post_credentials.rs | 371 -- .../src/l7/relay.rs | 894 +-- .../src/l7/rest.rs | 1341 ++--- .../src/l7/rest/http_response.rs | 52 +- .../src/l7/websocket.rs | 1 + .../openshell-supervisor-network/src/lib.rs | 1 + .../openshell-supervisor-network/src/proxy.rs | 177 +- .../src/sigv4.rs | 216 +- .../tests/sigv4_localstack.rs | 4 +- docs/extensibility/supervisor-middleware.mdx | 65 +- docs/providers/aws-sigv4.mdx | 4 +- docs/reference/gateway-config.mdx | 2 +- .../README.md | 11 +- .../src/main.rs | 643 +-- .../Cargo.lock | 2555 --------- .../Cargo.toml | 28 - .../README.md | 100 - .../policy.yaml | 48 - .../src/main.rs | 1022 ---- .../src/signer.rs | 1180 ---- proto/supervisor_middleware.proto | 576 +- rfc/0009-supervisor-middleware/README.md | 180 +- .../appendices/extension-authentication.md | 2 +- .../appendices/protocol-extensions.md | 18 +- .../references/supervisor-middleware.md | 9 +- skills/generate-sandbox-policy/SKILL.md | 9 +- skills/openshell-cli/SKILL.md | 4 +- tasks/rust.toml | 3 - tasks/test.toml | 1 - 45 files changed, 3979 insertions(+), 17819 deletions(-) delete mode 100644 crates/openshell-supervisor-middleware/src/response/preflight.rs delete mode 100644 crates/openshell-supervisor-middleware/src/response/validation.rs delete mode 100644 crates/openshell-supervisor-network/src/l7/post_credentials.rs rename crates/{openshell-supervisor-middleware-builtins => openshell-supervisor-network}/src/sigv4.rs (74%) delete mode 100644 examples/supervisor-middleware-git-signing/Cargo.lock delete mode 100644 examples/supervisor-middleware-git-signing/Cargo.toml delete mode 100644 examples/supervisor-middleware-git-signing/README.md delete mode 100644 examples/supervisor-middleware-git-signing/policy.yaml delete mode 100644 examples/supervisor-middleware-git-signing/src/main.rs delete mode 100644 examples/supervisor-middleware-git-signing/src/signer.rs diff --git a/Cargo.lock b/Cargo.lock index 861c142253..109e6df8d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4764,9 +4764,6 @@ name = "openshell-supervisor-middleware-builtins" version = "0.0.0" dependencies = [ "async-trait", - "aws-credential-types", - "aws-sigv4", - "aws-smithy-runtime-api", "miette", "openshell-core", "prost-types", @@ -4784,6 +4781,9 @@ version = "0.0.0" dependencies = [ "apollo-parser", "async-trait", + "aws-credential-types", + "aws-sigv4", + "aws-smithy-runtime-api", "base64", "bytes", "flate2", diff --git a/architecture/sandbox-limits.md b/architecture/sandbox-limits.md index 2cafb75669..aaf8e99fdf 100644 --- a/architecture/sandbox-limits.md +++ b/architecture/sandbox-limits.md @@ -60,14 +60,13 @@ budgets as new activity. |---|---:|---| | Concurrent buffered work | 32 | Shared by HTTP requests, WebSocket messages, and WebSocket preflight. One permit covers one complete unit of work. | | Admission waiters | 64 | Additional work is shed when both the active budget and waiter budget are full. HTTP receives a complete 503 response before its body is buffered. | -| Persistent middleware sessions | 32 | Shared process-wide session budget for HTTP request and WebSocket streams. Admission is retained while any stage remains active. | -| Middleware payload or unit | 4 MiB | Platform maximum for a complete whole-body payload, WebSocket text message, or advertised stream unit. Request stream units are further capped at 64 KiB. | -| Owned request representation | 1 GiB input and 1 GiB output | Logical deferred-storage limit for fail-closed owned streams. Implementations spool rather than retain this representation in memory. | +| Persistent middleware sessions | 32 | Shared process-wide session budget for HTTP request/response and WebSocket streams. Admission is retained while any stage remains active. | +| Middleware payload or unit | 4 MiB | Platform maximum for a complete buffered payload, WebSocket text message, or advertised stream unit. Request stream units are further capped at 64 KiB. | | Middleware configs and stages | 10 | At most 10 configs in policy and 10 selected stages in one chain. | | Selector patterns | 32 | Combined include and exclude patterns per middleware config. | | Per-stage RPC | 500 ms default, 10 ms–30 s | An operator timeout caps a binding timeout. | -| Middleware unit or message chain | 30 s | Bounds one HTTP body-unit pass, one HTTP finalization pass, or one WebSocket message across all selected stages. It is not an accepted-stream lifetime. | -| HTTP request body processing | 2 min total | Bounds request-body receipt, middleware processing, and output delivery. Pure lockstep chains may forward approved units during this interval; withholding modes spool first. Timeout cancels the session and terminates the request. | +| Middleware unit or message chain | 30 s | Bounds one HTTP exchange or one WebSocket message across selected stages. It is not an accepted-stream lifetime. | +| HTTP request body processing | 2 min total | Bounds request-body receipt, middleware processing, and output delivery. STREAM may forward output during this interval; BUFFERED and body-aware policy use bounded RAM. Timeout cancels the session and terminates the request. | | WebSocket preflight | 1 s maximum | Caps handshake delay independently of the message RPC timeout. | | Remote service connect | 5 s | Applies while establishing a middleware gRPC channel. | @@ -78,9 +77,9 @@ and 64 metadata entries. The detailed external contract lives in [Supervisor Middleware](../docs/extensibility/supervisor-middleware.mdx). The work semaphore bounds concurrent middleware progress and caps aggregate -in-memory whole-body or WebSocket input at approximately `32 × 4 MiB`, plus -bounded envelope and parser overhead. Streaming HTTP bodies use bounded units -and storage-backed output instead. This is a concurrency safety valve, not rate +in-memory buffered or WebSocket input at approximately `32 × 4 MiB`, plus +bounded envelope and parser overhead. Streaming HTTP bodies use bounded queues +while middleware owns any processing storage. This is a concurrency safety valve, not rate limiting or a promise that maximum-sized work is inexpensive. The persistent session semaphore is independent from the work semaphore. One @@ -117,9 +116,9 @@ buffer only when it owns an explicit bound. Every parsed WebSocket text message acquires network-owned assembly capacity before payload allocation or reading, including relays used only for native policy, credential rewriting, compression, or a disabled fail-open middleware session. The process-lifetime budget survives policy reloads, and the assembly retains its permit through decompression, policy and middleware evaluation, credential rewriting, and upstream forwarding. Active middleware sessions additionally acquire shared middleware work before buffering. Input progress resets only the idle deadline. Forwarding uses one total deadline across the complete frame header, payload, and flush. Every timeout and terminal parser error releases both permits through ordinary ownership. Queue exhaustion emits a payload-free network denial event. The operator middleware `max_payload_bytes` ceiling applies to complete -whole-body payloads, stream units, and WebSocket text messages. The HTTP request -runtime further caps units at 64 KiB and advertises the separate owned-stream -deferred limit during preflight. Neither limit replaces the raw binary frame +buffered payloads, stream units, and WebSocket text messages. The HTTP request +runtime further caps units at 64 KiB and advertises bounded queue limits during +preflight. Neither limit replaces the raw binary frame safety bound because binary messages are never delivered to V1 middleware. A passed binary logical message still advances the active middleware session sequence and emits coverage telemetry, so a later text RPC can contain a valid diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 85e702079b..e54e738291 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -285,23 +285,21 @@ operator-owned registration names identify implementations. Built-ins and operator services use the same event-oriented request contract. Each selected `HTTP_REQUEST/PRE_CREDENTIALS` stage opens a bidirectional stream, -receives preflight, and selects header-only, whole-body, lockstep stream, or -owned-stream processing. The HTTP/1 relay normalizes fixed and chunked bodies -into bounded units. A chain made only of lockstep stream stages can forward each -approved unit upstream immediately with bounded channel and socket backpressure. +receives preflight, and continues without a body, rejects, or selects BUFFERED +or STREAM processing. The HTTP/1 relay normalizes fixed and chunked bodies into +bounded units. A chain made only of STREAM stages can forward output upstream +immediately with bounded channel and socket backpressure. The relay switches the upstream request to chunked framing when transformations can change unit sizes and concurrently watches for an early upstream response. It cancels the middleware session and never replays the request if the upstream -responds before upload completes. Chains containing whole-body or owned stages, -body-aware policy re-evaluation, request-body credential rewriting, or signing -that needs the complete payload retain a hold barrier and spool output first. -Whole-body stages receive one data-bearing final unit; streaming and owned -stages receive nonempty data units followed by one empty terminal unit. Body +responds before upload completes. Chains containing BUFFERED stages, +body-aware policy re-evaluation, or request-body credential rewriting retain a +bounded in-memory hold barrier. OpenShell never creates a middleware body disk +spool or recovery copy. STREAM uses independent input and output pumps, so the +service can emit early, delay its output head, or own processing storage. Body receipt, middleware processing, and output delivery share a two-minute wall-clock deadline. -Owned streams transfer replay responsibility to a fail-closed stage, allowing -whole-request transformations larger than the per-message protobuf limit while -keeping input, output, and backpressure bounded. Body-aware GraphQL, JSON-RPC, +All HTTP middleware is fail-closed. Body-aware GraphQL, JSON-RPC, and MCP paths retain a hold barrier so policy can re-evaluate every accepted replacement before later stages or upstream delivery. When a stage ends, the remote adapter sends its terminal event, half-closes the @@ -320,15 +318,9 @@ 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 restricted `HTTP_REQUEST/POST_CREDENTIALS` phase is available only to -trusted in-process built-ins. External manifests advertising that phase are -rejected because it can observe resolved credentials. `openshell/sigv4` owns -AWS request signing at this phase while existing endpoint policy fields select -its signing mode and target. - 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 +it returns to the workload. Request and response hooks share the same contract; +the initial response rollout offers Continue and BUFFERED only. The relay owns response framing when body bytes can 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 @@ -551,9 +543,8 @@ subject, and gateway SPIFFE subject, and their cache lifetime is capped by the intermediate token response, stored subject-token expiry, and supervisor SVID expiry. -For AWS endpoints that require request-level signing, the restricted in-process -`openshell/sigv4` middleware performs SigV4 re-signing after provider credential -resolution. When `credential_signing: sigv4` is set on an L7 endpoint, it strips +For AWS endpoints that require request-level signing, the proxy supports SigV4 +re-signing. When `credential_signing: sigv4` is set on an L7 endpoint, the proxy strips the client's placeholder-based AWS auth headers, re-signs with real credentials from the provider, and forwards the request upstream. The signing endpoint must have a credential source before the policy generation activates: diff --git a/crates/openshell-core/src/middleware.rs b/crates/openshell-core/src/middleware.rs index e0cd2f2bce..3155df1c5f 100644 --- a/crates/openshell-core/src/middleware.rs +++ b/crates/openshell-core/src/middleware.rs @@ -11,20 +11,13 @@ use tokio::sync::mpsc; use tonic::{Request, Response, Status}; use crate::proto::{ - HttpRequestEvent, HttpRequestEventResult, HttpResponseEvent, HttpResponseEventResult, - MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, WebSocketSessionEvent, - WebSocketSessionEventResult, + HttpEvent, HttpResult, MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, + WebSocketSessionEvent, WebSocketSessionEventResult, }; -/// Transport-neutral result stream for one HTTP request middleware stage. -pub type HttpRequestResultStream = Pin< - Box> + Send + 'static>, ->; - -/// Transport-neutral result stream for one HTTP response middleware stage. -pub type HttpResponseResultStream = Pin< - Box> + Send + 'static>, ->; +/// Transport-neutral result stream for one HTTP middleware stage. +pub type HttpResultStream = + Pin> + Send + 'static>>; /// Transport-neutral response stream for one WebSocket middleware stage. pub type WebSocketResponseStream = Pin< @@ -50,8 +43,8 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync { async fn open_http_request_pre_credentials( &self, - _requests: mpsc::Receiver, - ) -> Result { + _requests: mpsc::Receiver, + ) -> Result { Err(Status::unimplemented( "middleware does not implement HTTP request pre-credentials evaluation", )) @@ -68,8 +61,8 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync { async fn open_http_response_pre_return( &self, - _requests: mpsc::Receiver, - ) -> Result { + _requests: mpsc::Receiver, + ) -> Result { Err(Status::unimplemented( "middleware does not implement HTTP response pre-return evaluation", )) @@ -101,7 +94,7 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync { /// use miette::Result; /// use openshell_core::middleware::InProcessMiddleware; /// use openshell_core::proto::{ -/// MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, +/// HttpBodyMode, MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, /// SupervisorMiddlewarePhase, /// }; /// use prost_types::Struct; @@ -119,6 +112,8 @@ pub trait SupervisorMiddlewareEndpoint: Send + Sync { /// phase: SupervisorMiddlewarePhase::PreCredentials as i32, /// max_payload_bytes: 1024, /// request_timeout: None, +/// http_protocol_version: 1, +/// supported_http_body_modes: vec![HttpBodyMode::Buffered as i32], /// }], /// expected_audience: String::new(), /// } @@ -156,8 +151,8 @@ pub trait InProcessMiddleware: Send + Sync { /// Open one HTTP request pre-credentials stream. async fn open_http_request_pre_credentials( &self, - _requests: mpsc::Receiver, - ) -> std::result::Result { + _requests: mpsc::Receiver, + ) -> std::result::Result { Err(Status::unimplemented( "middleware does not implement HTTP request pre-credentials evaluation", )) @@ -180,8 +175,8 @@ pub trait InProcessMiddleware: Send + Sync { /// Request-only implementations may keep the default unsupported response. async fn open_http_response_pre_return( &self, - _requests: mpsc::Receiver, - ) -> std::result::Result { + _requests: mpsc::Receiver, + ) -> std::result::Result { Err(Status::unimplemented( "middleware does not implement HTTP response pre-return evaluation", )) diff --git a/crates/openshell-supervisor-middleware-builtins/Cargo.toml b/crates/openshell-supervisor-middleware-builtins/Cargo.toml index 9142fe13df..8e38a05ad2 100644 --- a/crates/openshell-supervisor-middleware-builtins/Cargo.toml +++ b/crates/openshell-supervisor-middleware-builtins/Cargo.toml @@ -14,9 +14,6 @@ rust-version.workspace = true openshell-core = { path = "../openshell-core", default-features = false } async-trait = "0.1" -aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } -aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } -aws-smithy-runtime-api = { version = "1", features = ["client"] } miette = { workspace = true } prost-types = { workspace = true } regex = { workspace = true } diff --git a/crates/openshell-supervisor-middleware-builtins/src/lib.rs b/crates/openshell-supervisor-middleware-builtins/src/lib.rs index 9ee70a9db6..6731fd1dda 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/lib.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/lib.rs @@ -4,29 +4,23 @@ //! First-party in-process supervisor middleware implementations. mod regex; -pub mod sigv4; use std::sync::Arc; use miette::{Result, miette}; -use openshell_core::middleware::{ - HttpRequestResultStream, InProcessMiddleware, WebSocketResponseStream, -}; +use openshell_core::middleware::{HttpResultStream, InProcessMiddleware, WebSocketResponseStream}; use openshell_core::proto::{ - HttpRequestBodyMode, HttpRequestBodyPassThrough, HttpRequestBodyResult, - HttpRequestBodyTransform, HttpRequestEvent, HttpRequestEventResult, - HttpRequestPreflightInspect, HttpRequestPreflightResult, HttpRequestTrailersResult, - MiddlewareManifest, SupervisorMiddlewarePhase, WebSocketPreflightAction, - WebSocketPreflightDecision, WebSocketSessionEvent, WebSocketSessionEventResult, - http_request_body_result, http_request_body_transform, http_request_body_unit, - http_request_event, http_request_event_result, http_request_preflight_result, - web_socket_message, web_socket_session_event, web_socket_session_event_result, + HttpBodyMode, HttpBufferedMode, HttpBufferedResult, HttpEvent, HttpInspect, + HttpPreflightResult, HttpResult, HttpUnchanged, MiddlewareDiagnostics, MiddlewareManifest, + SupervisorMiddlewarePhase, WebSocketPreflightAction, WebSocketPreflightDecision, + WebSocketSessionEvent, WebSocketSessionEventResult, http_buffered_result, http_event, + http_inspect, http_preflight, http_preflight_result, http_result, web_socket_message, + web_socket_session_event, web_socket_session_event_result, }; use tokio_stream::{Stream, StreamExt}; use tonic::Status; pub use regex::{NAME as BUILTIN_REGEX, RegexConfig, RegexMode}; -pub use sigv4::NAME as BUILTIN_SIGV4; /// Return the first-party services that the gateway and supervisor install. pub fn services() -> Vec> { @@ -49,13 +43,11 @@ pub fn validate_config(implementation: &str, config: &prost_types::Struct) -> Re pub struct BuiltinMiddlewareService; impl BuiltinMiddlewareService { - fn request_stream( - mut requests: tokio::sync::mpsc::Receiver, - ) -> HttpRequestResultStream { + fn request_stream(mut requests: tokio::sync::mpsc::Receiver) -> HttpResultStream { let (responses_tx, responses_rx) = tokio::sync::mpsc::channel(4); tokio::spawn(async move { let mut config = None; - let mut inspected_body = false; + let mut began = false; while let Some(request) = requests.recv().await { let Some(event) = request.event else { let _ = responses_tx @@ -64,88 +56,79 @@ impl BuiltinMiddlewareService { break; }; let result = match event { - http_request_event::Event::Preflight(preflight) if config.is_none() => { - if preflight.middleware_name != BUILTIN_REGEX { + http_event::Event::Preflight(preflight) if config.is_none() => { + let Some(http_preflight::Head::Request(head)) = preflight.head else { + let _ = responses_tx + .send(Err(Status::invalid_argument("request head required"))) + .await; + break; + }; + if head.middleware_name != BUILTIN_REGEX { Err(Status::invalid_argument(format!( "middleware implementation '{}' is not a registered OpenShell built-in", - preflight.middleware_name + head.middleware_name ))) } else if !preflight .permitted_body_modes - .contains(&(HttpRequestBodyMode::WholeBodyBytes as i32)) + .contains(&(HttpBodyMode::Buffered as i32)) { Err(Status::failed_precondition( - "openshell/regex requires whole-body request inspection", + "openshell/regex requires BUFFERED request inspection", )) } else { - let selected = preflight.config.unwrap_or_default(); + let selected = head.config.unwrap_or_default(); match regex::validate_config(&selected) { Ok(()) => { config = Some(selected); - Ok(HttpRequestEventResult { - result: Some( - http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some( - http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::WholeBodyBytes as i32, - header_mutations: Vec::new(), - }, - ), + Ok(HttpResult { + result: Some(http_result::Result::PreflightResult( + HttpPreflightResult { + decision: Some( + http_preflight_result::Decision::Inspect( + HttpInspect { + mode: Some( + http_inspect::Mode::Buffered( + HttpBufferedMode { + max_body_bytes: + regex::MAX_PAYLOAD_BYTES, + }, + ), + ), + }, ), - ..Default::default() - }, - ), - ), + ), + ..Default::default() + }, + )), }) } Err(error) => Err(Status::invalid_argument(error.to_string())), } } } - http_request_event::Event::Body(body) - if config.is_some() && !inspected_body && body.end_of_stream => - { - let Some(http_request_body_unit::Payload::Data(data)) = body.payload else { - let _ = responses_tx - .send(Err(Status::invalid_argument( - "missing request body payload", - ))) - .await; - break; - }; - inspected_body = true; + http_event::Event::Begin(_) if config.is_some() && !began => { + began = true; + continue; + } + http_event::Event::BufferedBody(body) if config.is_some() && began => { match regex::evaluate_http_body( config.as_ref().expect("validated request config"), - &data, + &body.data, ) { Ok(evaluation) => { - let action = evaluation.replacement.map_or_else( - || { - http_request_body_result::Action::PassThrough( - HttpRequestBodyPassThrough {}, - ) - }, - |replacement| { - http_request_body_result::Action::Transform( - HttpRequestBodyTransform { - replacement: Some( - http_request_body_transform::Replacement::Data( - replacement, - ), - ), - }, - ) - }, + let body = evaluation.replacement.map_or_else( + || http_buffered_result::Body::Unchanged(HttpUnchanged {}), + http_buffered_result::Body::Replacement, ); - Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyResult( - HttpRequestBodyResult { - sequence: body.sequence, - action: Some(action), - findings: evaluation.findings, - metadata: evaluation.metadata, + Ok(HttpResult { + result: Some(http_result::Result::BufferedResult( + HttpBufferedResult { + body: Some(body), + diagnostics: Some(MiddlewareDiagnostics { + findings: evaluation.findings, + metadata: evaluation.metadata, + ..Default::default() + }), ..Default::default() }, )), @@ -154,14 +137,7 @@ impl BuiltinMiddlewareService { Err(error) => Err(Status::invalid_argument(error.to_string())), } } - http_request_event::Event::Trailers(_) if inspected_body => { - Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::TrailersResult( - HttpRequestTrailersResult::default(), - )), - }) - } - http_request_event::Event::SessionEnd(_) if config.is_some() => break, + http_event::Event::SessionEnd(_) if config.is_some() => break, _ => Err(Status::failed_precondition( "invalid built-in HTTP request lifecycle", )), @@ -339,8 +315,8 @@ impl InProcessMiddleware for BuiltinMiddlewareService { async fn open_http_request_pre_credentials( &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { Ok(Self::request_stream(requests)) } diff --git a/crates/openshell-supervisor-middleware-builtins/src/regex.rs b/crates/openshell-supervisor-middleware-builtins/src/regex.rs index 99176c7123..99fbccff75 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/regex.rs +++ b/crates/openshell-supervisor-middleware-builtins/src/regex.rs @@ -14,14 +14,14 @@ use std::sync::LazyLock; use miette::{Result, miette}; use openshell_core::proto::{ - Decision, Finding, MiddlewareBinding, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, - WebSocketMessageResult, web_socket_message_result, + Decision, Finding, HttpBodyMode, MiddlewareBinding, SupervisorMiddlewareOperation, + SupervisorMiddlewarePhase, WebSocketMessageResult, web_socket_message_result, }; use regex::Regex; use serde::Deserialize; pub const NAME: &str = "openshell/regex"; -const MAX_PAYLOAD_BYTES: u64 = 256 * 1024; +pub const MAX_PAYLOAD_BYTES: u64 = 256 * 1024; #[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)] #[serde(default, deny_unknown_fields)] @@ -56,12 +56,16 @@ pub fn describe() -> Vec { phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: MAX_PAYLOAD_BYTES, request_timeout: None, + http_protocol_version: 1, + supported_http_body_modes: vec![HttpBodyMode::Buffered as i32], }, MiddlewareBinding { operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, phase: SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: MAX_PAYLOAD_BYTES, request_timeout: None, + http_protocol_version: 0, + supported_http_body_modes: Vec::new(), }, ] } diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 17d0d99f52..7b2a4c2f20 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -10,9 +10,10 @@ mod response; mod websocket; pub use request::{ - HttpRequestDiagnostics, HttpRequestFinish, HttpRequestInvocation, HttpRequestInvocationOutcome, - HttpRequestMiddlewareFailure, HttpRequestPreflightInput, HttpRequestPreflightOutcome, - HttpRequestSession, MAX_HTTP_REQUEST_DEFERRED_BYTES, MAX_HTTP_REQUEST_STREAM_UNIT_BYTES, + HttpRequestBodyInput, HttpRequestBodyOutput, HttpRequestDiagnostics, HttpRequestFinish, + HttpRequestInvocation, HttpRequestInvocationOutcome, HttpRequestMiddlewareFailure, + HttpRequestPreflightInput, HttpRequestPreflightOutcome, HttpRequestSession, + MAX_HTTP_REQUEST_DEFERRED_BYTES, MAX_HTTP_REQUEST_STREAM_UNIT_BYTES, }; pub use response::{ @@ -47,8 +48,7 @@ use tokio::sync::{OnceCell, OwnedSemaphorePermit, Semaphore}; use tonic::Request; pub use openshell_core::middleware::{ - HttpRequestResultStream, HttpResponseResultStream, InProcessMiddleware, - SupervisorMiddlewareEndpoint, WebSocketResponseStream, + HttpResultStream, InProcessMiddleware, SupervisorMiddlewareEndpoint, WebSocketResponseStream, }; struct EndpointInProcessAdapter { endpoint: Arc, @@ -87,8 +87,8 @@ impl InProcessMiddleware for EndpointInProcessAdapter { async fn open_http_request_pre_credentials( &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { self.endpoint .open_http_request_pre_credentials(requests) .await @@ -103,8 +103,8 @@ impl InProcessMiddleware for EndpointInProcessAdapter { async fn open_http_response_pre_return( &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { self.endpoint.open_http_response_pre_return(requests).await } } @@ -242,11 +242,6 @@ pub const MIDDLEWARE_GRPC_MESSAGE_BYTES: usize = const MAX_STABLE_IDENTIFIER_BYTES: usize = 128; const EXTERNAL_FINDING_LABEL: &str = "External middleware finding"; -#[cfg(test)] -const HTTP_REQUEST_OPERATION: SupervisorMiddlewareOperation = - SupervisorMiddlewareOperation::HttpRequest; -#[cfg(test)] -const PRE_CREDENTIALS_PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OnError { FailClosed, @@ -474,8 +469,8 @@ impl MiddlewareDispatch { async fn open_http_request_pre_credentials( &self, - receiver: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { match self { Self::InProcess(service) => service.open_http_request_pre_credentials(receiver).await, Self::Grpc(service) => service.open_http_request_pre_credentials(receiver).await, @@ -494,8 +489,8 @@ impl MiddlewareDispatch { async fn open_http_response_pre_return( &self, - receiver: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { + 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, @@ -701,7 +696,6 @@ fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result Result Ok(SupportedBinding::HttpPreCredentials), - ( - Some(SupervisorMiddlewareOperation::HttpRequest), - Some(SupervisorMiddlewarePhase::PostCredentials), - ) => Ok(SupportedBinding::HttpPostCredentials), ( Some(SupervisorMiddlewareOperation::HttpResponse), Some(SupervisorMiddlewarePhase::PreReturn), @@ -750,7 +740,7 @@ fn validate_manifest_bindings( let mut described_pairs = HashSet::with_capacity(manifest.bindings.len()); for binding in &manifest.bindings { - supported_binding(source, binding)?; + let supported = supported_binding(source, binding)?; if !described_pairs.insert((binding.operation, binding.phase)) { return Err(miette!( "{source} describes a duplicate middleware operation/phase pair" @@ -772,6 +762,45 @@ fn validate_manifest_bindings( "{source} must configure max_payload_bytes for every payload-bearing binding" )); } + match supported { + SupportedBinding::HttpPreCredentials | SupportedBinding::HttpResponsePreReturn => { + if binding.http_protocol_version != 1 { + return Err(miette!( + "{source} must advertise HTTP middleware protocol version 1" + )); + } + if binding.supported_http_body_modes.is_empty() { + return Err(miette!( + "{source} must advertise at least one supported HTTP body mode" + )); + } + let mut modes = HashSet::new(); + for mode in &binding.supported_http_body_modes { + if !modes.insert(*mode) + || !matches!( + openshell_core::proto::HttpBodyMode::try_from(*mode).ok(), + Some( + openshell_core::proto::HttpBodyMode::Buffered + | openshell_core::proto::HttpBodyMode::Stream + ) + ) + { + return Err(miette!( + "{source} advertises an invalid or duplicate HTTP body mode" + )); + } + } + } + SupportedBinding::WebSocketPreCredentials => { + if binding.http_protocol_version != 0 + || !binding.supported_http_body_modes.is_empty() + { + return Err(miette!( + "{source} sets HTTP protocol capabilities on a WebSocket binding" + )); + } + } + } } Ok(()) } @@ -800,15 +829,6 @@ fn validate_external_manifest( operator_max_payload_bytes: usize, authenticated: bool, ) -> Result<()> { - if manifest.bindings.iter().any(|binding| { - SupervisorMiddlewarePhase::try_from(binding.phase) - .is_ok_and(|phase| phase == SupervisorMiddlewarePhase::PostCredentials) - }) { - return Err(miette!( - "external middleware registration '{}' advertises POST_CREDENTIALS, which is reserved for trusted in-process built-ins", - registration.name - )); - } validate_manifest_bindings( &format!("external middleware registration '{}'", registration.name), manifest, @@ -1006,7 +1026,27 @@ impl MiddlewareRegistry { pub async fn validate_policy_configs(&self, policy: &SandboxPolicy) -> Result<()> { ensure_config_capacity(policy.network_middlewares.len())?; let runner = ChainRunner::from_registry(self.clone()); + let manifests = runner.manifests().await?; for (name, config) in &policy.network_middlewares { + let entry = ChainEntry::try_from((name.as_str(), config))?; + if entry.on_error == OnError::FailOpen + && manifests.iter().any(|(state, manifest)| { + ChainRunner::attachment_name(state, manifest) == config.middleware + && manifest.bindings.iter().any(|binding| { + matches!( + SupervisorMiddlewareOperation::try_from(binding.operation).ok(), + Some( + SupervisorMiddlewareOperation::HttpRequest + | SupervisorMiddlewareOperation::HttpResponse + ) + ) + }) + }) + { + return Err(miette!( + "middleware config '{name}' uses on_error=fail_open with an HTTP binding; HTTP middleware is fail-closed" + )); + } runner .validate_config( &config.middleware, @@ -1099,13 +1139,6 @@ impl ChainRunner { } } - #[cfg(test)] - fn new_protobuf_for_tests(endpoint: Arc) -> Self { - Self::from_service(MiddlewareDispatch::Grpc( - remote::GrpcMiddlewareService::from_service(endpoint), - )) - } - pub fn from_registry(registry: MiddlewareRegistry) -> Self { Self { registry: Arc::new(registry), @@ -1280,7 +1313,7 @@ impl ChainRunner { }); continue; }; - let Some(binding) = Self::binding(manifest, operation, phase).copied() else { + let Some(binding) = Self::binding(manifest, operation, phase).cloned() else { // The config remains globally ordered, but it does not // participate in this exact operation/phase chain. unbound.push(entry); @@ -1497,7 +1530,7 @@ impl ChainRunner { let unit_limit = session.stream_unit_limit(); let mut failed = None; for chunk in body.chunks(unit_limit) { - match session.push_body(chunk.to_vec()).await { + match session.push_body(chunk.to_vec()) { Ok(units) => output.extend(units), Err(error) => { failed = Some(error); @@ -1644,4899 +1677,163 @@ pub(crate) fn safe_reason(reason: &str) -> String { #[cfg(test)] mod tests { use super::*; - use futures::{FutureExt, Stream, StreamExt}; - use openshell_core::proto::middleware::v1::http_request_pre_credentials_server::{ - HttpRequestPreCredentials, HttpRequestPreCredentialsServer, - }; - use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ - SupervisorMiddleware, SupervisorMiddlewareServer, - }; - use openshell_core::proto::{ExistingHeaderAction, header_mutation}; - use openshell_supervisor_middleware_builtins::{BUILTIN_REGEX, services}; - - use tokio_stream::wrappers::{ReceiverStream, TcpListenerStream}; - - #[derive(Clone, Default)] - struct TestRequestEvaluation { - phase: i32, - context: Option, - config: Option, - target: Option, - headers: Vec, - body: Vec, - middleware_name: String, - } - - #[derive(Clone, Default)] - struct TestRequestResult { - decision: i32, - reason: String, - body: Vec, - has_body: bool, - header_mutations: Vec, - findings: Vec, - metadata: HashMap, - reason_code: String, - } - - type TestBodyHandler = - Box, TestRequestEvaluation) -> TestRequestResult + Send + 'static>; - - struct TestRequestPlan { - preflight: TestRequestResult, - body: Option, - } - - fn constant_request_plan(result: TestRequestResult) -> TestRequestPlan { - if result.decision != Decision::Allow as i32 { - return TestRequestPlan { - preflight: result, - body: None, - }; - } - let preflight = TestRequestResult { - decision: result.decision, - header_mutations: result.header_mutations.clone(), - ..Default::default() - }; - TestRequestPlan { - preflight, - body: Some(Box::new(move |_body, _evaluation| result)), - } - } - - fn open_test_request_stream( - mut requests: tokio::sync::mpsc::Receiver, - plan: F, - ) -> HttpRequestResultStream - where - F: FnOnce(openshell_core::proto::HttpRequestPreflight) -> TestRequestPlan + Send + 'static, - { - use openshell_core::proto::{ - HttpRequestBlock, HttpRequestBodyPassThrough, HttpRequestBodyResult, - HttpRequestBodyTransform, HttpRequestEventResult, HttpRequestPreflightInspect, - HttpRequestPreflightResult, HttpRequestTrailersResult, http_request_body_result, - http_request_body_transform, http_request_event, http_request_event_result, - http_request_preflight_result, - }; - - let (sender, receiver) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - let Some(openshell_core::proto::HttpRequestEvent { - event: Some(http_request_event::Event::Preflight(preflight)), - }) = requests.recv().await - else { - return; - }; - let mut evaluation = TestRequestEvaluation { - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - context: preflight.context.clone(), - config: preflight.config.clone(), - target: preflight.target.clone(), - headers: preflight.headers.clone(), - body: Vec::new(), - middleware_name: preflight.middleware_name.clone(), - }; - let TestRequestPlan { - preflight: initial, - body, - } = plan(preflight); - let action = match Decision::try_from(initial.decision) { - Ok(Decision::Deny) => Some(http_request_preflight_result::Action::BlockRequest( - HttpRequestBlock {}, - )), - Ok(Decision::Allow) => Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: if body.is_some() { - openshell_core::proto::HttpRequestBodyMode::WholeBodyBytes as i32 - } else { - openshell_core::proto::HttpRequestBodyMode::HeadersOnly as i32 - }, - header_mutations: initial.header_mutations, - }, - )), - Ok(Decision::Unspecified) | Err(_) => None, - }; - if sender - .send(Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action, - reason: initial.reason, - reason_code: initial.reason_code, - findings: initial.findings, - metadata: initial.metadata, - }, - )), - })) - .await - .is_err() - { - return; - } + use openshell_core::proto::HttpBodyMode; - let Some(body_handler) = body else { - while requests.recv().await.is_some() {} - return; - }; - let Some(openshell_core::proto::HttpRequestEvent { - event: Some(http_request_event::Event::Body(body)), - }) = requests.recv().await - else { - return; - }; - evaluation.body = match body.payload { - Some(openshell_core::proto::http_request_body_unit::Payload::Data(data)) => data, - None => Vec::new(), - }; - let sequence = body.sequence; - let result = body_handler(evaluation.body.clone(), evaluation); - let action = if result.decision == Decision::Deny as i32 { - http_request_body_result::Action::BlockRequest(HttpRequestBlock {}) - } else if result.has_body { - http_request_body_result::Action::Transform(HttpRequestBodyTransform { - replacement: Some(http_request_body_transform::Replacement::Data(result.body)), - }) - } else { - http_request_body_result::Action::PassThrough(HttpRequestBodyPassThrough {}) - }; - if sender - .send(Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyResult( - HttpRequestBodyResult { - sequence, - action: Some(action), - reason: result.reason, - reason_code: result.reason_code, - findings: result.findings, - metadata: result.metadata, - }, - )), - })) - .await - .is_err() - { - return; - } - while let Some(event) = requests.recv().await { - match event.event { - Some(http_request_event::Event::Trailers(_)) => { - if sender - .send(Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::TrailersResult( - HttpRequestTrailersResult::default(), - )), - })) - .await - .is_err() - { - break; - } - } - _ => break, - } - } - }); - Box::pin(ReceiverStream::new(receiver)) - } - - fn proto_duration(value: &str) -> prost_types::Duration { - let duration = match (value.strip_suffix("ms"), value.strip_suffix('s')) { - (Some(milliseconds), _) => { - Duration::from_millis(milliseconds.parse().expect("integer milliseconds")) - } - (_, Some(seconds)) => Duration::from_secs(seconds.parse().expect("integer seconds")), - (None, None) => panic!("test duration must use ms or s"), - }; - openshell_core::time::duration_from_std(duration) - .expect("test duration is in protobuf range") - } - - #[test] - fn advertised_audience_mismatch_fails_registration() { - let configured = "urn:openshell:extension:middleware:content-guard"; - - // Matching and unadvertised audiences both pass. - validate_expected_audience("content-guard", configured, "", true) - .expect("unadvertised audience is accepted"); - validate_expected_audience("content-guard", configured, configured, true) - .expect("matching audience is accepted"); - - // Once authenticated Describe succeeds, reject a manifest that - // contradicts the operator-owned audience configuration. - let error = - validate_expected_audience("content-guard", configured, "urn:example:stale", true) - .expect_err("mismatched audience must fail closed"); - let message = error.to_string(); - assert!(message.contains("urn:example:stale")); - assert!(message.contains(configured)); - - // The check does not apply where no credential is attached at all, - // whether because the registration opted out or because the gateway - // has no signing key configured. - validate_expected_audience("content-guard", configured, "urn:example:stale", false) - .expect("an unauthenticated call has no audience to mismatch"); - } - - fn builtin_runner() -> ChainRunner { - ChainRunner::new( - services() - .into_iter() - .next() - .expect("built-in middleware service"), - ) - } - - fn entry(name: &str, on_error: OnError) -> ChainEntry { - ChainEntry { - name: name.into(), - implementation: BUILTIN_REGEX.into(), - order: 0, - config: prost_types::Struct { - fields: std::iter::once(( - "mode".into(), - prost_types::Value { - kind: Some(prost_types::value::Kind::StringValue("redact".into())), - }, - )) - .collect(), - }, - on_error, - } - } - - fn input(body: &str) -> HttpRequestInput { - HttpRequestInput { - request_id: "req".into(), - sandbox_id: "sbx-id".into(), - sandbox_name: "sbx-name".into(), - workspace: "wrks-default".into(), - scheme: "https".into(), - host: "api.example.com".into(), - port: 443, - method: "POST".into(), - path: "/v1".into(), - query: String::new(), - headers: Vec::new(), - connection_nominated_headers: Vec::new(), - body: body.as_bytes().to_vec(), - } - } - - fn write_header(name: &str, value: &str, on_existing: ExistingHeaderAction) -> HeaderMutation { - HeaderMutation { - operation: Some(header_mutation::Operation::Write( - openshell_core::proto::WriteHeader { - name: name.into(), - value: value.into(), - on_existing: on_existing as i32, - }, + fn binding( + operation: SupervisorMiddlewareOperation, + phase: SupervisorMiddlewarePhase, + modes: Vec, + ) -> MiddlewareBinding { + MiddlewareBinding { + operation: operation as i32, + phase: phase as i32, + max_payload_bytes: 1024, + request_timeout: None, + http_protocol_version: u32::from(matches!( + operation, + SupervisorMiddlewareOperation::HttpRequest + | SupervisorMiddlewareOperation::HttpResponse )), + supported_http_body_modes: modes.into_iter().map(i32::from).collect(), } } - /// An in-process service that yields forever so the runtime must enforce - /// the binding timeout around borrowed validation and evaluation futures. - struct PendingInProcessService; - - #[tonic::async_trait] - impl InProcessMiddleware for PendingInProcessService { - async fn describe(&self) -> MiddlewareManifest { - MiddlewareManifest { - name: "test/pending".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, - request_timeout: Some(proto_duration("10ms")), - }], - expected_audience: String::new(), - } - } - - async fn validate_config( - &self, - _middleware_name: &str, - _config: &prost_types::Struct, - ) -> Result<()> { - std::future::pending().await - } - - async fn open_http_request_pre_credentials( - &self, - _requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - std::future::pending().await - } - } - - struct OwnedStreamService { - invalid_finalization: bool, - } - - #[derive(Default)] - struct StreamSequenceRecorder { - streams: std::sync::Mutex>>, - } - - struct StreamSequenceService { - recorder: Arc, - findings_per_body: bool, - } - - #[tonic::async_trait] - impl InProcessMiddleware for StreamSequenceService { - async fn describe(&self) -> MiddlewareManifest { - MiddlewareManifest { - name: "test/stream-sequence".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: MAX_HTTP_REQUEST_STREAM_UNIT_BYTES as u64, - request_timeout: None, - }], - expected_audience: String::new(), - } - } - - async fn validate_config( - &self, - _middleware_name: &str, - _config: &prost_types::Struct, - ) -> Result<()> { - Ok(()) - } - - async fn open_http_request_pre_credentials( - &self, - mut requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - use openshell_core::proto::{ - HttpRequestBodyMode, HttpRequestBodyPassThrough, HttpRequestBodyResult, - HttpRequestEventResult, HttpRequestPreflightInspect, HttpRequestPreflightResult, - HttpRequestTrailersResult, http_request_body_result, http_request_body_unit, - http_request_event, http_request_event_result, http_request_preflight_result, - }; - - let stream_index = { - let mut streams = self.recorder.streams.lock().expect("stream recorder lock"); - streams.push(Vec::new()); - streams.len() - 1 - }; - let recorder = Arc::clone(&self.recorder); - let findings_per_body = self.findings_per_body; - let (sender, receiver) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - while let Some(event) = requests.recv().await { - let result = match event.event { - Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::StreamBytes as i32, - header_mutations: Vec::new(), - }, - )), - ..Default::default() - }, - )), - }, - Some(http_request_event::Event::Body(body)) => { - let size = match body.payload { - Some(http_request_body_unit::Payload::Data(ref data)) => data.len(), - None => break, - }; - recorder.streams.lock().expect("stream recorder lock")[stream_index] - .push((size, body.end_of_stream)); - HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyResult( - HttpRequestBodyResult { - sequence: body.sequence, - action: Some( - http_request_body_result::Action::PassThrough( - HttpRequestBodyPassThrough {}, - ), - ), - findings: findings_per_body - .then(|| Finding { - r#type: "test.stream".into(), - label: "Stream finding".into(), - count: 1, - confidence: "high".into(), - severity: "informational".into(), - }) - .into_iter() - .collect(), - ..Default::default() - }, - )), - } - } - Some(http_request_event::Event::Trailers(_)) => HttpRequestEventResult { - result: Some(http_request_event_result::Result::TrailersResult( - HttpRequestTrailersResult::default(), - )), - }, - Some(http_request_event::Event::SessionEnd(_)) | None => break, - }; - if sender.send(Ok(result)).await.is_err() { - break; - } - } - }); - Ok(Box::pin(ReceiverStream::new(receiver))) - } - } - - struct CancellationRecordingService { - terminal: std::sync::Mutex< - Option>, - >, - } - - struct BodyDenialService { - manifest_name: String, - deny: bool, - session_ends: tokio::sync::mpsc::UnboundedSender<( - String, - openshell_core::proto::MiddlewareSessionEndReason, - )>, - } - - #[tonic::async_trait] - impl InProcessMiddleware for BodyDenialService { - async fn describe(&self) -> MiddlewareManifest { - MiddlewareManifest { - name: self.manifest_name.clone(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: MAX_HTTP_REQUEST_STREAM_UNIT_BYTES as u64, - request_timeout: None, - }], - expected_audience: String::new(), - } - } - - async fn validate_config( - &self, - _middleware_name: &str, - _config: &prost_types::Struct, - ) -> Result<()> { - Ok(()) - } - - async fn open_http_request_pre_credentials( - &self, - mut requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - use openshell_core::proto::{ - HttpRequestBlock, HttpRequestBodyMode, HttpRequestBodyPassThrough, - HttpRequestBodyResult, HttpRequestEventResult, HttpRequestPreflightInspect, - HttpRequestPreflightResult, http_request_body_result, http_request_event, - http_request_event_result, http_request_preflight_result, - }; - - let name = self.manifest_name.clone(); - let deny = self.deny; - let session_ends = self.session_ends.clone(); - let (sender, receiver) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - while let Some(event) = requests.recv().await { - let result = match event.event { - Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::StreamBytes as i32, - header_mutations: Vec::new(), - }, - )), - ..Default::default() - }, - )), - }, - Some(http_request_event::Event::Body(body)) => HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyResult( - HttpRequestBodyResult { - sequence: body.sequence, - action: Some(if deny { - http_request_body_result::Action::BlockRequest( - HttpRequestBlock {}, - ) - } else { - http_request_body_result::Action::PassThrough( - HttpRequestBodyPassThrough {}, - ) - }), - reason_code: if deny { - "body_denied".into() - } else { - String::new() - }, - findings: deny - .then(|| Finding { - r#type: "test.request-body".into(), - label: "Request body denied".into(), - count: 1, - confidence: "high".into(), - severity: "medium".into(), - }) - .into_iter() - .collect(), - metadata: deny - .then(|| ("rule".into(), "deny-body".into())) - .into_iter() - .collect(), - ..Default::default() - }, - )), - }, - Some(http_request_event::Event::SessionEnd(end)) => { - if let Ok(reason) = - openshell_core::proto::MiddlewareSessionEndReason::try_from( - end.reason, - ) - { - let _ = session_ends.send((name.clone(), reason)); - } - break; - } - Some(http_request_event::Event::Trailers(_)) | None => break, - }; - if sender.send(Ok(result)).await.is_err() { - break; - } - } - }); - Ok(Box::pin(ReceiverStream::new(receiver))) + fn manifest(binding: MiddlewareBinding) -> MiddlewareManifest { + MiddlewareManifest { + name: "test/middleware".into(), + service_version: "1".into(), + bindings: vec![binding], + expected_audience: String::new(), } } - #[tonic::async_trait] - impl InProcessMiddleware for CancellationRecordingService { - async fn describe(&self) -> MiddlewareManifest { - MiddlewareManifest { - name: "test/cancellation-recorder".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: MAX_HTTP_REQUEST_STREAM_UNIT_BYTES as u64, - request_timeout: None, - }], - expected_audience: String::new(), - } - } - - async fn validate_config( - &self, - _middleware_name: &str, - _config: &prost_types::Struct, - ) -> Result<()> { - Ok(()) - } - - async fn open_http_request_pre_credentials( - &self, - mut requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - use openshell_core::proto::{ - HttpRequestBodyMode, HttpRequestEventResult, HttpRequestPreflightInspect, - HttpRequestPreflightResult, http_request_event, http_request_event_result, - http_request_preflight_result, + #[test] + fn accepts_two_mode_http_capabilities() { + for operation in [ + SupervisorMiddlewareOperation::HttpRequest, + SupervisorMiddlewareOperation::HttpResponse, + ] { + let phase = if operation == SupervisorMiddlewareOperation::HttpRequest { + SupervisorMiddlewarePhase::PreCredentials + } else { + SupervisorMiddlewarePhase::PreReturn }; - - let terminal = self.terminal.lock().expect("terminal sender lock").take(); - let (sender, receiver) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - let Some(openshell_core::proto::HttpRequestEvent { - event: Some(http_request_event::Event::Preflight(_)), - }) = requests.recv().await - else { - return; - }; - if sender - .send(Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::StreamBytes as i32, - header_mutations: Vec::new(), - }, - )), - ..Default::default() - }, - )), - })) - .await - .is_err() - { - return; - } - while let Some(event) = requests.recv().await { - if let Some(http_request_event::Event::SessionEnd(end)) = event.event { - if let (Some(terminal), Ok(reason)) = ( - terminal, - openshell_core::proto::MiddlewareSessionEndReason::try_from(end.reason), - ) { - let _ = terminal.send(reason); - } - break; - } - } - }); - Ok(Box::pin(ReceiverStream::new(receiver))) + validate_manifest_bindings( + "test service", + &manifest(binding( + operation, + phase, + vec![HttpBodyMode::Buffered, HttpBodyMode::Stream], + )), + None, + ) + .expect("valid HTTP capability"); } } - #[tonic::async_trait] - impl InProcessMiddleware for OwnedStreamService { - async fn describe(&self) -> MiddlewareManifest { - MiddlewareManifest { - name: "test/owned-stream".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: MAX_HTTP_REQUEST_STREAM_UNIT_BYTES as u64, - request_timeout: None, - }], - expected_audience: String::new(), - } - } - - async fn validate_config( - &self, - _middleware_name: &str, - _config: &prost_types::Struct, - ) -> Result<()> { - Ok(()) - } - - async fn open_http_request_pre_credentials( - &self, - mut requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - use openshell_core::proto::{ - HttpRequestBodyFinalize, HttpRequestBodyMode, HttpRequestBodyOutput, - HttpRequestBodyResult, HttpRequestBodyTakeOwnership, HttpRequestEventResult, - HttpRequestPreflightInspect, HttpRequestPreflightResult, HttpRequestTrailersResult, - http_request_body_result, http_request_event, http_request_event_result, - http_request_preflight_result, - }; - - let invalid_finalization = self.invalid_finalization; - let (sender, receiver) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - while let Some(event) = requests.recv().await { - let result = match event.event { - Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::OwnedStreamBytes as i32, - header_mutations: Vec::new(), - }, - )), - ..Default::default() - }, - )), - }, - Some(http_request_event::Event::Body(body)) => { - let sequence = body.sequence; - let end_of_stream = body.end_of_stream; - let result = HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyResult( - HttpRequestBodyResult { - sequence, - action: Some( - http_request_body_result::Action::TakeOwnership( - HttpRequestBodyTakeOwnership {}, - ), - ), - ..Default::default() - }, - )), - }; - if sender.send(Ok(result)).await.is_err() { - break; - } - if end_of_stream { - if sender - .send(Ok(HttpRequestEventResult { - result: Some( - http_request_event_result::Result::BodyOutput( - HttpRequestBodyOutput { - sequence: 1, - data: b"signed".to_vec(), - }, - ), - ), - })) - .await - .is_err() - { - break; - } - if sender - .send(Ok(HttpRequestEventResult { - result: Some( - http_request_event_result::Result::BodyFinalize( - HttpRequestBodyFinalize { - through_input_sequence: if invalid_finalization - { - sequence + 1 - } else { - sequence - }, - through_output_sequence: 1, - reason_code: "owned_complete".into(), - findings: vec![Finding { - r#type: "test.owned".into(), - label: "Owned transformation complete" - .into(), - count: 1, - confidence: "high".into(), - severity: "informational".into(), - }], - metadata: HashMap::from([( - "mode".into(), - "owned".into(), - )]), - ..Default::default() - }, - ), - ), - })) - .await - .is_err() - { - break; - } - } - continue; - } - Some(http_request_event::Event::Trailers(_)) => HttpRequestEventResult { - result: Some(http_request_event_result::Result::TrailersResult( - HttpRequestTrailersResult::default(), - )), - }, - Some(http_request_event::Event::SessionEnd(_)) | None => break, - }; - if sender.send(Ok(result)).await.is_err() { - break; - } - } - }); - Ok(Box::pin(ReceiverStream::new(receiver))) - } - } + #[test] + fn rejects_missing_version_or_body_modes() { + let mut candidate = binding( + SupervisorMiddlewareOperation::HttpRequest, + SupervisorMiddlewarePhase::PreCredentials, + vec![HttpBodyMode::Buffered], + ); + candidate.http_protocol_version = 0; + assert!( + validate_manifest_bindings("test service", &manifest(candidate), None) + .unwrap_err() + .to_string() + .contains("protocol version 1") + ); - fn owned_entry(on_error: OnError) -> ChainEntry { - ChainEntry { - name: "owned".into(), - implementation: "test/owned-stream".into(), - order: 0, - config: prost_types::Struct::default(), - on_error, - } + let candidate = binding( + SupervisorMiddlewareOperation::HttpRequest, + SupervisorMiddlewarePhase::PreCredentials, + Vec::new(), + ); + assert!( + validate_manifest_bindings("test service", &manifest(candidate), None) + .unwrap_err() + .to_string() + .contains("at least one") + ); } - #[tokio::test] - async fn owned_request_stream_replaces_a_body_larger_than_the_former_unary_limit() { - let runner = ChainRunner::new(Arc::new(OwnedStreamService { - invalid_finalization: false, - })); - let body = "x".repeat(4 * 1024 * 1024 + 17); - let mut preflight = runner - .preflight_http_request( - &[owned_entry(OnError::FailClosed)], - HttpRequestPreflightInput { - context: RequestContext::default(), - target: HttpRequestTarget::default(), - declared_body_length: Some(body.len() as u64), - headers: Vec::new(), - connection_nominated_headers: Vec::new(), - }, - ) - .await - .expect("owned request preflight"); - let mut session = preflight.session.take().expect("owned request session"); - for chunk in body.as_bytes().chunks(session.stream_unit_limit()) { - assert!(session.push_body(chunk.to_vec()).await.unwrap().is_empty()); + #[test] + fn rejects_duplicate_or_unspecified_body_modes() { + for modes in [ + vec![HttpBodyMode::Buffered, HttpBodyMode::Buffered], + vec![HttpBodyMode::Unspecified], + ] { + let candidate = binding( + SupervisorMiddlewareOperation::HttpRequest, + SupervisorMiddlewarePhase::PreCredentials, + modes, + ); + assert!( + validate_manifest_bindings("test service", &manifest(candidate), None) + .unwrap_err() + .to_string() + .contains("invalid or duplicate") + ); } - let finish = session - .finish(Vec::new()) - .await - .expect("owned finalization"); - assert_eq!(finish.body_units.concat(), b"signed"); - assert!(finish.body_transformed); - assert_eq!(finish.findings[0].middleware, "owned"); - assert_eq!(finish.findings[0].finding.r#type, "test.owned"); - assert_eq!(finish.metadata["owned"]["mode"], "owned"); } - #[tokio::test] - async fn owned_request_stream_requires_fail_closed_and_valid_finalization() { - let fail_open_runner = ChainRunner::new(Arc::new(OwnedStreamService { - invalid_finalization: false, - })); - let fail_open = fail_open_runner - .evaluate(&[owned_entry(OnError::FailOpen)], input("original")) - .await - .expect("fail-open evaluation"); - assert!(fail_open.allowed); - assert_eq!(fail_open.body, b"original"); - assert!(fail_open.applied[0].failed); - - let buffered_fail_closed = ChainRunner::new(Arc::new(OwnedStreamService { - invalid_finalization: false, - })) - .evaluate(&[owned_entry(OnError::FailClosed)], input("original")) - .await - .expect("buffered compatibility evaluation"); - assert!(!buffered_fail_closed.allowed); - assert_eq!( - buffered_fail_closed.reason, - "middleware_failed: request_body_mode_not_permitted" + #[test] + fn websocket_binding_cannot_advertise_http_capabilities() { + let mut candidate = binding( + SupervisorMiddlewareOperation::WebsocketMessage, + SupervisorMiddlewarePhase::PreCredentials, + Vec::new(), ); + validate_manifest_bindings("test service", &manifest(candidate.clone()), None) + .expect("plain WebSocket binding"); - let invalid_runner = ChainRunner::new(Arc::new(OwnedStreamService { - invalid_finalization: true, - })); - let mut preflight = invalid_runner - .preflight_http_request( - &[owned_entry(OnError::FailClosed)], - HttpRequestPreflightInput { - context: RequestContext::default(), - target: HttpRequestTarget::default(), - declared_body_length: Some(8), - headers: Vec::new(), - connection_nominated_headers: Vec::new(), - }, - ) - .await - .expect("invalid finalization preflight"); - let mut session = preflight.session.take().expect("owned request session"); + candidate.http_protocol_version = 1; + candidate.supported_http_body_modes = vec![HttpBodyMode::Buffered as i32]; assert!( - session - .push_body(b"original".to_vec()) - .await - .unwrap() - .is_empty() - ); - let invalid = session.finish(Vec::new()).await.unwrap_err(); - assert_eq!( - invalid.reason, - "middleware_failed: invalid_owned_finalization" + validate_manifest_bindings("test service", &manifest(candidate), None) + .unwrap_err() + .to_string() + .contains("WebSocket binding") ); - assert!(invalid.denial.is_none()); } - #[tokio::test] - async fn request_stream_stages_receive_one_empty_final_unit_only() { - let recorder = Arc::new(StreamSequenceRecorder::default()); - let runner = ChainRunner::new(Arc::new(StreamSequenceService { - recorder: Arc::clone(&recorder), - findings_per_body: false, - })); - let entries = [ - ChainEntry { - name: "first".into(), - implementation: "test/stream-sequence".into(), - order: 1, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ChainEntry { - name: "second".into(), - implementation: "test/stream-sequence".into(), - order: 2, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ]; - let preflight = runner - .preflight_http_request( - &entries, - HttpRequestPreflightInput { - context: RequestContext::default(), - target: HttpRequestTarget::default(), - declared_body_length: Some(3), - headers: Vec::new(), - connection_nominated_headers: Vec::new(), - }, - ) - .await - .expect("request preflight"); - let mut session = preflight.session.expect("streaming request session"); - assert_eq!( - session.push_body(b"abc".to_vec()).await.unwrap(), - vec![b"abc".to_vec()] + #[test] + fn rejects_removed_post_credentials_phase_number() { + let mut candidate = binding( + SupervisorMiddlewareOperation::HttpRequest, + SupervisorMiddlewarePhase::PreCredentials, + vec![HttpBodyMode::Buffered], ); - let finish = session.finish(Vec::new()).await.expect("request finish"); - assert!(finish.body_units.is_empty()); - - let streams = recorder.streams.lock().expect("stream recorder lock"); - assert_eq!( - streams.as_slice(), - [vec![(3, false), (0, true)], vec![(3, false), (0, true)]] + candidate.phase = 3; + assert!( + validate_manifest_bindings("test service", &manifest(candidate), None) + .unwrap_err() + .to_string() + .contains("unsupported") ); } - #[tokio::test] - async fn request_stream_bounds_findings_across_body_results() { - let runner = ChainRunner::new(Arc::new(StreamSequenceService { - recorder: Arc::new(StreamSequenceRecorder::default()), - findings_per_body: true, - })); - let entry = ChainEntry { - name: "finding-stream".into(), - implementation: "test/stream-sequence".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - let preflight = runner - .preflight_http_request( - &[entry], - HttpRequestPreflightInput { - context: RequestContext::default(), - target: HttpRequestTarget::default(), - declared_body_length: Some(33), - headers: Vec::new(), - connection_nominated_headers: Vec::new(), - }, - ) - .await - .expect("request preflight"); - let mut session = preflight.session.expect("streaming request session"); - for _ in 0..MAX_MIDDLEWARE_FINDINGS_PER_STAGE { - assert_eq!( - session.push_body(vec![b'x']).await.unwrap(), - vec![vec![b'x']] - ); - } - let failure = session - .push_body(vec![b'x']) - .await - .expect_err("the aggregate finding limit must fail closed"); - - assert_eq!( - failure.reason, - "middleware_failed: request_findings_over_capacity" - ); + #[test] + fn external_body_phase_failures_use_platform_owned_diagnostics() { + let status = tonic::Status::internal("operator detail with request content"); assert_eq!( - failure.diagnostics.findings.len(), - MAX_MIDDLEWARE_FINDINGS_PER_STAGE + MiddlewareDiagnosticPolicy::Normalize.error_reason(&status), + "external_service_error" ); - } - #[tokio::test] - async fn request_stream_bounds_retained_invocation_records() { - let runner = ChainRunner::new(Arc::new(StreamSequenceService { - recorder: Arc::new(StreamSequenceRecorder::default()), - findings_per_body: false, - })); - let entry = ChainEntry { - name: "long-stream".into(), - implementation: "test/stream-sequence".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, + let mutation = headers::HeaderMutationError::Protected { + name: "set-cookie".into(), }; - let preflight = runner - .preflight_http_request( - &[entry], - HttpRequestPreflightInput { - context: RequestContext::default(), - target: HttpRequestTarget::default(), - declared_body_length: Some(1025), - headers: Vec::new(), - connection_nominated_headers: Vec::new(), - }, - ) - .await - .expect("request preflight"); - let mut session = preflight.session.expect("streaming request session"); - for _ in 0..1025 { - assert_eq!( - session.push_body(vec![b'x']).await.unwrap(), - vec![vec![b'x']] - ); - } - let finish = session.finish(Vec::new()).await.expect("request finish"); - - assert_eq!(finish.invocations.len(), 1024); - assert!(!finish.invocations[0].failed); - assert!(finish.invocations[0].input_size > 1); - } - - #[tokio::test] - async fn dropped_request_session_sends_best_effort_cancellation() { - let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel(); - let runner = ChainRunner::new(Arc::new(CancellationRecordingService { - terminal: std::sync::Mutex::new(Some(terminal_tx)), - })); - let entry = ChainEntry { - name: "recorder".into(), - implementation: "test/cancellation-recorder".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - let preflight = runner - .preflight_http_request( - &[entry], - HttpRequestPreflightInput { - context: RequestContext::default(), - target: HttpRequestTarget::default(), - declared_body_length: Some(1), - headers: Vec::new(), - connection_nominated_headers: Vec::new(), - }, - ) - .await - .expect("request preflight"); - - drop(preflight.session.expect("streaming request session")); - let reason = tokio::time::timeout(Duration::from_secs(1), terminal_rx) - .await - .expect("cancellation delivery timeout") - .expect("cancellation sender dropped"); - assert_eq!( - reason, - openshell_core::proto::MiddlewareSessionEndReason::Cancellation - ); - } - - #[tokio::test] - async fn body_denial_preserves_diagnostics_and_ends_every_open_stage() { - use openshell_core::proto::MiddlewareSessionEndReason; - - let (session_ends_tx, mut session_ends_rx) = tokio::sync::mpsc::unbounded_channel(); - let endpoints: Vec> = vec![ - Arc::new(BodyDenialService { - manifest_name: "test/body-denier".into(), - deny: true, - session_ends: session_ends_tx.clone(), - }), - Arc::new(BodyDenialService { - manifest_name: "test/body-observer".into(), - deny: false, - session_ends: session_ends_tx, - }), - ]; - let runner = ChainRunner::from_registry( - MiddlewareRegistry::connect_services(endpoints, Vec::new()) - .await - .expect("connect body middleware services"), - ); - let entries = [ - ChainEntry { - name: "denier".into(), - implementation: "test/body-denier".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ChainEntry { - name: "observer".into(), - implementation: "test/body-observer".into(), - order: 1, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ]; - let preflight = runner - .preflight_http_request( - &entries, - HttpRequestPreflightInput { - context: RequestContext::default(), - target: HttpRequestTarget::default(), - declared_body_length: Some(7), - headers: Vec::new(), - connection_nominated_headers: Vec::new(), - }, - ) - .await - .expect("request preflight"); - let mut session = preflight.session.expect("streaming request session"); - let failure = session - .push_body(b"blocked".to_vec()) - .await - .expect_err("body denial must stop the request"); - - assert_eq!(failure.reason, "middleware_denied:denier:body_denied"); - assert_eq!( - failure - .denial - .as_ref() - .map(|denial| denial.config_name.as_str()), - Some("denier") - ); - assert_eq!(failure.diagnostics.invocations.len(), 1); - assert_eq!( - failure.diagnostics.invocations[0].outcome, - HttpRequestInvocationOutcome::BlockRequest - ); - assert_eq!(failure.diagnostics.findings.len(), 1); - assert_eq!(failure.diagnostics.findings[0].middleware, "denier"); - assert_eq!( - failure.diagnostics.findings[0].finding.r#type, - "test.request-body" - ); - assert_eq!(failure.diagnostics.metadata["denier"]["rule"], "deny-body"); - - let mut ended = BTreeMap::new(); - for _ in 0..2 { - let (name, reason) = - tokio::time::timeout(Duration::from_secs(1), session_ends_rx.recv()) - .await - .expect("session end delivery timeout") - .expect("session end sender dropped"); - ended.insert(name, reason); - } - assert_eq!( - ended.get("test/body-denier"), - Some(&MiddlewareSessionEndReason::MiddlewareDenial) - ); - assert_eq!( - ended.get("test/body-observer"), - Some(&MiddlewareSessionEndReason::MiddlewareDenial) - ); - assert!(session_ends_rx.try_recv().is_err()); - } - - #[tokio::test] - async fn in_process_evaluation_remains_interruptible_by_stage_timeout() { - let runner = ChainRunner::new(Arc::new(PendingInProcessService)); - let entry = |on_error| ChainEntry { - name: "pending".into(), - implementation: "test/pending".into(), - order: 0, - config: prost_types::Struct::default(), - on_error, - }; - let closed = runner - .evaluate(&[entry(OnError::FailClosed)], input("payload")) - .await - .expect("timed-out in-process evaluation"); - let open = runner - .evaluate(&[entry(OnError::FailOpen)], input("payload")) - .await - .expect("fail-open timed-out in-process evaluation"); - - assert!(!closed.allowed); - assert_eq!(closed.reason, "middleware_failed: middleware_timeout"); - assert!(closed.applied[0].failed); - assert!(open.allowed); - assert!(open.applied[0].failed); - } - - #[tokio::test] - async fn in_process_validation_remains_interruptible_by_binding_timeout() { - let runner = ChainRunner::new(Arc::new(PendingInProcessService)); - let error = runner - .validate_config("test/pending", prost_types::Struct::default()) - .await - .expect_err("timed-out in-process validation"); - - assert!(error.to_string().contains("ValidateConfig failed")); - assert!(error.to_string().contains("timed out")); - } - - #[tokio::test] - async fn applies_fixed_regex_replacements() { - let outcome = builtin_runner() - .evaluate( - &[entry("redact", OnError::FailClosed)], - input(r#"{"api_key":"sk-1234567890abcdef"}"#), - ) - .await - .expect("evaluate"); - assert!(outcome.allowed); - assert_eq!( - String::from_utf8(outcome.body).expect("utf8"), - r#"{"api_key":"[REDACTED]"}"# - ); - assert_eq!(outcome.findings[0].finding.count, 1); - } - - #[tokio::test] - async fn transformed_body_feeds_next_stage() { - let entries = [ - entry("first", OnError::FailClosed), - entry("second", OnError::FailClosed), - ]; - let outcome = builtin_runner() - .evaluate(&entries, input(r#"token="sk-ABCDEFGHIJKLMNOP""#)) - .await - .expect("evaluate"); - assert!(outcome.allowed); - assert_eq!( - String::from_utf8(outcome.body).expect("utf8"), - r#"token="[REDACTED]""# - ); - assert_eq!(outcome.applied.len(), 2); - assert_eq!( - [ - outcome.applied[0].transformed, - outcome.applied[1].transformed, - ], - [true, false] - ); - } - - #[tokio::test] - async fn describe_chain_sorts_by_order_then_name() { - let mut later = entry("later", OnError::FailClosed); - later.order = 20; - let mut beta = entry("beta", OnError::FailClosed); - beta.order = 10; - let mut alpha = entry("alpha", OnError::FailClosed); - alpha.order = 10; - - let described = builtin_runner() - .describe_chain(&[later, beta, alpha]) - .await - .expect("describe ordered chain"); - let names: Vec<_> = described - .iter() - .map(|entry| entry.entry.name.as_str()) - .collect(); - assert_eq!(names, vec!["alpha", "beta", "later"]); - } - - #[tokio::test] - async fn describe_chain_accepts_maximum_selected_stages() { - let entries: Vec<_> = (0..MAX_MIDDLEWARE_CHAIN_STAGES) - .map(|index| entry(&format!("stage-{index}"), OnError::FailClosed)) - .collect(); - - let described = builtin_runner() - .describe_chain(&entries) - .await - .expect("maximum selected stage count"); - assert_eq!(described.len(), MAX_MIDDLEWARE_CHAIN_STAGES); - } - - #[tokio::test] - async fn describe_chain_rejects_selected_stages_over_capacity() { - let entries: Vec<_> = (0..=MAX_MIDDLEWARE_CHAIN_STAGES) - .map(|index| entry(&format!("stage-{index}"), OnError::FailClosed)) - .collect(); - - let error = builtin_runner() - .describe_chain(&entries) - .await - .err() - .expect("selected stage count over capacity"); - assert!( - error - .to_string() - .contains("selected middleware stage count 11 exceeds platform maximum 10") - ); - } - - #[tokio::test] - async fn fail_open_allows_unavailable_middleware() { - let unavailable = ChainEntry { - name: "missing".into(), - implementation: "third-party/missing".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailOpen, - }; - let outcome = builtin_runner() - .evaluate(&[unavailable], input("hello")) - .await - .expect("evaluate"); - assert!(outcome.allowed); - assert_eq!(outcome.body, b"hello"); - } - - #[tokio::test] - async fn fail_closed_denies_unavailable_middleware() { - let unavailable = ChainEntry { - name: "missing".into(), - implementation: "third-party/missing".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - let outcome = builtin_runner() - .evaluate(&[unavailable], input("hello")) - .await - .expect("evaluate"); - assert!(!outcome.allowed); - assert!(outcome.reason.starts_with("middleware_failed:")); - } - - #[tokio::test] - async fn injected_service_names_drive_registration_checks() { - let registry = MiddlewareRegistry::connect_services(services(), Vec::new()) - .await - .expect("connect built-in service"); - let policy = SandboxPolicy { - network_middlewares: HashMap::from([( - "redactor".into(), - NetworkMiddlewareConfig { - middleware: BUILTIN_REGEX.into(), - ..Default::default() - }, - )]), - ..Default::default() - }; - registry - .ensure_policy_middlewares_registered(&policy) - .expect("described middleware is registered"); - } - - #[tokio::test] - async fn injected_services_cannot_duplicate_middleware_names() { - let first: Arc = Arc::new(PendingInProcessService); - let second: Arc = Arc::new(PendingInProcessService); - - let error = MiddlewareRegistry::connect_services(vec![first, second], Vec::new()) - .await - .expect_err("duplicate injected middleware name must fail registry construction"); - assert!( - error - .to_string() - .contains("duplicate supervisor middleware name") - ); - } - - /// A mock middleware that returns a fixed, caller-supplied result for every - /// evaluation. Used to exercise chain behavior the built-in cannot produce - /// (explicit deny, metadata, findings, unsafe header mutations). - #[derive(Clone)] - struct ScriptedService { - manifest_name: String, - max_body_bytes: u64, - result: TestRequestResult, - } - - #[tonic::async_trait] - impl SupervisorMiddlewareEndpoint for ScriptedService { - async fn describe( - &self, - _request: Request<()>, - ) -> std::result::Result, tonic::Status> { - Ok(tonic::Response::new(MiddlewareManifest { - name: self.manifest_name.clone(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: self.max_body_bytes, - request_timeout: None, - }], - expected_audience: String::new(), - })) - } - - async fn validate_config( - &self, - _request: Request, - ) -> std::result::Result, tonic::Status> { - Ok(tonic::Response::new(ValidateConfigResponse { - valid: true, - reason: String::new(), - })) - } - - async fn open_http_request_pre_credentials( - &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - let result = self.result.clone(); - Ok(open_test_request_stream(requests, move |_| { - constant_request_plan(result) - })) - } - } - - #[tonic::async_trait] - impl SupervisorMiddleware for ScriptedService { - type EvaluateWebSocketSessionStream = WebSocketResponseStream; - - async fn describe( - &self, - request: Request<()>, - ) -> std::result::Result, tonic::Status> { - SupervisorMiddlewareEndpoint::describe(self, request).await - } - - async fn validate_config( - &self, - request: Request, - ) -> std::result::Result, tonic::Status> { - SupervisorMiddlewareEndpoint::validate_config(self, request).await - } - - async fn evaluate_web_socket_session( - &self, - _request: Request>, - ) -> std::result::Result, tonic::Status> - { - Err(tonic::Status::unimplemented("HTTP-only test middleware")) - } - } - - #[tonic::async_trait] - impl HttpRequestPreCredentials for ScriptedService { - type EvaluateStream = HttpRequestResultStream; - - async fn evaluate( - &self, - request: Request>, - ) -> std::result::Result, tonic::Status> { - let (sender, receiver) = tokio::sync::mpsc::channel(4); - let mut requests = request.into_inner(); - tokio::spawn(async move { - while let Some(request) = requests.next().await { - let Ok(request) = request else { - break; - }; - if sender.send(request).await.is_err() { - break; - } - } - }); - let result = self.result.clone(); - Ok(tonic::Response::new(open_test_request_stream( - receiver, - move |_| constant_request_plan(result), - ))) - } - } - - struct SlowService { - delay: Duration, - binding_timeout: Option, - } - - #[tonic::async_trait] - impl SupervisorMiddlewareEndpoint for SlowService { - async fn describe( - &self, - _request: Request<()>, - ) -> std::result::Result, tonic::Status> { - Ok(tonic::Response::new(MiddlewareManifest { - name: "test/slow".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, - request_timeout: self.binding_timeout, - }], - expected_audience: String::new(), - })) - } - - async fn validate_config( - &self, - _request: Request, - ) -> std::result::Result, tonic::Status> { - tokio::time::sleep(self.delay).await; - Ok(tonic::Response::new(ValidateConfigResponse { - valid: true, - reason: String::new(), - })) - } - - async fn open_http_request_pre_credentials( - &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - tokio::time::sleep(self.delay).await; - Ok(open_test_request_stream(requests, |_| { - constant_request_plan(allow_result()) - })) - } - } - - /// A middleware attached twice for exercising per-stage validation. The - /// first policy config requests a body transformation; the second records - /// that it ran and allows. - struct TwoStageService { - second_ran: Arc, - } - - #[tonic::async_trait] - impl SupervisorMiddlewareEndpoint for TwoStageService { - async fn describe( - &self, - _request: Request<()>, - ) -> std::result::Result, tonic::Status> { - Ok(tonic::Response::new(MiddlewareManifest { - name: "test/two-stage".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 256 * 1024, - request_timeout: None, - }], - expected_audience: String::new(), - })) - } - - async fn validate_config( - &self, - _request: Request, - ) -> std::result::Result, tonic::Status> { - Ok(tonic::Response::new(ValidateConfigResponse { - valid: true, - reason: String::new(), - })) - } - - async fn open_http_request_pre_credentials( - &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - let second_ran = Arc::clone(&self.second_ran); - Ok(open_test_request_stream(requests, move |preflight| { - let transform = preflight.config.as_ref().is_some_and(|config| { - config.fields.get("transform").is_some_and(|value| { - matches!( - value.kind.as_ref(), - Some(prost_types::value::Kind::BoolValue(true)) - ) - }) - }); - TestRequestPlan { - preflight: allow_result(), - body: Some(Box::new(move |_body, _evaluation| { - let mut result = allow_result(); - if transform { - result.body = b"TRANSFORMED".to_vec(); - result.has_body = true; - } else { - second_ran.store(true, std::sync::atomic::Ordering::SeqCst); - } - result - })), - } - })) - } - } - - #[tokio::test] - async fn per_stage_validation_denies_before_the_next_stage_runs() { - // The validator rejects the first stage's transformed body. The chain - // must stop there: the second stage never runs, so it never sees a - // payload the policy would reject. - let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let service: Arc = Arc::new(TwoStageService { - second_ran: Arc::clone(&second_ran), - }); - let runner = ChainRunner::new_protobuf_for_tests(service); - let transform = ChainEntry { - name: "transform".into(), - implementation: "test/two-stage".into(), - order: 0, - config: prost_types::Struct { - fields: std::iter::once(( - "transform".into(), - prost_types::Value { - kind: Some(prost_types::value::Kind::BoolValue(true)), - }, - )) - .collect(), - }, - on_error: OnError::FailClosed, - }; - let second = ChainEntry { - name: "second".into(), - implementation: "test/two-stage".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - let described = runner - .describe_chain(&[transform, second]) - .await - .expect("describe two-stage chain"); - - let validator: Box> = Box::new(|body: &[u8]| { - if body == b"TRANSFORMED" { - Ok(Some("transformed body denied by policy".to_string())) - } else { - Ok(None) - } - }); - let outcome = runner - .evaluate_described_with_policy( - &described, - input("original"), - TransformedBodyPolicy::Reevaluate(&*validator), - ) - .await - .expect("evaluate two-stage chain"); - - assert!(!outcome.allowed); - assert_eq!(outcome.reason, "transformed body denied by policy"); - assert_eq!(outcome.applied.len(), 1, "only the first stage should run"); - assert_eq!(outcome.applied[0].name, "transform"); - assert!( - !second_ran.load(std::sync::atomic::Ordering::SeqCst), - "second stage must not run after a policy deny" - ); - } - - #[tokio::test] - async fn per_stage_validator_allows_compliant_transformations() { - // A validator that accepts every body lets both stages run; the second - // stage sees the first stage's output. - let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let service: Arc = Arc::new(TwoStageService { - second_ran: Arc::clone(&second_ran), - }); - let runner = ChainRunner::new_protobuf_for_tests(service); - let transform = ChainEntry { - name: "transform".into(), - implementation: "test/two-stage".into(), - order: 0, - config: prost_types::Struct { - fields: std::iter::once(( - "transform".into(), - prost_types::Value { - kind: Some(prost_types::value::Kind::BoolValue(true)), - }, - )) - .collect(), - }, - on_error: OnError::FailClosed, - }; - let second = ChainEntry { - name: "second".into(), - implementation: "test/two-stage".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - let described = runner - .describe_chain(&[transform, second]) - .await - .expect("describe two-stage chain"); - - let validator: Box> = Box::new(|_body: &[u8]| Ok(None)); - let outcome = runner - .evaluate_described_with_policy( - &described, - input("original"), - TransformedBodyPolicy::Reevaluate(&*validator), - ) - .await - .expect("evaluate two-stage chain"); - - assert!(outcome.allowed); - assert_eq!(outcome.applied.len(), 2); - assert!( - second_ran.load(std::sync::atomic::Ordering::SeqCst), - "second stage should run when the transformation is compliant" - ); - } - - #[tokio::test] - async fn per_stage_validator_error_becomes_structured_denial() { - let second_ran = Arc::new(std::sync::atomic::AtomicBool::new(false)); - let service: Arc = Arc::new(TwoStageService { - second_ran: Arc::clone(&second_ran), - }); - let runner = ChainRunner::new_protobuf_for_tests(service); - let entries = [ - ChainEntry { - name: "transform".into(), - implementation: "test/two-stage".into(), - order: 0, - config: prost_types::Struct { - fields: std::iter::once(( - "transform".into(), - prost_types::Value { - kind: Some(prost_types::value::Kind::BoolValue(true)), - }, - )) - .collect(), - }, - on_error: OnError::FailClosed, - }, - ChainEntry { - name: "second".into(), - implementation: "test/two-stage".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ]; - let described = runner - .describe_chain(&entries) - .await - .expect("describe two-stage chain"); - let validator: Box> = - Box::new(|_body: &[u8]| Err(miette!("OPA engine unavailable"))); - - let outcome = runner - .evaluate_described_with_policy( - &described, - input("original"), - TransformedBodyPolicy::Reevaluate(&*validator), - ) - .await - .expect("policy evaluator failure should be a chain outcome"); - - assert!(!outcome.allowed); - assert!( - outcome - .reason - .starts_with("transformed_body_policy_evaluation_failed:"), - "{}", - outcome.reason - ); - assert_eq!(outcome.applied.len(), 1); - assert!(!second_ran.load(std::sync::atomic::Ordering::SeqCst)); - } - - fn scripted_service(result: TestRequestResult) -> ScriptedService { - ScriptedService { - manifest_name: BUILTIN_REGEX.into(), - max_body_bytes: 256 * 1024, - result, - } - } - - fn allow_result() -> TestRequestResult { - TestRequestResult { - 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(), - } - } - - /// A middleware that records every evaluation it receives and allows the - /// request, for asserting what the supervisor actually sends to services. - struct RecordingService { - validated: Arc>>, - received: Arc>>, - } - - #[tonic::async_trait] - impl SupervisorMiddlewareEndpoint for RecordingService { - async fn describe( - &self, - _request: Request<()>, - ) -> std::result::Result, tonic::Status> { - Ok(tonic::Response::new(MiddlewareManifest { - name: "test/recorder".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, - request_timeout: None, - }], - expected_audience: String::new(), - })) - } - - async fn validate_config( - &self, - request: Request, - ) -> std::result::Result, tonic::Status> { - self.validated - .lock() - .expect("validated config lock") - .push(request.into_inner()); - Ok(tonic::Response::new(ValidateConfigResponse { - valid: true, - reason: String::new(), - })) - } - - async fn open_http_request_pre_credentials( - &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - let received = Arc::clone(&self.received); - Ok(open_test_request_stream(requests, move |_| { - TestRequestPlan { - preflight: allow_result(), - body: Some(Box::new(move |_body, evaluation| { - received.lock().expect("recording lock").push(evaluation); - allow_result() - })), - } - })) - } - } - - /// Three-stage service used to verify that each stage observes the header - /// state produced by all preceding stages. - struct HeaderChainService { - second_action: ExistingHeaderAction, - received: Arc>>, - } - - struct InProcessHeaderChainService { - received: Arc>>>, - } - - #[tonic::async_trait] - impl InProcessMiddleware for InProcessHeaderChainService { - async fn describe(&self) -> MiddlewareManifest { - MiddlewareManifest { - name: "test/in-process-header-chain".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, - request_timeout: None, - }], - expected_audience: String::new(), - } - } - - async fn validate_config( - &self, - _middleware_name: &str, - _config: &prost_types::Struct, - ) -> Result<()> { - Ok(()) - } - - async fn open_http_request_pre_credentials( - &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - let received = Arc::clone(&self.received); - Ok(open_test_request_stream(requests, move |preflight| { - let invocation = { - let mut received = received.lock().expect("in-process header chain lock"); - let invocation = received.len(); - received.push(preflight.headers); - invocation - }; - let mut result = allow_result(); - if invocation == 0 { - result.header_mutations.push(write_header( - "cache-control", - "no-store", - ExistingHeaderAction::Overwrite, - )); - } - TestRequestPlan { - preflight: result, - body: None, - } - })) - } - } - - #[tonic::async_trait] - impl SupervisorMiddlewareEndpoint for HeaderChainService { - async fn describe( - &self, - _request: Request<()>, - ) -> std::result::Result, tonic::Status> { - Ok(tonic::Response::new(MiddlewareManifest { - name: "test/header-chain".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, - request_timeout: None, - }], - expected_audience: String::new(), - })) - } - - async fn validate_config( - &self, - _request: Request, - ) -> std::result::Result, tonic::Status> { - Ok(tonic::Response::new(ValidateConfigResponse { - valid: true, - reason: String::new(), - })) - } - - async fn open_http_request_pre_credentials( - &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - let received = Arc::clone(&self.received); - let second_action = self.second_action; - Ok(open_test_request_stream(requests, move |preflight| { - let evaluation = TestRequestEvaluation { - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - context: preflight.context, - config: preflight.config, - target: preflight.target, - headers: preflight.headers, - body: Vec::new(), - middleware_name: preflight.middleware_name, - }; - let invocation = { - let mut received = received.lock().expect("header chain lock"); - let invocation = received.len(); - received.push(evaluation); - invocation - }; - let mut result = allow_result(); - if invocation == 0 { - result.header_mutations.push(write_header( - "cache-control", - "first", - ExistingHeaderAction::Overwrite, - )); - } else if invocation == 1 { - result.header_mutations.push(write_header( - "cache-control", - "second", - second_action, - )); - } - TestRequestPlan { - preflight: result, - body: None, - } - })) - } - } - - #[tokio::test] - async fn later_middleware_observes_prior_header_mutations() { - for (action, expected) in [ - (ExistingHeaderAction::Append, vec!["first", "second"]), - (ExistingHeaderAction::Overwrite, vec!["second"]), - (ExistingHeaderAction::Skip, vec!["first"]), - ] { - let service = Arc::new(HeaderChainService { - second_action: action, - received: Arc::new(std::sync::Mutex::new(Vec::new())), - }); - let runner = ChainRunner::new_protobuf_for_tests(service.clone()); - let entries = [ - ChainEntry { - name: "first".into(), - implementation: "test/header-chain".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ChainEntry { - name: "second".into(), - implementation: "test/header-chain".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ChainEntry { - name: "observer".into(), - implementation: "test/header-chain".into(), - order: 20, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ]; - - let outcome = runner - .evaluate(&entries, input("payload")) - .await - .expect("evaluate header chain"); - assert!(outcome.allowed); - let received = service.received.lock().expect("recorded header chain"); - let observed: Vec<&str> = received[2] - .headers - .iter() - .filter(|header| header.name == "cache-control") - .map(|header| header.value.as_str()) - .collect(); - assert_eq!(observed, expected, "action {action:?}"); - } - } - - #[tokio::test] - async fn in_process_request_middleware_writes_end_to_end_header_without_namespace() { - let service = Arc::new(InProcessHeaderChainService { - received: Arc::new(std::sync::Mutex::new(Vec::new())), - }); - let runner = ChainRunner::new(service.clone()); - let entries = [ - ChainEntry { - name: "writer".into(), - implementation: "test/in-process-header-chain".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ChainEntry { - name: "observer".into(), - implementation: "test/in-process-header-chain".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ]; - - let outcome = runner - .evaluate(&entries, input("payload")) - .await - .expect("evaluate in-process header chain"); - let received = service - .received - .lock() - .expect("recorded in-process headers"); - - assert!(outcome.allowed); - assert_eq!( - received[1] - .iter() - .filter(|header| header.name == "cache-control") - .map(|header| header.value.as_str()) - .collect::>(), - vec!["no-store"] - ); - } - - #[tokio::test] - async fn repeated_request_headers_reach_middleware_in_wire_order() { - // A map contract would collapse repeated header names to one value - // while the upstream still receives every original value, creating an - // inspection differential. The service must see each entry in wire - // order. - let service = Arc::new(RecordingService { - validated: Arc::new(std::sync::Mutex::new(Vec::new())), - received: Arc::new(std::sync::Mutex::new(Vec::new())), - }); - let recorder: Arc = service.clone(); - let runner = ChainRunner::new_protobuf_for_tests(recorder); - let validation_config = prost_types::Struct { - fields: std::iter::once(( - "required".into(), - prost_types::Value { - kind: Some(prost_types::value::Kind::StringValue("present".into())), - }, - )) - .collect(), - }; - runner - .validate_config("test/recorder", validation_config.clone()) - .await - .expect("validate recorder config"); - let evaluation_config = prost_types::Struct { - fields: std::iter::once(( - "evaluation".into(), - prost_types::Value { - kind: Some(prost_types::value::Kind::StringValue("preserved".into())), - }, - )) - .collect(), - }; - let recorder_entry = ChainEntry { - name: "recorder".into(), - implementation: "test/recorder".into(), - order: 0, - config: evaluation_config.clone(), - on_error: OnError::FailClosed, - }; - let mut request = input("payload"); - request.headers = vec![ - ("x-api-key".into(), "first-value".into()), - ("accept".into(), "application/json".into()), - ("x-api-key".into(), "second-value".into()), - ]; - request.query = "page=2".into(); - let original_body = request.body.as_ptr().addr(); - - let outcome = runner - .evaluate(&[recorder_entry], request) - .await - .expect("evaluate recording chain"); - assert!(outcome.allowed); - - let validated = service.validated.lock().expect("validated configs"); - assert_eq!(validated.len(), 1); - assert_eq!(validated[0].middleware_name, "test/recorder"); - assert_eq!(validated[0].config.as_ref(), Some(&validation_config)); - drop(validated); - - let received = service.received.lock().expect("recorded evaluations"); - assert_eq!(received.len(), 1); - assert_eq!(outcome.body, b"payload"); - assert_ne!(outcome.body.as_ptr().addr(), original_body); - assert_ne!(received[0].body.as_ptr().addr(), original_body); - assert_eq!(received[0].body, b"payload"); - assert_eq!( - received[0].phase, - SupervisorMiddlewarePhase::PreCredentials as i32 - ); - assert_eq!(received[0].middleware_name, "test/recorder"); - assert_eq!(received[0].config.as_ref(), Some(&evaluation_config)); - let context = received[0].context.as_ref().expect("request context"); - assert_eq!(context.request_id, "req"); - assert_eq!(context.sandbox_id, "sbx-id"); - assert_eq!(context.sandbox_name, "sbx-name"); - assert_eq!(context.workspace, "wrks-default"); - assert!(context.originating_process.is_none()); - let target = received[0].target.as_ref().expect("request target"); - assert_eq!(target.scheme, "https"); - assert_eq!(target.host, "api.example.com"); - assert_eq!(target.port, 443); - assert_eq!(target.method, "POST"); - assert_eq!(target.path, "/v1"); - assert_eq!(target.query, "page=2"); - let headers: Vec<(&str, &str)> = received[0] - .headers - .iter() - .map(|header| (header.name.as_str(), header.value.as_str())) - .collect(); - assert_eq!( - headers, - vec![ - ("x-api-key", "first-value"), - ("accept", "application/json"), - ("x-api-key", "second-value"), - ] - ); - } - - fn external_registration(max_payload_bytes: u64) -> SupervisorMiddlewareService { - SupervisorMiddlewareService { - name: "local-guard-service".into(), - grpc_endpoint: "http://127.0.0.1:50051".into(), - max_payload_bytes, - ..Default::default() - } - } - - async fn registry_with_external( - service: Arc, - registration: SupervisorMiddlewareService, - ) -> MiddlewareRegistry { - let builtin_service = services() - .into_iter() - .next() - .expect("built-in middleware service"); - let builtin_manifest = builtin_service.describe().await; - validate_manifest_bindings("test built-in service", &builtin_manifest, None) - .expect("valid built-in manifest"); - let builtin_name = builtin_manifest.name.clone(); - let builtin_manifest_cell = OnceCell::new(); - builtin_manifest_cell - .set(builtin_manifest) - .expect("built-in manifest cache"); - - let manifest = service - .describe(Request::new(())) - .await - .expect("describe test service") - .into_inner(); - let operator_max_payload_bytes = usize::try_from(registration.max_payload_bytes).unwrap(); - let operator_timeout = validate_registration(®istration).expect("valid registration"); - validate_external_manifest(®istration, &manifest, operator_max_payload_bytes, false) - .expect("valid external manifest"); - let manifest_cell = OnceCell::new(); - manifest_cell.set(manifest).expect("manifest cache"); - let registration_name = registration.name.clone(); - MiddlewareRegistry { - services: Arc::new(vec![ - Arc::new(MiddlewareServiceState { - attachment_name: Some(builtin_name.clone()), - service: MiddlewareDispatch::InProcess(builtin_service), - manifest: builtin_manifest_cell, - diagnostic_policy: MiddlewareDiagnosticPolicy::Preserve, - operator_max_payload_bytes: None, - operator_timeout: DEFAULT_MIDDLEWARE_TIMEOUT, - }), - Arc::new(MiddlewareServiceState { - attachment_name: Some(registration_name.clone()), - service: MiddlewareDispatch::Grpc(remote::GrpcMiddlewareService::from_service( - service, - )), - manifest: manifest_cell, - diagnostic_policy: MiddlewareDiagnosticPolicy::Normalize, - operator_max_payload_bytes: Some(operator_max_payload_bytes), - operator_timeout, - }), - ]), - registered_services: Arc::new(vec![RegisteredMiddlewareService { registration }]), - middleware_names: Arc::new(HashSet::from([builtin_name, registration_name])), - work_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_WORK)), - work_admission_waiters: Arc::new(Semaphore::new(MAX_QUEUED_MIDDLEWARE_WORK)), - session_admission: Arc::new(Semaphore::new(MAX_CONCURRENT_MIDDLEWARE_SESSIONS)), - } - } - - #[tokio::test] - async fn describe_chain_marks_resolved_and_unresolved_entries() { - let unresolved = ChainEntry { - name: "missing".into(), - implementation: "third-party/missing".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailOpen, - }; - let described = builtin_runner() - .describe_chain(&[entry("redact", OnError::FailClosed), unresolved]) - .await - .expect("describe chain"); - // The built-in resolves and reports its real limit; the missing binding - // does not resolve and must not contribute a body limit. - assert!(described[0].is_resolved()); - assert_eq!(described[0].max_payload_bytes(), 256 * 1024); - assert!(!described[1].is_resolved()); - } - - #[tokio::test] - async fn descriptors_are_resolved_from_any_middleware_service() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: allow_result(), - })); - let entry = ChainEntry { - name: "external".into(), - implementation: "test/middleware".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - - let described = runner - .describe_chain(std::slice::from_ref(&entry)) - .await - .expect("describe external middleware"); - assert_eq!(described[0].max_payload_bytes(), 4096); - assert_eq!( - described[0] - .binding - .as_ref() - .expect("described binding") - .phase, - SupervisorMiddlewarePhase::PreCredentials as i32 - ); - - let outcome = runner - .evaluate_described(&described, input("hello")) - .await - .expect("evaluate external middleware"); - assert!(outcome.allowed); - } - - #[tokio::test] - async fn mixed_builtin_and_external_chain_uses_operator_limit() { - let external = Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: allow_result(), - }); - let registry = registry_with_external(external, external_registration(1024)).await; - let runner = ChainRunner::from_registry(registry); - let external_entry = ChainEntry { - name: "external".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - let entries = [entry("builtin", OnError::FailClosed), external_entry]; - - let described = runner - .describe_chain(&entries) - .await - .expect("describe chain"); - assert_eq!(described[0].max_payload_bytes(), 256 * 1024); - assert_eq!(described[1].max_payload_bytes(), 1024); - - let outcome = runner - .evaluate_described(&described, input(r#"token="sk-ABCDEFGHIJKLMNOP""#)) - .await - .expect("evaluate mixed chain"); - assert!(outcome.allowed); - assert_eq!(outcome.applied.len(), 2); - assert_eq!( - String::from_utf8(outcome.body).expect("utf8"), - r#"token="[REDACTED]""# - ); - } - - #[tokio::test] - async fn undersized_stage_fails_open_while_later_stage_runs() { - // A body over one stage's limit must fail only that stage through its - // own `on_error`, not the whole chain: the 1 KiB fail-open guard is - // skipped while the 256 KiB fail-closed redactor still runs. - let external = Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: allow_result(), - }); - let registry = registry_with_external(external, external_registration(1024)).await; - let runner = ChainRunner::from_registry(registry); - let guard_entry = ChainEntry { - name: "guard".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailOpen, - }; - let mut redact_entry = entry("redact", OnError::FailClosed); - redact_entry.order = 10; - let entries = [guard_entry, redact_entry]; - - let body = format!("{}token=\"sk-ABCDEFGHIJKLMNOP\"", "x".repeat(1500)); - let outcome = runner - .evaluate(&entries, input(&body)) - .await - .expect("evaluate mixed-limit chain"); - - assert!(outcome.allowed); - assert_eq!(outcome.applied.len(), 2); - assert!( - outcome.applied[0].failed, - "undersized guard must be skipped" - ); - assert_eq!(outcome.applied[0].decision, Decision::Allow); - assert!(!outcome.applied[1].failed); - assert!(outcome.applied[1].transformed); - let body = String::from_utf8(outcome.body).expect("utf8"); - assert!(body.contains("[REDACTED]")); - assert!(!body.contains("sk-ABCDEFGHIJKLMNOP")); - } - - #[tokio::test] - async fn transformed_body_still_over_later_stage_capacity_honors_on_error() { - // Per-stage capacity applies to the current body: the redactor's - // replacement is still over the 1 KiB guard limit, so the fail-closed - // guard denies through its own `on_error` after the redactor ran. - let external = Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: allow_result(), - }); - let registry = registry_with_external(external, external_registration(1024)).await; - let runner = ChainRunner::from_registry(registry); - let guard_entry = ChainEntry { - name: "guard".into(), - implementation: "local-guard-service".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - let entries = [entry("redact", OnError::FailClosed), guard_entry]; - - let body = format!("{}token=\"sk-ABCDEFGHIJKLMNOP\"", "x".repeat(1500)); - let outcome = runner - .evaluate(&entries, input(&body)) - .await - .expect("evaluate mixed-limit chain"); - - assert!(!outcome.allowed); - assert_eq!( - outcome.reason, - "middleware_failed: request_body_mode_not_permitted" - ); - assert_eq!(outcome.applied.len(), 2); - assert!( - outcome.applied[0].transformed, - "redactor ran before the deny" - ); - assert!(outcome.applied[1].failed); - } - - #[test] - fn external_manifest_rejects_operator_limit_above_capability() { - let registration = external_registration(4097); - let manifest = MiddlewareManifest { - name: "example/service".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: HTTP_REQUEST_OPERATION as i32, - phase: PRE_CREDENTIALS_PHASE as i32, - max_payload_bytes: 4096, - request_timeout: None, - }], - expected_audience: String::new(), - }; - let error = validate_external_manifest(®istration, &manifest, 4097, false) - .expect_err("operator limit must fit capability"); - assert!(error.to_string().contains("exceeds")); - } - - #[test] - fn external_registration_rejects_payload_limit_above_platform_maximum() { - let registration = external_registration(u64::MAX); - let error = validate_registration(®istration) - .expect_err("extreme payload limit must be rejected before allocation"); - assert!(error.to_string().contains("platform maximum")); - } - - #[test] - fn manifest_rejects_payload_limit_above_platform_maximum() { - let registration = external_registration(4096); - let manifest = MiddlewareManifest { - name: "example/service".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: HTTP_REQUEST_OPERATION as i32, - phase: PRE_CREDENTIALS_PHASE as i32, - max_payload_bytes: u64::MAX, - request_timeout: None, - }], - expected_audience: String::new(), - }; - let error = validate_external_manifest(®istration, &manifest, 4096, false) - .expect_err("extreme advertised payload limit must be rejected"); - assert!(error.to_string().contains("platform maximum")); - } - - #[test] - fn manifest_rejects_duplicate_operation_phase_pairs() { - let registration = external_registration(4096); - let binding = || MiddlewareBinding { - operation: HTTP_REQUEST_OPERATION as i32, - phase: PRE_CREDENTIALS_PHASE as i32, - max_payload_bytes: 4096, - request_timeout: None, - }; - let manifest = MiddlewareManifest { - name: "example/service".into(), - service_version: "test".into(), - bindings: vec![binding(), binding()], - expected_audience: String::new(), - }; - - let error = validate_external_manifest(®istration, &manifest, 4096, false) - .expect_err("one service cannot advertise two bindings for the same pair"); - assert!( - error - .to_string() - .contains("duplicate middleware operation/phase pair") - ); - } - - #[test] - fn manifest_accepts_http_response_pre_return_binding_when_dispatch_is_available() { - let registration = external_registration(4096); - let manifest = MiddlewareManifest { - name: "example/response".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpResponse as i32, - phase: SupervisorMiddlewarePhase::PreReturn as i32, - max_payload_bytes: 4096, - request_timeout: Some(prost_types::Duration { - seconds: 0, - nanos: 500_000_000, - }), - }], - expected_audience: String::new(), - }; - - validate_external_manifest(®istration, &manifest, 4096, false) - .expect("HTTP response pre-return binding is supported"); - } - - #[test] - fn external_manifest_rejects_post_credentials_binding() { - let registration = external_registration(4096); - let manifest = MiddlewareManifest { - name: "example/credential-visible".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PostCredentials as i32, - max_payload_bytes: 4096, - request_timeout: None, - }], - expected_audience: String::new(), - }; - - let error = validate_external_manifest(®istration, &manifest, 4096, true) - .expect_err("external middleware must never observe resolved credentials"); - assert!( - error - .to_string() - .contains("reserved for trusted in-process") - ); - } - - #[test] - fn manifest_accepts_forward_websocket_binding_and_reserves_return_phase() { - let binding = |phase| MiddlewareBinding { - operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, - phase: phase as i32, - max_payload_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - request_timeout: Some(proto_duration("500ms")), - }; - let mut manifest = MiddlewareManifest { - name: "example/websocket".into(), - service_version: "test".into(), - bindings: vec![binding(SupervisorMiddlewarePhase::PreCredentials)], - expected_audience: String::new(), - }; - validate_manifest_bindings("test WebSocket service", &manifest, None) - .expect("forward WebSocket binding is supported"); - - manifest.bindings = vec![binding(SupervisorMiddlewarePhase::PreReturn)]; - let error = validate_manifest_bindings("test WebSocket service", &manifest, None) - .expect_err("return-path WebSocket binding is not yet supported"); - assert!(error.to_string().contains("not yet supported")); - } - - #[test] - fn external_websocket_binding_requires_operator_payload_limit() { - let registration = external_registration(0); - let manifest = MiddlewareManifest { - name: "example/websocket".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, - request_timeout: None, - }], - expected_audience: String::new(), - }; - - let error = validate_external_manifest(®istration, &manifest, 0, false) - .expect_err("WebSocket bindings require an operator payload ceiling"); - assert!( - error - .to_string() - .contains("must configure max_payload_bytes") - ); - } - - #[test] - fn external_websocket_binding_rejects_operator_limit_above_capability() { - let registration = external_registration(4097); - let manifest = MiddlewareManifest { - name: "example/websocket".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: 4096, - request_timeout: None, - }], - expected_audience: String::new(), - }; - - let error = validate_external_manifest(®istration, &manifest, 4097, false) - .expect_err("operator payload limit must fit WebSocket capability"); - assert!(error.to_string().contains("exceeds")); - } - - #[test] - fn external_registration_accepts_http_and_https_grpc_endpoints() { - for grpc_endpoint in [ - "http://127.0.0.1:50051", - "https://middleware.example.com:443", - ] { - let mut registration = external_registration(4096); - registration.grpc_endpoint = grpc_endpoint.into(); - validate_registration(®istration).expect("supported gRPC endpoint scheme"); - } - } - - #[test] - fn external_registration_rejects_unsupported_grpc_endpoint_scheme() { - let mut registration = external_registration(4096); - registration.grpc_endpoint = "ftp://middleware.example.com".into(); - let error = validate_registration(®istration).expect_err("unsupported scheme"); - assert!(error.to_string().contains("http:// or https://")); - } - - #[test] - fn external_registration_name_is_stable_and_cannot_shadow_builtins() { - for name in ["", "guard\nforged", "openshell/regex"] { - let mut registration = external_registration(4096); - registration.name = name.into(); - assert!( - validate_registration(®istration).is_err(), - "registration name {name:?} must be rejected" - ); - } - } - - #[test] - fn registration_timeout_uses_default_and_operator_override() { - let registration = external_registration(4096); - let timeout = validate_registration(®istration).expect("default timeout"); - assert_eq!(timeout, DEFAULT_MIDDLEWARE_TIMEOUT); - - let mut registration = external_registration(4096); - registration.request_timeout = Some(proto_duration("2s")); - let timeout = validate_registration(®istration).expect("operator timeout"); - assert_eq!(timeout, Duration::from_secs(2)); - } - - #[test] - fn registration_timeout_enforces_bounds() { - for timeout in ["9ms", "31s"] { - let mut registration = external_registration(4096); - registration.request_timeout = Some(proto_duration(timeout)); - assert!(validate_registration(®istration).is_err()); - } - } - - #[test] - fn manifest_binding_timeout_enforces_bounds() { - let registration = external_registration(4096); - for timeout in ["9ms", "31s"] { - let manifest = MiddlewareManifest { - name: "example/service".into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: HTTP_REQUEST_OPERATION as i32, - phase: PRE_CREDENTIALS_PHASE as i32, - max_payload_bytes: 4096, - request_timeout: Some(proto_duration(timeout)), - }], - expected_audience: String::new(), - }; - let error = validate_external_manifest(®istration, &manifest, 4096, false) - .expect_err("out-of-bounds binding timeout must be rejected"); - assert!(error.to_string().contains("invalid timeout")); - } - } - - #[tokio::test] - async fn binding_timeout_override_controls_evaluation_and_on_error() { - let mut registration = external_registration(4096); - registration.request_timeout = Some(proto_duration("2s")); - let registry = registry_with_external( - Arc::new(SlowService { - delay: Duration::from_millis(50), - binding_timeout: Some(proto_duration("10ms")), - }), - registration, - ) - .await; - let runner = ChainRunner::from_registry(registry); - let slow_entry = |on_error| ChainEntry { - name: "slow".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error, - }; - - let described = runner - .describe_chain(&[slow_entry(OnError::FailClosed)]) - .await - .expect("describe slow binding"); - assert_eq!(described[0].timeout(), Duration::from_millis(10)); - - let closed = runner - .evaluate(&[slow_entry(OnError::FailClosed)], input("payload")) - .await - .expect("fail-closed timeout outcome"); - assert!(!closed.allowed); - assert_eq!(closed.reason, "middleware_failed: middleware_timeout"); - - let open = runner - .evaluate(&[slow_entry(OnError::FailOpen)], input("payload")) - .await - .expect("fail-open timeout outcome"); - assert!(open.allowed); - assert!(open.applied[0].failed); - } - - #[tokio::test] - async fn operator_timeout_controls_binding_without_manifest_override() { - let mut registration = external_registration(4096); - registration.request_timeout = Some(proto_duration("10ms")); - let registry = registry_with_external( - Arc::new(SlowService { - delay: Duration::from_millis(50), - binding_timeout: None, - }), - registration, - ) - .await; - let runner = ChainRunner::from_registry(registry); - let slow_entry = ChainEntry { - name: "slow".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - - let described = runner - .describe_chain(std::slice::from_ref(&slow_entry)) - .await - .expect("describe slow binding"); - assert_eq!(described[0].timeout(), Duration::from_millis(10)); - - let outcome = runner - .evaluate(&[slow_entry], input("payload")) - .await - .expect("operator timeout outcome"); - assert!(!outcome.allowed); - assert_eq!(outcome.reason, "middleware_failed: middleware_timeout"); - } - - #[tokio::test] - async fn operator_timeout_caps_longer_binding_timeout_for_validation_and_evaluation() { - let mut registration = external_registration(4096); - registration.request_timeout = Some(proto_duration("10ms")); - let registry = registry_with_external( - Arc::new(SlowService { - delay: Duration::from_millis(50), - binding_timeout: Some(prost_types::Duration { - seconds: 2, - nanos: 0, - }), - }), - registration, - ) - .await; - let runner = ChainRunner::from_registry(registry); - let slow_entry = ChainEntry { - name: "slow".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }; - - let described = runner - .describe_chain(std::slice::from_ref(&slow_entry)) - .await - .expect("describe slow binding"); - assert_eq!(described[0].timeout(), Duration::from_millis(10)); - - let validation_error = runner - .validate_config("local-guard-service", prost_types::Struct::default()) - .await - .expect_err("operator timeout must cap ValidateConfig"); - assert!( - validation_error - .to_string() - .contains("ValidateConfig failed") - ); - assert!(validation_error.to_string().contains("timed out")); - - let outcome = runner - .evaluate(&[slow_entry], input("payload")) - .await - .expect("operator-capped evaluation outcome"); - assert!(!outcome.allowed); - assert_eq!(outcome.reason, "middleware_failed: middleware_timeout"); - } - - #[tokio::test] - async fn external_registry_attaches_same_service_under_multiple_names() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind test middleware"); - let address = listener.local_addr().expect("test middleware address"); - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let service = ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: allow_result(), - }; - let server = tonic::transport::Server::builder() - .add_service(SupervisorMiddlewareServer::new(service.clone())) - .add_service(HttpRequestPreCredentialsServer::new(service)) - .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { - let _ = shutdown_rx.await; - }); - let server_task = tokio::spawn(server); - - let mut registration = external_registration(1024); - registration.grpc_endpoint = format!("http://{address}"); - let mut second_registration = registration.clone(); - second_registration.name = "secondary-guard-service".into(); - let registry = MiddlewareRegistry::connect_services( - Vec::new(), - vec![registration.clone(), second_registration.clone()], - ) - .await - .expect("connect the same external middleware binding under two names"); - let policy = SandboxPolicy { - network_middlewares: HashMap::from([( - "guard".into(), - NetworkMiddlewareConfig { - name: String::new(), - middleware: "local-guard-service".into(), - order: 0, - config: Some(prost_types::Struct::default()), - on_error: "fail_closed".into(), - endpoints: None, - }, - )]), - ..Default::default() - }; - - registry - .validate_policy_configs(&policy) - .await - .expect("remote config validates"); - assert_eq!( - registry.required_services(Some(&policy)), - vec![registration.clone()] - ); - - let outcome = ChainRunner::from_registry(registry) - .evaluate( - &[ - ChainEntry { - name: "primary".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ChainEntry { - name: "secondary".into(), - implementation: "secondary-guard-service".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ], - input("hello"), - ) - .await - .expect("remote evaluation"); - assert!(outcome.allowed); - assert_eq!(outcome.applied.len(), 2); - assert_eq!(outcome.applied[0].implementation, registration.name); - assert_eq!(outcome.applied[1].implementation, second_registration.name); - - let _ = shutdown_tx.send(()); - server_task - .await - .expect("join test middleware") - .expect("serve"); - } - - #[tokio::test] - async fn remote_transport_accepts_maximum_bounded_request_and_response_envelopes() { - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind test middleware"); - let address = listener.local_addr().expect("test middleware address"); - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let response_findings = (0..MAX_MIDDLEWARE_FINDINGS_PER_STAGE) - .map(|_| Finding { - r#type: "f".repeat(1024), - label: "finding".into(), - count: 1, - confidence: "medium".into(), - severity: "medium".into(), - }) - .collect(); - let service = ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - result: TestRequestResult { - reason: "r".repeat(MAX_MIDDLEWARE_REASON_BYTES - 128), - reason_code: "r".repeat(MAX_MIDDLEWARE_REASON_CODE_BYTES), - body: vec![b'x'; MAX_MIDDLEWARE_PAYLOAD_BYTES], - has_body: true, - header_mutations: vec![write_header( - "x-openshell-middleware-envelope", - &"h".repeat(headers::MAX_HEADER_MUTATION_BYTES - 128), - ExistingHeaderAction::Append, - )], - findings: response_findings, - metadata: std::iter::once(( - "diagnostic".into(), - "m".repeat(MAX_MIDDLEWARE_METADATA_BYTES - 128), - )) - .collect(), - ..allow_result() - }, - }; - let server = tonic::transport::Server::builder() - .add_service( - SupervisorMiddlewareServer::new(service.clone()) - .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) - .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), - ) - .add_service( - HttpRequestPreCredentialsServer::new(service) - .max_decoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES) - .max_encoding_message_size(MIDDLEWARE_GRPC_MESSAGE_BYTES), - ) - .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { - let _ = shutdown_rx.await; - }); - let server_task = tokio::spawn(server); - - let mut registration = external_registration(MAX_MIDDLEWARE_PAYLOAD_BYTES as u64); - registration.grpc_endpoint = format!("http://{address}"); - let registry = MiddlewareRegistry::connect_services(Vec::new(), vec![registration]) - .await - .expect("connect external middleware"); - let config = prost_types::Struct { - fields: std::iter::once(( - "payload".into(), - prost_types::Value { - kind: Some(prost_types::value::Kind::StringValue( - "c".repeat(MAX_MIDDLEWARE_CONFIG_BYTES - 256), - )), - }, - )) - .collect(), - }; - assert!(config.encoded_len() <= MAX_MIDDLEWARE_CONFIG_BYTES); - let mut request = input(""); - request.request_id = "r".repeat(MAX_MIDDLEWARE_CONTEXT_BYTES - 256); - request.path = format!("/{}", "p".repeat(MAX_MIDDLEWARE_TARGET_BYTES - 512)); - request.headers = vec![( - "x-large-envelope".into(), - "v".repeat(MAX_MIDDLEWARE_HEADER_BYTES - 256), - )]; - request.body = vec![b'b'; MAX_MIDDLEWARE_PAYLOAD_BYTES]; - let outcome = ChainRunner::from_registry(registry) - .evaluate( - &[ChainEntry { - name: "guard".into(), - implementation: "local-guard-service".into(), - order: 0, - config, - on_error: OnError::FailClosed, - }], - request, - ) - .await - .expect("maximum bounded envelopes should fit configured transport limit"); - - assert!(outcome.allowed); - assert_eq!(outcome.body.len(), MAX_MIDDLEWARE_PAYLOAD_BYTES); - assert_eq!(outcome.header_mutations.len(), 1); - assert_eq!(outcome.findings.len(), MAX_MIDDLEWARE_FINDINGS_PER_STAGE); - let _ = shutdown_tx.send(()); - server_task - .await - .expect("join test middleware") - .expect("serve"); - } - - #[test] - fn grpc_envelope_headroom_matches_bounded_components() { - assert_eq!(MIDDLEWARE_GRPC_ENVELOPE_BYTES, 292 * 1024 + 64); - assert_eq!( - MIDDLEWARE_GRPC_MESSAGE_BYTES, - MAX_MIDDLEWARE_PAYLOAD_BYTES + 292 * 1024 + 64 - ); - } - - #[tokio::test] - async fn external_diagnostics_are_normalized_before_reaching_logs() { - let secret = "sk-secret-request-value"; - let registration = external_registration(4096); - let service = Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: TestRequestResult { - decision: Decision::Deny as i32, - reason: format!("denied body={secret}\nFINDING:FORGED"), - reason_code: "content_match".into(), - findings: vec![Finding { - r#type: format!("secret.{secret}\nforged"), - label: format!("matched {secret}\nFINDING:FORGED"), - count: 1, - confidence: secret.into(), - severity: "high\nFINDING:FORGED".into(), - }], - metadata: std::iter::once(("request".into(), secret.into())).collect(), - ..allow_result() - }, - }); - let registry = registry_with_external(service, registration).await; - let outcome = ChainRunner::from_registry(registry) - .evaluate( - &[ChainEntry { - name: "guard".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }], - input("hello"), - ) - .await - .expect("evaluate external middleware"); - - assert_eq!(outcome.reason, "middleware_denied:guard:content_match"); - assert_eq!( - outcome.denial, - Some(MiddlewareDenial { - config_name: "guard".into(), - reason_code: Some("content_match".into()), - }) - ); - assert_eq!( - outcome.findings[0].finding.r#type, - "local-guard-service.finding" - ); - assert_eq!(outcome.findings[0].finding.label, EXTERNAL_FINDING_LABEL); - assert_eq!(outcome.findings[0].finding.severity, "medium"); - assert!(outcome.metadata.is_empty()); - assert!(!format!("{outcome:?}").contains(secret)); - assert!(!format!("{outcome:?}").contains("FINDING:FORGED")); - } - - #[tokio::test] - async fn invalid_reason_code_is_a_middleware_failure() { - let runner = - ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { - decision: Decision::Deny as i32, - reason_code: "Secret value!".into(), - ..allow_result() - }))); - let outcome = runner - .evaluate( - &[entry("content-guard", OnError::FailClosed)], - input("hello"), - ) - .await - .expect("evaluate invalid reason code"); - - assert!(!outcome.allowed); - assert_eq!( - outcome.reason, - "middleware_failed: request_reason_code_invalid" - ); - assert!(outcome.denial.is_none()); - assert!(outcome.applied[0].failed); - } - - #[tokio::test] - async fn external_header_mutation_failure_uses_platform_reason() { - let secret = "sk-secret-request-value"; - let registration = external_registration(4096); - let service = Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: TestRequestResult { - header_mutations: vec![write_header( - &format!("x-openshell-middleware-invalid\n{secret}"), - "value", - ExistingHeaderAction::Append, - )], - ..allow_result() - }, - }); - let registry = registry_with_external(service, registration).await; - let outcome = ChainRunner::from_registry(registry) - .evaluate( - &[ChainEntry { - name: "guard".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }], - input("hello"), - ) - .await - .expect("evaluate external middleware"); - - assert!(!outcome.allowed); - assert_eq!( - outcome.reason, - "middleware_failed: header_mutation_invalid_name" - ); - assert!(outcome.findings.is_empty()); - assert!(!format!("{outcome:?}").contains(secret)); - } - - #[tokio::test] - async fn credential_placeholder_header_mutation_follows_on_error() { - let placeholder = "openshell:resolve:env:API_KEY"; - let service = Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: TestRequestResult { - header_mutations: vec![write_header( - "x-api-key", - placeholder, - ExistingHeaderAction::Overwrite, - )], - ..allow_result() - }, - }); - let registry = registry_with_external(service, external_registration(4096)).await; - let runner = ChainRunner::from_registry(registry); - - for (on_error, allowed) in [(OnError::FailClosed, false), (OnError::FailOpen, true)] { - let outcome = runner - .evaluate( - &[ChainEntry { - name: "guard".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error, - }], - input("hello"), - ) - .await - .expect("evaluate credential placeholder mutation"); - - assert_eq!(outcome.allowed, allowed); - assert!(outcome.header_mutations.is_empty()); - assert_eq!(outcome.applied.len(), 1); - assert!(outcome.applied[0].failed); - assert!(!format!("{outcome:?}").contains(placeholder)); - if !allowed { - assert_eq!( - outcome.reason, - "middleware_failed: header_mutation_credential_placeholder" - ); - } - } - } - - #[tokio::test] - async fn connection_nominated_write_and_remove_are_rejected_after_filtering() { - let mutations = [ - write_header( - "x-openshell-middleware-tag", - "value", - ExistingHeaderAction::Append, - ), - HeaderMutation { - operation: Some(header_mutation::Operation::Remove( - openshell_core::proto::RemoveHeader { - name: "x-openshell-middleware-tag".into(), - }, - )), - }, - ]; - - for mutation in mutations { - let service = Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: TestRequestResult { - header_mutations: vec![mutation], - ..allow_result() - }, - }); - let registry = registry_with_external(service, external_registration(4096)).await; - let mut request = input("hello"); - request.connection_nominated_headers = vec!["x-openshell-middleware-tag".into()]; - - let outcome = ChainRunner::from_registry(registry) - .evaluate( - &[ChainEntry { - name: "guard".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }], - request, - ) - .await - .expect("evaluate external middleware"); - - assert!(!outcome.allowed); - assert_eq!( - outcome.reason, - "middleware_failed: header_mutation_hop_by_hop_header" - ); - } - } - - #[tokio::test] - async fn finding_overflow_is_an_invalid_response_governed_by_on_error() { - let registration = external_registration(4096); - let service = Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: TestRequestResult { - findings: vec![Finding::default(); MAX_MIDDLEWARE_FINDINGS_PER_STAGE + 1], - ..allow_result() - }, - }); - let registry = registry_with_external(service, registration).await; - let runner = ChainRunner::from_registry(registry); - - for (on_error, allowed) in [(OnError::FailClosed, false), (OnError::FailOpen, true)] { - let outcome = runner - .evaluate( - &[ChainEntry { - name: "guard".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error, - }], - input("hello"), - ) - .await - .expect("evaluate finding overflow"); - - assert_eq!(outcome.allowed, allowed); - assert!(outcome.findings.is_empty()); - assert_eq!(outcome.applied.len(), 1); - assert!(outcome.applied[0].failed); - if !allowed { - assert_eq!( - outcome.reason, - "middleware_failed: request_findings_over_capacity" - ); - } - } - } - - #[tokio::test] - async fn maximum_chain_retains_findings_from_every_stage() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { - manifest_name: "test/middleware".into(), - max_body_bytes: 4096, - result: TestRequestResult { - findings: vec![ - Finding { - r#type: "example.finding".into(), - label: "Example finding".into(), - count: 1, - confidence: String::new(), - severity: "medium".into(), - }; - MAX_MIDDLEWARE_FINDINGS_PER_STAGE - ], - ..allow_result() - }, - })); - let entries: Vec<_> = (0..MAX_MIDDLEWARE_CHAIN_STAGES) - .map(|index| ChainEntry { - name: format!("guard-{index}"), - implementation: "test/middleware".into(), - order: i32::try_from(index).expect("bounded stage index"), - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }) - .collect(); - - let outcome = runner - .evaluate(&entries, input("hello")) - .await - .expect("evaluate maximum chain"); - - assert!(outcome.allowed); - assert_eq!(outcome.applied.len(), MAX_MIDDLEWARE_CHAIN_STAGES); - assert_eq!(outcome.findings.len(), MAX_MIDDLEWARE_CHAIN_FINDINGS); - for (stage, findings) in outcome - .findings - .chunks_exact(MAX_MIDDLEWARE_FINDINGS_PER_STAGE) - .enumerate() - { - assert!( - findings - .iter() - .all(|finding| finding.middleware == format!("guard-{stage}")) - ); - } - } - - #[tokio::test] - async fn deny_decision_short_circuits_chain() { - let runner = - ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { - decision: Decision::Deny as i32, - reason: "blocked_by_policy".into(), - ..allow_result() - }))); - let outcome = runner - .evaluate( - &[ - entry("first", OnError::FailClosed), - entry("second", OnError::FailClosed), - ], - input("hello"), - ) - .await - .expect("evaluate"); - assert!(!outcome.allowed); - assert_eq!(outcome.reason, "middleware_denied:first"); - assert_eq!( - outcome.denial, - Some(MiddlewareDenial { - config_name: "first".into(), - reason_code: None, - }) - ); - assert!(!format!("{outcome:?}").contains("blocked_by_policy")); - // The deny short-circuits the chain: the second middleware never runs. - assert_eq!(outcome.applied.len(), 1); - assert_eq!(outcome.applied[0].decision, Decision::Deny); - assert!(!outcome.applied[0].failed); - } - - #[tokio::test] - async fn deny_decision_ignores_unsafe_mutations_under_fail_open() { - let runner = - ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { - decision: Decision::Deny as i32, - reason: "blocked_by_policy".into(), - header_mutations: vec![write_header( - "x-openshell-middleware-inject", - "ok\r\nHost: evil", - ExistingHeaderAction::Append, - )], - ..allow_result() - }))); - - let outcome = runner - .evaluate(&[entry("guard", OnError::FailOpen)], input("hello")) - .await - .expect("evaluate"); - - assert!(!outcome.allowed); - assert_eq!(outcome.reason, "middleware_denied:guard"); - assert!(outcome.header_mutations.is_empty()); - assert_eq!(outcome.applied.len(), 1); - assert_eq!(outcome.applied[0].decision, Decision::Deny); - assert!(!outcome.applied[0].failed); - } - - #[tokio::test] - async fn deny_decision_ignores_oversized_replacement_under_fail_open() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { - manifest_name: BUILTIN_REGEX.into(), - max_body_bytes: 4, - result: TestRequestResult { - decision: Decision::Deny as i32, - reason: "blocked_by_policy".into(), - body: b"too large".to_vec(), - has_body: true, - ..allow_result() - }, - })); - - let outcome = runner - .evaluate(&[entry("guard", OnError::FailOpen)], input("safe")) - .await - .expect("evaluate"); - - assert!(!outcome.allowed); - assert_eq!(outcome.reason, "middleware_denied:guard"); - assert_eq!(outcome.body, b"safe"); - assert_eq!(outcome.applied.len(), 1); - assert_eq!(outcome.applied[0].decision, Decision::Deny); - assert!(!outcome.applied[0].transformed); - assert!(!outcome.applied[0].failed); - } - - #[tokio::test] - async fn metadata_and_findings_are_namespaced_per_config() { - let runner = - ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { - findings: vec![Finding { - r#type: "pii.email".into(), - label: "email address".into(), - count: 2, - confidence: "high".into(), - severity: "medium".into(), - }], - metadata: std::iter::once(("sensitivity".to_string(), "high".to_string())) - .collect(), - ..allow_result() - }))); - let outcome = runner - .evaluate( - &[ - entry("alpha", OnError::FailClosed), - entry("beta", OnError::FailClosed), - ], - input("hello"), - ) - .await - .expect("evaluate"); - assert!(outcome.allowed); - // Metadata is bucketed under each config's local name, so two configs - // emitting the same key do not collide. - assert_eq!(outcome.metadata["alpha"]["sensitivity"], "high"); - assert_eq!(outcome.metadata["beta"]["sensitivity"], "high"); - // Findings are tagged with the emitting config's name. - assert_eq!(outcome.findings.len(), 2); - assert_eq!(outcome.findings[0].middleware, "alpha"); - assert_eq!(outcome.findings[1].middleware, "beta"); - assert_eq!(outcome.findings[0].finding.r#type, "pii.email"); - assert_eq!(outcome.findings[0].finding.count, 2); - } - - fn unsafe_header_service() -> ScriptedService { - scripted_service(TestRequestResult { - header_mutations: vec![ - write_header( - "x-openshell-middleware-safe", - "safe", - ExistingHeaderAction::Append, - ), - write_header( - "x-openshell-middleware-inject", - "ok\r\nHost: evil", - ExistingHeaderAction::Append, - ), - ], - ..allow_result() - }) - } - - #[tokio::test] - async fn malformed_response_headers_fail_closed_denies() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(unsafe_header_service())); - let outcome = runner - .evaluate(&[entry("redact", OnError::FailClosed)], input("hello")) - .await - .expect("evaluate"); - assert!(!outcome.allowed); - assert!(outcome.reason.starts_with("middleware_failed:")); - // The deny reason names the offending header so operators can fix the - // service without reading supervisor source. - assert!( - outcome.reason.contains("x-openshell-middleware-inject"), - "reason should name the offending header: {}", - outcome.reason - ); - assert!(outcome.applied.iter().any(|inv| inv.failed)); - // The stage is atomic: neither the unsafe mutation nor the safe - // mutation preceding it is forwarded. - assert!(outcome.header_mutations.is_empty()); - } - - #[tokio::test] - async fn malformed_response_headers_fail_open_continues() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(unsafe_header_service())); - let outcome = runner - .evaluate(&[entry("redact", OnError::FailOpen)], input("hello")) - .await - .expect("evaluate"); - assert!(outcome.allowed); - assert_eq!(outcome.body, b"hello"); - assert!(outcome.header_mutations.is_empty()); - assert_eq!(outcome.applied.len(), 1); - assert!(outcome.applied[0].failed); - } - - #[tokio::test] - async fn oversized_replacement_body_honors_on_error() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { - manifest_name: BUILTIN_REGEX.into(), - max_body_bytes: 4, - result: TestRequestResult { - body: b"too large".to_vec(), - has_body: true, - ..allow_result() - }, - })); - let fail_open = entry("small", OnError::FailOpen); - let mut fail_closed = fail_open.clone(); - fail_closed.on_error = OnError::FailClosed; - - let open_outcome = runner - .evaluate(&[fail_open], input("safe")) - .await - .expect("fail-open evaluation"); - assert!(open_outcome.allowed); - assert_eq!(open_outcome.body, b"safe"); - assert!(open_outcome.applied[0].failed); - - let closed_outcome = runner - .evaluate(&[fail_closed], input("safe")) - .await - .expect("fail-closed evaluation"); - assert!(!closed_outcome.allowed); - assert_eq!( - closed_outcome.reason, - "middleware_failed: body_replacement_over_capacity" - ); - assert!(closed_outcome.applied[0].failed); - } - - #[tokio::test] - async fn oversized_request_body_honors_on_error() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { - manifest_name: BUILTIN_REGEX.into(), - max_body_bytes: 4, - result: allow_result(), - })); - let fail_open = entry("small", OnError::FailOpen); - let mut fail_closed = fail_open.clone(); - fail_closed.on_error = OnError::FailClosed; - - let open_outcome = runner - .evaluate(&[fail_open], input("hello")) - .await - .expect("fail-open evaluation"); - assert!(open_outcome.allowed); - assert_eq!(open_outcome.body, b"hello"); - assert!(open_outcome.applied[0].failed); - - let closed_outcome = runner - .evaluate(&[fail_closed], input("hello")) - .await - .expect("fail-closed evaluation"); - assert!(!closed_outcome.allowed); - assert_eq!( - closed_outcome.reason, - "middleware_failed: request_body_mode_not_permitted" - ); - assert!(closed_outcome.applied[0].failed); - } - - #[tokio::test] - async fn unspecified_decision_uses_fail_closed() { - let runner = - ChainRunner::new_protobuf_for_tests(Arc::new(scripted_service(TestRequestResult { - decision: Decision::Unspecified as i32, - ..allow_result() - }))); - - let outcome = runner - .evaluate(&[entry("redact", OnError::FailClosed)], input("hello")) - .await - .expect("evaluate"); - - assert!(!outcome.allowed); - assert_eq!( - outcome.reason, - "middleware_failed: missing_preflight_action" - ); - assert!(outcome.applied[0].failed); - } - - #[derive(Clone, Default)] - struct OpenAiRedactionService { - preflight: Arc>>, - manifest_name: String, - describe_calls: Arc, - skip: bool, - deny: bool, - preflight_reason: String, - preflight_reason_code: String, - preflight_findings: Vec, - preflight_metadata: HashMap, - close_on_first_message: bool, - messages: Arc, - session_ends: Option< - tokio::sync::mpsc::UnboundedSender, - >, - } - - impl OpenAiRedactionService { - fn websocket_stream(&self, mut requests: S) -> WebSocketResponseStream - where - S: Stream< - Item = std::result::Result< - openshell_core::proto::WebSocketSessionEvent, - tonic::Status, - >, - > + Send - + Unpin - + 'static, - { - use openshell_core::proto::{ - WebSocketMessageResult, WebSocketPreflightAction, WebSocketPreflightDecision, - WebSocketSessionEventResult, web_socket_message, web_socket_message_result, - web_socket_session_event, web_socket_session_event_result, - }; - - let preflight = Arc::clone(&self.preflight); - let skip = self.skip; - let deny = self.deny; - let preflight_reason = self.preflight_reason.clone(); - let preflight_reason_code = self.preflight_reason_code.clone(); - let preflight_findings = self.preflight_findings.clone(); - let preflight_metadata = self.preflight_metadata.clone(); - let close_on_first_message = self.close_on_first_message; - let messages = Arc::clone(&self.messages); - let session_ends = self.session_ends.clone(); - let (responses_tx, responses_rx) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - while let Some(Ok(request)) = requests.next().await { - let response = match request.event { - Some(web_socket_session_event::Event::Preflight(value)) => { - *preflight.lock().expect("preflight lock") = Some(value); - Some(WebSocketSessionEventResult { - result: Some( - web_socket_session_event_result::Result::PreflightDecision( - WebSocketPreflightDecision { - action: if deny { - WebSocketPreflightAction::Deny as i32 - } else if skip { - WebSocketPreflightAction::Skip as i32 - } else { - WebSocketPreflightAction::Inspect as i32 - }, - reason: preflight_reason.clone(), - findings: preflight_findings.clone(), - metadata: preflight_metadata.clone(), - reason_code: preflight_reason_code.clone(), - }, - ), - ), - }) - } - Some(web_socket_session_event::Event::Message(value)) => { - messages.fetch_add(1, std::sync::atomic::Ordering::SeqCst); - if close_on_first_message { - break; - } - let web_socket_message::Payload::Text(payload) = - value.payload.expect("test OpenAI event payload") - else { - panic!("test OpenAI event must be text"); - }; - let payload = payload.replace("customer-secret", "[REDACTED]"); - Some(WebSocketSessionEventResult { - result: Some( - web_socket_session_event_result::Result::MessageResult( - WebSocketMessageResult { - sequence: value.sequence, - decision: Decision::Allow as i32, - replacement: Some( - web_socket_message_result::Replacement::Text( - payload, - ), - ), - reason_code: "redacted".into(), - ..Default::default() - }, - ), - ), - }) - } - Some(web_socket_session_event::Event::SessionStart(_)) | None => None, - Some(web_socket_session_event::Event::SessionEnd(end)) => { - if let Some(session_ends) = &session_ends - && let Ok(reason) = - openshell_core::proto::MiddlewareSessionEndReason::try_from( - end.reason, - ) - { - let _ = session_ends.send(reason); - } - None - } - }; - if let Some(response) = response - && responses_tx.send(Ok(response)).await.is_err() - { - break; - } - } - }); - Box::pin(ReceiverStream::new(responses_rx)) - } - } - - fn websocket_preflight_input(session_id: impl Into) -> WebSocketPreflightInput { - WebSocketPreflightInput { - session_id: session_id.into(), - request_id: "request".into(), - sandbox_id: "sandbox".into(), - sandbox_name: "sandbox-name".into(), - workspace: "wrks-default".into(), - scheme: "wss".into(), - host: "api.openai.com".into(), - port: 443, - path: "/v1/responses".into(), - requested_subprotocols: Vec::new(), - } - } - - #[tonic::async_trait] - impl SupervisorMiddleware for OpenAiRedactionService { - type EvaluateWebSocketSessionStream = WebSocketResponseStream; - - async fn describe( - &self, - _request: Request<()>, - ) -> std::result::Result, tonic::Status> { - self.describe_calls - .fetch_add(1, std::sync::atomic::Ordering::SeqCst); - Ok(tonic::Response::new(MiddlewareManifest { - name: if self.manifest_name.is_empty() { - "test/openai-websocket-redactor".into() - } else { - self.manifest_name.clone() - }, - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: MAX_MIDDLEWARE_PAYLOAD_BYTES as u64, - request_timeout: Some(prost_types::Duration { - seconds: 1, - nanos: 0, - }), - }], - expected_audience: String::new(), - })) - } - - async fn validate_config( - &self, - _request: Request, - ) -> std::result::Result, tonic::Status> { - Ok(tonic::Response::new(ValidateConfigResponse { - valid: true, - reason: String::new(), - })) - } - - async fn evaluate_web_socket_session( - &self, - request: Request>, - ) -> std::result::Result, tonic::Status> - { - Ok(tonic::Response::new( - self.websocket_stream(request.into_inner()), - )) - } - } - - #[tonic::async_trait] - impl SupervisorMiddlewareEndpoint for OpenAiRedactionService { - async fn describe( - &self, - request: Request<()>, - ) -> std::result::Result, tonic::Status> { - SupervisorMiddleware::describe(self, request).await - } - - async fn validate_config( - &self, - request: Request, - ) -> std::result::Result, tonic::Status> { - SupervisorMiddleware::validate_config(self, request).await - } - - async fn open_websocket_session( - &self, - receiver: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { - Ok(self.websocket_stream(ReceiverStream::new(receiver).map(Ok))) - } - } - - fn websocket_entry( - name: &str, - implementation: &str, - order: i32, - on_error: OnError, - ) -> ChainEntry { - ChainEntry { - name: name.into(), - implementation: implementation.into(), - order, - config: prost_types::Struct::default(), - on_error, - } - } - - #[tokio::test] - async fn empty_websocket_chain_skips_describe_and_preflight() { - let service = OpenAiRedactionService::default(); - let describe_calls = Arc::clone(&service.describe_calls); - let observed_preflight = Arc::clone(&service.preflight); - let runner = ChainRunner::from_endpoint(Arc::new(service)); - - let outcome = runner - .preflight_websocket(&[], websocket_preflight_input("empty-chain")) - .await - .expect("empty preflight"); - - assert!(outcome.allowed); - assert_eq!(outcome.terminal_reason, None); - assert!(outcome.session.is_none()); - assert!(outcome.invocations.is_empty()); - assert_eq!(describe_calls.load(std::sync::atomic::Ordering::SeqCst), 0); - assert!(observed_preflight.lock().expect("preflight lock").is_none()); - } - - #[tokio::test] - async fn http_only_attachments_are_reported_but_do_not_select_websocket_stages() { - for on_error in [OnError::FailClosed, OnError::FailOpen] { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { - manifest_name: "test/http-only".into(), - max_body_bytes: 4096, - result: allow_result(), - })); - let chain = [ChainEntry { - name: "http-guard".into(), - implementation: "test/http-only".into(), - order: 0, - config: prost_types::Struct::default(), - on_error, - }]; - - let outcome = runner - .preflight_websocket( - &chain, - websocket_preflight_input(format!("http-only-{on_error:?}")), - ) - .await - .expect("HTTP-only attachment coverage"); - - assert!(outcome.allowed); - assert_eq!(outcome.terminal_reason, None); - assert!(outcome.session.is_none()); - assert!(outcome.invocations.is_empty()); - assert_eq!( - outcome.coverage, - [WebSocketCoverage { - config_name: "http-guard".into(), - implementation: "test/http-only".into(), - state: WebSocketCoverageState::BindingNotSelected, - sequence: None, - message_type: None, - original_size: 0, - }] - ); - } - } - - #[tokio::test] - async fn http_only_attachment_allows_33_requested_subprotocols() { - let runner = ChainRunner::new_protobuf_for_tests(Arc::new(ScriptedService { - manifest_name: "test/http-only".into(), - max_body_bytes: 4096, - result: allow_result(), - })); - let chain = [ChainEntry { - name: "http-guard".into(), - implementation: "test/http-only".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }]; - let mut input = websocket_preflight_input("http-only-many-subprotocols"); - input.requested_subprotocols = (0..33) - .map(|index| format!("subprotocol-{index}")) - .collect(); - - let outcome = runner - .preflight_websocket(&chain, input) - .await - .expect("HTTP-only attachment must not validate a WebSocket middleware envelope"); - - assert!(outcome.allowed); - assert_eq!(outcome.terminal_reason, None); - assert!(outcome.session.is_none()); - assert!(outcome.invocations.is_empty()); - assert_eq!( - outcome.coverage, - [WebSocketCoverage { - config_name: "http-guard".into(), - implementation: "test/http-only".into(), - state: WebSocketCoverageState::BindingNotSelected, - sequence: None, - message_type: None, - original_size: 0, - }] - ); - } - - #[tokio::test] - async fn unsupported_binary_messages_advance_sequence_without_applying_on_error() { - for on_error in [OnError::FailClosed, OnError::FailOpen] { - let runner = builtin_runner(); - let chain = [entry("regex-redactor", on_error)]; - let preflight = runner - .preflight_websocket( - &chain, - websocket_preflight_input(format!("binary-{on_error:?}")), - ) - .await - .expect("preflight"); - let mut session = preflight.session.expect("built-in inspects text"); - assert!(session.start("").await.allowed); - - let coverage = session.observe_unsupported_message(WebSocketMessageType::Binary, 23); - assert_eq!( - coverage, - [WebSocketCoverage { - config_name: "regex-redactor".into(), - implementation: BUILTIN_REGEX.into(), - state: WebSocketCoverageState::UnsupportedMessageType, - sequence: Some(1), - message_type: Some(WebSocketMessageType::Binary), - original_size: 23, - }] - ); - - let text = session.evaluate_text(r#"{"input":"safe"}"#.into()).await; - assert!(text.allowed); - assert_eq!(text.invocations[0].sequence, Some(2)); - assert!(!text.invocations[0].failed); - - session - .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) - .await; - } - } - - #[tokio::test] - async fn explicit_websocket_preflight_denial_is_authoritative_for_both_error_modes() { - use openshell_core::proto::MiddlewareSessionEndReason; - - for on_error in [OnError::FailOpen, OnError::FailClosed] { - let (session_ends_tx, mut session_ends_rx) = tokio::sync::mpsc::unbounded_channel(); - let runner = ChainRunner::from_endpoint(Arc::new(OpenAiRedactionService { - deny: true, - preflight_reason: "contains sensitive request data".into(), - preflight_reason_code: "upgrade_blocked".into(), - preflight_findings: vec![Finding { - r#type: "content.sensitive".into(), - label: "Sensitive content".into(), - count: 1, - confidence: "high".into(), - severity: "high".into(), - }], - preflight_metadata: HashMap::from([("policy_version".into(), "1".into())]), - session_ends: Some(session_ends_tx), - ..Default::default() - })); - - let outcome = runner - .preflight_websocket( - &[websocket_entry( - "deny-upgrade", - "test/openai-websocket-redactor", - 0, - on_error, - )], - websocket_preflight_input("explicit-denial"), - ) - .await - .expect("denied preflight"); - - assert!(!outcome.allowed); - assert_eq!( - outcome.terminal_reason, - Some(MiddlewareSessionEndReason::MiddlewareDenial) - ); - assert_eq!( - outcome.reason, - "middleware_denied:deny-upgrade:upgrade_blocked" - ); - assert_eq!( - outcome - .denial - .as_ref() - .map(|denial| denial.config_name.as_str()), - Some("deny-upgrade") - ); - assert_eq!( - outcome - .denial - .as_ref() - .and_then(|denial| denial.reason_code.as_deref()), - Some("upgrade_blocked") - ); - assert_eq!( - outcome.invocations[0].reason_code.as_deref(), - Some("upgrade_blocked") - ); - assert_eq!(outcome.findings.len(), 1); - assert_eq!(outcome.findings[0].middleware, "deny-upgrade"); - assert_eq!(outcome.findings[0].finding.r#type, "content.sensitive"); - assert_eq!(outcome.metadata["deny-upgrade"]["policy_version"], "1"); - assert!(!outcome.reason.contains("sensitive request data")); - assert!(outcome.session.is_none()); - assert_eq!(outcome.invocations.len(), 1); - assert_eq!( - outcome.invocations[0].outcome, - WebSocketInvocationOutcome::Deny - ); - assert!(!outcome.invocations[0].failed); - assert_eq!( - session_ends_rx.recv().await, - Some(MiddlewareSessionEndReason::MiddlewareDenial) - ); - assert!( - session_ends_rx.try_recv().is_err(), - "each opened stream receives at most one session_end" - ); - } - } - - #[tokio::test] - async fn mixed_websocket_preflight_denial_ends_every_opened_stage() { - use openshell_core::proto::MiddlewareSessionEndReason; - - let (first_end_tx, mut first_end_rx) = tokio::sync::mpsc::unbounded_channel(); - let (denier_end_tx, mut denier_end_rx) = tokio::sync::mpsc::unbounded_channel(); - let (last_end_tx, mut last_end_rx) = tokio::sync::mpsc::unbounded_channel(); - let endpoints: Vec> = vec![ - in_process_endpoint(Arc::new(OpenAiRedactionService { - manifest_name: "test/first-inspector".into(), - session_ends: Some(first_end_tx), - ..Default::default() - })), - in_process_endpoint(Arc::new(OpenAiRedactionService { - manifest_name: "test/denier".into(), - deny: true, - session_ends: Some(denier_end_tx), - ..Default::default() - })), - in_process_endpoint(Arc::new(OpenAiRedactionService { - manifest_name: "test/last-inspector".into(), - session_ends: Some(last_end_tx), - ..Default::default() - })), - ]; - let runner = ChainRunner::from_registry( - MiddlewareRegistry::connect_services(endpoints, Vec::new()) - .await - .expect("connect mixed middleware services"), - ); - let chain = [ - websocket_entry("first", "test/first-inspector", 0, OnError::FailClosed), - websocket_entry("deny", "test/denier", 1, OnError::FailOpen), - websocket_entry("last", "test/last-inspector", 2, OnError::FailClosed), - ]; - - let outcome = runner - .preflight_websocket(&chain, websocket_preflight_input("mixed-denial")) - .await - .expect("mixed preflight"); - - assert!(!outcome.allowed); - assert_eq!( - outcome.terminal_reason, - Some(MiddlewareSessionEndReason::MiddlewareDenial) - ); - assert_eq!( - outcome - .invocations - .iter() - .map(|invocation| invocation.outcome) - .collect::>(), - vec![ - WebSocketInvocationOutcome::Inspect, - WebSocketInvocationOutcome::Deny, - WebSocketInvocationOutcome::Inspect, - ] - ); - for receiver in [&mut first_end_rx, &mut denier_end_rx, &mut last_end_rx] { - assert_eq!( - receiver.recv().await, - Some(MiddlewareSessionEndReason::MiddlewareDenial) - ); - assert!( - receiver.try_recv().is_err(), - "each opened stream receives at most one session_end" - ); - } - assert_eq!( - runner.registry.session_admission.available_permits(), - MAX_CONCURRENT_MIDDLEWARE_SESSIONS - ); - } - - #[tokio::test] - async fn builtin_regex_redacts_ordered_websocket_text_messages() { - let runner = builtin_runner(); - let chain = [entry("regex-redactor", OnError::FailClosed)]; - let preflight = runner - .preflight_websocket( - &chain, - WebSocketPreflightInput { - session_id: "builtin-regex-session".into(), - request_id: "request".into(), - sandbox_id: "sandbox".into(), - sandbox_name: "sandbox-name".into(), - workspace: "wrks-default".into(), - scheme: "wss".into(), - host: "api.openai.com".into(), - port: 443, - path: "/v1/responses".into(), - requested_subprotocols: vec!["realtime".into()], - }, - ) - .await - .expect("preflight"); - assert!(preflight.allowed); - assert_eq!( - preflight.invocations[0].outcome, - WebSocketInvocationOutcome::Inspect - ); - let mut session = preflight.session.expect("built-in chose to inspect"); - assert!(session.start("realtime").await.allowed); - - let original = r#"{"type":"response.create","response":{"input":"sk-ABCDEFGHIJKLMNOP"}}"#; - let redacted = session.evaluate_text(original.into()).await; - assert!(redacted.allowed); - assert_eq!( - redacted.payload, - r#"{"type":"response.create","response":{"input":"[REDACTED]"}}"# - ); - assert_eq!(redacted.invocations[0].sequence, Some(1)); - assert!(redacted.invocations[0].transformed); - assert_eq!(redacted.findings.len(), 1); - assert_eq!(redacted.findings[0].middleware, "regex-redactor"); - assert_eq!(redacted.findings[0].finding.r#type, "regex.openai"); - assert_eq!( - redacted.metadata["regex-redactor"]["regex_matches_replaced"], - "1" - ); - - let unchanged = session - .evaluate_text(r#"{"type":"response.cancel"}"#.into()) - .await; - assert!(unchanged.allowed); - assert_eq!(unchanged.payload, r#"{"type":"response.cancel"}"#); - assert_eq!(unchanged.invocations[0].sequence, Some(2)); - assert!(!unchanged.invocations[0].transformed); - assert!(unchanged.findings.is_empty()); - assert!(unchanged.metadata.is_empty()); - - let oversized = session.evaluate_text("a".repeat(256 * 1024 + 1)).await; - assert!(!oversized.allowed); - assert_eq!( - oversized.reason, - "middleware_failed: request_message_over_capacity" - ); - session - .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) - .await; - } - - #[tokio::test] - async fn builtin_regex_redacts_after_fail_open_message_capacity_gap() { - let runner = builtin_runner(); - let chain = [entry("regex-redactor", OnError::FailOpen)]; - let preflight = runner - .preflight_websocket( - &chain, - WebSocketPreflightInput { - session_id: "builtin-regex-gap-session".into(), - request_id: "request".into(), - sandbox_id: "sandbox".into(), - sandbox_name: "sandbox-name".into(), - workspace: "wrks-default".into(), - scheme: "wss".into(), - host: "api.openai.com".into(), - port: 443, - path: "/v1/responses".into(), - requested_subprotocols: vec!["realtime".into()], - }, - ) - .await - .expect("preflight"); - assert!(preflight.allowed); - let mut session = preflight.session.expect("built-in chose to inspect"); - assert!(session.start("realtime").await.allowed); - - let oversized_payload = "a".repeat(256 * 1024 + 1); - let oversized = session.evaluate_text(oversized_payload.clone()).await; - assert!(oversized.allowed); - assert_eq!(oversized.payload, oversized_payload); - assert_eq!(oversized.invocations[0].sequence, Some(1)); - assert_eq!( - oversized.invocations[0].outcome, - WebSocketInvocationOutcome::FailOpen - ); - assert!(!oversized.invocations[0].stage_disabled); - - let original = r#"{"type":"response.create","response":{"input":"sk-ABCDEFGHIJKLMNOP"}}"#; - let redacted = session.evaluate_text(original.into()).await; - assert!(redacted.allowed); - assert_eq!( - redacted.payload, - r#"{"type":"response.create","response":{"input":"[REDACTED]"}}"# - ); - assert_eq!(redacted.invocations[0].sequence, Some(2)); - assert_eq!( - redacted.invocations[0].outcome, - WebSocketInvocationOutcome::Allow - ); - assert!(redacted.invocations[0].transformed); - assert!(!redacted.invocations[0].stage_disabled); - - session - .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) - .await; - } - - #[tokio::test] - async fn in_process_websocket_endpoint_redacts_openai_event() { - let service = OpenAiRedactionService::default(); - let runner = ChainRunner::from_endpoint(Arc::new(service)); - let chain = [ChainEntry { - name: "openai-redactor".into(), - implementation: "test/openai-websocket-redactor".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }]; - let preflight = runner - .preflight_websocket( - &chain, - WebSocketPreflightInput { - session_id: "in-process-session".into(), - request_id: "request".into(), - sandbox_id: "sandbox".into(), - sandbox_name: "sandbox-name".into(), - workspace: "wrks-default".into(), - scheme: "wss".into(), - host: "api.openai.com".into(), - port: 443, - path: "/v1/responses".into(), - requested_subprotocols: vec!["realtime".into()], - }, - ) - .await - .expect("preflight"); - assert!(preflight.allowed); - let mut session = preflight.session.expect("middleware chose to inspect"); - assert!(session.start("realtime").await.allowed); - - let original = r#"{"type":"response.create","response":{"input":"customer-secret"}}"#; - let outcome = session.evaluate_text(original.into()).await; - assert!(outcome.allowed); - assert_eq!( - outcome.payload, - r#"{"type":"response.create","response":{"input":"[REDACTED]"}}"# - ); - assert!(outcome.invocations[0].transformed); - session - .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) - .await; - } - - #[tokio::test] - async fn openai_websocket_event_is_introspected_and_redacted() { - let service = OpenAiRedactionService::default(); - let observed_preflight = Arc::clone(&service.preflight); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind WebSocket middleware"); - let address = listener.local_addr().expect("middleware address"); - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let server = tonic::transport::Server::builder() - .add_service(SupervisorMiddlewareServer::new(service)) - .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { - let _ = shutdown_rx.await; - }); - let server_task = tokio::spawn(server); - - let mut registration = external_registration(1024); - registration.grpc_endpoint = format!("http://{address}"); - let registry = MiddlewareRegistry::connect_services(Vec::new(), vec![registration]) - .await - .expect("connect WebSocket middleware"); - let runner = ChainRunner::from_registry(registry); - let chain = [ChainEntry { - name: "openai-redactor".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }]; - let described = runner - .describe_websocket_chain(&chain) - .await - .expect("describe WebSocket chain"); - assert_eq!( - described[0].max_payload_bytes(), - 1024, - "operator max_payload_bytes must cap WebSocket messages" - ); - let preflight = runner - .preflight_websocket( - &chain, - WebSocketPreflightInput { - session_id: "ws-session".into(), - request_id: "request".into(), - sandbox_id: "sandbox".into(), - sandbox_name: "sandbox-name".into(), - workspace: "wrks-default".into(), - scheme: "wss".into(), - host: "api.openai.com".into(), - port: 443, - path: "/v1/responses".into(), - requested_subprotocols: vec!["realtime".into()], - }, - ) - .await - .expect("preflight"); - assert!(preflight.allowed); - let mut session = preflight.session.expect("middleware chose to inspect"); - assert!(session.start("realtime").await.allowed); - - let original = r#"{"type":"response.create","response":{"input":"customer-secret"}}"#; - let outcome = session.evaluate_text(original.into()).await; - assert!(outcome.allowed); - let transformed = outcome.payload; - assert!(transformed.contains("[REDACTED]")); - assert!(!transformed.contains("customer-secret")); - assert!(outcome.invocations[0].transformed); - - let observed = observed_preflight - .lock() - .expect("preflight lock") - .clone() - .expect("preflight observed"); - let target = observed.target.expect("preflight target"); - assert_eq!(target.scheme, "wss"); - assert_eq!(target.host, "api.openai.com"); - assert_eq!(target.port, 443); - assert_eq!(target.method, "GET"); - assert_eq!(target.path, "/v1/responses"); - assert!(target.query.is_empty()); - assert_eq!(observed.requested_subprotocols, ["realtime"]); - session - .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) - .await; - let _ = shutdown_tx.send(()); - server_task - .await - .expect("join test middleware") - .expect("serve middleware"); - } - - #[tokio::test] - async fn fail_open_disables_broken_websocket_stage_for_later_messages() { - let service = OpenAiRedactionService { - close_on_first_message: true, - ..Default::default() - }; - let observed_preflight = Arc::clone(&service.preflight); - let message_count = Arc::clone(&service.messages); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind WebSocket middleware"); - let address = listener.local_addr().expect("middleware address"); - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let server = tonic::transport::Server::builder() - .add_service(SupervisorMiddlewareServer::new(service)) - .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { - let _ = shutdown_rx.await; - }); - let server_task = tokio::spawn(server); - - let mut registration = external_registration(1024); - registration.grpc_endpoint = format!("http://{address}"); - let registry = MiddlewareRegistry::connect_services(Vec::new(), vec![registration]) - .await - .expect("connect WebSocket middleware"); - let runner = ChainRunner::from_registry(registry); - let chain = [ChainEntry { - name: "openai-redactor".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailOpen, - }]; - let preflight = runner - .preflight_websocket( - &chain, - WebSocketPreflightInput { - session_id: "ws-session".into(), - request_id: "request".into(), - sandbox_id: "sandbox".into(), - sandbox_name: "sandbox-name".into(), - workspace: "wrks-default".into(), - scheme: "ws".into(), - host: "api.openai.com".into(), - port: 80, - path: "/v1/responses".into(), - requested_subprotocols: Vec::new(), - }, - ) - .await - .expect("preflight"); - let mut session = preflight.session.expect("middleware chose to inspect"); - assert!(session.start("").await.allowed); - assert_eq!( - observed_preflight - .lock() - .expect("preflight lock") - .as_ref() - .expect("preflight observed") - .target - .as_ref() - .expect("preflight target") - .scheme, - "ws" - ); - - let first = session - .evaluate_text(r#"{"type":"response.create"}"#.into()) - .await; - assert!(first.allowed, "fail-open should bypass the broken stage"); - assert_eq!(first.invocations.len(), 1); - assert!(first.invocations[0].failed); - assert!(first.invocations[0].stage_disabled); - assert_eq!( - runner.registry.session_admission.available_permits(), - MAX_CONCURRENT_MIDDLEWARE_SESSIONS, - "the final disabled stage must release persistent session capacity" - ); - - let mut work = Vec::new(); - for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { - work.push( - runner - .reserve_middleware_work_admission() - .await - .expect("fill middleware work budget"), - ); - } - - let second = session - .evaluate_text(r#"{"type":"response.cancel"}"#.into()) - .now_or_never() - .expect("fully disabled session must bypass without waiting for work admission"); - assert!(second.allowed); - assert!( - second.invocations.is_empty(), - "disabled stage must not be called again in this session" - ); - assert_eq!(message_count.load(std::sync::atomic::Ordering::SeqCst), 1); - drop(work); - - session - .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) - .await; - let _ = shutdown_tx.send(()); - server_task - .await - .expect("join test middleware") - .expect("serve middleware"); - } - - #[tokio::test] - async fn mixed_websocket_session_keeps_message_admission_while_one_stage_is_active() { - let broken = Arc::new(OpenAiRedactionService { - close_on_first_message: true, - ..Default::default() - }); - let mut endpoints = services(); - endpoints.push(in_process_endpoint(broken)); - let runner = ChainRunner::from_registry( - MiddlewareRegistry::connect_services(endpoints, Vec::new()) - .await - .expect("connect mixed middleware services"), - ); - let broken_entry = ChainEntry { - name: "best-effort-remote".into(), - implementation: "test/openai-websocket-redactor".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailOpen, - }; - let mut regex_entry = entry("required-regex", OnError::FailClosed); - regex_entry.order = 1; - let preflight = runner - .preflight_websocket( - &[broken_entry, regex_entry], - websocket_preflight_input("mixed-active"), - ) - .await - .expect("mixed preflight"); - let mut session = preflight.session.expect("both stages inspect"); - assert!(session.start("").await.allowed); - - let first = session - .evaluate_text(r#"{"input":"sk-ABCDEFGHIJKLMNOP"}"#.into()) - .await; - assert!(first.allowed); - assert!(first.invocations[0].stage_disabled); - assert_eq!( - first.invocations[1].outcome, - WebSocketInvocationOutcome::Allow - ); - - let mut work = Vec::new(); - for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { - work.push( - runner - .reserve_middleware_work_admission() - .await - .expect("fill middleware work budget"), - ); - } - assert!( - session.admit_message().now_or_never().is_none(), - "an active remaining stage must still wait for message work admission" - ); - drop(work); - - let second = session - .evaluate_text(r#"{"input":"sk-QRSTUVWXYZabcdef"}"#.into()) - .await; - assert!(second.allowed); - assert_eq!(second.invocations.len(), 1); - assert_eq!( - second.invocations[0].config_name, "required-regex", - "disabled stage must stay bypassed while the active stage continues" - ); - } - - #[tokio::test] - async fn websocket_admission_wait_queue_is_bounded() { - let runner = ChainRunner::default(); - let mut active = Vec::new(); - for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { - active.push( - runner - .reserve_middleware_work_admission() - .await - .expect("active admission"), - ); - } - - let mut waiters = Vec::new(); - for _ in 0..MAX_QUEUED_MIDDLEWARE_WORK { - let runner = runner.clone(); - waiters.push(tokio::spawn(async move { - runner.reserve_middleware_work().await - })); - } - while runner.registry.work_admission_waiters.available_permits() != 0 { - tokio::task::yield_now().await; - } - - let overflow = runner - .reserve_middleware_work() - .await - .expect("admission outcome"); - assert!(matches!( - overflow, - MiddlewareWorkAdmissionOutcome::QueueExhausted - )); - runner - .reserve_middleware_work_admission() - .await - .expect_err("WebSocket callers retain their existing failure path"); - - drop(active); - for waiter in waiters { - let admission = waiter - .await - .expect("waiter task") - .expect("queued admission after capacity is released"); - assert!(matches!( - admission, - MiddlewareWorkAdmissionOutcome::Admitted(_) - )); - } - assert_eq!( - runner.registry.work_admission.available_permits(), - MAX_CONCURRENT_MIDDLEWARE_WORK - ); - assert_eq!( - runner.registry.work_admission_waiters.available_permits(), - MAX_QUEUED_MIDDLEWARE_WORK - ); - - let mut recovered = Vec::new(); - for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { - recovered.push( - runner - .reserve_middleware_work_admission() - .await - .expect("recovered active admission"), - ); - } - assert_eq!(recovered.len(), MAX_CONCURRENT_MIDDLEWARE_WORK); - } - - #[tokio::test] - async fn websocket_queue_exhaustion_remains_a_protocol_failure() { - let runner = builtin_runner(); - let chain = [entry("regex-redactor", OnError::FailClosed)]; - let preflight = runner - .preflight_websocket(&chain, websocket_preflight_input("established")) - .await - .expect("initial preflight"); - let mut session = preflight.session.expect("built-in inspects session"); - assert!(session.start("").await.allowed); - - let mut active = Vec::new(); - for _ in 0..MAX_CONCURRENT_MIDDLEWARE_WORK { - active.push( - runner - .reserve_middleware_work_admission() - .await - .expect("fill active work"), - ); - } - let mut waiters = Vec::new(); - for _ in 0..MAX_QUEUED_MIDDLEWARE_WORK { - let runner = runner.clone(); - waiters.push(tokio::spawn(async move { - runner.reserve_middleware_work().await - })); - } - while runner.registry.work_admission_waiters.available_permits() != 0 { - tokio::task::yield_now().await; - } - - let preflight_overflow = runner - .preflight_websocket(&chain, websocket_preflight_input("preflight-overflow")) - .await; - assert!( - preflight_overflow.is_err(), - "preflight exhaustion remains an outer HTTP failure" - ); - session - .admit_message() - .await - .expect_err("established message exhaustion remains a typed termination input"); - - for waiter in waiters { - waiter.abort(); - } - drop(active); - } - - #[tokio::test] - async fn websocket_preflight_skip_removes_stage_without_message_calls() { - let (session_ends_tx, mut session_ends_rx) = tokio::sync::mpsc::unbounded_channel(); - let service = OpenAiRedactionService { - skip: true, - session_ends: Some(session_ends_tx), - ..Default::default() - }; - let message_count = Arc::clone(&service.messages); - let listener = tokio::net::TcpListener::bind("127.0.0.1:0") - .await - .expect("bind WebSocket middleware"); - let address = listener.local_addr().expect("middleware address"); - let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel(); - let server = tonic::transport::Server::builder() - .add_service(SupervisorMiddlewareServer::new(service)) - .serve_with_incoming_shutdown(TcpListenerStream::new(listener), async { - let _ = shutdown_rx.await; - }); - let server_task = tokio::spawn(server); - let mut registration = external_registration(1024); - registration.grpc_endpoint = format!("http://{address}"); - let runner = ChainRunner::from_registry( - MiddlewareRegistry::connect_services(Vec::new(), vec![registration]) - .await - .expect("connect middleware"), - ); - let result = runner - .preflight_websocket( - &[ChainEntry { - name: "scope".into(), - implementation: "local-guard-service".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }], - WebSocketPreflightInput { - session_id: "session".into(), - request_id: "request".into(), - sandbox_id: "sandbox".into(), - sandbox_name: "sandbox-name".into(), - workspace: "wrks-default".into(), - scheme: "wss".into(), - host: "api.openai.com".into(), - port: 443, - path: "/v1/responses".into(), - requested_subprotocols: Vec::new(), - }, - ) - .await - .expect("preflight"); - assert!(result.allowed); - assert!(result.session.is_none()); - assert_eq!( - result.invocations[0].outcome, - WebSocketInvocationOutcome::Skip - ); - assert_eq!(message_count.load(std::sync::atomic::Ordering::SeqCst), 0); - assert_eq!( - runner.registry.session_admission.available_permits(), - MAX_CONCURRENT_MIDDLEWARE_SESSIONS, - "all-skip preflight must not retain session capacity" - ); - assert_eq!( - tokio::time::timeout(Duration::from_secs(1), session_ends_rx.recv()) - .await - .expect("skipped stage must receive session_end"), - Some(openshell_core::proto::MiddlewareSessionEndReason::StageSkipped) - ); - assert!( - session_ends_rx.try_recv().is_err(), - "a skipped stage receives at most one session_end" - ); - let _ = shutdown_tx.send(()); - server_task - .await - .expect("join middleware") - .expect("serve middleware"); - } - - #[tokio::test] - async fn websocket_session_budget_caps_idle_inspecting_sessions_and_releases_on_end() { - let runner = builtin_runner(); - let chain = [entry("regex-redactor", OnError::FailClosed)]; - let mut sessions = Vec::new(); - for index in 0..MAX_CONCURRENT_MIDDLEWARE_SESSIONS { - let preflight = runner - .preflight_websocket( - &chain, - websocket_preflight_input(format!("session-{index}")), - ) - .await - .expect("admit inspecting session"); - assert!(preflight.allowed); - sessions.push(preflight.session.expect("built-in inspects session")); - } - assert_eq!(runner.registry.session_admission.available_permits(), 0); - - let overflow = runner - .preflight_websocket(&chain, websocket_preflight_input("overflow")) - .await - .expect("capacity exhaustion is a typed preflight outcome"); - assert!(!overflow.allowed); - assert!(overflow.session.is_none()); - assert!(overflow.session_capacity_exhausted); assert_eq!( - overflow.invocations[0].outcome, - WebSocketInvocationOutcome::FailClosed + MiddlewareDiagnosticPolicy::Normalize.header_mutation_error_reason(&mutation), + "header_mutation_protected_header" ); - - sessions - .pop() - .expect("retained session") - .end(openshell_core::proto::MiddlewareSessionEndReason::Normal) - .await; - assert_eq!(runner.registry.session_admission.available_permits(), 1); - - let replacement = runner - .preflight_websocket(&chain, websocket_preflight_input("replacement")) - .await - .expect("released session capacity is reusable"); - assert!(replacement.allowed); - assert!(replacement.session.is_some()); - } - - #[tokio::test] - async fn websocket_session_budget_survives_registry_replacement() { - let runner = builtin_runner(); - let chain = [entry("regex-redactor", OnError::FailClosed)]; - let mut sessions = Vec::new(); - for index in 0..MAX_CONCURRENT_MIDDLEWARE_SESSIONS { - let preflight = runner - .preflight_websocket( - &chain, - websocket_preflight_input(format!("old-generation-{index}")), - ) - .await - .expect("admit old-generation session"); - sessions.push(preflight.session.expect("built-in inspects session")); - } - - let replacement_registry = MiddlewareRegistry::connect_services(services(), Vec::new()) - .await - .expect("connect replacement registry"); - let replacement = runner.with_replacement_registry(replacement_registry); - let overflow = replacement - .preflight_websocket(&chain, websocket_preflight_input("new-generation-overflow")) - .await - .expect("capacity exhaustion is a typed preflight outcome"); - assert!(!overflow.allowed); - assert!(overflow.session_capacity_exhausted); - - sessions - .pop() - .expect("retained old-generation session") - .end(openshell_core::proto::MiddlewareSessionEndReason::PolicyReload) - .await; - let admitted = replacement - .preflight_websocket(&chain, websocket_preflight_input("new-generation-admitted")) - .await - .expect("released capacity is reusable after replacement"); - assert!(admitted.allowed); - assert!(admitted.session.is_some()); - } - - #[tokio::test] - async fn websocket_session_capacity_exhaustion_honors_mixed_on_error() { - let runner = builtin_runner(); - let mut held = Vec::new(); - for _ in 0..MAX_CONCURRENT_MIDDLEWARE_SESSIONS { - match runner.try_reserve_middleware_session() { - MiddlewareSessionAdmission::Admitted(admission) => held.push(admission), - MiddlewareSessionAdmission::AtCapacity => { - panic!("session budget exhausted before platform limit") - } - } - } - - let mut first = entry("best-effort-a", OnError::FailOpen); - first.order = 1; - let mut second = entry("best-effort-b", OnError::FailOpen); - second.order = 2; - let all_fail_open = runner - .preflight_websocket( - &[first.clone(), second.clone()], - websocket_preflight_input("all-fail-open"), - ) - .await - .expect("all-fail-open capacity outcome"); - assert!(all_fail_open.allowed); - assert!(all_fail_open.session.is_none()); - assert!(all_fail_open.session_capacity_exhausted); assert!( - all_fail_open - .invocations - .iter() - .all(|invocation| invocation.outcome == WebSocketInvocationOutcome::FailOpen) - ); - - second.on_error = OnError::FailClosed; - let mixed = runner - .preflight_websocket(&[first, second], websocket_preflight_input("mixed")) - .await - .expect("mixed capacity outcome"); - assert!(!mixed.allowed); - assert!(mixed.session.is_none()); - assert!(mixed.session_capacity_exhausted); - assert_eq!( - mixed - .invocations - .iter() - .map(|invocation| invocation.outcome) - .collect::>(), - [ - WebSocketInvocationOutcome::FailOpen, - WebSocketInvocationOutcome::FailClosed, - ] + MiddlewareDiagnosticPolicy::Preserve + .header_mutation_error_reason(&mutation) + .contains("set-cookie") ); - drop(held); } } diff --git a/crates/openshell-supervisor-middleware/src/remote.rs b/crates/openshell-supervisor-middleware/src/remote.rs index 80f43ffd69..4cfa7248c3 100644 --- a/crates/openshell-supervisor-middleware/src/remote.rs +++ b/crates/openshell-supervisor-middleware/src/remote.rs @@ -3,15 +3,14 @@ use miette::{IntoDiagnostic, Result, WrapErr}; use openshell_core::middleware::{ - HttpRequestResultStream, HttpResponseResultStream, SupervisorMiddlewareEndpoint, - WebSocketResponseStream, + HttpResultStream, SupervisorMiddlewareEndpoint, WebSocketResponseStream, }; use openshell_core::proto::middleware::v1::http_request_pre_credentials_client::HttpRequestPreCredentialsClient; use openshell_core::proto::middleware::v1::http_response_pre_return_client::HttpResponsePreReturnClient; use openshell_core::proto::middleware::v1::supervisor_middleware_client::SupervisorMiddlewareClient; use openshell_core::proto::{ - HttpRequestEvent, HttpResponseEvent, MiddlewareManifest, ValidateConfigRequest, - ValidateConfigResponse, WebSocketSessionEvent, + HttpEvent, MiddlewareManifest, ValidateConfigRequest, ValidateConfigResponse, + WebSocketSessionEvent, }; use openshell_extension_core::{ BearerTokenInterceptor, BearerTokenSlot, ExtensionChannelConfig, ExtensionServerTrust, @@ -53,12 +52,6 @@ impl GrpcMiddlewareService { }) } - /// Wrap a protobuf-shaped service used by transport-boundary tests. - #[cfg(test)] - pub fn from_service(service: Arc) -> Self { - Self { service } - } - /// Forward a manifest request through the protobuf service contract. pub async fn describe(&self) -> std::result::Result, Status> { self.service.describe(Request::new(())).await @@ -81,8 +74,8 @@ impl GrpcMiddlewareService { /// Open a remote HTTP request pre-credentials stream through the adapter. pub async fn open_http_request_pre_credentials( &self, - receiver: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { self.service .open_http_request_pre_credentials(receiver) .await @@ -99,8 +92,8 @@ impl GrpcMiddlewareService { /// 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 { + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { self.service.open_http_response_pre_return(receiver).await } } @@ -170,11 +163,11 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService { async fn open_http_request_pre_credentials( &self, - receiver: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { let mut client = self.request_client.clone(); let responses = client - .evaluate(Request::new(tokio_stream::wrappers::ReceiverStream::new( + .evaluate_http(Request::new(tokio_stream::wrappers::ReceiverStream::new( receiver, ))) .await? @@ -198,11 +191,11 @@ impl SupervisorMiddlewareEndpoint for RemoteMiddlewareService { async fn open_http_response_pre_return( &self, - receiver: tokio::sync::mpsc::Receiver, - ) -> std::result::Result { + receiver: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { let mut client = self.response_client.clone(); let responses = client - .evaluate(Request::new(tokio_stream::wrappers::ReceiverStream::new( + .evaluate_http(Request::new(tokio_stream::wrappers::ReceiverStream::new( receiver, ))) .await? diff --git a/crates/openshell-supervisor-middleware/src/request.rs b/crates/openshell-supervisor-middleware/src/request.rs index 33d83452e6..a1da40860d 100644 --- a/crates/openshell-supervisor-middleware/src/request.rs +++ b/crates/openshell-supervisor-middleware/src/request.rs @@ -2,45 +2,50 @@ // SPDX-License-Identifier: Apache-2.0 //! HTTP request pre-credentials middleware chain execution. +//! +//! BUFFERED stages hold one bounded body in memory. STREAM stages run input +//! and output pumps concurrently. The supervisor never retains `STREAM` input for +//! replay and never creates a middleware body spool. use std::collections::BTreeMap; +use std::sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, +}; use std::time::Duration; use futures::StreamExt as _; use prost::Message as _; -use tokio::sync::mpsc; -use tokio::time::Instant; +use tokio::sync::{mpsc, watch}; use openshell_core::proto::{ - Finding, HeaderMutation, HttpHeader, HttpRequestBodyMode, HttpRequestBodyOutput, - HttpRequestBodyUnit, HttpRequestEvent, HttpRequestEventResult, HttpRequestPreflight, - HttpRequestTarget, HttpRequestTrailers, MiddlewareSessionEnd, MiddlewareSessionEndReason, - RequestContext, http_request_body_result, http_request_body_skip_remaining, - http_request_body_transform, http_request_body_unit, http_request_event, - http_request_event_result, http_request_preflight_result, + HeaderMutation, HttpBegin, HttpBodyLimits, HttpBodyMode, HttpBufferedBody, HttpEvent, + HttpHeader, HttpInputChunk, HttpInputEnd, HttpPreflight, HttpRequestPreflightHead, + HttpRequestTarget, HttpResult, HttpUnchanged, MiddlewareDiagnostics, MiddlewareSessionEnd, + MiddlewareSessionEndReason, RequestContext, http_buffered_result, http_event, http_inspect, + http_preflight, http_preflight_result, http_result, }; use super::{ ChainEntry, ChainRunner, DescribedChainEntry, EXTERNAL_FINDING_LABEL, - MAX_MIDDLEWARE_CHAIN_TIMEOUT, MAX_MIDDLEWARE_CONTEXT_BYTES, MAX_MIDDLEWARE_FINDING_BYTES, - MAX_MIDDLEWARE_FINDINGS_PER_STAGE, MAX_MIDDLEWARE_HEADER_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, + MAX_MIDDLEWARE_CONTEXT_BYTES, MAX_MIDDLEWARE_FINDING_BYTES, MAX_MIDDLEWARE_FINDINGS_PER_STAGE, + MAX_MIDDLEWARE_HEADER_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; const SESSION_END_TIMEOUT: Duration = Duration::from_millis(10); const MAX_RECORDED_REQUEST_INVOCATIONS: usize = 1024; -/// Largest normalized request body unit sent in streaming modes. +/// Largest normalized STREAM chunk sent through the public contract. pub const MAX_HTTP_REQUEST_STREAM_UNIT_BYTES: usize = 64 * 1024; -/// Largest input or output representation an owned stage may retain. -/// -/// The limit bounds logical storage, not memory. Implementations are expected -/// to spool large representations instead of retaining them in RAM. -pub const MAX_HTTP_REQUEST_DEFERRED_BYTES: usize = 1024 * 1024 * 1024; + +/// Compatibility limit for callers that still collect a complete body before +/// entering the two-mode session API. The HTTP relay does not use this path. +pub const MAX_HTTP_REQUEST_DEFERRED_BYTES: usize = super::MAX_MIDDLEWARE_PAYLOAD_BYTES; #[derive(Debug, Clone)] pub struct HttpRequestPreflightInput { @@ -53,18 +58,13 @@ pub struct HttpRequestPreflightInput { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum HttpRequestInvocationOutcome { - Skip, - BlockRequest, - HeadersOnly, - WholeBody, + Continue, + Reject, + Buffered, Stream, - OwnedStream, - Trailers, - PassThrough, - Transform, - SkipRemaining, - TakeOwnership, - FailOpen, + Unchanged, + Replacement, + Finish, FailClosed, } @@ -87,7 +87,6 @@ pub struct HttpRequestPreflightOutcome { pub reason: String, pub denial: Option, pub headers: Vec, - /// Ordered mutations to replay against the original raw header block. pub header_mutations: Vec, pub session: Option, pub findings: Vec, @@ -111,17 +110,6 @@ impl std::fmt::Display for HttpRequestMiddlewareFailure { impl std::error::Error for HttpRequestMiddlewareFailure {} -impl HttpRequestMiddlewareFailure { - fn with_diagnostics(mut self, mut diagnostics: HttpRequestDiagnostics) -> Self { - let existing = *std::mem::take(&mut self.diagnostics); - diagnostics.findings.extend(existing.findings); - diagnostics.metadata.extend(existing.metadata); - diagnostics.invocations.extend(existing.invocations); - self.diagnostics = Box::new(diagnostics); - self - } -} - #[derive(Debug)] pub struct HttpRequestFinish { pub body_units: Vec>, @@ -139,82 +127,92 @@ pub struct HttpRequestDiagnostics { pub invocations: Vec, } -struct HttpRequestStageTransport { - sender: mpsc::Sender, - responses: super::HttpRequestResultStream, - terminal_sent: bool, +/// Input accepted by the independent request pipeline. +#[derive(Debug)] +pub enum HttpRequestBodyInput { + Chunk(Vec), + End(Vec), } -impl HttpRequestStageTransport { - async fn end(mut self, reason: MiddlewareSessionEndReason) { - let _ = tokio::time::timeout(SESSION_END_TIMEOUT, self.end_inner(reason)).await; - } +/// Output produced by the independent request pipeline. +#[derive(Debug)] +pub enum HttpRequestBodyOutput { + Start { output_body_bytes: Option }, + Chunk(Vec), + End { trailers: Vec }, +} + +struct HttpStageTransport { + sender: mpsc::Sender, + responses: super::HttpResultStream, + terminal_sent: bool, +} - async fn end_inner(&mut self, reason: MiddlewareSessionEndReason) { - if self.sender.send(session_end_event(reason)).await.is_err() { - self.terminal_sent = true; +impl HttpStageTransport { + async fn end(&mut self, reason: MiddlewareSessionEndReason) { + if self.terminal_sent { return; } self.terminal_sent = true; - self.drain().await; - } - - async fn drain(&mut self) { - while self.responses.next().await.is_some() {} + let event = HttpEvent { + event: Some(http_event::Event::SessionEnd(MiddlewareSessionEnd { + reason: reason as i32, + protocol_error: None, + })), + }; + let _ = tokio::time::timeout(SESSION_END_TIMEOUT, self.sender.send(event)).await; } } -impl Drop for HttpRequestStageTransport { +impl Drop for HttpStageTransport { fn drop(&mut self) { if !self.terminal_sent { - let _ = self - .sender - .try_send(session_end_event(MiddlewareSessionEndReason::Cancellation)); + let _ = self.sender.try_send(HttpEvent { + event: Some(http_event::Event::SessionEnd(MiddlewareSessionEnd { + reason: MiddlewareSessionEndReason::Cancellation as i32, + protocol_error: None, + })), + }); } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum StageMode { - HeadersOnly, - WholeBody, + Buffered { max_body_bytes: usize }, Stream, - OwnedStream, } struct HttpRequestStage { entry: DescribedChainEntry, - transport: Option, + transport: HttpStageTransport, mode: StageMode, - next_sequence: u64, - whole_body: Vec, - owned_input_bytes: usize, - owned_output_bytes: usize, - next_output_sequence: u64, - finding_count: usize, + connection_nominated_headers: Vec, } -impl HttpRequestStage { - fn is_active(&self) -> bool { - self.transport.is_some() - } +#[derive(Debug)] +enum StageFrame { + Start { output_body_bytes: Option }, + Chunk(Vec), + End(Vec), +} - async fn end(&mut self, reason: MiddlewareSessionEndReason) { - if let Some(transport) = self.transport.take() { - transport.end(reason).await; - } - } +#[derive(Default)] +struct StageReport { + findings: Vec, + metadata: BTreeMap>, + invocations: Vec, + transformed: bool, } pub struct HttpRequestSession { - runner: ChainRunner, stages: Vec, findings: Vec, metadata: BTreeMap>, invocations: Vec, session_admission: Option, - connection_nominated_headers: Vec, - body_transformed: bool, + declared_body_length: Option, + pending_input: Vec>, } impl HttpRequestSession { @@ -230,831 +228,578 @@ impl HttpRequestSession { pub fn stream_unit_limit(&self) -> usize { self.stages .iter() - .filter(|stage| { - stage.is_active() - && matches!(stage.mode, StageMode::Stream | StageMode::OwnedStream) - }) + .filter(|stage| stage.mode == StageMode::Stream) .map(|stage| { stage .entry - .max_payload_bytes + .max_payload_bytes() .clamp(1, MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) }) .min() .unwrap_or(MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) } - /// Whether any active stage must see the complete representation before - /// the supervisor may disclose request bytes upstream. #[must_use] pub fn requires_withholding(&self) -> bool { - self.stages.iter().any(|stage| { - stage.is_active() && matches!(stage.mode, StageMode::WholeBody | StageMode::OwnedStream) + self.stages + .iter() + .any(|stage| matches!(stage.mode, StageMode::Buffered { .. })) + } + + /// Run all body stages as a bounded pipeline. The caller must pump input + /// and drain output concurrently with this future. + pub async fn run( + mut self, + mut input: mpsc::Receiver, + output: mpsc::Sender, + ) -> Result { + let stage_count = self.stages.len(); + if stage_count == 0 { + return Err(failure("request_pipeline_without_stages", None)); + } + + let mut links = Vec::with_capacity(stage_count + 1); + for _ in 0..=stage_count { + links.push(mpsc::channel::(STREAM_CHANNEL_CAPACITY)); + } + let mut receivers: Vec>> = links + .iter_mut() + .map(|(_, receiver)| Some(std::mem::replace(receiver, mpsc::channel(1).1))) + .collect(); + let source = links[0].0.clone(); + let source_limit = self.stream_unit_limit(); + let source_declared = self.declared_body_length; + let source_task = tokio::spawn(async move { + source + .send(StageFrame::Start { + output_body_bytes: source_declared, + }) + .await + .map_err(|_| failure("request_pipeline_closed", None))?; + let mut ended = false; + while let Some(item) = input.recv().await { + match item { + HttpRequestBodyInput::Chunk(data) => { + if ended || data.is_empty() || data.len() > source_limit { + return Err(failure("request_stream_chunk_invalid", None)); + } + source + .send(StageFrame::Chunk(data)) + .await + .map_err(|_| failure("request_pipeline_closed", None))?; + } + HttpRequestBodyInput::End(trailers) => { + if ended { + return Err(failure("request_input_end_duplicate", None)); + } + ended = true; + source + .send(StageFrame::End(trailers)) + .await + .map_err(|_| failure("request_pipeline_closed", None))?; + break; + } + } + } + if !ended { + return Err(failure("request_input_end_missing", None)); + } + Ok::<(), HttpRequestMiddlewareFailure>(()) + }); + + let mut stage_tasks = Vec::with_capacity(stage_count); + for (index, stage) in self.stages.drain(..).enumerate() { + let receiver = receivers[index] + .take() + .expect("stage input receiver must exist"); + let sender = links[index + 1].0.clone(); + stage_tasks.push(tokio::spawn(run_stage(stage, receiver, sender))); + } + drop(links); + + let mut final_receiver = receivers[stage_count] + .take() + .expect("final output receiver must exist"); + let mut started = false; + let mut ended = false; + let mut trailers = Vec::new(); + while let Some(frame) = final_receiver.recv().await { + match frame { + StageFrame::Start { output_body_bytes } if !started => { + started = true; + output + .send(HttpRequestBodyOutput::Start { output_body_bytes }) + .await + .map_err(|_| failure("request_output_closed", None))?; + } + StageFrame::Chunk(data) if started && !ended => { + output + .send(HttpRequestBodyOutput::Chunk(data)) + .await + .map_err(|_| failure("request_output_closed", None))?; + } + StageFrame::End(value) if started && !ended => { + ended = true; + trailers = value.clone(); + output + .send(HttpRequestBodyOutput::End { trailers: value }) + .await + .map_err(|_| failure("request_output_closed", None))?; + break; + } + _ => return Err(failure("request_pipeline_event_order_invalid", None)), + } + } + drop(output); + + source_task + .await + .map_err(|_| failure("request_input_task_failed", None))??; + + let mut body_transformed = false; + for task in stage_tasks { + let report = task + .await + .map_err(|_| failure("request_stage_task_failed", None))??; + body_transformed |= report.transformed; + self.findings.extend(report.findings); + self.metadata.extend(report.metadata); + self.invocations.extend(report.invocations); + } + if !started || !ended { + return Err(failure("request_pipeline_incomplete", None)); + } + self.session_admission.take(); + Ok(HttpRequestFinish { + body_units: Vec::new(), + trailers, + body_transformed, + findings: self.findings, + metadata: self.metadata, + invocations: self.invocations, }) } - /// Process one non-final normalized body unit through the active chain. - pub async fn push_body( + /// Compatibility helper for complete-body callers. Network relays use + /// [`Self::run`] so input and output remain independent. + pub fn push_body( &mut self, data: Vec, ) -> Result>, HttpRequestMiddlewareFailure> { - if data.is_empty() { - return Err(Self::failure("request_stream_unit_empty", None)); - } - if data.len() > self.stream_unit_limit() { - return Err(Self::failure("request_stream_unit_over_capacity", None)); - } - let _work = self - .runner - .reserve_middleware_work_admission() - .await - .map_err(|error| Self::failure(&format!("middleware_failed: {error}"), None))?; - let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; - match self.process_units_from(0, vec![data], deadline).await { - Ok(units) => Ok(units), - Err(error) => { - let reason = if error.denial.is_some() { - MiddlewareSessionEndReason::MiddlewareDenial - } else { - MiddlewareSessionEndReason::MiddlewareFailure - }; - self.end_all(reason).await; - Err(error.with_diagnostics(self.take_diagnostics())) - } + if data.is_empty() || data.len() > self.stream_unit_limit() { + return Err(failure("request_stream_chunk_invalid", None)); } + self.pending_input.push(data); + Ok(Vec::new()) } - /// Finalize all body stages and collect output in memory. - /// - /// Network relays should prefer [`Self::finish_to`] so owned output remains - /// bounded by channel and storage backpressure. pub async fn finish( self, trailers: Vec, ) -> Result { - let (sender, mut receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); - let finish = self.finish_to(trailers, sender); + let (output_tx, mut output_rx) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let (body_tx, body_rx) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let pending = self.pending_input.clone(); + let run = self.run(body_rx, output_tx); + let feed = async move { + for chunk in pending { + body_tx + .send(HttpRequestBodyInput::Chunk(chunk)) + .await + .map_err(|_| failure("request_pipeline_closed", None))?; + } + body_tx + .send(HttpRequestBodyInput::End(trailers)) + .await + .map_err(|_| failure("request_pipeline_closed", None)) + }; let collect = async move { let mut units = Vec::new(); - while let Some(unit) = receiver.recv().await { - units.push(unit); + while let Some(event) = output_rx.recv().await { + if let HttpRequestBodyOutput::Chunk(data) = event { + units.push(data); + } } units }; - let (finish, units) = tokio::join!(finish, collect); + let (finish, feed, units) = tokio::join!(run, feed, collect); + feed?; let mut finish = finish?; finish.body_units = units; Ok(finish) } - /// Finalize all stages while sending normalized output through a bounded - /// channel. The receiver controls backpressure and may spool to storage. pub async fn finish_to( - mut self, - mut trailers: Vec, + self, + trailers: Vec, output: mpsc::Sender>, ) -> Result { - let _work = match self.runner.reserve_middleware_work_admission().await { - Ok(work) => work, - Err(error) => { - return Err(HttpRequestMiddlewareFailure { - reason: format!("middleware_failed: {error}"), - denial: None, - diagnostics: Box::new(self.take_diagnostics()), - }); + let (event_tx, mut event_rx) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let (body_tx, body_rx) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let pending = self.pending_input.clone(); + let run = self.run(body_rx, event_tx); + let feed = async move { + for chunk in pending { + body_tx + .send(HttpRequestBodyInput::Chunk(chunk)) + .await + .map_err(|_| failure("request_pipeline_closed", None))?; } + body_tx + .send(HttpRequestBodyInput::End(trailers)) + .await + .map_err(|_| failure("request_pipeline_closed", None)) }; - let deadline = Instant::now() + MAX_MIDDLEWARE_CHAIN_TIMEOUT; - for index in 0..self.stages.len() { - let result = self.finish_stage_to(index, deadline, &output).await; - if let Err(failure) = result { - self.end_all(MiddlewareSessionEndReason::MiddlewareFailure) - .await; - return Err(failure.with_diagnostics(self.take_diagnostics())); - } - } - - 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())); + let forward = async move { + while let Some(event) = event_rx.recv().await { + if let HttpRequestBodyOutput::Chunk(data) = event { + output + .send(data) + .await + .map_err(|_| failure("request_output_closed", None))?; + } } + Ok::<(), HttpRequestMiddlewareFailure>(()) }; - self.end_all(MiddlewareSessionEndReason::Normal).await; - self.session_admission.take(); - drop(output); - Ok(HttpRequestFinish { - body_units: Vec::new(), - trailers, - body_transformed: self.body_transformed, - findings: self.findings, - metadata: self.metadata, - invocations: self.invocations, - }) + let (finish, feed, forward) = tokio::join!(run, feed, forward); + feed?; + forward?; + finish } pub async fn end(mut self, reason: MiddlewareSessionEndReason) { - self.end_all(reason).await; + for stage in &mut self.stages { + stage.transport.end(reason).await; + } + self.session_admission.take(); } +} - async fn process_units_from( - &mut self, - start: usize, - mut units: Vec>, - deadline: Instant, - ) -> Result>, HttpRequestMiddlewareFailure> { - // An empty replacement deletes the current unit. Streaming and owned - // stages receive only nonempty data units followed by their own single - // empty end-of-stream unit during `finish_stage_to`. - units.retain(|unit| !unit.is_empty()); - for index in start..self.stages.len() { - let mut next = Vec::new(); - for unit in units { - let chunk_limit = if matches!( - self.stages[index].mode, - StageMode::Stream | StageMode::OwnedStream - ) { - self.stages[index] - .entry - .max_payload_bytes - .min(MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) - } else { - unit.len().max(1) - }; - for chunk in unit.chunks(chunk_limit) { - next.extend( - self.process_stage_unit(index, chunk.to_vec(), deadline) - .await?, - ); +async fn run_stage( + mut stage: HttpRequestStage, + input: mpsc::Receiver, + output: mpsc::Sender, +) -> Result { + send_event_for_entry( + &stage.entry, + &stage.transport.sender, + HttpEvent { + event: Some(http_event::Event::Begin(HttpBegin {})), + }, + ) + .await?; + let result = match stage.mode { + StageMode::Buffered { max_body_bytes } => { + run_buffered_stage(&mut stage, input, output, max_body_bytes).await + } + StageMode::Stream => run_stream_stage(&mut stage, input, output).await, + }; + stage + .transport + .end(if result.is_ok() { + MiddlewareSessionEndReason::Normal + } else { + MiddlewareSessionEndReason::MiddlewareFailure + }) + .await; + result +} + +async fn run_buffered_stage( + stage: &mut HttpRequestStage, + mut input: mpsc::Receiver, + output: mpsc::Sender, + max_body_bytes: usize, +) -> Result { + let mut body = Vec::new(); + let mut trailers = None; + let mut saw_start = false; + while let Some(frame) = input.recv().await { + match frame { + StageFrame::Start { .. } if !saw_start => saw_start = true, + StageFrame::Chunk(data) if saw_start && trailers.is_none() => { + if body.len().saturating_add(data.len()) > max_body_bytes { + return Err(stage_failure(stage, "buffered_input_over_capacity")); } + body.extend_from_slice(&data); } - units = next; - if units.is_empty() - && self.stages[index + 1..].iter().all(|stage| { - !matches!(stage.mode, StageMode::WholeBody | StageMode::OwnedStream) - }) - { + StageFrame::End(value) if saw_start && trailers.is_none() => { + trailers = Some(value); break; } + _ => return Err(stage_failure(stage, "buffered_input_order_invalid")), } - Ok(units) } - - async fn process_stage_unit( - &mut self, - index: usize, - data: Vec, - deadline: Instant, - ) -> Result>, HttpRequestMiddlewareFailure> { - if !self.stages[index].is_active() || self.stages[index].mode == StageMode::HeadersOnly { - return Ok(vec![data]); - } - if self.stages[index].mode == StageMode::WholeBody { - if self.stages[index] - .whole_body - .len() - .saturating_add(data.len()) - > self.stages[index].entry.max_payload_bytes - { - let mut original = std::mem::take(&mut self.stages[index].whole_body); - original.extend_from_slice(&data); - return self - .handle_stage_failure(index, "whole_body_over_capacity", None, original) - .await; - } - self.stages[index].whole_body.extend_from_slice(&data); - return Ok(Vec::new()); + let mut trailers = + trailers.ok_or_else(|| stage_failure(stage, "buffered_input_end_missing"))?; + let original_len = body.len(); + let result = exchange( + stage, + HttpEvent { + event: Some(http_event::Event::BufferedBody(HttpBufferedBody { + data: body.clone(), + visible_trailers: trailers.clone(), + })), + }, + ) + .await?; + let buffered = match result.result { + Some(http_result::Result::BufferedResult(result)) => result, + Some(http_result::Result::Reject(reject)) => { + return Err(rejection(stage, reject.diagnostics)); } - - let sequence = self.stages[index].next_sequence; - self.stages[index].next_sequence += 1; - if self.stages[index].mode == StageMode::OwnedStream - && self.stages[index] - .owned_input_bytes - .saturating_add(data.len()) - > MAX_HTTP_REQUEST_DEFERRED_BYTES - { - return self - .handle_stage_failure( - index, - "owned_input_over_capacity", - Some(sequence), - Vec::new(), - ) - .await; + _ => return Err(stage_failure(stage, "buffered_result_expected")), + }; + let diagnostics = validate_diagnostics_message(buffered.diagnostics.as_ref())?; + if !buffered.header_mutations.is_empty() { + return Err(stage_failure(stage, "late_header_mutations_not_permitted")); + } + trailers = headers::apply( + headers::HeaderAuthority::RequestTrailers, + &trailers, + &stage.connection_nominated_headers, + &buffered.trailer_mutations, + ) + .map_err(|error| mutation_failure(stage, &error))?; + let (body, outcome, transformed) = match buffered.body { + Some(http_buffered_result::Body::Unchanged(HttpUnchanged {})) => { + (body, HttpRequestInvocationOutcome::Unchanged, false) } - let event = body_event(sequence, data.clone(), false); - let result = match exchange(&mut self.stages[index], event, deadline).await { - Ok(result) => result, - Err(reason) => { - return self - .handle_stage_failure(index, &reason, Some(sequence), data) - .await; + Some(http_buffered_result::Body::Replacement(replacement)) => { + if replacement.len() > max_body_bytes { + return Err(stage_failure(stage, "buffered_output_over_capacity")); } - }; - self.apply_body_result(index, result, sequence, data, false) - .await - } - - async fn finish_stage_to( - &mut self, - index: usize, - deadline: Instant, - output: &mpsc::Sender>, - ) -> Result<(), HttpRequestMiddlewareFailure> { - if !self.stages[index].is_active() || self.stages[index].mode == StageMode::HeadersOnly { - return Ok(()); + (replacement, HttpRequestInvocationOutcome::Replacement, true) } - let mode = self.stages[index].mode; - let data = if mode == StageMode::WholeBody { - std::mem::take(&mut self.stages[index].whole_body) - } else { - Vec::new() - }; - let sequence = self.stages[index].next_sequence; - self.stages[index].next_sequence += 1; - let result = match exchange( - &mut self.stages[index], - body_event(sequence, data.clone(), true), - deadline, - ) + None => return Err(stage_failure(stage, "buffered_body_result_missing")), + }; + output + .send(StageFrame::Start { + output_body_bytes: Some(body.len() as u64), + }) .await - { - Ok(result) => result, - Err(reason) => { - let original = if mode == StageMode::OwnedStream { - Vec::new() - } else { - data - }; - let recovered = self - .handle_stage_failure(index, &reason, Some(sequence), original) - .await?; - return self - .emit_downstream(index + 1, recovered, deadline, output) - .await; - } - }; - let recovered = self - .apply_body_result(index, result, sequence, data, true) - .await?; - self.emit_downstream(index + 1, recovered, deadline, output) - .await?; - - if mode == StageMode::OwnedStream && self.stages[index].is_active() { - self.drain_owned_output(index, sequence, deadline, output) - .await?; - } - Ok(()) + .map_err(|_| stage_failure(stage, "request_output_closed"))?; + for chunk in body.chunks(MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) { + output + .send(StageFrame::Chunk(chunk.to_vec())) + .await + .map_err(|_| stage_failure(stage, "request_output_closed"))?; } + output + .send(StageFrame::End(trailers)) + .await + .map_err(|_| stage_failure(stage, "request_output_closed"))?; + Ok(report_from_diagnostics( + stage, + diagnostics, + outcome, + original_len, + Some(body.len()), + transformed, + )) +} - async fn drain_owned_output( - &mut self, - index: usize, - final_input_sequence: u64, - deadline: Instant, - output: &mpsc::Sender>, - ) -> Result<(), HttpRequestMiddlewareFailure> { - loop { - let result = next_result(&mut self.stages[index], deadline) - .await - .map_err(|reason| { - Self::failure(&format!("middleware_failed: {reason}"), Some(index)) - })?; - match result.result { - Some(http_request_event_result::Result::BodyOutput(body_output)) => { - self.validate_owned_output(index, &body_output)?; - let downstream = self - .process_units_from(index + 1, vec![body_output.data], deadline) - .await?; - Self::send_output(output, downstream).await?; - } - Some(http_request_event_result::Result::BodyFinalize(finalize)) => { - let stage = &self.stages[index]; - let final_output_sequence = stage.next_output_sequence.saturating_sub(1); - if finalize.through_input_sequence != final_input_sequence - || finalize.through_output_sequence != final_output_sequence +async fn run_stream_stage( + stage: &mut HttpRequestStage, + mut input: mpsc::Receiver, + output: mpsc::Sender, +) -> Result { + let input_ended = Arc::new(AtomicBool::new(false)); + let input_trailers = Arc::new(Mutex::new(None::>)); + let input_bytes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let output_bytes = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let (input_delivered, mut input_delivered_rx) = watch::channel(false); + + let sender = stage.transport.sender.clone(); + let input_entry = stage.entry.clone(); + let input_ended_for_pump = Arc::clone(&input_ended); + let trailers_for_pump = Arc::clone(&input_trailers); + let input_bytes_for_pump = Arc::clone(&input_bytes); + let input_pump = async move { + let mut saw_start = false; + while let Some(frame) = input.recv().await { + match frame { + StageFrame::Start { .. } if !saw_start => saw_start = true, + StageFrame::Chunk(data) if saw_start => { + if data.is_empty() + || data.len() + > input_entry + .max_payload_bytes() + .min(MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) { - return Err(Self::failure( - "middleware_failed: invalid_owned_finalization", - Some(index), + return Err(failure_for_entry( + &input_entry, + "stream_input_chunk_invalid", )); } - if let Err(reason) = validate_stage_diagnostics( - self.stages[index].finding_count, - &finalize.reason, - &finalize.reason_code, - &finalize.findings, - &finalize.metadata, - ) { - return Err(Self::failure( - &format!("middleware_failed: {reason}"), - Some(index), - )); - } - let reason_code = - (!finalize.reason_code.is_empty()).then(|| finalize.reason_code.clone()); - self.stages[index].finding_count += finalize.findings.len(); - collect_diagnostics( - &self.stages[index].entry, - finalize.findings, - finalize.metadata, - &mut self.findings, - &mut self.metadata, - ); - record_request_invocation( - &mut self.invocations, - body_invocation( - &self.stages[index], - HttpRequestInvocationOutcome::Transform, - final_input_sequence, - self.stages[index].owned_input_bytes, - self.stages[index].owned_output_bytes, - reason_code, - ), - ); - return Ok(()); + input_bytes_for_pump.fetch_add(data.len(), Ordering::Relaxed); + send_event_for_entry( + &input_entry, + &sender, + HttpEvent { + event: Some(http_event::Event::InputChunk(HttpInputChunk { data })), + }, + ) + .await?; + } + StageFrame::End(trailers) if saw_start => { + *trailers_for_pump.lock().expect("trailer lock poisoned") = + Some(trailers.clone()); + // A Finish can race the sender task as soon as the peer + // consumes InputEnd. Publish the state before enqueueing + // the event; a failed send still fails the joined pump. + input_ended_for_pump.store(true, Ordering::Release); + send_event_for_entry( + &input_entry, + &sender, + HttpEvent { + event: Some(http_event::Event::InputEnd(HttpInputEnd { + visible_trailers: trailers, + })), + }, + ) + .await?; + input_delivered.send_replace(true); + return Ok::<(), HttpRequestMiddlewareFailure>(()); } _ => { - return Err(Self::failure( - "middleware_failed: unexpected_owned_output_result", - Some(index), + return Err(failure_for_entry( + &input_entry, + "stream_input_order_invalid", )); } } } - } - - fn validate_owned_output( - &mut self, - index: usize, - output: &HttpRequestBodyOutput, - ) -> Result<(), HttpRequestMiddlewareFailure> { - let stage = &mut self.stages[index]; - if output.sequence != stage.next_output_sequence { - return Err(HttpRequestMiddlewareFailure { - reason: "middleware_failed: invalid_owned_output_sequence".into(), - denial: None, - diagnostics: Box::default(), - }); - } - if output.data.len() > stage.entry.max_payload_bytes { - return Err(HttpRequestMiddlewareFailure { - reason: "middleware_failed: owned_output_unit_over_capacity".into(), - denial: None, - diagnostics: Box::default(), - }); - } - stage.owned_output_bytes = stage.owned_output_bytes.saturating_add(output.data.len()); - if stage.owned_output_bytes > MAX_HTTP_REQUEST_DEFERRED_BYTES { - return Err(HttpRequestMiddlewareFailure { - reason: "middleware_failed: owned_output_over_capacity".into(), - denial: None, - diagnostics: Box::default(), - }); - } - stage.next_output_sequence += 1; - Ok(()) - } - - async fn emit_downstream( - &mut self, - start: usize, - units: Vec>, - deadline: Instant, - output: &mpsc::Sender>, - ) -> Result<(), HttpRequestMiddlewareFailure> { - let units = self.process_units_from(start, units, deadline).await?; - Self::send_output(output, units).await - } - - async fn send_output( - output: &mpsc::Sender>, - units: Vec>, - ) -> Result<(), HttpRequestMiddlewareFailure> { - for unit in units { - output - .send(unit) - .await - .map_err(|_| HttpRequestMiddlewareFailure { - reason: "request_output_consumer_closed".into(), - denial: None, - diagnostics: Box::default(), - })?; - } - Ok(()) - } - - async fn apply_body_result( - &mut self, - index: usize, - result: HttpRequestEventResult, - sequence: u64, - original: Vec, - end_of_stream: bool, - ) -> Result>, HttpRequestMiddlewareFailure> { - let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { - return self - .handle_stage_failure(index, "unexpected_body_result", Some(sequence), original) - .await; - }; - if result.sequence != sequence { - return self - .handle_stage_failure(index, "invalid_body_sequence", Some(sequence), original) - .await; - } - if let Err(reason) = validate_stage_diagnostics( - self.stages[index].finding_count, - &result.reason, - &result.reason_code, - &result.findings, - &result.metadata, - ) { - return self - .handle_stage_failure(index, reason, Some(sequence), original) - .await; - } - let reason_code = (!result.reason_code.is_empty()).then(|| result.reason_code.clone()); - self.stages[index].finding_count += result.findings.len(); - collect_diagnostics( - &self.stages[index].entry, - result.findings, - result.metadata, - &mut self.findings, - &mut self.metadata, - ); - - let owned = self.stages[index].mode == StageMode::OwnedStream; - let action = result.action; - if owned { - if !matches!( - action, - Some(http_request_body_result::Action::TakeOwnership(_)) - ) { - return self - .handle_stage_failure( - index, - "owned_stage_did_not_take_ownership", - Some(sequence), - Vec::new(), - ) - .await; - } - self.stages[index].owned_input_bytes = self.stages[index] - .owned_input_bytes - .saturating_add(original.len()); - self.body_transformed = true; - record_request_invocation( - &mut self.invocations, - body_invocation( - &self.stages[index], - HttpRequestInvocationOutcome::TakeOwnership, - sequence, - original.len(), - 0, - reason_code, - ), - ); - return Ok(Vec::new()); - } + Err(failure_for_entry(&input_entry, "stream_input_end_missing")) + }; - let (outcome, replacement, skip_remaining) = match action { - Some(http_request_body_result::Action::PassThrough(_)) => ( - HttpRequestInvocationOutcome::PassThrough, - original.clone(), - false, - ), - Some(http_request_body_result::Action::Transform(transform)) => { - let Some(http_request_body_transform::Replacement::Data(data)) = - transform.replacement - else { - return self - .handle_stage_failure( - index, - "missing_body_replacement", - Some(sequence), - original, - ) - .await; - }; - if data.len() > self.stages[index].entry.max_payload_bytes { - return self - .handle_stage_failure( - index, - "body_replacement_over_capacity", - Some(sequence), - original, - ) - .await; + let entry = stage.entry.clone(); + let diagnostic_policy = stage + .entry + .service + .as_ref() + .map_or(MiddlewareDiagnosticPolicy::Preserve, |service| { + service.diagnostic_policy + }); + let responses = &mut stage.transport.responses; + let connection_nominated = stage.connection_nominated_headers.clone(); + let output_bytes_for_pump = Arc::clone(&output_bytes); + let output_pump = async move { + let mut started = false; + let mut declared_output = None; + loop { + // STREAM output is independent of input. A whole-body service may + // legitimately withhold OutputStart until it has consumed all + // input, so apply the response idle timeout only after InputEnd has + // been delivered. Transport closure and early output remain + // observable while the input pump is active. + let result = loop { + if *input_delivered_rx.borrow() { + break next_result_for_entry(&entry, diagnostic_policy, responses).await?; } - self.body_transformed = true; - (HttpRequestInvocationOutcome::Transform, data, false) - } - Some(http_request_body_result::Action::SkipRemaining(skip)) => { - let replacement = match skip.current { - Some(http_request_body_skip_remaining::Current::PassThrough(_)) => { - original.clone() + tokio::select! { + result = responses.next() => { + break validate_next_result_for_entry( + &entry, + diagnostic_policy, + result, + )?; } - Some(http_request_body_skip_remaining::Current::Transform(transform)) => { - let Some(http_request_body_transform::Replacement::Data(data)) = - transform.replacement - else { - return self - .handle_stage_failure( - index, - "missing_body_replacement", - Some(sequence), - original, - ) - .await; - }; - if data.len() > self.stages[index].entry.max_payload_bytes { - return self - .handle_stage_failure( - index, - "body_replacement_over_capacity", - Some(sequence), - original, - ) - .await; + changed = input_delivered_rx.changed() => { + if changed.is_err() { + return Err(failure_for_entry( + &entry, + "stream_input_end_missing", + )); } - self.body_transformed = true; - data - } - None => { - return self - .handle_stage_failure( - index, - "missing_skip_remaining_action", - Some(sequence), - original, - ) - .await; } - }; - ( - HttpRequestInvocationOutcome::SkipRemaining, - replacement, - true, - ) - } - Some(http_request_body_result::Action::BlockRequest(_)) => { - let denial = super::MiddlewareDenial { - config_name: self.stages[index].entry.entry.name.clone(), - reason_code: reason_code.clone(), - }; - record_request_invocation( - &mut self.invocations, - body_invocation( - &self.stages[index], - HttpRequestInvocationOutcome::BlockRequest, - sequence, - original.len(), - 0, - reason_code, - ), - ); - self.end_all(MiddlewareSessionEndReason::MiddlewareDenial) - .await; - return Err(HttpRequestMiddlewareFailure { - reason: middleware_denial_reason( - &denial.config_name, - denial.reason_code.as_deref(), - ), - denial: Some(denial), - diagnostics: Box::default(), - }); - } - Some(http_request_body_result::Action::TakeOwnership(_)) | None => { - return self - .handle_stage_failure(index, "invalid_body_action", Some(sequence), original) - .await; - } - }; - record_request_invocation( - &mut self.invocations, - body_invocation( - &self.stages[index], - outcome, - sequence, - original.len(), - replacement.len(), - reason_code, - ), - ); - if skip_remaining { - self.stages[index] - .end(MiddlewareSessionEndReason::StageSkipped) - .await; - self.release_admission_if_idle(); - } else if end_of_stream { - // Keep the transport open for the trailers event. - } - Ok(vec![replacement]) - } - - async fn process_trailers( - &mut self, - mut trailers: Vec, - deadline: Instant, - ) -> Result, HttpRequestMiddlewareFailure> { - for index in 0..self.stages.len() { - if !self.stages[index].is_active() || self.stages[index].mode == StageMode::HeadersOnly - { - continue; - } - let event = HttpRequestEvent { - event: Some(http_request_event::Event::Trailers(HttpRequestTrailers { - 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 Some(http_request_event_result::Result::TrailersResult(result)) = result.result - else { - trailers = self - .handle_trailer_failure(index, "unexpected_trailers_result", trailers) - .await?; - continue; - }; - if let Err(reason) = validate_stage_diagnostics( - self.stages[index].finding_count, - &result.reason, - &result.reason_code, - &result.findings, - &result.metadata, - ) { - trailers = self.handle_trailer_failure(index, reason, trailers).await?; - continue; - } - self.stages[index].finding_count += result.findings.len(); - let updated = match headers::apply( - headers::HeaderAuthority::RequestTrailers, - &trailers, - &self.connection_nominated_headers, - &result.trailer_mutations, - ) { - Ok(updated) => updated, - Err(error) => { - let reason = self.stages[index].entry.service.as_ref().map_or_else( - || error.to_string(), - |service| { - service - .diagnostic_policy - .header_mutation_error_reason(&error) - }, - ); - trailers = self - .handle_trailer_failure(index, &reason, trailers) - .await?; - continue; + match result.result { + Some(http_result::Result::OutputStart(start)) if !started => { + if !start.header_mutations.is_empty() { + return Err(failure_for_entry( + &entry, + "late_header_mutations_not_permitted", + )); + } + declared_output = start.output_body_bytes; + started = true; + output + .send(StageFrame::Start { + output_body_bytes: declared_output, + }) + .await + .map_err(|_| failure_for_entry(&entry, "request_output_closed"))?; } - }; - collect_diagnostics( - &self.stages[index].entry, - result.findings, - result.metadata, - &mut self.findings, - &mut self.metadata, - ); - record_request_invocation( - &mut self.invocations, - HttpRequestInvocation { - config_name: self.stages[index].entry.entry.name.clone(), - implementation: self.stages[index].entry.entry.implementation.clone(), - outcome: HttpRequestInvocationOutcome::Trailers, - sequence: None, - input_size: encoded_header_bytes(&trailers), - output_size: Some(encoded_header_bytes(&updated)), - failed: false, - stage_disabled: false, - reason_code: (!result.reason_code.is_empty()).then_some(result.reason_code), - failure_category: None, - }, - ); - trailers = updated; - } - Ok(trailers) - } - - async fn handle_trailer_failure( - &mut self, - index: usize, - reason: &str, - original: Vec, - ) -> Result, HttpRequestMiddlewareFailure> { - let stage = &mut self.stages[index]; - let fail_open = stage.entry.on_error() == OnError::FailOpen; - record_request_invocation( - &mut self.invocations, - HttpRequestInvocation { - config_name: stage.entry.entry.name.clone(), - implementation: stage.entry.entry.implementation.clone(), - outcome: if fail_open { - HttpRequestInvocationOutcome::FailOpen - } else { - HttpRequestInvocationOutcome::FailClosed - }, - sequence: None, - input_size: encoded_header_bytes(&original), - output_size: None, - failed: true, - stage_disabled: true, - reason_code: None, - failure_category: Some(request_failure_category(reason).into()), - }, - ); - stage - .end(MiddlewareSessionEndReason::MiddlewareFailure) - .await; - self.release_admission_if_idle(); - if fail_open { - Ok(original) - } else { - Err(HttpRequestMiddlewareFailure { - reason: format!("middleware_failed: {reason}"), - denial: None, - diagnostics: Box::default(), - }) - } - } - - async fn handle_stage_failure( - &mut self, - index: usize, - reason: &str, - sequence: Option, - original: Vec, - ) -> Result>, HttpRequestMiddlewareFailure> { - let stage = &mut self.stages[index]; - let fail_open = - stage.entry.on_error() == OnError::FailOpen && stage.mode != StageMode::OwnedStream; - record_request_invocation( - &mut self.invocations, - HttpRequestInvocation { - config_name: stage.entry.entry.name.clone(), - implementation: stage.entry.entry.implementation.clone(), - outcome: if fail_open { - HttpRequestInvocationOutcome::FailOpen - } else { - HttpRequestInvocationOutcome::FailClosed - }, - sequence, - input_size: original.len(), - output_size: None, - failed: true, - stage_disabled: true, - reason_code: None, - failure_category: Some(request_failure_category(reason).into()), - }, - ); - stage - .end(MiddlewareSessionEndReason::MiddlewareFailure) - .await; - self.release_admission_if_idle(); - if fail_open { - Ok(vec![original]) - } else { - Err(HttpRequestMiddlewareFailure { - reason: format!("middleware_failed: {reason}"), - denial: None, - diagnostics: Box::default(), - }) - } - } - - async fn end_all(&mut self, reason: MiddlewareSessionEndReason) { - for stage in &mut self.stages { - stage.end(reason).await; - } - self.session_admission.take(); - } - - fn release_admission_if_idle(&mut self) { - if self.stages.iter().all(|stage| !stage.is_active()) { - self.session_admission.take(); + Some(http_result::Result::OutputChunk(chunk)) if started => { + if chunk.data.is_empty() + || chunk.data.len() + > entry + .max_payload_bytes() + .min(MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) + { + return Err(failure_for_entry(&entry, "stream_output_chunk_invalid")); + } + let total = output_bytes_for_pump + .fetch_add(chunk.data.len(), Ordering::Relaxed) + + chunk.data.len(); + if declared_output.is_some_and(|declared| total as u64 > declared) { + return Err(failure_for_entry(&entry, "stream_output_length_mismatch")); + } + output + .send(StageFrame::Chunk(chunk.data)) + .await + .map_err(|_| failure_for_entry(&entry, "request_output_closed"))?; + } + Some(http_result::Result::Finish(finish)) if started => { + if !input_ended.load(Ordering::Acquire) { + return Err(failure_for_entry(&entry, "stream_finish_before_input_end")); + } + let total = output_bytes_for_pump.load(Ordering::Relaxed) as u64; + if declared_output.is_some_and(|declared| declared != total) { + return Err(failure_for_entry(&entry, "stream_output_length_mismatch")); + } + let diagnostics = validate_diagnostics_message(finish.diagnostics.as_ref())?; + let trailers = input_trailers + .lock() + .expect("trailer lock poisoned") + .clone() + .ok_or_else(|| failure_for_entry(&entry, "stream_input_end_missing"))?; + let trailers = headers::apply( + headers::HeaderAuthority::RequestTrailers, + &trailers, + &connection_nominated, + &finish.trailer_mutations, + ) + .map_err(|error| { + mutation_failure_for_entry(&entry, diagnostic_policy, &error) + })?; + output + .send(StageFrame::End(trailers)) + .await + .map_err(|_| failure_for_entry(&entry, "request_output_closed"))?; + return Ok::(diagnostics); + } + Some(http_result::Result::Reject(reject)) => { + return Err(rejection_for_entry(&entry, reject.diagnostics)); + } + _ => return Err(failure_for_entry(&entry, "stream_result_order_invalid")), + } } - } + }; - fn failure(reason: &str, _index: Option) -> HttpRequestMiddlewareFailure { - HttpRequestMiddlewareFailure { - reason: reason.to_string(), - // Transport and protocol failures are not authoritative service - // denials. The caller still fails closed when policy requires it, - // but must not present the failure as an accepted block decision. - denial: None, - diagnostics: Box::default(), - } - } + let ((), diagnostics) = tokio::try_join!(input_pump, output_pump)?; + let input_size = input_bytes.load(Ordering::Relaxed); + let output_size = output_bytes.load(Ordering::Relaxed); + Ok(report_from_diagnostics( + stage, + diagnostics, + HttpRequestInvocationOutcome::Finish, + input_size, + Some(output_size), + true, + )) } impl ChainRunner { @@ -1072,28 +817,21 @@ impl ChainRunner { &self, described: Vec, input: HttpRequestPreflightInput, - ) -> miette::Result { - self.preflight_described_http_request_with_owned(described, input, true) - .await - } - - /// Open a request stream with explicit control over storage-backed owned - /// mode. Complete-body compatibility callers disable owned mode because - /// their result is necessarily materialized in memory. - pub(crate) async fn preflight_described_http_request_with_owned( - &self, - described: Vec, - input: HttpRequestPreflightInput, - allow_owned: bool, ) -> miette::Result { if described.is_empty() { return Ok(empty_preflight_outcome(input.headers)); } if validate_preflight_input(&input).is_err() { - return Ok(preflight_input_failure( - &described, + return Ok(failed_preflight_outcome( input.headers, - "request_input_over_capacity", + Vec::new(), + "middleware_failed: request_input_over_capacity".into(), + Vec::new(), + BTreeMap::new(), + described + .iter() + .map(|entry| failed_invocation(entry, "request_input_over_capacity")) + .collect(), )); } let session_admission = match self.try_reserve_middleware_session() { @@ -1102,13 +840,6 @@ impl ChainRunner { return Ok(session_capacity_exhausted(described, input.headers)); } }; - let work_admission = self.reserve_middleware_work().await?; - let _work = match work_admission { - super::MiddlewareWorkAdmissionOutcome::Admitted(admission) => admission, - super::MiddlewareWorkAdmissionOutcome::QueueExhausted => { - return Ok(session_capacity_exhausted(described, input.headers)); - } - }; let mut headers = input.headers.clone(); let mut header_mutations = Vec::new(); let mut stages = Vec::new(); @@ -1117,44 +848,53 @@ impl ChainRunner { let mut invocations = Vec::new(); for entry in described { + if entry.on_error() == OnError::FailOpen { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + invocations.push(failed_invocation(&entry, "http_fail_open_unsupported")); + return Ok(failed_preflight_outcome( + headers, + header_mutations, + "middleware_failed: HTTP middleware no longer supports on_error=fail_open; use fail_closed or remove on_error".into(), + findings, + metadata, + invocations, + )); + } 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, - header_mutations, - reason, - findings, - metadata, - invocations, - )); - } - continue; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + invocations.push(failed_invocation(&entry, "binding_not_described")); + return Ok(failed_preflight_outcome( + headers, + header_mutations, + "middleware_failed: binding_not_described".into(), + findings, + metadata, + invocations, + )); }; - let permitted_modes = permitted_body_modes(&input, &entry, allow_owned); + let supported = entry.binding.as_ref().map_or(&[][..], |binding| { + binding.supported_http_body_modes.as_slice() + }); + let permitted_modes = permitted_body_modes(&input, &entry, supported); + let limits = body_limits(&entry); let (sender, receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); - let preflight = HttpRequestPreflight { - context: Some(input.context.clone()), - target: Some(input.target.clone()), - headers: headers.clone(), - middleware_name: entry.entry.implementation.clone(), - config: Some(entry.entry.config.clone()), - max_payload_bytes: entry.max_payload_bytes as u64, + let preflight = HttpPreflight { + head: Some(http_preflight::Head::Request(HttpRequestPreflightHead { + context: Some(input.context.clone()), + target: Some(input.target.clone()), + headers: headers.clone(), + middleware_name: entry.entry.implementation.clone(), + config: Some(entry.entry.config.clone()), + })), permitted_body_modes: permitted_modes.clone(), - max_deferred_bytes: if entry.on_error() == OnError::FailClosed { - MAX_HTTP_REQUEST_DEFERRED_BYTES as u64 - } else { - 0 - }, - declared_body_length: input.declared_body_length, + late_header_modes: Vec::new(), + limits: Some(limits), + declared_input_bytes: input.declared_body_length, }; - let timeout = entry.timeout; - let opened = tokio::time::timeout(timeout, async { + let opened = tokio::time::timeout(entry.timeout(), async { sender - .send(HttpRequestEvent { - event: Some(http_request_event::Event::Preflight(preflight)), + .send(HttpEvent { + event: Some(http_event::Event::Preflight(preflight)), }) .await .map_err(|_| tonic::Status::unavailable("middleware request stream closed"))?; @@ -1162,232 +902,217 @@ impl ChainRunner { .service .open_http_request_pre_credentials(receiver) .await?; - let response = responses.next().await.ok_or_else(|| { + let result = responses.next().await.ok_or_else(|| { tonic::Status::unavailable("middleware result stream closed") })??; - Ok::<_, tonic::Status>((responses, response)) + Ok::<_, tonic::Status>((responses, result)) }) .await; - let (responses, response) = match opened { - Ok(Ok(opened)) => opened, + let (responses, result) = match opened { + Ok(Ok(value)) => value, Ok(Err(error)) => { let reason = 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, - header_mutations, - 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, - header_mutations, - reason, - findings, - metadata, - invocations, - )); - } - continue; - } - }; - let mut current_stage = HttpRequestStage { - entry: entry.clone(), - transport: Some(HttpRequestStageTransport { - sender, - responses, - terminal_sent: false, - }), - mode: StageMode::HeadersOnly, - next_sequence: 1, - whole_body: Vec::new(), - owned_input_bytes: 0, - owned_output_bytes: 0, - next_output_sequence: 1, - finding_count: 0, - }; - let Some(http_request_event_result::Result::PreflightResult(result)) = response.result - else { - if let Some(reason) = handle_opened_preflight_failure( - &entry, - &mut current_stage, - &mut stages, - "unexpected_preflight_result", - &mut invocations, - ) - .await - { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + invocations.push(failed_invocation(&entry, &reason)); return Ok(failed_preflight_outcome( headers, header_mutations, - reason, + format!("middleware_failed: {reason}"), findings, metadata, invocations, )); } - continue; - }; - if let Err(reason) = validate_diagnostics( - &result.reason, - &result.reason_code, - &result.findings, - &result.metadata, - ) { - if let Some(reason) = handle_opened_preflight_failure( - &entry, - &mut current_stage, - &mut stages, - reason, - &mut invocations, - ) - .await - { + Err(_) => { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + invocations.push(failed_invocation(&entry, "middleware_timeout")); return Ok(failed_preflight_outcome( headers, header_mutations, - reason, + "middleware_failed: middleware_timeout".into(), findings, metadata, invocations, )); } - continue; - } - let reason_code = (!result.reason_code.is_empty()).then(|| result.reason_code.clone()); - current_stage.finding_count = result.findings.len(); - let result_findings = result.findings; - let result_metadata = result.metadata; - match result.action { - Some(http_request_preflight_result::Action::Skip(_)) => { - collect_diagnostics( - &entry, - result_findings, - result_metadata, - &mut findings, - &mut metadata, - ); - invocations.push(preflight_invocation( - &entry, - HttpRequestInvocationOutcome::Skip, - reason_code, - )); - current_stage - .end(MiddlewareSessionEndReason::StageSkipped) - .await; - } - Some(http_request_preflight_result::Action::Inspect(inspect)) => { - let mode = match validate_inspect(&inspect, &permitted_modes) { - Ok(mode) => mode, - Err(reason) => { - if let Some(reason) = handle_opened_preflight_failure( - &entry, - &mut current_stage, - &mut stages, - reason, - &mut invocations, - ) - .await - { + }; + let mut stage = HttpRequestStage { + entry: entry.clone(), + transport: HttpStageTransport { + sender, + responses, + terminal_sent: false, + }, + mode: StageMode::Stream, + connection_nominated_headers: input.connection_nominated_headers.clone(), + }; + match result.result { + Some(http_result::Result::PreflightResult(result)) => { + let diagnostics = + match validate_diagnostics_message(result.diagnostics.as_ref()) { + Ok(value) => value, + Err(error) => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages( + &mut stages, + MiddlewareSessionEndReason::MiddlewareFailure, + ) + .await; + invocations.push(failed_invocation(&entry, &error.reason)); return Ok(failed_preflight_outcome( headers, header_mutations, - reason, + error.reason, findings, metadata, invocations, )); } - continue; - } - }; + }; let updated = match headers::apply( headers::HeaderAuthority::Request, &headers, &input.connection_nominated_headers, - &inspect.header_mutations, + &result.header_mutations, ) { - Ok(updated) => updated, + Ok(value) => value, Err(error) => { let reason = service .diagnostic_policy .header_mutation_error_reason(&error); - if let Some(reason) = handle_opened_preflight_failure( + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + invocations.push(failed_invocation(&entry, &reason)); + return Ok(failed_preflight_outcome( + headers, + header_mutations, + format!("middleware_failed: {reason}"), + findings, + metadata, + invocations, + )); + } + }; + headers = updated; + header_mutations.extend(result.header_mutations); + collect_diagnostics(&entry, diagnostics.clone(), &mut findings, &mut metadata); + let reason_code = nonempty(&diagnostics.reason_code); + match result.decision { + Some(http_preflight_result::Decision::ContinueWithoutBody(_)) => { + invocations.push(preflight_invocation( &entry, - &mut current_stage, - &mut stages, - &reason, - &mut invocations, - ) - .await - { + HttpRequestInvocationOutcome::Continue, + reason_code, + )); + stage + .transport + .end(MiddlewareSessionEndReason::StageSkipped) + .await; + } + Some(http_preflight_result::Decision::Inspect(inspect)) => { + let mode = match validate_inspect( + &inspect, + &permitted_modes, + entry.max_payload_bytes(), + ) { + Ok(value) => value, + Err(reason) => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages( + &mut stages, + MiddlewareSessionEndReason::MiddlewareFailure, + ) + .await; + invocations.push(failed_invocation(&entry, reason)); + return Ok(failed_preflight_outcome( + headers, + header_mutations, + format!("middleware_failed: {reason}"), + findings, + metadata, + invocations, + )); + } + }; + stage.mode = mode; + invocations.push(preflight_invocation( + &entry, + match mode { + StageMode::Buffered { .. } => { + HttpRequestInvocationOutcome::Buffered + } + StageMode::Stream => HttpRequestInvocationOutcome::Stream, + }, + reason_code, + )); + stages.push(stage); + } + None => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + invocations + .push(failed_invocation(&entry, "preflight_decision_missing")); + return Ok(failed_preflight_outcome( + headers, + header_mutations, + "middleware_failed: preflight_decision_missing".into(), + findings, + metadata, + invocations, + )); + } + } + } + Some(http_result::Result::Reject(reject)) => { + let diagnostics = + match validate_diagnostics_message(reject.diagnostics.as_ref()) { + Ok(value) => value, + Err(error) => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages( + &mut stages, + MiddlewareSessionEndReason::MiddlewareFailure, + ) + .await; + invocations.push(failed_invocation(&entry, &error.reason)); return Ok(failed_preflight_outcome( headers, header_mutations, - reason, + error.reason, findings, metadata, invocations, )); } - continue; - } - }; - headers = updated; - header_mutations.extend(inspect.header_mutations); - collect_diagnostics( - &entry, - result_findings, - result_metadata, - &mut findings, - &mut metadata, - ); - invocations.push(preflight_invocation( - &entry, - match mode { - StageMode::HeadersOnly => HttpRequestInvocationOutcome::HeadersOnly, - StageMode::WholeBody => HttpRequestInvocationOutcome::WholeBody, - StageMode::Stream => HttpRequestInvocationOutcome::Stream, - StageMode::OwnedStream => HttpRequestInvocationOutcome::OwnedStream, - }, - reason_code, - )); - current_stage.mode = mode; - if mode == StageMode::HeadersOnly { - current_stage.end(MiddlewareSessionEndReason::Normal).await; - } else { - stages.push(current_stage); - } - } - Some(http_request_preflight_result::Action::BlockRequest(_)) => { - collect_diagnostics( - &entry, - result_findings, - result_metadata, - &mut findings, - &mut metadata, - ); + }; + collect_diagnostics(&entry, diagnostics.clone(), &mut findings, &mut metadata); + let reason_code = nonempty(&diagnostics.reason_code); invocations.push(preflight_invocation( &entry, - HttpRequestInvocationOutcome::BlockRequest, + HttpRequestInvocationOutcome::Reject, reason_code.clone(), )); - stages.push(current_stage); + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareDenial) + .await; end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareDenial).await; let denial = super::MiddlewareDenial { config_name: entry.entry.name.clone(), @@ -1409,38 +1134,33 @@ impl ChainRunner { session_capacity_exhausted: false, }); } - None => { - if let Some(reason) = handle_opened_preflight_failure( - &entry, - &mut current_stage, - &mut stages, - "missing_preflight_action", - &mut invocations, - ) - .await - { - return Ok(failed_preflight_outcome( - headers, - header_mutations, - reason, - findings, - metadata, - invocations, - )); - } + _ => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + invocations.push(failed_invocation(&entry, "preflight_result_expected")); + return Ok(failed_preflight_outcome( + headers, + header_mutations, + "middleware_failed: preflight_result_expected".into(), + findings, + metadata, + invocations, + )); } } } let session = (!stages.is_empty()).then(|| HttpRequestSession { - runner: self.clone(), stages, findings: Vec::new(), metadata: BTreeMap::new(), invocations: Vec::new(), session_admission: Some(session_admission), - connection_nominated_headers: input.connection_nominated_headers, - body_transformed: false, + declared_body_length: input.declared_body_length, + pending_input: Vec::new(), }); Ok(HttpRequestPreflightOutcome { allowed: true, @@ -1455,95 +1175,139 @@ impl ChainRunner { session_capacity_exhausted: false, }) } + + pub(crate) async fn preflight_described_http_request_with_owned( + &self, + described: Vec, + input: HttpRequestPreflightInput, + _allow_owned: bool, + ) -> miette::Result { + self.preflight_described_http_request(described, input) + .await + } +} + +async fn send_event_for_entry( + entry: &DescribedChainEntry, + sender: &mpsc::Sender, + event: HttpEvent, +) -> Result<(), HttpRequestMiddlewareFailure> { + tokio::time::timeout(entry.timeout(), sender.send(event)) + .await + .map_err(|_| failure_for_entry(entry, "middleware_timeout"))? + .map_err(|_| failure_for_entry(entry, "middleware_stream_closed")) } async fn exchange( stage: &mut HttpRequestStage, - event: HttpRequestEvent, - 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()), + event: HttpEvent, +) -> Result { + send_event_for_entry(&stage.entry, &stage.transport.sender, event).await?; + let policy = stage + .entry + .service + .as_ref() + .map_or(MiddlewareDiagnosticPolicy::Preserve, |service| { + service.diagnostic_policy + }); + next_result_for_entry(&stage.entry, policy, &mut stage.transport.responses).await +} + +async fn next_result_for_entry( + entry: &DescribedChainEntry, + diagnostic_policy: MiddlewareDiagnosticPolicy, + responses: &mut super::HttpResultStream, +) -> Result { + tokio::time::timeout(entry.timeout(), responses.next()) + .await + .map_or_else( + |_| Err(failure_for_entry(entry, "middleware_timeout")), + |result| validate_next_result_for_entry(entry, diagnostic_policy, result), + ) +} + +fn validate_next_result_for_entry( + entry: &DescribedChainEntry, + diagnostic_policy: MiddlewareDiagnosticPolicy, + result: Option>, +) -> Result { + match result { + Some(Ok(result)) => Ok(result), + Some(Err(error)) => Err(failure_for_entry( + entry, + &diagnostic_policy.error_reason(&error), + )), + None => Err(failure_for_entry(entry, "middleware_result_stream_closed")), } } -async fn next_result( - stage: &mut HttpRequestStage, - chain_deadline: Instant, -) -> Result { - let remaining = chain_deadline.saturating_duration_since(Instant::now()); - if remaining.is_zero() { - return Err("middleware_chain_timeout".into()); +fn body_limits(entry: &DescribedChainEntry) -> HttpBodyLimits { + let max_chunk = entry + .max_payload_bytes() + .min(MAX_HTTP_REQUEST_STREAM_UNIT_BYTES) as u64; + HttpBodyLimits { + max_chunk_bytes: max_chunk, + max_buffered_body_bytes: entry.max_payload_bytes() as u64, + max_input_queue_bytes: max_chunk.saturating_mul(STREAM_CHANNEL_CAPACITY as u64), + max_input_queue_messages: STREAM_CHANNEL_CAPACITY as u64, + max_output_queue_bytes: max_chunk.saturating_mul(STREAM_CHANNEL_CAPACITY as u64), + max_output_queue_messages: STREAM_CHANNEL_CAPACITY as u64, + max_total_input_bytes: None, + max_total_output_bytes: None, + idle_timeout: Some(duration_to_proto(entry.timeout())), + session_timeout: None, } - 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, transport.responses.next()).await { - Ok(Some(Ok(result))) => Ok(result), - Ok(Some(Err(error))) => Err(stage - .entry - .service - .as_ref() - .map_or(MiddlewareDiagnosticPolicy::Preserve, |service| { - service.diagnostic_policy - }) - .error_reason(&error)), - Ok(None) => Err("middleware_result_stream_closed".into()), - Err(_) => Err("middleware_timeout".into()), +} + +fn duration_to_proto(duration: Duration) -> prost_types::Duration { + prost_types::Duration { + seconds: i64::try_from(duration.as_secs()).unwrap_or(i64::MAX), + nanos: i32::try_from(duration.subsec_nanos()).expect("nanoseconds fit in i32"), } } -fn body_event(sequence: u64, data: Vec, end_of_stream: bool) -> HttpRequestEvent { - HttpRequestEvent { - event: Some(http_request_event::Event::Body(HttpRequestBodyUnit { - sequence, - payload: Some(http_request_body_unit::Payload::Data(data)), - end_of_stream, - })), +fn permitted_body_modes( + input: &HttpRequestPreflightInput, + entry: &DescribedChainEntry, + supported: &[i32], +) -> Vec { + let mut modes = Vec::new(); + if supported.contains(&(HttpBodyMode::Buffered as i32)) + && input + .declared_body_length + .is_none_or(|length| length <= entry.max_payload_bytes() as u64) + { + modes.push(HttpBodyMode::Buffered as i32); } + if supported.contains(&(HttpBodyMode::Stream as i32)) && entry.max_payload_bytes() > 0 { + modes.push(HttpBodyMode::Stream as i32); + } + modes } -fn session_end_event(reason: MiddlewareSessionEndReason) -> HttpRequestEvent { - HttpRequestEvent { - event: Some(http_request_event::Event::SessionEnd( - MiddlewareSessionEnd { - reason: reason as i32, - protocol_error: None, - }, - )), +fn validate_inspect( + inspect: &openshell_core::proto::HttpInspect, + permitted_modes: &[i32], + max_payload_bytes: usize, +) -> Result { + match inspect.mode.as_ref() { + Some(http_inspect::Mode::Buffered(mode)) + if permitted_modes.contains(&(HttpBodyMode::Buffered as i32)) + && mode.max_body_bytes > 0 + && mode.max_body_bytes <= max_payload_bytes as u64 => + { + Ok(StageMode::Buffered { + max_body_bytes: usize::try_from(mode.max_body_bytes) + .map_err(|_| "request_body_mode_not_permitted")?, + }) + } + Some(http_inspect::Mode::Stream(_)) + if permitted_modes.contains(&(HttpBodyMode::Stream as i32)) => + { + Ok(StageMode::Stream) + } + Some(_) => Err("request_body_mode_not_permitted"), + None => Err("request_body_mode_missing"), } } @@ -1559,109 +1323,67 @@ fn validate_preflight_input(input: &HttpRequestPreflightInput) -> miette::Result "request header count exceeds platform limit" )); } - if encoded_header_bytes(&input.headers) > MAX_MIDDLEWARE_HEADER_BYTES { + if input + .headers + .iter() + .map(prost::Message::encoded_len) + .sum::() + > MAX_MIDDLEWARE_HEADER_BYTES + { return Err(miette::miette!("request 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("request_reason_over_capacity"); - } - if !reason_code.is_empty() - && (reason_code.len() > MAX_MIDDLEWARE_REASON_CODE_BYTES - || !is_stable_reason_code(reason_code)) - { - return Err("request_reason_code_invalid"); - } - if findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE { - return Err("request_findings_over_capacity"); +fn validate_diagnostics_message( + diagnostics: Option<&MiddlewareDiagnostics>, +) -> Result { + let diagnostics = diagnostics.cloned().unwrap_or_default(); + if diagnostics.reason.len() > MAX_MIDDLEWARE_REASON_BYTES { + return Err(failure( + "middleware_failed: request_reason_over_capacity", + None, + )); } - if findings - .iter() - .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) + if !diagnostics.reason_code.is_empty() + && (diagnostics.reason_code.len() > MAX_MIDDLEWARE_REASON_CODE_BYTES + || !is_stable_reason_code(&diagnostics.reason_code)) { - return Err("request_finding_over_capacity"); - } - if metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES { - return Err("request_metadata_count_over_capacity"); + return Err(failure( + "middleware_failed: request_reason_code_invalid", + None, + )); } - if metadata.iter().fold(0usize, |total, (key, value)| { - total.saturating_add(key.len()).saturating_add(value.len()) - }) > MAX_MIDDLEWARE_METADATA_BYTES + if diagnostics.findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE + || diagnostics + .findings + .iter() + .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) { - return Err("request_metadata_bytes_over_capacity"); - } - Ok(()) -} - -fn validate_stage_diagnostics( - existing_findings: usize, - reason: &str, - reason_code: &str, - findings: &[Finding], - metadata: &std::collections::HashMap, -) -> Result<(), &'static str> { - validate_diagnostics(reason, reason_code, findings, metadata)?; - if existing_findings.saturating_add(findings.len()) > MAX_MIDDLEWARE_FINDINGS_PER_STAGE { - return Err("request_findings_over_capacity"); + return Err(failure( + "middleware_failed: request_findings_over_capacity", + None, + )); } - Ok(()) -} - -fn permitted_body_modes( - input: &HttpRequestPreflightInput, - entry: &DescribedChainEntry, - allow_owned: bool, -) -> Vec { - let mut modes = vec![HttpRequestBodyMode::HeadersOnly as i32]; - if input - .declared_body_length - .is_none_or(|length| length <= entry.max_payload_bytes as u64) + if diagnostics.metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES + || diagnostics + .metadata + .iter() + .map(|(key, value)| key.len() + value.len()) + .sum::() + > MAX_MIDDLEWARE_METADATA_BYTES { - modes.push(HttpRequestBodyMode::WholeBodyBytes as i32); - } - if entry.max_payload_bytes > 0 { - modes.push(HttpRequestBodyMode::StreamBytes as i32); - if allow_owned && entry.on_error() == OnError::FailClosed { - modes.push(HttpRequestBodyMode::OwnedStreamBytes as i32); - } - } - modes -} - -fn validate_inspect( - inspect: &openshell_core::proto::HttpRequestPreflightInspect, - permitted_modes: &[i32], -) -> Result { - if !permitted_modes.contains(&inspect.body_mode) { - return Err("request_body_mode_not_permitted"); - } - match HttpRequestBodyMode::try_from(inspect.body_mode) { - Ok(HttpRequestBodyMode::HeadersOnly) => Ok(StageMode::HeadersOnly), - Ok(HttpRequestBodyMode::WholeBodyBytes) => Ok(StageMode::WholeBody), - Ok(HttpRequestBodyMode::StreamBytes) => Ok(StageMode::Stream), - Ok(HttpRequestBodyMode::OwnedStreamBytes) => Ok(StageMode::OwnedStream), - Ok(HttpRequestBodyMode::Unspecified) | Err(_) => Err("invalid_request_body_mode"), + return Err(failure( + "middleware_failed: request_metadata_over_capacity", + None, + )); } -} - -fn encoded_header_bytes(headers: &[HttpHeader]) -> usize { - headers.iter().fold(0usize, |total, header| { - total.saturating_add(header.encoded_len()) - }) + Ok(diagnostics) } fn collect_diagnostics( entry: &DescribedChainEntry, - mut findings: Vec, - mut metadata: std::collections::HashMap, + mut diagnostics: MiddlewareDiagnostics, all_findings: &mut Vec, all_metadata: &mut BTreeMap>, ) { @@ -1670,97 +1392,65 @@ fn collect_diagnostics( .as_ref() .is_some_and(|service| service.diagnostic_policy == MiddlewareDiagnosticPolicy::Normalize) { - metadata.clear(); - for finding in &mut findings { + diagnostics.metadata.clear(); + for finding in &mut diagnostics.findings { finding.r#type = format!("{}.finding", entry.entry.implementation); finding.label = EXTERNAL_FINDING_LABEL.to_string(); finding.confidence.clear(); finding.severity = "medium".into(); } } - all_findings.extend(findings.into_iter().map(|finding| NamespacedFinding { - middleware: entry.entry.name.clone(), - finding, - })); - if !metadata.is_empty() { - all_metadata.insert(entry.entry.name.clone(), metadata.into_iter().collect()); + all_findings.extend( + diagnostics + .findings + .into_iter() + .map(|finding| NamespacedFinding { + middleware: entry.entry.name.clone(), + finding, + }), + ); + if !diagnostics.metadata.is_empty() { + all_metadata.insert( + entry.entry.name.clone(), + diagnostics.metadata.into_iter().collect(), + ); } } -fn body_invocation( +fn report_from_diagnostics( stage: &HttpRequestStage, + diagnostics: MiddlewareDiagnostics, outcome: HttpRequestInvocationOutcome, - sequence: u64, input_size: usize, - output_size: usize, - reason_code: Option, -) -> HttpRequestInvocation { - HttpRequestInvocation { - 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 record_request_invocation( - invocations: &mut Vec, - invocation: HttpRequestInvocation, -) { - if invocations.len() < MAX_RECORDED_REQUEST_INVOCATIONS { - invocations.push(invocation); - return; - } - - let index = invocations - .iter() - .position(|existing| existing.config_name == invocation.config_name) - .unwrap_or(invocations.len() - 1); - let existing = &mut invocations[index]; - existing.sequence = invocation.sequence.or(existing.sequence); - existing.input_size = existing.input_size.saturating_add(invocation.input_size); - existing.output_size = match (existing.output_size, invocation.output_size) { - (_, None) if invocation.failed => None, - (Some(existing), Some(incoming)) => Some(existing.saturating_add(incoming)), - (None, Some(incoming)) => Some(incoming), - (existing, None) => existing, + output_size: Option, + transformed: bool, +) -> StageReport { + let mut report = StageReport { + transformed, + ..StageReport::default() }; - existing.failed |= invocation.failed; - existing.stage_disabled |= invocation.stage_disabled; - if invocation.reason_code.is_some() { - existing.reason_code = invocation.reason_code; - } - if invocation.failure_category.is_some() { - existing.failure_category = invocation.failure_category; - } - if request_invocation_priority(invocation.outcome) - >= request_invocation_priority(existing.outcome) - { - existing.outcome = invocation.outcome; - } -} - -fn request_invocation_priority(outcome: HttpRequestInvocationOutcome) -> u8 { - match outcome { - HttpRequestInvocationOutcome::BlockRequest | HttpRequestInvocationOutcome::FailClosed => 5, - HttpRequestInvocationOutcome::FailOpen => 4, - HttpRequestInvocationOutcome::Transform => 3, - HttpRequestInvocationOutcome::SkipRemaining - | HttpRequestInvocationOutcome::TakeOwnership => 2, - HttpRequestInvocationOutcome::Skip - | HttpRequestInvocationOutcome::HeadersOnly - | HttpRequestInvocationOutcome::WholeBody - | HttpRequestInvocationOutcome::Stream - | HttpRequestInvocationOutcome::OwnedStream - | HttpRequestInvocationOutcome::Trailers - | HttpRequestInvocationOutcome::PassThrough => 1, - } + collect_diagnostics( + &stage.entry, + diagnostics.clone(), + &mut report.findings, + &mut report.metadata, + ); + record_request_invocation( + &mut report.invocations, + HttpRequestInvocation { + config_name: stage.entry.entry.name.clone(), + implementation: stage.entry.entry.implementation.clone(), + outcome, + sequence: None, + input_size, + output_size, + failed: false, + stage_disabled: false, + reason_code: nonempty(&diagnostics.reason_code), + failure_category: None, + }, + ); + report } fn preflight_invocation( @@ -1782,20 +1472,11 @@ fn preflight_invocation( } } -fn collect_preflight_failure( - entry: &DescribedChainEntry, - reason: &str, - invocations: &mut Vec, -) -> Option { - let fail_closed = entry.on_error() == OnError::FailClosed; - invocations.push(HttpRequestInvocation { +fn failed_invocation(entry: &DescribedChainEntry, reason: &str) -> HttpRequestInvocation { + HttpRequestInvocation { config_name: entry.entry.name.clone(), implementation: entry.entry.implementation.clone(), - outcome: if fail_closed { - HttpRequestInvocationOutcome::FailClosed - } else { - HttpRequestInvocationOutcome::FailOpen - }, + outcome: HttpRequestInvocationOutcome::FailClosed, sequence: None, input_size: 0, output_size: None, @@ -1803,8 +1484,107 @@ fn collect_preflight_failure( stage_disabled: true, reason_code: None, failure_category: Some(request_failure_category(reason).into()), - }); - fail_closed.then(|| format!("middleware_failed: {reason}")) + } +} + +fn record_request_invocation( + invocations: &mut Vec, + invocation: HttpRequestInvocation, +) { + if invocations.len() < MAX_RECORDED_REQUEST_INVOCATIONS { + invocations.push(invocation); + } else if let Some(existing) = invocations + .iter_mut() + .find(|item| item.config_name == invocation.config_name) + { + existing.input_size = existing.input_size.saturating_add(invocation.input_size); + existing.output_size = match (existing.output_size, invocation.output_size) { + (Some(left), Some(right)) => Some(left.saturating_add(right)), + (left, right) => left.or(right), + }; + existing.failed |= invocation.failed; + existing.outcome = invocation.outcome; + } +} + +fn rejection( + stage: &HttpRequestStage, + diagnostics: Option, +) -> HttpRequestMiddlewareFailure { + rejection_for_entry(&stage.entry, diagnostics) +} + +fn rejection_for_entry( + entry: &DescribedChainEntry, + diagnostics: Option, +) -> HttpRequestMiddlewareFailure { + match validate_diagnostics_message(diagnostics.as_ref()) { + Ok(diagnostics) => { + let denial = super::MiddlewareDenial { + config_name: entry.entry.name.clone(), + reason_code: nonempty(&diagnostics.reason_code), + }; + HttpRequestMiddlewareFailure { + reason: middleware_denial_reason( + &denial.config_name, + denial.reason_code.as_deref(), + ), + denial: Some(denial), + diagnostics: Box::default(), + } + } + Err(error) => error, + } +} + +fn mutation_failure( + stage: &HttpRequestStage, + error: &headers::HeaderMutationError, +) -> HttpRequestMiddlewareFailure { + let policy = stage + .entry + .service + .as_ref() + .map_or(MiddlewareDiagnosticPolicy::Preserve, |service| { + service.diagnostic_policy + }); + mutation_failure_for_entry(&stage.entry, policy, error) +} + +fn mutation_failure_for_entry( + entry: &DescribedChainEntry, + policy: MiddlewareDiagnosticPolicy, + error: &headers::HeaderMutationError, +) -> HttpRequestMiddlewareFailure { + failure_for_entry(entry, &policy.header_mutation_error_reason(error)) +} + +fn stage_failure(stage: &HttpRequestStage, reason: &str) -> HttpRequestMiddlewareFailure { + failure_for_entry(&stage.entry, reason) +} + +fn failure_for_entry(entry: &DescribedChainEntry, reason: &str) -> HttpRequestMiddlewareFailure { + let mut diagnostics = HttpRequestDiagnostics::default(); + diagnostics + .invocations + .push(failed_invocation(entry, reason)); + HttpRequestMiddlewareFailure { + reason: format!("middleware_failed: {reason}"), + denial: None, + diagnostics: Box::new(diagnostics), + } +} + +fn failure(reason: &str, denial: Option) -> HttpRequestMiddlewareFailure { + HttpRequestMiddlewareFailure { + reason: reason.into(), + denial, + diagnostics: Box::default(), + } +} + +fn nonempty(value: &str) -> Option { + (!value.is_empty()).then(|| value.to_string()) } fn empty_preflight_outcome(headers: Vec) -> HttpRequestPreflightOutcome { @@ -1844,62 +1624,32 @@ fn failed_preflight_outcome( } } -fn preflight_input_failure( - entries: &[DescribedChainEntry], +fn session_capacity_exhausted( + entries: Vec, headers: Vec, - reason: &str, ) -> HttpRequestPreflightOutcome { - let mut invocations = Vec::new(); - let denied = entries + let invocations = entries .iter() - .find_map(|entry| collect_preflight_failure(entry, reason, &mut invocations)); - if let Some(reason) = denied { - failed_preflight_outcome( + .map(|entry| failed_invocation(entry, "session_capacity_exhausted")) + .collect(); + HttpRequestPreflightOutcome { + session_capacity_exhausted: true, + invocations, + ..failed_preflight_outcome( headers, Vec::new(), - reason, + "middleware_failed: session_capacity_exhausted".into(), Vec::new(), BTreeMap::new(), - invocations, + Vec::new(), ) - } else { - HttpRequestPreflightOutcome { - invocations, - ..empty_preflight_outcome(headers) - } } } -fn session_capacity_exhausted( - entries: Vec, - headers: Vec, -) -> HttpRequestPreflightOutcome { - let mut outcome = preflight_input_failure(&entries, headers, "session_capacity_exhausted"); - outcome.session_capacity_exhausted = true; - outcome -} - async fn end_stages(stages: &mut [HttpRequestStage], reason: MiddlewareSessionEndReason) { for stage in stages { - stage.end(reason).await; - } -} - -async fn handle_opened_preflight_failure( - entry: &DescribedChainEntry, - current_stage: &mut HttpRequestStage, - prior_stages: &mut [HttpRequestStage], - 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; + stage.transport.end(reason).await; } - failure } fn request_failure_category(reason: &str) -> &'static str { @@ -1909,9 +1659,23 @@ fn request_failure_category(reason: &str) -> &'static str { "capacity" } else if reason.contains("header") { "header_mutation" - } else if reason.contains("sequence") || reason.contains("result") { + } else if reason.contains("order") || reason.contains("result") || reason.contains("protocol") { "protocol" } else { "service" } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diagnostics_reject_unknown_reason_codes() { + let diagnostics = MiddlewareDiagnostics { + reason_code: "Not-Stable".into(), + ..MiddlewareDiagnostics::default() + }; + assert!(validate_diagnostics_message(Some(&diagnostics)).is_err()); + } +} diff --git a/crates/openshell-supervisor-middleware/src/response.rs b/crates/openshell-supervisor-middleware/src/response.rs index a179eaabcd..2753022262 100644 --- a/crates/openshell-supervisor-middleware/src/response.rs +++ b/crates/openshell-supervisor-middleware/src/response.rs @@ -1,17 +1,12 @@ // 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. - -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, -}; +//! HTTP response pre-return middleware execution. +//! +//! Request and response hooks use the same wire protocol. The first rollout +//! intentionally offers BUFFERED only for responses; STREAM remains in the +//! shared schema but is not advertised until the response relay can preserve +//! its independent input/output guarantees end to end. use std::collections::BTreeMap; use std::time::Duration; @@ -22,32 +17,28 @@ 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, + HttpBegin, HttpBodyLimits, HttpBodyMode, HttpBufferedBody, HttpEvent, HttpHeader, + HttpPreflight, HttpRequestTarget, HttpResponsePreflightHead, HttpResult, HttpUnchanged, + MiddlewareDiagnostics, MiddlewareSessionEnd, MiddlewareSessionEndReason, RequestContext, + http_buffered_result, http_event, http_inspect, http_preflight, http_preflight_result, + http_result, }; use super::{ - ChainEntry, ChainRunner, DescribedChainEntry, MAX_MIDDLEWARE_CHAIN_TIMEOUT, + ChainEntry, ChainRunner, DescribedChainEntry, EXTERNAL_FINDING_LABEL, 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, + MAX_MIDDLEWARE_HEADER_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; 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!( @@ -68,27 +59,18 @@ 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, } @@ -122,8 +104,7 @@ pub struct HttpResponsePreflightOutcome { pub struct HttpResponseMiddlewareFailure { pub reason: String, pub denial: Option, - /// Exchange diagnostics collected before a consuming operation failed. - pub diagnostics: HttpResponseDiagnostics, + pub diagnostics: Box, } impl std::fmt::Display for HttpResponseMiddlewareFailure { @@ -134,20 +115,10 @@ impl std::fmt::Display for HttpResponseMiddlewareFailure { 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>, @@ -162,78 +133,58 @@ pub struct HttpResponseDiagnostics { } struct HttpResponseStageTransport { - sender: mpsc::Sender, - responses: super::HttpResponseResultStream, + sender: mpsc::Sender, + responses: super::HttpResultStream, + terminal_sent: bool, } 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() { + async fn end(&mut self, reason: MiddlewareSessionEndReason) { + if self.terminal_sent { 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() {} + self.terminal_sent = true; + let _ = tokio::time::timeout( + SESSION_END_TIMEOUT, + self.sender.send(HttpEvent { + event: Some(http_event::Event::SessionEnd(MiddlewareSessionEnd { + reason: reason as i32, + protocol_error: None, + })), + }), + ) + .await; } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum StageMode { - HeadersOnly, - WholeBody, - Stream, +impl Drop for HttpResponseStageTransport { + fn drop(&mut self) { + if !self.terminal_sent { + let _ = self.sender.try_send(HttpEvent { + event: Some(http_event::Event::SessionEnd(MiddlewareSessionEnd { + reason: MiddlewareSessionEndReason::Cancellation as i32, + protocol_error: None, + })), + }); + } + } } 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() { - transport.end(reason).await; - } - } + transport: HttpResponseStageTransport, + max_body_bytes: usize, + connection_nominated_headers: Vec, } pub struct HttpResponseSession { - runner: ChainRunner, stages: Vec, + body: Vec, findings: Vec, metadata: BTreeMap>, invocations: Vec, session_admission: Option, body_transformed: bool, - retained_body_bytes: usize, - defer_output_until_finish: bool, - deferred_output: Vec>, - connection_nominated_headers: Vec, whole_body_deadline: Option, } @@ -248,177 +199,169 @@ impl HttpResponseSession { #[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 - }) + !self.stages.is_empty() + } + + #[must_use] + pub fn stream_unit_limit(&self) -> usize { + MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES } - /// 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); + self.whole_body_deadline = Some(Instant::now() + timeout); } #[must_use] pub fn whole_body_deadline(&self) -> Option { - self.requires_whole_body() - .then_some(self.whole_body_deadline) - .flatten() + self.whole_body_deadline } - /// 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; - self.release_body_bytes(&released); - 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) + self.end_all(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + Err(self.failure("middleware_timeout", None)) } - /// 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( + pub 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; - // 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 data.is_empty() || data.len() > MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES { + return Err(self.failure("response_body_unit_invalid", None)); } - if self.requires_whole_body() { - self.deferred_output.extend(output); - return Ok(Vec::new()); + let limit = self + .stages + .iter() + .map(|stage| stage.max_body_bytes) + .min() + .unwrap_or(MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES) + .min(MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES); + if self.body.len().saturating_add(data.len()) > limit { + return Err(self.failure("buffered_input_over_capacity", None)); } - - 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) + self.body.extend_from_slice(&data); + Ok(Vec::new()) } - /// Finalize every body stage, preserve normalized trailers, and end streams. pub async fn finish( + self, + trailers: Vec, + ) -> Result { + let deadline = self.whole_body_deadline; + let finish = self.finish_inner(trailers); + if let Some(deadline) = deadline { + return tokio::time::timeout_at(deadline, finish) + .await + .unwrap_or_else(|_| Err(response_failure("middleware_timeout", None))); + } + finish.await + } + + async fn finish_inner( 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); + let mut body = std::mem::take(&mut self.body); 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())); + let stage = &mut self.stages[index]; + send_event( + &stage.entry, + &stage.transport.sender, + HttpEvent { + event: Some(http_event::Event::Begin(HttpBegin {})), + }, + ) + .await?; + let input_size = body.len(); + let result = exchange( + stage, + HttpEvent { + event: Some(http_event::Event::BufferedBody(HttpBufferedBody { + data: body.clone(), + visible_trailers: trailers.clone(), + })), + }, + ) + .await?; + let buffered = match result.result { + Some(http_result::Result::BufferedResult(result)) => result, + Some(http_result::Result::Reject(reject)) => { + let diagnostics = validate_diagnostics(reject.diagnostics.as_ref())?; + let denial = super::MiddlewareDenial { + config_name: stage.entry.entry.name.clone(), + reason_code: nonempty(&diagnostics.reason_code), + }; + return Err(self.failure( + &middleware_denial_reason( + &denial.config_name, + denial.reason_code.as_deref(), + ), + Some(denial), + )); } + _ => return Err(self.failure("buffered_result_expected", None)), }; - 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 !buffered.header_mutations.is_empty() { + return Err(self.failure("late_header_mutations_not_permitted", None)); } + let diagnostics = validate_diagnostics(buffered.diagnostics.as_ref())?; + trailers = headers::apply( + headers::HeaderAuthority::ResponseTrailers, + &trailers, + &stage.connection_nominated_headers, + &buffered.trailer_mutations, + ) + .map_err(|error| { + let policy = stage + .entry + .service + .as_ref() + .map_or(MiddlewareDiagnosticPolicy::Preserve, |service| { + service.diagnostic_policy + }); + response_failure(&policy.header_mutation_error_reason(&error), None) + })?; + let (next_body, outcome, transformed) = match buffered.body { + Some(http_buffered_result::Body::Unchanged(HttpUnchanged {})) => { + (body, HttpResponseInvocationOutcome::PassThrough, false) + } + Some(http_buffered_result::Body::Replacement(replacement)) => { + if replacement.len() > stage.max_body_bytes { + return Err(self.failure("buffered_output_over_capacity", None)); + } + (replacement, HttpResponseInvocationOutcome::Transform, true) + } + None => return Err(self.failure("buffered_body_result_missing", None)), + }; + collect_diagnostics( + &stage.entry, + &diagnostics, + &mut self.findings, + &mut self.metadata, + ); + self.invocations.push(invocation( + &stage.entry, + outcome, + input_size, + Some(next_body.len()), + nonempty(&diagnostics.reason_code), + )); + self.body_transformed |= transformed; + body = next_body; + stage + .transport + .end(MiddlewareSessionEndReason::Normal) + .await; } - - 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(); + let body_units = body + .chunks(MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES) + .map(<[u8]>::to_vec) + .collect(); Ok(HttpResponseFinish { - body_units: released, + body_units, trailers, strip_stale_integrity_headers: self.body_transformed, findings: self.findings, @@ -427,539 +370,630 @@ impl HttpResponseSession { }) } - pub async fn end(mut self, reason: MiddlewareSessionEndReason) { - self.end_all(reason).await; + pub async fn finish_to( + self, + trailers: Vec, + output: mpsc::Sender>, + ) -> Result { + let mut finish = self.finish(trailers).await?; + for unit in std::mem::take(&mut finish.body_units) { + output + .send(unit) + .await + .map_err(|_| response_failure("response_output_closed", None))?; + } + Ok(finish) } - fn release_body_bytes(&mut self, units: &[Vec]) { - self.retained_body_bytes -= units.iter().map(Vec::len).sum::(); + 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; - } + async fn end_all(&mut self, reason: MiddlewareSessionEndReason) { + for stage in &mut self.stages { + stage.transport.end(reason).await; } - Ok(units) + self.session_admission.take(); } - async fn process_stage_unit( + fn failure( &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()); + reason: &str, + denial: Option, + ) -> HttpResponseMiddlewareFailure { + HttpResponseMiddlewareFailure { + reason: reason.to_string(), + denial, + diagnostics: Box::new(self.take_diagnostics()), } - - 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?, - ); - } +impl ChainRunner { + pub fn http_response_input_unrepresentable( + &self, + entries: &[DescribedChainEntry], + ) -> HttpResponsePreflightOutcome { + failed_preflight( + entries, + Vec::new(), + "middleware_failed: response_input_unrepresentable", + ) + } - 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, - ) + 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 - { - 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; + pub async fn preflight_described_http_response( + &self, + described: Vec, + input: HttpResponsePreflightInput, + ) -> miette::Result { + if described.is_empty() { + return Ok(empty_preflight(input.headers)); + } + if validate_preflight_input(&input).is_err() { + return Ok(failed_preflight( + &described, + input.headers, + "middleware_failed: response_input_over_capacity", + )); + } + let admission = match self.try_reserve_middleware_session() { + MiddlewareSessionAdmission::Admitted(admission) => admission, + MiddlewareSessionAdmission::AtCapacity => { + let mut outcome = failed_preflight( + &described, + input.headers, + "middleware_failed: middleware_session_capacity_exhausted", + ); + outcome.session_capacity_exhausted = true; + return Ok(outcome); } }; - let input_size = original.len(); - let replacement_size = match &decision.action { - BodyAction::Transform(replacement) - | BodyAction::SkipRemaining(CurrentBodyAction::Transform(replacement)) => { - Some(replacement.len()) + 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 { + if entry.on_error() == OnError::FailOpen { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + invocations.push(failed_invocation(&entry, "http_fail_open_unsupported")); + return Ok(HttpResponsePreflightOutcome { + allowed: false, + reason: "middleware_failed: HTTP middleware no longer supports on_error=fail_open; use fail_closed or remove on_error".into(), + denial: None, + headers, + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }); } - _ => 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 + let Some(service) = entry.service.as_ref() else { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + invocations.push(failed_invocation(&entry, "binding_not_described")); + return Ok(HttpResponsePreflightOutcome { + allowed: false, + reason: "middleware_failed: binding_not_described".into(), + denial: None, + headers, + session: None, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }); + }; + let supports_buffered = entry.binding.as_ref().is_some_and(|binding| { + binding + .supported_http_body_modes + .contains(&(HttpBodyMode::Buffered as i32)) + }); + let permitted = if supports_buffered + && body_restriction(&input).is_none() + && input + .declared_body_length + .is_none_or(|length| length <= entry.max_payload_bytes() as u64) { - 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, - 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 + vec![HttpBodyMode::Buffered as i32] + } else { + Vec::new() + }; + let (sender, receiver) = mpsc::channel(STREAM_CHANNEL_CAPACITY); + let preflight = HttpPreflight { + head: Some(http_preflight::Head::Response(HttpResponsePreflightHead { + 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()), + })), + permitted_body_modes: permitted.clone(), + late_header_modes: Vec::new(), + limits: Some(body_limits(&entry)), + declared_input_bytes: input.declared_body_length, + }; + let opened = tokio::time::timeout(entry.timeout(), async { + sender + .send(HttpEvent { + event: Some(http_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 result = responses.next().await.ok_or_else(|| { + tonic::Status::unavailable("middleware result stream closed") + })??; + Ok::<_, tonic::Status>((responses, result)) + }) + .await; + let (responses, result) = match opened { + Ok(Ok(value)) => value, + Ok(Err(error)) => { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + let reason = service.diagnostic_policy.error_reason(&error); + invocations.push(failed_invocation(&entry, &reason)); + return Ok(failed_outcome( + headers, + findings, + metadata, + invocations, + &reason, + )); + } + Err(_) => { + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + invocations.push(failed_invocation(&entry, "middleware_timeout")); + return Ok(failed_outcome( + headers, + findings, + metadata, + invocations, + "middleware_timeout", + )); + } + }; + let mut stage = HttpResponseStage { + entry: entry.clone(), + transport: HttpResponseStageTransport { + sender, + responses, + terminal_sent: false, + }, + max_body_bytes: entry.max_payload_bytes(), + connection_nominated_headers: input.connection_nominated_headers.clone(), + }; + match result.result { + Some(http_result::Result::PreflightResult(result)) => { + let diagnostics = match validate_diagnostics(result.diagnostics.as_ref()) { + Ok(value) => value, + Err(error) => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + invocations.push(failed_invocation(&entry, &error.reason)); + return Ok(failed_outcome( + headers, + findings, + metadata, + invocations, + &error.reason, + )); + } + }; + headers = match headers::apply( + headers::HeaderAuthority::Response, + &headers, + &input.connection_nominated_headers, + &result.header_mutations, + ) { + Ok(value) => value, + Err(error) => { + let reason = service + .diagnostic_policy + .header_mutation_error_reason(&error); + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + invocations.push(failed_invocation(&entry, &reason)); + return Ok(failed_outcome( + headers, + findings, + metadata, + invocations, + &reason, + )); + } + }; + collect_diagnostics(&entry, &diagnostics, &mut findings, &mut metadata); + match result.decision { + Some(http_preflight_result::Decision::ContinueWithoutBody(_)) => { + invocations.push(invocation( + &entry, + HttpResponseInvocationOutcome::HeadersOnly, + 0, + None, + nonempty(&diagnostics.reason_code), + )); + stage + .transport + .end(MiddlewareSessionEndReason::StageSkipped) + .await; + } + Some(http_preflight_result::Decision::Inspect(inspect)) => { + let max_body_bytes = match inspect.mode { + Some(http_inspect::Mode::Buffered(mode)) + if permitted.contains(&(HttpBodyMode::Buffered as i32)) + && mode.max_body_bytes > 0 + && mode.max_body_bytes + <= entry.max_payload_bytes() as u64 => + { + usize::try_from(mode.max_body_bytes) + .expect("validated response body limit fits usize") + } + _ => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages( + &mut stages, + MiddlewareSessionEndReason::MiddlewareFailure, + ) + .await; + invocations.push(failed_invocation( + &entry, + "response_body_mode_not_permitted", + )); + return Ok(failed_outcome( + headers, + findings, + metadata, + invocations, + "response_body_mode_not_permitted", + )); + } + }; + stage.max_body_bytes = max_body_bytes; + invocations.push(invocation( + &entry, + HttpResponseInvocationOutcome::WholeBody, + 0, + None, + nonempty(&diagnostics.reason_code), + )); + stages.push(stage); + } + None => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + invocations + .push(failed_invocation(&entry, "preflight_decision_missing")); + return Ok(failed_outcome( + headers, + findings, + metadata, + invocations, + "preflight_decision_missing", + )); + } } - }; - 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(), - }) + } + Some(http_result::Result::Reject(reject)) => { + let diagnostics = match validate_diagnostics(reject.diagnostics.as_ref()) { + Ok(value) => value, + Err(error) => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure) + .await; + invocations.push(failed_invocation(&entry, &error.reason)); + return Ok(failed_outcome( + headers, + findings, + metadata, + invocations, + &error.reason, + )); + } + }; + collect_diagnostics(&entry, &diagnostics, &mut findings, &mut metadata); + let denial = super::MiddlewareDenial { + config_name: entry.entry.name.clone(), + reason_code: nonempty(&diagnostics.reason_code), + }; + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareDenial) + .await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareDenial).await; + invocations.push(invocation( + &entry, + HttpResponseInvocationOutcome::BlockDelivery, + 0, + None, + denial.reason_code.clone(), + )); + return Ok(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, + }); + } + _ => { + stage + .transport + .end(MiddlewareSessionEndReason::MiddlewareFailure) + .await; + end_stages(&mut stages, MiddlewareSessionEndReason::MiddlewareFailure).await; + invocations.push(failed_invocation(&entry, "preflight_result_expected")); + return Ok(failed_outcome( + headers, + findings, + metadata, + invocations, + "preflight_result_expected", + )); + } } } + + let session = (!stages.is_empty()).then(|| HttpResponseSession { + stages, + body: Vec::new(), + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: Vec::new(), + session_admission: Some(admission), + body_transformed: false, + whole_body_deadline: None, + }); + Ok(HttpResponsePreflightOutcome { + allowed: true, + reason: String::new(), + denial: None, + headers, + session, + findings, + metadata, + invocations, + session_capacity_exhausted: false, + }) } +} - 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()), +async fn send_event( + entry: &DescribedChainEntry, + sender: &mpsc::Sender, + event: HttpEvent, +) -> Result<(), HttpResponseMiddlewareFailure> { + tokio::time::timeout(entry.timeout(), sender.send(event)) + .await + .map_err(|_| response_failure("middleware_timeout", None))? + .map_err(|_| response_failure("middleware_stream_closed", None)) +} + +async fn exchange( + stage: &mut HttpResponseStage, + event: HttpEvent, +) -> Result { + tokio::time::timeout(stage.entry.timeout(), stage.transport.sender.send(event)) + .await + .map_err(|_| response_failure("middleware_timeout", None))? + .map_err(|_| response_failure("middleware_stream_closed", None))?; + let policy = stage + .entry + .service + .as_ref() + .map_or(MiddlewareDiagnosticPolicy::Preserve, |service| { + service.diagnostic_policy }); - 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(), - }) - } + match tokio::time::timeout(stage.entry.timeout(), stage.transport.responses.next()).await { + Ok(Some(Ok(result))) => Ok(result), + Ok(Some(Err(error))) => Err(response_failure(&policy.error_reason(&error), None)), + Ok(None) => Err(response_failure("middleware_result_stream_closed", None)), + Err(_) => Err(response_failure("middleware_timeout", None)), } +} - async fn end_all(&mut self, reason: MiddlewareSessionEndReason) { - for stage in &mut self.stages { - stage.end(reason).await; - } +async fn end_stages(stages: &mut [HttpResponseStage], reason: MiddlewareSessionEndReason) { + for stage in stages { + stage.transport.end(reason).await; } +} - fn release_admission_if_idle(&mut self) { - if self.stages.iter().all(|stage| !stage.is_active()) { - self.session_admission.take(); - } +fn body_limits(entry: &DescribedChainEntry) -> HttpBodyLimits { + let max_chunk = entry + .max_payload_bytes() + .min(MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES) as u64; + HttpBodyLimits { + max_chunk_bytes: max_chunk, + max_buffered_body_bytes: entry.max_payload_bytes() as u64, + max_input_queue_bytes: max_chunk.saturating_mul(STREAM_CHANNEL_CAPACITY as u64), + max_input_queue_messages: STREAM_CHANNEL_CAPACITY as u64, + max_output_queue_bytes: max_chunk.saturating_mul(STREAM_CHANNEL_CAPACITY as u64), + max_output_queue_messages: STREAM_CHANNEL_CAPACITY as u64, + max_total_input_bytes: None, + max_total_output_bytes: None, + idle_timeout: Some(prost_types::Duration { + seconds: i64::try_from(entry.timeout().as_secs()).unwrap_or(i64::MAX), + nanos: i32::try_from(entry.timeout().subsec_nanos()).expect("nanoseconds fit in i32"), + }), + session_timeout: None, + } +} + +fn validate_preflight_input(input: &HttpResponsePreflightInput) -> miette::Result<()> { + if input.context.encoded_len() > MAX_MIDDLEWARE_CONTEXT_BYTES + || input.target.encoded_len() > MAX_MIDDLEWARE_TARGET_BYTES + || input.headers.len() > MAX_MIDDLEWARE_HEADERS + || input + .headers + .iter() + .map(prost::Message::encoded_len) + .sum::() + > MAX_MIDDLEWARE_HEADER_BYTES + { + return Err(miette::miette!("response preflight exceeds platform limit")); + } + Ok(()) +} + +/// Return why middleware may inspect response headers but must not transform +/// the representation body. These restrictions preserve HTTP semantics that +/// cannot be safely reconstructed by the initial BUFFERED-only response path. +fn body_restriction(input: &HttpResponsePreflightInput) -> Option<&'static str> { + if input.target.method.eq_ignore_ascii_case("HEAD") + || (100..200).contains(&input.status_code) + || matches!(input.status_code, 204 | 304) + { + return Some("bodyless_response"); } + 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"); + } + 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"); + } + 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"); + } + None +} - fn exchange_deadline(&self, chain_deadline: Instant) -> Instant { - self.whole_body_deadline() - .map_or(chain_deadline, |deadline| deadline.min(chain_deadline)) +fn validate_diagnostics( + diagnostics: Option<&MiddlewareDiagnostics>, +) -> Result { + let diagnostics = diagnostics.cloned().unwrap_or_default(); + if diagnostics.reason.len() > MAX_MIDDLEWARE_REASON_BYTES + || (!diagnostics.reason_code.is_empty() + && (diagnostics.reason_code.len() > MAX_MIDDLEWARE_REASON_CODE_BYTES + || !is_stable_reason_code(&diagnostics.reason_code))) + || diagnostics.findings.len() > MAX_MIDDLEWARE_FINDINGS_PER_STAGE + || diagnostics + .findings + .iter() + .any(|finding| finding.encoded_len() > MAX_MIDDLEWARE_FINDING_BYTES) + || diagnostics.metadata.len() > MAX_MIDDLEWARE_METADATA_ENTRIES + || diagnostics + .metadata + .iter() + .map(|(key, value)| key.len() + value.len()) + .sum::() + > MAX_MIDDLEWARE_METADATA_BYTES + { + return Err(response_failure("response_diagnostics_invalid", None)); } + Ok(diagnostics) +} - 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 +fn collect_diagnostics( + entry: &DescribedChainEntry, + diagnostics: &MiddlewareDiagnostics, + findings: &mut Vec, + metadata: &mut BTreeMap>, +) { + let normalize = entry + .service + .as_ref() + .is_some_and(|service| service.diagnostic_policy == MiddlewareDiagnosticPolicy::Normalize); + findings.extend(diagnostics.findings.iter().cloned().map(|mut finding| { + if normalize { + finding.r#type = format!("{}.finding", entry.entry.implementation); + finding.label = EXTERNAL_FINDING_LABEL.to_string(); + finding.confidence.clear(); + finding.severity = "medium".into(); + } + NamespacedFinding { + middleware: entry.entry.name.clone(), + finding, } + })); + if !normalize && !diagnostics.metadata.is_empty() { + metadata.insert( + entry.entry.name.clone(), + diagnostics.metadata.clone().into_iter().collect(), + ); } +} - 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(), - }) - } - } -} - -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, +fn invocation( + entry: &DescribedChainEntry, outcome: HttpResponseInvocationOutcome, - sequence: u64, input_size: usize, - output_size: usize, + output_size: Option, reason_code: Option, ) -> HttpResponseInvocation { HttpResponseInvocation { - config_name: stage.entry.entry.name.clone(), - implementation: stage.entry.entry.implementation.clone(), + config_name: entry.entry.name.clone(), + implementation: entry.entry.implementation.clone(), outcome, - sequence: Some(sequence), + sequence: None, input_size, - output_size: Some(output_size), + output_size, failed: false, stage_disabled: false, reason_code, @@ -967,132 +1001,31 @@ fn body_invocation_with_reason( } } -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 { +fn failed_invocation(entry: &DescribedChainEntry, reason: &str) -> HttpResponseInvocation { + HttpResponseInvocation { config_name: entry.entry.name.clone(), implementation: entry.entry.implementation.clone(), - outcome: if fail_closed { - HttpResponseInvocationOutcome::FailClosed - } else { - HttpResponseInvocationOutcome::FailOpen - }, + outcome: HttpResponseInvocationOutcome::FailClosed, 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" - | "response_input_unrepresentable" - | "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, + failure_category: Some(reason.to_string()), } } -fn failed_preflight_outcome( +fn failed_outcome( headers: Vec, - reason: String, findings: Vec, metadata: BTreeMap>, invocations: Vec, + reason: &str, ) -> HttpResponsePreflightOutcome { HttpResponsePreflightOutcome { allowed: false, - reason, + reason: format!("middleware_failed: {reason}"), denial: None, headers, session: None, @@ -1103,1481 +1036,52 @@ fn failed_preflight_outcome( } } -fn blocked_preflight_outcome( +fn failed_preflight( + entries: &[DescribedChainEntry], headers: Vec, - denial: super::MiddlewareDenial, - findings: Vec, - metadata: BTreeMap>, - invocations: Vec, + reason: &str, ) -> HttpResponsePreflightOutcome { HttpResponsePreflightOutcome { allowed: false, - reason: middleware_denial_reason(&denial.config_name, denial.reason_code.as_deref()), - denial: Some(denial), + reason: reason.to_string(), + denial: None, headers, session: None, - findings, - metadata, - invocations, + findings: Vec::new(), + metadata: BTreeMap::new(), + invocations: entries + .iter() + .map(|entry| failed_invocation(entry, reason)) + .collect(), session_capacity_exhausted: false, } } -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, -) -> 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() - }); +fn empty_preflight(headers: Vec) -> HttpResponsePreflightOutcome { HttpResponsePreflightOutcome { - allowed: !fail_closed, - reason: if fail_closed { - "middleware_failed: middleware_session_capacity_exhausted".into() - } else { - String::new() - }, + allowed: true, + reason: 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; + invocations: Vec::new(), + session_capacity_exhausted: false, } } -async fn handle_opened_preflight_failure( - entry: &DescribedChainEntry, - current_stage: &mut HttpResponseStage, - prior_stages: &mut [HttpResponseStage], +fn response_failure( 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; + denial: Option, +) -> HttpResponseMiddlewareFailure { + HttpResponseMiddlewareFailure { + reason: reason.to_string(), + denial, + diagnostics: Box::default(), } - failure } -#[cfg(test)] -mod tests { - use std::sync::Arc; - - use openshell_core::middleware::InProcessMiddleware; - use openshell_core::proto::{ - ExistingHeaderAction, HeaderMutation, 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, - Expansion, - DeleteBody, - SkipBody, - Skip, - InvalidSkipReason, - TrailerMutation, - InvalidTrailerMutation, - } - - struct ResponseService { - script: Script, - } - - struct PreflightLifecycleService { - completion_tx: mpsc::UnboundedSender<(String, Vec)>, - } - - #[derive(Clone)] - struct RemoteResponseService { - session_end_tx: Option>, - } - - #[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_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(); - // 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); - let session_end_tx = self.session_end_tx.clone(); - 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(end)) => { - if let Some(sender) = &session_end_tx - && let Ok(reason) = MiddlewareSessionEndReason::try_from(end.reason) - { - let _ = sender.send(reason); - } - break; - } - 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 | Script::Expansion - ) { - 128 * 1024 - } else { - 4096 - }, - request_timeout: matches!(self.script, Script::HangBody).then(|| { - openshell_core::time::duration_from_std(Duration::from_millis(10)) - .expect("test timeout is in protobuf range") - }), - }], - expected_audience: String::new(), - } - } - - async fn validate_config( - &self, - _middleware_name: &str, - _config: &prost_types::Struct, - ) -> miette::Result<()> { - Ok(()) - } - - 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::Expansion - | Script::DeleteBody - | Script::SkipBody - | 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::Expansion => vec![b'x'; 128 * 1024], - Script::DeleteBody => Vec::new(), - Script::SkipBody => b"replacement".to_vec(), - 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, - }; - 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 { - sequence: if matches!( - selected_script, - Script::InvalidSequence - ) { - body.sequence + 1 - } else { - body.sequence - }, - action: Some(action), - ..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))) - } - } - - #[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 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 { - 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, - request_timeout: None, - }], - 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 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 { - 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 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") - })); - } - - #[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 { - 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))); - } - - 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].request_timeout = Some( - openshell_core::time::duration_from_std(Duration::from_millis(10)) - .expect("test timeout is in protobuf range"), - ); - manifest - } - - async fn validate_config(&self, _: &str, _: &prost_types::Struct) -> miette::Result<()> { - Ok(()) - } - - 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 { - 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 (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(service.clone())) - .add_service(HttpResponsePreReturnServer::new(service)) - .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()); - 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) - .await - .expect("bounded server shutdown") - .expect("join response middleware server") - .expect("serve response middleware"); - } +fn nonempty(value: &str) -> Option { + (!value.is_empty()).then(|| value.to_string()) } diff --git a/crates/openshell-supervisor-middleware/src/response/preflight.rs b/crates/openshell-supervisor-middleware/src/response/preflight.rs deleted file mode 100644 index e2e53e9bbe..0000000000 --- a/crates/openshell-supervisor-middleware/src/response/preflight.rs +++ /dev/null @@ -1,427 +0,0 @@ -// 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 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) = handle_opened_preflight_failure( - &entry, - &mut current_stage, - &mut stages, - "unexpected_response_result", - &mut invocations, - ) - .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) = handle_opened_preflight_failure( - &entry, - &mut current_stage, - &mut stages, - reason, - &mut invocations, - ) - .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, - }); - current_stage - .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) = handle_opened_preflight_failure( - &entry, - &mut current_stage, - &mut stages, - &reason, - &mut invocations, - ) - .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) = handle_opened_preflight_failure( - &entry, - &mut current_stage, - &mut stages, - &reason, - &mut invocations, - ) - .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, - }); - current_stage.mode = mode; - if mode == StageMode::HeadersOnly { - current_stage.end(MiddlewareSessionEndReason::Normal).await; - } else { - stages.push(current_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(current_stage); - 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) = handle_opened_preflight_failure( - &entry, - &mut current_stage, - &mut stages, - "invalid_preflight_decision", - &mut invocations, - ) - .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 deleted file mode 100644 index 03febd4479..0000000000 --- a/crates/openshell-supervisor-middleware/src/response/validation.rs +++ /dev/null @@ -1,328 +0,0 @@ -// 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/Cargo.toml b/crates/openshell-supervisor-network/Cargo.toml index a8f0561701..b7d57fb111 100644 --- a/crates/openshell-supervisor-network/Cargo.toml +++ b/crates/openshell-supervisor-network/Cargo.toml @@ -22,6 +22,9 @@ openshell-supervisor-middleware-builtins = { path = "../openshell-supervisor-mid async-trait = "0.1" apollo-parser = { workspace = true } +aws-credential-types = { version = "1", features = ["hardcoded-credentials"] } +aws-sigv4 = { version = "1", features = ["sign-http", "http1"] } +aws-smithy-runtime-api = { version = "1", features = ["client"] } http = { workspace = true } base64 = { workspace = true } bytes = { workspace = true } diff --git a/crates/openshell-supervisor-network/src/l7/middleware.rs b/crates/openshell-supervisor-network/src/l7/middleware.rs index 8f3a4b3b37..77635e2f20 100644 --- a/crates/openshell-supervisor-network/src/l7/middleware.rs +++ b/crates/openshell-supervisor-network/src/l7/middleware.rs @@ -5,18 +5,17 @@ use crate::l7::relay::L7EvalContext; use crate::opa::PolicyGenerationGuard; -use miette::{IntoDiagnostic, Result, miette}; +use miette::{Result, miette}; use openshell_ocsf::{ ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, }; -use std::io::SeekFrom; use std::path::PathBuf; use std::time::Duration; -use tokio::io::{AsyncRead, AsyncSeekExt, AsyncWrite, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncWrite}; -/// Maximum wall-clock time spent receiving, evaluating, and spooling one +/// Maximum wall-clock time spent receiving, evaluating, and processing one /// request body before the supervisor cancels the middleware session. // Keep `from_secs` while the workspace MSRV predates `Duration::from_mins`. #[allow(clippy::duration_suboptimal_units)] @@ -38,18 +37,17 @@ pub enum MiddlewareApplyResult { AdmissionExhausted, } -/// Storage-backed body produced by request middleware. The file is rewound and -/// contains normalized bytes without HTTP transfer framing. -pub struct RequestBodySpool { - pub(crate) file: tokio::fs::File, - pub(crate) len: u64, +/// Bounded normalized body retained only when a later policy or credential +/// step requires the complete representation before upstream contact. +pub struct BufferedRequestBody { + pub(crate) bytes: Vec, pub(crate) trailers: Vec, } /// Body delivery selected after all request-middleware preflights complete. pub enum MiddlewareRequestBody { /// A whole-body or ownership barrier completed before upstream contact. - Spool(RequestBodySpool), + Buffered(BufferedRequestBody), /// Every active body stage selected unit-local streaming, so approved /// units can be released under network backpressure. Live(Box), @@ -112,14 +110,14 @@ impl HttpMiddlewareExchange { where C: AsyncRead + AsyncWrite + Unpin + Send, { - self.apply_request_with_delivery( + Box::pin(self.apply_request_with_delivery( request, client, ctx, scheme, transformed_body_policy, RequestBodyDelivery::Incremental, - ) + )) .await } @@ -135,17 +133,19 @@ impl HttpMiddlewareExchange { where C: AsyncRead + AsyncWrite + Unpin + Send, { - apply_middleware_chain_for_scheme_with_request_id_and_delivery( - request, - client, - ctx, - scheme, - self.chain.clone(), - &self.runner, - &self.generation_guard, - transformed_body_policy, - &self.request_id, - delivery, + Box::pin( + apply_middleware_chain_for_scheme_with_request_id_and_delivery( + request, + client, + ctx, + scheme, + self.chain.clone(), + &self.runner, + &self.generation_guard, + transformed_body_policy, + &self.request_id, + delivery, + ), ) .await } @@ -590,7 +590,7 @@ pub async fn apply_middleware_chain_with_request_id, request_id: &str, ) -> Result { - apply_middleware_chain_with_request_id_and_delivery( + Box::pin(apply_middleware_chain_with_request_id_and_delivery( req, client, ctx, @@ -600,7 +600,7 @@ pub async fn apply_middleware_chain_with_request_id Result { - apply_middleware_chain_for_scheme_with_request_id_and_delivery( - req, - client, - ctx, - "https", - chain, - runner, - generation_guard, - transformed_body_policy, - request_id, - delivery, - ) - .await -} - -#[allow(clippy::too_many_arguments)] -#[cfg(test)] -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, - scheme: &str, - chain: Vec, - runner: &openshell_supervisor_middleware::ChainRunner, - generation_guard: &PolicyGenerationGuard, - transformed_body_policy: openshell_supervisor_middleware::TransformedBodyPolicy<'_>, - request_id: &str, -) -> Result { - apply_middleware_chain_for_scheme_with_request_id_and_delivery( - req, - client, - ctx, - scheme, - chain, - runner, - generation_guard, - transformed_body_policy, - request_id, - RequestBodyDelivery::Incremental, + Box::pin( + apply_middleware_chain_for_scheme_with_request_id_and_delivery( + req, + client, + ctx, + "https", + chain, + runner, + generation_guard, + transformed_body_policy, + request_id, + delivery, + ), ) .await } @@ -700,7 +672,7 @@ pub async fn apply_middleware_chain_for_scheme_with_request_id_and_delivery< .await; } - apply_streaming_middleware_chain( + Box::pin(apply_streaming_middleware_chain( req, client, ctx, @@ -710,7 +682,7 @@ pub async fn apply_middleware_chain_for_scheme_with_request_id_and_delivery< generation_guard, request_id, delivery, - ) + )) .await } @@ -779,8 +751,8 @@ async fn apply_buffered_middleware_chain buffered, - crate::l7::rest::BufferResult::OverCapacity { recoverable } => { - return Ok(resolve_unbuffered_body(ctx, req, &chain, recoverable)); + crate::l7::rest::BufferResult::OverCapacity => { + return Ok(resolve_unbuffered_body(ctx)); } }; let headers = safe_middleware_headers(&buffered.headers)?; @@ -892,7 +864,7 @@ async fn apply_streaming_middleware_chain { - let diagnostics = session.take_diagnostics(); - session - .end(openshell_core::proto::MiddlewareSessionEndReason::Cancellation) - .await; - emit_request_body_timeout(ctx, &req, &preflight, diagnostics); - return Ok(MiddlewareApplyResult::Denied { denial: None }); - } - Ok(Ok(units)) => { - write_spooled_units(&mut file, &mut output_len, units).await?; - } - Ok(Err(error)) => { - body_invocations.extend(error.diagnostics.invocations); - body_findings.extend(error.diagnostics.findings); - body_metadata.extend(error.diagnostics.metadata); - let mut invocations = preflight.invocations; - invocations.extend(body_invocations); - let mut findings = preflight.findings; - findings.extend(body_findings); - let mut metadata = preflight.metadata; - metadata.extend(body_metadata); - emit_streaming_middleware_events( - ctx, - &req, - false, - &error.reason, - error.denial.as_ref(), - &findings, - &metadata, - &invocations, - false, - ); - return Ok(MiddlewareApplyResult::Denied { - denial: error.denial, - }); + let feed = async { + loop { + let unit = body_reader + .next_unit(client, Some(generation_guard), unit_limit) + .await?; + let Some(unit) = unit else { + break; + }; + input_tx + .send(openshell_supervisor_middleware::HttpRequestBodyInput::Chunk(unit)) + .await + .map_err(|_| miette!("request middleware input closed"))?; + } + input_tx + .send(openshell_supervisor_middleware::HttpRequestBodyInput::End( + body_reader.take_trailers(), + )) + .await + .map_err(|_| miette!("request middleware input closed")) + }; + let collect = async { + let mut body = Vec::new(); + let mut trailers = Vec::new(); + let mut started = false; + while let Some(event) = output_rx.recv().await { + match event { + openshell_supervisor_middleware::HttpRequestBodyOutput::Start { .. } + if !started => + { + started = true; + } + openshell_supervisor_middleware::HttpRequestBodyOutput::Chunk(unit) if started => { + if body.len().saturating_add(unit.len()) + > openshell_supervisor_middleware::MAX_HTTP_REQUEST_DEFERRED_BYTES + { + return Err(miette!( + "middleware request output exceeds platform memory limit" + )); + } + body.extend_from_slice(&unit); + } + openshell_supervisor_middleware::HttpRequestBodyOutput::End { trailers: value } + if started => + { + trailers = value; + break; + } + _ => return Err(miette!("invalid request middleware output order")), } } - } - let trailers = body_reader.take_trailers(); - let (output_tx, mut output_rx) = tokio::sync::mpsc::channel(4); - let finish_future = session.finish_to(trailers, output_tx); - let writer_future = async { - while let Some(unit) = output_rx.recv().await { - write_spooled_units(&mut file, &mut output_len, vec![unit]).await?; + if !started { + return Err(miette!("request middleware output did not start")); } - Ok::<(), miette::Report>(()) + Ok::<_, miette::Report>((body, trailers)) }; - let completed = tokio::time::timeout_at(body_deadline, async { - let (finish, writer) = tokio::join!(finish_future, writer_future); - writer?; - Ok::<_, miette::Report>(finish) - }) + let run = session.run(input_rx, output_tx); + let completed = Box::pin(tokio::time::timeout_at(body_deadline, async { + let (finish, feed, output) = tokio::join!(run, feed, collect); + feed?; + let output = output?; + Ok::<_, miette::Report>((finish, output)) + })) .await; - let finish = if let Ok(completed) = completed { - completed? - } else { - emit_request_body_timeout( - ctx, - &req, - &preflight, - openshell_supervisor_middleware::HttpRequestDiagnostics::default(), - ); - return Ok(MiddlewareApplyResult::Denied { denial: None }); - }; - let finish = match finish { - Ok(finish) => finish, - Err(error) => { + let (finish, (body, trailers)) = match completed { + Err(_) => { + emit_request_body_timeout( + ctx, + &req, + &preflight, + openshell_supervisor_middleware::HttpRequestDiagnostics::default(), + ); + return Ok(MiddlewareApplyResult::Denied { denial: None }); + } + Ok(Err(error)) => return Err(error), + Ok(Ok((Err(error), _))) => { let mut invocations = preflight.invocations; invocations.extend(error.diagnostics.invocations); let mut findings = preflight.findings; @@ -1060,16 +1016,15 @@ async fn apply_streaming_middleware_chain (finish, output), }; generation_guard.ensure_current()?; - file.flush().await.into_diagnostic()?; - file.seek(SeekFrom::Start(0)).await.into_diagnostic()?; let rebuilt = crate::l7::rest::rebuild_request_with_streamed_body( &req, &prepared_headers, - output_len, - &finish.trailers, + body.len() as u64, + &trailers, &preflight.header_mutations, )?; let mut invocations = preflight.invocations; @@ -1091,10 +1046,9 @@ async fn apply_streaming_middleware_chain( &mut self, client: &mut C, - output: tokio::sync::mpsc::Sender>, + output: tokio::sync::mpsc::Sender, ) -> Result where C: AsyncRead + Unpin, { - loop { - let unit_limit = self.session.as_ref().map_or( - 1, - openshell_supervisor_middleware::HttpRequestSession::stream_unit_limit, - ); - let next = tokio::time::timeout_at( - self.deadline, - self.reader - .next_unit(client, Some(&self.generation_guard), unit_limit), - ) - .await; - let unit = match next { - Err(_) => { - self.cancel_with_diagnostics( - "middleware_failed: request_body_timeout", - openshell_supervisor_middleware::HttpRequestDiagnostics::default(), - ) - .await; - return Err(miette!("request middleware body deadline exceeded")); - } - Ok(Err(error)) => { - self.cancel_with_diagnostics( - "middleware_failed: request_body_read_failed", - openshell_supervisor_middleware::HttpRequestDiagnostics::default(), - ) - .await; - return Err(error); - } - Ok(Ok(None)) => break, - Ok(Ok(Some(unit))) => unit, - }; - - let pushed = { - let session = self - .session - .as_mut() - .ok_or_else(|| miette!("request middleware session is unavailable"))?; - tokio::time::timeout_at(self.deadline, session.push_body(unit)).await - }; - let units = match pushed { - Err(_) => { - let diagnostics = self - .session - .as_mut() - .map(openshell_supervisor_middleware::HttpRequestSession::take_diagnostics) - .unwrap_or_default(); - self.cancel_with_diagnostics( - "middleware_failed: request_body_timeout", - diagnostics, - ) - .await; - return Err(miette!("request middleware body deadline exceeded")); - } - Ok(Err(error)) => { - let reason = error.reason.clone(); - let denial = error.denial.clone(); - self.emit_failure(&reason, denial.as_ref(), *error.diagnostics); - self.session.take(); - return Err(miette!("{reason}")); - } - Ok(Ok(units)) => units, - }; - for unit in units { - match tokio::time::timeout_at(self.deadline, output.send(unit)).await { - Err(_) => { - let diagnostics = self - .session - .as_mut() - .map( - openshell_supervisor_middleware::HttpRequestSession::take_diagnostics, - ) - .unwrap_or_default(); - self.cancel_with_diagnostics( - "middleware_failed: request_body_timeout", - diagnostics, - ) - .await; - return Err(miette!("request middleware body deadline exceeded")); - } - Ok(Err(_)) => { - self.cancel_with_diagnostics( - "middleware_failed: request_output_closed", - openshell_supervisor_middleware::HttpRequestDiagnostics::default(), - ) - .await; - return Err(miette!("request middleware output consumer closed")); - } - Ok(Ok(())) => {} - } - } - } - - let trailers = self.reader.take_trailers(); let session = self .session .take() .ok_or_else(|| miette!("request middleware session is unavailable"))?; - let finish = - tokio::time::timeout_at(self.deadline, session.finish_to(trailers, output)).await; - match finish { + let unit_limit = session.stream_unit_limit(); + let (input_tx, input_rx) = tokio::sync::mpsc::channel(4); + let (event_tx, mut event_rx) = tokio::sync::mpsc::channel(4); + let feed = async { + loop { + let unit = self + .reader + .next_unit(client, Some(&self.generation_guard), unit_limit) + .await?; + let Some(unit) = unit else { + break; + }; + input_tx + .send(openshell_supervisor_middleware::HttpRequestBodyInput::Chunk(unit)) + .await + .map_err(|_| miette!("request middleware input closed"))?; + } + input_tx + .send(openshell_supervisor_middleware::HttpRequestBodyInput::End( + self.reader.take_trailers(), + )) + .await + .map_err(|_| miette!("request middleware input closed")) + }; + let forward = async { + let mut started = false; + while let Some(event) = event_rx.recv().await { + match &event { + openshell_supervisor_middleware::HttpRequestBodyOutput::Start { .. } + if !started => + { + started = true; + } + openshell_supervisor_middleware::HttpRequestBodyOutput::Chunk(_) if started => { + } + openshell_supervisor_middleware::HttpRequestBodyOutput::End { .. } + if started => + { + output + .send(event) + .await + .map_err(|_| miette!("request middleware output consumer closed"))?; + return Ok::<(), miette::Report>(()); + } + _ => return Err(miette!("invalid request middleware output order")), + } + output + .send(event) + .await + .map_err(|_| miette!("request middleware output consumer closed"))?; + } + Err(miette!("request middleware output ended early")) + }; + let run = session.run(input_rx, event_tx); + let completed = Box::pin(tokio::time::timeout_at(self.deadline, async { + let (finish, feed, forward) = tokio::join!(run, feed, forward); + feed?; + forward?; + Ok::<_, miette::Report>(finish) + })) + .await; + match completed { Err(_) => { self.emit_failure( "middleware_failed: request_body_timeout", @@ -1222,12 +1142,20 @@ impl RequestBodyStream { Err(miette!("request middleware body deadline exceeded")) } Ok(Err(error)) => { + self.emit_failure( + "middleware_failed: request_body_io_failed", + None, + openshell_supervisor_middleware::HttpRequestDiagnostics::default(), + ); + Err(error) + } + Ok(Ok(Err(error))) => { let reason = error.reason.clone(); let denial = error.denial.clone(); self.emit_failure(&reason, denial.as_ref(), *error.diagnostics); Err(miette!("{reason}")) } - Ok(Ok(finish)) => { + Ok(Ok(Ok(finish))) => { self.emit_success(&finish); Ok(finish) } @@ -1339,25 +1267,6 @@ fn emit_request_body_timeout( ); } -async fn write_spooled_units( - file: &mut tokio::fs::File, - output_len: &mut u64, - units: Vec>, -) -> Result<()> { - for unit in units { - *output_len = output_len - .checked_add(unit.len() as u64) - .ok_or_else(|| miette!("middleware request output length overflow"))?; - if *output_len > openshell_supervisor_middleware::MAX_HTTP_REQUEST_DEFERRED_BYTES as u64 { - return Err(miette!( - "middleware request output exceeds platform storage limit" - )); - } - file.write_all(&unit).await.into_diagnostic()?; - } - Ok(()) -} - #[allow(clippy::too_many_arguments)] fn emit_streaming_middleware_events( ctx: &L7EvalContext, @@ -1379,12 +1288,11 @@ fn emit_streaming_middleware_events( existing.failed |= invocation.failed; existing.transformed |= matches!( invocation.outcome, - openshell_supervisor_middleware::HttpRequestInvocationOutcome::Transform - | openshell_supervisor_middleware::HttpRequestInvocationOutcome::OwnedStream + openshell_supervisor_middleware::HttpRequestInvocationOutcome::Replacement ); if matches!( invocation.outcome, - openshell_supervisor_middleware::HttpRequestInvocationOutcome::BlockRequest + openshell_supervisor_middleware::HttpRequestInvocationOutcome::Reject | openshell_supervisor_middleware::HttpRequestInvocationOutcome::FailClosed ) { existing.decision = openshell_core::proto::Decision::Deny; @@ -1396,7 +1304,7 @@ fn emit_streaming_middleware_events( implementation: invocation.implementation.clone(), decision: if matches!( invocation.outcome, - openshell_supervisor_middleware::HttpRequestInvocationOutcome::BlockRequest + openshell_supervisor_middleware::HttpRequestInvocationOutcome::Reject | openshell_supervisor_middleware::HttpRequestInvocationOutcome::FailClosed ) { openshell_core::proto::Decision::Deny @@ -1536,35 +1444,17 @@ pub(super) fn raw_query_from_request_headers(headers: &[u8]) -> Result { .map_or_else(String::new, |(_, query)| query.to_string())) } -/// Apply the chain's `on_error` policy when the request body exceeds every -/// stage's buffering limit. No stage can inspect such a body, so each stage -/// would individually fail with `request_body_over_capacity`; the aggregate is -/// a deny unless every attached middleware is `fail_open`, and passing the -/// body through is only safe when no bytes were consumed. -pub(super) fn resolve_unbuffered_body( - ctx: &L7EvalContext, - req: crate::l7::provider::L7Request, - chain: &[openshell_supervisor_middleware::DescribedChainEntry], - recoverable: bool, -) -> MiddlewareApplyResult { - let all_fail_open = chain - .iter() - .all(|entry| entry.on_error() == openshell_supervisor_middleware::OnError::FailOpen); - if recoverable && all_fail_open { - emit_middleware_body_unavailable(ctx, false); - return MiddlewareApplyResult::Allowed(req); - } - emit_middleware_body_unavailable(ctx, true); +/// Deny when the request body exceeds the bounded hold required by this path. +/// HTTP middleware never bypasses a selected stage on failure, even if invalid +/// policy state reaches this defensive runtime check. +pub(super) fn resolve_unbuffered_body(ctx: &L7EvalContext) -> MiddlewareApplyResult { + emit_middleware_body_unavailable(ctx); MiddlewareApplyResult::Denied { denial: None } } -fn emit_middleware_body_unavailable(ctx: &L7EvalContext, denied: bool) { +fn emit_middleware_body_unavailable(ctx: &L7EvalContext) { let event = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .severity(if denied { - SeverityId::High - } else { - SeverityId::Medium - }) + .severity(SeverityId::High) .finding_info(FindingInfo::new( "openshell.middleware.body_unavailable", "Supervisor middleware could not inspect request body", @@ -1572,13 +1462,9 @@ fn emit_middleware_body_unavailable(ctx: &L7EvalContext, denied: bool) { .evidence_pairs(&[ ("policy", ctx.policy_name.as_str()), ("host", ctx.host.as_str()), - ("disposition", if denied { "denied" } else { "fail_open" }), + ("disposition", "denied"), ]) - .message(if denied { - "Request body exceeded middleware inspection cap; denied" - } else { - "Request body exceeded middleware inspection cap; passed through (fail_open)" - }) + .message("Request body exceeded middleware inspection cap; denied") .build(); ocsf_emit!(event); } diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 7fd7ba5ee4..e51422dbc6 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -14,7 +14,6 @@ pub mod jsonrpc; pub(crate) mod mcp; pub(crate) mod middleware; pub mod path; -pub(crate) mod post_credentials; pub mod provider; pub mod relay; pub mod rest; diff --git a/crates/openshell-supervisor-network/src/l7/post_credentials.rs b/crates/openshell-supervisor-network/src/l7/post_credentials.rs deleted file mode 100644 index aadf991330..0000000000 --- a/crates/openshell-supervisor-network/src/l7/post_credentials.rs +++ /dev/null @@ -1,371 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Restricted in-process request middleware that runs after credential -//! resolution. External middleware can never enter this phase. - -use std::io::SeekFrom; - -use miette::{IntoDiagnostic as _, Result, miette}; -use openshell_core::secrets::SecretResolver; -use openshell_supervisor_middleware_builtins::sigv4::{ - BodyFraming as SigV4BodyFraming, PayloadMode as SigV4PayloadMode, RequestedPayloadMode, - SigV4Middleware, SigningCredentials, SigningTarget, -}; -use tokio::io::{AsyncRead, AsyncReadExt as _, AsyncSeekExt as _, AsyncWrite, AsyncWriteExt as _}; - -use crate::l7::middleware::RequestBodySpool; -use crate::l7::provider::{BodyLength, L7Request}; -use crate::opa::PolicyGenerationGuard; - -/// A trusted built-in synthesized from endpoint policy. It is deliberately not -/// representable by an operator-run middleware registration. -#[derive(Clone, Copy, Debug)] -pub enum PostCredentialsMiddleware<'a> { - SigV4 { - requested_mode: RequestedPayloadMode, - service: &'a str, - region: &'a str, - host: &'a str, - port: u16, - }, -} - -/// Result of the post-credentials stage. A complete request already contains -/// its body; a signed head leaves normalized body relay to the HTTP owner. -pub enum PostCredentialsOutput { - Complete { - request: Vec, - payload_mode: SigV4PayloadMode, - }, - Head { - headers: Vec, - payload_mode: SigV4PayloadMode, - }, -} - -impl<'a> PostCredentialsMiddleware<'a> { - pub(crate) fn from_endpoint( - signing: crate::l7::CredentialSigning, - service: &'a str, - region: &'a str, - host: &'a str, - port: u16, - ) -> Option { - let requested_mode = match signing { - crate::l7::CredentialSigning::None => return None, - crate::l7::CredentialSigning::SigV4 => RequestedPayloadMode::Auto, - crate::l7::CredentialSigning::SigV4Body => RequestedPayloadMode::SignBody, - crate::l7::CredentialSigning::SigV4NoBody => RequestedPayloadMode::UnsignedPayload, - }; - Some(Self::SigV4 { - requested_mode, - service, - region, - host, - port, - }) - } - - /// Remove caller-provided authorization fields before placeholder - /// rewriting. The trusted stage regenerates them after credentials resolve. - pub(crate) fn strip_caller_authorization(self, raw_headers: &[u8]) -> Result> { - match self { - Self::SigV4 { .. } => SigV4Middleware::strip_existing_auth(raw_headers), - } - } - - #[allow(clippy::too_many_arguments)] - pub(crate) async fn evaluate( - self, - request: &L7Request, - original_headers: &str, - rewritten_headers: &[u8], - client: &mut C, - prepared_body: Option<&mut RequestBodySpool>, - resolver: Option<&SecretResolver>, - generation_guard: Option<&PolicyGenerationGuard>, - ) -> Result - where - C: AsyncRead + AsyncWrite + Unpin, - { - match self { - Self::SigV4 { - requested_mode, - service, - region, - host, - .. - } => { - let resolver = resolver.ok_or_else(|| { - miette::Report::new(super::rest::CredentialUnavailableError::new( - "SigV4 signing configured but no secret resolver available", - )) - })?; - let access_key = resolver - .resolve_current_env_key_checked("AWS_ACCESS_KEY_ID", "sigv4") - .map_err(miette::Report::new)?; - let secret_key = resolver - .resolve_current_env_key_checked("AWS_SECRET_ACCESS_KEY", "sigv4") - .map_err(miette::Report::new)?; - let session_token = resolver - .resolve_current_env_key_checked("AWS_SESSION_TOKEN", "sigv4") - .map_err(miette::Report::new)?; - let (Some(access_key), Some(secret_key)) = (access_key, secret_key) else { - return Err(miette::Report::new( - super::rest::CredentialUnavailableError::new( - "SigV4 signing configured but AWS credentials not found in provider", - ), - )); - }; - - if service.is_empty() { - return Err(miette!( - "SigV4 signing configured but signing_service not set in policy" - )); - } - let resolved_region = if region.is_empty() { - openshell_supervisor_middleware_builtins::sigv4::extract_aws_region(host) - .ok_or_else(|| { - miette!( - "SigV4 signing: cannot extract AWS region from hostname \ - '{host}'; set signing_region in the policy endpoint" - ) - })? - } else { - region.to_string() - }; - - let framing = sigv4_body_framing(request.body_length); - let payload_mode = - openshell_supervisor_middleware_builtins::sigv4::resolve_payload_mode( - requested_mode, - original_headers, - framing, - )?; - let target = SigningTarget { - host, - region: &resolved_region, - service, - }; - let credentials = SigningCredentials { - access_key, - secret_key, - session_token, - }; - - if payload_mode == SigV4PayloadMode::SignBody { - if matches!(request.body_length, BodyLength::Chunked) { - return Err(miette!( - "SigV4 body signing requires Content-Length; chunked transfer \ - encoding is not supported in this mode" - )); - } - let body = - collect_body_for_signing(request, client, prepared_body, generation_guard) - .await?; - let mut complete = Vec::with_capacity(rewritten_headers.len() + body.len()); - complete.extend_from_slice(rewritten_headers); - complete.extend_from_slice(&body); - let request = SigV4Middleware::sign_body(&complete, target, credentials)?; - Ok(PostCredentialsOutput::Complete { - request, - payload_mode, - }) - } else { - let headers = SigV4Middleware::sign_headers( - rewritten_headers, - target, - credentials, - payload_mode, - )?; - Ok(PostCredentialsOutput::Head { - headers, - payload_mode, - }) - } - } - } - } - - pub(crate) fn emit_success(self, payload_mode: SigV4PayloadMode) { - match self { - Self::SigV4 { - service, - region, - host, - port, - .. - } => { - let resolved_region = if region.is_empty() { - openshell_supervisor_middleware_builtins::sigv4::extract_aws_region(host) - .unwrap_or_else(|| "unknown".into()) - } else { - region.to_string() - }; - let event = openshell_ocsf::NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(openshell_ocsf::ActivityId::Traffic) - .action(openshell_ocsf::ActionId::Allowed) - .disposition(openshell_ocsf::DispositionId::Allowed) - .severity(openshell_ocsf::SeverityId::Informational) - .status(openshell_ocsf::StatusId::Success) - .dst_endpoint(openshell_ocsf::Endpoint::from_domain(host, port)) - .message(format!( - "openshell/sigv4 signed {host}:{port} service={service} \ - region={resolved_region} mode={payload_mode}" - )) - .build(); - openshell_ocsf::ocsf_emit!(event); - } - } - } -} - -fn sigv4_body_framing(body_length: BodyLength) -> SigV4BodyFraming { - match body_length { - BodyLength::None => SigV4BodyFraming::None, - BodyLength::ContentLength(_) => SigV4BodyFraming::ContentLength, - BodyLength::Chunked => SigV4BodyFraming::Chunked, - } -} - -async fn collect_body_for_signing( - request: &L7Request, - client: &mut C, - prepared_body: Option<&mut RequestBodySpool>, - generation_guard: Option<&PolicyGenerationGuard>, -) -> Result> -where - C: AsyncRead + AsyncWrite + Unpin, -{ - use openshell_supervisor_middleware_builtins::sigv4::MAX_BODY_BYTES; - - if let Some(body) = prepared_body { - if body.len > MAX_BODY_BYTES as u64 { - return Err(miette!( - "SigV4 body signing buffers at most {MAX_BODY_BYTES} bytes" - )); - } - if !body.trailers.is_empty() { - return Err(miette!( - "SigV4 body signing does not support request trailers" - )); - } - body.file.seek(SeekFrom::Start(0)).await.into_diagnostic()?; - let capacity = usize::try_from(body.len) - .map_err(|_| miette!("SigV4 middleware body does not fit addressable memory"))?; - let mut bytes = Vec::with_capacity(capacity); - body.file.read_to_end(&mut bytes).await.into_diagnostic()?; - if bytes.len() as u64 != body.len { - return Err(miette!("middleware request spool ended early")); - } - return Ok(bytes); - } - - if has_expect_continue(original_header_text(request)?) { - client - .write_all(b"HTTP/1.1 100 Continue\r\n\r\n") - .await - .into_diagnostic()?; - client.flush().await.into_diagnostic()?; - } - - let header_end = request_header_end(request); - let overflow = &request.raw_header[header_end..]; - match request.body_length { - BodyLength::None => { - if !overflow.is_empty() { - return Err(miette!("bodyless SigV4 request contains read-ahead bytes")); - } - Ok(Vec::new()) - } - BodyLength::ContentLength(body_len) => { - if body_len > MAX_BODY_BYTES as u64 { - return Err(miette!( - "SigV4 body signing buffers at most {MAX_BODY_BYTES} bytes" - )); - } - if overflow.len() as u64 > body_len { - return Err(miette!( - "SigV4 request read-ahead exceeds its declared Content-Length" - )); - } - let body_len = usize::try_from(body_len) - .map_err(|_| miette!("SigV4 request body does not fit addressable memory"))?; - let mut body = Vec::with_capacity(body_len); - body.extend_from_slice(overflow); - let remaining = body_len - overflow.len(); - if remaining > 0 { - let start = body.len(); - body.resize(body_len, 0); - client - .read_exact(&mut body[start..]) - .await - .into_diagnostic()?; - } - if let Some(guard) = generation_guard { - guard.ensure_current()?; - } - Ok(body) - } - BodyLength::Chunked => Err(miette!( - "SigV4 body signing requires Content-Length; chunked transfer encoding is not supported" - )), - } -} - -fn request_header_end(request: &L7Request) -> usize { - request - .raw_header - .windows(4) - .position(|window| window == b"\r\n\r\n") - .map_or(request.raw_header.len(), |position| position + 4) -} - -fn original_header_text(request: &L7Request) -> Result<&str> { - std::str::from_utf8(&request.raw_header[..request_header_end(request)]) - .map_err(|_| miette!("SigV4 request headers are not valid UTF-8")) -} - -fn has_expect_continue(headers: &str) -> bool { - headers.lines().skip(1).any(|line| { - line.split_once(':').is_some_and(|(name, value)| { - name.eq_ignore_ascii_case("expect") - && value - .split(',') - .any(|token| token.trim().eq_ignore_ascii_case("100-continue")) - }) - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn synthesizes_only_configured_post_credentials_middleware() { - assert!( - PostCredentialsMiddleware::from_endpoint( - crate::l7::CredentialSigning::None, - "", - "", - "example.com", - 443, - ) - .is_none() - ); - assert!(matches!( - PostCredentialsMiddleware::from_endpoint( - crate::l7::CredentialSigning::SigV4NoBody, - "s3", - "us-east-1", - "s3.us-east-1.amazonaws.com", - 443, - ), - Some(PostCredentialsMiddleware::SigV4 { - requested_mode: RequestedPayloadMode::UnsignedPayload, - .. - }) - )); - } -} diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index ca6e1be3b2..931aa582e0 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -1212,14 +1212,9 @@ where && config.request_body_credential_rewrite, deny_uninspected_credentials: config .deny_uninspected_body_credentials(ctx.secret_resolver.is_some()), - post_credentials: - crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( - config.credential_signing, - &config.signing_service, - &config.signing_region, - &ctx.host, - ctx.port, - ), + credential_signing: config.credential_signing, + signing_service: &config.signing_service, + signing_region: &config.signing_region, host: &ctx.host, port: ctx.port, }, @@ -1997,14 +1992,9 @@ where && config.request_body_credential_rewrite, deny_uninspected_credentials: config .deny_uninspected_body_credentials(ctx.secret_resolver.is_some()), - post_credentials: - crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( - config.credential_signing, - &config.signing_service, - &config.signing_region, - &ctx.host, - ctx.port, - ), + credential_signing: config.credential_signing, + signing_service: &config.signing_service, + signing_region: &config.signing_region, host: &ctx.host, port: ctx.port, }, @@ -3427,73 +3417,57 @@ mod tests { use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; fn whole_body_request_stream( - mut requests: tokio::sync::mpsc::Receiver, + mut requests: tokio::sync::mpsc::Receiver, replacement: Option>, gate: Option<(Arc, Arc)>, - ) -> openshell_core::middleware::HttpRequestResultStream { + ) -> openshell_core::middleware::HttpResultStream { use openshell_core::proto::{ - HttpRequestBodyMode, HttpRequestBodyPassThrough, HttpRequestBodyResult, - HttpRequestBodyTransform, HttpRequestEventResult, HttpRequestPreflightInspect, - HttpRequestPreflightResult, HttpRequestTrailersResult, http_request_body_result, - http_request_body_transform, http_request_event, http_request_event_result, - http_request_preflight_result, + HttpBufferedMode, HttpBufferedResult, HttpInspect, HttpPreflightResult, HttpResult, + HttpUnchanged, http_buffered_result, http_event, http_inspect, http_preflight_result, + http_result, }; let (sender, receiver) = tokio::sync::mpsc::channel(4); tokio::spawn(async move { while let Some(event) = requests.recv().await { let result = match event.event { - Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::WholeBodyBytes as i32, - header_mutations: Vec::new(), - }, - )), - ..Default::default() - }, - )), - }, - Some(http_request_event::Event::Body(body)) => { + Some(http_event::Event::Preflight(preflight)) => { if let Some((entered, release)) = &gate { entered.notify_one(); release.notified().await; } - let action = replacement.as_ref().map_or_else( - || { - http_request_body_result::Action::PassThrough( - HttpRequestBodyPassThrough {}, - ) - }, - |replacement| { - http_request_body_result::Action::Transform( - HttpRequestBodyTransform { - replacement: Some( - http_request_body_transform::Replacement::Data( - replacement.clone(), - ), - ), - }, - ) - }, - ); - HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyResult( - HttpRequestBodyResult { - sequence: body.sequence, - action: Some(action), + HttpResult { + result: Some(http_result::Result::PreflightResult( + HttpPreflightResult { + decision: Some(http_preflight_result::Decision::Inspect( + HttpInspect { + mode: Some(http_inspect::Mode::Buffered( + HttpBufferedMode { + max_body_bytes: preflight + .limits + .as_ref() + .map_or(1, |limits| { + limits.max_buffered_body_bytes + }), + }, + )), + }, + )), ..Default::default() }, )), } } - Some(http_request_event::Event::Trailers(_)) => HttpRequestEventResult { - result: Some(http_request_event_result::Result::TrailersResult( - HttpRequestTrailersResult::default(), - )), + Some(http_event::Event::BufferedBody(_)) => HttpResult { + result: Some(http_result::Result::BufferedResult(HttpBufferedResult { + body: Some(replacement.as_ref().map_or_else( + || http_buffered_result::Body::Unchanged(HttpUnchanged {}), + |value| http_buffered_result::Body::Replacement(value.clone()), + )), + ..Default::default() + })), }, - Some(http_request_event::Event::SessionEnd(_)) | None => break, + Some(http_event::Event::SessionEnd(_)) | None => break, + Some(_) => continue, }; if sender.send(Ok(result)).await.is_err() { break; @@ -4063,14 +4037,9 @@ mod tests { request, resolver.as_ref(), crate::l7::rest::RelayRequestOptions { - post_credentials: - crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( - crate::l7::CredentialSigning::SigV4NoBody, - "execute-api", - "us-west-2", - "denied.example.test", - 443, - ), + credential_signing: crate::l7::CredentialSigning::SigV4NoBody, + signing_service: "execute-api", + signing_region: "us-west-2", host: "denied.example.test", port: 443, ..Default::default() @@ -4104,35 +4073,6 @@ mod tests { assert!(body.get("agent_guidance").is_none()); } - fn assert_middleware_unavailable_response(response: &str, policy_name: &str) { - assert!( - response.starts_with("HTTP/1.1 503 Service Unavailable\r\n"), - "{response}" - ); - assert!(!response.contains("100 Continue"), "{response}"); - assert!(!response.to_ascii_lowercase().contains("retry-after")); - let (headers, body) = response.split_once("\r\n\r\n").expect("HTTP response"); - let content_length = headers - .lines() - .find_map(|line| { - line.strip_prefix("Content-Length: ") - .and_then(|value| value.parse::().ok()) - }) - .expect("Content-Length"); - assert_eq!(content_length, body.len()); - let body: serde_json::Value = serde_json::from_str(body).expect("JSON response"); - assert_eq!(body["error"], "middleware_failed"); - assert_eq!( - body["detail"], - "Request could not be processed by configured middleware" - ); - assert_eq!(body["policy"], policy_name); - assert!(body.get("middleware").is_none()); - assert!(body.get("reason_code").is_none()); - assert!(body.get("rule_missing").is_none()); - assert!(body.get("next_steps").is_none()); - } - fn rest_token_grant_relay_context( resolver_response: std::result::Result<&str, &str>, ) -> ( @@ -4438,6 +4378,7 @@ network_policies: seconds: 2, nanos: 0, }), + ..Default::default() }], expected_audience: String::new(), }, @@ -5341,94 +5282,6 @@ network_policies: .unwrap(); } - #[tokio::test] - async fn l7_rest_exhausted_middleware_admission_returns_503_before_body_or_upstream() { - let (config, tunnel_engine, ctx) = middleware_relay_context("openshell/regex", "fail_open"); - let runner = tunnel_engine.middleware_runner().clone(); - - let mut active = Vec::new(); - for _ in 0..openshell_supervisor_middleware::MAX_CONCURRENT_MIDDLEWARE_WORK { - active.push( - runner - .reserve_middleware_work_admission() - .await - .expect("fill active middleware work"), - ); - } - let mut waiters = Vec::new(); - for _ in 0..openshell_supervisor_middleware::MAX_QUEUED_MIDDLEWARE_WORK { - let runner = runner.clone(); - waiters.push(Box::pin( - async move { runner.reserve_middleware_work().await }, - )); - } - for waiter in &mut waiters { - assert!( - futures::poll!(waiter.as_mut()).is_pending(), - "every bounded waiter slot must be occupied" - ); - } - - let (mut app, mut relay_client) = tokio::io::duplex(8192); - let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); - let relay = tokio::spawn(async move { - relay_with_inspection( - &config, - tunnel_engine, - &mut relay_client, - &mut relay_upstream, - &ctx, - ) - .await - }); - - // The declared body is within the built-in regex HTTP capability, but - // the client intentionally withholds it behind Expect: 100-continue. - // Queue exhaustion must be answered before buffering begins. - app.write_all( - b"POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: 32\r\nExpect: 100-continue\r\nConnection: close\r\n\r\n", - ) - .await - .expect("send headers without body"); - - tokio::time::timeout(std::time::Duration::from_secs(1), relay) - .await - .expect("relay should shed immediately") - .expect("join relay") - .expect("relay returns a complete HTTP response"); - - let mut response = Vec::new(); - tokio::time::timeout( - std::time::Duration::from_secs(1), - app.read_to_end(&mut response), - ) - .await - .expect("client should receive 503 without sending its body") - .expect("read client response"); - let response = String::from_utf8(response).expect("UTF-8 response"); - assert_middleware_unavailable_response(&response, "rest_api"); - - let mut upstream_bytes = Vec::new(); - tokio::time::timeout( - std::time::Duration::from_secs(1), - upstream.read_to_end(&mut upstream_bytes), - ) - .await - .expect("upstream side should close") - .expect("read upstream"); - assert!( - upstream_bytes.is_empty(), - "admission-exhausted request must not reach upstream" - ); - - drop(waiters); - drop(active); - runner - .reserve_middleware_work_admission() - .await - .expect("work capacity recovers after saturation fixture"); - } - #[tokio::test] async fn l7_rest_middleware_fail_closed_does_not_reach_upstream() { let (config, tunnel_engine, ctx) = @@ -6109,10 +5962,8 @@ network_policies: .unwrap(); } - #[tokio::test] - async fn over_capacity_resolution_honors_on_error() { - use openshell_supervisor_middleware::{ChainEntry, OnError}; - + #[test] + fn over_capacity_http_body_always_fails_closed() { let ctx = L7EvalContext { host: "api.example.test".into(), port: 443, @@ -6124,50 +5975,8 @@ network_policies: secret_resolver: None, ..Default::default() }; - let req = || crate::l7::provider::L7Request { - action: "POST".into(), - target: "/v1".into(), - query_params: std::collections::HashMap::new(), - raw_header: Vec::new(), - body_length: crate::l7::provider::BodyLength::None, - }; - let fail_open = ChainEntry { - name: "m".into(), - implementation: "openshell/regex".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailOpen, - }; - let fail_closed = ChainEntry { - on_error: OnError::FailClosed, - ..fail_open.clone() - }; - - let runner = openshell_supervisor_middleware::ChainRunner::default(); - let open_chain = runner - .describe_chain(std::slice::from_ref(&fail_open)) - .await - .expect("describe fail-open chain"); - let mixed_chain = runner - .describe_chain(&[fail_open.clone(), fail_closed]) - .await - .expect("describe mixed chain"); - - // Recoverable (Content-Length over cap, nothing consumed) + all fail-open - // -> stream through unprocessed. assert!(matches!( - resolve_unbuffered_body(&ctx, req(), &open_chain, true), - MiddlewareApplyResult::Allowed(_) - )); - // Any fail-closed entry -> deny. - assert!(matches!( - resolve_unbuffered_body(&ctx, req(), &mixed_chain, true), - MiddlewareApplyResult::Denied { .. } - )); - // Not recoverable (chunked overflow already consumed bytes) -> deny even - // when every entry is fail-open. - assert!(matches!( - resolve_unbuffered_body(&ctx, req(), &open_chain, false), + resolve_unbuffered_body(&ctx), MiddlewareApplyResult::Denied { .. } )); } @@ -6237,6 +6046,10 @@ network_policies: phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 8192, request_timeout: None, + http_protocol_version: 1, + supported_http_body_modes: vec![ + openshell_core::proto::HttpBodyMode::Buffered as i32, + ], }], expected_audience: String::new(), } @@ -6252,8 +6065,8 @@ network_policies: async fn open_http_request_pre_credentials( &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { Ok(whole_body_request_stream( requests, @@ -6384,6 +6197,10 @@ network_policies: phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 8192, request_timeout: None, + http_protocol_version: 1, + supported_http_body_modes: vec![ + openshell_core::proto::HttpBodyMode::Buffered as i32, + ], }], expected_audience: String::new(), } @@ -6399,8 +6216,8 @@ network_policies: async fn open_http_request_pre_credentials( &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result + requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { Ok(whole_body_request_stream( requests, @@ -6832,20 +6649,13 @@ network_policies: ); } - /// One named middleware with one HTTP/pre-credentials binding. Two - /// instances exercise mixed-limit chain buffering at the relay level. - struct LimitService { - name: &'static str, - max_body_bytes: u64, - replacement: Option<&'static [u8]>, - } - #[derive(Clone, Copy)] enum RequestRelayMode { HeadersOnly, AppendPerUnit, CredentialMarkerPerUnit, WholeBodyAppend, + DelayedWholeBodyStream, } struct RequestRelayService { @@ -6857,10 +6667,11 @@ network_policies: #[tonic::async_trait] impl openshell_core::middleware::InProcessMiddleware for RequestRelayService { async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { - openshell_core::proto::MiddlewareManifest { + use openshell_core::proto::{HttpBodyMode, MiddlewareBinding, MiddlewareManifest}; + MiddlewareManifest { name: "test/request-relay".into(), service_version: "test".into(), - bindings: vec![openshell_core::proto::MiddlewareBinding { + bindings: vec![MiddlewareBinding { operation: openshell_core::proto::SupervisorMiddlewareOperation::HttpRequest as i32, phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, @@ -6868,6 +6679,11 @@ network_policies: (openshell_supervisor_middleware::MAX_HTTP_REQUEST_STREAM_UNIT_BYTES + 1) as u64, request_timeout: None, + http_protocol_version: 1, + supported_http_body_modes: vec![ + HttpBodyMode::Buffered as i32, + HttpBodyMode::Stream as i32, + ], }], expected_audience: String::new(), } @@ -6883,97 +6699,176 @@ network_policies: async fn open_http_request_pre_credentials( &self, - mut requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result + mut requests: tokio::sync::mpsc::Receiver, + ) -> std::result::Result { use openshell_core::proto::{ - ExistingHeaderAction, HeaderMutation, HttpRequestBodyMode, - HttpRequestBodyPassThrough, HttpRequestBodyResult, HttpRequestBodyTransform, - HttpRequestEventResult, HttpRequestPreflightInspect, HttpRequestPreflightResult, - HttpRequestTrailersResult, WriteHeader, header_mutation, http_request_body_result, - http_request_body_transform, http_request_body_unit, http_request_event, - http_request_event_result, http_request_preflight_result, + ExistingHeaderAction, HeaderMutation, HttpBufferedMode, HttpBufferedResult, + HttpFinish, HttpInspect, HttpOutputChunk, HttpOutputStart, HttpPreflightResult, + HttpResult, HttpStreamMode, HttpUnchanged, WriteHeader, header_mutation, + http_buffered_result, http_event, http_inspect, http_preflight_result, http_result, }; - let mode = self.mode; let rewrite_trace_trailer = self.rewrite_trace_trailer; let session_end = self.session_end.clone(); let (sender, receiver) = tokio::sync::mpsc::channel(4); tokio::spawn(async move { + let mut delayed_body = Vec::new(); while let Some(event) = requests.recv().await { let result = match event.event { - Some(http_request_event::Event::Preflight(_)) => HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: match mode { - RequestRelayMode::HeadersOnly => { - HttpRequestBodyMode::HeadersOnly as i32 - } - RequestRelayMode::AppendPerUnit - | RequestRelayMode::CredentialMarkerPerUnit => { - HttpRequestBodyMode::StreamBytes as i32 - } - RequestRelayMode::WholeBodyAppend => { - HttpRequestBodyMode::WholeBodyBytes as i32 - } - }, - header_mutations: Vec::new(), + Some(http_event::Event::Preflight(_)) => HttpResult { + result: Some(http_result::Result::PreflightResult( + HttpPreflightResult { + decision: Some( + if matches!(mode, RequestRelayMode::HeadersOnly) { + http_preflight_result::Decision::ContinueWithoutBody( + openshell_core::proto::HttpContinue::default(), + ) + } else { + http_preflight_result::Decision::Inspect(HttpInspect { + mode: Some( + if matches!( + mode, + RequestRelayMode::WholeBodyAppend + ) { + http_inspect::Mode::Buffered(HttpBufferedMode { + max_body_bytes: + (openshell_supervisor_middleware::MAX_HTTP_REQUEST_STREAM_UNIT_BYTES + 1) as u64, + }) + } else { + http_inspect::Mode::Stream( + HttpStreamMode {}, + ) + }, + ), + }) }, - )), + ), ..Default::default() }, )), }, - Some(http_request_event::Event::Body(body)) => { - let data = match body.payload { - Some(http_request_body_unit::Payload::Data(data)) => data, - None => Vec::new(), + Some(http_event::Event::Begin(_)) => { + if matches!( + mode, + RequestRelayMode::WholeBodyAppend + | RequestRelayMode::DelayedWholeBodyStream + ) { + continue; + } + HttpResult { + result: Some(http_result::Result::OutputStart(HttpOutputStart { + output_body_bytes: None, + ..Default::default() + })), + } + } + Some(http_event::Event::InputChunk(chunk)) => { + let data = match mode { + RequestRelayMode::AppendPerUnit => { + let mut data = chunk.data; + if data.len() + == openshell_supervisor_middleware::MAX_HTTP_REQUEST_STREAM_UNIT_BYTES + { + if sender + .send(Ok(HttpResult { + result: Some( + http_result::Result::OutputChunk( + HttpOutputChunk { data }, + ), + ), + })) + .await + .is_err() + { + break; + } + vec![b'!'] + } else { + data.push(b'!'); + data + } + } + RequestRelayMode::CredentialMarkerPerUnit => { + b"openshell:resolve:env:v1_API_TOKEN".to_vec() + } + RequestRelayMode::DelayedWholeBodyStream => { + delayed_body.extend_from_slice(&chunk.data); + continue; + } + RequestRelayMode::HeadersOnly + | RequestRelayMode::WholeBodyAppend => continue, }; + HttpResult { + result: Some(http_result::Result::OutputChunk(HttpOutputChunk { + data, + })), + } + } + Some(http_event::Event::InputEnd(_)) => { + if matches!(mode, RequestRelayMode::DelayedWholeBodyStream) { + if sender + .send(Ok(HttpResult { + result: Some(http_result::Result::OutputStart( + HttpOutputStart { + output_body_bytes: Some(delayed_body.len() as u64), + ..Default::default() + }, + )), + })) + .await + .is_err() + { + break; + } + if !delayed_body.is_empty() + && sender + .send(Ok(HttpResult { + result: Some(http_result::Result::OutputChunk( + HttpOutputChunk { + data: std::mem::take(&mut delayed_body), + }, + )), + })) + .await + .is_err() + { + break; + } + } + let trailer_mutations = rewrite_trace_trailer + .then(|| HeaderMutation { + operation: Some(header_mutation::Operation::Write( + WriteHeader { + name: "x-trace".into(), + value: "rewritten".into(), + on_existing: ExistingHeaderAction::Overwrite as i32, + }, + )), + }) + .into_iter() + .collect(); + HttpResult { + result: Some(http_result::Result::Finish(HttpFinish { + trailer_mutations, + ..Default::default() + })), + } + } + Some(http_event::Event::BufferedBody(body)) => { let replacement = match mode { RequestRelayMode::AppendPerUnit - | RequestRelayMode::WholeBodyAppend - if !data.is_empty() => - { - let mut replacement = data; + | RequestRelayMode::WholeBodyAppend => { + let mut replacement = body.data; replacement.push(b'!'); Some(replacement) } - RequestRelayMode::CredentialMarkerPerUnit if !data.is_empty() => { + RequestRelayMode::CredentialMarkerPerUnit => { Some(b"openshell:resolve:env:v1_API_TOKEN".to_vec()) } - _ => None, + RequestRelayMode::HeadersOnly + | RequestRelayMode::DelayedWholeBodyStream => None, }; - let action = replacement.map_or_else( - || { - http_request_body_result::Action::PassThrough( - HttpRequestBodyPassThrough {}, - ) - }, - |replacement| { - http_request_body_result::Action::Transform( - HttpRequestBodyTransform { - replacement: Some( - http_request_body_transform::Replacement::Data( - replacement, - ), - ), - }, - ) - }, - ); - HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyResult( - HttpRequestBodyResult { - sequence: body.sequence, - action: Some(action), - ..Default::default() - }, - )), - } - } - Some(http_request_event::Event::Trailers(_)) => { let trailer_mutations = rewrite_trace_trailer .then(|| HeaderMutation { operation: Some(header_mutation::Operation::Write( @@ -6986,16 +6881,24 @@ network_policies: }) .into_iter() .collect(); - HttpRequestEventResult { - result: Some(http_request_event_result::Result::TrailersResult( - HttpRequestTrailersResult { + HttpResult { + result: Some(http_result::Result::BufferedResult( + HttpBufferedResult { + body: Some(replacement.map_or_else( + || { + http_buffered_result::Body::Unchanged( + HttpUnchanged {}, + ) + }, + http_buffered_result::Body::Replacement, + )), trailer_mutations, ..Default::default() }, )), } } - Some(http_request_event::Event::SessionEnd(end)) => { + Some(http_event::Event::SessionEnd(end)) => { if let Some(session_end) = &session_end { let _ = session_end.send(end.reason); } @@ -7014,154 +6917,6 @@ network_policies: } } - #[tonic::async_trait] - impl openshell_core::middleware::InProcessMiddleware for LimitService { - async fn describe(&self) -> openshell_core::proto::MiddlewareManifest { - use openshell_core::proto::{ - MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, - SupervisorMiddlewarePhase, - }; - MiddlewareManifest { - name: self.name.into(), - service_version: "test".into(), - bindings: vec![MiddlewareBinding { - operation: SupervisorMiddlewareOperation::HttpRequest as i32, - phase: SupervisorMiddlewarePhase::PreCredentials as i32, - max_payload_bytes: self.max_body_bytes, - request_timeout: None, - }], - expected_audience: String::new(), - } - } - - async fn validate_config( - &self, - _middleware_name: &str, - _config: &prost_types::Struct, - ) -> Result<()> { - Ok(()) - } - - async fn open_http_request_pre_credentials( - &self, - requests: tokio::sync::mpsc::Receiver, - ) -> std::result::Result - { - Ok(whole_body_request_stream( - requests, - self.replacement.map(<[u8]>::to_vec), - None, - )) - } - } - - #[tokio::test] - async fn body_over_smallest_stage_limit_is_buffered_and_evaluated() { - use openshell_supervisor_middleware::{ChainEntry, ChainRunner, OnError}; - - // A 64-byte body exceeds the 16-byte guard limit but fits the 8 KiB - // redactor. The chain must buffer for its largest stage so the - // redactor runs and replaces the body, while the undersized fail-open - // guard is skipped through its own on_error, instead of the whole - // chain taking the unbuffered over-capacity path. - let (_config, tunnel_engine, ctx) = - middleware_relay_context("openshell/regex", "fail_closed"); - let registry = openshell_supervisor_middleware::MiddlewareRegistry::connect_services( - vec![ - Arc::new(LimitService { - name: "test/redactor", - max_body_bytes: 8192, - replacement: Some(b"[SCRUBBED BY TEST REDACTOR]"), - }), - Arc::new(LimitService { - name: "test/guard", - max_body_bytes: 16, - replacement: None, - }), - ], - Vec::new(), - ) - .await - .expect("connect named middleware services"); - let runner = ChainRunner::from_registry(registry); - let chain = vec![ - ChainEntry { - name: "redact".into(), - implementation: "test/redactor".into(), - order: 0, - config: prost_types::Struct::default(), - on_error: OnError::FailClosed, - }, - ChainEntry { - name: "guard".into(), - implementation: "test/guard".into(), - order: 10, - config: prost_types::Struct::default(), - on_error: OnError::FailOpen, - }, - ]; - let described = runner.describe_chain(&chain).await.expect("describe chain"); - assert_eq!(middleware_chain_body_limit(&described), Some(8192)); - - let body = [b'a'; 64]; - let raw_header = format!( - "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\n\r\n", - body.len() - ); - let req = crate::l7::provider::L7Request { - action: "POST".into(), - target: "/v1/messages".into(), - query_params: std::collections::HashMap::new(), - raw_header: raw_header.into_bytes(), - body_length: crate::l7::provider::BodyLength::ContentLength(body.len() as u64), - }; - let (mut app, mut relay_client) = tokio::io::duplex(8192); - app.write_all(&body).await.unwrap(); - - let result = crate::l7::middleware::apply_middleware_chain_for_scheme_with_request_id( - req, - &mut relay_client, - &ctx, - "https", - chain, - &runner, - tunnel_engine.generation_guard(), - openshell_supervisor_middleware::TransformedBodyPolicy::NotPolicyRelevant, - "test-request-id", - ) - .await - .expect("apply middleware chain"); - - match result { - MiddlewareApplyResult::Allowed(rebuilt) => { - let raw = String::from_utf8(rebuilt.raw_header).expect("utf8 request"); - assert!( - raw.ends_with("[SCRUBBED BY TEST REDACTOR]"), - "redactor must replace the body: {raw}" - ); - } - MiddlewareApplyResult::Streamed { request, mut body } => { - let crate::l7::middleware::MiddlewareRequestBody::Spool(body) = &mut body else { - panic!("whole-body middleware must retain output before forwarding") - }; - let mut rewritten = Vec::new(); - body.file - .read_to_end(&mut rewritten) - .await - .expect("read spooled middleware output"); - assert_eq!(rewritten, b"[SCRUBBED BY TEST REDACTOR]"); - assert_eq!(body.len, rewritten.len() as u64); - assert!(request.raw_header.ends_with(b"\r\n\r\n")); - } - MiddlewareApplyResult::Denied { .. } => { - panic!("body within the largest stage limit must not fail the chain") - } - MiddlewareApplyResult::AdmissionExhausted => { - panic!("test middleware work admission must be available") - } - } - } - #[tokio::test] async fn header_only_request_middleware_forwards_large_body_without_collecting_it() { let runner = @@ -7310,6 +7065,72 @@ network_policies: .unwrap(); } + #[tokio::test] + async fn whole_body_stream_can_delay_output_while_input_is_active() { + let runner = + openshell_supervisor_middleware::ChainRunner::new(Arc::new(RequestRelayService { + mode: RequestRelayMode::DelayedWholeBodyStream, + rewrite_trace_trailer: false, + session_end: None, + })); + let (mut config, tunnel_engine, ctx) = + middleware_relay_context_with_runner("test/request-relay", runner); + config.provider_credentialed = true; + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"POST /v1/delayed HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: 2\r\nConnection: close\r\n\r\na", + ) + .await + .unwrap(); + // The default middleware response timeout is 500ms. Waiting longer + // before completing input proves the output pump does not treat a + // legitimate whole-body STREAM strategy as idle. + tokio::time::sleep(std::time::Duration::from_millis(650)).await; + assert!( + tokio::time::timeout( + std::time::Duration::from_millis(50), + read_http_headers(&mut upstream), + ) + .await + .is_err(), + "STREAM must hold the upstream head until OutputStart" + ); + app.write_all(b"b").await.unwrap(); + + let headers = String::from_utf8(read_http_headers(&mut upstream).await).unwrap(); + assert!(headers.contains("Transfer-Encoding: chunked\r\n")); + assert_eq!( + read_http_chunk(&mut upstream).await.as_deref(), + Some(&b"ab"[..]) + ); + assert!(read_http_chunk(&mut upstream).await.is_none()); + upstream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + assert!( + String::from_utf8_lossy(&read_http_headers(&mut app).await).contains("204 No Content") + ); + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(2), relay) + .await + .expect("delayed whole-body stream relay should finish") + .unwrap() + .unwrap(); + } + #[tokio::test] async fn provider_guard_rejects_a_marker_created_by_streaming_middleware() { let runner = @@ -7412,78 +7233,6 @@ network_policies: .unwrap(); } - #[tokio::test] - async fn streaming_request_stops_on_early_upstream_response_without_replay() { - let (session_end_tx, mut session_end_rx) = tokio::sync::mpsc::unbounded_channel(); - let runner = - openshell_supervisor_middleware::ChainRunner::new(Arc::new(RequestRelayService { - mode: RequestRelayMode::AppendPerUnit, - rewrite_trace_trailer: false, - session_end: Some(session_end_tx), - })); - let (config, tunnel_engine, ctx) = - middleware_relay_context_with_runner("test/request-relay", runner); - let (mut app, mut relay_client) = tokio::io::duplex(128 * 1024); - let (mut relay_upstream, mut upstream) = tokio::io::duplex(128 * 1024); - let relay = tokio::spawn(async move { - relay_with_inspection( - &config, - tunnel_engine, - &mut relay_client, - &mut relay_upstream, - &ctx, - ) - .await - }); - - let unit_len = openshell_supervisor_middleware::MAX_HTTP_REQUEST_STREAM_UNIT_BYTES; - let declared_len = 4 * 1024 * 1024 + 17; - app.write_all( - format!( - "POST /v1/early HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {declared_len}\r\nConnection: close\r\n\r\n" - ) - .as_bytes(), - ) - .await - .unwrap(); - let headers = String::from_utf8(read_http_headers(&mut upstream).await).unwrap(); - assert!(headers.contains("Transfer-Encoding: chunked\r\n")); - app.write_all(&vec![b'a'; unit_len]).await.unwrap(); - assert_eq!( - read_http_chunk(&mut upstream).await.unwrap().len(), - unit_len + 1 - ); - - upstream - .write_all( - b"HTTP/1.1 413 Payload Too Large\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", - ) - .await - .unwrap(); - let response = tokio::time::timeout( - std::time::Duration::from_secs(1), - read_http_headers(&mut app), - ) - .await - .expect("early upstream response should not wait for client EOS"); - assert!(String::from_utf8_lossy(&response).contains("413 Payload Too Large")); - let reason = tokio::time::timeout(std::time::Duration::from_secs(1), session_end_rx.recv()) - .await - .expect("middleware should receive cancellation") - .expect("session-end reason"); - assert_eq!( - reason, - openshell_core::proto::MiddlewareSessionEndReason::Cancellation as i32 - ); - - drop(app); - tokio::time::timeout(std::time::Duration::from_secs(1), relay) - .await - .expect("early-response relay should terminate") - .unwrap() - .unwrap(); - } - #[tokio::test] async fn streaming_request_middleware_preserves_trailers_and_handles_expect_continue() { let runner = @@ -7559,67 +7308,6 @@ network_policies: .unwrap(); } - #[tokio::test] - async fn all_unresolved_fail_open_forwards_body_unbuffered() { - // A chain whose only entry is an unregistered binding has no resolvable - // body limit. Under fail_open the request must pass through with its - // body intact rather than being denied over a phantom zero-byte cap. - let (config, tunnel_engine, ctx) = - middleware_relay_context("third-party/missing", "fail_open"); - let (mut app, mut relay_client) = tokio::io::duplex(8192); - let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); - let relay = tokio::spawn(async move { - relay_with_inspection( - &config, - tunnel_engine, - &mut relay_client, - &mut relay_upstream, - &ctx, - ) - .await - }); - - let body = br#"{"api_key":"sk-1234567890abcdef"}"#; - let request = format!( - "POST /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - std::str::from_utf8(body).unwrap() - ); - app.write_all(request.as_bytes()).await.unwrap(); - - let mut upstream_request = [0u8; 1024]; - let n = tokio::time::timeout( - std::time::Duration::from_secs(1), - upstream.read(&mut upstream_request), - ) - .await - .expect("request should reach upstream") - .unwrap(); - let upstream_request = String::from_utf8_lossy(&upstream_request[..n]); - // No middleware ran, so the body is forwarded verbatim. - assert!(upstream_request.contains(r#""api_key":"sk-1234567890abcdef""#)); - - upstream - .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") - .await - .unwrap(); - let mut client_response = [0u8; 512]; - let n = tokio::time::timeout( - std::time::Duration::from_secs(1), - app.read(&mut client_response), - ) - .await - .expect("response should reach client") - .unwrap(); - assert!(String::from_utf8_lossy(&client_response[..n]).contains("204 No Content")); - drop(app); - tokio::time::timeout(std::time::Duration::from_secs(1), relay) - .await - .expect("relay should finish") - .unwrap() - .unwrap(); - } - #[test] fn middleware_keeps_the_raw_request_query() { let query = raw_query_from_request_headers( diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 16e723a215..d0a3febaee 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -15,14 +15,14 @@ pub(crate) use http_response::{ 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, + http_response_middleware_invocation_events, parse_connection_close, parse_status_code, + response_is_event_stream, strip_response_integrity_headers, }; use crate::l7::EndpointObserver; use crate::l7::provider::{BodyLength, L7Provider, L7Request, RelayOutcome}; use crate::opa::PolicyGenerationGuard; +use aws_sigv4::http_request::SignableBody; use base64::Engine as _; use miette::{IntoDiagnostic, Result, miette}; use openshell_core::endpoint_status::EndpointResult; @@ -38,12 +38,12 @@ use openshell_ocsf::ctx::ctx as ocsf_ctx; use sha1::{Digest, Sha1}; use std::collections::{HashMap, HashSet}; use std::fmt::{self, Write as _}; -use std::io::SeekFrom; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncSeekExt, AsyncWrite, AsyncWriteExt}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; use tracing::debug; const MAX_HEADER_BYTES: usize = 16384; // 16 KiB for HTTP headers const MAX_REWRITE_BODY_BYTES: usize = 256 * 1024; +const MAX_SIGV4_BODY_BYTES: usize = 10 * 1024 * 1024; #[cfg(test)] async fn max_middleware_body_bytes() -> usize { let chain = openshell_supervisor_middleware::ChainRunner::new( @@ -745,7 +745,9 @@ where websocket_extensions: WebSocketExtensionMode::Preserve, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - post_credentials: None, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", host: "", port: 0, }, @@ -769,7 +771,9 @@ pub(crate) struct RelayRequestOptions<'a> { pub(crate) websocket_extensions: WebSocketExtensionMode, pub(crate) request_body_credential_rewrite: bool, pub(crate) deny_uninspected_credentials: bool, - pub(crate) post_credentials: Option>, + pub(crate) credential_signing: crate::l7::CredentialSigning, + pub(crate) signing_service: &'a str, + pub(crate) signing_region: &'a str, pub(crate) host: &'a str, pub(crate) port: u16, } @@ -863,6 +867,11 @@ where let mut observed_upstream = ObservedUpstream { upstream, observer }; let upstream = &mut observed_upstream; ensure_credential_generation_current(options)?; + if options.credential_signing.is_sigv4() && prepared_body.is_some() { + return Err(miette!( + "inline SigV4 signing cannot consume a body produced by request middleware; apply the stacked SigV4 middleware update before combining these features" + )); + } let header_end = req .raw_header .windows(4) @@ -878,12 +887,12 @@ where parse_websocket_upgrade_request(&req.raw_header[..header_end])? }; - // A restricted post-credentials built-in may remove caller authorization - // before placeholder rewriting, then regenerate it from trusted provider - // credentials below. + // When SigV4 signing is configured, strip AWS auth headers before credential + // rewriting so the fail-closed placeholder scan doesn't reject the SigV4 + // Authorization header (which embeds placeholder strings). let raw_for_rewrite; - let header_source = if let Some(middleware) = options.post_credentials { - raw_for_rewrite = middleware.strip_caller_authorization(&req.raw_header[..header_end])?; + let header_source = if options.credential_signing.is_sigv4() { + raw_for_rewrite = crate::sigv4::strip_aws_headers(&req.raw_header[..header_end])?; &raw_for_rewrite[..] } else { &req.raw_header[..header_end] @@ -911,93 +920,239 @@ where guard.ensure_current()?; } - if let Some(middleware) = options.post_credentials { - // Defense-in-depth: post-credential signing and request body credential - // rewriting are mutually exclusive (validated at policy load time). + // Apply SigV4 signing if configured. + if options.credential_signing.is_sigv4() { + // Defense-in-depth: credential_signing and request_body_credential_rewrite + // are mutually exclusive (validated at policy load time). if options.request_body_credential_rewrite { return Err(miette!( "credential_signing and request_body_credential_rewrite are \ mutually exclusive on the same endpoint" )); } - let prepared_spool = prepared_body.as_deref_mut().and_then(|body| match body { - crate::l7::middleware::MiddlewareRequestBody::Spool(spool) => Some(spool), - crate::l7::middleware::MiddlewareRequestBody::Live(_) => None, - }); - let output = middleware - .evaluate( - req, - header_str, - &rewrite_result.rewritten, - client, - prepared_spool, - options.resolver, - options.generation_guard, - ) - .await?; - ensure_credential_generation_current(options)?; - if let Some(guard) = options.generation_guard { - guard.ensure_current()?; - } - let payload_mode = match output { - crate::l7::post_credentials::PostCredentialsOutput::Complete { - request, - payload_mode, - } => { - upstream.write_all(&request).await.into_diagnostic()?; - payload_mode - } - crate::l7::post_credentials::PostCredentialsOutput::Head { - headers, - payload_mode, - } => { - if let Some(crate::l7::middleware::MiddlewareRequestBody::Live(body)) = - prepared_body.as_deref_mut() - { - let outcome = relay_live_request_and_response( - req, - client, - upstream, - &headers, - body, - options, - false, - RelayResponseOptions { - websocket_extensions: options.websocket_extensions, - websocket: websocket_response, - client_requested_upgrade, - observer, - }, - response_middleware.take(), + // SigV4 re-signing needs the body before forwarding. If the client + // sent `Expect: 100-continue`, acknowledge it so the client transmits + // the body. Scoped to SigV4 paths only — non-SigV4 traffic forwards + // the Expect header to upstream for normal handling. + if has_expect_continue(header_str) { + client + .write_all(b"HTTP/1.1 100 Continue\r\n\r\n") + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + } + if let Some(resolver) = options.resolver { + let access_key = resolver + .resolve_current_env_key_checked("AWS_ACCESS_KEY_ID", "sigv4") + .map_err(miette::Report::new)?; + let secret_key = resolver + .resolve_current_env_key_checked("AWS_SECRET_ACCESS_KEY", "sigv4") + .map_err(miette::Report::new)?; + let session_token = resolver + .resolve_current_env_key_checked("AWS_SESSION_TOKEN", "sigv4") + .map_err(miette::Report::new)?; + + match (access_key, secret_key) { + (Some(access_key), Some(secret_key)) => { + // Use explicit signing_region from policy if set, + // otherwise extract from hostname. + let region = if options.signing_region.is_empty() { + match crate::sigv4::extract_aws_region(options.host) { + Some(r) => r, + None => { + return Err(miette!( + "SigV4 signing: cannot extract AWS region from \ + hostname '{host}'; set signing_region in the \ + policy endpoint", + host = options.host, + )); + } + } + } else { + options.signing_region.to_string() + }; + let service = &options.signing_service; + if service.is_empty() { + return Err(miette!( + "SigV4 signing configured but signing_service not set in policy" + )); + } + + let payload_mode = match options.credential_signing { + crate::l7::CredentialSigning::SigV4Body => SigV4PayloadMode::SignBody, + crate::l7::CredentialSigning::SigV4NoBody => { + SigV4PayloadMode::UnsignedPayload + } + crate::l7::CredentialSigning::SigV4 => detect_payload_mode(header_str)?, + crate::l7::CredentialSigning::None => unreachable!(), + }; + + if payload_mode == SigV4PayloadMode::SignBody { + // Buffer body and include its hash in the signature. + // This requires Content-Length — chunked bodies cannot + // be buffered for signing. detect_payload_mode() should + // route chunked requests to the streaming path, but + // guard here as defense-in-depth. + let body_length = parse_body_length(header_str)?; + match body_length { + BodyLength::ContentLength(_) | BodyLength::None => { + // ContentLength: buffer and sign the body. + // None: no body (e.g. GET/HEAD/DELETE) — sign + // with the empty-body hash. boto3 sends + // x-amz-content-sha256 set to the SHA-256 of + // "" on all requests, which routes here via + // detect_payload_mode's catch-all. + } + BodyLength::Chunked => { + return Err(miette!( + "SigV4 body signing requires Content-Length; \ + chunked transfer encoding is not supported in this mode" + )); + } + } + // NOTE(defense-in-depth): Build the full request from + // rewritten headers + body. `rewrite_result.rewritten` + // has already had AWS auth headers stripped by + // `strip_aws_headers`; `apply_sigv4_to_request` strips + // them again internally via `parse_request_parts` — + // the redundancy is intentional. + let overflow = &req.raw_header[header_end..]; + let mut full_request = rewrite_result.rewritten.clone(); + full_request.extend_from_slice(overflow); + if let BodyLength::ContentLength(body_len) = body_length { + if body_len > MAX_SIGV4_BODY_BYTES as u64 { + return Err(miette!( + "SigV4 body signing buffers at most {MAX_SIGV4_BODY_BYTES} bytes" + )); + } + let already_have = overflow.len() as u64; + if body_len > already_have { + let remaining = + usize::try_from(body_len - already_have).unwrap_or(usize::MAX); + let mut body_buf = vec![0u8; remaining]; + client.read_exact(&mut body_buf).await.into_diagnostic()?; + full_request.extend_from_slice(&body_buf); + } + } + + // Re-check policy after body buffering — a slow upload + // may have outlived a policy reload. + if let Some(guard) = options.generation_guard { + guard.ensure_current()?; + } + + let signed = crate::sigv4::apply_sigv4_to_request( + &full_request, + options.host, + ®ion, + service, + access_key, + secret_key, + session_token, + )?; + ensure_credential_generation_current(options)?; + upstream.write_all(&signed).await.into_diagnostic()?; + } else { + // Sign headers only, stream body through. + let signable_body = match payload_mode { + SigV4PayloadMode::StreamingUnsignedTrailer => { + SignableBody::StreamingUnsignedPayloadTrailer + } + _ => SignableBody::UnsignedPayload, + }; + let signed_headers = crate::sigv4::apply_sigv4_headers_only_with_body( + &rewrite_result.rewritten, + options.host, + ®ion, + service, + access_key, + secret_key, + session_token, + signable_body, + )?; + ensure_credential_generation_current(options)?; + upstream + .write_all(&signed_headers) + .await + .into_diagnostic()?; + + let overflow = &req.raw_header[header_end..]; + if !overflow.is_empty() { + if let Some(guard) = options.generation_guard { + guard.ensure_current()?; + } + upstream.write_all(overflow).await.into_diagnostic()?; + } + let overflow_len = overflow.len() as u64; + + match req.body_length { + BodyLength::ContentLength(len) => { + let remaining = len.saturating_sub(overflow_len); + if remaining > 0 { + relay_fixed( + client, + upstream, + remaining, + options.generation_guard, + ) + .await?; + } + } + BodyLength::Chunked => { + relay_chunked( + client, + upstream, + &req.raw_header[header_end..], + options.generation_guard, + ) + .await?; + } + BodyLength::None => {} + } + } + + // OCSF event after successful signing and upstream write. + let event = openshell_ocsf::NetworkActivityBuilder::new( + ocsf_ctx(), ) - .await?; - middleware.emit_success(payload_mode); - return Ok(outcome); + .activity(openshell_ocsf::ActivityId::Traffic) + .action(openshell_ocsf::ActionId::Allowed) + .disposition(openshell_ocsf::DispositionId::Allowed) + .severity(openshell_ocsf::SeverityId::Informational) + .status(openshell_ocsf::StatusId::Success) + .dst_endpoint(openshell_ocsf::Endpoint::from_domain( + options.host, + options.port, + )) + .message(format!( + "SigV4 re-signed {host}:{port} service={service} region={region} mode={payload_mode}", + host = options.host, + port = options.port, + )) + .build(); + openshell_ocsf::ocsf_emit!(event); + } + _ => { + return Err(miette::Report::new(CredentialUnavailableError::new( + "SigV4 signing configured but AWS credentials not found in provider", + ))); } - upstream.write_all(&headers).await.into_diagnostic()?; - relay_request_body( - req, - client, - upstream, - prepared_body.as_deref_mut(), - options.generation_guard, - ) - .await?; - payload_mode } - }; - middleware.emit_success(payload_mode); + } else { + return Err(miette::Report::new(CredentialUnavailableError::new( + "SigV4 signing configured but no secret resolver available", + ))); + } } else if options.request_body_credential_rewrite { let body = match prepared_body.as_deref_mut() { - Some(crate::l7::middleware::MiddlewareRequestBody::Spool(body)) => { - collect_and_rewrite_spooled_request_body( + Some(crate::l7::middleware::MiddlewareRequestBody::Buffered(body)) => { + collect_and_rewrite_buffered_request_body( body, &rewrite_result.rewritten, header_str, options.resolver, options.generation_guard, - ) - .await? + )? } Some(crate::l7::middleware::MiddlewareRequestBody::Live(_)) => { return Err(miette!( @@ -1142,16 +1297,13 @@ where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, { - ensure_body_generation_current(options)?; - upstream.write_all(headers).await.into_diagnostic()?; - upstream.flush().await.into_diagnostic()?; - let (mut client_reader, mut client_writer) = tokio::io::split(&mut *client); let (mut upstream_reader, mut upstream_writer) = tokio::io::split(&mut *upstream); let mut upload = Box::pin(relay_live_middleware_request_body( body, &mut client_reader, &mut upstream_writer, + Some(headers), options, inspect_credential_markers, )); @@ -1200,14 +1352,15 @@ where { if let Some(body) = prepared_body { return match body { - crate::l7::middleware::MiddlewareRequestBody::Spool(body) => { - relay_spooled_request_body(req, upstream, body, generation_guard).await + crate::l7::middleware::MiddlewareRequestBody::Buffered(body) => { + relay_buffered_request_body(req, upstream, body, generation_guard).await } crate::l7::middleware::MiddlewareRequestBody::Live(body) => { relay_live_middleware_request_body( body, client, upstream, + None, RelayRequestOptions { generation_guard, ..Default::default() @@ -1256,71 +1409,39 @@ where Ok(()) } -async fn relay_spooled_request_body( +async fn relay_buffered_request_body( req: &L7Request, upstream: &mut U, - body: &mut crate::l7::middleware::RequestBodySpool, + body: &crate::l7::middleware::BufferedRequestBody, generation_guard: Option<&PolicyGenerationGuard>, ) -> Result<()> { - body.file.seek(SeekFrom::Start(0)).await.into_diagnostic()?; - let mut remaining = body.len; - let mut buffer = [0u8; RELAY_BUF_SIZE]; + let bytes = &body.bytes; match req.body_length { BodyLength::None => { - if remaining != 0 || !body.trailers.is_empty() { - return Err(miette!("bodyless middleware request has spooled payload")); + if !bytes.is_empty() || !body.trailers.is_empty() { + return Err(miette!("bodyless middleware request has a payload")); } } BodyLength::ContentLength(expected) => { - if expected != remaining || !body.trailers.is_empty() { - return Err(miette!("middleware request spool framing mismatch")); + if expected != bytes.len() as u64 || !body.trailers.is_empty() { + return Err(miette!("middleware request framing mismatch")); } - while remaining > 0 { - let limit = usize::try_from(remaining.min(buffer.len() as u64)) - .expect("relay buffer length fits usize"); - let read = body - .file - .read(&mut buffer[..limit]) - .await - .into_diagnostic()?; - if read == 0 { - return Err(miette!("middleware request spool ended early")); - } - if let Some(guard) = generation_guard { - guard.ensure_current()?; - } - upstream - .write_all(&buffer[..read]) - .await - .into_diagnostic()?; - remaining -= read as u64; + if let Some(guard) = generation_guard { + guard.ensure_current()?; } + upstream.write_all(bytes).await.into_diagnostic()?; } BodyLength::Chunked => { - while remaining > 0 { - let limit = usize::try_from(remaining.min(buffer.len() as u64)) - .expect("relay buffer length fits usize"); - let read = body - .file - .read(&mut buffer[..limit]) - .await - .into_diagnostic()?; - if read == 0 { - return Err(miette!("middleware request spool ended early")); - } + for chunk in bytes.chunks(RELAY_BUF_SIZE) { if let Some(guard) = generation_guard { guard.ensure_current()?; } upstream - .write_all(format!("{read:X}\r\n").as_bytes()) - .await - .into_diagnostic()?; - upstream - .write_all(&buffer[..read]) + .write_all(format!("{:X}\r\n", chunk.len()).as_bytes()) .await .into_diagnostic()?; + upstream.write_all(chunk).await.into_diagnostic()?; upstream.write_all(b"\r\n").await.into_diagnostic()?; - remaining -= read as u64; } upstream.write_all(b"0\r\n").await.into_diagnostic()?; for trailer in &body.trailers { @@ -1339,6 +1460,7 @@ async fn relay_live_middleware_request_body( body: &mut crate::l7::middleware::RequestBodyStream, client: &mut C, upstream: &mut U, + headers: Option<&[u8]>, options: RelayRequestOptions<'_>, inspect_credential_markers: bool, ) -> Result<()> @@ -1351,12 +1473,34 @@ where let write = async { let mut scanner = inspect_credential_markers .then(|| ReservedMarkerStreamGuard::new(options.body_classifier)); - while let Some(unit) = receiver.recv().await { - let output = match scanner.as_mut() { - Some(scanner) => scanner.push(&unit)?, - None => unit, - }; - write_guarded_chunk(upstream, &output, options).await?; + let mut started = false; + while let Some(event) = receiver.recv().await { + match event { + openshell_supervisor_middleware::HttpRequestBodyOutput::Start { .. } + if !started => + { + ensure_body_generation_current(options)?; + if let Some(headers) = headers { + upstream.write_all(headers).await.into_diagnostic()?; + upstream.flush().await.into_diagnostic()?; + } + started = true; + } + openshell_supervisor_middleware::HttpRequestBodyOutput::Chunk(unit) if started => { + let output = match scanner.as_mut() { + Some(scanner) => scanner.push(&unit)?, + None => unit, + }; + write_guarded_chunk(upstream, &output, options).await?; + } + openshell_supervisor_middleware::HttpRequestBodyOutput::End { .. } if started => { + break; + } + _ => return Err(miette!("invalid live request middleware output order")), + } + } + if !started { + return Err(miette!("request middleware output did not start")); } Ok::<_, miette::Report>(scanner) }; @@ -1550,66 +1694,45 @@ where C: AsyncRead + Unpin, U: AsyncWrite + Unpin, { - ensure_body_generation_current(options)?; - upstream.write_all(headers).await.into_diagnostic()?; match body { crate::l7::middleware::MiddlewareRequestBody::Live(body) => { - relay_live_middleware_request_body(body, client, upstream, options, true).await + relay_live_middleware_request_body(body, client, upstream, Some(headers), options, true) + .await } - crate::l7::middleware::MiddlewareRequestBody::Spool(body) => { - relay_spooled_request_body_with_marker_guard(req, upstream, body, options).await + crate::l7::middleware::MiddlewareRequestBody::Buffered(body) => { + ensure_body_generation_current(options)?; + upstream.write_all(headers).await.into_diagnostic()?; + relay_buffered_request_body_with_marker_guard(req, upstream, body, options).await } } } -async fn relay_spooled_request_body_with_marker_guard( +async fn relay_buffered_request_body_with_marker_guard( req: &L7Request, upstream: &mut U, - body: &mut crate::l7::middleware::RequestBodySpool, + body: &crate::l7::middleware::BufferedRequestBody, options: RelayRequestOptions<'_>, ) -> Result<()> { - body.file.seek(SeekFrom::Start(0)).await.into_diagnostic()?; - let mut remaining = body.len; - let mut buffer = [0u8; RELAY_BUF_SIZE]; let mut scanner = ReservedMarkerStreamGuard::new(options.body_classifier); - - while remaining > 0 { - let limit = usize::try_from(remaining.min(buffer.len() as u64)) - .expect("relay buffer length fits usize"); - let read = body - .file - .read(&mut buffer[..limit]) - .await - .into_diagnostic()?; - if read == 0 { - return Err(miette!("middleware request spool ended early")); - } - let safe = scanner.push(&buffer[..read])?; - match req.body_length { - BodyLength::None => { - return Err(miette!("bodyless middleware request has spooled payload")); - } - BodyLength::ContentLength(_) => write_body_bytes(upstream, &safe, options).await?, - BodyLength::Chunked => write_guarded_chunk(upstream, &safe, options).await?, - } - remaining -= read as u64; + let mut encoded = Vec::new(); + for chunk in body.bytes.chunks(RELAY_BUF_SIZE) { + encoded.extend_from_slice(&scanner.push(chunk)?); } - - let tail = scanner.finish()?; + encoded.extend_from_slice(&scanner.finish()?); match req.body_length { BodyLength::None => { - if body.len != 0 || !body.trailers.is_empty() { - return Err(miette!("bodyless middleware request has spooled payload")); + if !body.bytes.is_empty() || !body.trailers.is_empty() { + return Err(miette!("bodyless middleware request has a payload")); } } BodyLength::ContentLength(expected) => { - if expected != body.len || !body.trailers.is_empty() { - return Err(miette!("middleware request spool framing mismatch")); + if expected != body.bytes.len() as u64 || !body.trailers.is_empty() { + return Err(miette!("middleware request framing mismatch")); } - write_body_bytes(upstream, &tail, options).await?; + write_body_bytes(upstream, &encoded, options).await?; } BodyLength::Chunked => { - write_guarded_chunk(upstream, &tail, options).await?; + write_guarded_chunk(upstream, &encoded, options).await?; write_body_bytes(upstream, b"0\r\n", options).await?; for trailer in &body.trailers { let encoded = format!("{}: {}", trailer.name, trailer.value); @@ -1764,12 +1887,8 @@ pub(crate) struct BufferedRequestBody { pub(crate) enum BufferResult { /// The full body was buffered within the size cap. Buffered(BufferedRequestBody), - /// The body exceeded the inspection cap. `recoverable` is true when no body - /// bytes were consumed yet (a declared `Content-Length` over the cap), so the - /// request can still be streamed through unprocessed under fail-open. It is - /// false once bytes have been consumed (chunked overflow), where denying is - /// the only safe outcome. - OverCapacity { recoverable: bool }, + /// The body exceeded the inspection cap. + OverCapacity, } /// Incremental decoder for one normalized HTTP/1 request body. @@ -2077,13 +2196,13 @@ pub(crate) async fn buffer_request_body_for_middleware { // The declared length is known before any further reads, so an - // over-cap body here has not consumed the stream and can be passed - // through unprocessed if every middleware is fail-open. + // over-cap body here has not consumed the stream and the caller may + // still select another body-delivery path. let Ok(len) = usize::try_from(len) else { - return Ok(BufferResult::OverCapacity { recoverable: true }); + return Ok(BufferResult::OverCapacity); }; if len > max_body_bytes { - return Ok(BufferResult::OverCapacity { recoverable: true }); + return Ok(BufferResult::OverCapacity); } let initial_len = already_read.len().min(len); let mut body = Vec::new(); @@ -2129,9 +2248,7 @@ pub(crate) async fn buffer_request_body_for_middleware { - Ok(BufferResult::OverCapacity { recoverable: false }) - } + Err(CollectChunkedError::OverCapacity) => Ok(BufferResult::OverCapacity), Err(CollectChunkedError::Failed(error)) => Err(error), } } @@ -2285,7 +2402,8 @@ pub(crate) fn rebuild_request_for_incremental_stream( } /// Rebuild a request whose normalized middleware output lives in a separate -/// spool. The raw request contains only the new head; relay writes the spool. +/// buffered body. The raw request contains only the new head; relay writes the +/// bounded in-memory representation separately. pub(crate) fn rebuild_request_with_streamed_body( req: &L7Request, headers: &[u8], @@ -2400,19 +2518,19 @@ async fn collect_and_rewrite_request_body( } } -async fn collect_and_rewrite_spooled_request_body( - spool: &mut crate::l7::middleware::RequestBodySpool, +fn collect_and_rewrite_buffered_request_body( + buffered: &crate::l7::middleware::BufferedRequestBody, rewritten_headers: &[u8], original_header_str: &str, resolver: Option<&SecretResolver>, generation_guard: Option<&PolicyGenerationGuard>, ) -> Result { - if spool.len > MAX_REWRITE_BODY_BYTES as u64 { + if buffered.bytes.len() > MAX_REWRITE_BODY_BYTES { return Err(miette!( "request body credential rewrite buffers at most {MAX_REWRITE_BODY_BYTES} bytes" )); } - if !spool.trailers.is_empty() { + if !buffered.trailers.is_empty() { return Err(miette!( "request body credential rewrite does not support transformed request trailers" )); @@ -2420,20 +2538,12 @@ async fn collect_and_rewrite_spooled_request_body( if let Some(guard) = generation_guard { guard.ensure_current()?; } - spool - .file - .seek(SeekFrom::Start(0)) - .await - .into_diagnostic()?; - let capacity = usize::try_from(spool.len) - .map_err(|_| miette!("middleware request body is too large for credential rewrite"))?; - let mut bytes = Vec::with_capacity(capacity); - spool.file.read_to_end(&mut bytes).await.into_diagnostic()?; - if bytes.len() as u64 != spool.len { - return Err(miette!("middleware request spool ended early")); - } - let (mut headers, body) = - rewrite_buffered_body(rewritten_headers, original_header_str, bytes, resolver)?; + let (mut headers, body) = rewrite_buffered_body( + rewritten_headers, + original_header_str, + buffered.bytes.clone(), + resolver, + )?; headers = set_content_length(&headers, body.len())?; headers = strip_header(&headers, "transfer-encoding")?; headers = strip_header(&headers, "trailer")?; @@ -2602,7 +2712,7 @@ fn hex_value(byte: u8) -> Option { #[derive(Debug)] enum CollectChunkedError { /// The caller-supplied wire/decoded cap was exceeded. Bytes may already - /// have been consumed from the client stream, so fail-open streaming is + /// have been consumed from the client stream, so changing delivery paths is /// unsafe. OverCapacity, /// Protocol, I/O, or policy-generation failure. Not an over-capacity event. @@ -3606,6 +3716,50 @@ fn has_expect_continue(headers: &str) -> bool { }) } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SigV4PayloadMode { + SignBody, + UnsignedPayload, + StreamingUnsignedTrailer, +} + +impl fmt::Display for SigV4PayloadMode { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::SignBody => formatter.write_str("sign_body"), + Self::UnsignedPayload => formatter.write_str("unsigned_payload"), + Self::StreamingUnsignedTrailer => formatter.write_str("streaming_unsigned_trailer"), + } + } +} + +fn detect_payload_mode(headers: &str) -> Result { + for line in headers.lines().skip(1) { + let lower = line.to_ascii_lowercase(); + if lower.starts_with("x-amz-content-sha256:") { + let value = lower.split_once(':').map_or("", |(_, value)| value.trim()); + return match value { + "streaming-unsigned-payload-trailer" => { + Ok(SigV4PayloadMode::StreamingUnsignedTrailer) + } + "unsigned-payload" => Ok(SigV4PayloadMode::UnsignedPayload), + value if value.starts_with("streaming-") => Err(miette!( + "SigV4 auto-detect does not support chunk-signed streaming mode '{value}'; \ + use credential_signing: sigv4:no_body to stream with UNSIGNED-PAYLOAD instead" + )), + _ => Ok(SigV4PayloadMode::SignBody), + }; + } + } + Ok( + if matches!(parse_body_length(headers)?, BodyLength::ContentLength(_)) { + SigV4PayloadMode::SignBody + } else { + SigV4PayloadMode::UnsignedPayload + }, + ) +} + /// Parse Content-Length or Transfer-Encoding from HTTP headers. /// /// Per RFC 7230 Section 3.3.3, rejects requests containing both @@ -4129,13 +4283,11 @@ mod tests { use openshell_core::endpoint_status::{EndpointStatusCommand, EndpointStatusReceiver}; use openshell_core::proposals::AgentProposals; use openshell_core::proto::{ - 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, + HttpBodyMode, HttpBufferedMode, HttpBufferedResult, HttpEvent, HttpInspect, + HttpOutputChunk, HttpPreflightResult, HttpReject, HttpResult, HttpUnchanged, + MiddlewareBinding, MiddlewareDiagnostics, MiddlewareManifest, + SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, http_buffered_result, http_event, + http_inspect, http_preflight_result, http_result, }; use openshell_core::secrets::SecretResolver; use std::pin::Pin; @@ -4246,15 +4398,12 @@ mod tests { .contains("content-length:") ); - let mut file = tokio::fs::File::from_std(tempfile::tempfile().unwrap()); - file.write_all(&normalized).await.unwrap(); - let mut spool = crate::l7::middleware::RequestBodySpool { - file, - len: normalized.len() as u64, + let body = crate::l7::middleware::BufferedRequestBody { + bytes: normalized, trailers: trailers.clone(), }; let mut upstream = Vec::new(); - relay_spooled_request_body(&rebuilt, &mut upstream, &mut spool, None) + relay_buffered_request_body(&rebuilt, &mut upstream, &body, None) .await .unwrap(); assert_eq!( @@ -4268,13 +4417,10 @@ mod tests { HeadersOnly, WholeBody, WholeBodyWithTrailer, - Stream, + UppercaseBody, BlockPreflight, BlockWholeBody, - BlockStream, SlowWholeBody, - SlowStream, - InvalidBodySequence, InvalidWholeBodySequence, } @@ -4310,6 +4456,8 @@ mod tests { } as i32, max_payload_bytes: 4096, request_timeout: None, + http_protocol_version: 1, + supported_http_body_modes: vec![HttpBodyMode::Buffered as i32], }], expected_audience: String::new(), } @@ -4325,47 +4473,32 @@ mod tests { async fn open_http_request_pre_credentials( &self, - mut requests: mpsc::Receiver, - ) -> std::result::Result< - openshell_supervisor_middleware::HttpRequestResultStream, - tonic::Status, - > { - assert!( - self.request_only, - "response-only service received a request" - ); + mut requests: mpsc::Receiver, + ) -> std::result::Result + { + assert!(self.request_only); let (sender, receiver) = mpsc::channel(2); tokio::spawn(async move { - use openshell_core::proto::{ - HttpRequestBodyMode, HttpRequestEventResult, HttpRequestPreflightInspect, - HttpRequestPreflightResult, http_request_event, http_request_event_result, - http_request_preflight_result, - }; while let Some(event) = requests.recv().await { match event.event { - Some(http_request_event::Event::Preflight(_)) => { - let result = HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some( - http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::HeadersOnly - as i32, - header_mutations: Vec::new(), - }, + Some(http_event::Event::Preflight(_)) => { + let _ = sender + .send(Ok(HttpResult { + result: Some(http_result::Result::PreflightResult( + HttpPreflightResult { + decision: Some( + http_preflight_result::Decision::ContinueWithoutBody( + openshell_core::proto::HttpContinue::default(), + ), ), - ), - ..Default::default() - }, - )), - }; - if sender.send(Ok(result)).await.is_err() { - break; - } + ..Default::default() + }, + )), + })) + .await; } - Some(http_request_event::Event::SessionEnd(_)) | None => break, - Some(_) => panic!("headers-only request received an unexpected event"), + Some(http_event::Event::SessionEnd(_)) | None => break, + Some(_) => {} } } }); @@ -4374,183 +4507,132 @@ mod tests { async fn open_http_response_pre_return( &self, - mut requests: mpsc::Receiver, - ) -> std::result::Result< - openshell_supervisor_middleware::HttpResponseResultStream, - tonic::Status, - > { - assert!( - !self.request_only, - "request-only service received a response" - ); - let mut script = self.script; + mut requests: mpsc::Receiver, + ) -> std::result::Result + { + assert!(!self.request_only); + let script = self.script; let body_gate = self.body_gate.clone(); - let captured_preflight_headers = self.captured_preflight_headers.clone(); + let captured = self.captured_preflight_headers.clone(); 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(preflight) => { - if let Some(captured) = &captured_preflight_headers { - *captured.lock().expect("preflight capture lock") = - preflight.headers.clone(); - } - if preflight - .config - .as_ref() - .is_some_and(|config| config.fields.contains_key("whole_body")) + let result = match event.event { + Some(http_event::Event::Preflight(preflight)) => { + let body_permitted = !preflight.permitted_body_modes.is_empty(); + if let Some(captured) = &captured + && let Some(openshell_core::proto::http_preflight::Head::Response( + head, + )) = preflight.head { - script = ResponseRelayScript::WholeBody; + *captured.lock().unwrap() = head.headers; } 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() - }, - ), - ), + HttpResult { + result: Some(http_result::Result::Reject(HttpReject { + diagnostics: Some(MiddlewareDiagnostics { + reason_code: "content_match".into(), + ..Default::default() + }), + })), + } + } else if matches!(script, ResponseRelayScript::HeadersOnly) + || !body_permitted + { + HttpResult { + result: Some(http_result::Result::PreflightResult( + HttpPreflightResult { + decision: Some(http_preflight_result::Decision::ContinueWithoutBody(openshell_core::proto::HttpContinue::default())), + header_mutations: matches!(script, ResponseRelayScript::HeadersOnly) + .then(|| write_header( + "cache-control", + "private", + ExistingHeaderAction::Overwrite, + )) + .into_iter() + .collect(), + ..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::SlowStream - | 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, - }, - ), + HttpResult { + result: Some(http_result::Result::PreflightResult( + HttpPreflightResult { + decision: Some( + http_preflight_result::Decision::Inspect( + HttpInspect { + mode: Some(http_inspect::Mode::Buffered( + HttpBufferedMode { + max_body_bytes: 4096, + }, + )), + }, ), - ..Default::default() - }, - ), - ), + ), + ..Default::default() + }, + )), } } } - http_response_event::Event::Body(body) => { + Some(http_event::Event::BufferedBody(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; - }; - let replacement = match script { - ResponseRelayScript::WholeBody - | ResponseRelayScript::BlockWholeBody - | ResponseRelayScript::SlowWholeBody - | ResponseRelayScript::WholeBodyWithTrailer - | ResponseRelayScript::InvalidWholeBodySequence => { - [b"whole:".as_slice(), &data].concat() + if matches!(script, ResponseRelayScript::InvalidWholeBodySequence) { + HttpResult { + result: Some(http_result::Result::OutputChunk( + HttpOutputChunk { data: body.data }, + )), } - ResponseRelayScript::Stream - | ResponseRelayScript::SlowStream - | ResponseRelayScript::BlockStream - | ResponseRelayScript::InvalidBodySequence => { - data.to_ascii_uppercase() + } else if matches!(script, ResponseRelayScript::BlockWholeBody) { + HttpResult { + result: Some(http_result::Result::Reject(HttpReject { + diagnostics: Some(MiddlewareDiagnostics { + reason_code: "blocked".into(), + ..Default::default() + }), + })), } - ResponseRelayScript::HeadersOnly - | ResponseRelayScript::BlockPreflight => break, - }; - if matches!( - script, - ResponseRelayScript::SlowWholeBody - | ResponseRelayScript::SlowStream - ) { - 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, - ), - ), + } else { + if matches!(script, ResponseRelayScript::SlowWholeBody) { + tokio::time::sleep(std::time::Duration::from_millis(75)).await; + } + let replacement = match script { + ResponseRelayScript::WholeBody + | ResponseRelayScript::SlowWholeBody + | ResponseRelayScript::InvalidWholeBodySequence + | ResponseRelayScript::WholeBodyWithTrailer => { + let mut value = b"whole:".to_vec(); + value.extend_from_slice(&body.data); + Some(value) + } + ResponseRelayScript::UppercaseBody => { + Some(body.data.iter().map(u8::to_ascii_uppercase).collect()) + } + _ => None, + }; + HttpResult { + result: Some(http_result::Result::BufferedResult( + HttpBufferedResult { + body: Some(replacement.map_or_else( + || { + http_buffered_result::Body::Unchanged( + HttpUnchanged {}, + ) }, - ) - }, - ), - reason_code: if matches!( - script, - ResponseRelayScript::BlockWholeBody - | ResponseRelayScript::BlockStream - ) { - "content_match".into() - } else { - String::new() + http_buffered_result::Body::Replacement, + )), + ..Default::default() }, - ..Default::default() - }, - )), + )), + } } } - http_response_event::Event::Trailers(_) => HttpResponseEventResult { - result: Some(http_response_event_result::Result::TrailersResult( - HttpResponseTrailersResult::default(), - )), - }, - http_response_event::Event::SessionEnd(_) => break, + Some(http_event::Event::SessionEnd(_)) | None => break, + Some(_) => continue, }; if sender.send(Ok(result)).await.is_err() { break; @@ -5723,7 +5805,7 @@ mod tests { } #[tokio::test] - async fn credential_rewrite_uses_spooled_middleware_output() { + async fn credential_rewrite_uses_buffered_middleware_output() { let (child_env, resolver) = SecretResolver::from_provider_env( [("API_TOKEN".to_string(), "provider-real-token".to_string())] .into_iter() @@ -5735,23 +5817,18 @@ mod tests { "POST /api HTTP/1.1\r\nHost: example.com\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", body.len() ); - let mut file = tokio::fs::File::from_std(tempfile::tempfile().unwrap()); - file.write_all(body.as_bytes()).await.unwrap(); - file.flush().await.unwrap(); - let mut spool = crate::l7::middleware::RequestBodySpool { - file, - len: body.len() as u64, + let buffered = crate::l7::middleware::BufferedRequestBody { + bytes: body.into_bytes(), trailers: Vec::new(), }; - let rewritten = collect_and_rewrite_spooled_request_body( - &mut spool, + let rewritten = collect_and_rewrite_buffered_request_body( + &buffered, headers.as_bytes(), &headers, Some(&resolver), None, ) - .await .expect("rewrite middleware output"); assert_eq!(rewritten.body, br#"{"token":"provider-real-token"}"#); @@ -5807,10 +5884,7 @@ mod tests { .await .expect("oversized body should produce a capacity result"); - assert!(matches!( - result, - BufferResult::OverCapacity { recoverable: true } - )); + assert!(matches!(result, BufferResult::OverCapacity)); } #[tokio::test] @@ -5918,7 +5992,7 @@ mod tests { .expect("over-capacity is a BufferResult, not an Err"); assert!( - matches!(result, BufferResult::OverCapacity { recoverable: false }), + matches!(result, BufferResult::OverCapacity), "expected OverCapacity, got {result:?}" ); } @@ -5981,7 +6055,7 @@ mod tests { ); assert!(!text.contains("GET /other")); } - other @ BufferResult::OverCapacity { .. } => { + other @ BufferResult::OverCapacity => { panic!("expected Buffered, got {other:?}") } } @@ -6910,65 +6984,6 @@ mod tests { assert!(delivered.ends_with("\r\n\r\nhello")); } - #[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, ) -> ( @@ -7228,24 +7243,6 @@ mod tests { assert!(!delivered.contains("HTTP/1.1 200 OK"), "{delivered}"); } - #[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}" - ); - } - async fn run_response_relay_across_policy_reload( script: ResponseRelayScript, ) -> (Result, Vec) { @@ -7294,17 +7291,6 @@ mod tests { (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) = @@ -7318,21 +7304,6 @@ mod tests { #[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"; - 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; - assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); - assert!( - String::from_utf8(delivered) - .unwrap() - .ends_with("\r\n\r\nhello") - ); - let (outcome, delivered) = run_response_middleware_relay_with_timeout( response, "GET", @@ -7353,7 +7324,7 @@ mod tests { 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, + openshell_supervisor_middleware::OnError::FailClosed, ); let (mut upstream_read, mut upstream_write) = tokio::io::duplex(16 * 1024); let (mut client_read, mut client_write) = tokio::io::duplex(16 * 1024); @@ -7382,125 +7353,20 @@ mod tests { let mut delivered = Vec::new(); client_read.read_to_end(&mut delivered).await.unwrap(); - assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); + assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); let delivered = String::from_utf8(delivered).unwrap(); - 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}"); - } + assert!( + delivered.contains("response_delivery_failed"), + "{delivered}" + ); } #[tokio::test] - async fn response_middleware_streams_normalized_chunks_and_preserves_trailers() { + async fn response_middleware_buffers_normalized_body_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, + ResponseRelayScript::UppercaseBody, ) .await; assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); @@ -7560,7 +7426,7 @@ mod tests { async fn response_middleware_never_uses_chunked_framing_for_http_10() { for (script, expected_body) in [ (ResponseRelayScript::HeadersOnly, "hello"), - (ResponseRelayScript::Stream, "HELLO"), + (ResponseRelayScript::UppercaseBody, "HELLO"), (ResponseRelayScript::WholeBodyWithTrailer, "whole:hello"), ] { let (outcome, delivered) = run_response_middleware_relay( @@ -7689,41 +7555,27 @@ mod tests { } #[tokio::test] - async fn response_middleware_head_failure_reports_body_length_without_body() { + async fn response_middleware_head_does_not_offer_body_inspection() { 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; - assert!(matches!(outcome.unwrap(), RelayOutcome::Consumed)); + assert!(matches!(outcome.unwrap(), RelayOutcome::Reusable)); 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!(head.starts_with("HTTP/1.1 200 OK\r\n"), "{head}"); + assert!(head.contains("Content-Length: 5\r\n"), "{head}"); assert_eq!(&delivered[split..], b""); } #[tokio::test] - 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; - 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}"); - } - - #[tokio::test] - async fn response_middleware_unrepresentable_input_obeys_failure_policy() { + async fn response_middleware_unrepresentable_input_fails_closed() { 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"); @@ -7733,30 +7585,20 @@ mod tests { 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, + let (outcome, delivered) = run_response_middleware_relay_with_error( + response, + "GET", + ResponseRelayScript::HeadersOnly, 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") - ); - } - } + ) + .await; + 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") + ); } } @@ -7769,40 +7611,13 @@ mod tests { ] { let (outcome, delivered) = run_response_middleware_relay_with_error( response, "GET", ResponseRelayScript::HeadersOnly, - openshell_supervisor_middleware::OnError::FailOpen, + openshell_supervisor_middleware::OnError::FailClosed, ).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 [ - ( - 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}"); - } - } - #[tokio::test] async fn response_middleware_stale_policy_generation_aborts_before_preflight() { let policy_data = "network_policies: {}\n"; @@ -7811,7 +7626,7 @@ mod tests { .generation_guard(engine.current_generation()) .unwrap(); engine.reload(TEST_POLICY, policy_data).unwrap(); - let (runner, chain) = response_middleware_fixture(ResponseRelayScript::Stream); + let (runner, chain) = response_middleware_fixture(ResponseRelayScript::UppercaseBody); let (mut upstream_read, mut upstream_write) = tokio::io::duplex(4096); let (mut client_read, mut client_write) = tokio::io::duplex(4096); upstream_write @@ -7838,7 +7653,7 @@ mod tests { #[tokio::test] async fn response_middleware_client_disconnect_aborts_stream_delivery() { - let (runner, chain) = response_middleware_fixture(ResponseRelayScript::Stream); + let (runner, chain) = response_middleware_fixture(ResponseRelayScript::UppercaseBody); let (mut upstream_read, mut upstream_write) = tokio::io::duplex(4096); let (client_read, mut client_write) = tokio::io::duplex(4096); drop(client_read); @@ -7859,20 +7674,17 @@ mod tests { } #[tokio::test] - async fn response_middleware_streams_close_delimited_body_with_owned_framing() { + async fn response_middleware_buffers_close_delimited_body_with_derived_framing() { let (outcome, delivered) = run_response_middleware_relay( b"HTTP/1.1 200 OK\r\nConnection: close\r\n\r\nhello", "GET", - ResponseRelayScript::Stream, + ResponseRelayScript::UppercaseBody, ) .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}"); + assert!(delivered.contains("Content-Length: 5\r\n"), "{delivered}"); + assert!(delivered.ends_with("\r\n\r\nHELLO"), "{delivered}"); } #[test] @@ -7892,7 +7704,7 @@ mod tests { &[openshell_supervisor_middleware::HttpResponseInvocation { config_name: "scan".into(), implementation: "example/scan".into(), - outcome: openshell_supervisor_middleware::HttpResponseInvocationOutcome::FailOpen, + outcome: openshell_supervisor_middleware::HttpResponseInvocationOutcome::FailClosed, sequence: Some(1), input_size: 19, output_size: None, @@ -7920,62 +7732,6 @@ mod tests { assert!(json.contains("example/scan"), "{json}"); } - #[test] - 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] async fn relay_response_no_framing_with_connection_close_reads_until_eof() { // Response with Connection: close but no Content-Length/TE: body is @@ -10338,14 +10094,9 @@ mod tests { &mut proxy_to_upstream, RelayRequestOptions { resolver: Some(&resolver), - post_credentials: - crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( - crate::l7::CredentialSigning::SigV4Body, - "s3", - "us-east-1", - "s3.us-east-1.amazonaws.com", - 443, - ), + credential_signing: crate::l7::CredentialSigning::SigV4Body, + signing_service: "s3", + signing_region: "us-east-1", host: "s3.us-east-1.amazonaws.com", port: 443, ..Default::default() 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 9a04244282..5493b41314 100644 --- a/crates/openshell-supervisor-network/src/l7/rest/http_response.rs +++ b/crates/openshell-supervisor-network/src/l7/rest/http_response.rs @@ -842,11 +842,6 @@ fn emit_http_response_middleware_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) { @@ -986,51 +981,6 @@ fn http_response_middleware_block_finding_event( ) } -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, @@ -1790,7 +1740,7 @@ async fn process_response_unit( unit: Vec, framing: &mut ResponseOutputState<'_>, ) -> Result<()> { - let output = session.push_body(unit).await; + let output = session.push_body(unit); if let Some(guard) = framing.generation_guard { guard.ensure_current()?; } diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index ac6b65f76c..e737d10f36 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -3569,6 +3569,7 @@ network_policies: seconds: 1, nanos: 0, }), + ..Default::default() }], expected_audience: String::new(), })) diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 810d683dd0..458e236f01 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -19,6 +19,7 @@ pub mod policy_local; pub mod procfs; pub mod proxy; pub mod run; +pub mod sigv4; mod spiffe_endpoint; mod token_grant; pub mod upstream_proxy; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index bbcb9c8c56..a0b2000c2c 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -4714,7 +4714,9 @@ struct ForwardRelayOptions<'a> { secret_resolver: Option<&'a SecretResolver>, request_body_credential_rewrite: bool, deny_uninspected_credentials: bool, - post_credentials: Option>, + credential_signing: crate::l7::CredentialSigning, + signing_service: &'a str, + signing_region: &'a str, host: &'a str, port: u16, response_middleware: Option>, @@ -4772,7 +4774,9 @@ where websocket_extensions: options.websocket_extensions, request_body_credential_rewrite: options.request_body_credential_rewrite, deny_uninspected_credentials: options.deny_uninspected_credentials, - post_credentials: options.post_credentials, + credential_signing: options.credential_signing, + signing_service: options.signing_service, + signing_region: options.signing_region, host: options.host, port: options.port, }, @@ -6103,15 +6107,17 @@ async fn handle_forward_proxy( return Ok(()); } - let post_credentials = forward_upgrade_config.as_ref().and_then(|config| { - crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( - config.credential_signing, - &config.signing_service, - &config.signing_region, - &host_lc, - port, - ) - }); + let credential_signing = forward_upgrade_config + .as_ref() + .map_or(crate::l7::CredentialSigning::None, |config| { + config.credential_signing + }); + let signing_service = forward_upgrade_config + .as_ref() + .map_or("", |config| config.signing_service.as_str()); + let signing_region = forward_upgrade_config + .as_ref() + .map_or("", |config| config.signing_region.as_str()); let outcome_result = relay_rewritten_forward_request( method, &upstream_target, @@ -6126,7 +6132,9 @@ async fn handle_forward_proxy( body_classifier: endpoint_credentials.body_classifier.as_deref(), request_body_credential_rewrite, deny_uninspected_credentials, - post_credentials, + credential_signing, + signing_service, + signing_region, host: &host_lc, port, response_middleware: response_selection.as_ref().map(|exchange| { @@ -6693,6 +6701,7 @@ process: seconds: 1, nanos: 0, }), + ..Default::default() }], expected_audience: String::new(), }, @@ -6773,6 +6782,10 @@ process: phase: openshell_core::proto::SupervisorMiddlewarePhase::PreReturn as i32, max_payload_bytes: 8192, request_timeout: None, + http_protocol_version: 1, + supported_http_body_modes: vec![ + openshell_core::proto::HttpBodyMode::Buffered as i32, + ], }], expected_audience: String::new(), } @@ -6788,8 +6801,8 @@ process: async fn open_http_response_pre_return( &self, - mut requests: mpsc::Receiver, - ) -> std::result::Result + mut requests: mpsc::Receiver, + ) -> std::result::Result { let (sender, receiver) = mpsc::channel(2); let expected_path = self.expected_path.clone(); @@ -6798,21 +6811,36 @@ process: 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"); + Some(openshell_core::proto::http_event::Event::Preflight(preflight)) => { + let Some(openshell_core::proto::http_preflight::Head::Response(head)) = + preflight.head + else { + panic!("response head") + }; + let target = head.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 {}, - ) + let result = if block { + openshell_core::proto::HttpResult { + result: Some( + openshell_core::proto::http_result::Result::Reject( + openshell_core::proto::HttpReject { + diagnostics: Some( + openshell_core::proto::MiddlewareDiagnostics { + reason_code: "query_guard".into(), + ..Default::default() + }, + ), + }, + ), + ), + } } 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 { + openshell_core::proto::HttpResult { + result: Some(openshell_core::proto::http_result::Result::PreflightResult( + openshell_core::proto::HttpPreflightResult { + decision: Some(openshell_core::proto::http_preflight_result::Decision::ContinueWithoutBody(openshell_core::proto::HttpContinue::default())), + header_mutations: vec![openshell_core::proto::HeaderMutation { operation: Some( openshell_core::proto::header_mutation::Operation::Write( openshell_core::proto::WriteHeader { @@ -6823,30 +6851,18 @@ process: ), ), }], - }, - ) - }; - 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(openshell_core::proto::http_event::Event::SessionEnd(_)) | None => { + break; + } Some(_) => panic!("headers-only response received an unexpected event"), } } @@ -6869,6 +6885,10 @@ process: phase: openshell_core::proto::SupervisorMiddlewarePhase::PreCredentials as i32, max_payload_bytes: 8192, request_timeout: None, + http_protocol_version: 1, + supported_http_body_modes: vec![ + openshell_core::proto::HttpBodyMode::Buffered as i32, + ], }], expected_audience: String::new(), } @@ -6884,20 +6904,19 @@ process: async fn open_http_request_pre_credentials( &self, - mut requests: mpsc::Receiver, - ) -> std::result::Result + mut requests: mpsc::Receiver, + ) -> std::result::Result { let entered = Arc::clone(&self.entered); let release = Arc::clone(&self.release); let (sender, receiver) = mpsc::channel(2); tokio::spawn(async move { use openshell_core::proto::{ - HttpRequestBodyMode, HttpRequestEventResult, HttpRequestPreflightInspect, - HttpRequestPreflightResult, http_request_event, http_request_event_result, - http_request_preflight_result, + HttpEvent, HttpPreflightResult, HttpResult, http_event, http_preflight_result, + http_result, }; - let Some(openshell_core::proto::HttpRequestEvent { - event: Some(http_request_event::Event::Preflight(_)), + let Some(HttpEvent { + event: Some(http_event::Event::Preflight(_)), }) = requests.recv().await else { return; @@ -6905,18 +6924,13 @@ process: entered.notify_one(); release.notified().await; let _ = sender - .send(Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::HeadersOnly as i32, - header_mutations: Vec::new(), - }, - )), - ..Default::default() - }, - )), + .send(Ok(HttpResult { + result: Some(http_result::Result::PreflightResult(HttpPreflightResult { + decision: Some(http_preflight_result::Decision::ContinueWithoutBody( + openshell_core::proto::HttpContinue::default(), + )), + ..Default::default() + })), })) .await; while requests.recv().await.is_some() {} @@ -8638,7 +8652,9 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - post_credentials: None, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", host: "api.example.test", port: 80, response_middleware: Some(ForwardResponseMiddleware { @@ -8725,7 +8741,9 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - post_credentials: None, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", host: "api.example.test", port: 80, response_middleware: Some(ForwardResponseMiddleware { @@ -8845,7 +8863,9 @@ network_policies: secret_resolver: resolver, request_body_credential_rewrite, deny_uninspected_credentials: body_classifier.is_some(), - post_credentials: None, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", host: "", port: 0, response_middleware: None, @@ -9110,7 +9130,9 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - post_credentials: None, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", host: "", port: 0, response_middleware: None, @@ -11656,7 +11678,9 @@ network_policies: secret_resolver: Some(&resolver), request_body_credential_rewrite: true, deny_uninspected_credentials: false, - post_credentials: None, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", host: "", port: 0, response_middleware: None, @@ -11738,14 +11762,9 @@ network_policies: secret_resolver: Some(&resolver), request_body_credential_rewrite: false, deny_uninspected_credentials: false, - post_credentials: - crate::l7::post_credentials::PostCredentialsMiddleware::from_endpoint( - crate::l7::CredentialSigning::SigV4NoBody, - "execute-api", - "us-west-2", - "api.example.com", - 80, - ), + credential_signing: crate::l7::CredentialSigning::SigV4NoBody, + signing_service: "execute-api", + signing_region: "us-west-2", host: "api.example.com", port: 80, response_middleware: None, @@ -11834,7 +11853,9 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - post_credentials: None, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", host: "", port: 0, response_middleware: None, @@ -11885,7 +11906,9 @@ network_policies: secret_resolver: None, request_body_credential_rewrite: false, deny_uninspected_credentials: false, - post_credentials: None, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", host: "", port: 0, response_middleware: None, diff --git a/crates/openshell-supervisor-middleware-builtins/src/sigv4.rs b/crates/openshell-supervisor-network/src/sigv4.rs similarity index 74% rename from crates/openshell-supervisor-middleware-builtins/src/sigv4.rs rename to crates/openshell-supervisor-network/src/sigv4.rs index eccf320a04..79e4c32e18 100644 --- a/crates/openshell-supervisor-middleware-builtins/src/sigv4.rs +++ b/crates/openshell-supervisor-network/src/sigv4.rs @@ -8,161 +8,8 @@ use aws_sigv4::http_request::{ use aws_sigv4::sign::v4; use aws_smithy_runtime_api::client::identity::Identity; use miette::{Result, miette}; -use std::fmt; use std::time::SystemTime; -/// Built-in name used for the trusted post-credential signing stage. -pub const NAME: &str = "openshell/sigv4"; - -/// Maximum body retained by the built-in when the AWS signature covers the -/// complete payload. -pub const MAX_BODY_BYTES: usize = 10 * 1024 * 1024; - -/// Endpoint-selected signing behavior. This mirrors the existing policy -/// values without exposing network-policy types to the built-in crate. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RequestedPayloadMode { - /// Preserve the payload mode selected by the AWS client when possible. - Auto, - /// Hash and sign the complete request body. - SignBody, - /// Sign only the request head with `UNSIGNED-PAYLOAD`. - UnsignedPayload, -} - -/// Normalized request framing relevant to payload-mode selection. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BodyFraming { - None, - ContentLength, - Chunked, -} - -/// Payload representation covered by the generated signature. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum PayloadMode { - /// Hash the complete request body. - SignBody, - /// Sign headers with the AWS `UNSIGNED-PAYLOAD` sentinel. - UnsignedPayload, - /// Sign an `aws-chunked` stream that carries an unsigned trailer. - StreamingUnsignedTrailer, -} - -impl fmt::Display for PayloadMode { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::SignBody => formatter.write_str("sign_body"), - Self::UnsignedPayload => formatter.write_str("unsigned_payload"), - Self::StreamingUnsignedTrailer => formatter.write_str("streaming_unsigned_trailer"), - } - } -} - -/// Resolve the concrete AWS payload mode from endpoint policy and the -/// caller's original request head. -pub fn resolve_payload_mode( - requested: RequestedPayloadMode, - original_headers: &str, - framing: BodyFraming, -) -> Result { - match requested { - RequestedPayloadMode::SignBody => return Ok(PayloadMode::SignBody), - RequestedPayloadMode::UnsignedPayload => return Ok(PayloadMode::UnsignedPayload), - RequestedPayloadMode::Auto => {} - } - - for line in original_headers.lines().skip(1) { - let Some((name, value)) = line.split_once(':') else { - continue; - }; - if !name.eq_ignore_ascii_case("x-amz-content-sha256") { - continue; - } - let value = value.trim().to_ascii_lowercase(); - return match value.as_str() { - "streaming-unsigned-payload-trailer" => Ok(PayloadMode::StreamingUnsignedTrailer), - "unsigned-payload" => Ok(PayloadMode::UnsignedPayload), - value if value.starts_with("streaming-") => Err(miette!( - "SigV4 auto-detect does not support chunk-signed streaming mode \ - '{value}'; use credential_signing: sigv4:no_body to stream \ - with UNSIGNED-PAYLOAD instead" - )), - _ => Ok(PayloadMode::SignBody), - }; - } - - Ok(if framing == BodyFraming::ContentLength { - PayloadMode::SignBody - } else { - PayloadMode::UnsignedPayload - }) -} - -/// Credential values kept inside the trusted built-in boundary. -#[derive(Debug, Clone, Copy)] -pub struct SigningCredentials<'a> { - pub access_key: &'a str, - pub secret_key: &'a str, - pub session_token: Option<&'a str>, -} - -/// Non-secret signing target selected from the admitted endpoint policy. -#[derive(Debug, Clone, Copy)] -pub struct SigningTarget<'a> { - pub host: &'a str, - pub region: &'a str, - pub service: &'a str, -} - -/// Restricted in-process signer invoked only after credential resolution. -#[derive(Debug, Default)] -pub struct SigV4Middleware; - -impl SigV4Middleware { - /// Remove caller-provided AWS authorization fields before credential - /// placeholder rewriting and trusted re-signing. - pub fn strip_existing_auth(raw: &[u8]) -> Result> { - strip_aws_headers(raw) - } - - /// Sign a complete request, including its body hash. - pub fn sign_body( - raw: &[u8], - target: SigningTarget<'_>, - credentials: SigningCredentials<'_>, - ) -> Result> { - apply_sigv4_to_request( - raw, - target.host, - target.region, - target.service, - credentials.access_key, - credentials.secret_key, - credentials.session_token, - ) - } - - /// Sign a request head without retaining the request body. - pub fn sign_headers( - raw_headers: &[u8], - target: SigningTarget<'_>, - credentials: SigningCredentials<'_>, - payload_mode: PayloadMode, - ) -> Result> { - apply_sigv4_headers_only_with_body( - raw_headers, - target.host, - target.region, - target.service, - credentials.access_key, - credentials.secret_key, - credentials.session_token, - payload_mode, - ) - } -} - /// AWS regions contain a hyphen followed by a digit (e.g., `us-east-1`). /// Service names like `s3` or `bedrock-runtime` do not. fn looks_like_region(s: &str) -> bool { @@ -479,7 +326,7 @@ pub fn apply_sigv4_headers_only( access_key, secret_key, session_token, - PayloadMode::UnsignedPayload, + SignableBody::UnsignedPayload, ) } @@ -497,7 +344,7 @@ pub fn apply_sigv4_headers_only_with_body( access_key: &str, secret_key: &str, session_token: Option<&str>, - body: PayloadMode, + body: SignableBody<'_>, ) -> Result> { let header_str = std::str::from_utf8(raw_headers) .map_err(|e| miette!("SigV4 signing: request headers are not valid UTF-8: {e}"))?; @@ -506,15 +353,6 @@ pub fn apply_sigv4_headers_only_with_body( let identity = build_identity(access_key, secret_key, session_token); let signing_params = build_signing_params(&identity, region, service)?; - let body = match body { - PayloadMode::SignBody => { - return Err(miette!( - "headers-only SigV4 signing cannot select full body hashing" - )); - } - PayloadMode::UnsignedPayload => SignableBody::UnsignedPayload, - PayloadMode::StreamingUnsignedTrailer => SignableBody::StreamingUnsignedPayloadTrailer, - }; let signable_request = SignableRequest::new( parts.method, &uri, @@ -537,56 +375,6 @@ pub fn apply_sigv4_headers_only_with_body( mod tests { use super::*; - #[test] - fn auto_payload_mode_preserves_unsigned_payload() { - assert_eq!( - resolve_payload_mode( - RequestedPayloadMode::Auto, - "PUT / HTTP/1.1\r\nX-Amz-Content-Sha256: UNSIGNED-PAYLOAD\r\n\r\n", - BodyFraming::ContentLength, - ) - .unwrap(), - PayloadMode::UnsignedPayload - ); - } - - #[test] - fn auto_payload_mode_preserves_streaming_unsigned_trailer() { - assert_eq!( - resolve_payload_mode( - RequestedPayloadMode::Auto, - "PUT / HTTP/1.1\r\nX-Amz-Content-Sha256: STREAMING-UNSIGNED-PAYLOAD-TRAILER\r\n\r\n", - BodyFraming::Chunked, - ) - .unwrap(), - PayloadMode::StreamingUnsignedTrailer - ); - } - - #[test] - fn auto_payload_mode_uses_body_hash_for_content_length() { - assert_eq!( - resolve_payload_mode( - RequestedPayloadMode::Auto, - "POST / HTTP/1.1\r\nContent-Length: 2\r\n\r\n", - BodyFraming::ContentLength, - ) - .unwrap(), - PayloadMode::SignBody - ); - } - - #[test] - fn auto_payload_mode_rejects_chunk_signed_streams() { - let error = resolve_payload_mode( - RequestedPayloadMode::Auto, - "PUT / HTTP/1.1\r\nX-Amz-Content-Sha256: STREAMING-AWS4-HMAC-SHA256-PAYLOAD\r\n\r\n", - BodyFraming::Chunked, - ) - .unwrap_err(); - assert!(error.to_string().contains("chunk-signed streaming mode")); - } - #[test] fn extract_region_from_hostname() { let region = extract_aws_region("bedrock-runtime.us-east-2.amazonaws.com").unwrap(); diff --git a/crates/openshell-supervisor-network/tests/sigv4_localstack.rs b/crates/openshell-supervisor-network/tests/sigv4_localstack.rs index d5d103f6e5..2d76b7e2f7 100644 --- a/crates/openshell-supervisor-network/tests/sigv4_localstack.rs +++ b/crates/openshell-supervisor-network/tests/sigv4_localstack.rs @@ -5,9 +5,7 @@ // Requires LocalStack running on localhost:4566. // Run with: cargo test -p openshell-supervisor-network --test sigv4_localstack -- --ignored --nocapture -use openshell_supervisor_middleware_builtins::sigv4::{ - apply_sigv4_headers_only, apply_sigv4_to_request, -}; +use openshell_supervisor_network::sigv4::{apply_sigv4_headers_only, apply_sigv4_to_request}; use std::sync::atomic::{AtomicU32, Ordering}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::TcpStream; diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index d04044aecd..cd9f088ed4 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -17,9 +17,9 @@ For each inspected HTTP request, the supervisor: 1. Evaluates network and L7 policy. 2. Selects middleware whose host selectors match the admitted destination. -3. Opens one `HttpRequestPreCredentials.Evaluate` bidirectional stream for each matching request binding, in ascending `order`. -4. Sends preflight with the admitted target, safe headers, config, limits, and permitted body modes. The stage skips, blocks, or selects header-only, whole-body, lockstep streaming, or owned streaming inspection. -5. Streams normalized body bytes and trailers through the active chain with bounded units and backpressure. A chain made only of `STREAM_BYTES` stages can forward each approved unit upstream immediately. Whole-body, owned, and other withholding paths spool transformed output outside the RPC envelope first. +3. Opens one `HttpRequestPreCredentials.EvaluateHttp` bidirectional stream for each matching request binding, in ascending `order`. +4. Sends preflight with the admitted target, safe headers, config, limits, and permitted body modes. The stage returns `Continue`, rejects the request, or selects `BUFFERED` or `STREAM`. +5. Pumps normalized body bytes through the active chain with bounded queues and network backpressure. `STREAM` input and output are independent: a service may emit early, change chunk cardinality, or retain its own working state. OpenShell does not create a disk spool or retain a recovery copy. 6. Re-checks body-aware protocol policy (GraphQL, JSON-RPC, MCP) after each stage that replaces the body. These protocols retain a bounded hold barrier so every later stage and the upstream receive a payload admitted by policy. 7. Injects provider credentials and forwards the request. @@ -59,7 +59,7 @@ The request context identifies the originating sandbox to operator-run services. Operator-run services expose bindings for supported operation and phase pairs. A binding is identified by its operation and phase. V1 supports external `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` bindings. Policies attach the complete middleware by its operator-owned gateway registration name. -`openshell/sigv4` is a restricted in-process built-in at `HTTP_REQUEST/POST_CREDENTIALS`. The REST endpoint's `credential_signing` fields configure it; do not attach it through `network_middlewares`. External registrations that advertise `POST_CREDENTIALS` are rejected because that phase can see supervisor-resolved credentials. +The existing endpoint `credential_signing` fields continue to configure proxy-side SigV4 signing. Moving SigV4 into a built-in middleware is separate follow-up work. A request whose body was produced by middleware cannot use the current inline SigV4 path; OpenShell fails that combination closed instead of signing different bytes. ## Register a Middleware Service @@ -82,7 +82,7 @@ timeout = "500ms" | `tls_ca_cert_path` | Optional PEM trust roots for a private HTTPS service. Custom roots replace platform roots and retain hostname verification. | | `audience` | Exact audience expected by the service. Defaults to `urn:openshell:extension:middleware:`. | | `allow_insecure_transport` | Opt this registration out of extension authentication, permitting a plaintext `http://` endpoint with no bearer credential. Defaults to `false`. Development and trusted-network deployments only. | -| `max_payload_bytes` | Shared operator limit applied across every binding, up to the 4 MiB platform maximum. For streamed HTTP request and response modes it caps each unit; for whole-body modes and WebSocket text it caps the complete logical payload. | +| `max_payload_bytes` | Shared operator limit applied across every binding, up to the 4 MiB platform maximum. For `BUFFERED` it caps the complete input and replacement separately; for `STREAM` it caps each unit; for WebSocket it caps the complete text message. | | `timeout` | Optional service-wide RPC timeout using an integer with an `ms` or `s` suffix. Defaults to `500ms`; valid values range from `10ms` through `30s`. | Each binding returned by `Describe` may advertise a shorter `timeout` using the same syntax and bounds. The operator-configured service timeout is a ceiling: OpenShell uses the smaller of the binding and service values. An omitted binding timeout inherits the service setting, and an omitted service setting uses the 500 ms platform default. OpenShell rejects an invalid timeout before accepting the manifest. The operator-configured service timeout applies to `Describe` and `ValidateConfig`. The effective binding timeout applies to HTTP stream open and each request or response exchange, WebSocket preflight, and each WebSocket message. A 30-second chain deadline separately bounds one HTTP body-unit pass, one HTTP finalization pass, or one WebSocket message across all stages; it is not a lifetime limit on an accepted stream. Request body receipt, middleware processing, and output delivery share a 2-minute wall-clock deadline. WebSocket streams have no connection-wide middleware deadline. @@ -93,28 +93,26 @@ Registration is static. Restart the gateway after adding, removing, or changing ### Implement the HTTP request stream -HTTP request middleware implements the `HttpRequestPreCredentials` service. Its `Evaluate` RPC is bidirectional streaming and follows this lifecycle: +HTTP request middleware implements the `HttpRequestPreCredentials` service. Its `EvaluateHttp` RPC is bidirectional streaming and follows this lifecycle: -1. OpenShell sends one `HttpRequestPreflight`. Return `skip`, `block_request`, or `inspect`. An inspecting stage applies any request-header mutations and selects one permitted body mode. -2. For a body mode, OpenShell sends contiguous `HttpRequestBodyUnit` sequences. `WHOLE_BODY_BYTES` receives the complete body in its final unit. Streaming and owned modes receive nonempty data units followed by one empty unit with `end_of_stream`; an empty body also produces that empty final unit. -3. Return one matching `HttpRequestBodyResult` per input unit. `STREAM_BYTES` must account for the current unit without retaining bytes. `OWNED_STREAM_BYTES` acknowledges each unit with `take_ownership`, then returns contiguous `body_output` units and exactly one `body_finalize` after final input. -4. OpenShell sends one trailers event to active body stages, including an empty trailer set. Return ordered mutations only for safe, existing trailer fields. -5. OpenShell sends a best-effort `session_end`, half-closes the request stream, and briefly drains results. +1. OpenShell sends one `HttpPreflight`. Return `Continue`, `Reject`, or `Inspect` with exactly one offered mode. Preflight may include safe header mutations. +2. After `Inspect`, OpenShell sends `HttpBegin`. +3. In `BUFFERED`, OpenShell sends one `HttpBufferedBody`, including an empty body and visible trailers. Return `HttpBufferedResult` with `Unchanged` or a present replacement; present empty bytes delete the body. +4. In `STREAM`, OpenShell independently sends nonempty `HttpInputChunk` values and one `HttpInputEnd`. The service independently returns one `HttpOutputStart`, zero or more nonempty `HttpOutputChunk` values, and one `HttpFinish`. Output may begin before input ends, but `Finish` is valid only after `InputEnd`. +5. OpenShell sends one best-effort `session_end`, half-closes the request stream, and briefly drains results. The body modes are: -| Mode | Contract | Failure recovery | -| --- | --- | --- | -| `HEADERS_ONLY` | Inspect preflight and mutate safe request headers. | Follows `on_error`. | -| `WHOLE_BODY_BYTES` | Receive one final normalized body unit. The complete body and replacement must fit `max_payload_bytes`. | Follows `on_error`; the original remains available. | -| `STREAM_BYTES` | Receive and return bounded units in lockstep. A result cannot retain input across units. | Follows `on_error`; the current unit remains available. | -| `OWNED_STREAM_BYTES` | Durably accept each input unit, then emit a new bounded representation and final accounting. Intended for transformations such as Git pack signing. | Permitted only for `fail_closed`; the original is no longer replayable. | +| Mode | Contract | +| --- | --- | +| `BUFFERED` | One bounded complete body in RAM. The input and replacement each fit the selected positive `max_body_bytes`; `Unchanged` is explicit success, not failure recovery. | +| `STREAM` | Independent input and output pumps with bounded byte/message queues. The middleware owns processing storage and every output byte after selecting this mode. | -OpenShell caps request stream units at 64 KiB even when the binding advertises more. Owned input and output are each capped at 1 GiB and should be spooled rather than retained in memory. The service must respect the `max_deferred_bytes` advertised in preflight. +OpenShell caps request stream units at 64 KiB even when the binding advertises more. Preflight advertises per-message, queue, timeout, and optional total limits. A service that needs whole-body hashing, windowed matching, or disk-backed processing implements that strategy within `STREAM` and owns cleanup on every terminal path. -For a chain made only of `STREAM_BYTES` stages, OpenShell may send each approved unit upstream before receiving the rest of the request. A later block or failure terminates the upstream upload but cannot retract bytes already sent. OpenShell does not retry or replay the request. It also reads the upstream response concurrently; an early response cancels the middleware session, stops upload, and makes the downstream HTTP/1 connection non-reusable. Any whole-body or owned stage forces withholding for the complete chain. Body-aware policy re-evaluation, request-body credential rewriting, and body-dependent request signing also force withholding. +For a chain made only of `STREAM` stages, OpenShell may send output upstream before receiving the rest of the request. A later rejection or failure terminates the upload but cannot retract prior bytes. OpenShell does not retry or replay the request. It reads the upstream response concurrently; an early response cancels the middleware session, stops upload, and makes the downstream HTTP/1 connection non-reusable. A `BUFFERED` stage, body-aware policy re-evaluation, or request-body credential rewriting adds a bounded in-memory hold barrier. -The former unary `SupervisorMiddleware.EvaluateHttpRequest` RPC and its `HttpRequestEvaluation` and `HttpRequestResult` messages have been removed. Services must expose `HttpRequestPreCredentials` alongside `SupervisorMiddleware`; OpenShell does not fall back to the unary API. +The former unary `SupervisorMiddleware.EvaluateHttpRequest` RPC and the earlier multi-mode stream messages have been removed. Services must advertise HTTP protocol version `1`, list their supported `BUFFERED` and/or `STREAM` capability, and expose the phase-specific service beside `SupervisorMiddleware`. OpenShell does not fall back to an older API. Request and response use the same `HttpEvent`/`HttpResult` schema; the initial response rollout offers only `BUFFERED`. ### Authenticate OpenShell Callers @@ -169,16 +167,14 @@ See [Policy Schema](/reference/policy-schema#network-middleware) for the complet ## Configure Failure Behavior -`on_error` controls what happens after an operation binding is selected and middleware is unavailable, rejects its configuration, returns an invalid result, or exceeds the selected binding's payload limit. It does not turn an unadvertised operation or an unsupported WebSocket message class into a middleware failure. +`on_error` controls selected WebSocket-stage failures. HTTP request and response hooks are always fail-closed: policy validation rejects `fail_open` when the selected implementation advertises any HTTP binding. It does not turn an unadvertised operation or an unsupported WebSocket message class into a middleware failure. | Value | Behavior | | --- | --- | -| `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. +| `fail_closed` | Denies the HTTP request/response or closes the WebSocket when the stage fails. This is the default. | +| `fail_open` | Available only to WebSocket-only implementations. A broken stage stream is disabled for the rest of the connection while the remaining chain continues. | -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. +A selected HTTP-stage failure before commitment returns the platform denial response; a failure after partial delivery aborts the connection. OpenShell does not recover the original body, retry, or silently bypass a required stage. WebSocket fail-open bypasses emit a detection finding and a separate state-change finding when a 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. @@ -204,14 +200,13 @@ Every middleware binding declares the largest payload unit or complete buffered - Built-in middleware uses its OpenShell-defined limit. - Each operator-run registration sets one `max_payload_bytes` ceiling no higher than any binding's advertised `max_payload_bytes` capability. -- Whole-body request and response modes apply it to the complete body and replacement. -- Streaming request and response modes apply it to each input or output unit. OpenShell further caps request units at 64 KiB. +- `BUFFERED` applies it to the complete body and replacement separately. +- `STREAM` applies it to each input or output unit. OpenShell further caps request units at 64 KiB. - WebSocket mode applies it to one complete text message or replacement, not the whole session. -- Owned request streams separately advertise a 1 GiB deferred input/output limit and require `fail_closed`. The gateway rejects a registration whose operator limit exceeds the service capability or the 4 MiB platform maximum instead of silently clamping it. OpenShell also bounds the non-payload protobuf components: 64 KiB for service config, 4 KiB for request context, 32 KiB for the target, and 128 request header lines totaling at most 64 KiB encoded. Results allow a 4 KiB discarded free-form reason, a 64-byte validated reason code, 64 header mutations totaling at most 64 KiB encoded, 32 findings of at most 4 KiB encoded each, and 64 metadata entries totaling at most 32 KiB. Middleware gRPC servers should configure messages for their advertised limit plus the bounded envelope. Request streaming needs only the 64 KiB unit plus that envelope even when the complete body is larger than 4 MiB. -At request time, an unavailable selected mode, oversized unit, invalid sequence, or incomplete finalization is a middleware failure. A non-owned stage follows its config's `on_error`; an owned stage always fails closed after ownership begins. Whole-body stages can fail independently when the complete body exceeds their limit, while streaming stages continue with bounded units. +At request time, an unavailable selected mode, oversized unit, invalid lifecycle, or incomplete finalization is a fail-closed middleware failure. `BUFFERED` rejects a complete body or replacement above its selected bound; `STREAM` continues with bounded units and service-owned processing storage. For a WebSocket binding, `max_payload_bytes` covers complete client text messages and replacements. Exceeding a selected stage's effective text-message limit follows that stage's `on_error`. The 4 MiB parsed-text platform cap and other protocol-safety limits are independent of middleware failure policy. Binary messages are not delivered to middleware, so the operator ceiling does not become a binary relay limit; individual raw binary frames retain the 16 MiB relay-safety bound. Oversized parsed text closes the connection with code `1009`; invalid UTF-8 uses `1007`; protocol errors use `1002`; middleware or policy denials use `1008`; and policy reload uses `1012`. @@ -237,7 +232,7 @@ Plan startup and updates around these boundaries: - Keep service endpoints reachable from both the gateway and sandbox supervisors. The supervisors call operator-run services directly on the request path. - Restart the gateway after changing registrations. - Keep required services available before creating or updating policies. The gateway validates implementation-owned config before persisting a policy. -- Treat `fail_open` as an explicit availability-over-enforcement decision. +- Use `fail_open` only for WebSocket-only observational stages whose bypass is acceptable. When the effective sandbox configuration changes, a running supervisor validates the new service registry before installing it. If the reload fails, the supervisor keeps its last-known-good registry and emits a configuration failure event. @@ -247,7 +242,7 @@ Middleware activity is emitted through OpenShell's OCSF logging: - Each invocation records its policy-local config name, attached middleware name, decision, transformation state, and failure state. - A denied invocation records a platform-owned reason derived from the policy-local config name and optional validated reason code. OpenShell does not record service-provided free-form reason text. -- A bypass under `fail_open` emits a detection finding. +- A WebSocket bypass under `fail_open` emits a detection finding. - A required stage that fails closed emits a high-severity detection finding. - A host-matched attachment without a WebSocket binding emits an informational `binding_not_selected` coverage event. - A binary message encountered by an active WebSocket stage emits an informational `unsupported_message_type` coverage event with message type, sequence, and byte count. It is not reported as an invocation or failure. @@ -262,16 +257,14 @@ The [content guard example](https://github.com/NVIDIA/OpenShell/tree/main/exampl The example includes a policy, local fixture, and smoke launcher. -The [Git signing example](https://github.com/NVIDIA/OpenShell/tree/main/examples/supervisor-middleware-git-signing) uses owned request streaming to spool a Git smart-HTTP push, sign rewritten commit objects with a host-held SSH key, and stream the replacement pack back. Its tests exercise a receive-pack body larger than 4 MiB. It is a proof of concept, not a production signing service. - ## 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. +- Middleware applies only through operation bindings advertised by each implementation. Protocols without a supported middleware operation, such as HTTP/2 prior knowledge or non-HTTP TCP, cannot satisfy a required HTTP hook. - 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. -- A fail-closed middleware cannot cover `tls: skip` endpoints because OpenShell cannot inspect that traffic. An all-`fail_open` match may cover the endpoint; OpenShell bypasses the middleware and emits a detection finding. +- HTTP middleware cannot cover `tls: skip` endpoints because OpenShell cannot inspect that traffic. - Operator-run services use TLS `https://` when gateway JWT signing is enabled, unless the registration sets `allow_insecure_transport`. Certificates must chain to the configured custom CA or platform roots, and the endpoint hostname must match. - Extension tokens and sandbox-to-gateway tokens are signed by the same key. They are separated by audience and by `typ`, but the extension credential path cannot yet be rotated or revoked independently of sandbox admission. - OpenShell does not track or revoke `jti`; bearer tokens can be replayed until expiry. Per-request replay resistance requires proof of possession or request binding. diff --git a/docs/providers/aws-sigv4.mdx b/docs/providers/aws-sigv4.mdx index cc5b98b102..76c6c57365 100644 --- a/docs/providers/aws-sigv4.mdx +++ b/docs/providers/aws-sigv4.mdx @@ -7,9 +7,7 @@ description: "Configure proxy-side AWS SigV4 request signing so sandbox agents c keywords: "Generative AI, Cybersecurity, AI Agents, AWS, SigV4, Bedrock, S3, Credential Signing, Sandbox" --- -AWS SigV4 credential signing lets sandbox agents call AWS services (Bedrock, S3, STS, and others) through the proxy's CONNECT tunnel. The restricted in-process `openshell/sigv4` middleware runs at `HTTP_REQUEST/POST_CREDENTIALS`, strips the sandbox client's placeholder `Authorization` header, and re-signs the request with real AWS credentials from the provider. The sandbox and external middleware never see the real credentials. - -Configure this built-in with the endpoint fields below. Do not attach it through `network_middlewares`; OpenShell reserves the credential-visible `POST_CREDENTIALS` phase for trusted in-process implementations. +AWS SigV4 credential signing lets sandbox agents call AWS services (Bedrock, S3, STS, and others) through the proxy's CONNECT tunnel. The proxy strips the sandbox client's placeholder `Authorization` header and re-signs the request with real AWS credentials from the provider. The sandbox and external middleware never see the real credentials. ## Prerequisites diff --git a/docs/reference/gateway-config.mdx b/docs/reference/gateway-config.mdx index 380a545836..eab9c11a68 100644 --- a/docs/reference/gateway-config.mdx +++ b/docs/reference/gateway-config.mdx @@ -356,7 +356,7 @@ 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. Operator-run services may expose `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. `HTTP_REQUEST/POST_CREDENTIALS` is reserved for trusted in-process built-ins such as `openshell/sigv4`; the gateway rejects an external manifest that advertises it. 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 `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/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. HTTP bindings must advertise protocol version `1` and at least one supported `BUFFERED` or `STREAM` mode. 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. diff --git a/examples/supervisor-middleware-content-guard/README.md b/examples/supervisor-middleware-content-guard/README.md index fcdc37df56..6b53f8e076 100644 --- a/examples/supervisor-middleware-content-guard/README.md +++ b/examples/supervisor-middleware-content-guard/README.md @@ -111,12 +111,11 @@ 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`. +Every selected response requires `BUFFERED`. OpenShell does not offer body +inspection for encoded, partial, `no-transform`, or bodyless responses, so the +service continues after header inspection. Known oversized bodies are not +offered; unknown-length bodies can still exceed the runtime limit during +collection. Invalid UTF-8 and protocol failures 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 diff --git a/examples/supervisor-middleware-content-guard/src/main.rs b/examples/supervisor-middleware-content-guard/src/main.rs index 81eedb97bf..ddfc73f02b 100644 --- a/examples/supervisor-middleware-content-guard/src/main.rs +++ b/examples/supervisor-middleware-content-guard/src/main.rs @@ -6,9 +6,7 @@ use std::net::SocketAddr; use std::ops::Range; use clap::Parser; -use openshell_core::middleware::{ - HttpRequestResultStream, HttpResponseResultStream, WebSocketResponseStream, -}; +use openshell_core::middleware::{HttpResultStream, WebSocketResponseStream}; use openshell_core::proto::middleware::v1::http_request_pre_credentials_server::{ HttpRequestPreCredentials, HttpRequestPreCredentialsServer, }; @@ -19,20 +17,13 @@ use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ SupervisorMiddleware, SupervisorMiddlewareServer, }; use openshell_core::proto::{ - Decision, Finding, HttpRequestBlock, HttpRequestBodyMode, HttpRequestBodyPassThrough, - HttpRequestBodyResult, HttpRequestBodyTransform, HttpRequestEvent, HttpRequestEventResult, - HttpRequestPreflightInspect, HttpRequestPreflightResult, HttpRequestTrailersResult, - HttpResponseBlockDelivery, HttpResponseBodyMode, HttpResponseBodyResult, - HttpResponseBodyTransform, HttpResponseEvent, HttpResponseEventResult, - HttpResponsePreflightInspect, HttpResponsePreflightResult, HttpResponseTrailersResult, - MiddlewareBinding, MiddlewareManifest, SupervisorMiddlewareOperation, + Decision, Finding, HttpBodyMode, HttpBufferedMode, HttpBufferedResult, HttpEvent, HttpInspect, + HttpPreflightResult, HttpReject, HttpResult, HttpUnchanged, MiddlewareBinding, + MiddlewareDiagnostics, MiddlewareManifest, SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, ValidateConfigResponse, WebSocketMessage, WebSocketMessageResult, WebSocketPreflightAction, WebSocketPreflightDecision, - WebSocketSessionEvent, WebSocketSessionEventResult, http_request_body_result, - http_request_body_transform, http_request_body_unit, http_request_event, - http_request_event_result, http_request_preflight_result, 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, + WebSocketSessionEvent, WebSocketSessionEventResult, http_buffered_result, http_event, + http_inspect, http_preflight, http_preflight_result, http_result, web_socket_message, web_socket_message_result, web_socket_session_event, web_socket_session_event_result, }; use prost_types::Struct; @@ -250,18 +241,24 @@ impl SupervisorMiddleware for ContentGuard { phase: PHASE as i32, max_payload_bytes: MAX_PAYLOAD_BYTES, request_timeout: None, + http_protocol_version: 1, + supported_http_body_modes: vec![HttpBodyMode::Buffered as i32], }, MiddlewareBinding { operation: SupervisorMiddlewareOperation::WebsocketMessage as i32, phase: PHASE as i32, max_payload_bytes: MAX_PAYLOAD_BYTES, request_timeout: None, + http_protocol_version: 0, + supported_http_body_modes: Vec::new(), }, MiddlewareBinding { operation: SupervisorMiddlewareOperation::HttpResponse as i32, phase: SupervisorMiddlewarePhase::PreReturn as i32, max_payload_bytes: MAX_PAYLOAD_BYTES, request_timeout: None, + http_protocol_version: 1, + supported_http_body_modes: vec![HttpBodyMode::Buffered as i32], }, ], expected_audience: String::new(), @@ -295,283 +292,141 @@ impl SupervisorMiddleware for ContentGuard { } #[derive(Debug, Default)] -struct RequestSessionState { +struct HttpSessionState { config: Option, - body_ended: bool, - trailers_seen: bool, + began: bool, + completed: bool, } -impl RequestSessionState { - fn preflight( - &mut self, - preflight: openshell_core::proto::HttpRequestPreflight, - ) -> Result { - if self.config.is_some() { - return Err(Status::failed_precondition("duplicate preflight")); - } - let config = - GuardConfig::parse(preflight.config.as_ref()).map_err(Status::invalid_argument)?; - if !preflight - .permitted_body_modes - .contains(&(HttpRequestBodyMode::WholeBodyBytes as i32)) - { - return Err(Status::failed_precondition( - "content guard requires WHOLE_BODY_BYTES", - )); - } - self.config = Some(config); - Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::WholeBodyBytes as i32, - header_mutations: vec![], +impl HttpSessionState { + fn handle(&mut self, event: HttpEvent) -> Result, Status> { + match event.event { + Some(http_event::Event::Preflight(preflight)) if self.config.is_none() => { + let config = match preflight.head { + Some(http_preflight::Head::Request(head)) => head.config, + Some(http_preflight::Head::Response(head)) => head.config, + None => return Err(Status::invalid_argument("HTTP head is required")), + }; + if !preflight + .permitted_body_modes + .contains(&(HttpBodyMode::Buffered as i32)) + { + return Err(Status::failed_precondition( + "content guard requires BUFFERED mode", + )); + } + self.config = + Some(GuardConfig::parse(config.as_ref()).map_err(Status::invalid_argument)?); + Ok(Some(HttpResult { + result: Some(http_result::Result::PreflightResult(HttpPreflightResult { + decision: Some(http_preflight_result::Decision::Inspect(HttpInspect { + mode: Some(http_inspect::Mode::Buffered(HttpBufferedMode { + max_body_bytes: MAX_PAYLOAD_BYTES, + })), + })), + ..Default::default() + })), + })) + } + Some(http_event::Event::Begin(_)) + if self.config.is_some() && !self.began && !self.completed => + { + self.began = true; + Ok(None) + } + Some(http_event::Event::BufferedBody(body)) if self.began && !self.completed => { + let config = self.config.as_ref().expect("preflight set config"); + let text = std::str::from_utf8(&body.data) + .map_err(|_| Status::invalid_argument("content guard requires a UTF-8 body"))?; + let inspected = inspect(config, text); + self.completed = true; + let diagnostics = MiddlewareDiagnostics { + reason: inspected.reason, + reason_code: inspected.reason_code, + findings: inspected.findings, + metadata: inspected.metadata, + }; + if inspected.denied { + Ok(Some(HttpResult { + result: Some(http_result::Result::Reject(HttpReject { + diagnostics: Some(diagnostics), + })), + })) + } else { + let body = inspected.replacement.map_or_else( + || http_buffered_result::Body::Unchanged(HttpUnchanged {}), + |replacement| { + http_buffered_result::Body::Replacement(replacement.into_bytes()) }, - )), - ..Default::default() - }, - )), - }) - } - - fn body( - &mut self, - body: openshell_core::proto::HttpRequestBodyUnit, - ) -> Result { - 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( - "expected one complete request body", - )); - } - let Some(http_request_body_unit::Payload::Data(data)) = body.payload else { - return Err(Status::invalid_argument("body data required")); - }; - 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_request_body_result::Action::BlockRequest(HttpRequestBlock {}) - } else if let Some(replacement) = result.replacement { - http_request_body_result::Action::Transform(HttpRequestBodyTransform { - replacement: Some(http_request_body_transform::Replacement::Data( - replacement.into_bytes(), - )), - }) - } else { - http_request_body_result::Action::PassThrough(HttpRequestBodyPassThrough {}) - }; - self.body_ended = true; - Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyResult( - HttpRequestBodyResult { - sequence: body.sequence, - action: Some(action), - reason: result.reason, - reason_code: result.reason_code, - findings: result.findings, - metadata: result.metadata, - }, + ); + Ok(Some(HttpResult { + result: Some(http_result::Result::BufferedResult(HttpBufferedResult { + body: Some(body), + diagnostics: Some(diagnostics), + ..Default::default() + })), + })) + } + } + Some(http_event::Event::SessionEnd(_)) => Ok(None), + _ => Err(Status::failed_precondition( + "invalid content guard HTTP lifecycle", )), - }) - } - - fn trailers(&mut self) -> Result { - if !self.body_ended || self.trailers_seen { - return Err(Status::failed_precondition("expected trailers after body")); } - self.trailers_seen = true; - Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::TrailersResult( - HttpRequestTrailersResult::default(), - )), - }) } } -#[tonic::async_trait] -impl HttpRequestPreCredentials for ContentGuard { - type EvaluateStream = HttpRequestResultStream; - - 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 = RequestSessionState::default(); - while let Some(event) = events.next().await { - let result = match event { - Ok(event) => match event.event { - Some(http_request_event::Event::Preflight(preflight)) => { - state.preflight(preflight) - } - Some(http_request_event::Event::Body(body)) => state.body(body), - Some(http_request_event::Event::Trailers(_)) => state.trailers(), - Some(http_request_event::Event::SessionEnd(_)) => break, - None => Err(Status::invalid_argument("request event is required")), - }, - Err(error) => Err(error), - }; - match result { - Ok(result) => { - if sender.send(Ok(result)).await.is_err() { - break; - } +fn http_stream(mut events: tonic::Streaming) -> HttpResultStream { + let (sender, receiver) = mpsc::channel(4); + tokio::spawn(async move { + let mut state = HttpSessionState::default(); + while let Some(event) = events.next().await { + let result = match event { + Ok(event) => state.handle(event), + Err(error) => Err(error), + }; + match result { + Ok(Some(result)) => { + if sender.send(Ok(result)).await.is_err() { + break; } - Err(error) => { - let _ = sender.send(Err(error)).await; + } + Ok(None) => { + if state.completed { break; } } + Err(error) => { + let _ = sender.send(Err(error)).await; + break; + } } - }); - Ok(Response::new(Box::pin(ReceiverStream::new(receiver)))) - } + } + }); + Box::pin(ReceiverStream::new(receiver)) } -#[derive(Debug, Default)] -struct ResponseSessionState { - config: Option, - body_ended: bool, - trailers_seen: bool, -} -impl ResponseSessionState { - fn preflight( - &mut self, - preflight: openshell_core::proto::HttpResponsePreflight, - ) -> Result { - if self.config.is_some() { - return Err(Status::failed_precondition("duplicate preflight")); - } - 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: HttpResponseBodyMode::WholeBodyBytes as i32, - header_mutations: vec![], - }, - )), - ..Default::default() - }, - )), - }) - } - fn body( - &mut self, - body: openshell_core::proto::HttpResponseBodyUnit, - ) -> Result { - 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( - "expected one complete response body", - )); - } - let Some(http_response_body_unit::Payload::Data(data)) = body.payload else { - return Err(Status::invalid_argument("body data required")); - }; - 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: result.reason, - reason_code: result.reason_code, - findings: result.findings, - metadata: result.metadata, - }, - )), - }) - } - fn trailers(&mut self) -> Result { - if !self.body_ended || self.trailers_seen { - return Err(Status::failed_precondition("expected trailers after body")); - } - self.trailers_seen = true; - Ok(HttpResponseEventResult { - result: Some(http_response_event_result::Result::TrailersResult( - HttpResponseTrailersResult::default(), - )), - }) +#[tonic::async_trait] +impl HttpRequestPreCredentials for ContentGuard { + type EvaluateHttpStream = HttpResultStream; + + async fn evaluate_http( + &self, + request: Request>, + ) -> Result, Status> { + Ok(Response::new(http_stream(request.into_inner()))) } } #[tonic::async_trait] impl HttpResponsePreReturn for ContentGuard { - type EvaluateStream = HttpResponseResultStream; + type EvaluateHttpStream = HttpResultStream; - async fn evaluate( + async fn evaluate_http( &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)))) + request: Request>, + ) -> Result, Status> { + Ok(Response::new(http_stream(request.into_inner()))) } } @@ -763,8 +618,8 @@ async fn main() -> Result<(), Box> { mod tests { use super::*; use openshell_core::proto::{ - HttpRequestBodyUnit, HttpRequestPreflight, HttpResponseBodyUnit, HttpResponsePreflight, - MiddlewareSessionEnd, WebSocketPreflight, WebSocketSessionStart, + HttpBegin, HttpBodyLimits, HttpBufferedBody, HttpPreflight, HttpRequestPreflightHead, + HttpResponsePreflightHead, MiddlewareSessionEnd, WebSocketPreflight, WebSocketSessionStart, }; use prost_types::{ListValue, Value}; use std::collections::BTreeMap; @@ -825,98 +680,98 @@ mod tests { ); } - fn response_preflight(mode: &str) -> HttpResponsePreflight { - HttpResponsePreflight { - config: Some(config(mode, &["prototype-secret", "秘密"], None)), - permitted_body_modes: vec![HttpResponseBodyMode::WholeBodyBytes as i32], - ..Default::default() + fn preflight(mode: &str, response: bool) -> HttpEvent { + let config = Some(config(mode, &["prototype-secret", "秘密"], None)); + let head = if response { + http_preflight::Head::Response(HttpResponsePreflightHead { + config, + ..Default::default() + }) + } else { + http_preflight::Head::Request(HttpRequestPreflightHead { + config, + ..Default::default() + }) + }; + HttpEvent { + event: Some(http_event::Event::Preflight(HttpPreflight { + head: Some(head), + permitted_body_modes: vec![HttpBodyMode::Buffered as i32], + limits: Some(HttpBodyLimits { + max_buffered_body_bytes: MAX_PAYLOAD_BYTES, + ..Default::default() + }), + ..Default::default() + })), } } - fn request_preflight(mode: &str) -> HttpRequestPreflight { - HttpRequestPreflight { - config: Some(config(mode, &["prototype-secret", "秘密"], None)), - permitted_body_modes: vec![HttpRequestBodyMode::WholeBodyBytes as i32], - ..Default::default() - } + fn begin(state: &mut HttpSessionState) { + assert!( + state + .handle(HttpEvent { + event: Some(http_event::Event::Begin(HttpBegin {})), + }) + .expect("begin") + .is_none() + ); + } + + fn body(state: &mut HttpSessionState, value: &[u8]) -> HttpResult { + state + .handle(HttpEvent { + event: Some(http_event::Event::BufferedBody(HttpBufferedBody { + data: value.to_vec(), + visible_trailers: Vec::new(), + })), + }) + .expect("body") + .expect("body result") } #[test] - fn request_guard_uses_streamed_whole_body_contract() { - let mut state = RequestSessionState::default(); - let preflight = state.preflight(request_preflight("redact")).unwrap(); + fn http_guard_selects_buffered_mode_and_redacts_requests() { + let mut state = HttpSessionState::default(); + let result = state + .handle(preflight("redact", false)) + .expect("preflight") + .expect("preflight result"); assert!(matches!( - preflight.result, - Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { body_mode, .. } - )), - .. - } - )) if body_mode == HttpRequestBodyMode::WholeBodyBytes as i32 + result.result, + Some(http_result::Result::PreflightResult(HttpPreflightResult { + decision: Some(http_preflight_result::Decision::Inspect(HttpInspect { + mode: Some(http_inspect::Mode::Buffered(_)), + })), + .. + })) )); - let result = state - .body(HttpRequestBodyUnit { - sequence: 1, - payload: Some(http_request_body_unit::Payload::Data( - b"contains prototype-secret".to_vec(), - )), - end_of_stream: true, - }) - .unwrap(); - let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { - panic!("body result"); - }; - let Some(http_request_body_result::Action::Transform(transform)) = result.action else { - panic!("transform"); + begin(&mut state); + let result = body(&mut state, b"contains prototype-secret"); + let Some(http_result::Result::BufferedResult(result)) = result.result else { + panic!("buffered result"); }; assert_eq!( - transform.replacement, - Some(http_request_body_transform::Replacement::Data( + result.body, + Some(http_buffered_result::Body::Replacement( b"contains [REDACTED]".to_vec() )) ); - assert!(matches!( - state.trailers().unwrap().result, - Some(http_request_event_result::Result::TrailersResult(_)) - )); } #[test] - fn request_guard_denies_and_rejects_unavailable_whole_body_mode() { - let mut unavailable = request_preflight("redact"); - unavailable.permitted_body_modes = vec![HttpRequestBodyMode::HeadersOnly as i32]; - assert!( - RequestSessionState::default() - .preflight(unavailable) - .is_err() - ); - - let mut state = RequestSessionState::default(); - state.preflight(request_preflight("deny")).unwrap(); - let result = state - .body(HttpRequestBodyUnit { - sequence: 1, - payload: Some(http_request_body_unit::Payload::Data( - b"prototype-secret".to_vec(), - )), - end_of_stream: true, - }) - .unwrap(); - let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { - panic!("body result"); + fn http_guard_rejects_when_buffered_mode_is_unavailable() { + let mut event = preflight("redact", false); + let Some(http_event::Event::Preflight(preflight)) = event.event.as_mut() else { + unreachable!() }; - assert!(matches!( - result.action, - Some(http_request_body_result::Action::BlockRequest(_)) - )); - assert_eq!(result.reason_code, "content_match"); + preflight.permitted_body_modes.clear(); + + assert!(HttpSessionState::default().handle(event).is_err()); } #[test] - fn response_guard_passes_redacts_and_denies() { + fn http_guard_passes_redacts_and_denies_responses() { for (mode, input, expected) in [ ("redact", "clean", None), ( @@ -926,82 +781,58 @@ mod tests { ), ("deny", "prototype-secret", None), ] { - let mut state = ResponseSessionState::default(); - state.preflight(response_preflight(mode)).unwrap(); - let unit = HttpResponseBodyUnit { - sequence: 1, - payload: Some(http_response_body_unit::Payload::Data( - input.as_bytes().to_vec(), - )), - end_of_stream: true, - }; - 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") - }; + let mut state = HttpSessionState::default(); + state.handle(preflight(mode, true)).expect("preflight"); + begin(&mut state); + let result = body(&mut state, input.as_bytes()); + 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") + let Some(http_result::Result::Reject(reject)) = result.result else { + panic!("reject"); }; assert_eq!( - transform.replacement, - Some(http_response_body_transform::Replacement::Data( - expected.as_bytes().to_vec() - )) + reject.diagnostics.expect("diagnostics").reason_code, + "content_match" ); } else { - assert!(matches!( - result.action, - Some(http_response_body_result::Action::PassThrough(_)) - )); + let Some(http_result::Result::BufferedResult(result)) = result.result else { + panic!("buffered result"); + }; + match expected { + Some(expected) => assert_eq!( + result.body, + Some(http_buffered_result::Body::Replacement( + expected.as_bytes().to_vec() + )) + ), + None => assert!(matches!( + result.body, + Some(http_buffered_result::Body::Unchanged(_)) + )), + } } - 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_guard_rejects_unavailable_inspection_and_invalid_input() { - let mut preflight = response_preflight("redact"); - preflight.permitted_body_modes = vec![HttpResponseBodyMode::HeadersOnly as i32]; + fn http_guard_enforces_lifecycle_and_utf8() { + let mut state = HttpSessionState::default(); + let body_event = |data: Vec| HttpEvent { + event: Some(http_event::Event::BufferedBody(HttpBufferedBody { + data, + visible_trailers: Vec::new(), + })), + }; assert!( - ResponseSessionState::default() - .preflight(preflight) + state + .handle(body_event(b"before preflight".to_vec())) .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() - ); - } + + state.handle(preflight("redact", false)).expect("preflight"); + assert!(state.handle(preflight("redact", false)).is_err()); + begin(&mut state); + assert!(state.handle(body_event(vec![0xff])).is_err()); } #[tokio::test] diff --git a/examples/supervisor-middleware-git-signing/Cargo.lock b/examples/supervisor-middleware-git-signing/Cargo.lock deleted file mode 100644 index f5ad14da36..0000000000 --- a/examples/supervisor-middleware-git-signing/Cargo.lock +++ /dev/null @@ -1,2555 +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.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" -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.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" - -[[package]] -name = "async-trait" -version = "0.1.92" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "atomic-waker" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "aws-lc-rs" -version = "1.18.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" -dependencies = [ - "aws-lc-sys", - "untrusted 0.7.1", - "zeroize", -] - -[[package]] -name = "aws-lc-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" -dependencies = [ - "cc", - "cmake", - "dunce", - "fs_extra", - "pkg-config", -] - -[[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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" - -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3eb0f42d6c360dc3f8a821f6bf2fdea7f72bfd36b3076eb0e6d1e9e0752fff4" -dependencies = [ - "find-msvc-tools", - "jobserver", - "libc", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "clap" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" -dependencies = [ - "clap_builder", - "clap_derive", -] - -[[package]] -name = "clap_builder" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" -dependencies = [ - "anstream", - "anstyle", - "clap_lex", - "strsim", -] - -[[package]] -name = "clap_derive" -version = "4.6.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "clap_lex" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" - -[[package]] -name = "cmake" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" -dependencies = [ - "cc", -] - -[[package]] -name = "colorchoice" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" - -[[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.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - -[[package]] -name = "displaydoc" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "dunce" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" - -[[package]] -name = "either" -version = "1.18.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" - -[[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.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - -[[package]] -name = "find-msvc-tools" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" - -[[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 = "fs_extra" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" - -[[package]] -name = "futures-channel" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" -dependencies = [ - "futures-core", -] - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-sink" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[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", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - -[[package]] -name = "h2" -version = "0.4.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" -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 = "http" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" -dependencies = [ - "bytes", - "itoa", -] - -[[package]] -name = "http-body" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" -dependencies = [ - "bytes", - "http", -] - -[[package]] -name = "http-body-util" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" -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.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" -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.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" -dependencies = [ - "displaydoc", - "potential_utf", - "utf8_iter", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "icu_locale_core" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" -dependencies = [ - "displaydoc", - "litemap", - "serde", - "tinystr", - "writeable", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" -dependencies = [ - "icu_locale_core", - "icu_locale_fallback_data", - "icu_provider", - "potential_utf", - "tinystr", - "zerovec", -] - -[[package]] -name = "icu_locale_fallback_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" - -[[package]] -name = "icu_normalizer" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" -dependencies = [ - "icu_collections", - "icu_normalizer_data", - "icu_properties", - "icu_provider", - "smallvec", - "zerovec", -] - -[[package]] -name = "icu_normalizer_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" - -[[package]] -name = "icu_properties" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" -dependencies = [ - "displaydoc", - "icu_collections", - "icu_locale_core", - "icu_properties_data", - "icu_provider", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_properties_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" - -[[package]] -name = "icu_provider" -version = "2.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" -dependencies = [ - "displaydoc", - "icu_locale_core", - "serde", - "stable_deref_trait", - "writeable", - "yoke", - "zerofrom", - "zerotrie", - "zerovec", -] - -[[package]] -name = "icu_segmenter" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" -dependencies = [ - "icu_collections", - "icu_locale_fallback", - "icu_provider", - "icu_segmenter_data", - "potential_utf", - "smallvec", - "utf8_iter", - "zerovec", -] - -[[package]] -name = "icu_segmenter_data" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" - -[[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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown 0.17.1", - "serde", - "serde_core", -] - -[[package]] -name = "ipnet" -version = "2.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" - -[[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 = "jobserver" -version = "0.1.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" -dependencies = [ - "getrandom 0.4.3", - "libc", -] - -[[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "jsonwebtoken" -version = "10.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" -dependencies = [ - "aws-lc-rs", - "base64", - "getrandom 0.2.17", - "js-sys", - "pem", - "serde", - "serde_json", - "signature", - "simple_asn1", - "zeroize", -] - -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - -[[package]] -name = "libc" -version = "0.2.189" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" - -[[package]] -name = "libm" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" - -[[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.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "matchers" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" -dependencies = [ - "regex-automata", -] - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[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 2.0.119", -] - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" -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 = "noyalib" -version = "0.0.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f075ef19fa3bcf8697c0ef96c37d5c435d339a40ab8081cae3aac3a4e7fee9a" -dependencies = [ - "hashbrown 0.17.1", - "indexmap", - "libm", - "memchr", - "rustc-hash", - "serde", - "serde_core", - "smallvec", -] - -[[package]] -name = "nu-ansi-term" -version = "0.50.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" -dependencies = [ - "windows-sys 0.61.2", -] - -[[package]] -name = "num-bigint" -version = "0.4.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" -dependencies = [ - "num-integer", - "num-traits", -] - -[[package]] -name = "num-conv" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" - -[[package]] -name = "num-integer" -version = "0.1.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[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" - -[[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", - "openshell-policy-schema", - "prost", - "prost-types", - "protoc-bin-vendored", - "rustix", - "rustls", - "rustls-pemfile", - "serde", - "serde_json", - "sha2", - "thiserror", - "tokio", - "tokio-stream", - "tonic", - "tonic-prost", - "tonic-prost-build", - "tonic-types", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "openshell-extension-core" -version = "0.0.0" -dependencies = [ - "hyper-util", - "serde", - "thiserror", - "tokio", - "tonic", - "tower", -] - -[[package]] -name = "openshell-policy-schema" -version = "0.0.0" -dependencies = [ - "miette", - "noyalib", - "serde", - "serde_json", -] - -[[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.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8" - -[[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 = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - -[[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 2.0.119", -] - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "pkg-config" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" - -[[package]] -name = "potential_utf" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" -dependencies = [ - "serde_core", - "writeable", - "zerovec", -] - -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.119", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -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 2.0.119", - "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 2.0.119", -] - -[[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.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab1ad36992cead65f02aa399a373a42730922f1525d988172634fdefdecb8a60" -dependencies = [ - "pulldown-cmark", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -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_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[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.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" -dependencies = [ - "aho-corasick", - "memchr", - "regex-automata", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.4.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" -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 0.9.0", - "windows-sys 0.52.0", -] - -[[package]] -name = "rustc-demangle" -version = "0.1.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb" - -[[package]] -name = "rustc-hash" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" - -[[package]] -name = "rustix" -version = "1.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" -dependencies = [ - "bitflags", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - -[[package]] -name = "rustls" -version = "0.23.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" -dependencies = [ - "aws-lc-rs", - "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.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "aws-lc-rs", - "ring", - "rustls-pki-types", - "untrusted 0.9.0", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[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 = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_json" -version = "1.0.151" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "sha2" -version = "0.10.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - -[[package]] -name = "sharded-slab" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" -dependencies = [ - "lazy_static", -] - -[[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 = "signature" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" -dependencies = [ - "rand_core", -] - -[[package]] -name = "simple_asn1" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror", - "time", -] - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "smallvec" -version = "1.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba467056f1b547ed52077911161fc86985becbc60e8e1857c8a144dab0def891" - -[[package]] -name = "socket2" -version = "0.6.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" -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 = "supervisor-middleware-git-signing" -version = "0.0.0" -dependencies = [ - "clap", - "jsonwebtoken", - "openshell-core", - "openshell-extension-core", - "prost-types", - "tempfile", - "tokio", - "tokio-stream", - "tonic", - "tracing", - "tracing-subscriber", -] - -[[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.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" -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.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ecfad6c3abc80a577f2b91c1e412ee57e7a060d430b553c1b0c940974ebcd49" -dependencies = [ - "icu_segmenter", - "unicode-width 0.2.2", -] - -[[package]] -name = "thiserror" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" -dependencies = [ - "thiserror-impl", -] - -[[package]] -name = "thiserror-impl" -version = "2.0.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "thread_local" -version = "1.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "time" -version = "0.3.55" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" -dependencies = [ - "deranged", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" - -[[package]] -name = "time-macros" -version = "0.2.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" -dependencies = [ - "num-conv", - "time-core", -] - -[[package]] -name = "tinystr" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" -dependencies = [ - "displaydoc", - "serde_core", - "zerovec", -] - -[[package]] -name = "tokio" -version = "1.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" -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.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "tokio-rustls" -version = "0.26.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" -dependencies = [ - "rustls", - "tokio", -] - -[[package]] -name = "tokio-stream" -version = "0.1.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" -dependencies = [ - "futures-core", - "pin-project-lite", - "tokio", -] - -[[package]] -name = "tokio-util" -version = "0.7.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" -dependencies = [ - "bytes", - "futures-core", - "futures-sink", - "libc", - "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 2.0.119", -] - -[[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 2.0.119", - "tempfile", - "tonic-build", -] - -[[package]] -name = "tonic-types" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab1b02061f83d519bba3caa167f88f261ef05720ab8ebc954ade70de3348e8" -dependencies = [ - "prost", - "prost-types", - "tonic", -] - -[[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 2.0.119", -] - -[[package]] -name = "tracing-core" -version = "0.1.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" -dependencies = [ - "once_cell", - "valuable", -] - -[[package]] -name = "tracing-log" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" -dependencies = [ - "log", - "once_cell", - "tracing-core", -] - -[[package]] -name = "tracing-subscriber" -version = "0.3.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" -dependencies = [ - "matchers", - "nu-ansi-term", - "once_cell", - "regex-automata", - "sharded-slab", - "smallvec", - "thread_local", - "tracing", - "tracing-core", - "tracing-log", -] - -[[package]] -name = "try-lock" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" - -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - -[[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.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" - -[[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.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" - -[[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 = "uuid" -version = "1.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" -dependencies = [ - "getrandom 0.4.3", - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "valuable" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" - -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[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 = "wasm-bindgen" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.6", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.128" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" -dependencies = [ - "unicode-ident", -] - -[[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.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" - -[[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.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "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.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", - "synstructure", -] - -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zerotrie" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" -dependencies = [ - "displaydoc", - "yoke", - "zerofrom", - "zerovec", -] - -[[package]] -name = "zerovec" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" -dependencies = [ - "serde", - "yoke", - "zerofrom", - "zerovec-derive", -] - -[[package]] -name = "zerovec-derive" -version = "0.11.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.6", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/examples/supervisor-middleware-git-signing/Cargo.toml b/examples/supervisor-middleware-git-signing/Cargo.toml deleted file mode 100644 index d5b63eeb75..0000000000 --- a/examples/supervisor-middleware-git-signing/Cargo.toml +++ /dev/null @@ -1,28 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -[workspace] - -[package] -name = "supervisor-middleware-git-signing" -version = "0.0.0" -edition = "2024" -rust-version = "1.90" -license = "Apache-2.0" -publish = false - -[dependencies] -clap = { version = "4.5", features = ["derive"] } -jsonwebtoken = { version = "10", features = ["aws_lc_rs"] } -openshell-core = { path = "../../crates/openshell-core", default-features = false } -openshell-extension-core = { path = "../../crates/openshell-extension-core" } -prost-types = "0.14" -tempfile = "3" -tokio = { version = "1.43", features = ["fs", "io-util", "macros", "rt-multi-thread"] } -tokio-stream = "0.1" -tonic = { version = "0.14", features = ["transport", "tls-ring"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } - -[lints.rust] -unsafe_code = "forbid" diff --git a/examples/supervisor-middleware-git-signing/README.md b/examples/supervisor-middleware-git-signing/README.md deleted file mode 100644 index ff0ab3bfad..0000000000 --- a/examples/supervisor-middleware-git-signing/README.md +++ /dev/null @@ -1,100 +0,0 @@ - - -# Git commit signing supervisor middleware prototype - -This example tests whether an operator-run OpenShell supervisor middleware can sign commits without mounting a signing key into the sandbox. It uses the streaming `HttpRequestPreCredentials.Evaluate` API to intercept Git smart-HTTP `git-receive-pack` requests after network policy admission and before provider credential injection. The service selects `OWNED_STREAM_BYTES`, accepts bounded 64 KiB units into an unlinked temporary file, rewrites the pushed commit objects with SSH signatures, and streams the replacement request back in bounded units. - -The sandbox sees neither the private key nor the signature operation. Its Git client creates ordinary unsigned commits and pushes over HTTPS. - -## What the prototype does - -For each direct branch update in a push, the service: - -1. Derives a bounded `github.com//.git` upstream URL from the policy-admitted request target. -2. Shallow-fetches the upstream default branch and updated branch tips into a temporary bare repository. This supplies bases omitted from normal thin pushes. -3. Decodes the receive-pack command pkt-lines and packfile, then walks only commits that are not already reachable from the fetched upstream refs. -4. Removes any existing commit signature and signs the exact rewritten commit payload with `ssh-keygen -Y sign -n git`. -5. Rewrites parent object IDs, creates a replacement thin pack, and substitutes the new branch-tip object ID in the receive-pack command. -6. Returns the modified body to the supervisor, which forwards it with the sandbox's normal GitHub credential. - -The signing key is a service startup argument, not policy-controlled middleware configuration. A sandbox cannot select an arbitrary host file to sign with. - -## Build and test - -The test constructs a two-commit push larger than the former 4 MiB unary limit, transforms it, sends the replacement request through Git's real `receive-pack --stateless-rpc`, checks the updated branch tip, and verifies both SSH signatures with `git verify-commit`. - -```shell -cargo test --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml -``` - -The example requires `git` and `ssh-keygen` on the host. - -## Run the service - -Use an SSH key that is also registered as a signing key with the Git forge. GitHub distinguishes signing keys from authentication keys even when the same public key material is used. - -The service requires TLS and verifies every OpenShell caller with an exact-audience extension JWT. Provision a service certificate and private key whose DNS name matches the middleware endpoint, the issuing CA certificate for the gateway registration, and the Ed25519 public key configured in `[openshell.gateway.gateway_jwt]`. - -```shell -cargo run \ - --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml \ - -- \ - --bind 0.0.0.0:50051 \ - --signing-key /run/secrets/git-signing-key \ - --tls-cert /run/secrets/git-signer.pem \ - --tls-key /run/secrets/git-signer-key.pem \ - --extension-public-key /run/secrets/openshell-extension-public.pem \ - --expected-gateway-id openshell \ - --audience urn:openshell:extension:middleware:local-git-signer \ - --max-concurrent-signings 2 \ - --signing-timeout-seconds 25 -``` - -Register the local service in `gateway.toml`. A container-backed gateway can reach a host service through `host.openshell.internal`; a host-native gateway can use `127.0.0.1`. - -```toml -[[openshell.supervisor.middleware]] -name = "local-git-signer" -grpc_endpoint = "https://host.openshell.internal:50051" -tls_ca_cert_path = "/etc/openshell/certs/git-signer-ca.pem" -audience = "urn:openshell:extension:middleware:local-git-signer" -max_payload_bytes = 65536 -timeout = "30s" - -[openshell.gateway.gateway_jwt] -signing_key_path = "/etc/openshell/jwt/signing.pem" -public_key_path = "/etc/openshell/jwt/public.pem" -kid_path = "/etc/openshell/jwt/kid" -gateway_id = "openshell" -ttl_secs = 3600 -``` - -The `public_key_path` file must be the same key supplied to the service with `--extension-public-key`. The registration audience must exactly match `--audience`, and `gateway_id` must exactly match `--expected-gateway-id`. The service authorizes gateway callers for discovery and configuration validation and sandbox supervisor callers for request evaluation. - -Set `max_payload_bytes` to 65536 as shown for full-size stream units. A lower positive value uses smaller output units; the value does not cap the complete push. OpenShell separately enforces a bounded deferred-storage limit for owned streams. Keep the registration timeout above the service's signing timeout so OpenShell can receive either the signed output or a controlled failure. - -Restart the gateway after adding the static registration, then replace `` and `` in [policy.yaml](policy.yaml) and create a sandbox with that policy. Keep the endpoint on `fail_closed`: reject a push rather than forwarding it unsigned when signing fails. - -The service skips unrelated GitHub HTTP requests during preflight. It inspects only `POST` requests whose path ends in `/git-receive-pack` and whose content type is `application/x-git-receive-pack-request`. This example accepts only validated HTTPS targets on `github.com:443` with a two-segment repository path. - -The signer implements `SupervisorMiddleware` for discovery and configuration validation, and serves `HttpRequestPreCredentials` alongside it. The request stream is the only HTTP request middleware API. - -## Resource bounds and cancellation - -Input and replacement packs stay in unlinked temporary files. The service parses only a bounded 1 MiB receive-pack prefix in memory, feeds the input pack directly to `git index-pack`, and writes the replacement pack directly to its output file. It emits the replacement to OpenShell in bounded units. - -`--max-concurrent-signings` limits active Git rewrite workers. `--signing-timeout-seconds` covers upstream fetches, graph rewriting, signing, and pack generation. If the OpenShell request is canceled or the deadline expires, the service kills the active `git` or `ssh-keygen` subprocess and waits for it to exit. Subprocess diagnostic capture is bounded and is not returned to the sandbox. - -## Prototype limits - -- Owned streams are available only with `on_error: fail_closed`. Once the service acknowledges ownership, OpenShell has no replay copy and cannot safely fail open. -- The implementation supports SHA-1 repositories and direct `refs/heads/*` updates. It rejects SHA-256 repositories, annotated-tag pushes, push certificates, and other ref types. -- Each push shallow-fetches upstream objects before signing. The host must be able to reach the repository, and private repositories need a non-interactive Git credential helper available to the local middleware user. A production deployment should use a bounded object cache and explicit credential plumbing. -- The service shells out to the host's `git` and `ssh-keygen`. Run it with OS-level CPU, memory, process, temporary-storage, and network limits in addition to its own concurrency and time bounds. -- Rewriting commit object IDs means the sandbox's local branch still points to the unsigned commit after a successful push. A subsequent fetch updates the remote-tracking ref, but the local branch must be reconciled with the rewritten history. This is the largest workflow issue for transparent push-time signing. -- The example has protocol and local end-to-end Git coverage, not a live GitHub push. Test against a disposable repository before using a real signing key. - -These constraints make the approach viable as a focused deployment or proof of concept, but not yet transparent enough for general production pushes. A first-class commit-signing operation invoked before Git creates the final local object would avoid the local/remote object-ID split while still keeping the private key outside the sandbox. diff --git a/examples/supervisor-middleware-git-signing/policy.yaml b/examples/supervisor-middleware-git-signing/policy.yaml deleted file mode 100644 index 45c861bfc2..0000000000 --- a/examples/supervisor-middleware-git-signing/policy.yaml +++ /dev/null @@ -1,48 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -version: 1 - -filesystem_policy: - include_workdir: true - read_only: [/usr, /lib, /proc, /dev/urandom, /etc] - read_write: [/sandbox, /tmp, /dev/null] - -landlock: - compatibility: best_effort - -process: - run_as_user: sandbox - run_as_group: sandbox - -network_middlewares: - sign-git-commits: - name: Sign outgoing Git commits - middleware: local-git-signer - order: 10 - config: {} - on_error: fail_closed - endpoints: - include: [github.com] - -network_policies: - github_git: - name: github-git - endpoints: - - host: github.com - port: 443 - protocol: rest - enforcement: enforce - rules: - - allow: - method: GET - path: "//.git/info/refs*" - - allow: - method: POST - path: "//.git/git-upload-pack" - - allow: - method: POST - path: "//.git/git-receive-pack" - binaries: - - { path: /usr/bin/git } - - { path: /usr/lib/git-core/git-remote-http } diff --git a/examples/supervisor-middleware-git-signing/src/main.rs b/examples/supervisor-middleware-git-signing/src/main.rs deleted file mode 100644 index 71d52bde8b..0000000000 --- a/examples/supervisor-middleware-git-signing/src/main.rs +++ /dev/null @@ -1,1022 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -mod signer; - -use std::collections::HashMap; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -use clap::Parser; -use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header}; -use openshell_core::middleware::{HttpRequestResultStream, WebSocketResponseStream}; -use openshell_core::proto::middleware::v1::http_request_pre_credentials_server::{ - HttpRequestPreCredentials, HttpRequestPreCredentialsServer, -}; -use openshell_core::proto::middleware::v1::supervisor_middleware_server::{ - SupervisorMiddleware, SupervisorMiddlewareServer, -}; -use openshell_core::proto::{ - Finding, HttpRequestBodyFinalize, HttpRequestBodyMode, HttpRequestBodyOutput, - HttpRequestBodyResult, HttpRequestBodyTakeOwnership, HttpRequestEvent, HttpRequestEventResult, - HttpRequestPreflight, HttpRequestPreflightInspect, HttpRequestPreflightResult, - HttpRequestPreflightSkip, HttpRequestTrailersResult, MiddlewareBinding, MiddlewareManifest, - SupervisorMiddlewareOperation, SupervisorMiddlewarePhase, ValidateConfigRequest, - ValidateConfigResponse, WebSocketSessionEvent, http_request_body_result, - http_request_body_unit, http_request_event, http_request_event_result, - http_request_preflight_result, -}; -use openshell_extension_core::{ - EXTENSION_JWT_TYP, ExtensionCallerKind, ExtensionJwtClaims, MAX_EXTENSION_TOKEN_TTL, -}; -use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; -use tokio_stream::StreamExt as _; -use tokio_stream::wrappers::ReceiverStream; -use tonic::transport::{Identity, Server, ServerTlsConfig}; -use tonic::{Request, Response, Status}; -use tracing::{info, warn}; -use tracing_subscriber::EnvFilter; - -use crate::signer::{GitSigner, SignControl}; - -const MANIFEST_NAME: &str = "example/git-commit-signing"; -const OPERATION: SupervisorMiddlewareOperation = SupervisorMiddlewareOperation::HttpRequest; -const PHASE: SupervisorMiddlewarePhase = SupervisorMiddlewarePhase::PreCredentials; -const MAX_UNIT_BYTES: usize = 64 * 1024; -const JWT_CLOCK_SKEW_SECONDS: i64 = 60; - -#[derive(Debug, Parser)] -#[command(about = "Sign commits in Git smart-HTTP pushes outside an OpenShell sandbox")] -struct Cli { - /// Address on which to serve authenticated TLS gRPC. - #[arg(long, default_value = "127.0.0.1:50051")] - bind: SocketAddr, - - /// Local SSH private key used by ssh-keygen. This path is never sent to the sandbox. - #[arg(long)] - signing_key: PathBuf, - - /// PEM certificate presented by this middleware service. - #[arg(long)] - tls_cert: PathBuf, - - /// PEM private key for the middleware TLS certificate. - #[arg(long)] - tls_key: PathBuf, - - /// PEM Ed25519 public key used to verify OpenShell extension JWTs. - #[arg(long)] - extension_public_key: PathBuf, - - /// Gateway ID expected in extension-token issuer claims. - #[arg(long)] - expected_gateway_id: String, - - /// Exact extension JWT audience configured for this registration. - #[arg(long)] - audience: String, - - /// Maximum number of concurrent Git rewrite workers. - #[arg(long, default_value_t = 2)] - max_concurrent_signings: usize, - - /// Total deadline for fetch, rewrite, signing, and pack generation. - #[arg(long, default_value_t = 25)] - signing_timeout_seconds: u64, -} - -struct ExtensionAuth { - decoding_key: DecodingKey, - issuer: String, - audience: String, -} - -impl ExtensionAuth { - fn new(public_key_pem: &[u8], gateway_id: &str, audience: String) -> Result { - if gateway_id.is_empty() || audience.is_empty() { - return Err("expected gateway ID and audience must be nonempty".into()); - } - let decoding_key = DecodingKey::from_ed_pem(public_key_pem) - .map_err(|error| format!("invalid extension public key: {error}"))?; - Ok(Self { - decoding_key, - issuer: format!("openshell-gateway:{gateway_id}"), - audience, - }) - } - - fn authenticate( - &self, - request: &Request, - required_caller: Option, - ) -> Result<(), Status> { - let authorization = request - .metadata() - .get("authorization") - .and_then(|value| value.to_str().ok()) - .and_then(|value| value.strip_prefix("Bearer ")) - .ok_or_else(|| Status::unauthenticated("extension bearer token is required"))?; - let header = decode_header(authorization) - .map_err(|_| Status::unauthenticated("extension bearer token is invalid"))?; - if header.alg != Algorithm::EdDSA - || header.typ.as_deref() != Some(EXTENSION_JWT_TYP) - || header.kid.as_deref().is_none_or(str::is_empty) - { - return Err(Status::unauthenticated( - "extension bearer token header is invalid", - )); - } - let mut validation = Validation::new(Algorithm::EdDSA); - validation.set_issuer(&[self.issuer.as_str()]); - validation.set_audience(&[self.audience.as_str()]); - validation.set_required_spec_claims(&["iss", "aud", "sub", "iat", "exp", "jti"]); - let claims = decode::(authorization, &self.decoding_key, &validation) - .map_err(|_| Status::unauthenticated("extension bearer token is invalid"))? - .claims; - if claims.jti.is_empty() - || claims.iat < 0 - || claims.exp <= claims.iat - || u64::try_from(claims.exp - claims.iat) - .ok() - .is_none_or(|ttl| ttl > MAX_EXTENSION_TOKEN_TTL.as_secs()) - { - return Err(Status::unauthenticated( - "extension bearer token lifetime is invalid", - )); - } - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map_err(|_| Status::internal("system clock is before the Unix epoch"))? - .as_secs() as i64; - if claims.iat > now.saturating_add(JWT_CLOCK_SKEW_SECONDS) { - return Err(Status::unauthenticated( - "extension bearer token issue time is invalid", - )); - } - if required_caller.is_some_and(|required| claims.caller_kind != required) { - return Err(Status::permission_denied( - "extension caller kind is not authorized for this RPC", - )); - } - match claims.caller_kind { - ExtensionCallerKind::Gateway - if claims.sandbox_id.is_none() && claims.sub == self.issuer => {} - ExtensionCallerKind::Supervisor => { - let sandbox_id = claims.sandbox_id.as_deref().filter(|id| !id.is_empty()); - if sandbox_id - .is_none_or(|id| claims.sub != format!("spiffe://openshell/sandbox/{id}")) - { - return Err(Status::permission_denied( - "extension supervisor identity is invalid", - )); - } - } - _ => { - return Err(Status::permission_denied( - "extension gateway identity is invalid", - )); - } - } - Ok(()) - } -} - -#[derive(Clone)] -struct GitSigningMiddleware { - signer: Arc, - auth: Arc, - signing_slots: Arc, - signing_timeout: Duration, - expected_audience: String, -} - -impl GitSigningMiddleware { - fn new( - signing_key: PathBuf, - max_concurrent_signings: usize, - signing_timeout: Duration, - auth: ExtensionAuth, - ) -> Result { - if max_concurrent_signings == 0 { - return Err("max concurrent signings must be positive".into()); - } - Ok(Self { - signer: Arc::new(GitSigner::new(signing_key)?), - expected_audience: auth.audience.clone(), - auth: Arc::new(auth), - signing_slots: Arc::new(tokio::sync::Semaphore::new(max_concurrent_signings)), - signing_timeout, - }) - } - - #[cfg(test)] - fn new_for_test(signing_key: PathBuf) -> Result { - Ok(Self { - signer: Arc::new(GitSigner::new(signing_key)?), - auth: Arc::new(ExtensionAuth { - decoding_key: DecodingKey::from_secret(b"unused-test-key"), - issuer: "openshell-gateway:test".into(), - audience: "urn:openshell:extension:test:git-signing".into(), - }), - signing_slots: Arc::new(tokio::sync::Semaphore::new(1)), - signing_timeout: Duration::from_secs(60), - expected_audience: "urn:openshell:extension:test:git-signing".into(), - }) - } - - fn request_stream(&self, mut events: S) -> HttpRequestResultStream - where - S: tokio_stream::Stream> + Send + Unpin + 'static, - { - let signer = Arc::clone(&self.signer); - let signing_slots = Arc::clone(&self.signing_slots); - let signing_timeout = self.signing_timeout; - let (results_tx, results_rx) = tokio::sync::mpsc::channel(4); - tokio::spawn(async move { - let mut selection = None; - let mut input = None; - let mut input_bytes = 0u64; - let mut next_input_sequence = 1u64; - let mut final_input_sequence = None; - - while let Some(event) = events.next().await { - let event = match event { - Ok(event) => event, - Err(error) => { - let _ = results_tx.send(Err(error)).await; - break; - } - }; - match event.event { - Some(http_request_event::Event::Preflight(preflight)) - if selection.is_none() => - { - match select_request(&preflight) { - Ok(None) => { - selection = Some(Selection::Skipped); - if results_tx.send(Ok(preflight_skip())).await.is_err() { - break; - } - } - Ok(Some(selected)) => { - if !preflight - .permitted_body_modes - .contains(&(HttpRequestBodyMode::OwnedStreamBytes as i32)) - || preflight.max_deferred_bytes == 0 - { - send_stream_error( - &results_tx, - Status::failed_precondition( - "Git signing requires fail-closed owned request streaming", - ), - ) - .await; - break; - } - match tempfile::tempfile() { - Ok(file) => { - input = Some(tokio::fs::File::from_std(file)); - selection = Some(selected); - if results_tx.send(Ok(preflight_owned())).await.is_err() { - break; - } - } - Err(_) => { - send_stream_error( - &results_tx, - Status::resource_exhausted( - "Git signing temporary storage is unavailable", - ), - ) - .await; - break; - } - } - } - Err(status) => { - send_stream_error(&results_tx, status).await; - break; - } - } - } - Some(http_request_event::Event::Body(body)) - if matches!(selection, Some(Selection::Sign { .. })) - && final_input_sequence.is_none() => - { - if body.sequence != next_input_sequence { - send_stream_error( - &results_tx, - Status::invalid_argument( - "Git signing input sequence is not contiguous", - ), - ) - .await; - break; - } - let Some(http_request_body_unit::Payload::Data(data)) = body.payload else { - send_stream_error( - &results_tx, - Status::invalid_argument("Git signing body data is required"), - ) - .await; - break; - }; - input_bytes = input_bytes.saturating_add(data.len() as u64); - let max_deferred_bytes = match selection.as_ref() { - Some(Selection::Sign { limits, .. }) => limits.deferred_bytes, - _ => 0, - }; - if input_bytes > max_deferred_bytes { - send_stream_error( - &results_tx, - Status::resource_exhausted( - "Git signing request exceeds deferred storage limit", - ), - ) - .await; - break; - } - let Some(file) = input.as_mut() else { - send_stream_error( - &results_tx, - Status::internal("Git signing temporary storage was lost"), - ) - .await; - break; - }; - if file.write_all(&data).await.is_err() { - send_stream_error( - &results_tx, - Status::resource_exhausted( - "Git signing temporary storage write failed", - ), - ) - .await; - break; - } - if results_tx - .send(Ok(body_take_ownership(body.sequence))) - .await - .is_err() - { - break; - } - next_input_sequence = next_input_sequence.saturating_add(1); - if body.end_of_stream { - final_input_sequence = Some(body.sequence); - let Some(Selection::Sign { - upstream_url, - request_id, - limits, - .. - }) = selection.as_ref() - else { - break; - }; - let upstream_url = upstream_url.clone(); - let request_id = request_id.clone(); - let limits = *limits; - if let Err(error) = finish_signing( - Arc::clone(&signer), - SigningRequest { - input: input - .take() - .expect("selected request has temporary storage"), - upstream_url, - request_id, - final_input_sequence: body.sequence, - limits, - }, - Arc::clone(&signing_slots), - signing_timeout, - &results_tx, - ) - .await - { - send_stream_error(&results_tx, error).await; - break; - } - } - } - Some(http_request_event::Event::Trailers(_)) - if final_input_sequence.is_some() => - { - if results_tx - .send(Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::TrailersResult( - HttpRequestTrailersResult::default(), - )), - })) - .await - .is_err() - { - break; - } - } - Some(http_request_event::Event::SessionEnd(_)) if selection.is_some() => break, - _ => { - send_stream_error( - &results_tx, - Status::failed_precondition( - "invalid Git signing request stream lifecycle", - ), - ) - .await; - break; - } - } - } - }); - Box::pin(ReceiverStream::new(results_rx)) - } -} - -enum Selection { - Skipped, - Sign { - upstream_url: String, - request_id: String, - limits: OwnedOutputLimits, - }, -} - -#[derive(Clone, Copy)] -struct OwnedOutputLimits { - deferred_bytes: u64, - unit_bytes: usize, -} - -struct SigningRequest { - input: tokio::fs::File, - upstream_url: String, - request_id: String, - final_input_sequence: u64, - limits: OwnedOutputLimits, -} - -#[tonic::async_trait] -impl SupervisorMiddleware for GitSigningMiddleware { - type EvaluateWebSocketSessionStream = WebSocketResponseStream; - - async fn describe(&self, request: Request<()>) -> Result, Status> { - self.auth.authenticate(&request, None)?; - Ok(Response::new(MiddlewareManifest { - name: MANIFEST_NAME.into(), - service_version: env!("CARGO_PKG_VERSION").into(), - bindings: vec![MiddlewareBinding { - operation: OPERATION as i32, - phase: PHASE as i32, - max_payload_bytes: MAX_UNIT_BYTES as u64, - request_timeout: Some(prost_types::Duration { - seconds: 30, - nanos: 0, - }), - }], - expected_audience: self.expected_audience.clone(), - })) - } - - async fn validate_config( - &self, - request: Request, - ) -> Result, Status> { - self.auth - .authenticate(&request, Some(ExtensionCallerKind::Gateway))?; - let request = request.into_inner(); - let unknown = request - .config - .as_ref() - .and_then(|config| config.fields.keys().next()) - .cloned(); - Ok(Response::new(match unknown { - None => ValidateConfigResponse { - valid: true, - reason: String::new(), - }, - Some(field) => ValidateConfigResponse { - valid: false, - reason: format!("unsupported config field '{field}'"), - }, - })) - } - - async fn evaluate_web_socket_session( - &self, - request: Request>, - ) -> Result, Status> { - self.auth - .authenticate(&request, Some(ExtensionCallerKind::Supervisor))?; - Err(Status::unimplemented( - "WebSocket middleware is not supported", - )) - } -} - -#[tonic::async_trait] -impl HttpRequestPreCredentials for GitSigningMiddleware { - type EvaluateStream = HttpRequestResultStream; - - async fn evaluate( - &self, - request: Request>, - ) -> Result, Status> { - self.auth - .authenticate(&request, Some(ExtensionCallerKind::Supervisor))?; - Ok(Response::new(self.request_stream(request.into_inner()))) - } -} - -fn select_request(preflight: &HttpRequestPreflight) -> Result, Status> { - if !is_receive_pack_request(preflight) { - return Ok(None); - } - let target = preflight - .target - .as_ref() - .ok_or_else(|| Status::invalid_argument("Git push request has no target"))?; - let max_output_unit_bytes = usize::try_from(preflight.max_payload_bytes) - .ok() - .filter(|limit| *limit > 0) - .map(|limit| limit.min(MAX_UNIT_BYTES)) - .ok_or_else(|| Status::failed_precondition("Git signing output unit limit is invalid"))?; - Ok(Some(Selection::Sign { - upstream_url: github_upstream_url(target)?, - request_id: preflight - .context - .as_ref() - .map(|context| context.request_id.clone()) - .unwrap_or_default(), - limits: OwnedOutputLimits { - deferred_bytes: preflight.max_deferred_bytes, - unit_bytes: max_output_unit_bytes, - }, - })) -} - -fn preflight_skip() -> HttpRequestEventResult { - HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Skip( - HttpRequestPreflightSkip {}, - )), - reason_code: "not_git_receive_pack".into(), - ..Default::default() - }, - )), - } -} - -fn preflight_owned() -> HttpRequestEventResult { - HttpRequestEventResult { - result: Some(http_request_event_result::Result::PreflightResult( - HttpRequestPreflightResult { - action: Some(http_request_preflight_result::Action::Inspect( - HttpRequestPreflightInspect { - body_mode: HttpRequestBodyMode::OwnedStreamBytes as i32, - header_mutations: Vec::new(), - }, - )), - reason_code: "git_receive_pack_selected".into(), - ..Default::default() - }, - )), - } -} - -fn body_take_ownership(sequence: u64) -> HttpRequestEventResult { - HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyResult( - HttpRequestBodyResult { - sequence, - action: Some(http_request_body_result::Action::TakeOwnership( - HttpRequestBodyTakeOwnership {}, - )), - ..Default::default() - }, - )), - } -} - -async fn finish_signing( - signer: Arc, - request: SigningRequest, - signing_slots: Arc, - signing_timeout: Duration, - sender: &tokio::sync::mpsc::Sender>, -) -> Result<(), Status> { - let SigningRequest { - mut input, - upstream_url, - request_id, - final_input_sequence, - limits, - } = request; - input - .flush() - .await - .map_err(|_| Status::internal("Git signing temporary storage flush failed"))?; - let input = input.into_std().await; - let log_upstream = upstream_url.clone(); - let log_request_id = request_id.clone(); - let _permit = signing_slots - .acquire_owned() - .await - .map_err(|_| Status::unavailable("Git signing service is shutting down"))?; - let cancelled = Arc::new(AtomicBool::new(false)); - let control = SignControl::new(Arc::clone(&cancelled), Instant::now() + signing_timeout); - let mut worker = tokio::task::spawn_blocking(move || { - signer.sign_receive_pack(input, Some(&upstream_url), &control) - }); - let signed = tokio::select! { - result = &mut worker => { - result.map_err(|_| Status::internal("Git signing worker failed"))? - } - () = sender.closed() => { - cancelled.store(true, Ordering::Release); - let _ = worker.await; - return Err(Status::cancelled("Git signing request was cancelled")); - } - () = tokio::time::sleep(signing_timeout) => { - cancelled.store(true, Ordering::Release); - let _ = worker.await; - return Err(Status::deadline_exceeded("Git signing deadline exceeded")); - } - } - .map_err(|error| { - let status = if error.is_cancelled() { - Status::cancelled("Git signing request was cancelled") - } else if error.is_timed_out() { - Status::deadline_exceeded("Git signing deadline exceeded") - } else { - Status::failed_precondition(error.public_message()) - }; - warn!( - request_id = log_request_id, - upstream = %log_upstream, - category = if error.is_cancelled() { "cancelled" } else if error.is_timed_out() { "timeout" } else { "invalid_push" }, - "outgoing Git push could not be signed" - ); - status - })?; - - if signed.body_len > limits.deferred_bytes { - return Err(Status::resource_exhausted( - "signed Git request exceeds deferred storage limit", - )); - } - - let signed_commits = signed.signed_commits; - let mut body = tokio::fs::File::from_std(signed.body); - let mut chunk = vec![0; limits.unit_bytes]; - let mut output_sequence = 0u64; - loop { - let read = body - .read(&mut chunk) - .await - .map_err(|_| Status::internal("signed Git temporary storage read failed"))?; - if read == 0 { - break; - } - output_sequence += 1; - sender - .send(Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyOutput( - HttpRequestBodyOutput { - sequence: output_sequence, - data: chunk[..read].to_vec(), - }, - )), - })) - .await - .map_err(|_| Status::cancelled("Git signing request was cancelled"))?; - } - sender - .send(Ok(HttpRequestEventResult { - result: Some(http_request_event_result::Result::BodyFinalize( - HttpRequestBodyFinalize { - through_input_sequence: final_input_sequence, - through_output_sequence: output_sequence, - reason_code: "git_commits_signed".into(), - findings: vec![Finding { - r#type: "git.commits_signed".into(), - label: "Git commits signed".into(), - count: signed_commits, - confidence: "high".into(), - severity: "informational".into(), - }], - metadata: HashMap::from([( - "signed_commit_count".into(), - signed_commits.to_string(), - )]), - ..Default::default() - }, - )), - })) - .await - .map_err(|_| Status::cancelled("Git signing request was cancelled"))?; - info!( - request_id, - upstream = %log_upstream, - signed_commits, - "signed outgoing Git push" - ); - Ok(()) -} - -async fn send_stream_error( - sender: &tokio::sync::mpsc::Sender>, - status: Status, -) { - let _ = sender.send(Err(status)).await; -} - -fn github_upstream_url( - target: &openshell_core::proto::HttpRequestTarget, -) -> Result { - if target.scheme != "https" || target.host != "github.com" || target.port != 443 { - return Err(Status::invalid_argument( - "prototype supports HTTPS pushes to github.com only", - )); - } - let repository_path = target - .path - .strip_suffix("/git-receive-pack") - .ok_or_else(|| Status::invalid_argument("invalid Git receive-pack path"))?; - let segments = repository_path - .strip_prefix('/') - .and_then(|path| path.strip_suffix(".git")) - .map(|path| path.split('/').collect::>()) - .ok_or_else(|| Status::invalid_argument("invalid GitHub repository path"))?; - if segments.len() != 2 - || segments.iter().any(|segment| { - segment.is_empty() - || !segment - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - }) - { - return Err(Status::invalid_argument("invalid GitHub repository path")); - } - Ok(format!("https://github.com{repository_path}")) -} - -fn is_receive_pack_request(request: &HttpRequestPreflight) -> bool { - let Some(target) = request.target.as_ref() else { - return false; - }; - target.method == "POST" - && target.path.ends_with("/git-receive-pack") - && request.headers.iter().any(|header| { - header.name.eq_ignore_ascii_case("content-type") - && header - .value - .split(';') - .next() - .is_some_and(|value| value.trim() == "application/x-git-receive-pack-request") - }) -} - -#[tokio::main] -async fn main() -> Result<(), Box> { - tracing_subscriber::fmt() - .with_env_filter(EnvFilter::from_default_env()) - .init(); - let cli = Cli::parse(); - if cli.signing_timeout_seconds == 0 { - return Err("signing timeout must be positive".into()); - } - let tls_cert = std::fs::read(&cli.tls_cert)?; - let tls_key = std::fs::read(&cli.tls_key)?; - let extension_public_key = std::fs::read(&cli.extension_public_key)?; - let auth = ExtensionAuth::new( - &extension_public_key, - &cli.expected_gateway_id, - cli.audience, - ) - .map_err(|error| format!("invalid extension authentication configuration: {error}"))?; - let middleware = GitSigningMiddleware::new( - cli.signing_key, - cli.max_concurrent_signings, - Duration::from_secs(cli.signing_timeout_seconds), - auth, - ) - .map_err(|error| format!("invalid signing configuration: {error}"))?; - info!(bind = %cli.bind, "starting Git commit signing middleware"); - Server::builder() - .tls_config(ServerTlsConfig::new().identity(Identity::from_pem(tls_cert, tls_key)))? - .add_service(SupervisorMiddlewareServer::new(middleware.clone())) - .add_service(HttpRequestPreCredentialsServer::new(middleware)) - .serve(cli.bind) - .await?; - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use jsonwebtoken::{EncodingKey, Header, encode}; - use openshell_core::proto::{ - HttpHeader, HttpRequestBodyUnit, HttpRequestTarget, MiddlewareSessionEnd, - http_request_event_result, - }; - - const TEST_PRIVATE_KEY: &[u8] = br#"-----BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIAR9CeOXmiSU6YscHZWTYbW7DUc5uhdO3/OeXZg3j1+u ------END PRIVATE KEY----- -"#; - const TEST_PUBLIC_KEY: &[u8] = br#"-----BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEAuEbM0q6xP8hFxwY5kd/fD/mwr3ZpA/T7zhx6TN0DKMM= ------END PUBLIC KEY----- -"#; - - fn authenticated_request(claims: ExtensionJwtClaims) -> Request<()> { - let mut header = Header::new(Algorithm::EdDSA); - header.typ = Some(EXTENSION_JWT_TYP.into()); - header.kid = Some("test-key".into()); - let token = encode( - &header, - &claims, - &EncodingKey::from_ed_pem(TEST_PRIVATE_KEY).unwrap(), - ) - .unwrap(); - let mut request = Request::new(()); - request - .metadata_mut() - .insert("authorization", format!("Bearer {token}").parse().unwrap()); - request - } - - fn test_claims(caller_kind: ExtensionCallerKind) -> ExtensionJwtClaims { - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_secs() as i64; - let (sub, sandbox_id) = match caller_kind { - ExtensionCallerKind::Gateway => ("openshell-gateway:test".into(), None), - ExtensionCallerKind::Supervisor => ( - "spiffe://openshell/sandbox/sandbox-test".into(), - Some("sandbox-test".into()), - ), - }; - ExtensionJwtClaims { - iss: "openshell-gateway:test".into(), - aud: "urn:openshell:extension:test:git-signing".into(), - sub, - iat: now, - exp: now + 300, - jti: "unique-test-token".into(), - caller_kind, - sandbox_id, - } - } - - #[test] - fn extension_auth_enforces_rpc_caller_kind() { - let auth = ExtensionAuth::new( - TEST_PUBLIC_KEY, - "test", - "urn:openshell:extension:test:git-signing".into(), - ) - .unwrap(); - - assert!( - auth.authenticate( - &authenticated_request(test_claims(ExtensionCallerKind::Gateway)), - Some(ExtensionCallerKind::Gateway), - ) - .is_ok() - ); - assert_eq!( - auth.authenticate( - &authenticated_request(test_claims(ExtensionCallerKind::Gateway)), - Some(ExtensionCallerKind::Supervisor), - ) - .unwrap_err() - .code(), - tonic::Code::PermissionDenied - ); - assert!( - auth.authenticate( - &authenticated_request(test_claims(ExtensionCallerKind::Supervisor)), - Some(ExtensionCallerKind::Supervisor), - ) - .is_ok() - ); - } - - #[test] - fn extension_auth_rejects_mismatched_supervisor_identity() { - let auth = ExtensionAuth::new( - TEST_PUBLIC_KEY, - "test", - "urn:openshell:extension:test:git-signing".into(), - ) - .unwrap(); - let mut claims = test_claims(ExtensionCallerKind::Supervisor); - claims.sub = "spiffe://openshell/sandbox/another-sandbox".into(); - - assert_eq!( - auth.authenticate( - &authenticated_request(claims), - Some(ExtensionCallerKind::Supervisor), - ) - .unwrap_err() - .code(), - tonic::Code::PermissionDenied - ); - } - - fn receive_pack_preflight() -> HttpRequestPreflight { - HttpRequestPreflight { - target: Some(HttpRequestTarget { - scheme: "https".into(), - host: "github.com".into(), - port: 443, - method: "POST".into(), - path: "/NVIDIA/OpenShell.git/git-receive-pack".into(), - ..Default::default() - }), - headers: vec![HttpHeader { - name: "content-type".into(), - value: "application/x-git-receive-pack-request".into(), - }], - permitted_body_modes: vec![HttpRequestBodyMode::OwnedStreamBytes as i32], - max_payload_bytes: MAX_UNIT_BYTES as u64, - max_deferred_bytes: 16 * 1024 * 1024, - ..Default::default() - } - } - - #[test] - fn recognizes_git_receive_pack_only() { - let request = receive_pack_preflight(); - assert!(is_receive_pack_request(&request)); - - let mut fetch = request; - fetch.target.as_mut().unwrap().path = "/NVIDIA/OpenShell.git/git-upload-pack".into(); - assert!(!is_receive_pack_request(&fetch)); - } - - #[test] - fn derives_a_bounded_github_upstream_url() { - let target = receive_pack_preflight().target.unwrap(); - assert_eq!( - github_upstream_url(&target).unwrap(), - "https://github.com/NVIDIA/OpenShell.git" - ); - - let mut traversal = target; - traversal.path = "/NVIDIA/../OpenShell.git/git-receive-pack".into(); - assert!(github_upstream_url(&traversal).is_err()); - } - - #[tokio::test] - async fn owned_stream_accepts_more_than_former_unary_limit() { - let key = tempfile::NamedTempFile::new().unwrap(); - let middleware = GitSigningMiddleware::new_for_test(key.path().to_path_buf()).unwrap(); - let mut events = vec![Ok(HttpRequestEvent { - event: Some(http_request_event::Event::Preflight( - receive_pack_preflight(), - )), - })]; - for sequence in 1..=65u64 { - events.push(Ok(HttpRequestEvent { - event: Some(http_request_event::Event::Body(HttpRequestBodyUnit { - sequence, - payload: Some(http_request_body_unit::Payload::Data(vec![ - b'x'; - MAX_UNIT_BYTES - ])), - end_of_stream: false, - })), - })); - } - events.push(Ok(HttpRequestEvent { - event: Some(http_request_event::Event::SessionEnd( - MiddlewareSessionEnd::default(), - )), - })); - - let mut results = middleware.request_stream(tokio_stream::iter(events)); - assert!(matches!( - results.next().await.unwrap().unwrap().result, - Some(http_request_event_result::Result::PreflightResult(_)) - )); - for sequence in 1..=65u64 { - let result = results.next().await.unwrap().unwrap(); - let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { - panic!("expected body ownership result"); - }; - assert_eq!(result.sequence, sequence); - assert!(matches!( - result.action, - Some(http_request_body_result::Action::TakeOwnership(_)) - )); - } - assert!(results.next().await.is_none()); - } -} diff --git a/examples/supervisor-middleware-git-signing/src/signer.rs b/examples/supervisor-middleware-git-signing/src/signer.rs deleted file mode 100644 index e36f92aa97..0000000000 --- a/examples/supervisor-middleware-git-signing/src/signer.rs +++ /dev/null @@ -1,1180 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -use std::collections::{HashMap, HashSet}; -use std::fmt; -use std::fs::{self, File}; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::{Path, PathBuf}; -use std::process::{Command, Stdio}; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::{Duration, Instant}; - -use tempfile::TempDir; - -const SHA1_HEX_LEN: usize = 40; -const ZERO_SHA1: &str = "0000000000000000000000000000000000000000"; -const MAX_RECEIVE_PACK_PREFIX_BYTES: usize = 1024 * 1024; -const MAX_CAPTURE_BYTES: usize = 64 * 1024 * 1024; -const CHILD_POLL_INTERVAL: Duration = Duration::from_millis(10); - -pub struct GitSigner { - signing_key: PathBuf, - upstream_override: Option, -} - -pub struct SignedPush { - pub body: File, - pub body_len: u64, - pub signed_commits: u32, -} - -#[derive(Clone)] -pub struct SignControl { - cancelled: Arc, - deadline: Instant, -} - -impl SignControl { - pub fn new(cancelled: Arc, deadline: Instant) -> Self { - Self { - cancelled, - deadline, - } - } - - fn check(&self) -> Result<(), SignError> { - if self.cancelled.load(Ordering::Acquire) { - return Err(SignError::cancelled()); - } - if Instant::now() >= self.deadline { - return Err(SignError::timed_out()); - } - Ok(()) - } -} - -#[derive(Debug)] -pub struct SignError { - message: String, - kind: SignErrorKind, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum SignErrorKind { - InvalidRequest, - Cancelled, - TimedOut, -} - -impl SignError { - fn new(message: impl Into) -> Self { - Self { - message: message.into(), - kind: SignErrorKind::InvalidRequest, - } - } - - fn cancelled() -> Self { - Self { - message: "Git signing was cancelled".into(), - kind: SignErrorKind::Cancelled, - } - } - - fn timed_out() -> Self { - Self { - message: "Git signing exceeded its deadline".into(), - kind: SignErrorKind::TimedOut, - } - } - - pub fn is_cancelled(&self) -> bool { - self.kind == SignErrorKind::Cancelled - } - - pub fn is_timed_out(&self) -> bool { - self.kind == SignErrorKind::TimedOut - } - - pub fn public_message(&self) -> &'static str { - "outgoing Git push could not be signed" - } -} - -impl fmt::Display for SignError { - fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { - formatter.write_str(&self.message) - } -} - -impl std::error::Error for SignError {} - -impl GitSigner { - pub fn new(signing_key: PathBuf) -> Result { - if !signing_key.is_file() { - return Err("the signing key must name a readable file".into()); - } - Ok(Self { - signing_key, - upstream_override: None, - }) - } - - #[cfg(test)] - fn new_with_upstream_override( - signing_key: PathBuf, - upstream_override: String, - ) -> Result { - let mut signer = Self::new(signing_key)?; - signer.upstream_override = Some(upstream_override); - Ok(signer) - } - - pub fn sign_receive_pack( - &self, - mut body: File, - upstream_url: Option<&str>, - control: &SignControl, - ) -> Result { - control.check()?; - let mut parsed = ReceivePackRequest::parse_file(&mut body)?; - if parsed.updates.is_empty() { - return Err(SignError::new( - "receive-pack request contains no ref updates", - )); - } - - let workspace = TempDir::new().map_err(|error| SignError::new(error.to_string()))?; - let repo = workspace.path().join("objects.git"); - run_git_controlled( - None, - &["init", "--bare", repo_string(&repo)?], - None, - control, - )?; - if let Some(upstream_url) = self.upstream_override.as_deref().or(upstream_url) { - hydrate_upstream(&repo, upstream_url, &parsed.updates, control)?; - } - body.seek(SeekFrom::Start(parsed.pack_offset)) - .map_err(|error| SignError::new(error.to_string()))?; - run_git_file_input( - Some(&repo), - &["index-pack", "--stdin", "--fix-thin"], - body, - control, - )?; - - let mut commit_ids = HashSet::new(); - for update in &parsed.updates { - if update.new_oid == ZERO_SHA1 { - continue; - } - control.check()?; - let output = run_git_controlled( - Some(&repo), - &["rev-list", &update.new_oid, "--not", "--all"], - None, - control, - )?; - let output = String::from_utf8(output) - .map_err(|_| SignError::new("git returned a non-UTF-8 commit list"))?; - commit_ids.extend(output.lines().map(str::to_string)); - } - let mut rewriter = CommitRewriter { - repo: &repo, - signing_key: &self.signing_key, - commit_ids, - rewritten: HashMap::new(), - active: HashSet::new(), - signed_count: 0, - workspace: workspace.path(), - control, - }; - - for update in &mut parsed.updates { - if update.new_oid == ZERO_SHA1 { - continue; - } - if !update.ref_name.starts_with("refs/heads/") { - return Err(SignError::new( - "prototype supports direct branch updates only", - )); - } - if !rewriter.commit_ids.contains(&update.new_oid) { - return Err(SignError::new( - "branch tip commit is not self-contained in the push pack", - )); - } - update.new_oid = rewriter.rewrite(&update.new_oid)?; - } - - if rewriter.signed_count == 0 { - return Err(SignError::new( - "receive-pack request contains no commits to sign", - )); - } - - let base_oids = list_ref_oids(&repo, "refs/middleware", control)?; - let mut revisions = parsed - .updates - .iter() - .filter(|update| update.new_oid != ZERO_SHA1) - .map(|update| update.new_oid.clone()) - .collect::>(); - revisions.extend(base_oids.into_iter().map(|oid| format!("^{oid}"))); - let revision_input = revisions.join("\n") + "\n"; - let mut prefix = parsed.prefix; - for update in &parsed.updates { - prefix[update.new_oid_range.clone()].copy_from_slice(update.new_oid.as_bytes()); - } - let mut result = tempfile::tempfile().map_err(|error| SignError::new(error.to_string()))?; - result - .write_all(&prefix) - .map_err(|error| SignError::new(error.to_string()))?; - run_git_to_file( - Some(&repo), - &["pack-objects", "--stdout", "--revs", "--thin"], - Some(revision_input.as_bytes()), - &mut result, - control, - )?; - let body_len = result - .metadata() - .map_err(|error| SignError::new(error.to_string()))? - .len(); - result - .seek(SeekFrom::Start(0)) - .map_err(|error| SignError::new(error.to_string()))?; - Ok(SignedPush { - body: result, - body_len, - signed_commits: rewriter.signed_count, - }) - } -} - -struct ReceivePackRequest { - prefix: Vec, - pack_offset: u64, - updates: Vec, -} - -struct RefUpdate { - old_oid: String, - new_oid: String, - ref_name: String, - new_oid_range: std::ops::Range, -} - -impl ReceivePackRequest { - fn parse_file(body: &mut File) -> Result { - body.seek(SeekFrom::Start(0)) - .map_err(|error| SignError::new(error.to_string()))?; - let mut prefix = Vec::new(); - let mut updates = Vec::new(); - let mut offset = 0; - let mut command_section = true; - let pack_offset = loop { - let mut marker = [0u8; 4]; - body.read_exact(&mut marker) - .map_err(|_| SignError::new("receive-pack request has no packfile"))?; - if !command_section && marker == *b"PACK" { - body.seek(SeekFrom::Current(-4)) - .map_err(|error| SignError::new(error.to_string()))?; - break offset as u64; - } - let length = parse_pkt_length(&marker)?; - prefix.extend_from_slice(&marker); - if prefix.len() > MAX_RECEIVE_PACK_PREFIX_BYTES { - return Err(SignError::new("receive-pack command prefix is too large")); - } - if length == 0 { - offset += 4; - command_section = false; - continue; - } - if length < 4 { - return Err(SignError::new("invalid receive-pack pkt-line length")); - } - let payload_start = offset + 4; - let mut payload = vec![0; length - 4]; - body.read_exact(&mut payload) - .map_err(|_| SignError::new("truncated receive-pack pkt-line"))?; - prefix.extend_from_slice(&payload); - if prefix.len() > MAX_RECEIVE_PACK_PREFIX_BYTES { - return Err(SignError::new("receive-pack command prefix is too large")); - } - if !command_section { - // Push options, when negotiated, are pkt-lines between the - // command flush and the packfile. Preserve them unchanged. - offset += length; - continue; - } - let command = payload.split(|byte| *byte == 0).next().unwrap_or(&payload); - let command = command.strip_suffix(b"\n").unwrap_or(command); - let first_space = command - .iter() - .position(|byte| *byte == b' ') - .ok_or_else(|| SignError::new("invalid receive-pack ref command"))?; - let second_space = command[first_space + 1..] - .iter() - .position(|byte| *byte == b' ') - .map(|index| first_space + 1 + index) - .ok_or_else(|| SignError::new("invalid receive-pack ref command"))?; - if first_space != SHA1_HEX_LEN || second_space - first_space - 1 != SHA1_HEX_LEN { - return Err(SignError::new( - "prototype supports SHA-1 Git repositories only", - )); - } - let new_start = payload_start + first_space + 1; - let old_oid = ascii_oid(&prefix[payload_start..payload_start + SHA1_HEX_LEN])?; - let new_oid = ascii_oid(&prefix[new_start..new_start + SHA1_HEX_LEN])?; - let ref_name = std::str::from_utf8(&command[second_space + 1..]) - .map_err(|_| SignError::new("receive-pack ref name is not UTF-8"))? - .to_string(); - updates.push(RefUpdate { - old_oid, - new_oid, - ref_name, - new_oid_range: new_start..new_start + SHA1_HEX_LEN, - }); - offset += length; - }; - - Ok(Self { - prefix, - pack_offset, - updates, - }) - } -} - -fn hydrate_upstream( - repo: &Path, - upstream_url: &str, - updates: &[RefUpdate], - control: &SignControl, -) -> Result<(), SignError> { - run_git_controlled( - Some(repo), - &[ - "-c", - "credential.interactive=false", - "fetch", - "--no-tags", - "--depth=1", - upstream_url, - "+HEAD:refs/middleware/upstream-head", - ], - None, - control, - )?; - for (index, old_oid) in updates - .iter() - .map(|update| update.old_oid.as_str()) - .filter(|oid| *oid != ZERO_SHA1) - .collect::>() - .into_iter() - .enumerate() - { - let destination = format!("+{old_oid}:refs/middleware/base-{index}"); - run_git_controlled( - Some(repo), - &[ - "-c", - "credential.interactive=false", - "fetch", - "--no-tags", - "--depth=1", - upstream_url, - &destination, - ], - None, - control, - )?; - } - Ok(()) -} - -fn list_ref_oids( - repo: &Path, - prefix: &str, - control: &SignControl, -) -> Result, SignError> { - let output = run_git_controlled( - Some(repo), - &["for-each-ref", "--format=%(objectname)", prefix], - None, - control, - )?; - let output = String::from_utf8(output) - .map_err(|_| SignError::new("git returned a non-UTF-8 ref list"))?; - Ok(output.lines().map(str::to_string).collect()) -} - -fn parse_pkt_length(bytes: &[u8]) -> Result { - let text = - std::str::from_utf8(bytes).map_err(|_| SignError::new("pkt-line length is not ASCII"))?; - usize::from_str_radix(text, 16).map_err(|_| SignError::new("invalid pkt-line length")) -} - -fn ascii_oid(bytes: &[u8]) -> Result { - if bytes.len() != SHA1_HEX_LEN || !bytes.iter().all(u8::is_ascii_hexdigit) { - return Err(SignError::new("invalid SHA-1 object id")); - } - String::from_utf8(bytes.to_vec()).map_err(|_| SignError::new("invalid SHA-1 object id")) -} - -struct CommitRewriter<'a> { - repo: &'a Path, - signing_key: &'a Path, - commit_ids: HashSet, - rewritten: HashMap, - active: HashSet, - signed_count: u32, - workspace: &'a Path, - control: &'a SignControl, -} - -impl CommitRewriter<'_> { - fn rewrite(&mut self, oid: &str) -> Result { - self.control.check()?; - if let Some(rewritten) = self.rewritten.get(oid) { - return Ok(rewritten.clone()); - } - if !self.commit_ids.contains(oid) { - return Ok(oid.to_string()); - } - if !self.active.insert(oid.to_string()) { - return Err(SignError::new("commit graph contains a cycle")); - } - - let raw = run_git_controlled( - Some(self.repo), - &["cat-file", "commit", oid], - None, - self.control, - )?; - let parsed = ParsedCommit::parse(&raw)?; - let mut parents = Vec::with_capacity(parsed.parents.len()); - for parent in &parsed.parents { - parents.push(self.rewrite(parent)?); - } - let unsigned = parsed.unsigned_with_parents(&parents); - let signature = self.sign_payload(oid, &unsigned)?; - let signed = insert_signature(&unsigned, &signature)?; - let new_oid = String::from_utf8(run_git_controlled( - Some(self.repo), - &["hash-object", "-t", "commit", "-w", "--stdin"], - Some(&signed), - self.control, - )?) - .map_err(|_| SignError::new("git returned a non-UTF-8 object id"))? - .trim() - .to_string(); - - self.active.remove(oid); - self.rewritten.insert(oid.to_string(), new_oid.clone()); - self.signed_count = self.signed_count.saturating_add(1); - Ok(new_oid) - } - - fn sign_payload(&self, oid: &str, payload: &[u8]) -> Result, SignError> { - let payload_path = self.workspace.join(format!("commit-{oid}")); - fs::write(&payload_path, payload).map_err(|error| SignError::new(error.to_string()))?; - let mut command = Command::new("ssh-keygen"); - command - .args(["-Y", "sign", "-n", "git", "-f"]) - .arg(self.signing_key) - .arg(&payload_path) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::piped()); - run_command_controlled(command, None, "ssh-keygen", self.control)?; - // ssh-keygen appends `.sig` to the input path. - let signature_path = PathBuf::from(format!("{}.sig", payload_path.display())); - fs::read(signature_path).map_err(|error| SignError::new(error.to_string())) - } -} - -struct ParsedCommit { - headers: Vec
, - parents: Vec, - message: Vec, -} - -struct Header { - name: Vec, - block: Vec, -} - -impl ParsedCommit { - fn parse(raw: &[u8]) -> Result { - let separator = raw - .windows(2) - .position(|window| window == b"\n\n") - .ok_or_else(|| SignError::new("commit object has no header separator"))?; - let header_bytes = &raw[..separator]; - let mut headers: Vec
= Vec::new(); - for line in header_bytes.split(|byte| *byte == b'\n') { - if line.starts_with(b" ") { - let previous = headers - .last_mut() - .ok_or_else(|| SignError::new("commit starts with a continuation header"))?; - previous.block.push(b'\n'); - previous.block.extend_from_slice(line); - continue; - } - let name_end = line - .iter() - .position(|byte| *byte == b' ') - .ok_or_else(|| SignError::new("invalid commit header"))?; - headers.push(Header { - name: line[..name_end].to_vec(), - block: line.to_vec(), - }); - } - let parents = headers - .iter() - .filter(|header| header.name == b"parent") - .map(|header| ascii_oid(&header.block[b"parent ".len()..])) - .collect::, _>>()?; - if !headers.iter().any(|header| header.name == b"tree") { - return Err(SignError::new("commit object has no tree")); - } - Ok(Self { - headers, - parents, - message: raw[separator + 2..].to_vec(), - }) - } - - fn unsigned_with_parents(&self, parents: &[String]) -> Vec { - let mut result = Vec::new(); - let mut parent_index = 0; - for header in &self.headers { - if header.name == b"gpgsig" || header.name == b"gpgsig-sha256" { - continue; - } - if header.name == b"parent" { - result.extend_from_slice(b"parent "); - result.extend_from_slice(parents[parent_index].as_bytes()); - parent_index += 1; - } else { - result.extend_from_slice(&header.block); - } - result.push(b'\n'); - } - result.push(b'\n'); - result.extend_from_slice(&self.message); - result - } -} - -fn insert_signature(unsigned: &[u8], signature: &[u8]) -> Result, SignError> { - let separator = unsigned - .windows(2) - .position(|window| window == b"\n\n") - .ok_or_else(|| SignError::new("unsigned commit has no header separator"))?; - let signature = signature.strip_suffix(b"\n").unwrap_or(signature); - let mut result = Vec::with_capacity(unsigned.len() + signature.len() + 16); - result.extend_from_slice(&unsigned[..separator + 1]); - for (index, line) in signature.split(|byte| *byte == b'\n').enumerate() { - result.extend_from_slice(if index == 0 { b"gpgsig " } else { b" " }); - result.extend_from_slice(line); - result.push(b'\n'); - } - result.extend_from_slice(&unsigned[separator + 1..]); - Ok(result) -} - -fn repo_string(path: &Path) -> Result<&str, SignError> { - path.to_str() - .ok_or_else(|| SignError::new("temporary repository path is not UTF-8")) -} - -fn run_git_controlled( - repo: Option<&Path>, - args: &[&str], - input: Option<&[u8]>, - control: &SignControl, -) -> Result, SignError> { - let mut command = Command::new("git"); - if let Some(repo) = repo { - command.arg("-C").arg(repo); - } - command - .args(args) - .stdin(if input.is_some() { - Stdio::piped() - } else { - Stdio::null() - }) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - run_command_controlled( - command, - input, - args.first().copied().unwrap_or("command"), - control, - ) -} - -fn run_git_file_input( - repo: Option<&Path>, - args: &[&str], - input: File, - control: &SignControl, -) -> Result, SignError> { - let mut command = Command::new("git"); - if let Some(repo) = repo { - command.arg("-C").arg(repo); - } - command - .args(args) - .stdin(Stdio::from(input)) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - run_command_controlled( - command, - None, - args.first().copied().unwrap_or("command"), - control, - ) -} - -fn run_git_to_file( - repo: Option<&Path>, - args: &[&str], - input: Option<&[u8]>, - output: &mut File, - control: &SignControl, -) -> Result<(), SignError> { - output - .seek(SeekFrom::End(0)) - .map_err(|error| SignError::new(error.to_string()))?; - let output_handle = output - .try_clone() - .map_err(|error| SignError::new(error.to_string()))?; - let mut command = Command::new("git"); - if let Some(repo) = repo { - command.arg("-C").arg(repo); - } - command - .args(args) - .stdin(if input.is_some() { - Stdio::piped() - } else { - Stdio::null() - }) - .stdout(Stdio::from(output_handle)) - .stderr(Stdio::piped()); - run_command_controlled( - command, - input, - args.first().copied().unwrap_or("command"), - control, - )?; - Ok(()) -} - -fn run_command_controlled( - mut command: Command, - input: Option<&[u8]>, - command_name: &str, - control: &SignControl, -) -> Result, SignError> { - control.check()?; - let mut child = command - .spawn() - .map_err(|error| SignError::new(format!("could not run {command_name}: {error}")))?; - let stdout = child.stdout.take().map(spawn_pipe_drain); - let stderr = child.stderr.take().map(spawn_pipe_drain); - if let Some(input) = input { - let write_result = child - .stdin - .take() - .ok_or_else(|| SignError::new(format!("{command_name} stdin was unavailable")))? - .write_all(input); - if let Err(error) = write_result { - let _ = child.kill(); - let _ = child.wait(); - return Err(SignError::new(error.to_string())); - } - } - drop(child.stdin.take()); - - let status = loop { - if let Err(error) = control.check() { - let _ = child.kill(); - let _ = child.wait(); - join_pipe(stdout)?; - join_pipe(stderr)?; - return Err(error); - } - match child - .try_wait() - .map_err(|error| SignError::new(error.to_string()))? - { - Some(status) => break status, - None => std::thread::sleep(CHILD_POLL_INTERVAL), - } - }; - let stdout = join_pipe(stdout)?; - let stderr = join_pipe(stderr)?; - if !status.success() { - let stderr = String::from_utf8_lossy(&stderr); - return Err(SignError::new(format!( - "{command_name} failed: {}", - stderr.trim() - ))); - } - Ok(stdout) -} - -type PipeDrain = std::thread::JoinHandle>>; - -fn spawn_pipe_drain(mut pipe: R) -> PipeDrain -where - R: Read + Send + 'static, -{ - std::thread::spawn(move || { - let mut captured = Vec::new(); - let mut buffer = [0u8; 8192]; - loop { - let read = pipe.read(&mut buffer)?; - if read == 0 { - return Ok(captured); - } - if captured.len() < MAX_CAPTURE_BYTES { - let keep = read.min(MAX_CAPTURE_BYTES - captured.len()); - captured.extend_from_slice(&buffer[..keep]); - } - } - }) -} - -fn join_pipe(drain: Option) -> Result, SignError> { - drain.map_or_else( - || Ok(Vec::new()), - |drain| { - drain - .join() - .map_err(|_| SignError::new("subprocess output worker failed"))? - .map_err(|error| SignError::new(error.to_string())) - }, - ) -} - -#[cfg(test)] -fn run_git(repo: Option<&Path>, args: &[&str], input: Option<&[u8]>) -> Result, SignError> { - let control = SignControl::new( - Arc::new(AtomicBool::new(false)), - Instant::now() + Duration::from_secs(300), - ); - run_git_controlled(repo, args, input, &control) -} - -#[cfg(test)] -mod tests { - use super::*; - use openshell_core::proto::{ - HttpHeader, HttpRequestBodyUnit, HttpRequestEvent, HttpRequestPreflight, HttpRequestTarget, - HttpRequestTrailers, MiddlewareSessionEnd, RequestContext, http_request_body_result, - http_request_body_unit, http_request_event, http_request_event_result, - }; - use tokio_stream::StreamExt as _; - - #[cfg(unix)] - #[test] - fn cancellation_terminates_an_active_subprocess() { - let cancelled = Arc::new(AtomicBool::new(false)); - let control = SignControl::new( - Arc::clone(&cancelled), - Instant::now() + Duration::from_secs(30), - ); - let started = Instant::now(); - let worker = std::thread::spawn(move || { - let mut command = Command::new("sleep"); - command - .arg("30") - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - run_command_controlled(command, None, "sleep", &control) - }); - - std::thread::sleep(Duration::from_millis(50)); - cancelled.store(true, Ordering::Release); - let error = worker - .join() - .expect("subprocess worker must not panic") - .expect_err("cancellation must terminate the subprocess"); - - assert!(error.is_cancelled()); - assert!(started.elapsed() < Duration::from_secs(2)); - } - - #[tokio::test] - async fn signs_every_commit_and_rewrites_the_branch_tip() { - let fixture = TempDir::new().unwrap(); - let source = fixture.path().join("source.git"); - run_git( - None, - &["init", "--bare", repo_string(&source).unwrap()], - None, - ) - .unwrap(); - let large_blob = pseudo_random_bytes(5 * 1024 * 1024); - let blob = object(&source, "blob", &large_blob); - let tree_line = format!("100644 blob {blob}\tREADME.md\n"); - let tree = String::from_utf8( - run_git(Some(&source), &["mktree"], Some(tree_line.as_bytes())).unwrap(), - ) - .unwrap() - .trim() - .to_string(); - let first = unsigned_commit(&source, &tree, None, "first"); - let second = unsigned_commit(&source, &tree, Some(&first), "second"); - let upstream_head = unsigned_commit(&source, &tree, None, "upstream"); - run_git( - Some(&source), - &["update-ref", "refs/heads/main", &upstream_head], - None, - ) - .unwrap(); - run_git( - Some(&source), - &["symbolic-ref", "HEAD", "refs/heads/main"], - None, - ) - .unwrap(); - let objects = format!("{blob}\n{tree}\n{first}\n{second}\n"); - let pack = run_git( - Some(&source), - &["pack-objects", "--stdout"], - Some(objects.as_bytes()), - ) - .unwrap(); - let body = receive_pack_body(&second, &pack); - assert!(body.len() > 4 * 1024 * 1024); - - let key = fixture.path().join("signing-key"); - let status = Command::new("ssh-keygen") - .args(["-q", "-t", "ed25519", "-N", "", "-f"]) - .arg(&key) - .status() - .unwrap(); - assert!(status.success()); - let signed = sign_through_owned_stream(&key, &source, &body).await; - assert_eq!(signed.signed_commits, 2); - - let mut signed_file = file_with_bytes(&signed.body); - let parsed = ReceivePackRequest::parse_file(&mut signed_file).unwrap(); - let new_tip = parsed.updates[0].new_oid.clone(); - assert_ne!(new_tip, second); - let verify = fixture.path().join("verify.git"); - run_git( - None, - &["init", "--bare", repo_string(&verify).unwrap()], - None, - ) - .unwrap(); - run_git( - None, - &[ - "receive-pack", - "--stateless-rpc", - repo_string(&verify).unwrap(), - ], - Some(&signed.body), - ) - .unwrap(); - let received_tip = String::from_utf8( - run_git(Some(&verify), &["rev-parse", "refs/heads/main"], None).unwrap(), - ) - .unwrap(); - assert_eq!(received_tip.trim(), new_tip); - let tip = run_git(Some(&verify), &["cat-file", "commit", &new_tip], None).unwrap(); - assert!(tip.windows(7).any(|window| window == b"gpgsig ")); - let tip = ParsedCommit::parse(&tip).unwrap(); - assert_eq!(tip.parents.len(), 1); - assert_ne!(tip.parents[0], first); - let parent = run_git( - Some(&verify), - &["cat-file", "commit", &tip.parents[0]], - None, - ) - .unwrap(); - assert!(parent.windows(7).any(|window| window == b"gpgsig ")); - - let allowed = fixture.path().join("allowed-signers"); - let public_key = fs::read_to_string(key.with_extension("pub")).unwrap(); - fs::write(&allowed, format!("agent@example.com {}", public_key.trim())).unwrap(); - verify_commit(&verify, &allowed, &new_tip); - verify_commit(&verify, &allowed, &tip.parents[0]); - } - - struct CollectedSignedPush { - body: Vec, - signed_commits: u32, - } - - async fn sign_through_owned_stream( - key: &Path, - upstream: &Path, - body: &[u8], - ) -> CollectedSignedPush { - let signer = GitSigner::new_with_upstream_override( - key.to_path_buf(), - repo_string(upstream).unwrap().to_string(), - ) - .unwrap(); - let mut middleware = crate::GitSigningMiddleware::new_for_test(key.to_path_buf()).unwrap(); - middleware.signer = std::sync::Arc::new(signer); - let mut events = vec![Ok(HttpRequestEvent { - event: Some(http_request_event::Event::Preflight(HttpRequestPreflight { - context: Some(RequestContext { - request_id: "large-push".into(), - ..Default::default() - }), - target: Some(HttpRequestTarget { - scheme: "https".into(), - host: "github.com".into(), - port: 443, - method: "POST".into(), - path: "/NVIDIA/OpenShell.git/git-receive-pack".into(), - ..Default::default() - }), - headers: vec![HttpHeader { - name: "content-type".into(), - value: "application/x-git-receive-pack-request".into(), - }], - permitted_body_modes: vec![ - openshell_core::proto::HttpRequestBodyMode::OwnedStreamBytes as i32, - ], - max_payload_bytes: crate::MAX_UNIT_BYTES as u64, - max_deferred_bytes: 16 * 1024 * 1024, - ..Default::default() - })), - })]; - let mut final_input_sequence = 1u64; - for (index, chunk) in body.chunks(crate::MAX_UNIT_BYTES).enumerate() { - final_input_sequence = index as u64 + 1; - events.push(Ok(HttpRequestEvent { - event: Some(http_request_event::Event::Body(HttpRequestBodyUnit { - sequence: final_input_sequence, - payload: Some(http_request_body_unit::Payload::Data(chunk.to_vec())), - end_of_stream: false, - })), - })); - } - final_input_sequence += 1; - events.push(Ok(HttpRequestEvent { - event: Some(http_request_event::Event::Body(HttpRequestBodyUnit { - sequence: final_input_sequence, - payload: Some(http_request_body_unit::Payload::Data(Vec::new())), - end_of_stream: true, - })), - })); - events.push(Ok(HttpRequestEvent { - event: Some(http_request_event::Event::Trailers( - HttpRequestTrailers::default(), - )), - })); - events.push(Ok(HttpRequestEvent { - event: Some(http_request_event::Event::SessionEnd( - MiddlewareSessionEnd::default(), - )), - })); - - let mut results = middleware.request_stream(tokio_stream::iter(events)); - assert!(matches!( - results.next().await.unwrap().unwrap().result, - Some(http_request_event_result::Result::PreflightResult(_)) - )); - for sequence in 1..=final_input_sequence { - let result = results.next().await.unwrap().unwrap(); - let Some(http_request_event_result::Result::BodyResult(result)) = result.result else { - panic!("expected body ownership result"); - }; - assert_eq!(result.sequence, sequence); - assert!(matches!( - result.action, - Some(http_request_body_result::Action::TakeOwnership(_)) - )); - } - - let mut output = Vec::new(); - let mut next_output_sequence = 1u64; - let signed_commits = loop { - let result = results.next().await.unwrap().unwrap(); - match result.result { - Some(http_request_event_result::Result::BodyOutput(unit)) => { - assert_eq!(unit.sequence, next_output_sequence); - next_output_sequence += 1; - output.extend_from_slice(&unit.data); - } - Some(http_request_event_result::Result::BodyFinalize(finalize)) => { - assert_eq!(finalize.through_input_sequence, final_input_sequence); - assert_eq!(finalize.through_output_sequence, next_output_sequence - 1); - break finalize.findings[0].count; - } - other => panic!("unexpected owned output result: {other:?}"), - } - }; - assert!(matches!( - results.next().await.unwrap().unwrap().result, - Some(http_request_event_result::Result::TrailersResult(_)) - )); - assert!(results.next().await.is_none()); - CollectedSignedPush { - body: output, - signed_commits, - } - } - - #[test] - fn rejects_non_branch_updates() { - let payload = format!("{ZERO_SHA1} {ZERO_SHA1} refs/tags/v1\n"); - let length = payload.len() + 4; - let mut body = format!("{length:04x}{payload}0000").into_bytes(); - body.extend_from_slice(b"PACK"); - let mut file = file_with_bytes(&body); - let parsed = ReceivePackRequest::parse_file(&mut file).unwrap(); - assert_eq!(parsed.updates[0].ref_name, "refs/tags/v1"); - } - - #[test] - fn resolves_a_thin_pack_from_the_upstream_repository() { - let fixture = TempDir::new().unwrap(); - let source = fixture.path().join("source.git"); - run_git( - None, - &["init", "--bare", repo_string(&source).unwrap()], - None, - ) - .unwrap(); - let blob = object(&source, "blob", b"base\n"); - let tree_line = format!("100644 blob {blob}\tREADME.md\n"); - let tree = String::from_utf8( - run_git(Some(&source), &["mktree"], Some(tree_line.as_bytes())).unwrap(), - ) - .unwrap() - .trim() - .to_string(); - let base = unsigned_commit(&source, &tree, None, "base"); - run_git( - Some(&source), - &["update-ref", "refs/heads/main", &base], - None, - ) - .unwrap(); - run_git( - Some(&source), - &["symbolic-ref", "HEAD", "refs/heads/main"], - None, - ) - .unwrap(); - let tip = unsigned_commit(&source, &tree, Some(&base), "tip"); - let revisions = format!("{tip}\n^{base}\n"); - let pack = run_git( - Some(&source), - &["pack-objects", "--stdout", "--revs", "--thin"], - Some(revisions.as_bytes()), - ) - .unwrap(); - let body = receive_pack_body(&tip, &pack); - - let key = fixture.path().join("signing-key"); - assert!( - Command::new("ssh-keygen") - .args(["-q", "-t", "ed25519", "-N", "", "-f"]) - .arg(&key) - .status() - .unwrap() - .success() - ); - let signed = GitSigner::new(key) - .unwrap() - .sign_receive_pack(file_with_bytes(&body), source.to_str(), &test_control()) - .unwrap(); - assert_eq!(signed.signed_commits, 1); - } - - fn file_with_bytes(bytes: &[u8]) -> File { - let mut file = tempfile::tempfile().unwrap(); - file.write_all(bytes).unwrap(); - file.seek(SeekFrom::Start(0)).unwrap(); - file - } - - fn test_control() -> SignControl { - SignControl::new( - Arc::new(AtomicBool::new(false)), - Instant::now() + Duration::from_secs(300), - ) - } - - fn object(repo: &Path, kind: &str, body: &[u8]) -> String { - String::from_utf8( - run_git( - Some(repo), - &["hash-object", "-t", kind, "-w", "--stdin"], - Some(body), - ) - .unwrap(), - ) - .unwrap() - .trim() - .to_string() - } - - fn unsigned_commit(repo: &Path, tree: &str, parent: Option<&str>, subject: &str) -> String { - let parent = parent.map_or(String::new(), |oid| format!("parent {oid}\n")); - let raw = format!( - "tree {tree}\n{parent}author Agent 1700000000 +0000\ncommitter Agent 1700000000 +0000\n\n{subject}\n" - ); - object(repo, "commit", raw.as_bytes()) - } - - fn receive_pack_body(new_oid: &str, pack: &[u8]) -> Vec { - let payload = format!( - "{ZERO_SHA1} {new_oid} refs/heads/main\0 report-status side-band-64k object-format=sha1\n" - ); - let length = payload.len() + 4; - let mut body = format!("{length:04x}{payload}0000").into_bytes(); - body.extend_from_slice(pack); - body - } - - fn pseudo_random_bytes(len: usize) -> Vec { - let mut state = 0x4d59_5df4_d0f3_3173_u64; - (0..len) - .map(|_| { - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - state as u8 - }) - .collect() - } - - fn verify_commit(repo: &Path, allowed: &Path, oid: &str) { - let output = Command::new("git") - .arg("-C") - .arg(repo) - .args(["-c", "gpg.format=ssh", "-c"]) - .arg(format!("gpg.ssh.allowedSignersFile={}", allowed.display())) - .args(["verify-commit", oid]) - .output() - .unwrap(); - assert!( - output.status.success(), - "{}", - String::from_utf8_lossy(&output.stderr) - ); - } -} diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 76059153f4..8aa15360bd 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -31,24 +31,21 @@ service SupervisorMiddleware { } // HttpRequestPreCredentials begins after network-policy admission and before -// OpenShell injects credentials. A STREAM_BYTES stage may remain active while +// OpenShell injects credentials. A STREAM stage may remain active while // OpenShell forwards already-approved units upstream. service HttpRequestPreCredentials { - // Evaluate starts with preflight and may continue with selected body units - // and trailers. A body unit marked end_of_stream ends body input, not the - // event stream. Trailers and one best-effort session_end may follow. - rpc Evaluate(stream HttpRequestEvent) - returns (stream HttpRequestEventResult); + // EvaluateHttp uses the negotiated two-mode HTTP protocol. The distinct + // method path prevents old and new peers from decoding incompatible stream + // messages as though they shared a contract. + rpc EvaluateHttp(stream HttpEvent) returns (stream HttpResult); } // HttpResponsePreReturn evaluates one response for one middleware stage before // OpenShell returns it to the sandbox. service HttpResponsePreReturn { - // Evaluate starts with preflight and may continue with selected body units - // and trailers. A body unit marked end_of_stream ends body inspection, not - // the event stream. Trailers and one best-effort session_end may follow. - rpc Evaluate(stream HttpResponseEvent) - returns (stream HttpResponseEventResult); + // EvaluateHttp uses the same body lifecycle as request evaluation. Runtime + // support and per-message eligibility still determine the offered modes. + rpc EvaluateHttp(stream HttpEvent) returns (stream HttpResult); } // MiddlewareManifest describes one middleware service and its bindings. @@ -85,6 +82,13 @@ message MiddlewareBinding { // A non-empty value may shorten but cannot extend the operator timeout. // Values must be between 10ms and 30s. google.protobuf.Duration request_timeout = 104; + // HTTP body protocol version implemented by this binding. HTTP request and + // response bindings must advertise version 1. Other operations leave this + // unset. OpenShell rejects missing and unsupported versions at activation. + uint32 http_protocol_version = 5; + // HTTP body modes this implementation can select. OpenShell intersects this + // set with runtime, policy, and message eligibility before every preflight. + repeated HttpBodyMode supported_http_body_modes = 6; } // ValidateConfigRequest contains one policy configuration to validate. @@ -111,454 +115,190 @@ message HttpHeader { string value = 2; } -// One ordered request event. A stream starts with preflight, may continue with -// body units ending in end_of_stream, may then include trailers, and may end -// with one best-effort session_end. -message HttpRequestEvent { +// One ordered event shared by request and response evaluation. Preflight is +// first. Inspect is followed by Begin and exactly one selected body lifecycle. +// session_end is best effort and has no result. +message HttpEvent { oneof event { - HttpRequestPreflight preflight = 1; - HttpRequestBodyUnit body = 2; - MiddlewareSessionEnd session_end = 3; - HttpRequestTrailers trailers = 4; + HttpPreflight preflight = 1; + HttpBegin begin = 2; + HttpInputChunk input_chunk = 3; + HttpInputEnd input_end = 4; + HttpBufferedBody buffered_body = 5; + MiddlewareSessionEnd session_end = 6; } } -// Each preflight, body, and trailers event requires one ordered result. -// session_end has no result. An owned stage may additionally produce output -// units and one finalization after accepting its final body unit. -message HttpRequestEventResult { +// One ordered result shared by request and response evaluation. Unknown and +// unset alternatives are protocol errors, never implicit success. +message HttpResult { oneof result { - HttpRequestPreflightResult preflight_result = 1; - HttpRequestBodyResult body_result = 2; - HttpRequestTrailersResult trailers_result = 3; - HttpRequestBodyOutput body_output = 4; - HttpRequestBodyFinalize body_finalize = 5; + HttpPreflightResult preflight_result = 1; + HttpBufferedResult buffered_result = 2; + HttpOutputStart output_start = 3; + HttpOutputChunk output_chunk = 4; + HttpFinish finish = 5; + HttpReject reject = 6; } } -// HttpRequestPreflight exposes the current admitted request head to one stage. -message HttpRequestPreflight { - // Request identity. Limited to 4 KiB encoded. +enum HttpBodyMode { + HTTP_BODY_MODE_UNSPECIFIED = 0; + // One complete normalized body and one terminal result. OpenShell retains + // bounded input in RAM and never spills to disk. + HTTP_BODY_MODE_BUFFERED = 1; + // Independent ordered input and output streams. Middleware owns all working + // storage and returns every selected output byte. + HTTP_BODY_MODE_STREAM = 2; +} + +message HttpPreflight { + oneof head { + HttpRequestPreflightHead request = 1; + HttpResponsePreflightHead response = 2; + } + // Intersection of service capabilities, runtime support, policy, and message + // eligibility. Empty permits only Continue or Reject. + repeated HttpBodyMode permitted_body_modes = 3; + // Offered modes allowed to mutate otherwise writable headers after body + // inspection. Empty in the initial public rollout. + repeated HttpBodyMode late_header_modes = 4; + HttpBodyLimits limits = 5; + optional uint64 declared_input_bytes = 6; +} + +// Request head exposed after admission and before credential injection. +message HttpRequestPreflightHead { RequestContext context = 1; - // Admitted destination and request target. Limited to 32 KiB encoded. HttpRequestTarget target = 2; - // Current headers after earlier stages and before credential injection, in - // wire order. Protected fields are omitted. Limited to 128 lines and 64 KiB. repeated HttpHeader headers = 3; - // Built-in middleware name or operator-owned registration name. string middleware_name = 4; - // Validated service configuration. Limited to 64 KiB encoded. google.protobuf.Struct config = 5; - // Effective per-unit or whole-body input/replacement limit. - uint64 max_payload_bytes = 6; - // Modes OpenShell permits for this request and binding. HEADERS_ONLY is - // always present. OWNED_STREAM_BYTES is present only for fail-closed stages - // with a non-zero deferred-byte limit. - repeated HttpRequestBodyMode permitted_body_modes = 7; - // Effective per-representation input and output limit for - // OWNED_STREAM_BYTES. Input and output are each bounded by this value. - uint64 max_deferred_bytes = 8; - // Present when the normalized body length is known before inspection. - optional uint64 declared_body_length = 9; -} - -// Selects skip, inspect, or block. Invalid diagnostics make the complete -// result a middleware failure handled according to on_error. -message HttpRequestPreflightResult { - oneof action { - HttpRequestPreflightSkip skip = 1; - HttpRequestPreflightInspect inspect = 2; - HttpRequestBlock block_request = 7; - } - // Service diagnostic, never sent to the sandbox or security logs. - string reason = 3; - // Optional stable audit code, returned only for an accepted block. - string reason_code = 4; - repeated Finding findings = 5; - map metadata = 6; -} - -message HttpRequestPreflightSkip {} - -// Selects body inspection and request-header mutations. -message HttpRequestPreflightInspect { - HttpRequestBodyMode body_mode = 1; - repeated HeaderMutation header_mutations = 2; -} - -// Authoritatively rejects the request. A preflight rejection occurs before -// upstream contact; a streaming body rejection may terminate a partial upload. -message HttpRequestBlock {} - -// Controls which request body units a stage receives. -enum HttpRequestBodyMode { - HTTP_REQUEST_BODY_MODE_UNSPECIFIED = 0; - // Inspect only the request head. - HTTP_REQUEST_BODY_MODE_HEADERS_ONLY = 1; - // Receive the complete normalized body as one final unit. Input and - // replacement must fit max_payload_bytes. - HTTP_REQUEST_BODY_MODE_WHOLE_BODY_BYTES = 2; - // Receive normalized units in lockstep. Each result fully accounts for its - // input; the stage cannot retain bytes across units. - HTTP_REQUEST_BODY_MODE_STREAM_BYTES = 3; - // Take ownership of normalized input units without requiring OpenShell to - // retain a replay copy. The stage must accept every unit with take_ownership, - // then emit bounded output units and one body_finalize after the final input. - // Failure is always fail closed because the original bytes are no longer - // available for replay. - HTTP_REQUEST_BODY_MODE_OWNED_STREAM_BYTES = 4; -} - -// One normalized body unit. Boundaries have no transport or application -// meaning. -message HttpRequestBodyUnit { - // Contiguous and stage-local, starting at 1. - uint64 sequence = 1; - oneof payload { - bytes data = 2; - } - // Marks the final input unit. WHOLE_BODY_BYTES receives the complete body in - // this unit. STREAM_BYTES and OWNED_STREAM_BYTES receive nonempty data units - // with this unset, followed by one empty final unit. A body-capable request - // with no bytes also receives one empty final unit. Trailers and session_end - // may follow. - bool end_of_stream = 3; } -// Result for one body unit. -message HttpRequestBodyResult { - uint64 sequence = 1; - oneof action { - HttpRequestBodyPassThrough pass_through = 2; - HttpRequestBodyTransform transform = 3; - HttpRequestBlock block_request = 8; - HttpRequestBodySkipRemaining skip_remaining = 9; - HttpRequestBodyTakeOwnership take_ownership = 10; +// Current final response head exposed before delivery to the sandbox. +message HttpResponsePreflightHead { + RequestContext context = 1; + HttpRequestTarget target = 2; + uint32 status_code = 3; + repeated HttpHeader headers = 4; + string middleware_name = 5; + google.protobuf.Struct config = 6; +} + +// Limits are positive when present. Queue limits include transport adapter +// queues, not only application channels. Omitted total limits do not make any +// individual queue unbounded. +message HttpBodyLimits { + uint64 max_chunk_bytes = 1; + uint64 max_buffered_body_bytes = 2; + uint64 max_input_queue_bytes = 3; + uint64 max_input_queue_messages = 4; + uint64 max_output_queue_bytes = 5; + uint64 max_output_queue_messages = 6; + optional uint64 max_total_input_bytes = 7; + optional uint64 max_total_output_bytes = 8; + google.protobuf.Duration idle_timeout = 9; + google.protobuf.Duration session_timeout = 10; +} + +message HttpPreflightResult { + oneof decision { + HttpContinue continue_without_body = 1; + HttpInspect inspect = 2; } - string reason = 4; - string reason_code = 5; - repeated Finding findings = 6; - map metadata = 7; + // Validated before the next stage sees the head. + repeated HeaderMutation header_mutations = 3; + MiddlewareDiagnostics diagnostics = 4; } -message HttpRequestBodyPassThrough {} +// Successful terminal decision before any body delivery. The body bypasses +// this stage but remains eligible for later stages. +message HttpContinue {} -message HttpRequestBodyTransform { - oneof replacement { - bytes data = 1; +message HttpInspect { + oneof mode { + HttpBufferedMode buffered = 1; + HttpStreamMode stream = 2; } } -// Finalizes the current unit and ends inspection for this stage. -message HttpRequestBodySkipRemaining { - oneof current { - HttpRequestBodyPassThrough pass_through = 1; - HttpRequestBodyTransform transform = 2; - } +message HttpBufferedMode { + // Positive and no larger than the offered max_buffered_body_bytes. The cap + // applies separately to input and replacement. + uint64 max_body_bytes = 1; } -// Acknowledges that an owned stage durably accepted the complete input unit. -message HttpRequestBodyTakeOwnership {} +// STREAM transfers responsibility for all output at accepted preflight. +message HttpStreamMode {} -// One owned-stage output unit. Output sequence is contiguous from 1 and each -// unit fits max_payload_bytes. The complete output fits max_deferred_bytes. -message HttpRequestBodyOutput { - uint64 sequence = 1; - bytes data = 2; +message HttpBegin {} + +// BUFFERED receives exactly one complete body, including an empty body. +message HttpBufferedBody { + bytes data = 1; + repeated HttpHeader visible_trailers = 2; } -// Completes owned output and proves how much input and output it accounts for. -message HttpRequestBodyFinalize { - // Must equal the final accepted input sequence. - uint64 through_input_sequence = 1; - // Must equal the final output sequence, or zero for empty output. - uint64 through_output_sequence = 2; - // Final owned-stage diagnostic, never sent to the sandbox or security logs. - string reason = 3; - // Optional stable audit code for the completed transformation. - string reason_code = 4; - repeated Finding findings = 5; - map metadata = 6; +message HttpBufferedResult { + oneof body { + HttpUnchanged unchanged = 1; + // Present empty bytes delete the body. + bytes replacement = 2; + } + repeated HeaderMutation header_mutations = 3; + repeated HeaderMutation trailer_mutations = 4; + MiddlewareDiagnostics diagnostics = 5; } -// Current normalized request trailers in wire order. An inspecting body stage -// receives exactly one trailers event, including when the set is empty. -message HttpRequestTrailers { - repeated HttpHeader headers = 1; +// Successful BUFFERED pass-through. This is not failure recovery. +message HttpUnchanged {} + +message HttpInputChunk { + // Nonempty normalized bytes. Boundaries have no transport or application + // meaning. + bytes data = 1; } -// Applies ordered mutations to existing request trailers. V1 cannot create a -// trailer name that was not announced by the sender. -message HttpRequestTrailersResult { - repeated HeaderMutation trailer_mutations = 1; - string reason = 2; - string reason_code = 3; - repeated Finding findings = 4; - map metadata = 5; +message HttpInputEnd { + // Sent exactly once after input, including for an empty body. + repeated HttpHeader visible_trailers = 1; } -// One ordered response event. A stream starts with preflight, may continue with -// body units ending in end_of_stream, may then include trailers, and may end -// with one best-effort session_end. -message HttpResponseEvent { - oneof event { - // Initial response head and request context. - HttpResponsePreflight preflight = 1; - // Next normalized body unit. - HttpResponseBodyUnit body = 2; - // Normalized trailers after the final body result. - HttpResponseTrailers trailers = 4; - // Optional terminal notification. - MiddlewareSessionEnd session_end = 3; - } +message HttpOutputStart { + // Sent exactly once before output bytes. Mutations require the selected mode + // to appear in late_header_modes. + repeated HeaderMutation header_mutations = 1; + // When present, OpenShell derives framing and validates actual output length. + optional uint64 output_body_bytes = 2; } -// Each preflight, body, and trailers event requires one ordered result. -// session_end has no result. -message HttpResponseEventResult { - oneof result { - // Result for preflight. - HttpResponsePreflightResult preflight_result = 1; - // Result for the next body unit. - HttpResponseBodyResult body_result = 2; - // Result for response trailers. - HttpResponseTrailersResult trailers_result = 3; - } +message HttpOutputChunk { + // Nonempty normalized bytes. Input and output cardinality are independent. + bytes data = 1; } -// HttpResponsePreflight exposes the current final response head to one stage. -message HttpResponsePreflight { - // Request identity. request_id links request and response evaluations. - // Limited to 4 KiB encoded. - RequestContext context = 1; - // Admitted request target with a redacted query. Limited to 32 KiB encoded. - HttpRequestTarget target = 2; - // Final non-informational upstream status. Upgrades are not evaluated. - uint32 status_code = 3; - // Response headers after prior stages, in wire order. Repeated names remain - // separate. Credential, routing, and hop-by-hop headers are omitted. - // Content-Length, Content-Encoding, and Content-Range retain their read-only - // upstream values. OpenShell may recompute or remove Content-Length later. - // Limited to 128 lines and 64 KiB encoded. - repeated HttpHeader headers = 4; - // Built-in middleware name or operator-owned registration name. - string middleware_name = 5; - // Validated service configuration. Limited to 64 KiB encoded. - google.protobuf.Struct config = 6; - // Effective minimum of platform, registration, and binding limits. Applies to - // whole-body input/replacement and each stream input/replacement. Stream - // inputs use at most min(64 KiB, max_payload_bytes). - uint64 max_payload_bytes = 7; - // Modes derived independently for this stage. OpenShell first determines - // response-shape eligibility from the original final response head, then - // applies this stage's effective max_payload_bytes. Different stages may - // receive different lists. HEADERS_ONLY is always present and is the only - // mode for bodyless, partial, encoded, or no-transform responses. For an - // otherwise eligible response, a known body larger than this stage's limit - // omits WHOLE_BODY_BYTES. An eligible unknown-length response may select - // WHOLE_BODY_BYTES and later fail with whole_body_over_capacity according to - // this stage's on_error. STREAM_BYTES is omitted when - // max_payload_bytes is zero. Selecting an unlisted mode fails according to - // on_error. - repeated HttpResponseBodyMode permitted_body_modes = 8; -} - -// Selects skip, inspect, or block. Diagnostic fields apply to every action. -// Invalid diagnostics make the entire result a middleware failure handled -// according to on_error. -message HttpResponsePreflightResult { - oneof action { - // Deliver unchanged without invoking on_error. - HttpResponsePreflightSkip skip = 1; - // Inspect with the selected body mode and mutations. - HttpResponsePreflightInspect inspect = 2; - // Prevent delivery to the sandbox. - HttpResponseBlockDelivery block_delivery = 7; - } - // Service diagnostic, never sent to the sandbox or security logs. Maximum - // 4 KiB. - string reason = 3; - // Optional audit code using the HttpRequestPreflightResult.reason_code format and - // 64-byte maximum. Returned to the sandbox only for block_delivery. - string reason_code = 4; - // Up to 32 audit-safe findings, each limited to 4 KiB encoded. - repeated Finding findings = 5; - // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. - map metadata = 6; -} - -// Ends this stage successfully without body inspection. -message HttpResponsePreflightSkip {} - -// Blocks delivery as a successful decision regardless of on_error. OpenShell -// evaluates results in policy order. Once it accepts a valid block, it stops -// later middleware evaluation and ends every still-writable opened stage with -// MIDDLEWARE_DENIAL. A failure handled earlier may already have stopped -// evaluation, so a later block does not override it. An invalid block result -// is a middleware failure handled according to on_error. The upstream request -// has already run; blocking its response does not reject or roll back that -// request. -// -// Before response commitment, including at preflight and during -// WHOLE_BODY_BYTES, OpenShell replaces the upstream response with the canonical -// 403 Forbidden middleware-denial response. Its JSON body has -// error = "middleware_denied" and includes a validated reason_code when the -// result supplies one. OpenShell never returns the free-form reason or writes it -// to security logs. For HEAD, OpenShell sends the canonical response headers -// and Content-Length but no body. It closes the downstream connection after the -// denial response. -// -// After response commitment, including during STREAM_BYTES, OpenShell aborts -// downstream delivery. It does not inject an error body, a terminating chunk, -// or an error trailer. OpenShell does not reuse the upstream connection. -message HttpResponseBlockDelivery {} - -// Selects body inspection and response-header mutations. -message HttpResponsePreflightInspect { - // Required mode from permitted_body_modes. Invalid values fail according to - // on_error. - HttpResponseBodyMode body_mode = 1; - // Ordered mutations applied atomically before the next stage. Only visible - // end-to-end headers may change. Routing, credential, framing, coding, range, - // and hop-by-hop headers are protected; integrity headers may only be removed. - // Limited to 64 operations, 32 KiB of name/value data, and 64 KiB encoded. - repeated HeaderMutation header_mutations = 2; -} - -// Controls which response-body units a stage receives. -enum HttpResponseBodyMode { - // Invalid value handled according to on_error. - HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; - // Inspect only the response head. - HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; - // Buffer the normalized body as one final unit before committing the head. - // Input and replacement must fit max_payload_bytes. Capacity failures use - // whole_body_over_capacity and follow this stage's on_error. - HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; - // Receive normalized units ending with end_of_stream. Each input is at most - // min(64 KiB, max_payload_bytes), and each replacement must fit - // max_payload_bytes. Each result fully accounts for its input unit; V1 does - // not permit retaining input across units. The full body may exceed the - // limit. STREAM_BYTES has no total response-lifetime deadline. - HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; -} - -// One normalized body unit. Boundaries have no transport or application -// meaning. -message HttpResponseBodyUnit { - // Contiguous and stage-local, starting at 1. - uint64 sequence = 1; - oneof payload { - // Bytes without transfer framing. A body-capable response with no body bytes - // has present empty data in sequence 1. STREAM_BYTES input size is at most - // min(64 KiB, max_payload_bytes). A unit may be shorter to preserve - // flushing. - bytes data = 2; - } - // Marks the final body unit. Every normally completed body inspection receives - // exactly one. For a body-capable response with no body bytes, this is the - // empty sequence-1 unit. OpenShell does not read ahead, so it may send an empty - // final unit after the last nonempty unit. Trailers and session_end may - // follow. A stage ended by skip_remaining, block, or failure receives no - // later final unit. - bool end_of_stream = 3; -} - -// Result for one body unit. Units are processed in lockstep; V1 does not -// support ownership transfer or cross-unit retention. Diagnostic fields apply -// to every action. Invalid diagnostics make the entire result a middleware -// failure handled according to on_error. OpenShell retains the current input -// until it validates the result, so fail-open can continue from the last input -// OpenShell still owns. -message HttpResponseBodyResult { - // Must match the next unit. Zero, gaps, duplicates, and regressions fail. - uint64 sequence = 1; - // Exactly one explicit action is required. - oneof action { - // Forward the input unit unchanged. - HttpResponseBodyPassThrough pass_through = 2; - // Replace the complete input unit. - HttpResponseBodyTransform transform = 3; - // Stop delivery. See HttpResponseBlockDelivery. - HttpResponseBlockDelivery block_delivery = 8; - // Finalize this unit and stop inspecting. - HttpResponseBodySkipRemaining skip_remaining = 9; - } - // Service diagnostic, never sent to the sandbox or security logs. Maximum - // 4 KiB. - string reason = 4; - // Optional audit code using the HttpRequestPreflightResult.reason_code format and - // 64-byte maximum. When OpenShell accepts block_delivery before response - // commitment, it includes this code in the canonical denial response. It is - // never returned after commitment. - string reason_code = 5; - // Up to 32 audit-safe findings, each limited to 4 KiB encoded. - repeated Finding findings = 6; - // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. - map metadata = 7; -} - -// Preserves the input unit. -message HttpResponseBodyPassThrough {} - -// Finalizes this unit and ends the stage. This stage receives no later body or -// trailer events. The current and later units continue through other stages. -// For WHOLE_BODY_BYTES, this equals its nested action. -message HttpResponseBodySkipRemaining { - // Exactly one action for the current unit. - oneof current { - // Forward the current unit unchanged. - HttpResponseBodyPassThrough pass_through = 1; - // Replace the current unit. - HttpResponseBodyTransform transform = 2; - } +// STREAM terminal success. Finish is valid only after InputEnd was consumed. +message HttpFinish { + repeated HeaderMutation trailer_mutations = 1; + MiddlewareDiagnostics diagnostics = 2; } -// Replaces the complete input unit. -message HttpResponseBodyTransform { - // Required replacement, limited to max_payload_bytes. Present empty data - // deletes the input unit. The replacement fully accounts for this input unit; - // middleware must not retain input bytes for a later unit in V1. - oneof replacement { - // Normalized replacement bytes. - bytes data = 1; - } +// Explicit terminal denial at preflight or during body processing. +message HttpReject { + MiddlewareDiagnostics diagnostics = 1; } -// The current normalized response trailers in wire order. Repeated names stay -// as separate fields. A stage that completes WHOLE_BODY_BYTES or STREAM_BYTES -// receives exactly one trailers event after its final body result, including -// when this set is empty. SKIP, HEADERS_ONLY, semantically bodyless responses, -// and stages ended by block, failure, or skip_remaining receive no trailers. -message HttpResponseTrailers { - repeated HttpHeader headers = 1; -} - -// Applies ordered trailer mutations atomically. An empty mutation list -// preserves the current trailers. A write may target only a case-insensitive -// name present in the trailers event; V1 cannot create a trailer name. Removal -// of an absent name is a no-op. Credential, routing, framing, coding, range, -// hop-by-hop, and connection-nominated fields are protected. Diagnostic fields -// apply whether mutations are empty or nonempty. Invalid diagnostics or -// mutations make the entire result a middleware failure handled according to -// on_error. -message HttpResponseTrailersResult { - // At most 64 operations, 32 KiB of validated name/value data, and 64 KiB - // encoded are accepted. - repeated HeaderMutation trailer_mutations = 1; - // Service diagnostic, never sent to the sandbox or security logs. Maximum - // 4 KiB. - string reason = 2; - // Optional audit code using the HttpRequestPreflightResult.reason_code format and - // 64-byte maximum. Never sent to the sandbox. - string reason_code = 3; - // Up to 32 audit-safe findings, each limited to 4 KiB encoded. - repeated Finding findings = 4; - // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. - map metadata = 5; +message MiddlewareDiagnostics { + // Service text is operator-only and never copied to denied responses or + // security logs. + string reason = 1; + // Optional stable audit-safe code. + string reason_code = 2; + repeated Finding findings = 3; + map metadata = 4; } -// Stable reason OpenShell ended a middleware stage stream. enum MiddlewareSessionEndReason { // Invalid reason. MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; @@ -622,12 +362,10 @@ enum SupervisorMiddlewareOperation { // Ordered phase within a supervisor operation. enum SupervisorMiddlewarePhase { + reserved 3; SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN = 2; - // Restricted to trusted in-process middleware. External services cannot - // advertise this phase because credentials must not cross the extension API. - SUPERVISOR_MIDDLEWARE_PHASE_POST_CREDENTIALS = 3; } // WebSocketSessionEvent is one ordered event in a stage-local stream. diff --git a/rfc/0009-supervisor-middleware/README.md b/rfc/0009-supervisor-middleware/README.md index 4f60b0058e..05ff523707 100644 --- a/rfc/0009-supervisor-middleware/README.md +++ b/rfc/0009-supervisor-middleware/README.md @@ -19,7 +19,7 @@ links: |------|------------|--------| | 2026-07-17 | [#2010](https://github.com/NVIDIA/OpenShell/issues/2010) | Added HTTP request middleware with built-in and operator-run services. | | 2026-07-28 | [#2428](https://github.com/NVIDIA/OpenShell/issues/2428) | Added WebSocket preflight and text-message evaluation, and aligned the middleware API names, limits, and diagnostics. | -| 2026-09-17 | [#2431](https://github.com/NVIDIA/OpenShell/issues/2431), [#3307](https://github.com/NVIDIA/OpenShell/issues/3307) | Replaced the unary request hook with a bounded bidirectional stream, added owned transformations and request trailers, and moved SigV4 into a restricted post-credentials built-in. | +| 2026-09-17 | [#2431](https://github.com/NVIDIA/OpenShell/issues/2431), [#3307](https://github.com/NVIDIA/OpenShell/issues/3307) | Replaced the HTTP body hook with the shared BUFFERED/STREAM contract, explicit capability negotiation, bounded independent pumps, and mandatory fail-closed behavior. | ## Summary @@ -67,7 +67,7 @@ This RFC uses the following terms with specific meanings. - **Middleware config.** A policy entry stored under a stable policy-local map key that namespaces metadata and diagnostics. The optional `name` field is a human-readable label and defaults to the map key. The `middleware` field selects a built-in or operator-owned registration name, while the remaining fields define service-specific configuration, endpoint selectors, failure behavior, and ordering. - **Manifest.** The self-description a middleware returns from `Describe`: its service version and service-owned bindings for the hooks it supports. The protobuf package `openshell.middleware.v1` defines the wire-version boundary; requests and manifests do not carry a duplicate API-version string. - **Decision.** The allow-or-deny outcome a middleware returns for a request. `allow` lets the request proceed (possibly transformed); `deny` short-circuits it. This vocabulary matches the rest of the OpenShell policy system. -- **Failure policy.** The configured `on_error` behavior when middleware cannot return a valid result: `fail_closed` denies the request, while `fail_open` lets it continue without that middleware's transformation while recording an enforcement failure. `fail_closed` is the default whenever processing is required. +- **Failure policy.** HTTP hooks are fail-closed: a missing, invalid, or incomplete result denies or aborts delivery. `on_error: fail_open` remains available only to WebSocket-only implementations. - **Transformation.** A middleware returning replacement content, and any allowed header mutations, that the supervisor forwards in place of the original request. A later middleware in a chain sees the previous stage's transformed content. - **Finding.** A structured, audit-safe observation a middleware reports about a request, such as a machine-readable type, safe label, count, confidence, and optional severity. A finding never carries raw matched values, redacted spans, or the original sensitive content. The supervisor maps findings into OCSF `DetectionFinding` events. - **Metadata.** Namespaced string key/value annotations a middleware emits into a request-local bag. V1 metadata never carries raw sensitive values. Routing-grade typed metadata, including usage markers such as audit-safe, routing-safe, or internal-only, is deferred until a component consumes it. @@ -113,7 +113,7 @@ graph LR ### Operation phases and placement -A middleware service provides hook implementations that the supervisor invokes at defined operation phases in the proxy flow. V1 defines `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. It also reserves `HTTP_REQUEST/POST_CREDENTIALS` for trusted in-process built-ins. The supervisor invokes the external request hook after policy admission and before credential injection. +A middleware service provides hook implementations that the supervisor invokes at defined operation phases in the proxy flow. V1 defines `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. The supervisor invokes the request hook after policy admission and before credential injection. ```mermaid graph LR @@ -127,8 +127,7 @@ graph LR RECHECK -->|"deny"| DENY RECHECK -->|"next stage or complete"| ROUTE["Route selection"] ROUTE --> CRED["Credential injection"] - CRED --> SIGN["Restricted POST_CREDENTIALS
built-ins"] - SIGN --> UP["Upstream forwarding"] + CRED --> UP["Upstream forwarding"] ``` This ordering is deliberate: @@ -145,7 +144,7 @@ The hook operates on a parsed HTTP request, so it runs wherever OpenShell can pa > **Update in PR #2477 - WebSocket middleware:** The following text extends the original HTTP-only scope. It adds operation-specific selection, client WebSocket text-message inspection, and explicit coverage for traffic that an attached middleware cannot inspect. -If a selected operation chain becomes uninspectable at runtime, OpenShell examines that chain. If any selected stage is `fail_closed`, the request is denied. If every selected stage is `fail_open`, OpenShell relays the request and emits a bypass `DetectionFinding`. This chain-level rule prevents one permissive selected stage from overriding a required stage. +If an HTTP chain becomes uninspectable at runtime, OpenShell denies the request because HTTP middleware is always fail-closed. For a WebSocket-only chain, OpenShell denies when any selected stage is `fail_closed`; an all-`fail_open` chain may continue after emitting a bypass `DetectionFinding`. This chain-level rule prevents one permissive WebSocket stage from overriding a required stage. Attachment and operation selection are separate. A destination host selector attaches a policy config, then the implementation manifest decides whether that config participates in `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`, or multiple phases. The absence of an operation binding is a declared capability boundary rather than a middleware failure, so `on_error` does not apply. OpenShell records informational coverage when an attached config does not join the WebSocket chain. @@ -165,10 +164,10 @@ WebSocket sits on this boundary. The upgrade request is a normal HTTP/1.1 reques - HTTP/2 and HTTP/3. The proxy's TLS termination pins ALPN to `http/1.1` today, so these are not introspected. - Binary and control WebSocket messages and upstream-to-client WebSocket messages. - Opaque TCP streams and endpoints with `tls: skip`. -- Unbounded or full-duplex request processing. Owned streams have a finite deferred-storage limit and complete before upstream contact. +- Unbounded request processing. STREAM is duplex, but queues, units, and timeouts remain bounded and middleware owns any processing storage. - Multipart or compressed body semantics, unless a selected service's manifest and policy explicitly support them within the size limits. -The request hook is synchronous and opens one bidirectional stream for every selected stage. Timeout, failure behavior, sequencing, backpressure, and ownership are therefore load-bearing parts of the design. A preflight selects headers-only, whole-body, lockstep stream, or owned-stream processing. Whole-body input and each stream unit use the binding limit; OpenShell further caps request units at 64 KiB. Whole-body mode receives its complete body in one final unit. Lockstep and owned streams receive nonempty data units followed by an empty final unit. Owned streams durably accept input without a platform replay copy, are available only to `fail_closed` stages, and cap deferred input and output at 1 GiB each. A pure lockstep chain may forward approved units upstream as they complete; any whole-body, owned, policy re-evaluation, body credential rewrite, or body-signing requirement holds the complete representation. OpenShell never replays a partially forwarded request. Request body receipt, middleware processing, and output delivery share a two-minute wall-clock deadline. The hook remains before credential rewrite, which keeps OpenShell-managed credentials away from external middleware. A restricted in-process `HTTP_REQUEST/POST_CREDENTIALS` phase hosts `openshell/sigv4`; external manifests cannot advertise that credential-visible phase. +The request hook opens one bidirectional stream for every selected stage. Timeout, failure behavior, lifecycle validation, and backpressure are load-bearing parts of the design. Preflight continues without a body, rejects, or selects one of two modes. BUFFERED carries one complete body in bounded RAM and returns explicit unchanged or replacement bytes. STREAM has independent input and output pumps; output may start before input ends, cardinalities need not match, and middleware owns any processing storage. OpenShell caps request units at 64 KiB, uses bounded byte/message queues, never creates a middleware disk spool, and never retains a recovery copy. A pure STREAM chain may forward output upstream as it arrives; BUFFERED, body-aware policy re-evaluation, and request-body credential rewriting hold a bounded complete representation. OpenShell never replays a partially forwarded request. Request body receipt, middleware processing, and output delivery share a two-minute wall-clock deadline. HTTP failures are always closed. The hook remains before credential rewrite, which keeps OpenShell-managed credentials away from external middleware. ### The middleware contract @@ -181,9 +180,10 @@ Configuration-time: Request-time: -- `HttpRequestPreCredentials.Evaluate` is a bidirectional stream. Preflight carries the request context, policy config, admitted target, repeated safe headers, permitted modes, and effective limits. -- Body events carry contiguous bounded normalized units. Results pass, transform, block, skip remaining inspection, or take ownership of the corresponding unit. -- An owned stage acknowledges every input unit, then emits contiguous output units and final input/output accounting. Trailer and terminal events complete the lifecycle. +- `HttpRequestPreCredentials.EvaluateHttp` and `HttpResponsePreReturn.EvaluateHttp` share a bidirectional `HttpEvent`/`HttpResult` schema. Preflight carries request or response context, safe headers, offered modes, and effective limits. +- BUFFERED sends one complete body and receives explicit unchanged or replacement bytes. +- STREAM sends independent input chunks plus input end and receives output start, independent output chunks, and finish. Finish is valid only after input end. +- A best-effort terminal notification completes the lifecycle. A simplified sketch of the gRPC contract: @@ -196,7 +196,11 @@ service SupervisorMiddleware { // operation=HTTP_REQUEST, phase=PRE_CREDENTIALS. service HttpRequestPreCredentials { - rpc Evaluate(stream HttpRequestEvent) returns (stream HttpRequestEventResult); + rpc EvaluateHttp(stream HttpEvent) returns (stream HttpResult); +} + +service HttpResponsePreReturn { + rpc EvaluateHttp(stream HttpEvent) returns (stream HttpResult); } message MiddlewareManifest { @@ -208,37 +212,100 @@ message MiddlewareManifest { message MiddlewareBinding { SupervisorMiddlewareOperation operation = 1; SupervisorMiddlewarePhase phase = 2; - uint64 max_payload_bytes = 3; // whole body, message, or stream unit + uint64 max_payload_bytes = 3; // buffered body, stream unit, or message google.protobuf.Duration request_timeout = 104; + uint32 http_protocol_version = 5; + repeated HttpBodyMode supported_http_body_modes = 6; } -message HttpRequestEvent { +message HttpEvent { oneof event { - HttpRequestPreflight preflight = 1; - HttpRequestBodyUnit body = 2; - MiddlewareSessionEnd session_end = 3; - HttpRequestTrailers trailers = 4; + HttpPreflight preflight = 1; + HttpBegin begin = 2; + HttpInputChunk input_chunk = 3; + HttpInputEnd input_end = 4; + HttpBufferedBody buffered_body = 5; + MiddlewareSessionEnd session_end = 6; + } +} + +message HttpResult { + oneof result { + HttpPreflightResult preflight_result = 1; + HttpBufferedResult buffered_result = 2; + HttpOutputStart output_start = 3; + HttpOutputChunk output_chunk = 4; + HttpFinish finish = 5; + HttpReject reject = 6; + } +} + +message HttpPreflight { + oneof head { + HttpRequestPreflightHead request = 1; + HttpResponsePreflightHead response = 2; } + repeated HttpBodyMode permitted_body_modes = 3; + repeated HttpBodyMode late_header_modes = 4; + HttpBodyLimits limits = 5; + optional uint64 declared_input_bytes = 6; +} + +enum HttpBodyMode { + HTTP_BODY_MODE_UNSPECIFIED = 0; + HTTP_BODY_MODE_BUFFERED = 1; + HTTP_BODY_MODE_STREAM = 2; } -message HttpRequestPreflight { - RequestContext context = 1; - HttpRequestTarget target = 2; - repeated HttpHeader headers = 3; - string middleware_name = 4; - google.protobuf.Struct config = 5; - uint64 max_payload_bytes = 6; - repeated HttpRequestBodyMode permitted_body_modes = 7; - uint64 max_deferred_bytes = 8; - optional uint64 declared_body_length = 9; +message HttpPreflightResult { + oneof decision { + HttpContinue continue_without_body = 1; + HttpInspect inspect = 2; + } + repeated HeaderMutation header_mutations = 3; + MiddlewareDiagnostics diagnostics = 4; } -message HttpRequestBodyUnit { - uint64 sequence = 1; - oneof payload { - bytes data = 2; +message HttpInspect { + oneof mode { + HttpBufferedMode buffered = 1; + HttpStreamMode stream = 2; } - bool end_of_stream = 3; +} + +message HttpBufferedBody { + bytes data = 1; + repeated HttpHeader visible_trailers = 2; +} + +message HttpBufferedResult { + oneof body { + HttpUnchanged unchanged = 1; + bytes replacement = 2; + } + repeated HeaderMutation trailer_mutations = 4; + MiddlewareDiagnostics diagnostics = 5; +} + +message HttpInputChunk { + bytes data = 1; +} + +message HttpInputEnd { + repeated HttpHeader visible_trailers = 1; +} + +message HttpOutputStart { + optional uint64 output_body_bytes = 2; +} + +message HttpOutputChunk { + bytes data = 1; +} + +message HttpFinish { + repeated HeaderMutation trailer_mutations = 1; + MiddlewareDiagnostics diagnostics = 2; } message RequestContext { @@ -295,26 +362,17 @@ message RemoveHeader { string name = 1; } -message HttpRequestEventResult { - oneof result { - HttpRequestPreflightResult preflight_result = 1; - HttpRequestBodyResult body_result = 2; - HttpRequestTrailersResult trailers_result = 3; - HttpRequestBodyOutput body_output = 4; - HttpRequestBodyFinalize body_finalize = 5; - } -} ``` -The event and result streams compose as a chain over one request representation. A stage's accepted body units and safe header or trailer mutations feed the next stage; an explicit block short-circuits the rest. Whole-body mode produces one data-bearing final unit. Lockstep and owned streams receive nonempty units without `end_of_stream`, followed by one empty terminal unit. Lockstep streaming cannot retain bytes across results. Owned streaming transfers replay responsibility to the stage, which must accept every unit before emitting a bounded replacement and final accounting. See [Middleware ordering](#middleware-ordering) for how chains are assembled and ordered. +The event and result streams compose as a chain over one request representation. A stage's accepted body and safe header or trailer mutations feed the next stage; an explicit rejection short-circuits the rest. STREAM transfers output responsibility at preflight and does not imply input/output correspondence. See [Middleware ordering](#middleware-ordering) for how chains are assembled and ordered. Headers use a repeated representation so duplicate lines and wire order survive evaluation and chaining. Before an external call, OpenShell omits credential-bearing, routing, framing, hop-by-hop, and `Connection`-nominated headers. A result may return ordered writes and removals for middleware-visible end-to-end headers. Writes support append, overwrite, and skip modes. Credential-bearing, routing, framing, hop-by-hop, `Connection`-nominated, and OpenShell credential headers remain protected. Header values containing control characters or credential placeholders are invalid. OpenShell validates and applies a stage's mutations atomically. If any mutation is invalid, none are applied and the stage follows its configured `on_error` behavior. > **Update in PR #2477 - WebSocket middleware:** The following contract text adds the bidirectional `EvaluateWebSocketSession` RPC, WebSocket preflight, message limits, and the WebSocket binding for the built-in regex middleware. -The interface is gRPC. The protobuf package `openshell.middleware.v1` is the protocol version boundary, so manifests and evaluation messages do not repeat an API-version string. HTTP requests use `HttpRequestPreCredentials.Evaluate`; client-to-upstream WebSocket text messages use the separate `EvaluateWebSocketSession` RPC. Both are bidirectional streams with operation-specific events. Built-in middleware uses the same logical contracts in-process. `openshell/regex` advertises request and WebSocket bindings. Endpoint `credential_signing` fields synthesize the restricted `openshell/sigv4` stage at `HTTP_REQUEST/POST_CREDENTIALS`; it is not attachable through `network_middlewares`. +The interface is gRPC. HTTP bindings explicitly advertise protocol version `1` and supported body modes; OpenShell rejects missing or unsupported capabilities before activation. HTTP requests and responses use distinct `EvaluateHttp` method paths with the shared two-mode schema; client-to-upstream WebSocket text messages use `EvaluateWebSocketSession`. Built-in middleware uses the same logical contracts in-process. `openshell/regex` advertises request and WebSocket bindings. Endpoint `credential_signing` fields continue to configure the existing proxy-side SigV4 path; moving SigV4 into middleware is separate work. -V1 applies explicit public envelope limits before invoking a service or accepting its result: 64 KiB for encoded config, 4 KiB for request context, 32 KiB for the target, 128 header lines and 64 KiB of encoded headers, 4 MiB for a whole-body payload or advertised unit, 64 KiB for each request stream unit, 4 KiB for a reason, 64 header mutations with at most 32 KiB of validated name/value data and 64 KiB encoded, 32 findings per stage with each finding at most 4 KiB encoded, and 64 metadata entries totaling at most 32 KiB. Owned request streams separately cap deferred input and output at 1 GiB. A chain has at most 10 stages and therefore at most 320 findings. +V1 applies explicit public envelope limits before invoking a service or accepting its result: 64 KiB for encoded config, 4 KiB for request context, 32 KiB for the target, 128 header lines and 64 KiB of encoded headers, 4 MiB for a buffered payload or advertised unit, 64 KiB for each request stream unit, 4 KiB for a reason, 64 header mutations with at most 32 KiB of validated name/value data and 64 KiB encoded, 32 findings per stage with each finding at most 4 KiB encoded, and 64 metadata entries totaling at most 32 KiB. STREAM also advertises bounded input and output queues. A chain has at most 10 stages and therefore at most 320 findings. For WebSocket traffic, a service advertises `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` with `max_payload_bytes`, which limits one complete message or replacement rather than the whole session. For HTTP request traffic, the field limits a whole-body representation or individual stream unit. An attached service without the exact operation binding does not join that chain and does not apply `on_error`. OpenShell opens one phase-specific stream per selected stage. Preflight exposes only the admitted destination, sandbox context, attached middleware name, validated config, and bounded safe headers. Each stage returns inspect, voluntary skip, or authoritative deny before body processing begins. Explicit denial is a successful decision enforced independently of `on_error`; failures follow the stage's failure policy. OpenShell sends a terminal reason to each still-writable opened stream at most once. @@ -332,7 +390,7 @@ Shared mechanics: - **Manifest description.** Both extension systems use `Describe` to return a manifest that declares a diagnostic service name, implementation version, and service-owned bindings for supported hook points. - **Operation phases.** Both systems hook into a named operation plus phase. The phase sets differ by system, but the concept is the same: `method=CreateSandbox, phase=pre_request` for a gateway interceptor, and `HTTP_REQUEST/PRE_CREDENTIALS` for v1 supervisor middleware. - **Evaluation and result.** Both systems run evaluate-style exchanges. Middleware keeps operation-specific streaming services such as `HttpRequestPreCredentials` because inputs and outputs differ by protocol or operation type; interceptor methods and messages are defined by RFC 0010. -- **Failure policy.** Both systems use `on_error: fail_closed|fail_open`, with fail-closed as the safe default for required enforcement. +- **Failure policy.** HTTP middleware is fail-closed. WebSocket-only middleware retains `on_error: fail_closed|fail_open`; gateway interceptors keep their own failure contract. - **Observability.** Both systems emit OCSF events with the details relevant to the extension point, while preserving the same no-secrets logging rules. - **Ordering.** Both systems apply multiple configured extensions in deterministic order. @@ -346,7 +404,7 @@ Intentional differences: The middleware gRPC contract lives under a major-versioned protobuf package (`openshell.middleware.v1`), the same convention the compute-driver contract uses in [RFC 0001](../0001-core-architecture/README.md). Within a stable major version, changes stay additive and backward compatible - new fields, RPCs, operation phases, and manifest fields can be added - while breaking wire or semantic changes require a new major version. The research preview may still make intentional breaking changes before the contract is declared stable. -The protobuf package is the wire-version handshake. `Describe` reports a diagnostic service name, implementation version, and operation/phase bindings for supported hook points; it does not carry a second API-version field. Manifest validation is mandatory: if OpenShell cannot fetch the manifest, bindings conflict, an external service advertises restricted `POST_CREDENTIALS`, or policy asks for an unsupported implementation or invalid config, the gateway rejects the relevant configuration before traffic can depend on it. Runtime invocation failures are handled through `on_error` and use `fail_closed` by default. +The protobuf package and each HTTP binding's explicit protocol version form the wire-version handshake. `Describe` reports a diagnostic service name, implementation version, operation/phase bindings, and HTTP body capabilities. Manifest validation is mandatory: if OpenShell cannot fetch the manifest, bindings conflict, capabilities are missing, or policy asks for an unsupported implementation or invalid config, the gateway rejects the relevant configuration before traffic can depend on it. HTTP runtime invocation failures are fail-closed. ### Registration and delivery @@ -370,13 +428,13 @@ max_payload_bytes = 1048576 The stable transport requirement is confidentiality plus authentication of the intended middleware service. Phase 1 may temporarily accept a plaintext `http://` endpoint only when the same entry explicitly sets `allow_insecure_transport = true`. OpenShell rejects plaintext without that opt-in, warns prominently, and records the insecure registration as auditable configuration state. This escape hatch is limited to trusted local development and isolated research environments. Phase 2 removes plaintext support and the opt-out field, requiring authenticated encrypted transport. That removal is an intentional research-preview breaking change with no long-term compatibility obligation. The exact phase 2 mechanism, such as mTLS or TLS plus caller authentication, is follow-up protocol work. -For each binding, the operator's `max_payload_bytes` must not exceed the binding capability returned by `Describe` or the 4 MiB platform maximum. The gateway rejects an invalid registration rather than silently clamping it. Whole-body modes use the effective limit for the complete representation; stream modes use it per unit, with request units further capped at 64 KiB. Owned request streams separately advertise the fail-closed deferred-storage limit. +For each binding, the operator's `max_payload_bytes` must not exceed the binding capability returned by `Describe` or the 4 MiB platform maximum. The gateway rejects an invalid registration rather than silently clamping it. BUFFERED uses the effective limit for the complete input and replacement separately; STREAM uses it per unit, with request units further capped at 64 KiB. RPC timeouts use an integer with an `ms` or `s` suffix, range from 10 ms through 30 s, and default to 500 ms. A binding may advertise its own timeout through `Describe`; that value overrides the service registration timeout. The service timeout applies to `Describe`, while the effective binding timeout applies to `ValidateConfig`, stream open, and individual request exchanges. The external-service endpoint is trusted operator infrastructure in v1. The auth design must make both directions explicit: the supervisor proves to the middleware that the call is authorized for the specific middleware identity, and the supervisor verifies it is calling the intended middleware service. -Middleware names may be bare (`anonymizer`) or namespaced with `/` (`nvidia/anonymizer`, `acme/security/pii-redactor`). Empty path segments are invalid, so `/foo`, `foo/`, and `foo//bar` are rejected. The `openshell/` namespace is reserved for built-in OpenShell middleware, such as `openshell/regex` or `openshell/sigv4`. Policy config map keys remain stable local identities for metadata namespacing and diagnostics; the `middleware` field selects the built-in or operator registration. +Middleware names may be bare (`anonymizer`) or namespaced with `/` (`nvidia/anonymizer`, `acme/security/pii-redactor`). Empty path segments are invalid, so `/foo`, `foo/`, and `foo//bar` are rejected. The `openshell/` namespace is reserved for built-in OpenShell middleware, such as `openshell/regex`. Policy config map keys remain stable local identities for metadata namespacing and diagnostics; the `middleware` field selects the built-in or operator registration. Built-in middleware ships in the supervisor binary and needs no external registration. Supervisors install built-in bindings before attempting external connections. @@ -406,7 +464,7 @@ Selection occurs after network and L7 admission and depends only on the admitted Every config requires a non-empty `include` list. `exclude` is optional and takes precedence over `include`. Matching is case-insensitive and uses the same host-pattern implementation as network endpoints: `*` matches exactly one DNS label, `**` matches one or more DNS labels, and intra-label wildcards such as `*-api.example.com` are supported. Brace alternates are rejected; authors list each alternative explicitly. A config accepts at most 32 combined include and exclude patterns. A policy accepts at most 10 middleware configs, and runtime selection defensively rejects a chain longer than 10 stages. -The hook is a supervisor-side Rust enforcement stage selected by policy data, not a Rego rule. L4 policy admits the connection and, where the endpoint declares a `protocol`, L7 policy admits the parsed request. The supervisor then selects the chain, opens event streams, applies valid results, and re-evaluates body-aware protocol policy after each body replacement. Body-aware protocols retain a bounded hold barrier for this re-evaluation. Other HTTP/1 paths either forward pure lockstep output incrementally or spool when a stage takes ownership or needs the complete body. Request bodies do not otherwise become a new general Rego input surface. +The hook is a supervisor-side Rust enforcement stage selected by policy data, not a Rego rule. L4 policy admits the connection and, where the endpoint declares a `protocol`, L7 policy admits the parsed request. The supervisor then selects the chain, opens event streams, applies valid results, and re-evaluates body-aware protocol policy after each body replacement. Body-aware protocols retain a bounded hold barrier for this re-evaluation. Other HTTP/1 paths either forward pure STREAM output incrementally or retain a bounded body in RAM when BUFFERED or policy re-evaluation needs it. OpenShell does not create a middleware disk spool. Request bodies do not otherwise become a new general Rego input surface. ```yaml network_middlewares: @@ -435,7 +493,7 @@ network_middlewares: order: 30 config: exclude_images: true - on_error: fail_open + on_error: fail_closed endpoints: include: ["api.example.com"] ``` @@ -448,7 +506,7 @@ V1 middleware configs are policy-local and are not embedded in provider profiles When more than one middleware config matches a request, the supervisor sorts them by ascending numeric `order`. Order values must be unique across the policy and duplicate values are rejected during validation. `order` defaults to `0`, so policies with multiple configs normally set explicit values. Each matching config runs once. Different map keys that reference the same binding remain separate stages and may therefore run more than once with distinct configuration. -A later stage sees the earlier stage's accepted body and header mutations. A middleware `deny` short-circuits the chain. A failed `fail_closed` stage also stops and denies. A failed `fail_open` stage leaves the request representation unchanged for that stage, emits a bypass finding, and permits later stages to run. +A later stage sees the earlier stage's accepted body and header mutations. A middleware rejection or HTTP-stage failure short-circuits the chain and fails closed. WebSocket-only stages may use `fail_open` as described in the WebSocket contract. `before` and `after` constraints are deferred until reusable middleware profiles or cross-policy composition creates a demonstrated need for partial ordering. Implementation-defined ordering is rejected because middleware can transform request content, so operators require deterministic and reviewable behavior. @@ -467,7 +525,7 @@ A middleware decision is observable sandbox behavior, so it is recorded as an OC > **Update in PR #2477 - WebSocket middleware:** The coverage-boundary event below is new. It distinguishes an unsupported operation or message type from a middleware invocation or failure. - **Per-invocation decisions** are `HttpActivity` events, since middleware is an L7 enforcement point. Each stage records the policy-local config key, registered implementation name, decision, transformation state, latency, and policy and endpoint context. Allowed requests are `Informational`; denials are `Medium`. -- **Enforcement failures and bypasses** also emit `DetectionFinding` events. Required-stage failures, invalid responses, uninspectable traffic with a required stage, and body-aware policy evaluation failures are `High`. A `fail_open` bypass and uninspectable traffic allowed because every matching stage is `fail_open` are still findings so operators can alert on reduced enforcement. +- **Enforcement failures and bypasses** also emit `DetectionFinding` events. HTTP-stage failures, invalid responses, uninspectable HTTP traffic, and body-aware policy evaluation failures are `High`. A WebSocket-only `fail_open` bypass is still a finding so operators can alert on reduced enforcement. - **Coverage boundaries** emit informational `NetworkActivity` events separately from invocations and failures. `binding_not_selected` records an attached config whose manifest lacks the WebSocket binding. `unsupported_message_type` records binary pass-through for an active stage with its internal config identity, logical sequence, message class, and size. - **Configuration events** are `ConfigStateChange` events: middleware registration validation, registry reload success or failure, and policy validation outcome. @@ -485,13 +543,13 @@ Supervisor egress middleware stays opt-in throughout: until a policy declares a > **Update in PR #2477 - WebSocket middleware:** Phase 1 now also includes the forward-text WebSocket operation, bounded WebSocket messages, and the WebSocket binding for the built-in regex middleware. -**Phase 1 - research-preview contract and execution.** Define `openshell.middleware.v1` with `Describe`, `ValidateConfig`, bidirectional `HttpRequestPreCredentials.Evaluate`, HTTP response streams, and forward-text `EvaluateWebSocketSession`; ship `openshell/regex` and restricted `openshell/sigv4` built-ins; and support statically registered operator-run services. Policy uses a top-level selector-based `network_middlewares` map with stable config keys, unique numeric `order`, per-stage `on_error`, bounded units and bodies, bounded RPC timeouts, atomic header mutations, post-transformation policy re-evaluation, and OCSF observability. Gateway startup validates external manifests, policy writes validate implementation-owned config, effective sandbox config carries only required external registrations, and supervisors install policy plus registry as one last-known-good runtime generation. Phase 1 requires encrypted authenticated transport for normal use but temporarily permits plaintext `http://` only with explicit `allow_insecure_transport = true` for trusted local development or isolated research. OpenShell warns and emits auditable configuration state whenever that exception is used. +**Phase 1 - research-preview contract and execution.** Define `openshell.middleware.v1` with `Describe`, `ValidateConfig`, bidirectional HTTP request/response streams, and forward-text `EvaluateWebSocketSession`; ship `openshell/regex`; and support statically registered operator-run services. Policy uses a top-level selector-based `network_middlewares` map with stable config keys, unique numeric `order`, fail-closed HTTP handling, bounded units and bodies, bounded RPC timeouts, atomic header mutations, post-transformation policy re-evaluation, and OCSF observability. Gateway startup validates external manifests, policy writes validate implementation-owned config, effective sandbox config carries only required external registrations, and supervisors install policy plus registry as one last-known-good runtime generation. Phase 1 requires encrypted authenticated transport for normal use but temporarily permits plaintext `http://` only with explicit `allow_insecure_transport = true` for trusted local development or isolated research. OpenShell warns and emits auditable configuration state whenever that exception is used. **Phase 2 - mandatory authenticated encryption.** Remove plaintext middleware transport and remove `allow_insecure_transport`. Every external connection must provide transport confidentiality and authenticate the intended service, with the final mechanism and credential delivery model defined by follow-up protocol work. Because phase 1 is explicitly a research preview, removing its insecure escape hatch is an intentional breaking change and does not create a long-term compatibility obligation. Operator-run service deployment otherwise keeps the same binding, policy, validation, delivery, reload, and invocation model. ### Backwards compatibility and migration -Existing sandbox policies and gateway configs that declare no middleware remain valid and pay no per-request cost. The request API change is intentionally breaking within the research preview: services must replace the removed unary request method and messages with `HttpRequestPreCredentials.Evaluate`, implement preflight/body/trailers/session-end sequencing, and register that gRPC service beside `SupervisorMiddleware`. There is no unary fallback. Middleware configs that opt into phase 1 plaintext are intentionally temporary and must migrate to authenticated encrypted endpoints before phase 2. The research-preview contract may make other breaking changes before stability. +Existing sandbox policies and gateway configs that declare no middleware remain valid and pay no per-request cost. The HTTP API change is intentionally breaking within the research preview: services must implement `EvaluateHttp`, advertise protocol version `1` and body capabilities, follow the two-mode lifecycle, and register the phase-specific gRPC service beside `SupervisorMiddleware`. There is no fallback. Middleware configs that opt into phase 1 plaintext are intentionally temporary and must migrate to authenticated encrypted endpoints before phase 2. The research-preview contract may make other breaking changes before stability. ### Research preview @@ -505,7 +563,7 @@ Adding a synchronous, content-aware hook to the egress path has real costs. The - **Hot-path latency and a new per-request dependency.** Each selected external stage makes a synchronous call and blocks on its reply, so middleware latency becomes request latency and the service becomes a new failure surface on the data plane. This is bounded by opt-in host selectors, per-middleware timeouts, and built-ins running in-process with no network hop, but for matching traffic the tax is unavoidable. - **Fail-closed breaks workloads.** Denying traffic when a required middleware is unavailable, times out, or returns a malformed response is the safe default, but it converts a middleware outage into a sandbox outage. The opposite default leaks the very content the middleware exists to control. There is no choice that is both safe and always available; `on_error` makes the tradeoff explicit per middleware, but operators can still pick a default that surprises them. -- **Storage and size limits.** Whole-body inspection still buffers a bounded body. Streaming reduces protobuf message and relay-memory pressure but does not remove finite limits. Owned transformations spool input and output and can consume substantial disk; they therefore have a 1 GiB bound and require `fail_closed` because the original cannot be replayed after ownership transfer. Operators must size storage and middleware capacity for selected traffic. +- **Storage and size limits.** BUFFERED retains a bounded body in supervisor RAM. STREAM reduces protobuf-message and relay-memory pressure but does not remove finite unit, queue, timeout, or optional total limits. Middleware owns any processing storage and cleanup. OpenShell retains no recovery copy and never spools middleware bodies to disk. - **No OpenShell-side rate limiting.** OpenShell bounds concurrent middleware work and buffered memory, but does not throttle fast calls. A middleware that is slow, overloaded, or unavailable is handled by admission backpressure, its timeout, and `on_error`, so operators must still size, scale, and protect the service. - **Trusting an unsandboxed service with raw content.** Middleware receives raw request payloads, and OpenShell does not sandbox it, verify its behavior, or prevent it from mishandling or exfiltrating what it inspects. A buggy or malicious middleware is a direct data-exposure path. Trust in the middleware is the operator's responsibility, the same as trust in a sandbox image, but the blast radius here is in-flight request content. - **A false sense of coverage.** The hook runs only on traffic OpenShell terminates and parses. Opaque TCP or TLS passthrough, encrypted or otherwise opaque bodies, endpoints outside every selector, and content the middleware fails to detect can still leave without effective inspection. Policy validation rejects selector overlap with `tls: skip`, and runtime uninspectability follows the matching chain's failure policy, but detection correctness and traffic outside the selected host set remain inherent limitations. @@ -525,7 +583,7 @@ The cost of *not* doing this is leaving content-level egress control entirely ou Calling an external service from a proxy to inspect, transform, or block in-flight traffic is well-established. The closest analogs: -- **Envoy `ext_proc` (External Processing).** The primary model for this RFC. Envoy streams request headers and body to an external gRPC service that can mutate the body (for example redaction), allow, or deny, and the proxy and the processing service scale independently. `HTTP_REQUEST/PRE_CREDENTIALS` follows the same event-oriented boundary while adding OpenShell-specific modes for whole bodies, lockstep units, and ownership transfer. +- **Envoy `ext_proc` (External Processing).** The primary model for this RFC. Envoy streams request headers and body to an external gRPC service that can mutate the body (for example redaction), allow, or deny, and the proxy and the processing service scale independently. `HTTP_REQUEST/PRE_CREDENTIALS` follows the same event-oriented boundary while defining explicit BUFFERED and independent STREAM semantics. - **Envoy `ext_authz` (External Authorization).** A narrower sibling: an external service returns an allow/deny decision per request. It validates the "delegate the per-request decision to an external service in the hot path" pattern, without the content-transformation half that this RFC needs. - **ICAP (RFC 3507).** HTTP proxies offload content adaptation, virus scanning, DLP, and content filtering to external ICAP servers that can modify or block request/response content. It is the closest *functional* precedent for content-aware egress control. ICAP's pipelining and preview concepts map to our ordered chain and preflight. We avoid its dated text protocol; gRPC provides typed event streams and explicit ownership accounting. - **HashiCorp `go-plugin` (Terraform, Vault).** Third-party plugins run as separate processes and communicate with the core exclusively over gRPC. It shows a strictly typed gRPC contract is a robust way to manage cross-language third-party extensions, which informs our registration plus manifest handshake (`Describe`, `ValidateConfig`). @@ -545,7 +603,7 @@ This section closes the current review themes. - **Operation naming.** Use typed operation and phase enums such as `HTTP_REQUEST/PRE_CREDENTIALS`. The operation describes the middleware API payload, and the phase describes the proxy position. Later protocols can add typed operations such as WebSocket message or TCP connect without renaming the v1 hook. - **Operation scope of v1.** `HTTP_REQUEST/PRE_CREDENTIALS` applies to every HTTP/1.x request that OpenShell terminates and parses, whether or not the endpoint declares a `protocol`; WebSocket upgrade requests are included. `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` applies only to complete client-to-upstream text messages for attachments whose manifest advertises it. Binary and return-path messages, HTTP/2, HTTP/3, opaque TCP, and `tls: skip` traffic are excluded from those operation bindings. - **Route selection and forwarding.** V1 has no `forward_to` decision. Middleware never makes the upstream call. Future route-selection hooks may choose among OpenShell-managed destinations, such as model routes, but must not become arbitrary external endpoint rewrites. -- **SigV4/request signing.** AWS SigV4 belongs to a restricted built-in `HTTP_REQUEST/POST_CREDENTIALS` hook, not external `HTTP_REQUEST/PRE_CREDENTIALS` middleware. Endpoint policy synthesizes the stage, but it must run in-process with supervisor host capabilities so it can strip placeholder signatures and sign with real supervisor-resolved credentials without exposing those credentials over the external middleware contract. +- **SigV4/request signing.** Endpoint policy continues to configure the existing proxy-side SigV4 implementation. A stacked follow-up can move it behind a trusted built-in contract without exposing resolved credentials to external middleware. - **Composability and ordering.** Middleware is chainable and ordered by ascending numeric `order`. Order values must be unique across the policy. A stage receives the previous stage's transformed body and header mutations; `deny` short-circuits the chain; and different config map keys may invoke the same binding as separate stages. - **Header mutation.** Headers preserve duplicates and wire order. External writes and removes may target middleware-visible end-to-end headers and writes support append, overwrite, or skip. Credential-bearing, routing, framing, hop-by-hop, `Connection`-nominated, and OpenShell credential headers remain protected. Each stage's mutations are atomic. - **Finding shape.** Findings never include matched values or raw content. Built-ins may provide contract-defined audit-safe labels. Operator-run text and metadata are untrusted and are replaced or omitted in security outputs in favor of validated implementation names, platform labels, and aggregate counts. @@ -553,9 +611,9 @@ This section closes the current review themes. - **Metadata namespacing.** Metadata is stored under the policy-local middleware config map key rather than the optional human-readable name. This prevents collisions without a central key registry and lets two configs using the same implementation emit independent metadata. - **Selector-only placement.** V1 uses only config-level `endpoints.include` and `endpoints.exclude` selectors. Policy-level and endpoint-level attachment lists are not part of the schema. Selection is independent of the network rule that admitted the request and therefore remains stable after effective-policy composition. - **Failure behavior.** Middleware errors, timeouts, malformed responses, and over-cap inspectable payloads use `on_error` after an operation binding is selected; `fail_closed` is the default. An absent operation binding and binary WebSocket messages are capability coverage states, not failures, and pass with informational telemetry under both error modes. -- **Limits.** V1 caps policies at 10 middleware configs, selectors at 32 combined patterns per config, complete buffered bodies and advertised units at 4 MiB, request stream units at 64 KiB, owned deferred input and output at 1 GiB each, findings at 32 per stage, and all non-body fields at the public envelope limits in the contract section. +- **Limits.** V1 caps policies at 10 middleware configs, selectors at 32 combined patterns per config, complete buffered bodies and advertised units at 4 MiB, request stream units at 64 KiB, findings at 32 per stage, and all non-body fields at the public envelope limits in the contract section. STREAM queues are bounded independently. - **Delivery and reload.** `GetSandboxConfig` delivers only external registrations required by the effective policy. Built-ins are installed locally. Supervisors prepare candidate policy and registry state off-path, swap them as one generation, reuse connections for policy-only changes, and preserve the complete last-known-good runtime on failure. -- **Chunked and compressed bodies.** V1 normalizes fixed and chunked HTTP/1 request bodies into bounded units and preserves validated trailers. Whole-body mode remains bounded by the stage limit. Owned mode permits larger finite transformations only under fail-closed ownership and deferred-storage limits. Compressed bodies remain opaque unless a binding explicitly supports them. +- **Chunked and compressed bodies.** V1 normalizes fixed and chunked HTTP/1 request bodies into bounded units and preserves validated trailers. BUFFERED remains bounded by the stage limit. STREAM middleware may own larger finite working state subject to advertised limits. Compressed bodies remain opaque unless a binding explicitly supports them. - **Post-transformation enforcement.** Every body replacement is re-evaluated by body-aware GraphQL, JSON-RPC, or MCP policy before the next stage or upstream. An enforced denial blocks. In audit mode, a denial is logged, the remaining chain stops, and the transformed request is forwarded. Evaluation failure or an unparseable replacement is a hard denial. Middleware deny and `fail_closed` remain blocking regardless of endpoint audit mode. - **Trust boundary and phases.** Stable external middleware transport requires confidentiality and service authentication. Phase 1 may temporarily allow plaintext only with explicit `allow_insecure_transport = true`, a warning, and an audit event in trusted local or isolated research environments. Phase 2 removes plaintext and `allow_insecure_transport` as an intentional research-preview breaking change. - **Multitenancy.** OpenShell controls middleware application through policy selection. A middleware may receive sandbox and policy context for audit, but OpenShell does not define a middleware-owned tenant grouping model in v1. @@ -563,7 +621,7 @@ This section closes the current review themes. ### Explicit deferrals -- **Provider-profile middleware.** V1 middleware configs live in sandbox policy, not provider profiles. Provider-supplied network policies can be targeted after effective policy assembly. Provider-profile opt-ins for built-in middleware such as `openshell/sigv4`, and reusable cross-sandbox middleware profiles, are follow-up design work. +- **Provider-profile middleware.** V1 middleware configs live in sandbox policy, not provider profiles. Provider-supplied network policies can be targeted after effective policy assembly. Provider-profile opt-ins and reusable cross-sandbox middleware profiles are follow-up design work. - **Authenticated transport mechanism.** Phase 2 requires authenticated encrypted transport. The exact choice between mTLS, TLS plus caller authentication, or an equivalent mechanism, including credential delivery and rotation, is follow-up protocol work. - **Health checks.** V1 relies on connection establishment, `Describe`, per-request invocation, timeout, `on_error`, and registry polling. A dedicated health RPC can improve alerting later but is not required for correctness. - **Registration ergonomics and ownership.** V1 middleware registration is an operator concern: middleware services are declared in gateway configuration and changing the registered set requires a gateway restart. Runtime user-managed registration, CLI/API helpers, SDK helpers, and an agent skill for scaffolding or registering middleware are useful follow-ups after the policy and service contract stabilize. diff --git a/rfc/0009-supervisor-middleware/appendices/extension-authentication.md b/rfc/0009-supervisor-middleware/appendices/extension-authentication.md index bb03a748bd..eb1eb2cb8a 100644 --- a/rfc/0009-supervisor-middleware/appendices/extension-authentication.md +++ b/rfc/0009-supervisor-middleware/appendices/extension-authentication.md @@ -10,7 +10,7 @@ Related: [protocol-extensions.md](protocol-extensions.md#middleware-authenticati Transport is HTTPS with either platform trust roots or an operator-provided CA bundle, with normal certificate and endpoint-hostname verification. A middleware endpoint must be reachable from every sandbox supervisor as well as the gateway, so a gateway-local Unix socket is not an option for this mechanism. -Caller identity is a short-lived Ed25519 JWT minted by the gateway's existing sandbox signing authority. The gateway attaches one to its own `Describe` and `ValidateConfig` calls; sandbox supervisors attach one to `Describe` and operation-specific stream RPCs such as `HttpRequestPreCredentials.Evaluate`. Both directions of the RFC's stated requirement are covered: TLS and the configured trust roots authenticate the middleware service to OpenShell, and the exact-audience JWT proves to the middleware that a gateway or a policy-authorized sandbox supervisor made the call. +Caller identity is a short-lived Ed25519 JWT minted by the gateway's existing sandbox signing authority. The gateway attaches one to its own `Describe` and `ValidateConfig` calls; sandbox supervisors attach one to `Describe` and operation-specific stream RPCs such as `HttpRequestPreCredentials.EvaluateHttp`. Both directions of the RFC's stated requirement are covered: TLS and the configured trust roots authenticate the middleware service to OpenShell, and the exact-audience JWT proves to the middleware that a gateway or a policy-authorized sandbox supervisor made the call. ## Claim contract diff --git a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md index 8ca74362ca..8cc3d0c857 100644 --- a/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md +++ b/rfc/0009-supervisor-middleware/appendices/protocol-extensions.md @@ -6,7 +6,7 @@ V1 includes event-oriented HTTP request and response streams plus a forward-text ## Request streaming -`HttpRequestPreCredentials.Evaluate` is bidirectional streaming. Preflight selects header-only, whole-body, lockstep stream, or owned-stream processing. OpenShell normalizes HTTP/1 fixed and chunked bodies into bounded units, carries validated trailers as a separate event, and rebuilds transport framing after evaluation. +`HttpRequestPreCredentials.EvaluateHttp` is bidirectional streaming. Preflight continues without a body, rejects, or selects BUFFERED or STREAM. OpenShell normalizes HTTP/1 fixed and chunked bodies into bounded units, carries validated trailers at body end, and rebuilds transport framing after evaluation. ### Transport streaming vs processing streaming @@ -17,30 +17,30 @@ These are different concepts and are easy to conflate: Selecting a stream mode governs processing semantics; using the streaming RPC alone does not promise incremental processing. -### Full-body guards still buffer +### Full-body guards choose storage deliberately -Many guards need the entire body to do anything: a JSON-aware redactor must parse the whole document, and a PII scan may need all of it. Such a guard selects `WHOLE_BODY_BYTES` when the body fits the advertised limit. A transformation such as Git signing that needs complete input larger than one protobuf message selects fail-closed `OWNED_STREAM_BYTES`, spools accepted units, and emits output only after final input. Incremental guards select `STREAM_BYTES` and account for each unit without retaining input across results. +Many guards need the entire body to do anything: a JSON-aware redactor must parse the whole document, and a signer may need all bytes before emitting its output head. A guard selects BUFFERED when the complete body fits the negotiated RAM bound. Otherwise it selects STREAM, owns its working storage and cleanup, consumes input independently, and delays output until ready. Incremental guards also select STREAM but may emit output early. ### What streaming provides -The request stream provides two important properties: +The request stream provides three important properties: - It removes the requirement that a complete request fit in one gRPC message. OpenShell caps request units at 64 KiB. -- Owned mode lets the service spool a larger finite transformation without OpenShell retaining a replay copy. Input and output are each bounded at 1 GiB and failure is always closed after ownership begins. +- Independent pumps let the service change chunk cardinality, retain lookahead, or wait for complete input without blocking OpenShell from continuing to deliver input. +- STREAM assigns output responsibility at preflight without OpenShell retaining a replay copy. -For a chain made only of `STREAM_BYTES` stages, OpenShell may forward each approved unit upstream before the request ends. A later denial or failure terminates that upload but cannot retract prior bytes, so the relay never retries or replays it. Whole-body and owned stages, body-aware policy re-evaluation, request-body credential rewriting, and body-dependent signing retain a hold barrier. OpenShell rebuilds framing for both paths and can stop a live upload when the upstream responds early. +For a chain made only of STREAM stages, OpenShell may forward output upstream before the request ends. A later rejection or failure terminates that upload but cannot retract prior bytes, so the relay never retries or replays it. BUFFERED stages, body-aware policy re-evaluation, and request-body credential rewriting retain a bounded in-memory hold barrier. OpenShell rebuilds framing for both paths and can stop a live upload when the upstream responds early. -The state machine requires one preflight, contiguous input sequences, one result for each input unit, an optional owned-output phase with contiguous sequences and exact final accounting, one trailers exchange for active body stages, and a terminal notification. Invalid modes, sequences, actions, diagnostics, or finalization follow `on_error`, except that owned stages cannot fail open. +The state machine requires one preflight and, after selection, one Begin. BUFFERED has one body and one result. STREAM has nonempty input chunks plus one input end, independent output start/chunks, and finish after input end. Input and output cardinality do not correspond. Unknown, duplicate, missing, or out-of-order events fail closed. A best-effort terminal notification ends the session. ## Additional operation phases > **Update in PR #2477 - WebSocket middleware:** This section now records `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` as implemented. It keeps `WEBSOCKET_MESSAGE/PRE_RETURN` as a reserved future operation. -V1 supports `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and forward-text `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. It also implements restricted built-in-only `HTTP_REQUEST/POST_CREDENTIALS`. Each operation and phase pair encodes a different position in the proxy flow: +V1 supports `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and forward-text `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. Each operation and phase pair encodes a different position in the proxy flow: - `Connection/before_policy` / `HttpRequest/before_policy` - *before* network/L7 policy admits the request, for earlier classification. Riskier, because request content reaches a service before policy has allowed the request. - `HTTP_REQUEST/PRE_CREDENTIALS` (v1) - after policy admits the request, before credential injection. -- `HTTP_REQUEST/POST_CREDENTIALS` (v1 restricted) - after credential resolution, immediately before the relay writes the request upstream. This hook is credential-visible, so it is built-in-only: OpenShell rejects any externally registered middleware that advertises it. `openshell/sigv4` strips placeholder-signed AWS headers and signs the finalized request with supervisor-resolved credentials. - `HttpResponse/completed` - after an upstream request completes, emit metadata such as status, content length, selected route, selected model, and model usage if available. This is notification-only: no body, no transformation, and no allow/deny verdict. It would let reservation-style budget middleware reconcile a pre-dispatch decision without introducing response-body inspection. - `HTTP_RESPONSE/PRE_RETURN` (v1) - on the return path, after the upstream responds and before the response reaches the sandbox; inspect, redact, or block upstream responses. - `WEBSOCKET_MESSAGE/PRE_CREDENTIALS` (v1 forward text) - after a WebSocket upgrade, on each complete client text message before credential placeholder rewriting. Before upstream contact, a concurrent preflight lets each selected stage inspect, voluntarily skip, or authoritatively deny the upgrade. Explicit denial takes precedence over failures and is enforced independently of `on_error`; OpenShell best-effort ends every still-writable opened stage stream with the typed terminal reason. An attached implementation without this binding is not selected and records coverage rather than applying `on_error`. Binary messages pass without inspection, consume a logical sequence, and record unsupported-message coverage for active stages. diff --git a/skills/debug-openshell-cluster/references/supervisor-middleware.md b/skills/debug-openshell-cluster/references/supervisor-middleware.md index cbaab71a73..ca30cd4a7c 100644 --- a/skills/debug-openshell-cluster/references/supervisor-middleware.md +++ b/skills/debug-openshell-cluster/references/supervisor-middleware.md @@ -20,7 +20,7 @@ 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 external V1 bindings are `HTTP_REQUEST/PRE_CREDENTIALS`, `HTTP_RESPONSE/PRE_RETURN`, and `WEBSOCKET_MESSAGE/PRE_CREDENTIALS`. External manifests advertising `POST_CREDENTIALS` are rejected. `openshell/sigv4` is synthesized internally from endpoint credential-signing fields and must not appear in `network_middlewares`. +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`. HTTP bindings must advertise protocol version `1` and at least one `BUFFERED` or `STREAM` capability. 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. @@ -30,11 +30,11 @@ For response failures, distinguish a deliberate `middleware_denied` decision fro ## Request and WebSocket failures -For request streams, confirm the service receives preflight, contiguous body sequences, one trailers event, and one best-effort terminal event. Whole-body mode receives the complete body in its final unit. `STREAM_BYTES` and `OWNED_STREAM_BYTES` receive nonempty units of at most 64 KiB followed by one empty unit with `end_of_stream`. Owned mode is available only to `fail_closed` stages; the stage must acknowledge every input, emit contiguous output, and return exact final input/output accounting. Deferred input and output are each capped at 1 GiB. A pure `STREAM_BYTES` chain can forward approved units upstream immediately; a later denial terminates the upload without replay. Whole-body, owned, policy re-evaluation, body credential rewrite, and body-signing paths spool first. Request body receipt, evaluation, and output delivery share a fixed 120-second wall-clock deadline; expiry cancels the stage and terminates the request. +For HTTP streams, confirm the service receives one preflight followed by the selected lifecycle and one best-effort terminal event. `BUFFERED` receives exactly one complete body and returns explicit `Unchanged` or replacement bytes. `STREAM` receives nonempty input chunks of at most 64 KiB and one input end; output independently begins once, carries zero or more nonempty chunks, and finishes only after input end. A pure `STREAM` chain can forward output upstream immediately; a later denial terminates the upload without replay. OpenShell never creates a middleware disk spool or recovery copy. Request body receipt, evaluation, and output delivery share a fixed 120-second wall-clock deadline; expiry cancels the stage and terminates the request. 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`. +An explicit `middleware_denied` result is always enforced. HTTP middleware is always fail-closed; policy activation rejects `fail_open` for implementations with HTTP bindings. WebSocket preflight returns `INSPECT`, voluntary `SKIP`, or authoritative `DENY`; `DENY` rejects the upgrade before upstream contact under both WebSocket `on_error` modes. A fail-open WebSocket failure bypasses only that stage and emits a detection finding. 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`. @@ -47,9 +47,8 @@ WebSocket message sequences are allocated session-wide; each stage receives a st | 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 | -| Policy names `openshell/sigv4` in `network_middlewares` or an external service advertises `POST_CREDENTIALS` | SigV4 is an endpoint-synthesized trusted stage; credential-visible phases are unavailable to external middleware | Remove the attachment; set endpoint `credential_signing`, `signing_service`, and `signing_region`; inspect provider endpoint bindings | | 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` | -| Large Git push fails after ownership transfer | Owned stage used fail-open, exceeded its 1 GiB deferred bound, returned noncontiguous output, or reported incorrect final accounting | Require `fail_closed`; inspect input/output sequences, `body_finalize`, spool capacity, and service logs | +| HTTP middleware policy fails activation | The config uses `fail_open`, or the service omitted protocol version/body capabilities | Use `fail_closed`; advertise protocol version `1` and `BUFFERED` and/or `STREAM` | | Request stalls and fails near 120 seconds | Client body receipt, middleware processing, or output delivery exceeded the fixed request deadline | Client upload progress; per-stage timeout logs; middleware and upstream backpressure; disk capacity and latency for withholding modes | | 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 | diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index 44ccfe9bb7..6e69a064cc 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -218,13 +218,12 @@ Is L7 inspection needed? 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. -- Do not add `openshell/sigv4` to `network_middlewares`. Endpoint `credential_signing`, `signing_service`, and optional `signing_region` fields synthesize this trusted in-process `HTTP_REQUEST/POST_CREDENTIALS` stage after provider credentials resolve. External services cannot advertise that phase. - 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 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. -- Use `fail_closed` for an implementation that selects `OWNED_STREAM_BYTES`. Ownership removes the platform replay copy, so an owned-stage failure cannot fail open. Request stream units are at most 64 KiB; the advertised deferred input and output limit is 1 GiB each. +- HTTP middleware must use `fail_closed`; activation rejects `fail_open` for an implementation with an HTTP binding. Request `STREAM` units are at most 64 KiB and OpenShell retains no recovery copy. - 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. +- Default `on_error` to `fail_closed`. Use `fail_open` only for WebSocket-only middleware 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. - Do not select fail-closed middleware for `tls: skip` endpoints because the supervisor cannot inspect that traffic. @@ -389,7 +388,7 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] 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` - [ ] SigV4 uses endpoint credential-signing fields rather than a `network_middlewares` attachment, and the endpoint's provider binding covers the signed destination -- [ ] Any middleware that requires `OWNED_STREAM_BYTES` uses `on_error: fail_closed` +- [ ] Every middleware implementation with an HTTP binding uses `on_error: fail_closed` - [ ] 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) @@ -423,7 +422,7 @@ Evaluate the generated policy for overly broad access and **include warnings in | **Multiple broad endpoints** in one policy | "This policy grants the same broad access to N different hosts. If any of these hosts needs tighter restrictions later, you'll need to split the policy." | | **Hostless `allowed_ips`** (no `host` field and no `protocol: tcp`) | "This endpoint has no `host` — any domain resolving to the allowed IP range on this port will be permitted through the legacy proxy. Consider adding a `host` field to restrict which domains can use this allowlist." | | **Broad CIDR** in `allowed_ips` (e.g., `10.0.0.0/8`) | "This `allowed_ips` entry covers a very broad range. Consider narrowing to a specific subnet (e.g., `10.0.5.0/24`) to minimize exposure." | -| **`on_error: fail_open`** | "This middleware can be bypassed when it is unavailable, rejects configuration, returns an invalid result, or exceeds its body limit. Use `fail_closed` unless availability is more important than this control." | +| **`on_error: fail_open`** | "This WebSocket-only middleware can be bypassed when a selected stage fails. HTTP-capable implementations cannot use `fail_open`. Use `fail_closed` unless availability is more important than this control." | | **Broad middleware host selector** | "This middleware attaches independently of the admitting network rule to every matching destination, then runs only for operation bindings its implementation advertises. Narrow `endpoints.include` or add exclusions if the attachment is not required for every matching host." | | **`allow_uninspected_credentials: true`** | "This endpoint may carry provider credentials on traffic OpenShell cannot inspect or rewrite. Prefer an inspected protocol and credential rewrite; keep this exception only when raw traffic is required." | diff --git a/skills/openshell-cli/SKILL.md b/skills/openshell-cli/SKILL.md index ce67543274..38eb1a1ff4 100644 --- a/skills/openshell-cli/SKILL.md +++ b/skills/openshell-cli/SKILL.md @@ -529,11 +529,11 @@ 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 request/response and WebSocket bindings, and `fail_open` or `fail_closed` behavior +- Ordered `network_middlewares`, host selection, HTTP request/response and WebSocket bindings, fail-closed HTTP behavior, and WebSocket-only `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. -`openshell/sigv4` is not a `network_middlewares` attachment. The endpoint's `credential_signing`, `signing_service`, and optional `signing_region` fields synthesize a trusted in-process `HTTP_REQUEST/POST_CREDENTIALS` stage after AWS credentials resolve. External middleware cannot advertise this credential-visible phase. Middleware that selects `OWNED_STREAM_BYTES`, such as a Git signing service, must use `fail_closed`; the original request is no longer replayable after ownership transfer. +Endpoint `credential_signing`, `signing_service`, and optional `signing_region` fields configure the existing proxy-side SigV4 path; they are not `network_middlewares` attachments. HTTP middleware must use `fail_closed`. Request bindings advertise protocol version `1` plus `BUFFERED` and/or `STREAM`; OpenShell retains no recovery copy after a stage selects `STREAM`. Middleware can inspect HTTP requests, HTTP responses, or client WebSocket text messages when the implementation advertises the matching binding. The built-in diff --git a/tasks/rust.toml b/tasks/rust.toml index df80185be1..32928b8f69 100644 --- a/tasks/rust.toml +++ b/tasks/rust.toml @@ -32,7 +32,6 @@ run = [ "cargo clippy --manifest-path e2e/rust/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/governance-interceptor/Cargo.toml --all-targets -- -D warnings", "cargo clippy --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml --all-targets -- -D warnings", - "cargo clippy --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml --all-targets -- -D warnings", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 lint native" hide = true @@ -44,7 +43,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-git-signing/Cargo.toml --all", ] hide = true @@ -55,7 +53,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-git-signing/Cargo.toml --all -- --check", ] hide = true diff --git a/tasks/test.toml b/tasks/test.toml index d19c1676de..2b0feb2bba 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -86,7 +86,6 @@ run = [ "cargo test --workspace --exclude openshell-server", "cargo test -p openshell-server --features test-support", "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml", - "cargo nextest run --config-file .config/nextest.toml --manifest-path examples/supervisor-middleware-git-signing/Cargo.toml", ] run_windows = "powershell -NoProfile -ExecutionPolicy Bypass -File tasks/scripts/windows-msvc.ps1 test-precommit native" hide = true