Skip to content

Commit 769273f

Browse files
authored
feat(middleware): add a hook to inspect HTTP responses (#3074)
* feat(middleware): implement HTTP response processing Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(middleware): allow one-byte response stream units Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * feat(examples): separate content guard from middleware protocol demos Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(middleware): address HTTP response review findings Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * refactor(examples): defer protocol demo to a separate PR Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * refactor(middleware): keep response body timeout local Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(middleware): honor fail-open for unrepresentable responses Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * docs(middleware): trim runtime docs and extract troubleshooting reference Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * chore(middleware): split build fix and simplify test and skill guidance Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * refactor(middleware): isolate HTTP response processing Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(middleware): hide response credential headers Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(middleware): end invalid preflight streams Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * docs(middleware): remove hard-wrapped prose Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> * fix(middleware): use protobuf request timeouts Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com> --------- Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent 9b9f790 commit 769273f

27 files changed

Lines changed: 8798 additions & 600 deletions

File tree

‎.github/workflows/branch-checks.yml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ jobs:
172172
OPENSHELL_TELEMETRY_ENABLED: "false"
173173
run: |
174174
cargo nextest run --profile ci --workspace --features openshell-server/test-support
175+
cargo nextest run --config-file .config/nextest.toml --profile ci --manifest-path examples/supervisor-middleware-content-guard/Cargo.toml
175176
176177
- name: Verify standalone policy prover package
177178
if: matrix.system == 'x86_64-linux'

‎architecture/sandbox.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,23 @@ middleware registry validates implementation-owned config. The generic
304304
registry and chain runner live in `openshell-supervisor-middleware`; first-party
305305
implementations live in `openshell-supervisor-middleware-builtins`.
306306

307+
The selected middleware chain can also inspect the final HTTP response before
308+
it returns to the workload. Stages select header-only, whole-body, or streaming
309+
inspection independently. The relay owns response framing when body bytes can
310+
change. Preflight exposes upstream `Content-Length`, `Content-Encoding`, and
311+
`Content-Range` as read-only metadata, while the relay emits final framing
312+
separately from middleware-visible headers. Stage failures follow policy-local
313+
`on_error`; explicit denials always block delivery. Once delivery has started,
314+
blocking aborts the response.
315+
316+
The network supervisor represents the destination-selected request and response
317+
pair as one `HttpMiddlewareExchange`. It retains the full chain, runner, request
318+
identity, and policy generation while request and response bindings are selected
319+
independently. The HTTP response adapter owns wire parsing, downstream commit
320+
state, generation fences, framing, and transport error classification. The
321+
generic middleware crate owns stage selection, remote stream lifecycle, ordered
322+
body processing, limits, and result validation.
323+
307324
The supervisor installs policy and middleware registry changes as one runtime
308325
generation and preserves the last-known-good generation if preparation fails.
309326
Policy-only updates reuse the connected registry, so an external middleware

‎crates/openshell-supervisor-middleware/src/headers.rs‎

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -305,28 +305,39 @@ fn is_request_protected(name: &str) -> bool {
305305
|| name.starts_with("x-openshell-credential")
306306
}
307307

308-
fn is_response_protected(name: &str) -> bool {
308+
/// Return whether a response header can carry authentication material and must
309+
/// never be exposed to middleware.
310+
#[must_use]
311+
pub fn is_response_credential_header(name: &str) -> bool {
312+
let name = name.to_ascii_lowercase();
309313
matches!(
310-
name,
314+
name.as_str(),
311315
"authentication-info"
312-
| "connection"
313-
| "content-encoding"
314-
| "content-length"
315-
| "content-range"
316-
| "keep-alive"
317316
| "proxy-authenticate"
318317
| "proxy-authentication-info"
319318
| "proxy-authorization"
320-
| "proxy-connection"
321319
| "set-cookie"
322-
| "te"
323-
| "trailer"
324-
| "transfer-encoding"
325-
| "upgrade"
326320
| "www-authenticate"
327321
) || name.starts_with("x-openshell-credential")
328322
}
329323

324+
fn is_response_protected(name: &str) -> bool {
325+
is_response_credential_header(name)
326+
|| matches!(
327+
name,
328+
"connection"
329+
| "content-encoding"
330+
| "content-length"
331+
| "content-range"
332+
| "keep-alive"
333+
| "proxy-connection"
334+
| "te"
335+
| "trailer"
336+
| "transfer-encoding"
337+
| "upgrade"
338+
)
339+
}
340+
330341
fn is_response_remove_only(name: &str) -> bool {
331342
matches!(
332343
name,
@@ -642,6 +653,25 @@ mod tests {
642653
}
643654
}
644655

656+
#[test]
657+
fn response_authority_keeps_visible_body_metadata_read_only() {
658+
let existing = [
659+
header("content-length", "5"),
660+
header("content-encoding", "gzip"),
661+
header("content-range", "bytes 0-4/10"),
662+
];
663+
for name in ["Content-Length", "Content-Encoding", "Content-Range"] {
664+
for mutation in [
665+
write(name, "replacement", ExistingHeaderAction::Overwrite),
666+
remove(name),
667+
] {
668+
let error = apply(HeaderAuthority::Response, &existing, &[], &[mutation])
669+
.expect_err("read-only response body metadata");
670+
assert!(matches!(error, HeaderMutationError::Protected { .. }));
671+
}
672+
}
673+
}
674+
645675
#[test]
646676
fn response_authority_protects_credential_headers_from_writes_and_removals() {
647677
let existing = [header("set-cookie", "session=upstream")];

‎crates/openshell-supervisor-middleware/src/lib.rs‎

Lines changed: 23 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,16 @@
55
66
pub mod headers;
77
mod remote;
8+
mod response;
89
mod websocket;
910

11+
pub use response::{
12+
HttpResponseDiagnostics, HttpResponseFinish, HttpResponseInvocation,
13+
HttpResponseInvocationOutcome, HttpResponseMiddlewareFailure, HttpResponsePreflightInput,
14+
HttpResponsePreflightOutcome, HttpResponseSession, MAX_HTTP_RESPONSE_RETAINED_BODY_BYTES,
15+
MAX_HTTP_RESPONSE_STREAM_UNIT_BYTES, is_stale_http_response_integrity_header,
16+
};
17+
1018
pub use websocket::{
1119
WebSocketCoverage, WebSocketCoverageState, WebSocketInvocation, WebSocketInvocationOutcome,
1220
WebSocketMessageAdmission, WebSocketMessageOutcome, WebSocketMessageType,
@@ -626,6 +634,16 @@ impl MiddlewareDispatch {
626634
Self::Grpc(service) => service.open_websocket_session(receiver).await,
627635
}
628636
}
637+
638+
async fn open_http_response_pre_return(
639+
&self,
640+
receiver: tokio::sync::mpsc::Receiver<openshell_core::proto::HttpResponseEvent>,
641+
) -> std::result::Result<HttpResponseResultStream, tonic::Status> {
642+
match self {
643+
Self::InProcess(service) => service.open_http_response_pre_return(receiver).await,
644+
Self::Grpc(service) => service.open_http_response_pre_return(receiver).await,
645+
}
646+
}
629647
}
630648

631649
struct MiddlewareServiceState {
@@ -836,6 +854,7 @@ fn validate_payload_limit(source: &str, binding: &MiddlewareBinding) -> Result<u
836854
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837855
enum SupportedBinding {
838856
HttpPreCredentials,
857+
HttpResponsePreReturn,
839858
WebSocketPreCredentials,
840859
}
841860

@@ -851,9 +870,7 @@ fn supported_binding(source: &str, binding: &MiddlewareBinding) -> Result<Suppor
851870
(
852871
Some(SupervisorMiddlewareOperation::HttpResponse),
853872
Some(SupervisorMiddlewarePhase::PreReturn),
854-
) => Err(miette!(
855-
"{source} advertises HTTP_RESPONSE/PRE_RETURN, which is not yet supported"
856-
)),
873+
) => Ok(SupportedBinding::HttpResponsePreReturn),
857874
(
858875
Some(SupervisorMiddlewareOperation::WebsocketMessage),
859876
Some(SupervisorMiddlewarePhase::PreCredentials),
@@ -3721,7 +3738,7 @@ mod tests {
37213738
}
37223739

37233740
#[test]
3724-
fn manifest_rejects_http_response_pre_return_binding_until_dispatch_is_available() {
3741+
fn manifest_accepts_http_response_pre_return_binding_when_dispatch_is_available() {
37253742
let registration = external_registration(4096);
37263743
let manifest = MiddlewareManifest {
37273744
name: "example/response".into(),
@@ -3738,13 +3755,8 @@ mod tests {
37383755
expected_audience: String::new(),
37393756
};
37403757

3741-
let error = validate_external_manifest(&registration, &manifest, 4096, false)
3742-
.expect_err("HTTP response pre-return binding must remain unavailable");
3743-
assert!(
3744-
error
3745-
.to_string()
3746-
.contains("HTTP_RESPONSE/PRE_RETURN, which is not yet supported")
3747-
);
3758+
validate_external_manifest(&registration, &manifest, 4096, false)
3759+
.expect("HTTP response pre-return binding is supported");
37483760
}
37493761

37503762
#[test]

‎crates/openshell-supervisor-middleware/src/remote.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,14 @@ impl GrpcMiddlewareService {
103103
) -> std::result::Result<WebSocketResponseStream, Status> {
104104
self.service.open_websocket_session(receiver).await
105105
}
106+
107+
/// Open a remote HTTP response pre-return stream through the gRPC adapter.
108+
pub async fn open_http_response_pre_return(
109+
&self,
110+
receiver: tokio::sync::mpsc::Receiver<HttpResponseEvent>,
111+
) -> std::result::Result<HttpResponseResultStream, Status> {
112+
self.service.open_http_response_pre_return(receiver).await
113+
}
106114
}
107115

108116
#[derive(Clone)]

0 commit comments

Comments
 (0)