diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 6365ed0631..987f07c142 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -200,8 +200,9 @@ polling runs far more frequently than credentials expire, so the loop rotates only when a credential is missing or has passed four fifths of its lifetime, and bounds its sleep by the soonest rotation deadline. -Middleware cannot observe injected credentials or mutate supervisor-owned -credential, routing, or framing headers. Body transformations are re-evaluated +Middleware cannot observe injected credentials, introduce credential +placeholders, or mutate supervisor-owned credential, routing, or framing +headers. Body transformations are re-evaluated against body-aware L7 policy before later stages or the upstream can observe them. Requests, results, chain length, execution time, and diagnostics are bounded; external free-form diagnostic text is not exposed in responses or diff --git a/crates/openshell-core/src/secrets.rs b/crates/openshell-core/src/secrets.rs index bef39c8fe2..0ec130bd8e 100644 --- a/crates/openshell-core/src/secrets.rs +++ b/crates/openshell-core/src/secrets.rs @@ -54,6 +54,38 @@ pub fn contains_reserved_credential_marker(value: &str) -> bool { contains_raw_reserved_marker(&decoded) } +/// Return whether an HTTP header value contains syntax reserved for credential +/// placeholder rewriting, including the decoded value of Basic authentication. +pub fn header_value_contains_reserved_credential_marker(value: &str) -> bool { + let trimmed = value.trim(); + if contains_reserved_credential_marker(trimmed) { + return true; + } + + let Some(decoded) = decode_basic_auth_value(trimmed) else { + return false; + }; + contains_raw_reserved_marker(&decoded) +} + +fn basic_auth_token(value: &str) -> Option<&str> { + value + .strip_prefix("Basic ") + .or_else(|| value.strip_prefix("basic ")) + .map(str::trim) +} + +fn decode_basic_auth_value(value: &str) -> Option { + decode_basic_auth_token(basic_auth_token(value)?) +} + +fn decode_basic_auth_token(encoded: &str) -> Option { + let decoded = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + String::from_utf8(decoded).ok() +} + pub fn contains_reserved_credential_marker_bytes(value: &[u8]) -> bool { if value.is_empty() { return false; @@ -494,10 +526,7 @@ impl SecretResolver { // Basic auth decoding: `Basic ` where the decoded content // contains a placeholder (e.g. `user:openshell:resolve:env:PASS`). - if let Some(encoded) = trimmed - .strip_prefix("Basic ") - .or_else(|| trimmed.strip_prefix("basic ")) - .map(str::trim) + if let Some(encoded) = basic_auth_token(trimmed) && let Some(rewritten) = self.rewrite_basic_auth_token(encoded)? { return Ok(Some(format!("Basic {rewritten}"))); @@ -633,18 +662,15 @@ impl SecretResolver { encoded: &str, ) -> Result, UnresolvedPlaceholderError> { let b64 = base64::engine::general_purpose::STANDARD; - let Some(decoded_bytes) = b64.decode(encoded.trim()).ok() else { - return Ok(None); - }; - let Some(decoded) = std::str::from_utf8(&decoded_bytes).ok() else { + let Some(decoded) = decode_basic_auth_token(encoded.trim()) else { return Ok(None); }; - if !contains_raw_reserved_marker(decoded) { + if !contains_raw_reserved_marker(&decoded) { return Ok(None); } - let mut rewritten = decoded.to_string(); + let mut rewritten = decoded; let replacements = self.rewrite_text_placeholders(&mut rewritten, "header")?; if replacements == 0 { @@ -1413,6 +1439,28 @@ mod tests { ])); } + #[test] + fn header_value_marker_detection_covers_rewrite_forms() { + let basic = + base64::engine::general_purpose::STANDARD.encode("user:openshell:resolve:env:API_KEY"); + + for value in [ + "openshell:resolve:env:API_KEY".to_string(), + "Bearer openshell:resolve:env:API_KEY".to_string(), + "provider-OPENSHELL-RESOLVE-ENV-API_KEY".to_string(), + "openshell%3Aresolve%3Aenv%3AAPI_KEY".to_string(), + format!("Basic {basic}"), + ] { + assert!( + header_value_contains_reserved_credential_marker(&value), + "reserved credential marker in {value:?}" + ); + } + assert!(!header_value_contains_reserved_credential_marker( + "Bearer ordinary-token" + )); + } + fn fully_percent_encoded(marker: &str) -> String { const HEX: &[u8; 16] = b"0123456789ABCDEF"; let mut encoded = String::with_capacity(marker.len() * 3); diff --git a/crates/openshell-supervisor-middleware/src/headers.rs b/crates/openshell-supervisor-middleware/src/headers.rs index d4b1168f60..454b27c2aa 100644 --- a/crates/openshell-supervisor-middleware/src/headers.rs +++ b/crates/openshell-supervisor-middleware/src/headers.rs @@ -1,21 +1,31 @@ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Validation and logical application of middleware request-header mutations. +//! Validation and logical application of HTTP middleware header mutations. -use openshell_core::proto::{ExistingHeaderAction, HeaderMutation, HttpHeader, header_mutation}; +use openshell_core::{ + proto::{ExistingHeaderAction, HeaderMutation, HttpHeader, header_mutation}, + secrets::header_value_contains_reserved_credential_marker, +}; pub const MAX_HEADER_MUTATIONS: usize = 64; pub const MAX_HEADER_MUTATION_BYTES: usize = 32 * 1024; +/// Selects the protected-header rules for the HTTP message being mutated. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HeaderAuthority { + Request, + Response, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum HeaderMutationError { TooMany { count: usize }, InvalidName { name: String }, Protected { name: String }, HopByHop { name: String }, - WriteNamespace { name: String }, UnsafeValue { name: String }, + CredentialPlaceholder { name: String }, TooLarge, InvalidExistingAction, MissingExistingAction { name: String }, @@ -31,8 +41,8 @@ impl HeaderMutationError { Self::InvalidName { .. } => "header_mutation_invalid_name", Self::Protected { .. } => "header_mutation_protected_header", Self::HopByHop { .. } => "header_mutation_hop_by_hop_header", - Self::WriteNamespace { .. } => "header_mutation_write_namespace", Self::UnsafeValue { .. } => "header_mutation_unsafe_value", + Self::CredentialPlaceholder { .. } => "header_mutation_credential_placeholder", Self::TooLarge => "header_mutation_bytes_over_capacity", Self::InvalidExistingAction => "header_mutation_invalid_existing_action", Self::MissingExistingAction { .. } => "header_mutation_missing_existing_action", @@ -67,16 +77,16 @@ impl std::fmt::Display for HeaderMutationError { "middleware cannot mutate hop-by-hop header '{name}'" ) } - Self::WriteNamespace { name } => write!( - formatter, - "middleware can only write request headers prefixed with x-openshell-middleware- and cannot write '{name}'" - ), Self::UnsafeValue { name } => { write!( formatter, "middleware cannot write header '{name}' with an unsafe value" ) } + Self::CredentialPlaceholder { name } => write!( + formatter, + "middleware cannot write credential placeholder in header '{name}'" + ), Self::TooLarge => write!( formatter, "middleware header mutations exceed {MAX_HEADER_MUTATION_BYTES} bytes" @@ -105,6 +115,7 @@ impl std::error::Error for HeaderMutationError {} /// state observed by the next middleware. Repeated values and wire order are /// preserved; comparisons are case-insensitive. pub fn apply( + authority: HeaderAuthority, existing_headers: &[HttpHeader], connection_nominated_headers: &[String], mutations: &[HeaderMutation], @@ -121,18 +132,19 @@ pub fn apply( match mutation.operation.as_ref() { Some(header_mutation::Operation::Write(write)) => { let name = validate_name(&write.name)?; + validate_authority(authority, MutationKind::Write, &write.name, &name)?; if is_connection_nominated(connection_nominated_headers, &name) { return Err(HeaderMutationError::HopByHop { name: write.name.clone(), }); } - if !name.starts_with("x-openshell-middleware-") { - return Err(HeaderMutationError::WriteNamespace { + if !is_safe_value(&write.value) { + return Err(HeaderMutationError::UnsafeValue { name: write.name.clone(), }); } - if !is_safe_value(&write.value) { - return Err(HeaderMutationError::UnsafeValue { + if header_value_contains_reserved_credential_marker(&write.value) { + return Err(HeaderMutationError::CredentialPlaceholder { name: write.name.clone(), }); } @@ -166,6 +178,7 @@ pub fn apply( } Some(header_mutation::Operation::Remove(remove)) => { let name = validate_name(&remove.name)?; + validate_authority(authority, MutationKind::Remove, &remove.name, &name)?; if is_connection_nominated(connection_nominated_headers, &name) { return Err(HeaderMutationError::HopByHop { name: remove.name.clone(), @@ -195,12 +208,34 @@ fn validate_name(name: &str) -> Result { name: name.to_string(), }); } - if is_protected(&lower) { + Ok(lower) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MutationKind { + Write, + Remove, +} + +fn validate_authority( + authority: HeaderAuthority, + kind: MutationKind, + original_name: &str, + normalized_name: &str, +) -> Result<(), HeaderMutationError> { + let protected = match authority { + HeaderAuthority::Request => is_request_protected(normalized_name), + HeaderAuthority::Response => { + is_response_protected(normalized_name) + || (kind == MutationKind::Write && is_response_remove_only(normalized_name)) + } + }; + if protected { return Err(HeaderMutationError::Protected { - name: name.to_string(), + name: original_name.to_string(), }); } - Ok(lower) + Ok(()) } fn is_name_token_byte(byte: u8) -> bool { @@ -233,7 +268,7 @@ fn is_safe_value(value: &str) -> bool { .all(|byte| byte == b'\t' || (0x20..=0x7e).contains(&byte) || byte >= 0x80) } -fn is_protected(name: &str) -> bool { +fn is_request_protected(name: &str) -> bool { matches!( name, "authorization" @@ -253,6 +288,42 @@ fn is_protected(name: &str) -> bool { || name.starts_with("x-openshell-credential") } +fn is_response_protected(name: &str) -> bool { + matches!( + name, + "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_remove_only(name: &str) -> bool { + matches!( + name, + "accept-ranges" + | "etag" + | "content-md5" + | "digest" + | "content-digest" + | "repr-digest" + | "signature" + | "signature-input" + ) +} + fn is_connection_nominated(connection_nominated_headers: &[String], name: &str) -> bool { connection_nominated_headers .iter() @@ -292,6 +363,7 @@ mod tests { #[test] fn protected_header_write_is_rejected() { let error = apply( + HeaderAuthority::Request, &[], &[], &[write( @@ -311,6 +383,7 @@ mod tests { #[test] fn unsafe_header_value_is_rejected() { let error = apply( + HeaderAuthority::Request, &[], &[], &[write( @@ -323,6 +396,57 @@ mod tests { assert!(error.to_string().contains("unsafe value")); } + #[test] + fn credential_placeholder_header_values_are_rejected() { + for value in [ + "openshell:resolve:env:API_KEY", + "Bearer openshell:resolve:env:API_KEY", + "provider-OPENSHELL-RESOLVE-ENV-API_KEY", + "openshell%3Aresolve%3Aenv%3AAPI_KEY", + "Basic dXNlcjpvcGVuc2hlbGw6cmVzb2x2ZTplbnY6QVBJX0tFWQ==", + ] { + for authority in [HeaderAuthority::Request, HeaderAuthority::Response] { + let error = apply( + authority, + &[], + &[], + &[write("x-api-key", value, ExistingHeaderAction::Overwrite)], + ) + .expect_err("credential placeholder write"); + assert_eq!( + error, + HeaderMutationError::CredentialPlaceholder { + name: "x-api-key".to_string() + } + ); + } + } + } + + #[test] + fn existing_credential_placeholder_header_value_is_preserved() { + let existing = [header("x-api-key", "openshell:resolve:env:API_KEY")]; + let updated = apply( + HeaderAuthority::Request, + &existing, + &[], + &[write( + "cache-control", + "no-store", + ExistingHeaderAction::Overwrite, + )], + ) + .expect("ordinary mutation beside an existing placeholder"); + + assert_eq!( + updated, + vec![ + header("x-api-key", "openshell:resolve:env:API_KEY"), + header("cache-control", "no-store"), + ] + ); + } + #[test] fn existing_header_write_obeys_collision_action() { let existing = [ @@ -330,6 +454,7 @@ mod tests { header("accept", "application/json"), ]; let appended = apply( + HeaderAuthority::Request, &existing, &[], &[write( @@ -349,6 +474,7 @@ mod tests { ); let overwritten = apply( + HeaderAuthority::Request, &existing, &[], &[write( @@ -367,6 +493,7 @@ mod tests { ); let skipped = apply( + HeaderAuthority::Request, &existing, &[], &[write( @@ -386,13 +513,25 @@ mod tests { header("accept", "application/json"), header("x-trace", "two"), ]; - let updated = apply(&existing, &[], &[remove("X-Trace")]).expect("remove visible header"); + let updated = apply( + HeaderAuthority::Request, + &existing, + &[], + &[remove("X-Trace")], + ) + .expect("remove visible header"); assert_eq!(updated, vec![header("accept", "application/json")]); } #[test] fn protected_header_remove_is_rejected_even_when_not_visible() { - let error = apply(&[], &[], &[remove("Authorization")]).expect_err("protected removal"); + let error = apply( + HeaderAuthority::Request, + &[], + &[], + &[remove("Authorization")], + ) + .expect_err("protected removal"); assert!( error .to_string() @@ -404,6 +543,7 @@ mod tests { fn connection_nominated_header_is_protected() { let nominated = vec!["x-openshell-middleware-tag".to_string()]; let write_error = apply( + HeaderAuthority::Request, &[], &nominated, &[write( @@ -419,12 +559,95 @@ mod tests { .contains("hop-by-hop header 'X-OpenShell-Middleware-Tag'") ); - let remove_error = apply(&[], &nominated, &[remove("X-OpenShell-Middleware-Tag")]) - .expect_err("hop-by-hop removal"); + let remove_error = apply( + HeaderAuthority::Request, + &[], + &nominated, + &[remove("X-OpenShell-Middleware-Tag")], + ) + .expect_err("hop-by-hop removal"); assert!( remove_error .to_string() .contains("hop-by-hop header 'X-OpenShell-Middleware-Tag'") ); } + + #[test] + fn request_write_accepts_end_to_end_header_without_namespace() { + let updated = apply( + HeaderAuthority::Request, + &[], + &[], + &[write( + "Cache-Control", + "no-store", + ExistingHeaderAction::Overwrite, + )], + ) + .expect("ordinary end-to-end request header"); + + assert_eq!(updated, vec![header("cache-control", "no-store")]); + } + + #[test] + fn response_authority_allows_end_to_end_writes_and_integrity_removal() { + let existing = [header("etag", "old"), header("content-type", "text/plain")]; + let updated = apply( + HeaderAuthority::Response, + &existing, + &[], + &[ + write("Cache-Control", "private", ExistingHeaderAction::Overwrite), + remove("ETag"), + ], + ) + .expect("permitted response mutations"); + + assert_eq!( + updated, + vec![ + header("content-type", "text/plain"), + header("cache-control", "private"), + ] + ); + } + + #[test] + fn response_authority_rejects_framing_and_integrity_writes() { + for mutation in [ + remove("Content-Length"), + write("ETag", "new", ExistingHeaderAction::Overwrite), + ] { + let error = apply(HeaderAuthority::Response, &[], &[], &[mutation]) + .expect_err("protected response mutation"); + assert!(matches!(error, HeaderMutationError::Protected { .. })); + } + } + + #[test] + fn response_authority_protects_credential_headers_from_writes_and_removals() { + let existing = [header("set-cookie", "session=upstream")]; + for name in [ + "Set-Cookie", + "WWW-Authenticate", + "Authentication-Info", + "Proxy-Authentication-Info", + "X-OpenShell-Credential-Token", + ] { + for mutation in [ + write(name, "planted", ExistingHeaderAction::Overwrite), + remove(name), + ] { + let error = apply(HeaderAuthority::Response, &existing, &[], &[mutation]) + .expect_err("credential response header mutation"); + assert_eq!( + error, + HeaderMutationError::Protected { + name: name.to_string() + } + ); + } + } + } } diff --git a/crates/openshell-supervisor-middleware/src/lib.rs b/crates/openshell-supervisor-middleware/src/lib.rs index 902b5165c2..74f6637ffb 100644 --- a/crates/openshell-supervisor-middleware/src/lib.rs +++ b/crates/openshell-supervisor-middleware/src/lib.rs @@ -3,7 +3,7 @@ //! Supervisor middleware registration and chain execution. -mod headers; +pub mod headers; mod remote; mod websocket; @@ -1848,6 +1848,7 @@ impl ChainRunner { None } else { match headers::apply( + headers::HeaderAuthority::Request, &headers, &connection_nominated_headers, &result.header_mutations, @@ -3025,6 +3026,56 @@ mod tests { received: std::sync::Mutex>, } + struct InProcessHeaderChainService { + received: std::sync::Mutex>>, + } + + #[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, + timeout: String::new(), + }], + expected_audience: String::new(), + } + } + + async fn validate_config( + &self, + _middleware_name: &str, + _config: &prost_types::Struct, + ) -> Result<()> { + Ok(()) + } + + async fn evaluate_http_request( + &self, + request: 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) + } + } + #[tonic::async_trait] impl SupervisorMiddleware for HeaderChainService { type EvaluateWebSocketSessionStream = WebSocketResponseStream; @@ -3081,13 +3132,13 @@ mod tests { let mut result = allow_result(); if invocation == 0 { result.header_mutations.push(write_header( - "x-openshell-middleware-chain", + "cache-control", "first", ExistingHeaderAction::Overwrite, )); } else if invocation == 1 { result.header_mutations.push(write_header( - "x-openshell-middleware-chain", + "cache-control", "second", self.second_action, )); @@ -3141,13 +3192,56 @@ mod tests { let observed: Vec<&str> = received[2] .headers .iter() - .filter(|header| header.name == "x-openshell-middleware-chain") + .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: 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 @@ -4142,6 +4236,53 @@ mod tests { 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: openshell_core::proto::HttpRequestResult { + 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 = [ diff --git a/docs/extensibility/supervisor-middleware.mdx b/docs/extensibility/supervisor-middleware.mdx index 68cdbd29a5..d5909867d3 100644 --- a/docs/extensibility/supervisor-middleware.mdx +++ b/docs/extensibility/supervisor-middleware.mdx @@ -189,7 +189,7 @@ A middleware result can return ordered header mutations before OpenShell injects A `remove` mutation removes every value for a case-insensitive header name. OpenShell applies each successful stage's mutations before invoking the next middleware, so later stages observe the accumulated header state. -Header writes must use the `x-openshell-middleware-` prefix. Removes may target other middleware-visible request headers. Protected credential, routing, framing, and hop-by-hop headers are always rejected. Header values must not contain control characters. +Writes and removals may target middleware-visible end-to-end request headers. Protected credential, routing, framing, and hop-by-hop headers are always rejected. Header values must not contain control characters or OpenShell credential placeholder syntax. Middleware runs before credential injection, but it cannot introduce a value that the later injection step would resolve. OpenShell validates and applies each stage's mutations atomically. An invalid operation discards every mutation from that stage and follows its `on_error` behavior. Built-in failures can name the offending header. Operator-run failures use a platform-owned error code so request-derived header text cannot reach logs or denied responses. diff --git a/proto/supervisor_middleware.proto b/proto/supervisor_middleware.proto index 27fd804bdf..41b92d8d1a 100644 --- a/proto/supervisor_middleware.proto +++ b/proto/supervisor_middleware.proto @@ -368,7 +368,7 @@ message RemoveHeader { string name = 1; } -// HeaderMutation is one ordered request-header operation. +// HeaderMutation is one ordered HTTP header operation. message HeaderMutation { oneof operation { WriteHeader write = 1; @@ -388,10 +388,10 @@ message HttpRequestResult { // 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. Header writes are restricted to the - // "x-openshell-middleware-" namespace. Removes may target other visible + // 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. A violating result is a middleware failure handled + // 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;