Skip to content

Commit 66babd2

Browse files
committed
feat(middleware): add HTTP response trailer results
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
1 parent 97da678 commit 66babd2

2 files changed

Lines changed: 166 additions & 3 deletions

File tree

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

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ pub const MAX_HEADER_MUTATION_BYTES: usize = 32 * 1024;
1616
pub enum HeaderAuthority {
1717
Request,
1818
Response,
19+
ResponseTrailers,
1920
}
2021

2122
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -30,6 +31,7 @@ pub enum HeaderMutationError {
3031
InvalidExistingAction,
3132
MissingExistingAction { name: String },
3233
UnsupportedExistingAction,
34+
AbsentTrailerName { name: String },
3335
Empty,
3436
}
3537

@@ -47,6 +49,7 @@ impl HeaderMutationError {
4749
Self::InvalidExistingAction => "header_mutation_invalid_existing_action",
4850
Self::MissingExistingAction { .. } => "header_mutation_missing_existing_action",
4951
Self::UnsupportedExistingAction => "header_mutation_unsupported_existing_action",
52+
Self::AbsentTrailerName { .. } => "trailer_mutation_absent_name",
5053
Self::Empty => "header_mutation_empty",
5154
}
5255
}
@@ -104,6 +107,10 @@ impl std::fmt::Display for HeaderMutationError {
104107
"middleware returned unsupported on_existing action"
105108
)
106109
}
110+
Self::AbsentTrailerName { name } => write!(
111+
formatter,
112+
"middleware cannot create absent response trailer '{name}'"
113+
),
107114
Self::Empty => write!(formatter, "middleware returned an empty header mutation"),
108115
}
109116
}
@@ -133,6 +140,15 @@ pub fn apply(
133140
Some(header_mutation::Operation::Write(write)) => {
134141
let name = validate_name(&write.name)?;
135142
validate_authority(authority, MutationKind::Write, &write.name, &name)?;
143+
if authority == HeaderAuthority::ResponseTrailers
144+
&& !existing_headers
145+
.iter()
146+
.any(|existing| existing.name.eq_ignore_ascii_case(&name))
147+
{
148+
return Err(HeaderMutationError::AbsentTrailerName {
149+
name: write.name.clone(),
150+
});
151+
}
136152
if is_connection_nominated(connection_nominated_headers, &name) {
137153
return Err(HeaderMutationError::HopByHop {
138154
name: write.name.clone(),
@@ -229,6 +245,7 @@ fn validate_authority(
229245
is_response_protected(normalized_name)
230246
|| (kind == MutationKind::Write && is_response_remove_only(normalized_name))
231247
}
248+
HeaderAuthority::ResponseTrailers => is_response_protected(normalized_name),
232249
};
233250
if protected {
234251
return Err(HeaderMutationError::Protected {
@@ -650,4 +667,115 @@ mod tests {
650667
}
651668
}
652669
}
670+
671+
#[test]
672+
fn empty_response_trailers_accept_pass_through() {
673+
assert_eq!(
674+
apply(HeaderAuthority::ResponseTrailers, &[], &[], &[]),
675+
Ok(Vec::new())
676+
);
677+
}
678+
679+
#[test]
680+
fn response_trailer_pass_through_preserves_fields_and_order() {
681+
let existing = [
682+
header("x-checksum", "one"),
683+
header("x-trace", "middle"),
684+
header("x-checksum", "two"),
685+
];
686+
687+
let updated = apply(HeaderAuthority::ResponseTrailers, &existing, &[], &[])
688+
.expect("empty mutation list");
689+
690+
assert_eq!(updated, existing);
691+
}
692+
693+
#[test]
694+
fn response_trailer_mutations_modify_remove_and_preserve_order() {
695+
let existing = [
696+
header("x-checksum", "one"),
697+
header("x-remove", "gone"),
698+
header("x-trace", "middle"),
699+
header("x-checksum", "two"),
700+
];
701+
702+
let updated = apply(
703+
HeaderAuthority::ResponseTrailers,
704+
&existing,
705+
&[],
706+
&[
707+
write("X-Checksum", "replacement", ExistingHeaderAction::Overwrite),
708+
remove("X-Remove"),
709+
write("X-Trace", "last", ExistingHeaderAction::Append),
710+
],
711+
)
712+
.expect("permitted response trailer mutations");
713+
714+
assert_eq!(
715+
updated,
716+
vec![
717+
header("x-trace", "middle"),
718+
header("x-checksum", "replacement"),
719+
header("x-trace", "last"),
720+
]
721+
);
722+
}
723+
724+
#[test]
725+
fn response_trailer_write_cannot_introduce_an_absent_name() {
726+
let existing = [header("x-checksum", "one")];
727+
let error = apply(
728+
HeaderAuthority::ResponseTrailers,
729+
&existing,
730+
&[],
731+
&[write(
732+
"X-New-Trailer",
733+
"value",
734+
ExistingHeaderAction::Overwrite,
735+
)],
736+
)
737+
.expect_err("absent response trailer name");
738+
739+
assert_eq!(
740+
error,
741+
HeaderMutationError::AbsentTrailerName {
742+
name: "X-New-Trailer".into()
743+
}
744+
);
745+
}
746+
747+
#[test]
748+
fn response_trailer_removal_of_an_absent_name_is_a_noop() {
749+
let existing = [header("x-checksum", "one")];
750+
let updated = apply(
751+
HeaderAuthority::ResponseTrailers,
752+
&existing,
753+
&[],
754+
&[remove("X-Missing")],
755+
)
756+
.expect("absent response trailer removal");
757+
758+
assert_eq!(updated, existing);
759+
}
760+
761+
#[test]
762+
fn response_trailer_protected_fields_cannot_be_mutated() {
763+
for mutation in [
764+
write("Content-Length", "10", ExistingHeaderAction::Overwrite),
765+
remove("Set-Cookie"),
766+
] {
767+
let error = apply(
768+
HeaderAuthority::ResponseTrailers,
769+
&[
770+
header("content-length", "5"),
771+
header("set-cookie", "session=upstream"),
772+
],
773+
&[],
774+
&[mutation],
775+
)
776+
.expect_err("protected response trailer mutation");
777+
778+
assert!(matches!(error, HeaderMutationError::Protected { .. }));
779+
}
780+
}
653781
}

proto/supervisor_middleware.proto

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,26 +126,30 @@ message HttpHeader {
126126
}
127127

128128
// One ordered response event. A stream starts with preflight, may continue with
129-
// body units, and may end with one best-effort session_end.
129+
// body units and trailers, and may end with one best-effort session_end.
130130
message HttpResponseEvent {
131131
oneof event {
132132
// Initial response head and request context.
133133
HttpResponsePreflight preflight = 1;
134134
// Next normalized body unit.
135135
HttpResponseBodyUnit body = 2;
136+
// Normalized trailers after the final body result.
137+
HttpResponseTrailers trailers = 4;
136138
// Optional terminal notification.
137139
MiddlewareSessionEnd session_end = 3;
138140
}
139141
}
140142

141-
// Each preflight and body event requires one ordered result. session_end has no
142-
// result.
143+
// Each preflight, body, and trailers event requires one ordered result.
144+
// session_end has no result.
143145
message HttpResponseEventResult {
144146
oneof result {
145147
// Result for preflight.
146148
HttpResponsePreflightResult preflight_result = 1;
147149
// Result for the next body unit.
148150
HttpResponseBodyResult body_result = 2;
151+
// Result for response trailers.
152+
HttpResponseTrailersResult trailers_result = 3;
149153
}
150154
}
151155

@@ -316,6 +320,37 @@ message HttpResponseBodyTransform {
316320
}
317321
}
318322

323+
// The current normalized response trailers in wire order. Repeated names stay
324+
// as separate fields. A stage that completes WHOLE_BODY_BYTES or STREAM_BYTES
325+
// receives exactly one trailers event after its final body result, including
326+
// when this set is empty. SKIP, HEADERS_ONLY, semantically bodyless responses,
327+
// and stages ended by block, failure, or skip_remaining receive no trailers.
328+
message HttpResponseTrailers {
329+
repeated HttpHeader headers = 1;
330+
}
331+
332+
// Applies ordered trailer mutations atomically. An empty mutation list
333+
// preserves the current trailers. A write may target only a case-insensitive
334+
// name present in the trailers event; V1 cannot create a trailer name. Removal
335+
// of an absent name is a no-op. Credential, routing, framing, coding, range,
336+
// hop-by-hop, and connection-nominated fields are protected. A violating result
337+
// is a middleware failure handled according to on_error.
338+
message HttpResponseTrailersResult {
339+
// At most 64 operations, 32 KiB of validated name/value data, and 64 KiB
340+
// encoded are accepted.
341+
repeated HeaderMutation trailer_mutations = 1;
342+
// Service diagnostic, never sent to the sandbox or security logs. Maximum
343+
// 4 KiB.
344+
string reason = 2;
345+
// Optional audit code using preflight reason_code format. Never sent to the
346+
// sandbox.
347+
string reason_code = 3;
348+
// Up to 32 audit-safe findings, each limited to 4 KiB encoded.
349+
repeated Finding findings = 4;
350+
// Non-secret diagnostic metadata, limited to 64 entries and 32 KiB.
351+
map<string, string> metadata = 5;
352+
}
353+
319354
// Stable reason OpenShell ended a middleware stage stream.
320355
enum MiddlewareSessionEndReason {
321356
// Invalid reason.

0 commit comments

Comments
 (0)