Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/branch-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ jobs:
OPENSHELL_TELEMETRY_ENABLED: "false"
run: |
cargo nextest run --profile ci --workspace --features openshell-server/test-support
cargo nextest run --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml

- name: Verify telemetry can be compiled out
run: |
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 17 additions & 0 deletions architecture/sandbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,23 @@ 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 selected middleware chain can also inspect the final HTTP response before
it returns to the workload. Stages select header-only, whole-body, or streaming
inspection independently. The relay owns response framing when body bytes can
change. Preflight exposes upstream `Content-Length`, `Content-Encoding`, and
`Content-Range` as read-only metadata, while the relay emits final framing
separately from middleware-visible headers. Stage failures follow policy-local
`on_error`; explicit denials always block delivery. Once delivery has started,
blocking aborts the response.

The network supervisor represents the destination-selected request and response
pair as one `HttpMiddlewareExchange`. It retains the full chain, runner, request
identity, and policy generation while request and response bindings are selected
independently. The HTTP response adapter owns wire parsing, downstream commit
state, generation fences, framing, and transport error classification. The
generic middleware crate owns stage selection, remote stream lifecycle, ordered
body processing, limits, and result validation.

The supervisor installs policy and middleware registry changes as one runtime
generation and preserves the last-known-good generation if preparation fails.
Policy-only updates reuse the connected registry, so an external middleware
Expand Down
54 changes: 42 additions & 12 deletions crates/openshell-supervisor-middleware/src/headers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -305,28 +305,39 @@ fn is_request_protected(name: &str) -> bool {
|| name.starts_with("x-openshell-credential")
}

fn is_response_protected(name: &str) -> bool {
/// Return whether a response header can carry authentication material and must
/// never be exposed to middleware.
#[must_use]
pub fn is_response_credential_header(name: &str) -> bool {
let name = name.to_ascii_lowercase();
matches!(
name,
name.as_str(),
"authentication-info"
| "connection"
| "content-encoding"
| "content-length"
| "content-range"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authentication-info"
| "proxy-authorization"
| "proxy-connection"
| "set-cookie"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
| "www-authenticate"
) || name.starts_with("x-openshell-credential")
}

fn is_response_protected(name: &str) -> bool {
is_response_credential_header(name)
|| matches!(
name,
"connection"
| "content-encoding"
| "content-length"
| "content-range"
| "keep-alive"
| "proxy-connection"
| "te"
| "trailer"
| "transfer-encoding"
| "upgrade"
)
}

fn is_response_remove_only(name: &str) -> bool {
matches!(
name,
Expand Down Expand Up @@ -642,6 +653,25 @@ mod tests {
}
}

#[test]
fn response_authority_keeps_visible_body_metadata_read_only() {
let existing = [
header("content-length", "5"),
header("content-encoding", "gzip"),
header("content-range", "bytes 0-4/10"),
];
for name in ["Content-Length", "Content-Encoding", "Content-Range"] {
for mutation in [
write(name, "replacement", ExistingHeaderAction::Overwrite),
remove(name),
] {
let error = apply(HeaderAuthority::Response, &existing, &[], &[mutation])
.expect_err("read-only response body metadata");
assert!(matches!(error, HeaderMutationError::Protected { .. }));
}
}
}

#[test]
fn response_authority_protects_credential_headers_from_writes_and_removals() {
let existing = [header("set-cookie", "session=upstream")];
Expand Down
34 changes: 23 additions & 11 deletions crates/openshell-supervisor-middleware/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@

pub mod headers;
mod remote;
mod response;
mod websocket;

pub use response::{
HttpResponseDiagnostics, HttpResponseFinish, HttpResponseInvocation,
HttpResponseInvocationOutcome, HttpResponseMiddlewareFailure, HttpResponsePreflightInput,
HttpResponsePreflightOutcome, HttpResponseSession, MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES,
MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES, is_stale_http_response_integrity_header,
};

pub use websocket::{
WebSocketCoverage, WebSocketCoverageState, WebSocketInvocation, WebSocketInvocationOutcome,
WebSocketMessageAdmission, WebSocketMessageOutcome, WebSocketMessageType,
Expand Down Expand Up @@ -626,6 +634,16 @@ impl MiddlewareDispatch {
Self::Grpc(service) => service.open_websocket_session(receiver).await,
}
}

async fn open_http_response_pre_return(
&self,
receiver: tokio::sync::mpsc::Receiver<openshell_core::proto::HttpResponseEvent>,
) -> std::result::Result<HttpResponseResultStream, tonic::Status> {
match self {
Self::InProcess(service) => service.open_http_response_pre_return(receiver).await,
Self::Grpc(service) => service.open_http_response_pre_return(receiver).await,
}
}
}

struct MiddlewareServiceState {
Expand Down Expand Up @@ -831,6 +849,7 @@ fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result<u
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SupportedBinding {
HttpPreCredentials,
HttpResponsePreReturn,
WebSocketPreCredentials,
}

Expand All @@ -846,9 +865,7 @@ fn supported_binding(source: &str, binding: &MiddlewareBinding) -> Result<Suppor
(
Some(SupervisorMiddlewareOperation::HttpResponse),
Some(SupervisorMiddlewarePhase::PreReturn),
) => Err(miette!(
"{source} advertises HTTP_RESPONSE/PRE_RETURN, which is not yet supported"
)),
) => Ok(SupportedBinding::HttpResponsePreReturn),
(
Some(SupervisorMiddlewareOperation::WebsocketMessage),
Some(SupervisorMiddlewarePhase::PreCredentials),
Expand Down Expand Up @@ -3686,7 +3703,7 @@ mod tests {
}

#[test]
fn manifest_rejects_http_response_pre_return_binding_until_dispatch_is_available() {
fn manifest_accepts_http_response_pre_return_binding_when_dispatch_is_available() {
let registration = external_registration(4096);
let manifest = MiddlewareManifest {
name: "example/response".into(),
Expand All @@ -3700,13 +3717,8 @@ mod tests {
expected_audience: String::new(),
};

let error = validate_external_manifest(&registration, &manifest, 4096, false)
.expect_err("HTTP response pre-return binding must remain unavailable");
assert!(
error
.to_string()
.contains("HTTP_RESPONSE/PRE_RETURN, which is not yet supported")
);
validate_external_manifest(&registration, &manifest, 4096, false)
.expect("HTTP response pre-return binding is supported");
}

#[test]
Expand Down
8 changes: 8 additions & 0 deletions crates/openshell-supervisor-middleware/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,14 @@ impl GrpcMiddlewareService {
) -> std::result::Result<WebSocketResponseStream, Status> {
self.service.open_websocket_session(receiver).await
}

/// Open a remote HTTP response pre-return stream through the gRPC adapter.
pub async fn open_http_response_pre_return(
&self,
receiver: tokio::sync::mpsc::Receiver<HttpResponseEvent>,
) -> std::result::Result<HttpResponseResultStream, Status> {
self.service.open_http_response_pre_return(receiver).await
}
}

#[derive(Clone)]
Expand Down
Loading
Loading