From 893be8055445c5c2969c6121a0e28219f2126709 Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 27 Jul 2026 19:04:01 -0700 Subject: [PATCH 01/32] feat(proxy): bind static credentials to provider endpoints Signed-off-by: John Myers --- crates/openshell-core/src/endpoint_path.rs | 39 ++ crates/openshell-core/src/grpc_client.rs | 5 + crates/openshell-core/src/lib.rs | 1 + .../src/provider_credentials.rs | 341 ++++++++++++++++++ crates/openshell-core/src/secrets.rs | 187 ++++++++-- crates/openshell-providers/src/profiles.rs | 11 +- crates/openshell-sandbox/src/lib.rs | 245 ++++++++----- crates/openshell-server/src/grpc/policy.rs | 82 ++++- crates/openshell-server/src/grpc/provider.rs | 83 ++++- .../src/l7/mod.rs | 11 +- .../src/l7/relay.rs | 261 +++++++++----- .../src/l7/websocket.rs | 92 ++++- .../openshell-supervisor-network/src/proxy.rs | 107 +++++- .../src/proxy/relay.rs | 3 + .../src/proxy/tests/compatibility.rs | 1 + proto/openshell.proto | 22 ++ 16 files changed, 1236 insertions(+), 255 deletions(-) create mode 100644 crates/openshell-core/src/endpoint_path.rs diff --git a/crates/openshell-core/src/endpoint_path.rs b/crates/openshell-core/src/endpoint_path.rs new file mode 100644 index 0000000000..a82a6bc7b0 --- /dev/null +++ b/crates/openshell-core/src/endpoint_path.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Canonical provider endpoint path matching shared by policy and runtime code. + +/// Return whether `path` is selected by a provider endpoint path pattern. +/// +/// Empty paths and `**` match every request path. A trailing `/**` matches the +/// named path itself and every descendant. Other patterns use glob semantics. +#[must_use] +pub fn matches(pattern: &str, path: &str) -> bool { + if pattern.is_empty() || pattern == "**" || pattern == "/**" { + return true; + } + if pattern == path { + return true; + } + if let Some(prefix) = pattern.strip_suffix("/**") { + return path == prefix || path.starts_with(&format!("{prefix}/")); + } + glob::Pattern::new(pattern).is_ok_and(|glob| glob.matches(path)) +} + +#[cfg(test)] +mod tests { + use super::matches; + + #[test] + fn matches_canonical_endpoint_patterns() { + assert!(matches("", "/v1/messages")); + assert!(matches("/**", "/v1/messages")); + assert!(matches("/v1/**", "/v1")); + assert!(matches("/v1/**", "/v1/messages")); + assert!(matches("/v*/messages", "/v1/messages")); + assert!(matches("/v1/*", "/v1/chat/messages")); + assert!(!matches("/v1/**", "/v2/messages")); + assert!(!matches("/v1/*/messages", "/v1/chat/completions")); + } +} diff --git a/crates/openshell-core/src/grpc_client.rs b/crates/openshell-core/src/grpc_client.rs index 579ee4a5b3..7921b0716b 100644 --- a/crates/openshell-core/src/grpc_client.rs +++ b/crates/openshell-core/src/grpc_client.rs @@ -741,6 +741,7 @@ pub async fn fetch_provider_environment( let response = client .get_sandbox_provider_environment(GetSandboxProviderEnvironmentRequest { sandbox_id: sandbox_id.to_string(), + supports_static_credential_bindings: true, }) .await .into_diagnostic()?; @@ -751,6 +752,8 @@ pub async fn fetch_provider_environment( provider_env_revision: inner.provider_env_revision, credential_expires_at_ms: inner.credential_expires_at_ms, dynamic_credentials: inner.dynamic_credentials, + static_credential_bindings: inner.static_credential_bindings, + non_secret_environment_keys: inner.non_secret_environment_keys, }) } @@ -840,6 +843,8 @@ pub struct ProviderEnvironmentResult { pub provider_env_revision: u64, pub credential_expires_at_ms: HashMap, pub dynamic_credentials: HashMap, + pub static_credential_bindings: HashMap, + pub non_secret_environment_keys: Vec, } impl CachedOpenShellClient { diff --git a/crates/openshell-core/src/lib.rs b/crates/openshell-core/src/lib.rs index c75d1b1a9c..377aec6655 100644 --- a/crates/openshell-core/src/lib.rs +++ b/crates/openshell-core/src/lib.rs @@ -15,6 +15,7 @@ pub mod config; pub mod denial; pub mod driver_mounts; pub mod driver_utils; +pub mod endpoint_path; pub mod error; pub mod forward; pub mod google_cloud; diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index d0b7b38ad5..54d7cf6b1a 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -4,7 +4,12 @@ //! Runtime provider credential snapshots. use crate::secrets::SecretResolver; +use crate::{ + host_pattern::HostPattern, + proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}, +}; use std::collections::{HashMap, HashSet, VecDeque}; +use std::fmt; use std::sync::{Arc, RwLock}; const MAX_RETAINED_CREDENTIAL_GENERATIONS: usize = 8; @@ -23,6 +28,8 @@ struct ProviderCredentialStateInner { current_resolver: Option>, combined_resolver: Option>, suppressed_keys: HashSet, + static_credential_bindings: HashMap, + known_static_credential_keys: HashSet, } #[derive(Debug, Clone)] @@ -30,6 +37,19 @@ pub struct ProviderCredentialState { inner: Arc>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct StaticCredentialBindingError { + message: String, +} + +impl fmt::Display for StaticCredentialBindingError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message) + } +} + +impl std::error::Error for StaticCredentialBindingError {} + impl ProviderCredentialState { pub fn from_environment( revision: u64, @@ -59,10 +79,40 @@ impl ProviderCredentialState { current_resolver, combined_resolver, suppressed_keys: HashSet::new(), + static_credential_bindings: HashMap::new(), + known_static_credential_keys: HashSet::new(), })), } } + pub fn from_bound_environment( + revision: u64, + env: HashMap, + credential_expires_at_ms: HashMap, + dynamic_credentials: HashMap, + static_credential_bindings: HashMap, + non_secret_environment_keys: Vec, + ) -> Result { + validate_static_credential_bindings( + &env, + &static_credential_bindings, + &non_secret_environment_keys, + )?; + let state = + Self::from_environment(revision, env, credential_expires_at_ms, dynamic_credentials); + { + let mut inner = state + .inner + .write() + .expect("provider credential state poisoned"); + inner + .known_static_credential_keys + .extend(static_credential_bindings.keys().cloned()); + inner.static_credential_bindings = static_credential_bindings; + } + Ok(state) + } + /// Build a static provider state from an already-prepared child /// environment snapshot. /// @@ -85,6 +135,8 @@ impl ProviderCredentialState { current_resolver: None, combined_resolver: None, suppressed_keys: HashSet::new(), + static_credential_bindings: HashMap::new(), + known_static_credential_keys: HashSet::new(), })), } } @@ -117,6 +169,8 @@ impl ProviderCredentialState { inner.generations.clear(); inner.current_resolver = None; inner.combined_resolver = None; + inner.static_credential_bindings.clear(); + inner.known_static_credential_keys.clear(); inner.current.child_env.len() } @@ -136,6 +190,45 @@ impl ProviderCredentialState { .clone() } + /// Resolve provider placeholders only for credentials bound to this + /// concrete request endpoint. The view is created from one atomic state + /// snapshot and shares underlying resolver material. + #[must_use] + pub fn resolver_for_endpoint( + &self, + host: &str, + port: u16, + path: &str, + ) -> Option> { + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + let request_path = path.split_once('?').map_or(path, |(path, _)| path); + let allowed = inner + .static_credential_bindings + .iter() + .filter(|(_, binding)| { + binding.endpoints.iter().any(|endpoint| { + static_credential_endpoint_matches(endpoint, host, port, request_path) + }) + }) + .map(|(key, _)| key.clone()) + .collect(); + inner.combined_resolver.as_ref().map(|resolver| { + Arc::new(resolver.scoped_to_env_keys(&inner.known_static_credential_keys, &allowed)) + }) + } + + #[must_use] + pub fn revision(&self) -> u64 { + self.inner + .read() + .expect("provider credential state poisoned") + .current + .revision + } + /// Remove a key from the credential snapshot's child env. /// /// Used when a sandbox-side service (e.g., metadata server) fails to start @@ -294,6 +387,160 @@ impl ProviderCredentialState { merge_resolvers(&inner.generations, inner.current_resolver.as_ref()); inner.current.child_env.len() } + + pub fn install_bound_environment( + &self, + revision: u64, + env: HashMap, + credential_expires_at_ms: HashMap, + dynamic_credentials: HashMap, + static_credential_bindings: HashMap, + non_secret_environment_keys: Vec, + ) -> Result { + if let Err(error) = validate_static_credential_bindings( + &env, + &static_credential_bindings, + &non_secret_environment_keys, + ) { + self.revoke_static_provider_environment_inner(revision, Some(dynamic_credentials)); + return Err(error); + } + + let (mut child_env, generation_resolver, current_resolver) = + SecretResolver::from_provider_env_for_current_revision( + env, + credential_expires_at_ms, + revision, + ); + let mut inner = self + .inner + .write() + .expect("provider credential state poisoned"); + + for key in &inner.suppressed_keys { + child_env.remove(key); + } + inner.current = Arc::new(ProviderCredentialSnapshot { + revision, + child_env, + dynamic_credentials, + }); + inner.current_resolver = current_resolver.map(Arc::new); + if let Some(resolver) = generation_resolver { + inner.generations.push_back(Arc::new(resolver)); + while inner.generations.len() > MAX_RETAINED_CREDENTIAL_GENERATIONS { + inner.generations.pop_front(); + } + } + inner.combined_resolver = + merge_resolvers(&inner.generations, inner.current_resolver.as_ref()); + inner + .known_static_credential_keys + .extend(static_credential_bindings.keys().cloned()); + inner.static_credential_bindings = static_credential_bindings; + Ok(inner.current.child_env.len()) + } + + /// Atomically remove static provider material after a failed refresh. + /// + /// Dynamic token grants retain their independently endpoint-bound state + /// unless the caller supplies a newer dynamic snapshot. + pub fn revoke_static_provider_environment(&self, revision: u64) { + self.revoke_static_provider_environment_inner(revision, None); + } + + fn revoke_static_provider_environment_inner( + &self, + revision: u64, + dynamic_credentials: Option>, + ) { + let mut inner = self + .inner + .write() + .expect("provider credential state poisoned"); + let dynamic_credentials = + dynamic_credentials.unwrap_or_else(|| inner.current.dynamic_credentials.clone()); + inner.current = Arc::new(ProviderCredentialSnapshot { + revision, + child_env: HashMap::new(), + dynamic_credentials, + }); + inner.generations.clear(); + inner.current_resolver = None; + inner.combined_resolver = None; + inner.static_credential_bindings.clear(); + } +} + +fn validate_static_credential_bindings( + env: &HashMap, + bindings: &HashMap, + non_secret_environment_keys: &[String], +) -> Result<(), StaticCredentialBindingError> { + let non_secret_keys = non_secret_environment_keys + .iter() + .cloned() + .collect::>(); + if non_secret_keys.len() != non_secret_environment_keys.len() { + return Err(binding_error( + "provider environment repeats a non-secret environment key", + )); + } + if bindings.keys().any(|key| non_secret_keys.contains(key)) { + return Err(binding_error( + "provider environment classifies a key as both credential and non-secret configuration", + )); + } + if env + .keys() + .any(|key| !bindings.contains_key(key) && !non_secret_keys.contains(key)) + { + return Err(binding_error( + "provider environment contains an unclassified credential key", + )); + } + if bindings.keys().any(|key| !env.contains_key(key)) + || non_secret_keys.iter().any(|key| !env.contains_key(key)) + { + return Err(binding_error( + "provider environment metadata references a missing environment key", + )); + } + for binding in bindings.values() { + if binding.endpoints.is_empty() { + return Err(binding_error( + "static credential binding has no authorized endpoints", + )); + } + for endpoint in &binding.endpoints { + if endpoint.port == 0 + || endpoint.port > u32::from(u16::MAX) + || HostPattern::new(&endpoint.host).is_err() + { + return Err(binding_error( + "static credential binding contains an invalid endpoint", + )); + } + } + } + Ok(()) +} + +fn binding_error(message: &str) -> StaticCredentialBindingError { + StaticCredentialBindingError { + message: message.to_string(), + } +} + +fn static_credential_endpoint_matches( + endpoint: &StaticCredentialEndpointBinding, + host: &str, + port: u16, + path: &str, +) -> bool { + endpoint.port == u32::from(port) + && HostPattern::new(&endpoint.host).is_ok_and(|pattern| pattern.matches(host)) + && crate::endpoint_path::matches(&endpoint.path, path) } fn merge_resolvers( @@ -314,6 +561,100 @@ mod tests { use super::*; use crate::google_cloud; + fn binding(host: &str, port: u32, path: &str) -> StaticCredentialBinding { + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: host.to_string(), + port, + path: path.to_string(), + }], + } + } + + #[test] + fn bound_credentials_resolve_only_at_matching_endpoint() { + let state = ProviderCredentialState::from_bound_environment( + 7, + HashMap::from([("API_KEY".to_string(), "secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("*.example.com", 443, "/v1/**"), + )]), + Vec::new(), + ) + .expect("valid bindings"); + let placeholder = "openshell:resolve:env:v7_API_KEY"; + + let allowed = state + .resolver_for_endpoint("api.example.com", 443, "/v1/messages?stream=true") + .expect("resolver"); + assert_eq!(allowed.resolve_placeholder(placeholder), Some("secret")); + + for (host, port, path) in [ + ("example.com", 443, "/v1/messages"), + ("api.example.com", 80, "/v1/messages"), + ("api.example.com", 443, "/v2/messages"), + ] { + let denied = state + .resolver_for_endpoint(host, port, path) + .expect("resolver"); + let error = denied + .rewrite_header_value(placeholder) + .expect_err("endpoint mismatch must fail closed"); + assert!(error.is_endpoint_mismatch(), "{host}:{port}{path}"); + } + } + + #[test] + fn non_secret_provider_config_is_not_endpoint_scoped() { + let state = ProviderCredentialState::from_bound_environment( + 3, + HashMap::from([("GCP_PROJECT_ID".to_string(), "project".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec!["GCP_PROJECT_ID".to_string()], + ) + .expect("classified non-secret environment"); + let resolver = state + .resolver_for_endpoint("unrelated.example", 1234, "/") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v3_GCP_PROJECT_ID"), + Some("project") + ); + } + + #[test] + fn incomplete_refresh_revokes_previous_credentials_atomically() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "old".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("initial bindings"); + + let result = state.install_bound_environment( + 2, + HashMap::from([("API_KEY".to_string(), "new".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + Vec::new(), + ); + assert!(result.is_err()); + assert!(state.snapshot().child_env.is_empty()); + assert!(state.resolver().is_none()); + } + #[test] fn snapshots_use_revision_scoped_placeholders() { let state = ProviderCredentialState::from_environment( diff --git a/crates/openshell-core/src/secrets.rs b/crates/openshell-core/src/secrets.rs index e93bdc5390..b426564c90 100644 --- a/crates/openshell-core/src/secrets.rs +++ b/crates/openshell-core/src/secrets.rs @@ -5,6 +5,7 @@ use crate::time::now_ms; use base64::Engine as _; use std::collections::HashMap; use std::fmt; +use std::sync::Arc; const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:"; const PROVIDER_ALIAS_MARKER: &str = "OPENSHELL-RESOLVE-ENV-"; @@ -44,18 +45,50 @@ pub fn contains_reserved_credential_marker(value: &str) -> bool { #[derive(Debug)] pub struct UnresolvedPlaceholderError { pub location: &'static str, // "header", "query_param", "path" + reason: UnresolvedPlaceholderReason, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum UnresolvedPlaceholderReason { + Unavailable, + EndpointMismatch, +} + +impl UnresolvedPlaceholderError { + #[must_use] + pub fn unavailable(location: &'static str) -> Self { + unresolved(location) + } + + #[must_use] + pub fn is_endpoint_mismatch(&self) -> bool { + self.reason == UnresolvedPlaceholderReason::EndpointMismatch + } } impl fmt::Display for UnresolvedPlaceholderError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "unresolved credential placeholder in {}: detected reserved credential token that could not be resolved", - self.location + "{} in {}", + match self.reason { + UnresolvedPlaceholderReason::Unavailable => + "credential placeholder could not be resolved", + UnresolvedPlaceholderReason::EndpointMismatch => + "credential is not authorized for the request endpoint", + }, + self.location, ) } } +fn unresolved(location: &'static str) -> UnresolvedPlaceholderError { + UnresolvedPlaceholderError { + location, + reason: UnresolvedPlaceholderReason::Unavailable, + } +} + /// Result of rewriting an HTTP header block with credential resolution. #[derive(Debug)] pub struct RewriteResult { @@ -86,11 +119,12 @@ pub struct RewriteTargetResult { #[derive(Clone, Default)] pub struct SecretResolver { by_placeholder: HashMap, + denied_env_keys: std::collections::HashSet, } #[derive(Clone)] struct SecretValue { - value: String, + value: Arc, expires_at_ms: i64, } @@ -186,7 +220,7 @@ impl SecretResolver { } let placeholder = placeholder_for_env_key_for_revision(&key, revision); let secret = SecretValue { - value, + value: Arc::from(value), expires_at_ms: credential_expires_at_ms .get(&key) .copied() @@ -202,22 +236,77 @@ impl SecretResolver { if by_placeholder.is_empty() { (child_env, None) } else { - (child_env, Some(Self { by_placeholder })) + ( + child_env, + Some(Self { + by_placeholder, + denied_env_keys: std::collections::HashSet::new(), + }), + ) } } pub fn merge<'a>(resolvers: impl IntoIterator) -> Option { let mut by_placeholder = HashMap::new(); + let mut denied_env_keys = std::collections::HashSet::new(); for resolver in resolvers { by_placeholder.extend(resolver.by_placeholder.clone()); + denied_env_keys.extend(resolver.denied_env_keys.iter().cloned()); } if by_placeholder.is_empty() { None } else { - Some(Self { by_placeholder }) + Some(Self { + by_placeholder, + denied_env_keys, + }) + } + } + + /// Return a cheap endpoint-scoped resolver view. + /// + /// Secret strings are shared through cloned resolver entries. Credential + /// keys listed in `bound_keys` resolve only when also present in + /// `allowed_bound_keys`; unbound provider configuration remains available. + #[must_use] + pub fn scoped_to_env_keys( + &self, + bound_keys: &std::collections::HashSet, + allowed_bound_keys: &std::collections::HashSet, + ) -> Self { + let denied_env_keys = bound_keys + .difference(allowed_bound_keys) + .cloned() + .collect::>(); + let by_placeholder = self + .by_placeholder + .iter() + .filter(|(placeholder, _)| { + placeholder_env_key(placeholder).is_none_or(|key| !denied_env_keys.contains(key)) + }) + .map(|(placeholder, secret)| (placeholder.clone(), secret.clone())) + .collect(); + Self { + by_placeholder, + denied_env_keys, } } + fn unresolved_for( + &self, + location: &'static str, + placeholder: &str, + ) -> UnresolvedPlaceholderError { + let reason = if placeholder_env_key(placeholder) + .is_some_and(|key| self.denied_env_keys.contains(key)) + { + UnresolvedPlaceholderReason::EndpointMismatch + } else { + UnresolvedPlaceholderReason::Unavailable + }; + UnresolvedPlaceholderError { location, reason } + } + /// Resolve a placeholder string to the real secret value. /// /// Returns `None` if the placeholder is unknown or the resolved value @@ -240,7 +329,7 @@ impl SecretResolver { ); return None; } - match validate_resolved_secret(&secret.value) { + match validate_resolved_secret(secret.value.as_ref()) { Ok(s) => Some(s), Err(reason) => { tracing::warn!( @@ -284,7 +373,7 @@ impl SecretResolver { // Prefixed placeholder: `Bearer openshell:resolve:env:KEY` let Some(split_at) = trimmed.find(char::is_whitespace) else { if contains_reserved_credential_marker(trimmed) { - return Err(UnresolvedPlaceholderError { location: "header" }); + return Err(self.unresolved_for("header", trimmed)); } return Ok(None); }; @@ -295,7 +384,7 @@ impl SecretResolver { } if contains_reserved_credential_marker(candidate) { - return Err(UnresolvedPlaceholderError { location: "header" }); + return Err(self.unresolved_for("header", candidate)); } Ok(None) @@ -329,10 +418,10 @@ impl SecretResolver { if text[abs_start..].starts_with(PLACEHOLDER_PREFIX) { let Some((token_end, token)) = self.credential_token_at(text, abs_start) else { - return Err(UnresolvedPlaceholderError { location }); + return Err(unresolved(location)); }; let Some(secret) = self.resolve_placeholder(token) else { - return Err(UnresolvedPlaceholderError { location }); + return Err(self.unresolved_for(location, token)); }; rewritten.push_str(secret); replacements += 1; @@ -342,7 +431,7 @@ impl SecretResolver { if let Some((token_end, token)) = alias_token_at(text, abs_start) { let Some(secret) = self.resolve_placeholder(token) else { - return Err(UnresolvedPlaceholderError { location }); + return Err(self.unresolved_for(location, token)); }; rewritten.push_str(secret); replacements += 1; @@ -350,11 +439,11 @@ impl SecretResolver { continue; } - return Err(UnresolvedPlaceholderError { location }); + return Err(unresolved(location)); } if contains_raw_reserved_marker(&rewritten) { - return Err(UnresolvedPlaceholderError { location }); + return Err(unresolved(location)); } *text = rewritten; @@ -495,6 +584,12 @@ fn revisioned_placeholder_env_key(token: &str) -> Option<&str> { Some(key) } +fn placeholder_env_key(token: &str) -> Option<&str> { + revisioned_placeholder_env_key(token) + .or_else(|| token.strip_prefix(PLACEHOLDER_PREFIX)) + .or_else(|| alias_env_key(token)) +} + pub fn uses_reserved_revision_namespace(key: &str) -> bool { split_revisioned_env_key(key).is_some() } @@ -836,7 +931,7 @@ fn rewrite_path_segment( let Some((token_end, full_placeholder)) = canonical_token_at(segment, abs_start) .or_else(|| alias_token_at(segment, abs_start)) else { - return Err(UnresolvedPlaceholderError { location: "path" }); + return Err(unresolved("path")); }; if let Some(secret) = resolver.resolve_placeholder(full_placeholder) { validate_credential_for_path(secret).map_err(|reason| { @@ -845,12 +940,12 @@ fn rewrite_path_segment( %reason, "credential resolution rejected: resolved value unsafe for path" ); - UnresolvedPlaceholderError { location: "path" } + unresolved("path") })?; resolved.push_str(secret); redacted.push_str("[CREDENTIAL]"); } else { - return Err(UnresolvedPlaceholderError { location: "path" }); + return Err(resolver.unresolved_for("path", full_placeholder)); } pos = token_end; } else { @@ -887,9 +982,7 @@ fn rewrite_uri_query_params( let replacements = resolver.rewrite_text_placeholders(&mut rewritten, "query_param")?; if replacements == 0 || contains_raw_reserved_marker(&rewritten) { - return Err(UnresolvedPlaceholderError { - location: "query_param", - }); + return Err(unresolved("query_param")); } resolved_params.push(format!("{key}={}", percent_encode_query(&rewritten))); redacted_params.push(format!("{key}=[CREDENTIAL]")); @@ -941,7 +1034,7 @@ pub fn rewrite_http_header_block( .map_or(raw.len(), |p| raw.len().min(p + 4 + 256)); let header_region = String::from_utf8_lossy(&raw[..scan_end]); if contains_reserved_credential_marker(&header_region) { - return Err(UnresolvedPlaceholderError { location: "header" }); + return Err(unresolved("header")); } return Ok(RewriteResult { rewritten: raw.to_vec(), @@ -988,7 +1081,7 @@ pub fn rewrite_http_header_block( // provider-shaped aliases in both raw and percent-decoded header bytes. let output_header = String::from_utf8_lossy(&output[..output.len().min(header_end + 256)]); if contains_reserved_credential_marker(&output_header) { - return Err(UnresolvedPlaceholderError { location: "header" }); + return Err(unresolved("header")); } Ok(RewriteResult { @@ -1066,6 +1159,48 @@ pub fn rewrite_target_for_eval( Ok(RewriteTargetResult { resolved, redacted }) } +/// Produce the policy/logging representation of a request target without +/// consulting or materializing credential values. +/// +/// This validates placeholder syntax using the same URI-aware rewrite path as +/// upstream injection, but resolves every referenced key to a fixed redaction +/// marker. Callers can therefore select routes and evaluate policy before the +/// real endpoint-scoped resolver is touched. +pub fn redact_target_for_policy(target: &str) -> Result { + if !contains_reserved_credential_marker(target) { + return Ok(target.to_string()); + } + + let decoded = percent_decode(target); + let mut provider_env = HashMap::new(); + let mut pos = 0; + while pos < decoded.len() { + let next_canonical = decoded[pos..].find(PLACEHOLDER_PREFIX).map(|p| pos + p); + let next_alias = decoded[pos..] + .find(PROVIDER_ALIAS_MARKER) + .map(|marker_pos| alias_start_for_marker(&decoded, pos + marker_pos)); + let Some(start) = [next_canonical, next_alias].into_iter().flatten().min() else { + break; + }; + let Some((end, token)) = + canonical_token_at(&decoded, start).or_else(|| alias_token_at(&decoded, start)) + else { + return Err(unresolved("request_target")); + }; + let Some(key) = placeholder_env_key(token) else { + return Err(unresolved("request_target")); + }; + provider_env.insert(key.to_string(), "[CREDENTIAL]".to_string()); + pos = end; + } + + let (_, resolver) = SecretResolver::from_provider_env(provider_env); + let Some(resolver) = resolver else { + return Err(unresolved("request_target")); + }; + rewrite_target_for_eval(target, &resolver).map(|result| result.redacted) +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -2201,4 +2336,12 @@ mod tests { assert_eq!(result.resolved, "/bottok123/method?key=key456"); assert_eq!(result.redacted, "/bot[CREDENTIAL]/method?key=[CREDENTIAL]"); } + + #[test] + fn policy_target_redaction_never_requires_real_secret_material() { + let target = "/v1/openshell:resolve:env:v9_API_KEY/messages?token=provider-OPENSHELL-RESOLVE-ENV-API_KEY"; + let redacted = redact_target_for_policy(target).expect("valid placeholder syntax"); + assert_eq!(redacted, "/v1/[CREDENTIAL]/messages?token=[CREDENTIAL]"); + assert!(!redacted.contains("API_KEY")); + } } diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 2ba071db16..84131a1c77 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -2439,16 +2439,7 @@ fn path_prefix_pattern(path: &str) -> Option<&str> { } fn endpoint_path_matches(pattern: &str, path: &str) -> bool { - if path_matches_all(pattern) { - return true; - } - if pattern == path { - return true; - } - if let Some(prefix) = path_prefix_pattern(pattern) { - return path == prefix || path.starts_with(&format!("{prefix}/")); - } - glob::Pattern::new(pattern).is_ok_and(|glob| glob.matches(path)) + openshell_core::endpoint_path::matches(pattern, path) } fn validate_token_grant_endpoint(token_endpoint: &str) -> Result<(), String> { diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index daf797bc2f..a996fbfe21 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -219,80 +219,112 @@ pub async fn run_sandbox( } #[cfg_attr(not(target_os = "linux"), allow(unused_mut))] - let (provider_credentials, mut provider_env) = - if let Some(bootstrap) = sidecar_bootstrap.as_ref() { - let provider_credentials = ProviderCredentialState::from_child_env_snapshot( - bootstrap.provider_env_revision, - bootstrap.provider_child_env.clone(), - ); - (provider_credentials, bootstrap.provider_child_env.clone()) - } else { - // Fetch provider environment variables from the server. - // This is done after loading the policy so the sandbox can still start - // even if provider env fetch fails (graceful degradation). - let ( - provider_env_revision, - provider_env, - provider_credential_expires_at_ms, - dynamic_credentials, - ) = if let (Some(id), Some(endpoint)) = (&sandbox_id, &openshell_endpoint) { - match openshell_core::grpc_client::fetch_provider_environment(endpoint, id).await { - Ok(result) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .message(format!( - "Fetched provider environment [env_count:{}]", - result.environment.len() - )) - .build() - ); - ( - result.provider_env_revision, - result.environment, - result.credential_expires_at_ms, - result.dynamic_credentials, - ) - } - Err(e) => { - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .state(StateId::Other, "degraded") - .message(format!( - "Failed to fetch provider environment, continuing without: {e}" - )) - .build() - ); - ( - 0, - std::collections::HashMap::new(), - std::collections::HashMap::new(), - std::collections::HashMap::new(), - ) - } + let (provider_credentials, mut provider_env) = if let Some(bootstrap) = + sidecar_bootstrap.as_ref() + { + let provider_credentials = ProviderCredentialState::from_child_env_snapshot( + bootstrap.provider_env_revision, + bootstrap.provider_child_env.clone(), + ); + (provider_credentials, bootstrap.provider_child_env.clone()) + } else { + // Fetch provider environment variables from the server. + // This is done after loading the policy so the sandbox can still start + // even if provider env fetch fails (graceful degradation). + let ( + provider_env_revision, + provider_env, + provider_credential_expires_at_ms, + dynamic_credentials, + static_credential_bindings, + non_secret_environment_keys, + ) = if let (Some(id), Some(endpoint)) = (&sandbox_id, &openshell_endpoint) { + match openshell_core::grpc_client::fetch_provider_environment(endpoint, id).await { + Ok(result) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .message(format!( + "Fetched provider environment [env_count:{}]", + result.environment.len() + )) + .build() + ); + ( + result.provider_env_revision, + result.environment, + result.credential_expires_at_ms, + result.dynamic_credentials, + result.static_credential_bindings, + result.non_secret_environment_keys, + ) } - } else { - ( - 0, - std::collections::HashMap::new(), + Err(e) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message(format!( + "Failed to fetch provider environment; no provider credentials are active: {e}" + )) + .build() + ); + ( + 0, + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + Vec::new(), + ) + } + } + } else { + ( + 0, + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + Vec::new(), + ) + }; + + let dynamic_credentials_fallback = dynamic_credentials.clone(); + let provider_credentials = match ProviderCredentialState::from_bound_environment( + provider_env_revision, + provider_env, + provider_credential_expires_at_ms, + dynamic_credentials, + static_credential_bindings, + non_secret_environment_keys, + ) { + Ok(credentials) => credentials, + Err(error) => { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message(format!( + "Rejected provider environment bindings; no provider credentials are active: {error}" + )) + .build() + ); + ProviderCredentialState::from_environment( + provider_env_revision, std::collections::HashMap::new(), std::collections::HashMap::new(), + dynamic_credentials_fallback, ) - }; - - let provider_credentials = ProviderCredentialState::from_environment( - provider_env_revision, - provider_env, - provider_credential_expires_at_ms, - dynamic_credentials, - ); - let provider_env = provider_credentials.child_env_with_gcp_resolved(); - (provider_credentials, provider_env) + } }; + let provider_env = provider_credentials.child_env_with_gcp_resolved(); + (provider_credentials, provider_env) + }; // Shared agent-proposals feature flag. Seed from the same initial settings // snapshot that produced the policy so networking and process setup agree @@ -2994,42 +3026,67 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { .await { Ok(env_result) => { - ctx.provider_credentials.install_environment( - env_result.provider_env_revision, + let provider_env_revision = env_result.provider_env_revision; + let install_result = ctx.provider_credentials.install_bound_environment( + provider_env_revision, env_result.environment, env_result.credential_expires_at_ms, env_result.dynamic_credentials, + env_result.static_credential_bindings, + env_result.non_secret_environment_keys, ); - let child_env = ctx.provider_credentials.child_env_with_gcp_resolved(); - let env_count = child_env.len(); - if let Some(publisher) = ctx.sidecar_control_publisher.as_ref() { - publisher.publish_provider_env( - env_result.provider_env_revision, - child_env.clone(), + if let Err(error) = install_result { + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message(format!( + "Rejected provider environment refresh; previous provider credentials are not active: {error}" + )) + .build() + ); + } else { + let child_env = ctx.provider_credentials.child_env_with_gcp_resolved(); + let env_count = child_env.len(); + if let Some(publisher) = ctx.sidecar_control_publisher.as_ref() { + publisher + .publish_provider_env(provider_env_revision, child_env.clone()); + } + current_provider_env_revision = provider_env_revision; + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::Informational) + .status(StatusId::Success) + .state(StateId::Enabled, "loaded") + .unmapped( + "provider_env_revision", + serde_json::json!(provider_env_revision) + ) + .message(format!( + "Provider environment refreshed [revision:{provider_env_revision} env_count:{env_count}]" + )) + .build() ); } - current_provider_env_revision = env_result.provider_env_revision; - ocsf_emit!( - ConfigStateChangeBuilder::new(ocsf_ctx()) - .severity(SeverityId::Informational) - .status(StatusId::Success) - .state(StateId::Enabled, "loaded") - .unmapped( - "provider_env_revision", - serde_json::json!(env_result.provider_env_revision) - ) - .message(format!( - "Provider environment refreshed [revision:{} env_count:{env_count}]", - env_result.provider_env_revision - )) - .build() - ); } Err(e) => { + ctx.provider_credentials + .revoke_static_provider_environment(result.provider_env_revision); warn!( error = %e, provider_env_revision = result.provider_env_revision, - "Settings poll: failed to refresh provider environment" + "Settings poll: failed to refresh provider environment; previous provider credentials are not active" + ); + ocsf_emit!( + ConfigStateChangeBuilder::new(ocsf_ctx()) + .severity(SeverityId::High) + .status(StatusId::Failure) + .state(StateId::Disabled, "fail_closed") + .message( + "Provider environment refresh failed; previous provider credentials are not active" + ) + .build() ); } } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 26589764f8..fd2ce0ff92 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1590,7 +1590,7 @@ pub(super) async fn compute_provider_env_revision_with_catalog( provider_names: &[String], ) -> Result { let mut hasher = Sha256::new(); - hasher.update(b"openshell-provider-env-revision-v1"); + hasher.update(b"openshell-provider-env-revision-v2"); for provider_name in provider_names { hasher.update(provider_name.as_bytes()); @@ -1722,6 +1722,7 @@ pub(super) async fn handle_get_sandbox_provider_environment( request: Request, ) -> Result, Status> { let sandbox_id = request.get_ref().sandbox_id.clone(); + let supports_static_credential_bindings = request.get_ref().supports_static_credential_bindings; crate::auth::guard::enforce_sandbox_scope(&request, &sandbox_id)?; drop(request); @@ -1749,7 +1750,7 @@ pub(super) async fn handle_get_sandbox_provider_environment( &provider_names, ) .await?; - let provider_environment = super::provider::resolve_provider_environment_with_catalog( + let mut provider_environment = super::provider::resolve_provider_environment_with_catalog( state.store.as_ref(), &provider_profile_catalog, &workspace, @@ -1757,6 +1758,27 @@ pub(super) async fn handle_get_sandbox_provider_environment( ) .await?; + if supports_static_credential_bindings { + for key in &provider_environment.static_credential_keys { + let Some(binding) = provider_environment.static_credential_bindings.get(key) else { + return Err(Status::failed_precondition( + "static provider credentials require a provider profile with network endpoints", + )); + }; + if binding.endpoints.is_empty() { + return Err(Status::failed_precondition( + "static provider credentials require at least one provider profile endpoint", + )); + } + } + } else { + for key in &provider_environment.static_credential_keys { + provider_environment.environment.remove(key); + provider_environment.credential_expires_at_ms.remove(key); + } + provider_environment.static_credential_bindings.clear(); + } + info!( sandbox_id = %sandbox_id, provider_count = provider_names.len(), @@ -1765,11 +1787,20 @@ pub(super) async fn handle_get_sandbox_provider_environment( "GetSandboxProviderEnvironment request completed successfully" ); + let non_secret_environment_keys = provider_environment + .environment + .keys() + .filter(|key| !provider_environment.static_credential_keys.contains(*key)) + .cloned() + .collect(); + Ok(Response::new(GetSandboxProviderEnvironmentResponse { environment: provider_environment.environment, provider_env_revision, credential_expires_at_ms: provider_environment.credential_expires_at_ms, dynamic_credentials: provider_environment.dynamic_credentials, + static_credential_bindings: provider_environment.static_credential_bindings, + non_secret_environment_keys, })) } @@ -5566,6 +5597,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-snapshot-consistency".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -6599,6 +6631,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-env".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -6611,6 +6644,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-env".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -6622,6 +6656,42 @@ mod tests { assert_eq!(v2_env.get("GITHUB_TOKEN"), Some(&"ghp-test".to_string())); } + #[tokio::test] + async fn provider_environment_withholds_static_credentials_from_legacy_supervisors() { + use openshell_core::proto::GetSandboxProviderEnvironmentRequest; + + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-legacy-provider-env", + "legacy-provider-env", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["work-github".to_string()], + )) + .await + .unwrap(); + + let response = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-legacy-provider-env".to_string(), + supports_static_credential_bindings: false, + })), + ) + .await + .unwrap() + .into_inner(); + + assert!(!response.environment.contains_key("GITHUB_TOKEN")); + assert!(response.static_credential_bindings.is_empty()); + } + #[tokio::test] async fn provider_env_revision_changes_when_attached_provider_record_changes() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; @@ -6645,6 +6715,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-revision".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -6661,6 +6732,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-provider-revision".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -6832,6 +6904,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -6861,6 +6934,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -6898,6 +6972,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7000,6 +7075,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7032,6 +7108,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await @@ -7068,6 +7145,7 @@ mod tests { &state, with_user(Request::new(GetSandboxProviderEnvironmentRequest { sandbox_id: "sb-attach-lifecycle".to_string(), + supports_static_credential_bindings: true, })), ) .await diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index a1d134eac0..5f2063e2bd 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -15,14 +15,14 @@ use crate::provider_profile_sources::{ use openshell_core::metadata::ObjectWorkspace; use openshell_core::proto::{ Provider, ProviderCredentialTokenGrantAudienceOverride, ProviderProfile, - ProviderProfileCredential, Sandbox, + ProviderProfileCredential, Sandbox, StaticCredentialBinding, StaticCredentialEndpointBinding, }; use openshell_core::telemetry::{ LifecycleOperation, ProviderProfile as TelemetryProviderProfile, TelemetryOutcome, }; use openshell_policy::ProviderPolicyLayer; use prost::Message; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use tonic::Status; use tracing::warn; @@ -51,6 +51,8 @@ pub(super) struct ProviderEnvironment { pub environment: HashMap, pub credential_expires_at_ms: HashMap, pub dynamic_credentials: HashMap, + pub static_credential_bindings: HashMap, + pub static_credential_keys: HashSet, } impl ProviderEnvironment { @@ -549,6 +551,8 @@ pub(super) async fn resolve_provider_environment_with_catalog( let mut env = HashMap::new(); let mut expires = HashMap::new(); + let mut static_credential_bindings = HashMap::new(); + let mut static_credential_keys = HashSet::new(); let now_ms = crate::persistence::current_time_ms(); validate_provider_environment_keys_unique_at( store, @@ -568,6 +572,27 @@ pub(super) async fn resolve_provider_environment_with_catalog( .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; + let profile_id = + normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); + let profile = + get_provider_type_profile_for_scope(catalog, profile_id, &provider.profile_workspace); + let profile_endpoints = profile.as_ref().map(|profile| { + profile + .to_proto() + .endpoints + .into_iter() + .flat_map(|endpoint| { + endpoint_ports(endpoint.port, &endpoint.ports) + .into_iter() + .map(move |port| StaticCredentialEndpointBinding { + host: endpoint.host.clone(), + port, + path: endpoint.path.clone(), + }) + }) + .collect::>() + }); + for (key, value) in &provider.credentials { if is_non_injectable_provider_credential(&provider, key) { warn!( @@ -596,6 +621,15 @@ pub(super) async fn resolve_provider_environment_with_catalog( expires.entry(key.clone()).or_insert(expires_at_ms); } env.entry(key.clone()).or_insert_with(|| value.clone()); + static_credential_keys.insert(key.clone()); + if let Some(endpoints) = &profile_endpoints { + static_credential_bindings.insert( + key.clone(), + StaticCredentialBinding { + endpoints: endpoints.clone(), + }, + ); + } } else { warn!( provider_name = %name, @@ -618,6 +652,8 @@ pub(super) async fn resolve_provider_environment_with_catalog( provider_names, ) .await?, + static_credential_bindings, + static_credential_keys, }) } @@ -928,16 +964,7 @@ fn path_prefix_pattern(path: &str) -> Option<&str> { } fn endpoint_path_matches(pattern: &str, path: &str) -> bool { - if path_matches_all(pattern) { - return true; - } - if pattern == path { - return true; - } - if let Some(prefix) = path_prefix_pattern(pattern) { - return path == prefix || path.starts_with(&format!("{prefix}/")); - } - glob::Pattern::new(pattern).is_ok_and(|glob| glob.matches(path)) + openshell_core::endpoint_path::matches(pattern, path) } pub async fn validate_provider_environment_keys_unique( @@ -2189,6 +2216,25 @@ async fn profile_attached_sandbox_diagnostics( continue; } if let Some((source, profile)) = candidate_profiles.get(profile_id) { + let has_static_credentials = provider + .credentials + .keys() + .any(|key| !is_non_injectable_provider_credential(&provider, key)); + let has_usable_endpoint = profile.to_proto().endpoints.iter().any(|endpoint| { + !endpoint_ports(endpoint.port, &endpoint.ports).is_empty() + && !endpoint.host.trim().is_empty() + }); + if has_static_credentials && !has_usable_endpoint { + diagnostics.push(ProfileValidationDiagnostic { + source: source.clone(), + profile_id: profile_id.to_string(), + field: "endpoints".to_string(), + message: format!( + "{operation} would leave static provider credentials without an authorized endpoint on sandbox '{sandbox_name}'" + ), + severity: "error".to_string(), + }); + } bindings.extend(dynamic_token_grant_bindings_for_profile( provider.object_name(), &profile.to_proto(), @@ -6442,6 +6488,18 @@ mod tests { assert_eq!(result.get("ANTHROPIC_API_KEY"), Some(&"sk-abc".to_string())); assert_eq!(result.get("CLAUDE_API_KEY"), Some(&"sk-abc".to_string())); assert!(!result.contains_key("endpoint")); + assert!( + result + .static_credential_bindings + .get("ANTHROPIC_API_KEY") + .is_some_and(|binding| !binding.endpoints.is_empty()) + ); + assert!( + result + .static_credential_bindings + .get("CLAUDE_API_KEY") + .is_some_and(|binding| !binding.endpoints.is_empty()) + ); } #[tokio::test] @@ -6462,6 +6520,7 @@ mod tests { assert_eq!(result.get("API_TOKEN"), Some(&"token-123".to_string())); assert!(result.dynamic_credentials.is_empty()); + assert!(!result.static_credential_bindings.contains_key("API_TOKEN")); } #[tokio::test] diff --git a/crates/openshell-supervisor-network/src/l7/mod.rs b/crates/openshell-supervisor-network/src/l7/mod.rs index 64f4ae3741..27456f0b83 100644 --- a/crates/openshell-supervisor-network/src/l7/mod.rs +++ b/crates/openshell-supervisor-network/src/l7/mod.rs @@ -256,16 +256,7 @@ impl L7EndpointConfig { } pub fn endpoint_path_matches(pattern: &str, path: &str) -> bool { - if pattern.is_empty() || pattern == "**" || pattern == "/**" { - return true; - } - if pattern == path { - return true; - } - if let Some(prefix) = pattern.strip_suffix("/**") { - return path == prefix || path.starts_with(&format!("{prefix}/")); - } - glob::Pattern::new(pattern).is_ok_and(|glob| glob.matches(path)) + openshell_core::endpoint_path::matches(pattern, path) } /// Parse the `tls` field from an endpoint config, independent of L7 protocol. diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index fa2eab4ad7..291ec87e95 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -24,8 +24,9 @@ use miette::{IntoDiagnostic, Result, miette}; use openshell_core::activity::{ActivitySender, try_record_activity}; use openshell_core::secrets::{self, SecretResolver}; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, - NetworkActivityBuilder, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, + HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, SeverityId, StatusId, Url as OcsfUrl, + ocsf_emit, }; #[cfg(test)] use std::collections::BTreeMap; @@ -34,6 +35,7 @@ use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; use tracing::{debug, warn}; /// Context for L7 request policy evaluation. +#[derive(Clone)] #[cfg_attr(test, derive(Default))] pub struct L7EvalContext { /// Host from the CONNECT request. @@ -50,6 +52,9 @@ pub struct L7EvalContext { pub cmdline_paths: Vec, /// Supervisor-only placeholder resolver for outbound headers. pub(crate) secret_resolver: Option>, + /// Live provider state used to scope static credentials to each request. + pub(crate) provider_credentials: + Option, /// Anonymous activity counter channel. pub(crate) activity_tx: Option, /// Dynamic credentials (token grants) keyed by endpoint-bound provider metadata. @@ -67,6 +72,113 @@ pub struct L7EvalContext { pub(crate) agent_proposals: openshell_core::proposals::AgentProposals, } +fn scoped_context_for_request(ctx: &L7EvalContext, target: &str) -> Option { + let credentials = ctx.provider_credentials.as_ref()?; + let mut scoped = ctx.clone(); + scoped.secret_resolver = credentials.resolver_for_endpoint(&ctx.host, ctx.port, target); + Some(scoped) +} + +async fn reject_credential_resolution( + client: &mut W, + ctx: &L7EvalContext, + error: &secrets::UnresolvedPlaceholderError, +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + let endpoint_mismatch = error.is_endpoint_mismatch(); + let status = if endpoint_mismatch { + "403 Forbidden" + } else { + "500 Internal Server Error" + }; + let body = if endpoint_mismatch { + r#"{"error":"credential_endpoint_mismatch","message":"Credential is not authorized for this request endpoint"}"# + } else { + r#"{"error":"credential_unavailable","message":"Credential placeholder could not be resolved"}"# + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + client + .write_all(response.as_bytes()) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(if endpoint_mismatch { + SeverityId::High + } else { + SeverityId::Medium + }) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, "credential-binding") + .message(if endpoint_mismatch { + format!( + "Credential use denied: credential is not authorized for {}:{}", + ctx.host, ctx.port + ) + } else { + format!( + "Credential use denied: credential is unavailable for {}:{}", + ctx.host, ctx.port + ) + }) + .status_detail(if endpoint_mismatch { + "credential_endpoint_mismatch" + } else { + "credential_unavailable" + }) + .build(); + ocsf_emit!(event); + + if endpoint_mismatch { + let finding = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .is_alert(true) + .finding_info(FindingInfo::new( + "openshell.provider_credential.endpoint_mismatch", + "Provider credential used at an unauthorized endpoint", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("disposition", "denied"), + ]) + .message("Provider credential endpoint binding mismatch; request denied") + .build(); + ocsf_emit!(finding); + } + Ok(()) +} + +async fn preflight_request_credentials( + client: &mut W, + ctx: &L7EvalContext, + request: &crate::l7::provider::L7Request, +) -> Result +where + W: AsyncWrite + Unpin, +{ + match secrets::rewrite_http_header_block(&request.raw_header, ctx.secret_resolver.as_deref()) { + Ok(_) => Ok(true), + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + Ok(false) + } + } +} + #[derive(Default)] pub(crate) struct UpgradeRelayOptions<'a> { pub(crate) websocket_request: bool, @@ -299,7 +411,14 @@ where } }; - let Some(config) = select_l7_config_for_path(configs, &req.target) else { + let route_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); + } + }; + let Some(config) = select_l7_config_for_path(configs, &route_target) else { crate::l7::rest::RestProvider::default() .deny_with_redacted_target( &req, @@ -312,6 +431,11 @@ where .await?; return Ok(()); }; + let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + if !preflight_request_credentials(client, ctx, &req).await? { + return Ok(()); + } if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); @@ -387,24 +511,12 @@ where return Ok(()); } - let (eval_target, redacted_target) = if let Some(ref resolver) = ctx.secret_resolver { - match secrets::rewrite_target_for_eval(&req.target, resolver) { - Ok(result) => (result.resolved, result.redacted), - Err(e) => { - warn!( - host = %ctx.host, - port = ctx.port, - error = %e, - "credential resolution failed in request target, rejecting" - ); - let response = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - client.write_all(response).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(()); - } + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); } - } else { - (req.target.clone(), req.target.clone()) }; let request_info = L7RequestInfo { @@ -466,8 +578,6 @@ where &protocol_summary, ); - let _ = &eval_target; - if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; // Route selection resolved `config` per request, so re-check the @@ -716,6 +826,10 @@ where crate::l7::websocket::RelayOptions { policy_name: &options.policy_name, resolver, + provider_credentials: options + .ctx + .and_then(|ctx| ctx.provider_credentials.as_ref()), + target: &options.target, inspector, compression, }, @@ -765,7 +879,7 @@ pub(crate) fn upgrade_options<'a>( None }, engine, - ctx: engine.map(|_| ctx), + ctx: (engine.is_some() || websocket_credential_rewrite).then_some(ctx), enforcement: config.enforcement, target: target.to_string(), query_params: query_params.clone(), @@ -835,6 +949,11 @@ where return Ok(()); // Close connection on parse error } }; + let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + if !preflight_request_credentials(client, ctx, &req).await? { + return Ok(()); + } if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); @@ -844,27 +963,14 @@ where return Ok(()); } - // Rewrite credential placeholders in the request target BEFORE OPA - // evaluation. OPA sees the redacted path; the resolved path goes only - // to the upstream write. - let (eval_target, redacted_target) = if let Some(ref resolver) = ctx.secret_resolver { - match secrets::rewrite_target_for_eval(&req.target, resolver) { - Ok(result) => (result.resolved, result.redacted), - Err(e) => { - warn!( - host = %ctx.host, - port = ctx.port, - error = %e, - "credential resolution failed in request target, rejecting" - ); - let response = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - client.write_all(response).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(()); - } + // Redact placeholder syntax before OPA evaluation without consulting + // real credential material. Resolution happens only at upstream write. + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); } - } else { - (req.target.clone(), req.target.clone()) }; let request_info = L7RequestInfo { @@ -951,9 +1057,6 @@ where ocsf_emit!(event); } - // Store the resolved target for the deny response redaction - let _ = &eval_target; - if allowed || config.enforcement == EnforcementMode::Audit { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; // REST and websocket-upgrade policy evaluates only the method, @@ -1147,6 +1250,11 @@ where let req = parsed.request; let jsonrpc_info = parsed.info; + let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + if !preflight_request_credentials(client, ctx, &req).await? { + return Ok(()); + } if close_if_stale(engine.generation_guard(), ctx) { return Ok(()); @@ -1345,6 +1453,11 @@ where let req = parsed.request; let graphql_info = parsed.info; + let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + if !preflight_request_credentials(client, ctx, &req).await? { + return Ok(()); + } if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); @@ -1354,24 +1467,12 @@ where return Ok(()); } - let (eval_target, redacted_target) = if let Some(ref resolver) = ctx.secret_resolver { - match secrets::rewrite_target_for_eval(&req.target, resolver) { - Ok(result) => (result.resolved, result.redacted), - Err(e) => { - warn!( - host = %ctx.host, - port = ctx.port, - error = %e, - "credential resolution failed in GraphQL request target, rejecting" - ); - let response = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - client.write_all(response).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(()); - } + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); } - } else { - (req.target.clone(), req.target.clone()) }; let request_info = L7RequestInfo { @@ -1439,8 +1540,6 @@ where ocsf_emit!(event); } - let _ = &eval_target; - if allowed || (config.enforcement == EnforcementMode::Audit && !force_deny) { let chain = engine.query_middleware_chain(&middleware_network_input(ctx))?; // Policy admitted the original body above; re-check the body @@ -2000,8 +2099,6 @@ where // `allow_encoded_slash` opt-in applies. let provider = crate::l7::rest::RestProvider::default(); let mut request_count: u64 = 0; - let resolver = ctx.secret_resolver.as_deref(); - loop { if close_if_stale(generation_guard, ctx) { return Ok(()); @@ -2021,6 +2118,12 @@ where return Ok(()); } }; + let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + let resolver = ctx.secret_resolver.as_deref(); + if !preflight_request_credentials(client, ctx, &req).await? { + return Ok(()); + } if close_if_stale(generation_guard, ctx) { return Ok(()); @@ -2028,25 +2131,13 @@ where request_count += 1; - // Resolve and redact the target for logging. - let redacted_target = if let Some(ref res) = ctx.secret_resolver { - match secrets::rewrite_target_for_eval(&req.target, res) { - Ok(result) => result.redacted, - Err(e) => { - warn!( - host = %ctx.host, - port = ctx.port, - error = %e, - "credential resolution failed in request target, rejecting" - ); - let response = b"HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; - client.write_all(response).await.into_diagnostic()?; - client.flush().await.into_diagnostic()?; - return Ok(()); - } + // Build the logging representation without materializing a secret. + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); } - } else { - req.target.clone() }; // Log for observability via OCSF HTTP Activity event. diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index bb59c5227b..f8208f11f9 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -11,10 +11,11 @@ use crate::l7::{EnforcementMode, L7RequestInfo}; use crate::opa::TunnelPolicyEngine; use flate2::{Compress, Compression, Decompress, FlushCompress, FlushDecompress, Status}; use miette::{IntoDiagnostic, Result, miette}; -use openshell_core::secrets::SecretResolver; +use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_core::secrets::{SecretResolver, contains_reserved_credential_marker}; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, NetworkActivityBuilder, SeverityId, StatusId, - ocsf_emit, + ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, + NetworkActivityBuilder, SeverityId, StatusId, ocsf_emit, }; use std::collections::HashMap; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; @@ -28,6 +29,7 @@ const OPCODE_BINARY: u8 = 0x2; const OPCODE_CLOSE: u8 = 0x8; const OPCODE_PING: u8 = 0x9; const OPCODE_PONG: u8 = 0xA; +const CREDENTIAL_ENDPOINT_MISMATCH: &str = "websocket credential endpoint mismatch"; #[derive(Debug)] struct FrameHeader { @@ -65,6 +67,8 @@ pub(super) struct InspectionOptions<'a> { pub(super) struct RelayOptions<'a> { pub(super) policy_name: &'a str, pub(super) resolver: Option<&'a SecretResolver>, + pub(super) provider_credentials: Option<&'a ProviderCredentialState>, + pub(super) target: &'a str, pub(super) inspector: Option>, pub(super) compression: WebSocketCompression, } @@ -105,11 +109,67 @@ where result = client_to_server => result, result = server_to_client => result, }; + if result + .as_ref() + .is_err_and(|error| error.to_string().contains(CREDENTIAL_ENDPOINT_MISMATCH)) + { + emit_credential_endpoint_mismatch(host, port, options.policy_name); + write_policy_violation_close(&mut client_write).await?; + } let _ = upstream_write.shutdown().await; let _ = client_write.shutdown().await; result } +async fn write_policy_violation_close(writer: &mut W) -> Result<()> { + let reason = b"credential endpoint mismatch"; + let mut frame = Vec::with_capacity(reason.len() + 4); + frame.push(0x80 | OPCODE_CLOSE); + frame.push(u8::try_from(reason.len() + 2).expect("close reason fits one-byte length")); + frame.extend_from_slice(&1008u16.to_be_bytes()); + frame.extend_from_slice(reason); + writer.write_all(&frame).await.into_diagnostic()?; + writer.flush().await.into_diagnostic() +} + +fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { + ocsf_emit!( + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(host, port)) + .firewall_rule(policy_name, "credential-binding") + .message(format!( + "WebSocket credential use denied: credential is not authorized for {host}:{port}" + )) + .status_detail("credential_endpoint_mismatch") + .build() + ); + ocsf_emit!( + DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .is_alert(true) + .finding_info(FindingInfo::new( + "openshell.provider_credential.endpoint_mismatch", + "Provider credential used at an unauthorized endpoint", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("host", host), + ("protocol", "websocket"), + ("disposition", "denied"), + ]) + .message("Provider credential endpoint binding mismatch; WebSocket closed") + .build() + ); +} + async fn relay_client_to_server( reader: &mut R, writer: &mut W, @@ -492,10 +552,24 @@ async fn relay_text_payload( }; let mut text = String::from_utf8(message_payload) .map_err(|_| miette!("websocket text message is not valid UTF-8"))?; - let replacements = if let Some(resolver) = options.resolver { + let live_resolver = options + .provider_credentials + .and_then(|credentials| credentials.resolver_for_endpoint(host, port, options.target)); + let resolver = live_resolver.as_deref().or(options.resolver); + let replacements = if let Some(resolver) = resolver { resolver .rewrite_websocket_text_placeholders(&mut text) - .map_err(|_| miette!("websocket credential placeholder resolution failed"))? + .map_err(|error| { + if error.is_endpoint_mismatch() { + miette!(CREDENTIAL_ENDPOINT_MISMATCH) + } else { + miette!("websocket credential placeholder resolution failed") + } + })? + } else if contains_reserved_credential_marker(&text) { + return Err(miette!( + "websocket credential placeholder resolution failed" + )); } else { 0 }; @@ -1226,6 +1300,8 @@ network_policies: let options = RelayOptions { policy_name: "test-policy", resolver: Some(&resolver), + provider_credentials: None, + target: "/", inspector: None, compression: WebSocketCompression::None, }; @@ -1284,6 +1360,8 @@ network_policies: let options = RelayOptions { policy_name: "graphql_ws", resolver, + provider_credentials: None, + target: "/graphql", inspector: Some(InspectionOptions { engine: &tunnel_engine, ctx: &ctx, @@ -1320,6 +1398,8 @@ network_policies: let options = RelayOptions { policy_name: "test-policy", resolver: Some(&resolver), + provider_credentials: None, + target: "/", inspector: None, compression: WebSocketCompression::PermessageDeflate, }; @@ -1557,6 +1637,8 @@ network_policies: RelayOptions { policy_name: "test-policy", resolver: Some(&resolver), + provider_credentials: None, + target: "/", inspector: None, compression: WebSocketCompression::None, }, diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index bc093e439b..7d37b18499 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -20,8 +20,9 @@ use openshell_core::policy::ProxyPolicy; use openshell_core::provider_credentials::ProviderCredentialState; use openshell_core::secrets::{self, SecretResolver, rewrite_header_line_checked}; use openshell_ocsf::{ - ActionId, ActivityId, DispositionId, Endpoint, HttpActivityBuilder, HttpRequest, - NetworkActivityBuilder, Process, SeverityId, StatusId, Url as OcsfUrl, ocsf_emit, + ActionId, ActivityId, DetectionFindingBuilder, DispositionId, Endpoint, FindingInfo, + HttpActivityBuilder, HttpRequest, NetworkActivityBuilder, Process, SeverityId, StatusId, + Url as OcsfUrl, ocsf_emit, }; use std::net::{IpAddr, SocketAddr}; use std::path::PathBuf; @@ -59,6 +60,41 @@ const INFERENCE_LOCAL_PORT: u16 = 443; #[cfg(target_os = "linux")] const SIDECAR_SUPERVISOR_TOPOLOGY: &str = "sidecar"; +fn emit_credential_endpoint_mismatch(host: &str, port: u16, policy_name: &str) { + let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(host, port)) + .firewall_rule(policy_name, "credential-binding") + .message(format!( + "Credential use denied: credential is not authorized for {host}:{port}" + )) + .status_detail("credential_endpoint_mismatch") + .build(); + ocsf_emit!(event); + let finding = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .is_alert(true) + .finding_info(FindingInfo::new( + "openshell.provider_credential.endpoint_mismatch", + "Provider credential used at an unauthorized endpoint", + )) + .evidence_pairs(&[ + ("policy", policy_name), + ("host", host), + ("disposition", "denied"), + ]) + .message("Provider credential endpoint binding mismatch; request denied") + .build(); + ocsf_emit!(finding); +} + /// Hostnames injected by compute drivers as `/etc/hosts` aliases for the host /// machine. Traffic to these names is eligible for the trusted-gateway SSRF /// exemption when the resolved IP matches the driver-injected value read from @@ -323,6 +359,7 @@ impl ProxyHandle { let proposals = agent_proposals.clone(); let gw = trusted_host_gateway.clone(); let up_proxy = upstream_proxy.clone(); + let credentials = provider_credentials.clone(); let resolver = provider_credentials .as_ref() .and_then(ProviderCredentialState::resolver); @@ -346,6 +383,7 @@ impl ProxyHandle { proposals, gw, up_proxy, + credentials, resolver, dynamic_credentials, dtx, @@ -1061,6 +1099,7 @@ async fn handle_tcp_connection( agent_proposals: openshell_core::proposals::AgentProposals, trusted_host_gateway: Arc>, upstream_proxy: Arc>, + provider_credentials: Option, secret_resolver: Option>, dynamic_credentials: Option< Arc< @@ -1130,6 +1169,7 @@ async fn handle_tcp_connection( policy_local_ctx, agent_proposals, trusted_host_gateway, + provider_credentials, secret_resolver, dynamic_credentials, denial_tx.as_ref(), @@ -1469,6 +1509,7 @@ async fn handle_tcp_connection( // Build request-processing context shared by CONNECT and forward HTTP. let ctx = relay::http_context( &decision, + provider_credentials, secret_resolver.clone(), activity_tx.clone(), dynamic_credentials.clone(), @@ -3710,7 +3751,7 @@ fn rewrite_forward_request( if output_str.contains(secrets::PLACEHOLDER_PREFIX_PUBLIC) || output_str.contains(secrets::PROVIDER_ALIAS_MARKER_PUBLIC) { - return Err(secrets::UnresolvedPlaceholderError { location: "header" }); + return Err(secrets::UnresolvedPlaceholderError::unavailable("header")); } } @@ -3878,6 +3919,7 @@ async fn handle_forward_proxy( policy_local_ctx: Option>, agent_proposals: openshell_core::proposals::AgentProposals, trusted_host_gateway: Arc>, + provider_credentials: Option, secret_resolver: Option>, dynamic_credentials: Option< Arc< @@ -3908,6 +3950,10 @@ async fn handle_forward_proxy( } }; let host_lc = host.to_ascii_lowercase(); + let secret_resolver = provider_credentials + .as_ref() + .and_then(|credentials| credentials.resolver_for_endpoint(&host_lc, port, &path)) + .or(secret_resolver); if host_lc == POLICY_LOCAL_HOST { if scheme != "http" || port != 80 { @@ -4126,6 +4172,7 @@ async fn handle_forward_proxy( let mut request_body_credential_rewrite = false; let l7_ctx = relay::http_context( &decision, + provider_credentials, secret_resolver.clone(), activity_tx.cloned(), dynamic_credentials.clone(), @@ -4260,7 +4307,20 @@ async fn handle_forward_proxy( return Ok(()); } }; - let Some(l7_config) = select_l7_config_for_path(&route.configs, &path) else { + let Ok(redacted_path) = secrets::redact_target_for_policy(&path) else { + respond( + client, + &build_json_error_response( + 400, + "Bad Request", + "invalid_credential_placeholder", + "request-target contains an invalid credential placeholder", + ), + ) + .await?; + return Ok(()); + }; + let Some(l7_config) = select_l7_config_for_path(&route.configs, &redacted_path) else { emit_activity_simple(activity_tx, true, "l7_policy"); respond( client, @@ -4440,7 +4500,7 @@ async fn handle_forward_proxy( }; let request_info = crate::l7::L7RequestInfo { action: method.to_string(), - target: path.clone(), + target: redacted_path, query_params, graphql, jsonrpc, @@ -4822,16 +4882,30 @@ async fn handle_forward_proxy( error = %e, "credential injection failed in forward proxy" ); - respond( - client, - &build_json_error_response( - 500, - "Internal Server Error", - "credential_injection_failed", - "unresolved credential placeholder in request", - ), - ) - .await?; + if e.is_endpoint_mismatch() { + emit_credential_endpoint_mismatch(&host_lc, port, policy_str); + respond( + client, + &build_json_error_response( + 403, + "Forbidden", + "credential_endpoint_mismatch", + "credential is not authorized for this request endpoint", + ), + ) + .await?; + } else { + respond( + client, + &build_json_error_response( + 500, + "Internal Server Error", + "credential_injection_failed", + "unresolved credential placeholder in request", + ), + ) + .await?; + } return Ok(()); } }; @@ -5177,6 +5251,7 @@ network_policies: {} None, None, None, + None, )) .await .expect("malformed request should be handled"); @@ -9543,6 +9618,7 @@ network_policies: AgentProposals::default(), // agent_proposals Arc::new(None), // trusted_host_gateway Arc::new(None), // upstream_proxy + None, // provider_credentials None, // secret_resolver None, // dynamic_credentials Some(denial_tx), // denial_tx — positive allow/deny signal @@ -9611,6 +9687,7 @@ network_policies: Arc::new(None), None, None, + None, Some(denial_tx), None, )) diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 5eada877a1..81de24fc57 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -40,6 +40,7 @@ pub(super) struct RelayContext<'a> { /// Build the request-processing context shared by CONNECT and forward HTTP. pub(super) fn http_context( decision: &EgressDecision, + provider_credentials: Option, secret_resolver: Option>, activity_tx: Option, dynamic_credentials: Option, @@ -70,6 +71,7 @@ pub(super) fn http_context( .map(|path| path.to_string_lossy().into_owned()) .collect(), secret_resolver, + provider_credentials, activity_tx, dynamic_credentials: dynamic_credentials.clone(), token_grant_resolver: dynamic_credentials @@ -338,6 +340,7 @@ mod tests { ancestors: vec![], cmdline_paths: vec![], secret_resolver: None, + provider_credentials: None, activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 1ffe08d884..10a2a21a06 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -604,6 +604,7 @@ network_policies: None, None, None, + None, )) .await .unwrap(); diff --git a/proto/openshell.proto b/proto/openshell.proto index 1b447a17e3..ed2d0d5620 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1329,6 +1329,22 @@ message DeleteProviderProfileResponse { message GetSandboxProviderEnvironmentRequest { // The sandbox ID. string sandbox_id = 1; + // Whether the requesting supervisor enforces endpoint bindings for static + // provider credentials. Gateways withhold static credential material when + // this capability is absent. + bool supports_static_credential_bindings = 2; +} + +// One network endpoint at which a static provider credential may be resolved. +message StaticCredentialEndpointBinding { + string host = 1; + uint32 port = 2; + string path = 3; +} + +// Endpoint allowlist for one static provider credential environment variable. +message StaticCredentialBinding { + repeated StaticCredentialEndpointBinding endpoints = 1; } // Get sandbox provider environment response. @@ -1343,6 +1359,12 @@ message GetSandboxProviderEnvironmentResponse { // Maps endpoint-bound provider metadata to credential metadata. // Supervisor uses this to inject Authorization headers for token grant credentials. map dynamic_credentials = 4; + // Endpoint allowlists for static credential environment variables. Every + // static credential in `environment` has a complete binding. + map static_credential_bindings = 5; + // Environment variables that contain provider configuration rather than + // credentials and therefore do not require endpoint-scoped resolution. + repeated string non_secret_environment_keys = 6; } // --------------------------------------------------------------------------- From ea56878f2c211979f078d4a43acc4e972a6a2b18 Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 27 Jul 2026 19:04:09 -0700 Subject: [PATCH 02/32] test(e2e): verify static credential endpoint isolation Signed-off-by: John Myers --- e2e/python/test_sandbox_providers.py | 18 +- e2e/rust/tests/host_gateway_alias.rs | 263 +++++++++++++++++++++++++++ 2 files changed, 272 insertions(+), 9 deletions(-) diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 8c077712e0..3295f380ac 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -195,11 +195,11 @@ def read_env_var() -> str: assert value != "sk-e2e-test-key-12345" -def test_generic_provider_credentials_available_as_env_vars( +def test_profileless_provider_credentials_fail_closed( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, ) -> None: - """Generic provider env vars are placeholders, not raw secrets.""" + """Profileless credentials are withheld because they have no endpoint binding.""" with provider( sandbox_client._stub, name="e2e-test-generic-provider-env", @@ -225,8 +225,8 @@ def read_generic_env_vars() -> str: result = sb.exec_python(read_generic_env_vars) assert result.exit_code == 0, result.stderr token, url = result.stdout.strip().split("|") - assert _is_placeholder_for_env_key(token, "CUSTOM_SERVICE_TOKEN") - assert _is_placeholder_for_env_key(url, "CUSTOM_SERVICE_URL") + assert token == "NOT_SET" + assert url == "NOT_SET" def test_nvidia_provider_injects_nvidia_api_key_env_var( @@ -269,15 +269,15 @@ def test_attach_detach_updates_credentials_for_later_exec_launches( with provider( stub, name=provider_name, - provider_type="generic", - credentials={"CUSTOM_ATTACH_TOKEN": "token-attach-detach"}, + provider_type="nvidia", + credentials={"NVIDIA_API_KEY": "token-attach-detach"}, ): spec = datamodel_pb2.SandboxSpec(policy=_default_policy(), providers=[]) def read_attach_token() -> str: import os - return os.environ.get("CUSTOM_ATTACH_TOKEN", "NOT_SET") + return os.environ.get("NVIDIA_API_KEY", "NOT_SET") def exec_token(sb: Sandbox) -> str: result = sb.exec_python(read_attach_token) @@ -292,7 +292,7 @@ def wait_for_token(sb: Sandbox, expected: str) -> None: if expected == "NOT_SET": matched = last == expected else: - matched = _is_placeholder_for_env_key(last, "CUSTOM_ATTACH_TOKEN") + matched = _is_placeholder_for_env_key(last, "NVIDIA_API_KEY") if matched: return time.sleep(2) @@ -310,7 +310,7 @@ def wait_for_token(sb: Sandbox, expected: str) -> None: ) wait_for_token( sb, - "openshell:resolve:env:CUSTOM_ATTACH_TOKEN", + "openshell:resolve:env:NVIDIA_API_KEY", ) stub.DetachSandboxProvider( diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index 2dbdbf1dc4..2f37b4a3d9 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -17,6 +17,10 @@ use tokio::task::JoinHandle; const INFERENCE_PROVIDER_NAME: &str = "e2e-host-inference"; const INFERENCE_PROVIDER_UNREACHABLE_NAME: &str = "e2e-host-inference-unreachable"; +const BINDING_PROVIDER_A_NAME: &str = "e2e-static-endpoint-binding-provider-a"; +const BINDING_PROVIDER_B_NAME: &str = "e2e-static-endpoint-binding-provider-b"; +const BINDING_PROFILE_A_ID: &str = "e2e-static-endpoint-binding-a"; +const BINDING_PROFILE_B_ID: &str = "e2e-static-endpoint-binding-b"; static INFERENCE_ROUTE_LOCK: Mutex<()> = Mutex::new(()); async fn run_cli(args: &[&str]) -> Result { @@ -43,6 +47,36 @@ async fn run_cli(args: &[&str]) -> Result { Ok(combined) } +async fn wait_for_sandbox_logs( + sandbox_name: &str, + expected: impl Fn(&str) -> bool, +) -> Result { + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + + loop { + let logs = run_cli(&[ + "logs", + sandbox_name, + "-n", + "500", + "--since", + "2m", + "--source", + "sandbox", + ]) + .await?; + if expected(&logs) { + return Ok(logs); + } + if tokio::time::Instant::now() >= deadline { + return Err(format!( + "timed out waiting for expected sandbox logs:\n{logs}" + )); + } + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } +} + struct HostServer { port: u16, task: JoinHandle<()>, @@ -50,6 +84,13 @@ struct HostServer { impl HostServer { async fn start(response_body: &str) -> Result { + Self::start_with_auth_check(response_body, None).await + } + + async fn start_with_auth_check( + response_body: &str, + expected_authorization: Option<&str>, + ) -> Result { let listener = TcpListener::bind(("0.0.0.0", 0)) .await .map_err(|e| format!("bind host test server: {e}"))?; @@ -58,12 +99,14 @@ impl HostServer { .map_err(|e| format!("read host test server address: {e}"))? .port(); let response_body = response_body.as_bytes().to_vec(); + let expected_authorization = expected_authorization.map(str::to_string); let task = tokio::spawn(async move { loop { let Ok((mut stream, _)) = listener.accept().await else { break; }; let body = response_body.clone(); + let expected_authorization = expected_authorization.clone(); tokio::spawn(async move { let mut request = Vec::new(); let mut buf = [0_u8; 1024]; @@ -80,6 +123,16 @@ impl HostServer { } } + let body = expected_authorization.map_or(body, |expected| { + let request = String::from_utf8_lossy(&request); + format!( + r#"{{"authorized":{}}}"#, + request.lines().any(|line| { + line.eq_ignore_ascii_case(&format!("Authorization: {expected}")) + }) + ) + .into_bytes() + }); let response = format!( "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", body.len() @@ -97,6 +150,85 @@ impl HostServer { } } +fn write_binding_profile( + id: &str, + display_name: &str, + env_var: &str, + host: &str, + port: u16, +) -> Result { + let mut file = NamedTempFile::new().map_err(|e| format!("create provider profile: {e}"))?; + let profile = format!( + r#"id: {id} +display_name: {display_name} +category: other +credentials: + - name: bound_token + env_vars: [{env_var}] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: {host} + port: {port} + path: /allowed/** + protocol: rest + access: full + enforcement: enforce +binaries: [/usr/bin/curl] +"# + ); + file.write_all(profile.as_bytes()) + .map_err(|e| format!("write provider profile: {e}"))?; + file.flush() + .map_err(|e| format!("flush provider profile: {e}"))?; + Ok(file) +} + +fn write_binding_policy(port: u16) -> Result { + let mut file = NamedTempFile::new().map_err(|e| format!("create binding policy: {e}"))?; + let policy = format!( + r#"version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] + +landlock: + compatibility: best_effort + +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + binding_test: + name: binding_test + endpoints: + - host: host.openshell.internal + port: {port} + allowed_ips: ["10.0.0.0/8", "172.0.0.0/8", "192.168.0.0/16", "fc00::/7"] + protocol: rest + access: full + enforcement: enforce + - host: host.docker.internal + port: {port} + allowed_ips: ["10.0.0.0/8", "172.0.0.0/8", "192.168.0.0/16", "fc00::/7"] + protocol: rest + access: full + enforcement: enforce + binaries: + - path: /usr/bin/curl +"# + ); + file.write_all(policy.as_bytes()) + .map_err(|e| format!("write binding policy: {e}"))?; + file.flush() + .map_err(|e| format!("flush binding policy: {e}"))?; + Ok(file) +} + impl Drop for HostServer { fn drop(&mut self) { self.task.abort(); @@ -123,6 +255,17 @@ async fn delete_provider(name: &str) { let _ = cmd.status().await; } +async fn delete_provider_profile(id: &str) { + let mut cmd = openshell_cmd(); + cmd.arg("provider") + .arg("profile") + .arg("delete") + .arg(id) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = cmd.status().await; +} + async fn create_openai_provider(name: &str, base_url: &str) -> Result { run_cli(&[ "provider", @@ -223,6 +366,126 @@ async fn sandbox_reaches_host_openshell_internal_via_host_gateway_alias() { ); } +#[tokio::test] +async fn static_provider_credentials_are_bound_to_profile_endpoints() { + let server = HostServer::start_with_auth_check("", Some("Bearer e2e-bound-secret")) + .await + .expect("start credential echo server"); + let profile_a = write_binding_profile( + BINDING_PROFILE_A_ID, + "E2E static endpoint binding A", + "BOUND_TOKEN_A", + "host.openshell.internal", + server.port, + ) + .expect("write provider A binding profile"); + let profile_b = write_binding_profile( + BINDING_PROFILE_B_ID, + "E2E static endpoint binding B", + "BOUND_TOKEN_B", + "host.docker.internal", + server.port, + ) + .expect("write provider B binding profile"); + let policy = write_binding_policy(server.port).expect("write binding policy"); + let profile_a_path = profile_a.path().to_string_lossy().into_owned(); + let profile_b_path = profile_b.path().to_string_lossy().into_owned(); + let policy_path = policy.path().to_string_lossy().into_owned(); + + delete_provider(BINDING_PROVIDER_A_NAME).await; + delete_provider(BINDING_PROVIDER_B_NAME).await; + delete_provider_profile(BINDING_PROFILE_A_ID).await; + delete_provider_profile(BINDING_PROFILE_B_ID).await; + run_cli(&["provider", "profile", "import", "--file", &profile_a_path]) + .await + .expect("import provider A endpoint-binding profile"); + run_cli(&["provider", "profile", "import", "--file", &profile_b_path]) + .await + .expect("import provider B endpoint-binding profile"); + run_cli(&[ + "provider", + "create", + "--name", + BINDING_PROVIDER_A_NAME, + "--type", + BINDING_PROFILE_A_ID, + "--credential", + "BOUND_TOKEN_A=e2e-bound-secret", + ]) + .await + .expect("create endpoint-bound provider A"); + run_cli(&[ + "provider", + "create", + "--name", + BINDING_PROVIDER_B_NAME, + "--type", + BINDING_PROFILE_B_ID, + "--credential", + "BOUND_TOKEN_B=e2e-provider-b-secret", + ]) + .await + .expect("create endpoint-bound provider B"); + + let command = format!( + r#"allowed=$(curl --silent --show-error --max-time 15 -H "Authorization: Bearer $BOUND_TOKEN_A" http://host.openshell.internal:{}/allowed/check); denied=$(curl --silent --show-error --max-time 15 -o /tmp/denied-body -w "%{{http_code}}" -H "Authorization: Bearer $BOUND_TOKEN_A" http://host.docker.internal:{}/allowed/check); printf 'ALLOWED=%s DENIED=%s\n' "$allowed" "$denied""#, + server.port, server.port + ); + let mut guard = SandboxGuard::create(&[ + "--policy", + &policy_path, + "--provider", + BINDING_PROVIDER_A_NAME, + "--provider", + BINDING_PROVIDER_B_NAME, + "--no-auto-providers", + "--", + "sh", + "-c", + &command, + ]) + .await + .expect("run endpoint-binding requests"); + + assert!( + guard + .create_output + .contains(r#"ALLOWED={"authorized":true}"#), + "credential should resolve at the bound endpoint:\n{}", + guard.create_output + ); + assert!( + guard.create_output.contains("DENIED=403"), + "same placeholder must be denied at an unbound host:\n{}", + guard.create_output + ); + + let logs = wait_for_sandbox_logs(&guard.name, |logs| { + logs.contains("openshell.provider_credential.endpoint_mismatch") + && logs.contains("credential_endpoint_mismatch") + }) + .await + .expect("fetch endpoint mismatch logs"); + assert!( + logs.contains("openshell.provider_credential.endpoint_mismatch") + && logs.contains("credential_endpoint_mismatch"), + "OCSF logs should explain the endpoint-binding denial without secret material:\n{logs}" + ); + assert!( + !logs.contains("e2e-bound-secret") + && !logs.contains("e2e-provider-b-secret") + && !logs.contains("BOUND_TOKEN_A") + && !logs.contains("BOUND_TOKEN_B"), + "OCSF logs must not contain credential values or environment keys:\n{logs}" + ); + + guard.cleanup().await; + delete_provider(BINDING_PROVIDER_A_NAME).await; + delete_provider(BINDING_PROVIDER_B_NAME).await; + delete_provider_profile(BINDING_PROFILE_A_ID).await; + delete_provider_profile(BINDING_PROFILE_B_ID).await; +} + #[tokio::test] async fn sandbox_inference_local_routes_to_host_openshell_internal() { let _inference_lock = INFERENCE_ROUTE_LOCK From 30b873662a3993414bdd961d7cd207ea18d30180 Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 27 Jul 2026 19:04:14 -0700 Subject: [PATCH 03/32] docs(provider): explain static credential endpoint binding Signed-off-by: John Myers --- .agents/skills/generate-sandbox-policy/SKILL.md | 6 ++++++ .agents/skills/openshell-cli/SKILL.md | 10 +++++++++- architecture/gateway.md | 9 +++++++++ architecture/sandbox.md | 16 ++++++++++++++++ docs/sandboxes/providers-v2.mdx | 15 +++++++++------ 5 files changed, 49 insertions(+), 7 deletions(-) diff --git a/.agents/skills/generate-sandbox-policy/SKILL.md b/.agents/skills/generate-sandbox-policy/SKILL.md index b177c4f3f1..e7027327d7 100644 --- a/.agents/skills/generate-sandbox-policy/SKILL.md +++ b/.agents/skills/generate-sandbox-policy/SKILL.md @@ -384,6 +384,9 @@ Before presenting the policy to the user, verify correctness **and** flag breadt - [ ] `protocol: rest` on port 443 should have `tls: terminate` - [ ] HTTP methods are standard: GET, HEAD, POST, PUT, DELETE, PATCH, OPTIONS, or `*` +- [ ] Credentialed destinations are also covered by the attached provider + profile endpoint; policy admission alone does not authorize credential + resolution ### Structural Checks @@ -443,6 +446,9 @@ The policy needs to go somewhere. Determine which mode applies: 2. **Check for conflicts**: - Does a policy with the same key already exist? If so, ask the user whether to **replace** it, **merge** new endpoints/binaries into it, or use a different key. - Does an existing endpoint selector overlap the new selector? Compatible overlaps are allowed and can intentionally aggregate allow and deny rules. Reject or revise equally specific overlaps that disagree on connection or request-processing metadata, including TLS, destination constraints, protocol/parser behavior, enforcement, or credential handling. A more-specific path selector may override broader request-processing metadata. + - If the sandbox uses an attached provider credential, confirm the provider + profile also declares the intended host, port, and path. A sandbox policy + allow cannot expand the profile's static credential binding. 3. **Apply the change**: - **Adding a new policy**: Insert the new policy block under `network_policies`, maintaining the file's existing indentation and style. diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index dc225ab984..9c03ed05f2 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -119,7 +119,15 @@ Bare `KEY` reads the value from the environment variable of that name and avoids Other credential sources are `--from-gcloud-adc` for compatible profiles and `--runtime-credentials` when the gateway or sandbox resolves the required credentials at runtime. -Profile-backed provider policy and composition are controlled by the gateway-global `providers_v2_enabled` setting: +Static provider credentials resolve only for hosts, ports, and paths declared by +the provider profile. Use `provider profile export` to inspect that boundary +when a placeholder is present but requests receive +`credential_endpoint_mismatch`. A profileless static provider fails closed +because the gateway cannot construct a binding. + +Profile-backed provider policy composition is controlled by the gateway-global +`providers_v2_enabled` setting. Static credential endpoint binding remains +active even when policy composition is disabled: ```bash openshell settings get --global diff --git a/architecture/gateway.md b/architecture/gateway.md index c8b323ea13..c5b2dbe32d 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -400,6 +400,15 @@ resolution and again by the sandbox placeholder resolver. This keeps expired credentials from resolving even when a running sandbox still has retained placeholder generations from an earlier provider credential snapshot. +Static credential delivery is capability-negotiated and endpoint-bound. The +gateway classifies each returned environment entry as either a credential or +non-secret provider configuration and associates every credential key with the +host, port, and path selectors from its effective provider profile. It withholds +static credential material from supervisors that do not advertise binding +support and rejects profile-backed delivery when a static credential has no +usable endpoint. Provider environment revisions include profile endpoint and +binding changes. + ## Inference Resolution Cluster inference routes store only `provider_name`, `model_id`, and optional diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 8abdc68020..360d566450 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -72,6 +72,22 @@ its guarded single-request relay while sharing authorization, request context, policy-pinning, and destination boundaries. Adapter-specific response and OCSF event shapes remain at the protocol boundary. +Provider credential placeholders are resolved through the live provider state +for each HTTP request, after destination and L7 policy admission. A static +credential resolves only when the request host, port, and path match an endpoint +in that provider's effective profile. CONNECT, absolute-form forward HTTP, +request targets, headers, supported request bodies, SigV4 signing, and opted-in +WebSocket text rewriting use the same scoped resolver. Provider refresh swaps +credential values and endpoint bindings atomically. An invalid or unavailable +refresh clears the previous provider state instead of leaving a partially active +or last-known-good credential set. + +Route selection and policy evaluation use a syntax-only redacted request target; +they do not materialize real credentials. Cross-endpoint placeholder use returns +HTTP 403. After a WebSocket upgrade it closes the connection with policy +violation code 1008. Both paths emit a denied activity event and a detection +finding without logging the placeholder, environment key, secret, or query. + For inspected HTTP traffic, the proxy can enforce REST method/path rules, WebSocket upgrade and text-message rules, GraphQL operation rules, and MCP method, tool, and supported params rules or generic JSON-RPC method rules diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index fbb8e404fa..e54ce3fd30 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -35,7 +35,7 @@ Provider profile policy composition is controlled by the gateway-level `provider openshell settings set --global --key providers_v2_enabled --value true ``` -When the setting is disabled or unset, providers keep the existing credential-only behavior. Sandboxes still receive provider credential placeholders, but attached provider profiles do not add network policy entries to the effective policy. +When the setting is disabled or unset, attached provider profiles do not add network policy entries to the effective policy. Static provider credential placeholders still use the profile endpoints as a resolution boundary. To disable provider profile policy composition, delete the setting: @@ -44,7 +44,7 @@ openshell settings delete --global --key providers_v2_enabled ``` -The feature flag controls provider-derived policy layers. OpenShell still supports placeholder environment variables for provider credentials, and provider profiles can also declare dynamic token grants that the sandbox proxy resolves on demand for matching HTTP endpoints. +The feature flag controls provider-derived policy layers. It does not disable endpoint binding for static credential placeholders. Provider profiles can also declare dynamic token grants that the sandbox proxy resolves on demand for matching HTTP endpoints. ## Available Features @@ -62,6 +62,7 @@ Providers v2 currently includes these user-facing features: - Credential refresh configuration with `openshell provider refresh status|configure|rotate|delete`. - Credential expiry metadata with `openshell provider update --credential-expires-at`; values accept Unix epoch milliseconds or ISO/RFC3339 timestamps. - Dynamic token grants that use the sandbox's SPIFFE JWT-SVID as an OAuth2 client assertion and inject short-lived tokens into supported headers for matching profile endpoints. +- Endpoint-bound static credential placeholders. The sandbox proxy resolves a static credential only for request hosts, ports, and paths declared by its provider profile. ## Roadmap @@ -70,7 +71,7 @@ The following Providers v2 design items are not part of the current behavior: | Roadmap item | Current behavior | |---|---| | General profile-driven credential placement | Static `auth_style`, `header_name`, `query_param`, and `path_template` placement metadata is stored and validated, but static credential injection still depends on environment placeholders generated from provider credentials. Dynamic `token_grant` credentials support `bearer` and `header` placement for matching HTTP endpoints. | -| Endpoint and binary scoped credential injection | Provider profile endpoints and binaries affect policy composition. Dynamic token grants are endpoint-scoped. Static placeholder injection is not yet restricted by profile endpoint or binary metadata. | +| Binary-scoped credential injection | Provider profile binaries affect policy composition but do not yet restrict placeholder resolution by calling binary. Static and dynamic credentials are endpoint-scoped. | | Credential verification on create | `openshell provider create` does not yet probe provider verification endpoints or expose `--no-verify`. | | Automatic credential scope extraction | OpenShell does not yet inspect upstream provider responses to discover credential scopes. | | Inference mounting from attached providers | `inference_capable` is profile metadata. Attaching an inference-capable provider does not yet create `inference.local` routes. | @@ -445,6 +446,8 @@ Use an ISO/RFC3339 timestamp or Unix epoch milliseconds. Use `0` as the timestam OpenShell skips expired provider credentials when it builds a sandbox provider environment. Running sandboxes also reject expired retained credential generations during placeholder resolution, so stale placeholders fail closed instead of forwarding unresolved or expired credential material. +The gateway sends a complete host, port, and path binding for every static credential key. Supervisors reject incomplete binding metadata and clear previously active provider material when a refresh fails validation. A credential used at a different endpoint returns HTTP 403 and produces secret-safe security telemetry. + ## Configure Credential Refresh Refresh configuration is stored separately from the current injectable credential value. The gateway refresh worker reads refresh state, mints a new short-lived token for supported strategies, writes the token back to the provider record, and updates credential expiry metadata. @@ -585,7 +588,7 @@ openshell sandbox create \ -- claude ``` -When `providers_v2_enabled=true`, each attached provider with a matching profile contributes a provider policy layer to the sandbox effective policy. The base policy is the user-authored sandbox policy that you can edit and apply. The effective policy is the composed policy that the sandbox enforces: base policy plus provider policy layers. When the setting is disabled, the sandbox receives provider credentials but not provider-derived policy entries. +When `providers_v2_enabled=true`, each attached provider with a matching profile contributes a provider policy layer to the sandbox effective policy. The base policy is the user-authored sandbox policy that you can edit and apply. The effective policy is the composed policy that the sandbox enforces: base policy plus provider policy layers. When the setting is disabled, the sandbox still receives endpoint-bound provider credentials but not provider-derived policy entries. Updating a custom provider profile affects every provider instance whose `type` matches that profile ID. Provider instances are not rewritten, and sandbox-authored policies are not modified. Running sandboxes observe the updated provider-derived policy on their next config sync. If a gateway-global policy is active, provider-derived policy layers remain suppressed. @@ -730,9 +733,9 @@ Provider attach and detach update the persisted sandbox provider list. Running s The policy effect applies to future effective policy reads after the sandbox observes the update. The credential environment effect applies only to new process launches after the update is observed, such as later SSH, exec, or SFTP sessions. -Already-running processes keep the environment they started with. OpenShell does not mutate a live process environment after provider attach, detach, or credential update. If a long-running process needs a newly attached provider credential placeholder, restart that process or launch a new process after the sandbox has observed the provider update. +Already-running processes keep the placeholder environment they started with. OpenShell does not mutate a live process environment after provider attach, detach, or credential update. The proxy resolves existing placeholders against current credentials and bindings, so rotation, expiry, endpoint changes, and detach take effect without restarting the process. If a long-running process needs a newly attached provider credential placeholder, restart that process or launch a new process after the sandbox has observed the provider update. -Detaching a provider removes its provider policy layer from future effective policy reads and removes its credential placeholders from future process environments. It does not remove environment variables from already-running processes. +Detaching a provider removes its provider policy layer from future effective policy reads, revokes resolution for its existing placeholders, and removes its credential placeholders from future process environments. It does not remove the placeholder strings from already-running process environments. OpenShell rejects provider updates and refresh configuration when they would make two providers attached to the same sandbox expose the same active credential environment key. It also rejects attached provider sets with ambiguous dynamic token grants at equal host/path specificity. Use provider-specific credential names and make one dynamic grant selector more specific when one sandbox needs multiple providers with overlapping upstream concepts. From 6d36f25098f8fbb3b6a178f4218f5de3baaab6af Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 27 Jul 2026 19:09:40 -0700 Subject: [PATCH 04/32] fix(e2e): use valid endpoint isolation fixtures Signed-off-by: John Myers --- e2e/rust/tests/host_gateway_alias.rs | 27 +++++----- e2e/rust/tests/proxy_egress_pipeline.rs | 70 +++++++++++++++++++++---- e2e/rust/tests/websocket_conformance.rs | 65 ++++++++++++++++++++--- 3 files changed, 133 insertions(+), 29 deletions(-) diff --git a/e2e/rust/tests/host_gateway_alias.rs b/e2e/rust/tests/host_gateway_alias.rs index 2f37b4a3d9..1ea43ade92 100644 --- a/e2e/rust/tests/host_gateway_alias.rs +++ b/e2e/rust/tests/host_gateway_alias.rs @@ -9,7 +9,7 @@ use std::sync::Mutex; use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::sandbox::SandboxGuard; -use tempfile::NamedTempFile; +use tempfile::{Builder as TempFileBuilder, NamedTempFile}; use tokio::io::AsyncReadExt; use tokio::io::AsyncWriteExt; use tokio::net::TcpListener; @@ -157,7 +157,10 @@ fn write_binding_profile( host: &str, port: u16, ) -> Result { - let mut file = NamedTempFile::new().map_err(|e| format!("create provider profile: {e}"))?; + let mut file = TempFileBuilder::new() + .suffix(".yaml") + .tempfile() + .map_err(|e| format!("create provider profile: {e}"))?; let profile = format!( r#"id: {id} display_name: {display_name} @@ -208,13 +211,13 @@ network_policies: endpoints: - host: host.openshell.internal port: {port} - allowed_ips: ["10.0.0.0/8", "172.0.0.0/8", "192.168.0.0/16", "fc00::/7"] + path: /allowed/** protocol: rest access: full enforcement: enforce - host: host.docker.internal port: {port} - allowed_ips: ["10.0.0.0/8", "172.0.0.0/8", "192.168.0.0/16", "fc00::/7"] + path: /allowed/** protocol: rest access: full enforcement: enforce @@ -447,12 +450,18 @@ async fn static_provider_credentials_are_bound_to_profile_endpoints() { .await .expect("run endpoint-binding requests"); + let logs = wait_for_sandbox_logs(&guard.name, |logs| { + logs.contains("openshell.provider_credential.endpoint_mismatch") + && logs.contains("credential_endpoint_mismatch") + }) + .await + .expect("fetch endpoint mismatch logs"); assert!( guard .create_output .contains(r#"ALLOWED={"authorized":true}"#), - "credential should resolve at the bound endpoint:\n{}", - guard.create_output + "credential should resolve at the bound endpoint:\n{}\nlogs:\n{logs}", + guard.create_output, ); assert!( guard.create_output.contains("DENIED=403"), @@ -460,12 +469,6 @@ async fn static_provider_credentials_are_bound_to_profile_endpoints() { guard.create_output ); - let logs = wait_for_sandbox_logs(&guard.name, |logs| { - logs.contains("openshell.provider_credential.endpoint_mismatch") - && logs.contains("credential_endpoint_mismatch") - }) - .await - .expect("fetch endpoint mismatch logs"); assert!( logs.contains("openshell.provider_credential.endpoint_mismatch") && logs.contains("credential_endpoint_mismatch"), diff --git a/e2e/rust/tests/proxy_egress_pipeline.rs b/e2e/rust/tests/proxy_egress_pipeline.rs index cd33ffc6e9..a2b9c49a3f 100644 --- a/e2e/rust/tests/proxy_egress_pipeline.rs +++ b/e2e/rust/tests/proxy_egress_pipeline.rs @@ -24,13 +24,14 @@ use std::sync::{ use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::sandbox::SandboxGuard; use serde_json::Value; -use tempfile::NamedTempFile; +use tempfile::{Builder as TempFileBuilder, NamedTempFile}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::task::JoinHandle; const TEST_SERVER_HOST: &str = "host.openshell.internal"; const PROVIDER_NAME: &str = "e2e-proxy-egress-credentials"; +const PROVIDER_PROFILE_ID: &str = "e2e-proxy-egress-credentials"; const TOKEN_ENV: &str = "PROXY_E2E_TOKEN"; const TEST_SECRET: &str = "sk-e2e-proxy-egress-secret"; const PLACEHOLDER_PREFIX: &str = "openshell:resolve:env:"; @@ -102,7 +103,15 @@ async fn delete_provider(name: &str) { let _ = cmd.status().await; } -async fn create_generic_provider(name: &str) -> Result { +async fn delete_provider_profile(id: &str) { + let mut cmd = openshell_cmd(); + cmd.args(["provider", "profile", "delete", id]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = cmd.status().await; +} + +async fn create_bound_provider(name: &str) -> Result { let credential = format!("{TOKEN_ENV}={TEST_SECRET}"); run_cli(&[ "provider", @@ -110,13 +119,51 @@ async fn create_generic_provider(name: &str) -> Result { "--name", name, "--type", - "generic", + PROVIDER_PROFILE_ID, "--credential", &credential, ]) .await } +fn write_credential_profile(port: u16) -> Result { + let mut file = TempFileBuilder::new() + .suffix(".yaml") + .tempfile() + .map_err(|error| format!("create provider profile: {error}"))?; + let profile = format!( + r#"id: {PROVIDER_PROFILE_ID} +display_name: E2E proxy egress credentials +category: other +credentials: + - name: proxy_e2e_token + env_vars: [{TOKEN_ENV}] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: {TEST_SERVER_HOST} + port: {port} + path: /probe + protocol: rest + access: full + enforcement: enforce + request_body_credential_rewrite: true + allowed_ips: + - "10.0.0.0/8" + - "172.0.0.0/8" + - "192.168.0.0/16" + - "fc00::/7" +binaries: ["/**"] +"# + ); + file.write_all(profile.as_bytes()) + .map_err(|error| format!("write provider profile: {error}"))?; + file.flush() + .map_err(|error| format!("flush provider profile: {error}"))?; + Ok(file) +} + fn write_policy_document( host: &str, port: u16, @@ -1615,19 +1662,19 @@ async fn http_credentials_are_rewritten_in_headers_and_bodies_for_both_adapters( .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); delete_provider(PROVIDER_NAME).await; - create_generic_provider(PROVIDER_NAME) - .await - .expect("create generic provider"); + delete_provider_profile(PROVIDER_PROFILE_ID).await; let result = async { let server = CredentialProbeServer::start().await?; - let endpoint_options = r#" protocol: rest + let profile = write_credential_profile(server.port)?; + let profile_path = profile.path().to_string_lossy().into_owned(); + run_cli(&["provider", "profile", "import", "--file", &profile_path]).await?; + create_bound_provider(PROVIDER_NAME).await?; + let endpoint_options = r#" path: /probe + protocol: rest enforcement: enforce request_body_credential_rewrite: true - rules: - - allow: - method: POST - path: "/probe""#; + access: full"#; let policy = write_policy(TEST_SERVER_HOST, server.port, endpoint_options)?; let policy_path = policy_path(&policy); let script = format!( @@ -1721,6 +1768,7 @@ print(json.dumps({{"connect": connect, "forward": forward}}, sort_keys=True)) .await; delete_provider(PROVIDER_NAME).await; + delete_provider_profile(PROVIDER_PROFILE_ID).await; let guard = result.expect("sandbox create"); let result = parse_json_line(&guard.create_output); diff --git a/e2e/rust/tests/websocket_conformance.rs b/e2e/rust/tests/websocket_conformance.rs index 90f0e84024..4ba4dbd046 100644 --- a/e2e/rust/tests/websocket_conformance.rs +++ b/e2e/rust/tests/websocket_conformance.rs @@ -19,13 +19,14 @@ use base64::Engine as _; use openshell_e2e::harness::binary::openshell_cmd; use openshell_e2e::harness::sandbox::SandboxGuard; use sha1::{Digest, Sha1}; -use tempfile::NamedTempFile; +use tempfile::{Builder as TempFileBuilder, NamedTempFile}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; use tokio::task::JoinHandle; const WEBSOCKET_GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; const PROVIDER_NAME: &str = "e2e-websocket-conformance"; +const PROVIDER_PROFILE_ID: &str = "e2e-websocket-conformance"; const TEST_SERVER_HOST: &str = "host.openshell.internal"; const TEST_SECRET: &str = "sk-e2e-websocket-conformance-secret"; const TOKEN_ENV: &str = "WS_E2E_TOKEN"; @@ -66,7 +67,15 @@ async fn delete_provider(name: &str) { let _ = cmd.status().await; } -async fn create_generic_provider(name: &str) -> Result { +async fn delete_provider_profile(id: &str) { + let mut cmd = openshell_cmd(); + cmd.args(["provider", "profile", "delete", id]) + .stdout(Stdio::null()) + .stderr(Stdio::null()); + let _ = cmd.status().await; +} + +async fn create_bound_provider(name: &str) -> Result { let credential = format!("{TOKEN_ENV}={TEST_SECRET}"); run_cli(&[ "provider", @@ -74,13 +83,54 @@ async fn create_generic_provider(name: &str) -> Result { "--name", name, "--type", - "generic", + PROVIDER_PROFILE_ID, "--credential", &credential, ]) .await } +fn write_credential_profile(port: u16) -> Result { + let mut file = TempFileBuilder::new() + .suffix(".yaml") + .tempfile() + .map_err(|error| format!("create provider profile: {error}"))?; + let profile = format!( + r#"id: {PROVIDER_PROFILE_ID} +display_name: E2E WebSocket conformance credentials +category: other +credentials: + - name: websocket_token + env_vars: [{TOKEN_ENV}] + required: true + auth_style: bearer + header_name: authorization +endpoints: + - host: {TEST_SERVER_HOST} + port: {port} + path: /ws + protocol: websocket + enforcement: enforce + access: read-write + websocket_credential_rewrite: true + allowed_ips: + - "10.0.0.0/8" + - "172.0.0.0/8" + - "192.168.0.0/16" + - "fc00::/7" +binaries: + - path: /usr/bin/python* + - path: /usr/local/bin/python* + - path: /sandbox/.uv/python/*/bin/python* +"# + ); + file.write_all(profile.as_bytes()) + .map_err(|error| format!("write provider profile: {error}"))?; + file.flush() + .map_err(|error| format!("flush provider profile: {error}"))?; + Ok(file) +} + struct WebSocketProbeServer { port: u16, task: JoinHandle<()>, @@ -435,12 +485,14 @@ async fn websocket_text_placeholder_is_rewritten_through_both_adapters() { .unwrap_or_else(std::sync::PoisonError::into_inner); delete_provider(PROVIDER_NAME).await; - create_generic_provider(PROVIDER_NAME) - .await - .expect("create generic provider"); + delete_provider_profile(PROVIDER_PROFILE_ID).await; let result = async { let server = WebSocketProbeServer::start().await?; + let profile = write_credential_profile(server.port)?; + let profile_path = profile.path().to_string_lossy().into_owned(); + run_cli(&["provider", "profile", "import", "--file", &profile_path]).await?; + create_bound_provider(PROVIDER_NAME).await?; let policy = write_websocket_policy(TEST_SERVER_HOST, server.port)?; let policy_path = policy .path() @@ -464,6 +516,7 @@ async fn websocket_text_placeholder_is_rewritten_through_both_adapters() { .await; delete_provider(PROVIDER_NAME).await; + delete_provider_profile(PROVIDER_PROFILE_ID).await; let guard = result.expect("sandbox create"); assert!( From e2b271a59edddc324175d7eb1a7286fa6ea8a73b Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 27 Jul 2026 19:35:56 -0700 Subject: [PATCH 05/32] test(proxy): stabilize OCSF compatibility capture Signed-off-by: John Myers --- .../src/proxy/tests/compatibility.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 10a2a21a06..9cd8317a5b 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -171,6 +171,11 @@ fn representative_adapter_denials_preserve_ocsf_fields() { let subscriber = tracing_subscriber::registry().with(layer); let denial_reason = "target.example resolves to internal address 10.0.0.5"; tracing::subscriber::with_default(subscriber, || { + // Other parallel tests may register this callsite while no subscriber + // is active. Refresh the process-wide cache after installing this + // thread-local subscriber so the OCSF events cannot remain disabled. + tracing::callsite::rebuild_interest_cache(); + tokio::runtime::Builder::new_current_thread() .enable_all() .build() From 6231d642c9c58847fcdafdca8eaac42d53520231 Mon Sep 17 00:00:00 2001 From: John Myers Date: Mon, 27 Jul 2026 19:43:49 -0700 Subject: [PATCH 06/32] test(proxy): avoid global tracing capture race Signed-off-by: John Myers --- .../openshell-supervisor-network/src/proxy.rs | 127 +++++++++++------- .../src/proxy/tests/compatibility.rs | 126 +++++------------ 2 files changed, 115 insertions(+), 138 deletions(-) diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 7d37b18499..2763ab478b 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -939,9 +939,18 @@ fn build_forward_policy_deny_ocsf_event( .build() } +fn destination_denial_detail(kind: DestinationDenialKind) -> &'static str { + match kind { + DestinationDenialKind::TrustedGateway => "trusted-gateway check failed", + DestinationDenialKind::InvalidAllowedIps => "invalid allowed_ips in policy", + DestinationDenialKind::AllowedIps => "allowed_ips check failed", + DestinationDenialKind::DeclaredEndpoint => "declared endpoint check failed", + DestinationDenialKind::InternalAddress => "internal address", + } +} + #[allow(clippy::too_many_arguments)] -async fn deny_connect_destination( - client: &mut TcpStream, +fn build_connect_destination_deny_ocsf_event( denial: &DestinationDenial, peer_addr: SocketAddr, host: &str, @@ -950,24 +959,15 @@ async fn deny_connect_destination( pid: &str, ancestors: &str, cmdline: &str, - decision: &EgressDecision, - denial_tx: &Option>, - activity_tx: &Option, -) -> Result<()> { - let detail = match denial.kind { - DestinationDenialKind::TrustedGateway => "trusted-gateway check failed", - DestinationDenialKind::InvalidAllowedIps => "invalid allowed_ips in policy", - DestinationDenialKind::AllowedIps => "allowed_ips check failed", - DestinationDenialKind::DeclaredEndpoint => "declared endpoint check failed", - DestinationDenialKind::InternalAddress => "internal address", - }; +) -> openshell_ocsf::OcsfEvent { + let detail = destination_denial_detail(denial.kind); let message = if denial.kind == DestinationDenialKind::InternalAddress { format!("CONNECT blocked: internal address {host}:{port}") } else { format!("CONNECT blocked: {detail} for {host}:{port}") }; - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Open) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -979,8 +979,68 @@ async fn deny_connect_destination( .firewall_rule("-", "ssrf") .message(message) .status_detail(&denial.reason) - .build(); - ocsf_emit!(event); + .build() +} + +#[allow(clippy::too_many_arguments)] +fn build_forward_destination_deny_ocsf_event( + denial: &DestinationDenial, + peer_addr: SocketAddr, + method: &str, + host: &str, + port: u16, + path: &str, + binary: &str, + pid: &str, + ancestors: &str, + cmdline: &str, + policy: &str, +) -> openshell_ocsf::OcsfEvent { + let detail = destination_denial_detail(denial.kind); + let log_detail = if denial.kind == DestinationDenialKind::InternalAddress { + "internal IP without allowed_ips" + } else { + detail + }; + + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Other) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .http_request(HttpRequest::new( + method, + OcsfUrl::new("http", host, path, port), + )) + .dst_endpoint(Endpoint::from_domain(host, port)) + .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) + .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) + .firewall_rule(policy, "ssrf") + .message(format!("FORWARD blocked: {log_detail} for {host}:{port}")) + .status_detail(&denial.reason) + .build() +} + +#[allow(clippy::too_many_arguments)] +async fn deny_connect_destination( + client: &mut TcpStream, + denial: &DestinationDenial, + peer_addr: SocketAddr, + host: &str, + port: u16, + binary: &str, + pid: &str, + ancestors: &str, + cmdline: &str, + decision: &EgressDecision, + denial_tx: &Option>, + activity_tx: &Option, +) -> Result<()> { + let detail = destination_denial_detail(denial.kind); + ocsf_emit!(build_connect_destination_deny_ocsf_event( + denial, peer_addr, host, port, binary, pid, ancestors, cmdline, + )); emit_denial( denial_tx, @@ -1026,37 +1086,10 @@ async fn deny_forward_destination( denial_tx: Option<&mpsc::UnboundedSender>, activity_tx: Option<&ActivitySender>, ) -> Result<()> { - let detail = match denial.kind { - DestinationDenialKind::TrustedGateway => "trusted-gateway check failed", - DestinationDenialKind::InvalidAllowedIps => "invalid allowed_ips in policy", - DestinationDenialKind::AllowedIps => "allowed_ips check failed", - DestinationDenialKind::DeclaredEndpoint => "declared endpoint check failed", - DestinationDenialKind::InternalAddress => "internal address", - }; - let log_detail = if denial.kind == DestinationDenialKind::InternalAddress { - "internal IP without allowed_ips" - } else { - detail - }; - - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Other) - .action(ActionId::Denied) - .disposition(DispositionId::Blocked) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .http_request(HttpRequest::new( - method, - OcsfUrl::new("http", host, path, port), - )) - .dst_endpoint(Endpoint::from_domain(host, port)) - .src_endpoint(Endpoint::from_ip(peer_addr.ip(), peer_addr.port())) - .actor_process(Process::from_bypass(binary, pid, ancestors).with_cmd_line(cmdline)) - .firewall_rule(policy, "ssrf") - .message(format!("FORWARD blocked: {log_detail} for {host}:{port}")) - .status_detail(&denial.reason) - .build(); - ocsf_emit!(event); + let detail = destination_denial_detail(denial.kind); + ocsf_emit!(build_forward_destination_deny_ocsf_event( + denial, peer_addr, method, host, port, path, binary, pid, ancestors, cmdline, policy, + )); emit_denial_simple( denial_tx, diff --git a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs index 9cd8317a5b..b7abb31268 100644 --- a/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs +++ b/crates/openshell-supervisor-network/src/proxy/tests/compatibility.rs @@ -4,24 +4,8 @@ //! Compatibility and regression contracts for the shared proxy egress pipeline. use super::*; -use std::io::Write; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use tokio::io::{AsyncReadExt, AsyncWriteExt}; -use tracing_subscriber::prelude::*; - -#[derive(Clone)] -struct SharedBuffer(Arc>>); - -impl Write for SharedBuffer { - fn write(&mut self, bytes: &[u8]) -> std::io::Result { - self.0.lock().unwrap().extend_from_slice(bytes); - Ok(bytes.len()) - } - - fn flush(&mut self) -> std::io::Result<()> { - Ok(()) - } -} fn allowed_decision(intent: EgressIntent) -> EgressDecision { EgressDecision { @@ -166,80 +150,27 @@ async fn destination_denials_preserve_adapter_specific_wire_contracts() { #[test] fn representative_adapter_denials_preserve_ocsf_fields() { - let captured = Arc::new(Mutex::new(Vec::new())); - let layer = openshell_ocsf::tracing_layers::OcsfJsonlLayer::new(SharedBuffer(captured.clone())); - let subscriber = tracing_subscriber::registry().with(layer); let denial_reason = "target.example resolves to internal address 10.0.0.5"; - tracing::subscriber::with_default(subscriber, || { - // Other parallel tests may register this callsite while no subscriber - // is active. Refresh the process-wide cache after installing this - // thread-local subscriber so the OCSF events cannot remain disabled. - tracing::callsite::rebuild_interest_cache(); - - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(async { - let denial = DestinationDenial { - kind: DestinationDenialKind::InternalAddress, - reason: denial_reason.to_string(), - }; - let peer: SocketAddr = "127.0.0.1:41000".parse().unwrap(); - - let (_app, mut proxy) = tcp_pair().await; - deny_connect_destination( - &mut proxy, - &denial, - peer, - "target.example", - 8443, - "/usr/bin/curl", - "42", - "/usr/bin/sh", - "curl --proxy", - &allowed_decision(EgressIntent::connect("target.example".to_string(), 8443)), - &None, - &None, - ) - .await - .unwrap(); - - let (_app, mut proxy) = tcp_pair().await; - deny_forward_destination( - &mut proxy, - &denial, - peer, - "POST", - "target.example", - 8080, - "/v1/items", - "/usr/bin/curl", - "42", - "/usr/bin/sh", - "curl --proxy", - "proxy_compatibility", - &allowed_decision(EgressIntent::forward_http( - "target.example".to_string(), - 8080, - )), - None, - None, - ) - .await - .unwrap(); - }); - }); - - let bytes = captured.lock().unwrap().clone(); - let events: Vec = String::from_utf8(bytes) - .unwrap() - .lines() - .map(|line| serde_json::from_str(line).unwrap()) - .collect(); - assert_eq!(events.len(), 2); - - let connect = &events[0]; + let denial = DestinationDenial { + kind: DestinationDenialKind::InternalAddress, + reason: denial_reason.to_string(), + }; + let peer: SocketAddr = "127.0.0.1:41000".parse().unwrap(); + + // Build the production events directly rather than routing through the + // global tracing pipeline. Its callsite-interest cache is process-global, + // so parallel tests can otherwise make captured-event assertions flaky. + let connect = serde_json::to_value(build_connect_destination_deny_ocsf_event( + &denial, + peer, + "target.example", + 8443, + "/usr/bin/curl", + "42", + "/usr/bin/sh", + "curl --proxy", + )) + .unwrap(); assert_eq!(connect["class_name"], "Network Activity"); assert_eq!(connect["activity_name"], "Open"); assert_eq!(connect["action"], "Denied"); @@ -258,7 +189,20 @@ fn representative_adapter_denials_preserve_ocsf_fields() { ); assert_eq!(connect["status_detail"], denial_reason); - let forward = &events[1]; + let forward = serde_json::to_value(build_forward_destination_deny_ocsf_event( + &denial, + peer, + "POST", + "target.example", + 8080, + "/v1/items", + "/usr/bin/curl", + "42", + "/usr/bin/sh", + "curl --proxy", + "proxy_compatibility", + )) + .unwrap(); assert_eq!(forward["class_name"], "HTTP Activity"); assert_eq!(forward["activity_name"], "Other"); assert_eq!(forward["action"], "Denied"); From 94491febb429bc4bff1d801abdef2386239d96d8 Mon Sep 17 00:00:00 2001 From: John Myers Date: Tue, 28 Jul 2026 08:07:36 -0700 Subject: [PATCH 07/32] docs(provider): explain static credential endpoint binding Signed-off-by: John Myers --- docs/reference/policy-schema.mdx | 6 ++ docs/sandboxes/manage-providers.mdx | 101 ++++++++++++++++++++-------- docs/sandboxes/policies.mdx | 14 ++++ docs/sandboxes/providers-v2.mdx | 82 +++++++++++++++++++++- 4 files changed, 173 insertions(+), 30 deletions(-) diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index ffcd47d793..8ab8f636f8 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -193,6 +193,12 @@ Each endpoint defines a reachable destination and optional inspection rules. Credential rewrite recognizes the canonical `openshell:resolve:env:KEY` placeholder form and whole-token provider-shaped aliases such as `provider-OPENSHELL-RESOLVE-ENV-API_TOKEN` when the referenced environment key exists in the configured provider credentials. +Static provider placeholders also require the request host, port, and path to +match an endpoint in the provider profile. Network policy admission does not +expand that credential boundary. OpenShell rejects a mismatch with HTTP 403 and +`credential_endpoint_mismatch`. Refer to [Static Credential Endpoint +Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding). + #### Access Levels The `access` field accepts one of the following values on REST, WebSocket, and GraphQL endpoints. MCP and JSON-RPC endpoints reject `access` because HTTP method/path presets cannot authorize JSON-RPC safely. Use explicit MCP rules, set `mcp.allow_all_known_mcp_methods: true` for the MCP method profile, or use explicit JSON-RPC rules. diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index d639c37acb..5cb8125066 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -46,7 +46,7 @@ and stores them in the provider. Supply a credential value directly: ```shell -openshell provider create --name my-api --type generic --credential API_KEY=sk-abc123 +openshell provider create --name my-nvidia --type nvidia --credential NVIDIA_API_KEY=nvapi-example ``` ### Bare Key Form @@ -55,10 +55,10 @@ Pass a key name without a value to read the value from the environment variable of that name: ```shell -openshell provider create --name my-api --type generic --credential API_KEY +openshell provider create --name my-nvidia --type nvidia --credential NVIDIA_API_KEY ``` -This looks up the current value of `$API_KEY` in your shell and stores it. +This looks up the current value of `$NVIDIA_API_KEY` in your shell and stores it. Provider profile metadata is available for known provider types. Provider profile network policy is gateway opt-in: @@ -67,7 +67,9 @@ network policy is gateway opt-in: openshell settings set --global --key providers_v2_enabled --value true ``` -Without `providers_v2_enabled=true`, provider behavior remains credential-only. +Without `providers_v2_enabled=true`, attached provider profiles do not contribute +network policy to the sandbox. Static credential endpoint binding remains active +in either mode. When `providers_v2_enabled=true`, `--from-existing` uses profile-backed discovery instead of the legacy provider registry. The requested `--type` must @@ -90,6 +92,15 @@ one file at a time and rejects stale resource versions. When `providers_v2_enabled=true`, updated profile policy applies to all provider instances of that type on the next sandbox config sync. + +Static credentials require a built-in or imported provider profile with at +least one endpoint. OpenShell does not activate static credentials from a +profileless provider because it cannot determine where those credentials are +safe to use. The legacy `generic` provider type has no built-in endpoint +profile. Import a custom profile and use its ID as `--type` for custom +credentials. + + ## Manage Providers List, inspect, update, and delete providers from the active gateway. @@ -247,11 +258,13 @@ Pass one or more `--provider` flags when creating a sandbox: openshell sandbox create --provider my-claude --provider my-github -- claude ``` -Each `--provider` flag attaches one provider. The sandbox receives all -credentials from every attached provider at runtime. Profile-managed providers -also contribute provider-generated network policy entries when -`providers_v2_enabled` is enabled at the gateway. When the setting is disabled, -providers keep the previous behavior and only provide credentials. +Each `--provider` flag attaches one provider. The sandbox receives eligible +credentials from every attached provider as placeholders at runtime. Each +static credential resolves only for endpoints in that provider's profile. +Profile-managed providers also contribute provider-generated network policy +entries when `providers_v2_enabled` is enabled at the gateway. When the setting +is disabled, endpoint binding still applies, but provider-generated policy does +not. Legacy provider attachment is fixed at sandbox creation time. Providers v2 adds @@ -282,9 +295,31 @@ provider instance, and pass `--provider ` explicitly. ## How Credential Injection Works -The agent process inside the sandbox never sees real credential values. At startup, the proxy replaces each credential with an opaque placeholder token in the agent's environment. When the agent sends an HTTP request containing a placeholder, the proxy resolves it to the real credential before forwarding upstream. +The agent process inside the sandbox never sees real credential values. At +startup, OpenShell replaces each credential with an opaque placeholder token in +the agent's environment. When the agent sends an HTTP request containing a +placeholder, the proxy resolves it immediately before forwarding the request. + +Static credential resolution has two independent authorization boundaries: + +1. Network policy must allow the calling binary and request destination. +2. The credential's provider profile must include the request host, port, and + path. + +Both checks must pass. Adding an endpoint to sandbox policy does not expand +where an attached provider credential can resolve. Likewise, a provider profile +endpoint does not grant network access unless provider policy composition or +the sandbox's own policy allows the request. -This resolution requires the proxy to see plaintext HTTP. Endpoints must use `protocol: rest` in the policy (which auto-terminates TLS) or explicit `tls: terminate`. Endpoints without TLS termination pass traffic through as an opaque stream, and credential placeholders are forwarded unresolved. +Endpoint binding applies whether `providers_v2_enabled` is enabled or disabled. +The setting controls provider policy composition only. Every static credential +declared by a provider currently receives the complete endpoint set from that +provider's profile. + +Credential resolution requires the proxy to handle the request as HTTP. Raw +`tls: skip` and non-HTTP tunnels remain opaque and do not support credential +rewrite. Refer to [Providers v2](/sandboxes/providers-v2#understand-static-credential-endpoint-binding) +for endpoint matching and migration guidance. ### Supported injection locations @@ -296,30 +331,41 @@ The proxy resolves credential placeholders in the following parts of an HTTP req | Header value (Basic auth) | Agent base64-encodes `user:` in an `Authorization: Basic` header. The proxy decodes, resolves, and re-encodes. | `Authorization: Basic ` | | Query parameter value | Agent places the placeholder in a URL query parameter. | `GET /api?key=` | | URL path segment | Agent builds a URL with the placeholder in the path. Supports concatenated patterns. | `POST /bot/sendMessage` | +| Supported request body | An inspected REST endpoint opts in with `request_body_credential_rewrite: true`. | `{"api_key":""}` | +| WebSocket text message | A REST or WebSocket endpoint opts in with `websocket_credential_rewrite: true`. | `{"token":""}` | +| AWS SigV4 signing | An endpoint configures `credential_signing`, and the proxy signs with the endpoint-bound AWS credentials. | `credential_signing: sigv4` | -The proxy does not modify request bodies, cookies, or response content. +The proxy does not rewrite cookies, response content, unsupported request +bodies, or WebSocket binary frames. ### Fail-closed behavior -If the proxy detects a credential placeholder in a request but cannot resolve it, it rejects the request with HTTP 500 instead of forwarding the raw placeholder to the upstream server. This prevents accidental credential leakage in server logs or error responses. - -### Example: Telegram Bot API (path-based credential) - -Create a provider with the Telegram bot token: +If policy allows a request but the credential's profile does not include the +request endpoint, the proxy rejects the request with HTTP 403 and the +`credential_endpoint_mismatch` reason. It emits a denied activity event and a +security finding without recording the secret, placeholder, environment key, or +query string. -```shell -openshell provider create --name telegram --type generic --credential TELEGRAM_BOT_TOKEN=123456:ABC-DEF -``` +Unknown, malformed, expired, or otherwise unresolved placeholders also fail +closed instead of being forwarded to the upstream service. -The agent reads `TELEGRAM_BOT_TOKEN` from its environment and builds a request like `POST /bot/sendMessage`. The proxy resolves the placeholder in the URL path and forwards `POST /bot123456:ABC-DEF/sendMessage` to the upstream. +### Inspect an Endpoint Binding -### Example: Google API (query parameter credential) +Export the profile used by a provider to inspect its credential boundary: ```shell -openshell provider create --name google --type generic --credential YOUTUBE_API_KEY=AIzaSy-secret +openshell provider profile export github -o yaml ``` -The agent sends `GET /youtube/v3/search?part=snippet&key=`. The proxy resolves the placeholder in the query parameter value and percent-encodes the result before forwarding. +For the built-in GitHub profile, `GITHUB_TOKEN` and `GH_TOKEN` can resolve for +the profile's `api.github.com:443` and `github.com:443` endpoints. Even if a +sandbox policy allows `uploads.example.com:443`, sending either placeholder +there returns `credential_endpoint_mismatch`. + +For a custom service, define the credential and its allowed endpoints in a +custom provider profile, import the profile, and create the provider with the +profile ID as its type. Refer to [Provider Profiles](/sandboxes/providers-v2#provider-profiles) +for the profile workflow and schema. ## Supported Provider Types @@ -333,7 +379,7 @@ The following provider types are supported. | `codex` | `OPENAI_API_KEY` | OpenAI Codex | | `copilot` | `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN` | GitHub Copilot CLI | | `deepinfra` | `DEEPINFRA_API_KEY` | DeepInfra inference API | -| `generic` | User-defined | Any service with custom credentials | +| `generic` | User-defined | Legacy credential storage. Import an endpoint-bearing custom profile for credentials attached to a sandbox. | | `github` | `GITHUB_TOKEN`, `GH_TOKEN` | GitHub API and `gh` CLI. Refer to [GitHub Sandbox](/get-started/tutorials/github-sandbox). | | `gitlab` | `GITLAB_TOKEN`, `GLAB_TOKEN`, `CI_JOB_TOKEN` | GitLab API, `glab` CLI | | `nvidia` | `NVIDIA_API_KEY` | NVIDIA API Catalog | @@ -345,8 +391,9 @@ The following provider types are supported. -Use the `generic` type for any service not listed above. You define the -environment variable names and values yourself with `--credential`. +For a service not listed above, import a custom provider profile that declares +its credential environment variables and endpoints. Use the imported profile +ID as the provider `--type`. diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 0fef04a139..0342803893 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -328,6 +328,12 @@ Use the `request-body-credential-rewrite` endpoint option with `protocol: rest` Credential rewrite recognizes the canonical `openshell:resolve:env:KEY` placeholder form and whole-token provider-shaped aliases such as `provider-OPENSHELL-RESOLVE-ENV-API_TOKEN` when the referenced environment key exists in the configured provider credentials. +Static provider placeholders resolve only when the request host, port, and path +also match an endpoint in the provider profile. A sandbox policy allow does not +expand that binding. A mismatch returns HTTP 403 with +`credential_endpoint_mismatch`. Refer to [Static Credential Endpoint +Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding). + For example: - `api.github.com:443:read-only:rest` is valid. @@ -538,9 +544,17 @@ When triaging denied requests, check: - Destination host and port to confirm which endpoint is missing. - Calling binary path to confirm which `binaries` entry needs to be added or adjusted. - HTTP method and path for REST endpoints, or `GET` / `WEBSOCKET_TEXT` and the upgraded request path for WebSocket endpoints, to confirm which `rules` entry needs to be added or adjusted. +- `credential_endpoint_mismatch` in sandbox logs to confirm that policy admitted the request but the attached provider profile did not authorize its credential for that host, port, and path. Then push the updated policy as described above. +Do not fix `credential_endpoint_mismatch` by widening sandbox policy. Export the +provider profile with `openshell provider profile export -o yaml`. +Update the custom provider profile only when the destination is an intended +credential recipient. Refer to [Static Credential Endpoint +Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding) +for the complete authorization model. + For small changes, prefer `openshell policy update` over rewriting the full YAML: ```shell diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index e54ce3fd30..89e282ab16 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -25,7 +25,7 @@ Providers v2 keeps those pieces together: | Custom provider definitions | You can export, edit, lint, import, list, and delete custom profiles. | | Runtime provider lifecycle | You can list, attach, and detach providers on existing sandboxes. | | Credential rotation | Provider refresh metadata lets the gateway refresh short-lived access tokens and update provider records. | -| Backward compatibility | Credential delivery still uses environment placeholders and proxy rewrite. | +| Credential transport | Credential delivery uses environment placeholders and proxy rewrite. Static placeholders resolve only at profile endpoints. | ## Enable Providers v2 @@ -64,6 +64,82 @@ Providers v2 currently includes these user-facing features: - Dynamic token grants that use the sandbox's SPIFFE JWT-SVID as an OAuth2 client assertion and inject short-lived tokens into supported headers for matching profile endpoints. - Endpoint-bound static credential placeholders. The sandbox proxy resolves a static credential only for request hosts, ports, and paths declared by its provider profile. +## Understand Static Credential Endpoint Binding + +Static credential endpoint binding prevents a placeholder for one service from +resolving on a different policy-allowed service. OpenShell associates every +static credential environment key with the endpoints in its provider profile. +The proxy checks that association before it substitutes the real value. + +A request can use a static credential only when all of these checks pass: + +| Check | Configuration source | +|---|---| +| The placeholder belongs to the current attached provider state. | Sandbox provider attachment and current provider record. | +| The calling binary and destination are allowed. | Effective sandbox network policy. | +| The host, port, and canonical request path match the credential binding. | Provider profile endpoints. | +| The HTTP method, path, or protocol operation is allowed when L7 inspection is configured. | Effective sandbox network policy. | +| The credential has not expired. | Provider credential expiry metadata. | + +Network policy and credential binding serve different purposes. Network policy +authorizes traffic. Provider profile endpoints authorize use of the provider's +credentials. A sandbox policy allow cannot widen a credential binding, and a +credential binding cannot widen sandbox network policy. + +For example, this profile endpoint binds all static credential environment keys +from the profile to `api.example.com:443` under `/v1`: + +```yaml showLineNumbers={false} +endpoints: + - host: api.example.com + port: 443 + path: /v1/** +``` + +The path `/v1/**` matches `/v1` and its descendants. An empty path, `**`, or +`/**` matches every path on the selected host and port. Other path values use +glob matching. OpenShell removes the query string and uses a canonical, +secret-redacted path for this check, so a credential embedded in a request path +does not need to be revealed before authorization. + +The binding applies to CONNECT and forward-proxy HTTP requests, including +headers, Basic and Bearer authorization, URL paths, query parameters, opted-in +request bodies, AWS SigV4 signing, and opted-in WebSocket text messages. Raw +`tls: skip` and non-HTTP tunnels do not perform static credential substitution. + +If a request contains a known placeholder at a destination outside its binding, +OpenShell returns HTTP 403 with this response: + +```json +{"error":"credential_endpoint_mismatch","message":"Credential is not authorized for this request endpoint"} +``` + +The sandbox logs include the destination and +`credential_endpoint_mismatch`. OCSF output includes both the denied activity +and a detection finding. These events omit credential values, placeholders, +environment keys, and query strings. + +Binding updates apply to both current and retained placeholder generations. +Credential rotation keeps the current endpoint set. Updating a provider profile +changes the binding on the next sandbox provider-environment sync, and detaching +the provider revokes resolution for placeholders already held by running +processes. + + +Static credentials require a provider profile with at least one usable +endpoint. If any attached static provider lacks complete binding metadata, the +sandbox rejects the provider environment update and keeps no static provider +credentials active. Import a custom profile, create a replacement provider +whose `--type` is that profile ID, attach the replacement, and detach the +profileless provider. + + +After upgrading a gateway and supervisor to a release with endpoint binding, +restart or recreate older running sandboxes. A new gateway withholds static +credential material from supervisors that do not advertise binding support. +Rotate attached static credentials after upgrading when an older sandbox may +have received their real values. + ## Roadmap The following Providers v2 design items are not part of the current behavior: @@ -284,7 +360,7 @@ binaries: `category` groups profiles in `openshell provider list-profiles`. Use one of the values in the category enum. -`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. +`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at profile endpoints. Every static credential environment key currently receives the full profile endpoint set. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. `discovery` controls what `--from-existing` scans when `providers_v2_enabled=true`. Each entry in `discovery.credentials` must name a @@ -446,7 +522,7 @@ Use an ISO/RFC3339 timestamp or Unix epoch milliseconds. Use `0` as the timestam OpenShell skips expired provider credentials when it builds a sandbox provider environment. Running sandboxes also reject expired retained credential generations during placeholder resolution, so stale placeholders fail closed instead of forwarding unresolved or expired credential material. -The gateway sends a complete host, port, and path binding for every static credential key. Supervisors reject incomplete binding metadata and clear previously active provider material when a refresh fails validation. A credential used at a different endpoint returns HTTP 403 and produces secret-safe security telemetry. +The gateway sends a complete host, port, and path binding for every static credential key. Supervisors reject incomplete binding metadata and clear previously active provider material when a refresh fails validation. Refer to [Static Credential Endpoint Binding](#understand-static-credential-endpoint-binding) for matching, denial, lifecycle, and migration behavior. ## Configure Credential Refresh From 131b261b41018d16a163038bb654b9f95543971f Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:15:54 -0700 Subject: [PATCH 08/32] fix(credentials): preserve binding identity across rotations Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/provider_credentials.rs | 140 +++++++++++++++++- crates/openshell-core/src/secrets.rs | 38 ++++- crates/openshell-server/src/grpc/policy.rs | 104 +++++++++++-- crates/openshell-server/src/grpc/provider.rs | 7 + proto/openshell.proto | 10 +- 5 files changed, 281 insertions(+), 18 deletions(-) diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 54d7cf6b1a..2aee283e30 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -426,6 +426,11 @@ impl ProviderCredentialState { dynamic_credentials, }); inner.current_resolver = current_resolver.map(Arc::new); + if static_credential_identities(&inner.static_credential_bindings) + != static_credential_identities(&static_credential_bindings) + { + inner.generations.clear(); + } if let Some(resolver) = generation_resolver { inner.generations.push_back(Arc::new(resolver)); while inner.generations.len() > MAX_RETAINED_CREDENTIAL_GENERATIONS { @@ -507,6 +512,11 @@ fn validate_static_credential_bindings( )); } for binding in bindings.values() { + if binding.credential_identity.is_empty() { + return Err(binding_error( + "static credential binding has no provider credential identity", + )); + } if binding.endpoints.is_empty() { return Err(binding_error( "static credential binding has no authorized endpoints", @@ -526,6 +536,15 @@ fn validate_static_credential_bindings( Ok(()) } +fn static_credential_identities( + bindings: &HashMap, +) -> HashMap<&str, &str> { + bindings + .iter() + .map(|(key, binding)| (key.as_str(), binding.credential_identity.as_str())) + .collect() +} + fn binding_error(message: &str) -> StaticCredentialBindingError { StaticCredentialBindingError { message: message.to_string(), @@ -568,6 +587,7 @@ mod tests { port, path: path.to_string(), }], + credential_identity: "provider-a:API_KEY".to_string(), } } @@ -642,17 +662,135 @@ mod tests { ) .expect("initial bindings"); + let dynamic_credentials = HashMap::from([( + "dynamic".to_string(), + crate::proto::ProviderProfileCredential::default(), + )]); let result = state.install_bound_environment( 2, HashMap::from([("API_KEY".to_string(), "new".to_string())]), HashMap::new(), - HashMap::new(), + dynamic_credentials, HashMap::new(), Vec::new(), ); assert!(result.is_err()); assert!(state.snapshot().child_env.is_empty()); assert!(state.resolver().is_none()); + assert!(state.snapshot().dynamic_credentials.contains_key("dynamic")); + } + + #[test] + fn retained_generation_survives_rotation_of_same_provider_credential() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "old".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("initial bindings"); + + state + .install_bound_environment( + 2, + HashMap::from([("API_KEY".to_string(), "new".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("rotated bindings"); + + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + Some("old") + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v2_API_KEY"), + Some("new") + ); + } + + #[test] + fn replacing_provider_with_reused_key_purges_retained_generation() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "provider-a-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding("a.example.com", 443, "/**"))]), + Vec::new(), + ) + .expect("initial bindings"); + let mut replacement_binding = binding("b.example.com", 443, "/**"); + replacement_binding.credential_identity = "provider-b:API_KEY".to_string(); + + state + .install_bound_environment( + 2, + HashMap::from([("API_KEY".to_string(), "provider-b-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), replacement_binding)]), + Vec::new(), + ) + .expect("replacement bindings"); + + let resolver = state + .resolver_for_endpoint("b.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + None, + "a placeholder issued by another provider identity must fail closed" + ); + } + + #[test] + fn detach_then_attach_with_reused_key_cannot_resolve_detached_secret() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "provider-a-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding("a.example.com", 443, "/**"))]), + Vec::new(), + ) + .expect("initial bindings"); + state.revoke_static_provider_environment(2); + + let mut replacement_binding = binding("b.example.com", 443, "/**"); + replacement_binding.credential_identity = "provider-b:API_KEY".to_string(); + state + .install_bound_environment( + 3, + HashMap::from([("API_KEY".to_string(), "provider-b-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), replacement_binding)]), + Vec::new(), + ) + .expect("replacement bindings"); + + let resolver = state + .resolver_for_endpoint("b.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + None, + "a placeholder issued before detach must not resolve to a replacement provider" + ); } #[test] diff --git a/crates/openshell-core/src/secrets.rs b/crates/openshell-core/src/secrets.rs index b426564c90..f1f09427f6 100644 --- a/crates/openshell-core/src/secrets.rs +++ b/crates/openshell-core/src/secrets.rs @@ -42,7 +42,7 @@ pub fn contains_reserved_credential_marker(value: &str) -> bool { /// Error returned when a placeholder cannot be resolved or a resolved secret /// contains prohibited characters. -#[derive(Debug)] +#[derive(Debug, miette::Diagnostic)] pub struct UnresolvedPlaceholderError { pub location: &'static str, // "header", "query_param", "path" reason: UnresolvedPlaceholderReason, @@ -82,6 +82,8 @@ impl fmt::Display for UnresolvedPlaceholderError { } } +impl std::error::Error for UnresolvedPlaceholderError {} + fn unresolved(location: &'static str) -> UnresolvedPlaceholderError { UnresolvedPlaceholderError { location, @@ -120,6 +122,7 @@ pub struct RewriteTargetResult { pub struct SecretResolver { by_placeholder: HashMap, denied_env_keys: std::collections::HashSet, + no_revision_fallback_env_keys: std::collections::HashSet, } #[derive(Clone)] @@ -241,6 +244,7 @@ impl SecretResolver { Some(Self { by_placeholder, denied_env_keys: std::collections::HashSet::new(), + no_revision_fallback_env_keys: std::collections::HashSet::new(), }), ) } @@ -249,9 +253,12 @@ impl SecretResolver { pub fn merge<'a>(resolvers: impl IntoIterator) -> Option { let mut by_placeholder = HashMap::new(); let mut denied_env_keys = std::collections::HashSet::new(); + let mut no_revision_fallback_env_keys = std::collections::HashSet::new(); for resolver in resolvers { by_placeholder.extend(resolver.by_placeholder.clone()); denied_env_keys.extend(resolver.denied_env_keys.iter().cloned()); + no_revision_fallback_env_keys + .extend(resolver.no_revision_fallback_env_keys.iter().cloned()); } if by_placeholder.is_empty() { None @@ -259,6 +266,7 @@ impl SecretResolver { Some(Self { by_placeholder, denied_env_keys, + no_revision_fallback_env_keys, }) } } @@ -289,6 +297,7 @@ impl SecretResolver { Self { by_placeholder, denied_env_keys, + no_revision_fallback_env_keys: bound_keys.clone(), } } @@ -318,7 +327,15 @@ impl SecretResolver { // Once an old generation ages out, the revision number is only a // namespace marker. Fall back by key to the current credential so // long-running child processes survive provider credential refresh. + // Endpoint-bound credentials are the exception: a revision belongs + // to one provider identity, so falling back by key after replacement + // would let the old process obtain the replacement provider's secret. let key = revisioned_placeholder_env_key(value).or_else(|| alias_env_key(value))?; + if revisioned_placeholder_env_key(value).is_some() + && self.no_revision_fallback_env_keys.contains(key) + { + return None; + } let canonical = placeholder_for_env_key(key); self.by_placeholder.get(&canonical)? }; @@ -342,6 +359,25 @@ impl SecretResolver { } } + /// Resolve a placeholder while preserving endpoint-denial information. + /// + /// `None` means the credential is genuinely unavailable. An endpoint-bound + /// key denied by the scoped resolver returns a typed mismatch so callers + /// can emit the security denial instead of treating it as missing config. + pub fn resolve_placeholder_checked( + &self, + value: &str, + location: &'static str, + ) -> Result, UnresolvedPlaceholderError> { + if placeholder_env_key(value).is_some_and(|key| self.denied_env_keys.contains(key)) { + return Err(UnresolvedPlaceholderError { + location, + reason: UnresolvedPlaceholderReason::EndpointMismatch, + }); + } + Ok(self.resolve_placeholder(value)) + } + pub fn expires_at_ms_for_placeholder(&self, placeholder: &str) -> Option { self.by_placeholder .get(placeholder) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index fd2ce0ff92..09924980cb 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1758,20 +1758,7 @@ pub(super) async fn handle_get_sandbox_provider_environment( ) .await?; - if supports_static_credential_bindings { - for key in &provider_environment.static_credential_keys { - let Some(binding) = provider_environment.static_credential_bindings.get(key) else { - return Err(Status::failed_precondition( - "static provider credentials require a provider profile with network endpoints", - )); - }; - if binding.endpoints.is_empty() { - return Err(Status::failed_precondition( - "static provider credentials require at least one provider profile endpoint", - )); - } - } - } else { + if !supports_static_credential_bindings { for key in &provider_environment.static_credential_keys { provider_environment.environment.remove(key); provider_environment.credential_expires_at_ms.remove(key); @@ -6692,6 +6679,95 @@ mod tests { assert!(response.static_credential_bindings.is_empty()); } + #[tokio::test] + async fn invalid_static_binding_does_not_suppress_valid_dynamic_credentials() { + use openshell_core::proto::{ + GetSandboxProviderEnvironmentRequest, ProviderCredentialTokenGrant, ProviderProfile, + ProviderProfileCategory, ProviderProfileCredential, StoredProviderProfile, + }; + + let state = test_server_state().await; + let mut invalid_static = test_provider("invalid-static", "profile-without-endpoints"); + invalid_static.credentials = + HashMap::from([("INVALID_TOKEN".to_string(), "static-secret".to_string())]); + let dynamic = test_provider("dynamic", "custom-dynamic"); + let dynamic_profile = StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-custom-dynamic".to_string(), + name: "custom-dynamic".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: "custom-dynamic".to_string(), + display_name: "Custom Dynamic".to_string(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![ProviderProfileCredential { + name: "access_token".to_string(), + auth_style: "bearer".to_string(), + header_name: "authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + token_endpoint: "https://auth.example.test/token".to_string(), + audience: "api://default".to_string(), + ..Default::default() + }), + ..Default::default() + }], + endpoints: vec![NetworkEndpoint { + host: "api.dynamic.example.test".to_string(), + port: 443, + path: "/**".to_string(), + ..Default::default() + }], + ..Default::default() + }), + }; + + state.store.put_message(&invalid_static).await.unwrap(); + state.store.put_message(&dynamic).await.unwrap(); + state.store.put_message(&dynamic_profile).await.unwrap(); + state + .store + .put_message(&test_sandbox( + "sb-mixed-provider-env", + "mixed-provider-env", + test_policy_with_rule("sandbox_only", "sandbox.example.com"), + vec!["invalid-static".to_string(), "dynamic".to_string()], + )) + .await + .unwrap(); + + let response = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-mixed-provider-env".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .expect("mixed snapshot must be returned") + .into_inner(); + + assert_eq!( + response.environment.get("INVALID_TOKEN"), + Some(&"static-secret".to_string()) + ); + assert!( + !response + .static_credential_bindings + .contains_key("INVALID_TOKEN"), + "incomplete static metadata must reach the supervisor for fail-closed rejection" + ); + assert!( + !response.dynamic_credentials.is_empty(), + "valid dynamic credentials must survive an unrelated static binding failure" + ); + } + #[tokio::test] async fn provider_env_revision_changes_when_attached_provider_record_changes() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 5f2063e2bd..a23743836a 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -623,10 +623,17 @@ pub(super) async fn resolve_provider_environment_with_catalog( env.entry(key.clone()).or_insert_with(|| value.clone()); static_credential_keys.insert(key.clone()); if let Some(endpoints) = &profile_endpoints { + let provider_identity = provider.object_id(); + if provider_identity.is_empty() { + return Err(Status::failed_precondition(format!( + "provider '{name}' has no stable object identity" + ))); + } static_credential_bindings.insert( key.clone(), StaticCredentialBinding { endpoints: endpoints.clone(), + credential_identity: format!("{provider_identity}:{key}"), }, ); } diff --git a/proto/openshell.proto b/proto/openshell.proto index ed2d0d5620..97aba78c89 100644 --- a/proto/openshell.proto +++ b/proto/openshell.proto @@ -1345,6 +1345,10 @@ message StaticCredentialEndpointBinding { // Endpoint allowlist for one static provider credential environment variable. message StaticCredentialBinding { repeated StaticCredentialEndpointBinding endpoints = 1; + // Stable identity of the provider credential that produced this binding. + // Supervisors use it to retain old revision placeholders only across + // rotations of the same provider credential. + string credential_identity = 2; } // Get sandbox provider environment response. @@ -1359,8 +1363,10 @@ message GetSandboxProviderEnvironmentResponse { // Maps endpoint-bound provider metadata to credential metadata. // Supervisor uses this to inject Authorization headers for token grant credentials. map dynamic_credentials = 4; - // Endpoint allowlists for static credential environment variables. Every - // static credential in `environment` has a complete binding. + // Endpoint allowlists for static credential environment variables. Metadata + // can be incomplete when a provider profile is invalid; capable supervisors + // reject all static material in that snapshot while preserving independently + // endpoint-bound dynamic credentials. map static_credential_bindings = 5; // Environment variables that contain provider configuration rather than // credentials and therefore do not require endpoint-scoped resolution. From 59b7c13f00bbe696c71df6100d1ce281aa2a9f71 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:16:21 -0700 Subject: [PATCH 09/32] fix(proxy): enforce bindings across request lifecycle Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/l7/relay.rs | 323 +++++++-- .../src/l7/rest.rs | 24 +- .../src/l7/websocket.rs | 105 ++- .../openshell-supervisor-network/src/proxy.rs | 620 ++++++++++++++---- 4 files changed, 882 insertions(+), 190 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 291ec87e95..2909772cb7 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -79,36 +79,11 @@ fn scoped_context_for_request(ctx: &L7EvalContext, target: &str) -> Option( - client: &mut W, +fn build_credential_resolution_event( ctx: &L7EvalContext, - error: &secrets::UnresolvedPlaceholderError, -) -> Result<()> -where - W: AsyncWrite + Unpin, -{ - let endpoint_mismatch = error.is_endpoint_mismatch(); - let status = if endpoint_mismatch { - "403 Forbidden" - } else { - "500 Internal Server Error" - }; - let body = if endpoint_mismatch { - r#"{"error":"credential_endpoint_mismatch","message":"Credential is not authorized for this request endpoint"}"# - } else { - r#"{"error":"credential_unavailable","message":"Credential placeholder could not be resolved"}"# - }; - let response = format!( - "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", - body.len() - ); - client - .write_all(response.as_bytes()) - .await - .into_diagnostic()?; - client.flush().await.into_diagnostic()?; - - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + endpoint_mismatch: bool, +) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Fail) .action(ActionId::Denied) .disposition(DispositionId::Blocked) @@ -136,28 +111,62 @@ where } else { "credential_unavailable" }) - .build(); - ocsf_emit!(event); + .build() +} + +fn build_credential_endpoint_mismatch_finding(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { + DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .is_alert(true) + .finding_info(FindingInfo::new( + "openshell.provider_credential.endpoint_mismatch", + "Provider credential used at an unauthorized endpoint", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("disposition", "denied"), + ]) + .message("Provider credential endpoint binding mismatch; request denied") + .build() +} + +pub(crate) async fn reject_credential_resolution( + client: &mut W, + ctx: &L7EvalContext, + error: &secrets::UnresolvedPlaceholderError, +) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + let endpoint_mismatch = error.is_endpoint_mismatch(); + let status = if endpoint_mismatch { + "403 Forbidden" + } else { + "500 Internal Server Error" + }; + let body = if endpoint_mismatch { + r#"{"error":"credential_endpoint_mismatch","message":"Credential is not authorized for this request endpoint"}"# + } else { + r#"{"error":"credential_unavailable","message":"Credential placeholder could not be resolved"}"# + }; + let response = format!( + "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + client + .write_all(response.as_bytes()) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + + ocsf_emit!(build_credential_resolution_event(ctx, endpoint_mismatch)); if endpoint_mismatch { - let finding = DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Open) - .action(ActionId::Denied) - .disposition(DispositionId::Blocked) - .severity(SeverityId::High) - .is_alert(true) - .finding_info(FindingInfo::new( - "openshell.provider_credential.endpoint_mismatch", - "Provider credential used at an unauthorized endpoint", - )) - .evidence_pairs(&[ - ("policy", ctx.policy_name.as_str()), - ("host", ctx.host.as_str()), - ("disposition", "denied"), - ]) - .message("Provider credential endpoint binding mismatch; request denied") - .build(); - ocsf_emit!(finding); + ocsf_emit!(build_credential_endpoint_mismatch_finding(ctx)); } Ok(()) } @@ -179,6 +188,34 @@ where } } +async fn relay_http_request_with_credential_rejection( + request: &crate::l7::provider::L7Request, + client: &mut C, + upstream: &mut U, + options: crate::l7::rest::RelayRequestOptions<'_>, + ctx: &L7EvalContext, +) -> Result> +where + C: AsyncRead + AsyncWrite + Unpin, + U: AsyncRead + AsyncWrite + Unpin, +{ + match crate::l7::rest::relay_http_request_with_options_guarded( + request, client, upstream, options, + ) + .await + { + Ok(outcome) => Ok(Some(outcome)), + Err(report) => { + if let Some(error) = report.downcast_ref::() { + reject_credential_resolution(client, ctx, error).await?; + Ok(None) + } else { + Err(report) + } + } + } +} + #[derive(Default)] pub(crate) struct UpgradeRelayOptions<'a> { pub(crate) websocket_request: bool, @@ -616,7 +653,7 @@ where return Ok(()); } }; - let outcome = crate::l7::rest::relay_http_request_with_options_guarded( + let Some(outcome) = relay_http_request_with_credential_rejection( &req, client, upstream, @@ -632,8 +669,12 @@ where host: &ctx.host, port: ctx.port, }, + ctx, ) - .await?; + .await? + else { + return Ok(()); + }; match outcome { RelayOutcome::Reusable => {} RelayOutcome::Consumed => return Ok(()), @@ -1109,7 +1150,7 @@ where }; // Forward request to upstream and relay response - let outcome = crate::l7::rest::relay_http_request_with_options_guarded( + let Some(outcome) = relay_http_request_with_credential_rejection( &req_with_auth, client, upstream, @@ -1125,8 +1166,12 @@ where host: &ctx.host, port: ctx.port, }, + ctx, ) - .await?; + .await? + else { + return Ok(()); + }; match outcome { RelayOutcome::Reusable => {} // continue loop RelayOutcome::Consumed => { @@ -2224,7 +2269,7 @@ where // Forward request with credential rewriting and relay the response. // relay_http_request_with_resolver handles both directions: it sends // the request upstream and reads the response back to the client. - let outcome = crate::l7::rest::relay_http_request_with_options_guarded( + let Some(outcome) = relay_http_request_with_credential_rejection( &req_with_auth, client, upstream, @@ -2233,8 +2278,12 @@ where generation_guard: Some(generation_guard), ..Default::default() }, + ctx, ) - .await?; + .await? + else { + return Ok(()); + }; match outcome { RelayOutcome::Reusable => {} // continue loop @@ -2277,11 +2326,175 @@ where mod tests { use super::*; use crate::opa::{NetworkInput, OpaEngine}; + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + use openshell_core::provider_credentials::ProviderCredentialState; + use std::collections::HashMap as TestHashMap; use std::path::PathBuf; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); + fn endpoint_binding(identity: &str) -> StaticCredentialBinding { + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "allowed.example.test".to_string(), + port: 443, + path: "/allowed/**".to_string(), + }], + credential_identity: identity.to_string(), + } + } + + fn endpoint_mismatch_resolver( + values: TestHashMap, + ) -> (ProviderCredentialState, Arc) { + let bindings = values + .keys() + .map(|key| (key.clone(), endpoint_binding(&format!("provider-a:{key}")))) + .collect(); + let state = ProviderCredentialState::from_bound_environment( + 1, + values, + TestHashMap::new(), + TestHashMap::new(), + bindings, + Vec::new(), + ) + .expect("bound provider state"); + let resolver = state + .resolver_for_endpoint("denied.example.test", 443, "/outside") + .expect("endpoint-scoped resolver"); + (state, resolver) + } + + async fn assert_credential_relay_rejected( + request: crate::l7::provider::L7Request, + resolver: &SecretResolver, + options: crate::l7::rest::RelayRequestOptions<'_>, + ) { + let (mut client_peer, mut client) = tokio::io::duplex(8192); + let (mut upstream, mut upstream_peer) = tokio::io::duplex(8192); + let ctx = L7EvalContext { + host: "denied.example.test".to_string(), + port: 443, + policy_name: "bound".to_string(), + secret_resolver: Some(Arc::new(resolver.clone())), + ..Default::default() + }; + + let outcome = relay_http_request_with_credential_rejection( + &request, + &mut client, + &mut upstream, + crate::l7::rest::RelayRequestOptions { + resolver: Some(resolver), + ..options + }, + &ctx, + ) + .await + .expect("typed credential denial"); + assert!(outcome.is_none()); + drop(client); + drop(upstream); + + let mut response = String::new(); + client_peer.read_to_string(&mut response).await.unwrap(); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("credential_endpoint_mismatch"), + "{response}" + ); + let mut forwarded = Vec::new(); + upstream_peer.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "credential mismatch must not write upstream" + ); + + let activity = build_credential_resolution_event(&ctx, true) + .to_json() + .unwrap(); + assert_eq!(activity["status_detail"], "credential_endpoint_mismatch"); + assert_eq!(activity["action"], "Denied"); + assert_eq!(activity["disposition"], "Blocked"); + + let finding = build_credential_endpoint_mismatch_finding(&ctx) + .to_json() + .unwrap(); + assert_eq!( + finding["finding_info"]["uid"], + "openshell.provider_credential.endpoint_mismatch" + ); + assert_eq!(finding["action"], "Denied"); + assert_eq!(finding["disposition"], "Blocked"); + } + + #[tokio::test] + async fn body_only_endpoint_mismatch_returns_typed_403() { + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + let body = br#"{"token":"openshell:resolve:env:v1_API_TOKEN"}"#; + let request = crate::l7::provider::L7Request { + action: "POST".to_string(), + target: "/outside".to_string(), + query_params: TestHashMap::new(), + raw_header: format!( + "POST /outside HTTP/1.1\r\nHost: denied.example.test\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes() + .into_iter() + .chain(body.iter().copied()) + .collect(), + body_length: crate::l7::provider::BodyLength::ContentLength(body.len() as u64), + }; + + assert_credential_relay_rejected( + request, + resolver.as_ref(), + crate::l7::rest::RelayRequestOptions { + request_body_credential_rewrite: true, + ..Default::default() + }, + ) + .await; + } + + #[tokio::test] + async fn implicit_sigv4_endpoint_mismatch_returns_typed_403() { + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([ + ("AWS_ACCESS_KEY_ID".to_string(), "access".to_string()), + ("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string()), + ("AWS_SESSION_TOKEN".to_string(), "session".to_string()), + ])); + let request = crate::l7::provider::L7Request { + action: "GET".to_string(), + target: "/outside".to_string(), + query_params: TestHashMap::new(), + raw_header: + b"GET /outside HTTP/1.1\r\nHost: denied.example.test\r\nContent-Length: 0\r\n\r\n" + .to_vec(), + body_length: crate::l7::provider::BodyLength::ContentLength(0), + }; + + assert_credential_relay_rejected( + request, + resolver.as_ref(), + crate::l7::rest::RelayRequestOptions { + credential_signing: crate::l7::CredentialSigning::SigV4NoBody, + signing_service: "execute-api", + signing_region: "us-west-2", + host: "denied.example.test", + port: 443, + ..Default::default() + }, + ) + .await; + } + fn install_builtin_middleware(engine: &OpaEngine) { engine.set_middleware_runner_for_tests(openshell_supervisor_middleware::ChainRunner::new( openshell_supervisor_middleware_builtins::services() diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index fd9d373f81..e52828785a 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -677,8 +677,8 @@ where offered_subprotocols: request.subprotocols.clone(), }); - let rewrite_result = rewrite_http_header_block(&header_bytes, options.resolver) - .map_err(|e| miette!("credential injection failed: {e}"))?; + let rewrite_result = + rewrite_http_header_block(&header_bytes, options.resolver).map_err(miette::Report::new)?; if let Some(guard) = options.generation_guard { guard.ensure_current()?; @@ -713,12 +713,18 @@ where let session_token_placeholder = openshell_core::secrets::placeholder_for_env_key("AWS_SESSION_TOKEN"); - match ( - resolver.resolve_placeholder(&access_key_placeholder), - resolver.resolve_placeholder(&secret_key_placeholder), - ) { + let access_key = resolver + .resolve_placeholder_checked(&access_key_placeholder, "sigv4") + .map_err(miette::Report::new)?; + let secret_key = resolver + .resolve_placeholder_checked(&secret_key_placeholder, "sigv4") + .map_err(miette::Report::new)?; + let session_token = resolver + .resolve_placeholder_checked(&session_token_placeholder, "sigv4") + .map_err(miette::Report::new)?; + + match (access_key, secret_key) { (Some(access_key), Some(secret_key)) => { - let session_token = resolver.resolve_placeholder(&session_token_placeholder); // Use explicit signing_region from policy if set, // otherwise extract from hostname. let region = if options.signing_region.is_empty() { @@ -1295,7 +1301,7 @@ fn rewrite_buffered_body( } else { resolver .rewrite_text_placeholders(&mut text, "request_body") - .map_err(|e| miette!("credential injection failed: {e}"))? + .map_err(miette::Report::new)? }; if replacements == 0 || contains_reserved_credential_marker(&text) { return Err(miette!( @@ -1342,7 +1348,7 @@ fn rewrite_form_urlencoded_body(body: &str, resolver: &SecretResolver) -> Result let mut rewritten_value = decoded_value; let field_replacements = resolver .rewrite_text_placeholders(&mut rewritten_value, "request_body") - .map_err(|e| miette!("credential injection failed: {e}"))?; + .map_err(miette::Report::new)?; if field_replacements == 0 || contains_reserved_credential_marker(&rewritten_value) { return Err(miette!( "request body credential rewrite left unresolved credential placeholders" diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index f8208f11f9..5aaf964e0e 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -554,8 +554,10 @@ async fn relay_text_payload( .map_err(|_| miette!("websocket text message is not valid UTF-8"))?; let live_resolver = options .provider_credentials - .and_then(|credentials| credentials.resolver_for_endpoint(host, port, options.target)); - let resolver = live_resolver.as_deref().or(options.resolver); + .map(|credentials| credentials.resolver_for_endpoint(host, port, options.target)); + let resolver = live_resolver + .as_ref() + .map_or(options.resolver, |resolver| resolver.as_deref()); let replacements = if let Some(resolver) = resolver { resolver .rewrite_websocket_text_placeholders(&mut text) @@ -1182,6 +1184,8 @@ mod tests { use super::*; use crate::l7::relay::L7EvalContext; use crate::opa::{NetworkInput, OpaEngine}; + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + use openshell_core::provider_credentials::ProviderCredentialState; use openshell_core::secrets::SecretResolver; use std::path::PathBuf; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -1320,6 +1324,103 @@ network_policies: result.map(|()| output) } + fn bound_websocket_provider_state() -> ProviderCredentialState { + ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("DISCORD_BOT_TOKEN".to_string(), "real-token".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "DISCORD_BOT_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "gateway.example.test".to_string(), + port: 443, + path: "/socket".to_string(), + }], + credential_identity: "provider-a:DISCORD_BOT_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound websocket provider state") + } + + async fn relay_frame_after_live_state_change( + state: &ProviderCredentialState, + fallback: &SecretResolver, + ) -> (Result<()>, Vec) { + let placeholder = b"openshell:resolve:env:v1_DISCORD_BOT_TOKEN"; + let input = masked_frame(true, 0x1, placeholder); + let (mut client_write, mut relay_read) = tokio::io::duplex(4096); + let (mut relay_write, mut upstream_read) = tokio::io::duplex(4096); + client_write.write_all(&input).await.unwrap(); + drop(client_write); + + let options = RelayOptions { + policy_name: "test-policy", + resolver: Some(fallback), + provider_credentials: Some(state), + target: "/socket", + inspector: None, + compression: WebSocketCompression::None, + }; + let result = relay_client_to_server( + &mut relay_read, + &mut relay_write, + "gateway.example.test", + 443, + &options, + ) + .await; + drop(relay_write); + let mut output = Vec::new(); + upstream_read.read_to_end(&mut output).await.unwrap(); + (result, output) + } + + #[tokio::test] + async fn established_websocket_does_not_restore_resolver_after_detach() { + let state = bound_websocket_provider_state(); + let fallback = state.resolver().expect("upgrade-time resolver"); + state.revoke_static_provider_environment(2); + + let (result, output) = relay_frame_after_live_state_change(&state, fallback.as_ref()).await; + assert!(result.is_err(), "revoked placeholder must close the relay"); + assert!( + output.is_empty(), + "revoked WebSocket credential must not reach upstream" + ); + } + + #[tokio::test] + async fn established_websocket_does_not_restore_resolver_after_invalid_refresh() { + let state = bound_websocket_provider_state(); + let fallback = state.resolver().expect("upgrade-time resolver"); + let refresh = state.install_bound_environment( + 2, + HashMap::from([("DISCORD_BOT_TOKEN".to_string(), "rotated".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + Vec::new(), + ); + assert!( + refresh.is_err(), + "incomplete bindings must revoke static state" + ); + + let (result, output) = relay_frame_after_live_state_change(&state, fallback.as_ref()).await; + assert!( + result.is_err(), + "invalid-refresh placeholder must close the relay" + ); + assert!( + output.is_empty(), + "invalid-refresh credential must not reach upstream" + ); + } + async fn run_client_to_server_with_graphql_policy( input: Vec, resolver: Option<&SecretResolver>, diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 2763ab478b..916f191fbe 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -907,6 +907,15 @@ fn build_forward_allow_ocsf_event( .build() } +fn build_forward_parse_error_ocsf_event(path: &str) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Low) + .status(StatusId::Failure) + .message(format!("FORWARD parse error for {path}")) + .build() +} + #[allow(clippy::too_many_arguments)] fn build_forward_policy_deny_ocsf_event( peer_addr: SocketAddr, @@ -3580,6 +3589,71 @@ fn parse_proxy_uri(uri: &str) -> Result<(String, String, u16, String)> { Ok((scheme, host, port, path.to_string())) } +/// Return a query-free, credential-redacted path suitable for forward-proxy +/// telemetry. Malformed targets are represented by a fixed sentinel so parse +/// errors cannot expose query strings or credential environment-key names. +fn forward_telemetry_path(target_uri: &str) -> String { + let Ok((_, _, _, target)) = parse_proxy_uri(target_uri) else { + return "/[INVALID_REQUEST_TARGET]".to_string(); + }; + let path = target + .split_once('?') + .map_or(target.as_str(), |(path, _)| path); + secrets::redact_target_for_policy(path) + .unwrap_or_else(|_| "/[INVALID_REQUEST_TARGET]".to_string()) +} + +fn endpoint_secret_resolver( + provider_credentials: Option<&ProviderCredentialState>, + fallback: Option>, + host: &str, + port: u16, + canonical_path: &str, +) -> Option> { + provider_credentials.map_or(fallback, |credentials| { + credentials.resolver_for_endpoint(host, port, canonical_path) + }) +} + +struct PreparedForwardTarget { + canonical_path: String, + raw_query: Option, + upstream_target: String, + telemetry_path: String, + secret_resolver: Option>, +} + +fn prepare_forward_target( + target: &str, + canonicalize_options: crate::l7::path::CanonicalizeOptions, + provider_credentials: Option<&ProviderCredentialState>, + fallback: Option>, + host: &str, + port: u16, +) -> Result { + let (canonical, raw_query) = + crate::l7::path::canonicalize_request_target(target, &canonicalize_options)?; + let telemetry_path = secrets::redact_target_for_policy(&canonical.path) + .unwrap_or_else(|_| "/[INVALID_REQUEST_TARGET]".to_string()); + let upstream_target = raw_query + .as_deref() + .filter(|query| !query.is_empty()) + .map_or_else( + || canonical.path.clone(), + |query| format!("{}?{query}", canonical.path), + ); + let secret_resolver = + endpoint_secret_resolver(provider_credentials, fallback, host, port, &canonical.path); + + Ok(PreparedForwardTarget { + canonical_path: canonical.path, + raw_query, + upstream_target, + telemetry_path, + secret_resolver, + }) +} + /// Build the HTTP/1.1 `Host` value for a plain-HTTP absolute-form target. /// /// Forward proxy requests are restricted to `http`, so port 80 is omitted as @@ -3856,6 +3930,11 @@ struct ForwardRelayOptions<'a> { websocket_extensions: crate::l7::rest::WebSocketExtensionMode, secret_resolver: Option<&'a SecretResolver>, request_body_credential_rewrite: bool, + credential_signing: crate::l7::CredentialSigning, + signing_service: &'a str, + signing_region: &'a str, + host: &'a str, + port: u16, } async fn relay_rewritten_forward_request( @@ -3894,11 +3973,11 @@ where generation_guard: Some(options.generation_guard), websocket_extensions: options.websocket_extensions, request_body_credential_rewrite: options.request_body_credential_rewrite, - credential_signing: crate::l7::CredentialSigning::None, - signing_service: "", - signing_region: "", - host: "", - port: 0, + credential_signing: options.credential_signing, + signing_service: options.signing_service, + signing_region: options.signing_region, + host: options.host, + port: options.port, }, ) .await @@ -3964,29 +4043,16 @@ async fn handle_forward_proxy( denial_tx: Option<&mpsc::UnboundedSender>, activity_tx: Option<&ActivitySender>, ) -> Result<()> { - // 1. Parse the absolute-form URI. `path` is marked `mut` so that, when an - // L7 config applies, the canonicalized form produced below replaces it - // in-place — keeping OPA evaluation and the bytes written onto the wire - // in sync. See the L7 block below. - let (scheme, host, port, mut path) = match parse_proxy_uri(target_uri) { - Ok(parsed) => parsed, - Err(e) => { - let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Low) - .status(StatusId::Failure) - .message(format!("FORWARD parse error for {target_uri}: {e}")) - .build(); - ocsf_emit!(event); - respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; - return Ok(()); - } + let mut telemetry_path = forward_telemetry_path(target_uri); + // 1. Parse the absolute-form URI. Every external forward target is + // canonicalized below before credential binding, policy-path evaluation, + // upstream bytes, or telemetry consume it. + let Ok((scheme, host, port, mut path)) = parse_proxy_uri(target_uri) else { + ocsf_emit!(build_forward_parse_error_ocsf_event(&telemetry_path)); + respond(client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; + return Ok(()); }; let host_lc = host.to_ascii_lowercase(); - let secret_resolver = provider_credentials - .as_ref() - .and_then(|credentials| credentials.resolver_for_endpoint(&host_lc, port, &path)) - .or(secret_resolver); if host_lc == POLICY_LOCAL_HOST { if scheme != "http" || port != 80 { @@ -4117,7 +4183,7 @@ async fn handle_forward_proxy( method, &host_lc, port, - &path, + &telemetry_path, &binary_str, &pid_str, &ancestors_str, @@ -4140,7 +4206,7 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; @@ -4182,14 +4248,13 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } }; - let mut upstream_target = path.clone(); let mut websocket_extensions = crate::l7::rest::WebSocketExtensionMode::Preserve; let mut forward_tunnel_engine: Option = None; // L7 endpoint config and evaluated request info, carried past the L7 @@ -4203,14 +4268,6 @@ async fn handle_forward_proxy( let mut forward_websocket_request = crate::l7::rest::request_is_websocket_upgrade(&forward_request_bytes); let mut request_body_credential_rewrite = false; - let l7_ctx = relay::http_context( - &decision, - provider_credentials, - secret_resolver.clone(), - activity_tx.cloned(), - dynamic_credentials.clone(), - agent_proposals, - ); let mut l7_activity_pending = false; // 4b. If the endpoint has L7 config, evaluate the request against @@ -4219,6 +4276,67 @@ async fn handle_forward_proxy( // strips hop-by-hop `Connection` headers and drops the upstream after // the response instead of asking the upstream to close it. hydrate_l7_route(&opa_engine, &mut decision); + let canonicalize_options = crate::l7::path::CanonicalizeOptions { + allow_encoded_slash: decision.endpoint.l7_route.as_ref().is_some_and(|route| { + route + .configs + .iter() + .any(|snapshot| snapshot.config.allow_encoded_slash) + }), + ..Default::default() + }; + let prepared_target = match prepare_forward_target( + &path, + canonicalize_options, + provider_credentials.as_ref(), + secret_resolver, + &host_lc, + port, + ) { + Ok(prepared) => prepared, + Err(error) => { + let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .severity(SeverityId::Medium) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&host_lc, port)) + .message(format!( + "FORWARD rejecting non-canonical request-target: {error}" + )) + .build(); + ocsf_emit!(event); + emit_activity_simple(activity_tx, true, "forward_parse_rejection"); + respond( + client, + &build_json_error_response( + 400, + "Bad Request", + "invalid_request_target", + "request-target must be canonical", + ), + ) + .await?; + return Ok(()); + } + }; + path = prepared_target.canonical_path; + telemetry_path = prepared_target.telemetry_path; + let upstream_target = prepared_target.upstream_target; + let query_params = prepared_target + .raw_query + .as_deref() + .map_or_else(std::collections::HashMap::new, |query| { + crate::l7::rest::parse_query_params(query).unwrap_or_default() + }); + let secret_resolver = prepared_target.secret_resolver; + let l7_ctx = relay::http_context( + &decision, + provider_credentials, + secret_resolver.clone(), + activity_tx.cloned(), + dynamic_credentials.clone(), + agent_proposals, + ); if let Some(route) = decision .endpoint .l7_route @@ -4251,7 +4369,7 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; @@ -4276,7 +4394,9 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!( + "{method} {host_lc}:{port}{telemetry_path} not permitted by policy" + ), ), ) .await?; @@ -4284,62 +4404,6 @@ async fn handle_forward_proxy( } }; - // Canonicalize the request-target. The canonical form is fed to OPA - // AND reassigned to the outer `path` variable so the later call to - // `rewrite_forward_request` writes canonical bytes to the upstream. - // This closes the policy/upstream parser-differential at this site; - // without this reassignment, OPA would evaluate the canonical form - // while the upstream re-normalizes the raw input and dispatches on a - // potentially different path. - let canonicalize_options = crate::l7::path::CanonicalizeOptions { - allow_encoded_slash: route - .configs - .iter() - .any(|snapshot| snapshot.config.allow_encoded_slash), - ..Default::default() - }; - let query_params = - match crate::l7::path::canonicalize_request_target(&path, &canonicalize_options) { - Ok((canon, query)) => { - upstream_target = match query.as_deref() { - Some(raw_query) if !raw_query.is_empty() => { - format!("{}?{raw_query}", canon.path) - } - _ => canon.path.clone(), - }; - let params = query - .as_deref() - .map_or_else(std::collections::HashMap::new, |q| { - crate::l7::rest::parse_query_params(q).unwrap_or_default() - }); - path = canon.path; - params - } - Err(e) => { - let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) - .activity(ActivityId::Fail) - .severity(SeverityId::Medium) - .status(StatusId::Failure) - .dst_endpoint(Endpoint::from_domain(&host_lc, port)) - .message(format!( - "FORWARD_L7 rejecting non-canonical request-target: {e}" - )) - .build(); - ocsf_emit!(event); - emit_activity_simple(activity_tx, true, "l7_parse_rejection"); - respond( - client, - &build_json_error_response( - 400, - "Bad Request", - "invalid_request_target", - "request-target must be canonical", - ), - ) - .await?; - return Ok(()); - } - }; let Ok(redacted_path) = secrets::redact_target_for_policy(&path) else { respond( client, @@ -4361,7 +4425,9 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} did not match an L7 endpoint path"), + &format!( + "{method} {host_lc}:{port}{telemetry_path} did not match an L7 endpoint path" + ), ), ) .await?; @@ -4376,7 +4442,7 @@ async fn handle_forward_proxy( .status(StatusId::Failure) .http_request(HttpRequest::new( method, - OcsfUrl::new("http", &host_lc, &path, port), + OcsfUrl::new("http", &host_lc, &telemetry_path, port), )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) @@ -4386,7 +4452,7 @@ async fn handle_forward_proxy( ) .firewall_rule(policy_str, "l7") .message(format!( - "FORWARD_L7 denied unsupported h2c upgrade for {method} {host_lc}:{port}{path}" + "FORWARD_L7 denied unsupported h2c upgrade for {method} {host_lc}:{port}{telemetry_path}" )) .status_detail(crate::l7::rest::UNSUPPORTED_H2C_UPGRADE_DETAIL) .build(); @@ -4596,11 +4662,11 @@ async fn handle_forward_proxy( "FORWARD_L7" }; format!( - "{message_prefix} {decision_str} {method} {host_lc}:{port}{path} reason={reason}" + "{message_prefix} {decision_str} {method} {host_lc}:{port}{telemetry_path} reason={reason}" ) }, |jsonrpc_info| { - let endpoint = format!("{host_lc}:{port}{path}"); + let endpoint = format!("{host_lc}:{port}{telemetry_path}"); crate::l7::relay::jsonrpc_log_message( decision_str, method, @@ -4618,7 +4684,7 @@ async fn handle_forward_proxy( .severity(severity) .http_request(HttpRequest::new( method, - OcsfUrl::new("http", &host_lc, &path, port), + OcsfUrl::new("http", &host_lc, &telemetry_path, port), )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) @@ -4652,7 +4718,9 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} denied by L7 policy: {reason}"), + &format!( + "{method} {host_lc}:{port}{telemetry_path} denied by L7 policy: {reason}" + ), ), ) .await?; @@ -4683,7 +4751,7 @@ async fn handle_forward_proxy( method, &host_lc, port, - &path, + &telemetry_path, &binary_str, &pid_str, &ancestors_str, @@ -4720,7 +4788,7 @@ async fn handle_forward_proxy( method, &host_lc, port, - &path, + &telemetry_path, &binary_str, &pid_str, &ancestors_str, @@ -4752,7 +4820,7 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; @@ -4772,7 +4840,7 @@ async fn handle_forward_proxy( .status(StatusId::Failure) .http_request(HttpRequest::new( method, - OcsfUrl::new("http", &host_lc, &path, port), + OcsfUrl::new("http", &host_lc, &telemetry_path, port), )) .dst_endpoint(Endpoint::from_domain(&host_lc, port)) .src_endpoint(Endpoint::from_ip(workload_addr.ip(), workload_addr.port())) @@ -4826,7 +4894,7 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; @@ -4959,15 +5027,26 @@ async fn handle_forward_proxy( 403, "Forbidden", "policy_denied", - &format!("{method} {host_lc}:{port}{path} not permitted by policy"), + &format!("{method} {host_lc}:{port}{telemetry_path} not permitted by policy"), ), ) .await?; return Ok(()); } - let outcome = relay_rewritten_forward_request( + 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 = match relay_rewritten_forward_request( method, - &path, + &upstream_target, rewritten, client, &mut upstream, @@ -4976,9 +5055,24 @@ async fn handle_forward_proxy( websocket_extensions, secret_resolver: secret_resolver.as_deref(), request_body_credential_rewrite, + credential_signing, + signing_service, + signing_region, + host: &host_lc, + port, }, ) - .await?; + .await + { + Ok(outcome) => outcome, + Err(report) => { + if let Some(error) = report.downcast_ref::() { + crate::l7::relay::reject_credential_resolution(client, &l7_ctx, error).await?; + return Ok(()); + } + return Err(report); + } + }; // The request has now survived middleware, token grant, credential // rewriting, generation checks, and the HTTP relay. Only now record the @@ -4988,7 +5082,7 @@ async fn handle_forward_proxy( method, &host_lc, port, - &path, + &telemetry_path, &binary_str, &pid_str, &ancestors_str, @@ -5241,6 +5335,7 @@ fn is_benign_relay_error(err: &miette::Report) -> bool { mod tests { use super::*; use openshell_core::proposals::AgentProposals; + use std::collections::HashMap as TestHashMap; use std::future::Future; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::Arc; @@ -5435,6 +5530,64 @@ network_policies: {} assert_eq!(json["disposition"], "Blocked"); } + #[test] + fn forward_ocsf_events_omit_queries_and_credential_key_names() { + let peer = "127.0.0.1:45123".parse().unwrap(); + let path = forward_telemetry_path( + "http://api.example.com/v1/openshell:resolve:env:API_TOKEN?token=real-secret", + ); + assert_eq!(path, "/v1/[CREDENTIAL]"); + + let allowed = build_forward_allow_ocsf_event( + peer, + "GET", + "api.example.com", + 80, + &path, + "/usr/bin/curl", + "42", + "/usr/bin/bash", + "curl", + "allow_api", + ) + .to_json() + .unwrap(); + let denied = build_forward_policy_deny_ocsf_event( + peer, + "GET", + "api.example.com", + 80, + &path, + "/usr/bin/curl", + "42", + "/usr/bin/bash", + "curl", + "policy denied", + ) + .to_json() + .unwrap(); + for event in [&allowed, &denied] { + assert_eq!(event["http_request"]["url"]["path"], "/v1/[CREDENTIAL]"); + let serialized = event.to_string(); + assert!(!serialized.contains("API_TOKEN"), "{serialized}"); + assert!(!serialized.contains("real-secret"), "{serialized}"); + assert!(!serialized.contains("?token="), "{serialized}"); + } + + let malformed = build_forward_parse_error_ocsf_event(&forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + )) + .to_json() + .unwrap(); + assert_eq!( + malformed["message"], + "FORWARD parse error for /[INVALID_REQUEST_TARGET]" + ); + let serialized = malformed.to_string(); + assert!(!serialized.contains("API_TOKEN"), "{serialized}"); + assert!(!serialized.contains("real-secret"), "{serialized}"); + } + #[test] fn endpoint_only_opa_allows_declared_endpoint_without_process_identity() { let policy = include_str!("../data/sandbox-policy.rego"); @@ -6005,6 +6158,11 @@ network_policies: websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: resolver, request_body_credential_rewrite, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await?; @@ -6189,6 +6347,11 @@ network_policies: websocket_extensions, secret_resolver: None, request_body_credential_rewrite: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await?; @@ -8368,6 +8531,105 @@ network_policies: assert_eq!(path, "/api?key=val&foo=bar"); } + #[test] + fn forward_telemetry_path_omits_queries_and_redacts_credential_syntax() { + let target = "http://host:80/v1/openshell:resolve:env:API_TOKEN?token=real-secret"; + let redacted = forward_telemetry_path(target); + assert_eq!(redacted, "/v1/[CREDENTIAL]"); + assert!(!redacted.contains("API_TOKEN")); + assert!(!redacted.contains("real-secret")); + + let malformed = forward_telemetry_path( + "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", + ); + assert_eq!(malformed, "/[INVALID_REQUEST_TARGET]"); + assert!(!malformed.contains("API_TOKEN")); + assert!(!malformed.contains("real-secret")); + } + + #[test] + fn forward_binding_uses_canonical_path_for_dot_segment_traversal() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.com".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let placeholder = "openshell:resolve:env:v1_API_TOKEN"; + + for raw_path in ["/allowed/../outside", "/allowed/%2e%2e/outside"] { + let prepared = prepare_forward_target( + raw_path, + crate::l7::path::CanonicalizeOptions::default(), + Some(&state), + state.resolver(), + "api.example.com", + 80, + ) + .expect("prepared target"); + assert_eq!(prepared.canonical_path, "/outside"); + let resolver = prepared.secret_resolver.expect("scoped resolver"); + let error = resolver + .rewrite_header_value(placeholder) + .expect_err("canonical endpoint must deny traversal"); + assert!(error.is_endpoint_mismatch()); + } + } + + #[test] + fn live_forward_state_is_authoritative_after_revocation() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.com".to_string(), + port: 80, + path: "/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let connection_open_resolver = state.resolver(); + state.revoke_static_provider_environment(2); + + assert!( + endpoint_secret_resolver( + Some(&state), + connection_open_resolver, + "api.example.com", + 80, + "/v1", + ) + .is_none(), + "live revocation must not fall back to the connection-open resolver" + ); + } + #[test] fn test_parse_proxy_uri_ipv6() { let (_, host, port, path) = parse_proxy_uri("http://[::1]:8080/test").unwrap(); @@ -9006,19 +9268,34 @@ network_policies: } #[tokio::test] - async fn forward_relay_unresolved_body_placeholder_fails_before_upstream_write() { - let (_, resolver) = SecretResolver::from_provider_env( - [("API_TOKEN".to_string(), "provider-real-token".to_string())] - .into_iter() - .collect(), - ); - let resolver = resolver.expect("resolver"); - let alias = "provider-OPENSHELL-RESOLVE-ENV-API_TOKEN"; - let body = "token=provider-OPENSHELL-RESOLVE-ENV-MISSING_TOKEN"; + async fn forward_relay_body_endpoint_mismatch_is_typed_before_upstream_write() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "provider-real-token".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "allowed.example.com".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let resolver = state + .resolver_for_endpoint("api.example.com", 80, "/api/messages") + .expect("endpoint-scoped resolver"); + let body = "token=openshell:resolve:env:v1_API_TOKEN"; let raw = format!( "POST http://api.example.com/api/messages HTTP/1.1\r\n\ Host: api.example.com\r\n\ - Authorization: Bearer {alias}\r\n\ Content-Type: application/x-www-form-urlencoded\r\n\ Content-Length: {}\r\n\r\n{}", body.len(), @@ -9048,13 +9325,22 @@ network_policies: websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: Some(&resolver), request_body_credential_rewrite: true, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await .expect_err("unresolved body placeholder should fail closed"); + let credential_error = err + .downcast_ref::() + .expect("body mismatch must retain its typed error"); + assert!(credential_error.is_endpoint_mismatch()); assert!(!err.to_string().contains("provider-real-token")); - assert!(!err.to_string().contains("MISSING_TOKEN")); + assert!(!err.to_string().contains("API_TOKEN")); drop(proxy_to_upstream); let mut forwarded = Vec::new(); upstream_side.read_to_end(&mut forwarded).await.unwrap(); @@ -9064,6 +9350,82 @@ network_policies: ); } + #[tokio::test] + async fn forward_relay_sigv4_endpoint_mismatch_is_typed_before_upstream_write() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + let values = TestHashMap::from([ + ("AWS_ACCESS_KEY_ID".to_string(), "access".to_string()), + ("AWS_SECRET_ACCESS_KEY".to_string(), "secret".to_string()), + ("AWS_SESSION_TOKEN".to_string(), "session".to_string()), + ]); + let bindings = values + .keys() + .map(|key| { + ( + key.clone(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "allowed.example.com".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: format!("provider-a:{key}"), + }, + ) + }) + .collect(); + let state = ProviderCredentialState::from_bound_environment( + 1, + values, + TestHashMap::new(), + TestHashMap::new(), + bindings, + Vec::new(), + ) + .expect("bound provider state"); + let resolver = state + .resolver_for_endpoint("api.example.com", 80, "/api") + .expect("endpoint-scoped resolver"); + let guard = forward_test_guard(); + let rewritten = + b"GET /api HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 0\r\n\r\n".to_vec(); + let (mut proxy_to_upstream, mut upstream_side) = tokio::io::duplex(8192); + let (mut _app_side, mut proxy_to_client) = tokio::io::duplex(8192); + + let err = relay_rewritten_forward_request( + "GET", + "/api", + rewritten, + &mut proxy_to_client, + &mut proxy_to_upstream, + ForwardRelayOptions { + generation_guard: &guard, + websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, + secret_resolver: Some(&resolver), + request_body_credential_rewrite: false, + credential_signing: crate::l7::CredentialSigning::SigV4NoBody, + signing_service: "execute-api", + signing_region: "us-west-2", + host: "api.example.com", + port: 80, + }, + ) + .await + .expect_err("SigV4 endpoint mismatch should fail closed"); + + let credential_error = err + .downcast_ref::() + .expect("SigV4 mismatch must retain its typed error"); + assert!(credential_error.is_endpoint_mismatch()); + drop(proxy_to_upstream); + let mut forwarded = Vec::new(); + upstream_side.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "failed SigV4 credential lookup must not reach upstream" + ); + } + #[test] fn test_forward_rewrite_preserves_websocket_upgrade_connection_header() { let raw = "GET http://gateway.example.test/ws HTTP/1.1\r\n\ @@ -9128,6 +9490,11 @@ network_policies: websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await; @@ -9171,6 +9538,11 @@ network_policies: websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, + credential_signing: crate::l7::CredentialSigning::None, + signing_service: "", + signing_region: "", + host: "", + port: 0, }, ) .await; From 62d3c2107d9d87f52ba9011d3c17b8a9dbcacf4b Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:28:56 -0700 Subject: [PATCH 10/32] fix(proxy): close credential relay gaps Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/l7/relay.rs | 93 ++++++++++++++++++- .../openshell-supervisor-network/src/proxy.rs | 85 +++++++++++++---- 2 files changed, 161 insertions(+), 17 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 2909772cb7..0b9d18a069 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -810,7 +810,7 @@ where let use_websocket_relay = options.websocket_request && (options.websocket.message_policy.inspects_messages() || options.websocket.permessage_deflate - || (options.websocket.credential_rewrite && options.secret_resolver.is_some())); + || options.websocket.credential_rewrite); let relay_mode = if use_websocket_relay { "websocket parsed relay" } else { @@ -5350,6 +5350,97 @@ network_policies: assert_eq!(n, 0, "invalid response must not forward 101 headers"); } + #[tokio::test] + async fn websocket_rewrite_stays_parsed_when_live_credentials_are_revoked_before_upgrade() { + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "real-token".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "allowed.example.test".to_string(), + port: 443, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let placeholder = state + .snapshot() + .child_env + .get("API_TOKEN") + .expect("placeholder") + .clone(); + state.revoke_static_provider_environment(2); + + let ctx = L7EvalContext { + host: "allowed.example.test".into(), + port: 443, + policy_name: "route_api".into(), + provider_credentials: Some(state), + ..Default::default() + }; + let (mut app, mut relay_client) = tokio::io::duplex(4096); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(4096); + let relay = tokio::spawn(async move { + handle_upgrade( + &mut relay_client, + &mut relay_upstream, + Vec::new(), + "allowed.example.test", + 443, + UpgradeRelayOptions { + websocket_request: true, + websocket: WebSocketUpgradeBehavior { + credential_rewrite: true, + ..Default::default() + }, + ctx: Some(&ctx), + target: "/allowed/socket".to_string(), + policy_name: "route_api".to_string(), + ..Default::default() + }, + ) + .await + }); + + app.write_all(&masked_text_frame(placeholder.as_bytes())) + .await + .unwrap(); + app.flush().await.unwrap(); + + let mut forwarded = Vec::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read_to_end(&mut forwarded), + ) + .await + .expect("revoked parsed relay should close upstream") + .unwrap(); + assert!( + forwarded.is_empty(), + "revoked credential frame must not be raw-relayed upstream" + ); + + let error = tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("parsed relay should finish after credential rejection") + .unwrap() + .expect_err("revoked placeholder must fail closed"); + assert!( + error + .to_string() + .contains("credential placeholder resolution"), + "{error}" + ); + } + #[tokio::test] async fn route_selected_websocket_rewrites_text_credentials_after_upgrade() { let data = r#" diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 916f191fbe..0c0faeae27 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3529,23 +3529,32 @@ fn parse_proxy_uri(uri: &str) -> Result<(String, String, u16, String)> { .ok_or_else(|| miette::miette!("Missing scheme in proxy URI: {uri}"))?; let scheme = scheme.to_ascii_lowercase(); - // Split authority from path - let (authority, path) = if rest.starts_with('[') { - // IPv6: [::1]:port/path + // Split authority from the request target. A query may immediately follow + // the authority when the absolute URI has no explicit path, so `/` alone + // is not a sufficient delimiter. + let target_start = if rest.starts_with('[') { + // IPv6: [::1]:port/path or [::1]?query let bracket_end = rest .find(']') .ok_or_else(|| miette::miette!("Unclosed IPv6 bracket in URI: {uri}"))?; - let after_bracket = &rest[bracket_end + 1..]; - after_bracket.find('/').map_or((rest, "/"), |slash_pos| { - ( - &rest[..=bracket_end + slash_pos], - &after_bracket[slash_pos..], - ) - }) - } else if let Some(slash_pos) = rest.find('/') { - (&rest[..slash_pos], &rest[slash_pos..]) + rest[bracket_end + 1..] + .find(['/', '?', '#']) + .map(|position| bracket_end + 1 + position) } else { - (rest, "/") + rest.find(['/', '?', '#']) + }; + let (authority, target) = target_start.map_or((rest, ""), |position| rest.split_at(position)); + if target.contains('#') { + return Err(miette::miette!( + "Fragments are not allowed in proxy URI: {uri}" + )); + } + let path = match target.chars().next() { + None => "/".to_string(), + Some('/') => target.to_string(), + Some('?') => format!("/{target}"), + Some('#') => unreachable!("fragments were rejected above"), + Some(_) => unreachable!("target begins at a recognized delimiter"), }; // Parse host and port from authority @@ -3584,9 +3593,7 @@ fn parse_proxy_uri(uri: &str) -> Result<(String, String, u16, String)> { return Err(miette::miette!("Empty host in URI: {uri}")); } - let path = if path.is_empty() { "/" } else { path }; - - Ok((scheme, host, port, path.to_string())) + Ok((scheme, host, port, path)) } /// Return a query-free, credential-redacted path suitable for forward-proxy @@ -5574,6 +5581,30 @@ network_policies: {} assert!(!serialized.contains("?token="), "{serialized}"); } + let target = "http://api.example.com?token=real-secret"; + let (_, host, port, path) = parse_proxy_uri(target).expect("absolute URI without a path"); + assert_eq!(host, "api.example.com"); + assert_eq!(path, "/?token=real-secret"); + let no_path_query = build_forward_allow_ocsf_event( + peer, + "GET", + &host, + port, + &forward_telemetry_path(target), + "/usr/bin/curl", + "42", + "/usr/bin/bash", + "curl", + "allow_api", + ) + .to_json() + .unwrap(); + assert_eq!(no_path_query["dst_endpoint"]["domain"], "api.example.com"); + assert_eq!(no_path_query["http_request"]["url"]["path"], "/"); + let serialized = no_path_query.to_string(); + assert!(!serialized.contains("real-secret"), "{serialized}"); + assert!(!serialized.contains("?token="), "{serialized}"); + let malformed = build_forward_parse_error_ocsf_event(&forward_telemetry_path( "not-a-uri?token=real-secret&key=openshell:resolve:env:API_TOKEN", )) @@ -8531,6 +8562,14 @@ network_policies: assert_eq!(path, "/api?key=val&foo=bar"); } + #[test] + fn test_parse_proxy_uri_with_query_and_no_path() { + let (_, host, port, path) = parse_proxy_uri("http://host:8080?key=val&foo=bar").unwrap(); + assert_eq!(host, "host"); + assert_eq!(port, 8080); + assert_eq!(path, "/?key=val&foo=bar"); + } + #[test] fn forward_telemetry_path_omits_queries_and_redacts_credential_syntax() { let target = "http://host:80/v1/openshell:resolve:env:API_TOKEN?token=real-secret"; @@ -8646,6 +8685,20 @@ network_policies: assert_eq!(path, "/path"); } + #[test] + fn test_parse_proxy_uri_ipv6_with_query_and_no_path() { + let (_, host, port, path) = parse_proxy_uri("http://[fe80::1]:8080?key=val").unwrap(); + assert_eq!(host, "fe80::1"); + assert_eq!(port, 8080); + assert_eq!(path, "/?key=val"); + } + + #[test] + fn test_parse_proxy_uri_rejects_fragment() { + assert!(parse_proxy_uri("http://example.com#secret").is_err()); + assert!(parse_proxy_uri("http://[fe80::1]#secret").is_err()); + } + #[test] fn test_parse_proxy_uri_missing_scheme() { let result = parse_proxy_uri("example.com/path"); From 44ba6839f994dfe5855cfe9c2cc17cb3b3176a4b Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:29:24 -0700 Subject: [PATCH 11/32] docs(credentials): clarify binding failure behavior Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- architecture/gateway.md | 8 +++++--- docs/sandboxes/providers-v2.mdx | 8 ++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index c5b2dbe32d..1cd2aa8638 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -405,9 +405,11 @@ gateway classifies each returned environment entry as either a credential or non-secret provider configuration and associates every credential key with the host, port, and path selectors from its effective provider profile. It withholds static credential material from supervisors that do not advertise binding -support and rejects profile-backed delivery when a static credential has no -usable endpoint. Provider environment revisions include profile endpoint and -binding changes. +support. If a static credential has no usable endpoint, the gateway returns its +incomplete binding metadata with the valid dynamic credential snapshot. The +supervisor then revokes all static material while keeping the dynamic snapshot +active. Provider environment revisions include profile endpoint and binding +changes. ## Inference Resolution diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 89e282ab16..7f3b09bd62 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -107,8 +107,8 @@ headers, Basic and Bearer authorization, URL paths, query parameters, opted-in request bodies, AWS SigV4 signing, and opted-in WebSocket text messages. Raw `tls: skip` and non-HTTP tunnels do not perform static credential substitution. -If a request contains a known placeholder at a destination outside its binding, -OpenShell returns HTTP 403 with this response: +If an HTTP request contains a known placeholder at a destination outside its +binding, OpenShell returns HTTP 403 with this response: ```json {"error":"credential_endpoint_mismatch","message":"Credential is not authorized for this request endpoint"} @@ -119,6 +119,10 @@ The sandbox logs include the destination and and a detection finding. These events omit credential values, placeholders, environment keys, and query strings. +For opted-in WebSocket text-message rewriting, the mismatch can occur after the +HTTP 101 upgrade has completed. OpenShell closes that WebSocket with policy +violation code 1008 instead of returning an HTTP response. + Binding updates apply to both current and retained placeholder generations. Credential rotation keeps the current endpoint set. Updating a provider profile changes the binding on the next sandbox provider-environment sync, and detaching From b92f7696f2d41e674712a3514cd5206ba719aafe Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:09:57 -0700 Subject: [PATCH 12/32] fix(credentials): hash selected provider profile scope Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-server/src/grpc/policy.rs | 98 +++++++++++- .../src/provider_profile_sources.rs | 140 ++++++++++++++---- 2 files changed, 205 insertions(+), 33 deletions(-) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 09924980cb..1777442db0 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1608,7 +1608,12 @@ pub(super) async fn compute_provider_env_revision_with_catalog( Status::internal(format!("decode provider '{provider_name}' failed: {e}")) })?; hasher.update(provider.r#type.as_bytes()); - hash_provider_profile_revision(catalog, &provider.r#type, &mut hasher); + hash_provider_profile_revision( + catalog, + &provider.r#type, + &provider.profile_workspace, + &mut hasher, + ); let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); credential_keys.sort(); @@ -1637,10 +1642,11 @@ pub(super) async fn compute_provider_env_revision_with_catalog( fn hash_provider_profile_revision( catalog: &EffectiveProviderProfileCatalog, provider_type: &str, + profile_workspace: &str, hasher: &mut Sha256, ) { let profile_id = normalize_provider_type(provider_type).unwrap_or(provider_type); - catalog.hash_profile_revision(profile_id, hasher); + catalog.hash_type_profile_revision_for_scope(profile_id, profile_workspace, hasher); } #[cfg(test)] @@ -6942,6 +6948,94 @@ mod tests { ); } + #[tokio::test] + async fn platform_profile_narrowing_changes_platform_provider_revision_when_shadowed() { + use crate::persistence::WriteCondition; + use openshell_core::proto::{ + ProviderProfile, ProviderProfileCategory, StoredProviderProfile, + }; + + fn stored_profile(workspace: &str, path: &str) -> StoredProviderProfile { + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!( + "profile-scoped-revision-{}", + if workspace.is_empty() { + "platform" + } else { + workspace + } + ), + name: "scoped-revision".to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: workspace.to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: "scoped-revision".to_string(), + display_name: format!("{workspace} scoped revision"), + category: ProviderProfileCategory::Other as i32, + endpoints: vec![NetworkEndpoint { + host: "api.example.test".to_string(), + port: 443, + path: path.to_string(), + ..Default::default() + }], + ..Default::default() + }), + } + } + + let store = test_store().await; + store.put_message(&stored_profile("", "/**")).await.unwrap(); + store + .put_message(&stored_profile("default", "/workspace/**")) + .await + .unwrap(); + let mut provider = test_provider("platform-scoped", "scoped-revision"); + provider.profile_workspace = String::new(); + store.put_message(&provider).await.unwrap(); + + let first = + compute_provider_env_revision(&store, "default", &["platform-scoped".to_string()]) + .await + .unwrap(); + + let mut platform = store + .get_message_by_name::("", "scoped-revision") + .await + .unwrap() + .unwrap(); + let metadata = platform.metadata.as_ref().unwrap(); + let object_id = metadata.id.clone(); + let resource_version = metadata.resource_version; + platform.profile.as_mut().unwrap().endpoints[0].path = "/v1/**".to_string(); + store + .put_if( + StoredProviderProfile::object_type(), + &object_id, + "scoped-revision", + "", + &platform.encode_to_vec(), + None, + WriteCondition::MatchResourceVersion(resource_version), + ) + .await + .unwrap(); + + let second = + compute_provider_env_revision(&store, "default", &["platform-scoped".to_string()]) + .await + .unwrap(); + assert_ne!( + first, second, + "narrowing the selected platform fallback must refresh sandbox credentials" + ); + } + #[tokio::test] async fn sandbox_config_and_provider_env_follow_attached_provider_lifecycle() { use crate::grpc::sandbox::{ diff --git a/crates/openshell-server/src/provider_profile_sources.rs b/crates/openshell-server/src/provider_profile_sources.rs index c8ab5cf23a..346a1f3979 100644 --- a/crates/openshell-server/src/provider_profile_sources.rs +++ b/crates/openshell-server/src/provider_profile_sources.rs @@ -439,23 +439,30 @@ impl EffectiveProviderProfileCatalog { id: &str, profile_workspace: &str, ) -> Option { + self.scoped_type_profile_for_scope(id, profile_workspace) + .map(|entry| entry.profile.clone()) + } + + fn scoped_type_profile_for_scope( + &self, + id: &str, + profile_workspace: &str, + ) -> Option<&ScopedProfileEntry> { let id = normalize_profile_id(id)?; let entry = self.profiles.get(&id)?; if entry.effective.scope == ProfileScope::Static { - return Some(entry.effective.profile.clone()); + return Some(&entry.effective); } if profile_workspace.is_empty() { match &entry.platform_fallback { - Some(fallback) => Some(fallback.profile.clone()), - None if entry.effective.scope == ProfileScope::Platform => { - Some(entry.effective.profile.clone()) - } + Some(fallback) => Some(fallback), + None if entry.effective.scope == ProfileScope::Platform => Some(&entry.effective), None => None, } } else { - Some(entry.effective.profile.clone()) + Some(&entry.effective) } } @@ -467,36 +474,40 @@ impl EffectiveProviderProfileCatalog { .map(|entry| entry.effective.source_id.clone()) } - pub(crate) fn hash_profile_revision(&self, profile_id: &str, hasher: &mut Sha256) { - let Some(profile_id) = normalize_profile_id(profile_id) else { - hasher.update(b"invalid-profile-id"); - return; - }; - - let Some(entry) = self.profiles.get(&profile_id) else { + pub(crate) fn hash_type_profile_revision_for_scope( + &self, + profile_id: &str, + profile_workspace: &str, + hasher: &mut Sha256, + ) { + let Some(entry) = self.scoped_type_profile_for_scope(profile_id, profile_workspace) else { hasher.update(b"missing"); return; }; - hasher.update(b"provider-profile-source-entry"); - hasher.update(entry.effective.source_id.as_bytes()); - hasher.update(entry.effective.source_revision.as_bytes()); - let scope_tag: &[u8] = match entry.effective.scope { - ProfileScope::Static => b"static", - ProfileScope::Platform => b"platform", - ProfileScope::Workspace => b"workspace", - }; - hasher.update(scope_tag); - let ownership_tag: &[u8] = if entry.effective.user_managed { - b"user-managed" - } else { - b"source-managed" - }; - hasher.update(ownership_tag); - hasher.update(entry.effective.response.encode_to_vec()); + hash_scoped_profile_revision(entry, hasher); } } +fn hash_scoped_profile_revision(entry: &ScopedProfileEntry, hasher: &mut Sha256) { + hasher.update(b"provider-profile-source-entry"); + hasher.update(entry.source_id.as_bytes()); + hasher.update(entry.source_revision.as_bytes()); + let scope_tag: &[u8] = match entry.scope { + ProfileScope::Static => b"static", + ProfileScope::Platform => b"platform", + ProfileScope::Workspace => b"workspace", + }; + hasher.update(scope_tag); + let ownership_tag: &[u8] = if entry.user_managed { + b"user-managed" + } else { + b"source-managed" + }; + hasher.update(ownership_tag); + hasher.update(entry.response.encode_to_vec()); +} + fn scope_to_string(scope: ProfileScope) -> &'static str { match scope { ProfileScope::Static => "", @@ -921,7 +932,11 @@ mod tests { ); assert!(first.get_type_profile("moving-profile").is_some()); let mut first_profile_hash = Sha256::new(); - first.hash_profile_revision("moving-profile", &mut first_profile_hash); + first.hash_type_profile_revision_for_scope( + "moving-profile", + "default", + &mut first_profile_hash, + ); let first_profile_hash = first_profile_hash.finalize(); assert_eq!(fetch_count.load(Ordering::SeqCst), 1); @@ -932,7 +947,11 @@ mod tests { "revision-b" ); let mut second_profile_hash = Sha256::new(); - second.hash_profile_revision("moving-profile", &mut second_profile_hash); + second.hash_type_profile_revision_for_scope( + "moving-profile", + "default", + &mut second_profile_hash, + ); assert_ne!(first_profile_hash, second_profile_hash.finalize()); assert_ne!(first.revision(), second.revision()); } @@ -1633,6 +1652,65 @@ mod tests { assert_eq!(result.unwrap().display_name, "Platform Anthropic"); } + #[test] + fn scoped_profile_revision_hashes_platform_fallback_beneath_workspace_override() { + let catalog = |platform_path: &str| { + let mut platform = profile("anthropic"); + platform.endpoints.truncate(1); + platform.endpoints[0].path = platform_path.to_string(); + let mut workspace = profile("anthropic"); + workspace.display_name = "Workspace Anthropic".to_string(); + + build_effective_profiles(vec![CollectedProviderProfileSnapshot { + source_id: "user".to_string(), + revision: "same-source-revision".to_string(), + profiles: vec![ + ScopedSnapshotProfile { + scope: ProfileScope::Platform, + profile: platform, + }, + ScopedSnapshotProfile { + scope: ProfileScope::Workspace, + profile: workspace, + }, + ], + user_managed: true, + allow_empty: true, + }]) + .unwrap() + }; + + let broad = catalog("/**"); + let narrow = catalog("/v1/**"); + let mut broad_platform_hash = Sha256::new(); + broad.hash_type_profile_revision_for_scope("anthropic", "", &mut broad_platform_hash); + let mut narrow_platform_hash = Sha256::new(); + narrow.hash_type_profile_revision_for_scope("anthropic", "", &mut narrow_platform_hash); + assert_ne!( + broad_platform_hash.finalize(), + narrow_platform_hash.finalize(), + "platform-scoped providers must hash the selected platform fallback" + ); + + let mut broad_workspace_hash = Sha256::new(); + broad.hash_type_profile_revision_for_scope( + "anthropic", + "default", + &mut broad_workspace_hash, + ); + let mut narrow_workspace_hash = Sha256::new(); + narrow.hash_type_profile_revision_for_scope( + "anthropic", + "default", + &mut narrow_workspace_hash, + ); + assert_eq!( + broad_workspace_hash.finalize(), + narrow_workspace_hash.finalize(), + "workspace-scoped providers must remain keyed to the workspace override" + ); + } + #[test] fn scope_lookup_empty_pw_returns_platform_when_no_shadow() { let mut plat_profile = profile("anthropic"); From 348859fa49ff8b13b4a56e78e1dbe8aff8476dbc Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:10:30 -0700 Subject: [PATCH 13/32] fix(proxy): resolve credentials after request admission Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/l7/relay.rs | 92 ++++++++++++------- 1 file changed, 60 insertions(+), 32 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 0b9d18a069..344e56905a 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -171,23 +171,6 @@ where Ok(()) } -async fn preflight_request_credentials( - client: &mut W, - ctx: &L7EvalContext, - request: &crate::l7::provider::L7Request, -) -> Result -where - W: AsyncWrite + Unpin, -{ - match secrets::rewrite_http_header_block(&request.raw_header, ctx.secret_resolver.as_deref()) { - Ok(_) => Ok(true), - Err(error) => { - reject_credential_resolution(client, ctx, &error).await?; - Ok(false) - } - } -} - async fn relay_http_request_with_credential_rejection( request: &crate::l7::provider::L7Request, client: &mut C, @@ -470,9 +453,6 @@ where }; let scoped_ctx = scoped_context_for_request(ctx, &req.target); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); - if !preflight_request_credentials(client, ctx, &req).await? { - return Ok(()); - } if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); @@ -992,9 +972,6 @@ where }; let scoped_ctx = scoped_context_for_request(ctx, &req.target); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); - if !preflight_request_credentials(client, ctx, &req).await? { - return Ok(()); - } if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); @@ -1297,9 +1274,6 @@ where let jsonrpc_info = parsed.info; let scoped_ctx = scoped_context_for_request(ctx, &req.target); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); - if !preflight_request_credentials(client, ctx, &req).await? { - return Ok(()); - } if close_if_stale(engine.generation_guard(), ctx) { return Ok(()); @@ -1500,9 +1474,6 @@ where let graphql_info = parsed.info; let scoped_ctx = scoped_context_for_request(ctx, &req.target); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); - if !preflight_request_credentials(client, ctx, &req).await? { - return Ok(()); - } if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); @@ -2166,9 +2137,6 @@ where let scoped_ctx = scoped_context_for_request(ctx, &req.target); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); let resolver = ctx.secret_resolver.as_deref(); - if !preflight_request_credentials(client, ctx, &req).await? { - return Ok(()); - } if close_if_stale(generation_guard, ctx) { return Ok(()); @@ -3132,6 +3100,66 @@ network_policies: .unwrap(); } + #[tokio::test] + async fn l7_denial_precedes_credential_endpoint_resolution() { + let (config, tunnel_engine, mut ctx) = + middleware_relay_context("openshell/regex", "fail_closed"); + let (_credential_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + ctx.secret_resolver = Some(resolver); + + 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"GET /outside HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + + let mut response = [0u8; 2048]; + let n = tokio::time::timeout(std::time::Duration::from_secs(1), app.read(&mut response)) + .await + .expect("policy denial should reach client") + .unwrap(); + let response = String::from_utf8_lossy(&response[..n]); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + !response.contains("credential_endpoint_mismatch"), + "L7-denied request must not expose credential binding state: {response}" + ); + + let mut upstream_request = [0u8; 32]; + let result = tokio::time::timeout( + std::time::Duration::from_millis(100), + upstream.read(&mut upstream_request), + ) + .await; + assert!( + matches!(result, Err(_) | Ok(Ok(0))), + "L7-denied request must not reach upstream" + ); + + drop(app); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + #[tokio::test] async fn audit_endpoint_forwards_policy_denied_request_through_healthy_chain() { // Baseline for audit semantics: a request the L7 policy denies is From 42a454bb4143e672fa57f3daa0e0ee34eab11131 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:11:02 -0700 Subject: [PATCH 14/32] docs(credentials): clarify binding failure diagnostics Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .agents/skills/debug-inference/SKILL.md | 10 ++++++++++ architecture/sandbox.md | 6 ++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/.agents/skills/debug-inference/SKILL.md b/.agents/skills/debug-inference/SKILL.md index 3cb08b5861..cb76766c27 100644 --- a/.agents/skills/debug-inference/SKILL.md +++ b/.agents/skills/debug-inference/SKILL.md @@ -239,6 +239,15 @@ Check instead: 3. The sandbox has that provider attached (`openshell sandbox provider list [name]`) 4. `network_policies` allow that host, port, and HTTP rules +If the response reports `credential_endpoint_mismatch`, the provider is attached +but its credential profile does not authorize that request recipient. Inspect +`openshell provider get ` and compare the profile's endpoint host, +port, and path with the direct request. Correct the provider selection or profile +endpoint when that recipient is intentional. Do not widen the sandbox network +policy to work around the mismatch: policy admission and credential endpoint +authorization are separate checks, and the provider profile should authorize +only intended credential recipients. + Attach or detach a provider on an existing sandbox with `openshell sandbox provider attach ` and `openshell sandbox provider detach `. Use the `generate-sandbox-policy` skill when the user needs help authoring policy YAML. @@ -348,6 +357,7 @@ Both commands should return the upstream model list. | `no compatible route` | Provider type does not match request shape | Create or select a provider of the matching type, or change the client API | | `inference.local` works but a platform function fails | User route is configured but `sandbox-system` is missing or wrong | `openshell inference get --system`; configure or update with `--system`; inspect supervisor logs | | Direct call to external host is denied | Missing policy or provider attachment | Update `network_policies` and launch sandbox with the right provider | +| Direct call returns `credential_endpoint_mismatch` | Attached provider profile does not authorize the request host, port, or path | Inspect the provider profile endpoints; select or update the profile only if it intentionally authorizes that recipient | | SDK fails on empty auth token | Client requires a non-empty API key even though OpenShell injects the real one | Use any placeholder token such as `test` | | Upstream timeout from container to host-local backend | Host firewall or network config blocks container-to-host traffic | Allow the Docker bridge subnet to reach the inference port on the host gateway IP (see firewall fix section above) | diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 360d566450..8f08e55263 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -79,8 +79,10 @@ in that provider's effective profile. CONNECT, absolute-form forward HTTP, request targets, headers, supported request bodies, SigV4 signing, and opted-in WebSocket text rewriting use the same scoped resolver. Provider refresh swaps credential values and endpoint bindings atomically. An invalid or unavailable -refresh clears the previous provider state instead of leaving a partially active -or last-known-good credential set. +refresh revokes the previous static credential state instead of leaving a +partially active or last-known-good static set. Invalid metadata preserves the +supplied dynamic snapshot, while a fetch failure preserves the currently active +dynamic snapshot. Route selection and policy evaluation use a syntax-only redacted request target; they do not materialize real credentials. Cross-endpoint placeholder use returns From 9c56260580350b65c0775f4ad4b83a64a82fd830 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 12:13:02 -0700 Subject: [PATCH 15/32] fix(proxy): align single-route credential denials Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .agents/skills/debug-inference/SKILL.md | 18 +- .../src/l7/relay.rs | 264 +++++++++++++++++- 2 files changed, 261 insertions(+), 21 deletions(-) diff --git a/.agents/skills/debug-inference/SKILL.md b/.agents/skills/debug-inference/SKILL.md index cb76766c27..71da517b28 100644 --- a/.agents/skills/debug-inference/SKILL.md +++ b/.agents/skills/debug-inference/SKILL.md @@ -240,13 +240,17 @@ Check instead: 4. `network_policies` allow that host, port, and HTTP rules If the response reports `credential_endpoint_mismatch`, the provider is attached -but its credential profile does not authorize that request recipient. Inspect -`openshell provider get ` and compare the profile's endpoint host, -port, and path with the direct request. Correct the provider selection or profile -endpoint when that recipient is intentional. Do not widen the sandbox network -policy to work around the mismatch: policy admission and credential endpoint -authorization are separate checks, and the provider profile should authorize -only intended credential recipients. +but its credential profile does not authorize that request recipient. Run +`openshell provider get ` to identify the provider type, then +inspect its profile endpoints with +`openshell provider profile export -o yaml`. That export uses the current +workspace scope; add `--global` when the provider was created with +`--global-profile`. Compare the profile's endpoint host, port, and path with the +direct request. Correct the provider selection or profile endpoint when that +recipient is intentional. Do not widen the sandbox network policy to work around +the mismatch: policy admission and credential endpoint authorization are +separate checks, and the provider profile should authorize only intended +credential recipients. Attach or detach a provider on an existing sandbox with `openshell sandbox provider attach ` and `openshell sandbox provider detach `. diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 344e56905a..2043646195 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -1279,7 +1279,13 @@ where return Ok(()); } - let redacted_target = req.target.clone(); + let redacted_target = match secrets::redact_target_for_policy(&req.target) { + Ok(target) => target, + Err(error) => { + reject_credential_resolution(client, ctx, &error).await?; + return Ok(()); + } + }; let request_info = L7RequestInfo { action: req.action.clone(), @@ -1387,14 +1393,21 @@ where // trusted-annotations or version-profile field, so MCP responses and // SSE streams are relayed unchanged; see McpOptions in // proto/sandbox.proto for planned policy extensions. - let outcome = crate::l7::rest::relay_http_request_with_resolver_guarded( + let Some(outcome) = relay_http_request_with_credential_rejection( &req, client, upstream, - ctx.secret_resolver.as_deref(), - Some(engine.generation_guard()), + crate::l7::rest::RelayRequestOptions { + resolver: ctx.secret_resolver.as_deref(), + generation_guard: Some(engine.generation_guard()), + ..Default::default() + }, + ctx, ) - .await?; + .await? + else { + return Ok(()); + }; match outcome { RelayOutcome::Reusable => {} RelayOutcome::Consumed => { @@ -1594,14 +1607,21 @@ where return Ok(()); } }; - let outcome = crate::l7::rest::relay_http_request_with_resolver_guarded( + let Some(outcome) = relay_http_request_with_credential_rejection( &req, client, upstream, - ctx.secret_resolver.as_deref(), - Some(engine.generation_guard()), + crate::l7::rest::RelayRequestOptions { + resolver: ctx.secret_resolver.as_deref(), + generation_guard: Some(engine.generation_guard()), + ..Default::default() + }, + ctx, ) - .await?; + .await? + else { + return Ok(()); + }; match outcome { RelayOutcome::Reusable => {} RelayOutcome::Consumed => { @@ -2298,7 +2318,11 @@ mod tests { use openshell_core::provider_credentials::ProviderCredentialState; use std::collections::HashMap as TestHashMap; use std::path::PathBuf; + use std::sync::Mutex; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; + use tracing::instrument::WithSubscriber; + use tracing_subscriber::Layer; + use tracing_subscriber::layer::{Context as LayerContext, SubscriberExt}; const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); @@ -2335,6 +2359,101 @@ mod tests { (state, resolver) } + #[derive(Clone, Default)] + struct OcsfCapture { + events: Arc>>, + } + + impl Layer for OcsfCapture + where + S: tracing::Subscriber, + { + fn on_event(&self, event: &tracing::Event<'_>, _ctx: LayerContext<'_, S>) { + if event.metadata().target() != openshell_ocsf::OCSF_TARGET { + return; + } + let Some(event) = openshell_ocsf::clone_current_event() else { + return; + }; + self.events + .lock() + .expect("OCSF capture lock") + .push(event.to_json().expect("OCSF event JSON")); + } + } + + impl OcsfCapture { + fn snapshot(&self) -> Vec { + self.events.lock().expect("OCSF capture lock").clone() + } + } + + async fn run_single_config_credential_mismatch( + config: L7EndpointConfig, + engine: TunnelPolicyEngine, + mut ctx: L7EvalContext, + request: String, + resolver: Arc, + ) -> (String, Vec, Vec) { + ctx.secret_resolver = Some(resolver); + let (mut app, mut relay_client) = tokio::io::duplex(8192); + let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); + let capture = OcsfCapture::default(); + let subscriber = tracing_subscriber::registry().with(capture.clone()); + let relay = tokio::spawn( + async move { + relay_with_inspection( + &config, + engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + } + .with_subscriber(subscriber), + ); + + app.write_all(request.as_bytes()).await.unwrap(); + let mut response = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read_to_string(&mut response), + ) + .await + .expect("typed credential denial should close the client stream") + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + (response, forwarded, capture.snapshot()) + } + + fn assert_single_config_credential_mismatch( + response: &str, + forwarded: &[u8], + events: &[serde_json::Value], + ) { + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("credential_endpoint_mismatch"), + "{response}" + ); + assert!( + forwarded.is_empty(), + "credential mismatch must not write upstream" + ); + + let events = serde_json::to_string(events).unwrap(); + assert!(events.contains("\"status_detail\":\"credential_endpoint_mismatch\"")); + assert!(events.contains("openshell.provider_credential.endpoint_mismatch")); + } + async fn assert_credential_relay_rejected( request: crate::l7::provider::L7Request, resolver: &SecretResolver, @@ -2666,23 +2785,31 @@ network_policies: } fn jsonrpc_test_relay_context() -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { - let data = r" + jsonrpc_test_relay_context_with_path("/rpc") + } + + fn jsonrpc_test_relay_context_with_path( + endpoint_path: &str, + ) -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + let data = format!( + r#" network_policies: jsonrpc_api: name: jsonrpc_api endpoints: - host: jsonrpc.example.test port: 8000 - path: /rpc + path: "{endpoint_path}" protocol: json-rpc enforcement: enforce rules: - allow: method: initialize binaries: - - { path: /usr/bin/python3 } -"; - let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + - {{ path: /usr/bin/python3 }} +"# + ); + let engine = OpaEngine::from_strings(TEST_POLICY, &data).unwrap(); let input = NetworkInput { host: "jsonrpc.example.test".into(), port: 8000, @@ -2753,6 +2880,115 @@ network_policies: (config, tunnel_engine, ctx) } + fn graphql_test_relay_context() -> (L7EndpointConfig, TunnelPolicyEngine, L7EvalContext) { + let data = r" +network_policies: + graphql_api: + name: graphql_api + endpoints: + - host: graphql.example.test + port: 8000 + path: /graphql + protocol: graphql + enforcement: enforce + rules: + - allow: + operation_type: query + fields: [viewer] + binaries: + - { path: /usr/bin/python3 } +"; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let input = NetworkInput { + host: "graphql.example.test".into(), + port: 8000, + binary_path: PathBuf::from("/usr/bin/python3"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint_config, generation) = engine + .query_endpoint_config_with_generation(&input) + .unwrap(); + let config = crate::l7::parse_l7_config(&endpoint_config.unwrap()).unwrap(); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let ctx = L7EvalContext { + host: "graphql.example.test".into(), + port: 8000, + policy_name: "graphql_api".into(), + binary_path: "/usr/bin/python3".into(), + ancestors: vec![], + cmdline_paths: vec![], + secret_resolver: None, + ..Default::default() + }; + (config, tunnel_engine, ctx) + } + + #[tokio::test] + async fn single_config_jsonrpc_credential_mismatch_is_typed_and_telemetry_safe() { + let (config, engine, ctx) = jsonrpc_test_relay_context_with_path("/rpc/**"); + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#; + let request = format!( + "POST /rpc/openshell:resolve:env:v1_API_TOKEN HTTP/1.1\r\nHost: jsonrpc.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + + let (response, forwarded, events) = + run_single_config_credential_mismatch(config, engine, ctx, request, resolver).await; + assert_single_config_credential_mismatch(&response, &forwarded, &events); + + let events = serde_json::to_string(&events).unwrap(); + assert!( + events.contains("[CREDENTIAL]"), + "policy telemetry should contain the syntax-only redaction marker: {events}" + ); + assert!( + !events.contains("API_TOKEN"), + "policy telemetry must not expose credential environment keys: {events}" + ); + } + + #[tokio::test] + async fn single_config_mcp_credential_mismatch_returns_typed_denial() { + let (config, engine, ctx) = mcp_test_relay_context(); + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + let body = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"test","version":"1"}}}"#; + let request = format!( + "POST /mcp HTTP/1.1\r\nHost: mcp.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + + let (response, forwarded, events) = + run_single_config_credential_mismatch(config, engine, ctx, request, resolver).await; + assert_single_config_credential_mismatch(&response, &forwarded, &events); + } + + #[tokio::test] + async fn single_config_graphql_credential_mismatch_returns_typed_denial() { + let (config, engine, ctx) = graphql_test_relay_context(); + let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( + "API_TOKEN".to_string(), + "secret".to_string(), + )])); + let body = r#"{"query":"query { viewer }"}"#; + let request = format!( + "POST /graphql HTTP/1.1\r\nHost: graphql.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + + let (response, forwarded, events) = + run_single_config_credential_mismatch(config, engine, ctx, request, resolver).await; + assert_single_config_credential_mismatch(&response, &forwarded, &events); + } + fn authorization_header_count(headers: &str) -> usize { headers .lines() From a06b27236061a6cb1fe5a82ac4ab1144eb2bd72d Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 14:23:14 -0700 Subject: [PATCH 16/32] fix(credentials): harden endpoint-bound rotation Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/provider_credentials.rs | 108 +++++++++- crates/openshell-core/src/secrets.rs | 22 +- .../src/l7/relay.rs | 196 ++++++++++++++++++ .../src/l7/rest.rs | 101 +++++++++ 4 files changed, 422 insertions(+), 5 deletions(-) diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 2aee283e30..50ee983ae5 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -30,6 +30,13 @@ struct ProviderCredentialStateInner { suppressed_keys: HashSet, static_credential_bindings: HashMap, known_static_credential_keys: HashSet, + static_credential_identity_epochs: HashMap, +} + +#[derive(Debug)] +struct StaticCredentialIdentityEpoch { + identity: String, + first_revision: u64, } #[derive(Debug, Clone)] @@ -81,6 +88,7 @@ impl ProviderCredentialState { suppressed_keys: HashSet::new(), static_credential_bindings: HashMap::new(), known_static_credential_keys: HashSet::new(), + static_credential_identity_epochs: HashMap::new(), })), } } @@ -108,6 +116,11 @@ impl ProviderCredentialState { inner .known_static_credential_keys .extend(static_credential_bindings.keys().cloned()); + update_static_credential_identity_epochs( + &mut inner.static_credential_identity_epochs, + revision, + &static_credential_bindings, + ); inner.static_credential_bindings = static_credential_bindings; } Ok(state) @@ -137,6 +150,7 @@ impl ProviderCredentialState { suppressed_keys: HashSet::new(), static_credential_bindings: HashMap::new(), known_static_credential_keys: HashSet::new(), + static_credential_identity_epochs: HashMap::new(), })), } } @@ -171,6 +185,7 @@ impl ProviderCredentialState { inner.combined_resolver = None; inner.static_credential_bindings.clear(); inner.known_static_credential_keys.clear(); + inner.static_credential_identity_epochs.clear(); inner.current.child_env.len() } @@ -205,7 +220,7 @@ impl ProviderCredentialState { .read() .expect("provider credential state poisoned"); let request_path = path.split_once('?').map_or(path, |(path, _)| path); - let allowed = inner + let allowed: HashSet = inner .static_credential_bindings .iter() .filter(|(_, binding)| { @@ -216,7 +231,23 @@ impl ProviderCredentialState { .map(|(key, _)| key.clone()) .collect(); inner.combined_resolver.as_ref().map(|resolver| { - Arc::new(resolver.scoped_to_env_keys(&inner.known_static_credential_keys, &allowed)) + let revision_fallback_min_revisions = inner + .static_credential_identity_epochs + .iter() + .filter(|(key, epoch)| { + allowed.contains(*key) + && inner + .static_credential_bindings + .get(*key) + .is_some_and(|binding| binding.credential_identity == epoch.identity) + }) + .map(|(key, epoch)| (key.clone(), epoch.first_revision)) + .collect(); + Arc::new(resolver.scoped_to_env_keys( + &inner.known_static_credential_keys, + &allowed, + revision_fallback_min_revisions, + )) }) } @@ -442,6 +473,11 @@ impl ProviderCredentialState { inner .known_static_credential_keys .extend(static_credential_bindings.keys().cloned()); + update_static_credential_identity_epochs( + &mut inner.static_credential_identity_epochs, + revision, + &static_credential_bindings, + ); inner.static_credential_bindings = static_credential_bindings; Ok(inner.current.child_env.len()) } @@ -474,6 +510,7 @@ impl ProviderCredentialState { inner.current_resolver = None; inner.combined_resolver = None; inner.static_credential_bindings.clear(); + inner.static_credential_identity_epochs.clear(); } } @@ -545,6 +582,32 @@ fn static_credential_identities( .collect() } +fn update_static_credential_identity_epochs( + epochs: &mut HashMap, + revision: u64, + bindings: &HashMap, +) { + epochs.retain(|key, _| bindings.contains_key(key)); + for (key, binding) in bindings { + match epochs.get_mut(key) { + Some(epoch) if epoch.identity == binding.credential_identity => {} + Some(epoch) => { + epoch.identity.clone_from(&binding.credential_identity); + epoch.first_revision = revision; + } + None => { + epochs.insert( + key.clone(), + StaticCredentialIdentityEpoch { + identity: binding.credential_identity.clone(), + first_revision: revision, + }, + ); + } + } + } +} + fn binding_error(message: &str) -> StaticCredentialBindingError { StaticCredentialBindingError { message: message.to_string(), @@ -722,6 +785,47 @@ mod tests { ); } + #[test] + fn aged_generation_falls_back_after_many_rotations_of_same_provider_credential() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "secret-1".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("initial bindings"); + + for revision in 2..=10 { + state + .install_bound_environment( + revision, + HashMap::from([("API_KEY".to_string(), format!("secret-{revision}"))]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("rotated bindings"); + } + + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + Some("secret-10"), + "an aged-out placeholder may use the current secret while its provider identity is unchanged" + ); + } + #[test] fn replacing_provider_with_reused_key_purges_retained_generation() { let state = ProviderCredentialState::from_bound_environment( diff --git a/crates/openshell-core/src/secrets.rs b/crates/openshell-core/src/secrets.rs index f1f09427f6..1decd75ee6 100644 --- a/crates/openshell-core/src/secrets.rs +++ b/crates/openshell-core/src/secrets.rs @@ -123,6 +123,7 @@ pub struct SecretResolver { by_placeholder: HashMap, denied_env_keys: std::collections::HashSet, no_revision_fallback_env_keys: std::collections::HashSet, + revision_fallback_min_revisions: HashMap, } #[derive(Clone)] @@ -245,6 +246,7 @@ impl SecretResolver { by_placeholder, denied_env_keys: std::collections::HashSet::new(), no_revision_fallback_env_keys: std::collections::HashSet::new(), + revision_fallback_min_revisions: HashMap::new(), }), ) } @@ -254,11 +256,14 @@ impl SecretResolver { let mut by_placeholder = HashMap::new(); let mut denied_env_keys = std::collections::HashSet::new(); let mut no_revision_fallback_env_keys = std::collections::HashSet::new(); + let mut revision_fallback_min_revisions = HashMap::new(); for resolver in resolvers { by_placeholder.extend(resolver.by_placeholder.clone()); denied_env_keys.extend(resolver.denied_env_keys.iter().cloned()); no_revision_fallback_env_keys .extend(resolver.no_revision_fallback_env_keys.iter().cloned()); + revision_fallback_min_revisions + .extend(resolver.revision_fallback_min_revisions.clone()); } if by_placeholder.is_empty() { None @@ -267,6 +272,7 @@ impl SecretResolver { by_placeholder, denied_env_keys, no_revision_fallback_env_keys, + revision_fallback_min_revisions, }) } } @@ -281,6 +287,7 @@ impl SecretResolver { &self, bound_keys: &std::collections::HashSet, allowed_bound_keys: &std::collections::HashSet, + revision_fallback_min_revisions: HashMap, ) -> Self { let denied_env_keys = bound_keys .difference(allowed_bound_keys) @@ -298,6 +305,7 @@ impl SecretResolver { by_placeholder, denied_env_keys, no_revision_fallback_env_keys: bound_keys.clone(), + revision_fallback_min_revisions, } } @@ -331,8 +339,12 @@ impl SecretResolver { // to one provider identity, so falling back by key after replacement // would let the old process obtain the replacement provider's secret. let key = revisioned_placeholder_env_key(value).or_else(|| alias_env_key(value))?; - if revisioned_placeholder_env_key(value).is_some() + if let Some((revision, key)) = revisioned_placeholder_parts(value) && self.no_revision_fallback_env_keys.contains(key) + && self + .revision_fallback_min_revisions + .get(key) + .is_none_or(|minimum| revision < *minimum) { return None; } @@ -615,9 +627,13 @@ fn alias_env_key(token: &str) -> Option<&str> { } fn revisioned_placeholder_env_key(token: &str) -> Option<&str> { + revisioned_placeholder_parts(token).map(|(_, key)| key) +} + +fn revisioned_placeholder_parts(token: &str) -> Option<(u64, &str)> { let suffix = token.strip_prefix(PLACEHOLDER_PREFIX)?; - let (_, key) = split_revisioned_env_key(suffix)?; - Some(key) + let (revision, key) = split_revisioned_env_key(suffix)?; + Some((revision.parse().ok()?, key)) } fn placeholder_env_key(token: &str) -> Option<&str> { diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 2043646195..9d00fa488e 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -79,6 +79,83 @@ fn scoped_context_for_request(ctx: &L7EvalContext, target: &str) -> Option bool { + let authority = match crate::l7::rest::request_host_authority(&request.raw_header) { + Ok(Some(authority)) => authority, + Ok(None) => return true, + Err(_) => return false, + }; + let request_host = authority.host().trim_end_matches('.'); + let endpoint_host = ctx + .host + .trim() + .trim_start_matches('[') + .trim_end_matches(']') + .trim_end_matches('.'); + request_host.eq_ignore_ascii_case(endpoint_host) + && authority.port_u16().is_none_or(|port| port == ctx.port) +} + +async fn reject_request_authority_mismatch(client: &mut W, ctx: &L7EvalContext) -> Result<()> +where + W: AsyncWrite + Unpin, +{ + let body = r#"{"error":"request_authority_mismatch","message":"HTTP request authority does not match the authorized tunnel endpoint"}"#; + let response = format!( + "HTTP/1.1 403 Forbidden\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ); + client + .write_all(response.as_bytes()) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + + ocsf_emit!(build_request_authority_mismatch_event(ctx)); + ocsf_emit!(build_request_authority_mismatch_finding(ctx)); + Ok(()) +} + +fn build_request_authority_mismatch_event(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Fail) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .status(StatusId::Failure) + .dst_endpoint(Endpoint::from_domain(&ctx.host, ctx.port)) + .firewall_rule(&ctx.policy_name, "request-authority") + .message(format!( + "HTTP request authority does not match authorized tunnel endpoint {}:{}", + ctx.host, ctx.port + )) + .status_detail("request_authority_mismatch") + .build() +} + +fn build_request_authority_mismatch_finding(ctx: &L7EvalContext) -> openshell_ocsf::OcsfEvent { + DetectionFindingBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::Open) + .action(ActionId::Denied) + .disposition(DispositionId::Blocked) + .severity(SeverityId::High) + .is_alert(true) + .finding_info(FindingInfo::new( + "openshell.http.request_authority_mismatch", + "HTTP request authority does not match the authorized tunnel endpoint", + )) + .evidence_pairs(&[ + ("policy", ctx.policy_name.as_str()), + ("host", ctx.host.as_str()), + ("disposition", "denied"), + ]) + .message("HTTP request authority mismatch; request denied") + .build() +} + fn build_credential_resolution_event( ctx: &L7EvalContext, endpoint_mismatch: bool, @@ -430,6 +507,10 @@ where return Ok(()); } }; + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } let route_target = match secrets::redact_target_for_policy(&req.target) { Ok(target) => target, @@ -970,6 +1051,10 @@ where return Ok(()); // Close connection on parse error } }; + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } let scoped_ctx = scoped_context_for_request(ctx, &req.target); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); @@ -1272,6 +1357,10 @@ where let req = parsed.request; let jsonrpc_info = parsed.info; + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } let scoped_ctx = scoped_context_for_request(ctx, &req.target); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); @@ -1485,6 +1574,10 @@ where let req = parsed.request; let graphql_info = parsed.info; + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } let scoped_ctx = scoped_context_for_request(ctx, &req.target); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); @@ -2154,6 +2247,10 @@ where return Ok(()); } }; + if !request_authority_matches_endpoint(&req, ctx) { + reject_request_authority_mismatch(client, ctx).await?; + return Ok(()); + } let scoped_ctx = scoped_context_for_request(ctx, &req.target); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); let resolver = ctx.secret_resolver.as_deref(); @@ -3396,6 +3493,105 @@ network_policies: .unwrap(); } + #[tokio::test] + async fn connect_rejects_credential_request_with_mismatched_host_authority() { + let engine = OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").unwrap(); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.test".to_string(), + port: 8080, + path: "/v1/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let placeholder = state + .snapshot() + .child_env + .get("API_TOKEN") + .expect("placeholder") + .clone(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 8080, + policy_name: "passthrough_api".into(), + binary_path: "/usr/bin/curl".into(), + provider_credentials: Some(state), + ..Default::default() + }; + let event_ctx = ctx.clone(); + + 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_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + let request = format!( + "POST /v1/messages HTTP/1.1\r\nHost: attacker.example.test\r\nAuthorization: Bearer {placeholder}\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}" + ); + app.write_all(request.as_bytes()).await.unwrap(); + + let mut response = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read_to_string(&mut response), + ) + .await + .expect("authority denial should close the client stream") + .unwrap(); + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("request_authority_mismatch"), + "{response}" + ); + + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "mismatched request authority must not write upstream" + ); + let activity = build_request_authority_mismatch_event(&event_ctx) + .to_json() + .expect("serialize authority mismatch activity"); + assert_eq!(activity["status_detail"], "request_authority_mismatch"); + assert_eq!(activity["action"], "Denied"); + assert_eq!(activity["disposition"], "Blocked"); + let finding = build_request_authority_mismatch_finding(&event_ctx) + .to_json() + .expect("serialize authority mismatch finding"); + assert_eq!( + finding["finding_info"]["uid"], + "openshell.http.request_authority_mismatch" + ); + } + #[tokio::test] async fn audit_endpoint_forwards_policy_denied_request_through_healthy_chain() { // Baseline for audit semantics: a request the L7 policy denies is diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index e52828785a..f2511ed129 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -240,6 +240,11 @@ async fn parse_http_request( if version != "HTTP/1.1" && version != "HTTP/1.0" { return Err(miette!("Unsupported HTTP version: {version}")); } + let host_authority = request_host_authority_from_str(header_str)?; + if version == "HTTP/1.1" && host_authority.is_none() { + return Err(miette!("HTTP/1.1 request is missing a Host header")); + } + validate_absolute_form_authority(&target, host_authority.as_ref())?; // Determine body framing from headers let body_length = parse_body_length(header_str)?; @@ -385,6 +390,79 @@ pub(crate) fn validate_http_request_header_block(headers: &[u8]) -> Result<()> { Ok(()) } +pub(crate) fn request_host_authority(raw_header: &[u8]) -> Result> { + let header_end = raw_header + .windows(4) + .position(|window| window == b"\r\n\r\n") + .ok_or_else(|| miette!("HTTP request headers are missing the CRLF terminator"))? + + 4; + let headers = std::str::from_utf8(&raw_header[..header_end]) + .map_err(|_| miette!("HTTP headers contain invalid UTF-8"))?; + request_host_authority_from_str(headers) +} + +fn request_host_authority_from_str(headers: &str) -> Result> { + let mut authorities = headers.lines().skip(1).filter_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("host").then_some(value.trim()) + }); + let Some(authority) = authorities.next() else { + return Ok(None); + }; + if authorities.next().is_some() { + return Err(miette!("HTTP request contains multiple Host headers")); + } + authority + .parse() + .map(Some) + .map_err(|_| miette!("HTTP request Host header contains an invalid authority")) +} + +fn validate_absolute_form_authority( + target: &str, + host_authority: Option<&http::uri::Authority>, +) -> Result<()> { + if !target.contains("://") { + return Ok(()); + } + let uri = target + .parse::() + .map_err(|_| miette!("HTTP absolute-form request target contains an invalid URI"))?; + let request_authority = uri + .authority() + .ok_or_else(|| miette!("HTTP absolute-form request target is missing an authority"))?; + let host_authority = host_authority + .ok_or_else(|| miette!("HTTP absolute-form request is missing a Host header"))?; + if !authorities_match(request_authority, host_authority, uri.scheme_str()) { + return Err(miette!( + "HTTP absolute-form request authority does not match the Host header" + )); + } + Ok(()) +} + +fn authorities_match( + left: &http::uri::Authority, + right: &http::uri::Authority, + scheme: Option<&str>, +) -> bool { + if !normalized_authority_host(left.host()) + .eq_ignore_ascii_case(normalized_authority_host(right.host())) + { + return false; + } + let default_port = match scheme { + Some(scheme) if scheme.eq_ignore_ascii_case("http") => Some(80), + Some(scheme) if scheme.eq_ignore_ascii_case("https") => Some(443), + _ => None, + }; + left.port_u16().or(default_port) == right.port_u16().or(default_port) +} + +fn normalized_authority_host(host: &str) -> &str { + host.trim_end_matches('.') +} + fn validate_http_request_line(request_line: &str) -> Result<()> { let mut parts = request_line.split(' '); let method = parts @@ -4718,6 +4796,29 @@ mod tests { ); } + #[tokio::test] + async fn parse_http_request_rejects_absolute_authority_mismatched_with_host() { + let (mut client, mut peer) = tokio::io::duplex(1024); + peer.write_all( + b"GET http://attacker.example.test/v1 HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + ) + .await + .unwrap(); + + let error = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .expect_err("absolute-form authority mismatch must fail closed"); + assert!( + error + .to_string() + .contains("request authority does not match the Host header"), + "{error}" + ); + } + #[tokio::test] async fn parse_http_request_canonicalization_preserves_query_string() { let (mut client, mut writer) = tokio::io::duplex(4096); From 3f5be52b4dfd7688dc165352f31bd7db7c1ffd85 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:13:38 -0700 Subject: [PATCH 17/32] fix(credentials): enforce identity and authority binding Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/provider_credentials.rs | 65 +++- crates/openshell-core/src/secrets.rs | 133 ++++--- .../src/l7/relay.rs | 368 ++++++++++++++---- .../src/l7/rest.rs | 69 +++- .../openshell-supervisor-network/src/proxy.rs | 11 +- .../src/proxy/relay.rs | 2 + 6 files changed, 484 insertions(+), 164 deletions(-) diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 50ee983ae5..f4e201c992 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -36,7 +36,7 @@ struct ProviderCredentialStateInner { #[derive(Debug)] struct StaticCredentialIdentityEpoch { identity: String, - first_revision: u64, + revisions: HashSet, } #[derive(Debug, Clone)] @@ -231,7 +231,7 @@ impl ProviderCredentialState { .map(|(key, _)| key.clone()) .collect(); inner.combined_resolver.as_ref().map(|resolver| { - let revision_fallback_min_revisions = inner + let revision_fallback_allowed_revisions = inner .static_credential_identity_epochs .iter() .filter(|(key, epoch)| { @@ -241,12 +241,12 @@ impl ProviderCredentialState { .get(*key) .is_some_and(|binding| binding.credential_identity == epoch.identity) }) - .map(|(key, epoch)| (key.clone(), epoch.first_revision)) + .map(|(key, epoch)| (key.clone(), epoch.revisions.clone())) .collect(); Arc::new(resolver.scoped_to_env_keys( &inner.known_static_credential_keys, &allowed, - revision_fallback_min_revisions, + revision_fallback_allowed_revisions, )) }) } @@ -590,17 +590,19 @@ fn update_static_credential_identity_epochs( epochs.retain(|key, _| bindings.contains_key(key)); for (key, binding) in bindings { match epochs.get_mut(key) { - Some(epoch) if epoch.identity == binding.credential_identity => {} + Some(epoch) if epoch.identity == binding.credential_identity => { + epoch.revisions.insert(revision); + } Some(epoch) => { epoch.identity.clone_from(&binding.credential_identity); - epoch.first_revision = revision; + epoch.revisions = HashSet::from([revision]); } None => { epochs.insert( key.clone(), StaticCredentialIdentityEpoch { identity: binding.credential_identity.clone(), - first_revision: revision, + revisions: HashSet::from([revision]), }, ); } @@ -786,10 +788,10 @@ mod tests { } #[test] - fn aged_generation_falls_back_after_many_rotations_of_same_provider_credential() { + fn aged_generation_falls_back_across_non_monotonic_same_identity_rotations() { let state = ProviderCredentialState::from_bound_environment( - 1, - HashMap::from([("API_KEY".to_string(), "secret-1".to_string())]), + 50, + HashMap::from([("API_KEY".to_string(), "secret-50".to_string())]), HashMap::new(), HashMap::new(), HashMap::from([( @@ -800,7 +802,7 @@ mod tests { ) .expect("initial bindings"); - for revision in 2..=10 { + for revision in [10, 100, 9, 101, 8, 102, 7, 103, 6] { state .install_bound_environment( revision, @@ -820,16 +822,16 @@ mod tests { .resolver_for_endpoint("api.example.com", 443, "/v1") .expect("resolver"); assert_eq!( - resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), - Some("secret-10"), - "an aged-out placeholder may use the current secret while its provider identity is unchanged" + resolver.resolve_placeholder("openshell:resolve:env:v50_API_KEY"), + Some("secret-6"), + "an aged-out placeholder may use the current secret across revisions in both numeric directions while its provider identity is unchanged" ); } #[test] fn replacing_provider_with_reused_key_purges_retained_generation() { let state = ProviderCredentialState::from_bound_environment( - 1, + u64::MAX, HashMap::from([("API_KEY".to_string(), "provider-a-secret".to_string())]), HashMap::new(), HashMap::new(), @@ -842,7 +844,7 @@ mod tests { state .install_bound_environment( - 2, + 1, HashMap::from([("API_KEY".to_string(), "provider-b-secret".to_string())]), HashMap::new(), HashMap::new(), @@ -855,9 +857,26 @@ mod tests { .resolver_for_endpoint("b.example.com", 443, "/v1") .expect("resolver"); assert_eq!( - resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + resolver.resolve_placeholder(&format!("openshell:resolve:env:v{}_API_KEY", u64::MAX)), + None, + "an opaque revision from another provider identity must fail closed even when it is numerically greater" + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:API_KEY"), + None, + "an identityless canonical placeholder must not resolve a replacement provider" + ); + assert_eq!( + resolver.resolve_placeholder("vendor-OPENSHELL-RESOLVE-ENV-API_KEY"), None, - "a placeholder issued by another provider identity must fail closed" + "an identityless provider alias must not resolve a replacement provider" + ); + assert_eq!( + resolver + .resolve_current_env_key_checked("API_KEY", "trusted-transform") + .expect("binding authorizes the endpoint"), + Some("provider-b-secret"), + "trusted supervisor transforms may select the current bound credential by key" ); } @@ -895,6 +914,16 @@ mod tests { None, "a placeholder issued before detach must not resolve to a replacement provider" ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:API_KEY"), + None, + "an identityless canonical placeholder must not cross detach and reattach" + ); + assert_eq!( + resolver.resolve_placeholder("vendor-OPENSHELL-RESOLVE-ENV-API_KEY"), + None, + "an identityless provider alias must not cross detach and reattach" + ); } #[test] diff --git a/crates/openshell-core/src/secrets.rs b/crates/openshell-core/src/secrets.rs index 1decd75ee6..31df1b4f6f 100644 --- a/crates/openshell-core/src/secrets.rs +++ b/crates/openshell-core/src/secrets.rs @@ -3,7 +3,7 @@ use crate::time::now_ms; use base64::Engine as _; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::fmt; use std::sync::Arc; @@ -121,9 +121,9 @@ pub struct RewriteTargetResult { #[derive(Clone, Default)] pub struct SecretResolver { by_placeholder: HashMap, - denied_env_keys: std::collections::HashSet, - no_revision_fallback_env_keys: std::collections::HashSet, - revision_fallback_min_revisions: HashMap, + denied_env_keys: HashSet, + identity_bound_env_keys: HashSet, + revision_fallback_allowed_revisions: HashMap>, } #[derive(Clone)] @@ -244,9 +244,9 @@ impl SecretResolver { child_env, Some(Self { by_placeholder, - denied_env_keys: std::collections::HashSet::new(), - no_revision_fallback_env_keys: std::collections::HashSet::new(), - revision_fallback_min_revisions: HashMap::new(), + denied_env_keys: HashSet::new(), + identity_bound_env_keys: HashSet::new(), + revision_fallback_allowed_revisions: HashMap::new(), }), ) } @@ -254,16 +254,15 @@ impl SecretResolver { pub fn merge<'a>(resolvers: impl IntoIterator) -> Option { let mut by_placeholder = HashMap::new(); - let mut denied_env_keys = std::collections::HashSet::new(); - let mut no_revision_fallback_env_keys = std::collections::HashSet::new(); - let mut revision_fallback_min_revisions = HashMap::new(); + let mut denied_env_keys = HashSet::new(); + let mut identity_bound_env_keys = HashSet::new(); + let mut revision_fallback_allowed_revisions = HashMap::new(); for resolver in resolvers { by_placeholder.extend(resolver.by_placeholder.clone()); denied_env_keys.extend(resolver.denied_env_keys.iter().cloned()); - no_revision_fallback_env_keys - .extend(resolver.no_revision_fallback_env_keys.iter().cloned()); - revision_fallback_min_revisions - .extend(resolver.revision_fallback_min_revisions.clone()); + identity_bound_env_keys.extend(resolver.identity_bound_env_keys.iter().cloned()); + revision_fallback_allowed_revisions + .extend(resolver.revision_fallback_allowed_revisions.clone()); } if by_placeholder.is_empty() { None @@ -271,8 +270,8 @@ impl SecretResolver { Some(Self { by_placeholder, denied_env_keys, - no_revision_fallback_env_keys, - revision_fallback_min_revisions, + identity_bound_env_keys, + revision_fallback_allowed_revisions, }) } } @@ -285,14 +284,14 @@ impl SecretResolver { #[must_use] pub fn scoped_to_env_keys( &self, - bound_keys: &std::collections::HashSet, - allowed_bound_keys: &std::collections::HashSet, - revision_fallback_min_revisions: HashMap, + bound_keys: &HashSet, + allowed_bound_keys: &HashSet, + revision_fallback_allowed_revisions: HashMap>, ) -> Self { let denied_env_keys = bound_keys .difference(allowed_bound_keys) .cloned() - .collect::>(); + .collect::>(); let by_placeholder = self .by_placeholder .iter() @@ -304,8 +303,8 @@ impl SecretResolver { Self { by_placeholder, denied_env_keys, - no_revision_fallback_env_keys: bound_keys.clone(), - revision_fallback_min_revisions, + identity_bound_env_keys: bound_keys.clone(), + revision_fallback_allowed_revisions, } } @@ -329,46 +328,63 @@ impl SecretResolver { /// Returns `None` if the placeholder is unknown or the resolved value /// contains prohibited control characters (CRLF, null byte). pub fn resolve_placeholder(&self, value: &str) -> Option<&str> { + if placeholder_env_key(value).is_some_and(|key| self.identity_bound_env_keys.contains(key)) + && revisioned_placeholder_parts(value).is_none() + { + // Canonical placeholders and provider-shaped aliases carry no + // credential identity. Endpoint-bound request input must use the + // revision-scoped placeholder issued to the workload so a stale + // process cannot resolve a replacement provider's credential. + return None; + } let secret = if let Some(secret) = self.by_placeholder.get(value) { secret } else { - // Once an old generation ages out, the revision number is only a - // namespace marker. Fall back by key to the current credential so - // long-running child processes survive provider credential refresh. - // Endpoint-bound credentials are the exception: a revision belongs - // to one provider identity, so falling back by key after replacement - // would let the old process obtain the replacement provider's secret. + // Once an old generation ages out, fall back by key to the current + // credential so long-running child processes survive provider + // credential refresh. For endpoint-bound credentials, permit that + // fallback only when the exact opaque revision belongs to the + // current provider-identity epoch. let key = revisioned_placeholder_env_key(value).or_else(|| alias_env_key(value))?; if let Some((revision, key)) = revisioned_placeholder_parts(value) - && self.no_revision_fallback_env_keys.contains(key) + && self.identity_bound_env_keys.contains(key) && self - .revision_fallback_min_revisions + .revision_fallback_allowed_revisions .get(key) - .is_none_or(|minimum| revision < *minimum) + .is_none_or(|revisions| !revisions.contains(&revision)) { return None; } let canonical = placeholder_for_env_key(key); self.by_placeholder.get(&canonical)? }; - if secret.expires_at_ms > 0 && secret.expires_at_ms <= now_ms() { - tracing::warn!( - location = "resolve_placeholder", - "credential resolution rejected: credential is expired" - ); - return None; - } - match validate_resolved_secret(secret.value.as_ref()) { - Ok(s) => Some(s), - Err(reason) => { - tracing::warn!( - location = "resolve_placeholder", - reason, - "credential resolution rejected: resolved value contains prohibited characters" - ); - None - } + resolve_secret_value(secret) + } + + /// Resolve the current value for an environment key selected by trusted + /// supervisor code. + /// + /// Unlike [`Self::resolve_placeholder_checked`], this method accepts an + /// environment key rather than a user-provided placeholder token. Internal + /// request transforms such as `SigV4` can therefore select the endpoint-bound + /// current credential without making identityless placeholder aliases + /// available to sandbox request input. + pub fn resolve_current_env_key_checked( + &self, + key: &str, + location: &'static str, + ) -> Result, UnresolvedPlaceholderError> { + if self.denied_env_keys.contains(key) { + return Err(UnresolvedPlaceholderError { + location, + reason: UnresolvedPlaceholderReason::EndpointMismatch, + }); } + let placeholder = placeholder_for_env_key(key); + Ok(self + .by_placeholder + .get(&placeholder) + .and_then(resolve_secret_value)) } /// Resolve a placeholder while preserving endpoint-denial information. @@ -570,6 +586,27 @@ impl SecretResolver { } } +fn resolve_secret_value(secret: &SecretValue) -> Option<&str> { + if secret.expires_at_ms > 0 && secret.expires_at_ms <= now_ms() { + tracing::warn!( + location = "resolve_placeholder", + "credential resolution rejected: credential is expired" + ); + return None; + } + match validate_resolved_secret(secret.value.as_ref()) { + Ok(s) => Some(s), + Err(reason) => { + tracing::warn!( + location = "resolve_placeholder", + reason, + "credential resolution rejected: resolved value contains prohibited characters" + ); + None + } + } +} + fn alias_start_for_marker(text: &str, marker_abs: usize) -> usize { let mut start = marker_abs; let bytes = text.as_bytes(); diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 9d00fa488e..089c5e67e1 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -42,6 +42,9 @@ pub struct L7EvalContext { pub host: String, /// Port from the CONNECT request. pub port: u16, + /// Default authority port for the inspected HTTP transport (80 for + /// plaintext, 443 after TLS termination). + pub(crate) request_default_port: Option, /// Matched policy name from L4 evaluation. pub policy_name: String, /// Binary path (for cross-layer Rego evaluation). @@ -72,10 +75,42 @@ pub struct L7EvalContext { pub(crate) agent_proposals: openshell_core::proposals::AgentProposals, } -fn scoped_context_for_request(ctx: &L7EvalContext, target: &str) -> Option { - let credentials = ctx.provider_credentials.as_ref()?; +fn request_default_port(ctx: &L7EvalContext) -> Option { + ctx.request_default_port.or({ + // Existing direct relay tests construct the request context below the + // production adapter that records the inspected transport. Keep those + // fixtures focused on their original concern; authority regressions + // set this field explicitly. + #[cfg(test)] + { + Some(ctx.port) + } + #[cfg(not(test))] + { + None + } + }) +} + +fn scoped_context_for_request( + ctx: &L7EvalContext, + request: &crate::l7::provider::L7Request, +) -> Option { let mut scoped = ctx.clone(); - scoped.secret_resolver = credentials.resolver_for_endpoint(&ctx.host, ctx.port, target); + if matches!( + crate::l7::rest::request_authority(&request.raw_header, request_default_port(ctx)), + Ok(None) + ) { + // HTTP/1.0 permits an origin-form request without Host. Such requests + // remain compatible, but an absent authority cannot authorize static + // credential use. Clearing the resolver makes any placeholder or + // signing attempt fail closed before an upstream write. + scoped.secret_resolver = None; + return Some(scoped); + } + let credentials = ctx.provider_credentials.as_ref()?; + scoped.secret_resolver = + credentials.resolver_for_endpoint(&ctx.host, ctx.port, &request.target); Some(scoped) } @@ -83,20 +118,23 @@ fn request_authority_matches_endpoint( request: &crate::l7::provider::L7Request, ctx: &L7EvalContext, ) -> bool { - let authority = match crate::l7::rest::request_host_authority(&request.raw_header) { - Ok(Some(authority)) => authority, - Ok(None) => return true, - Err(_) => return false, - }; - let request_host = authority.host().trim_end_matches('.'); + let authority = + match crate::l7::rest::request_authority(&request.raw_header, request_default_port(ctx)) { + Ok(Some(authority)) => authority, + Ok(None) => { + return std::str::from_utf8(&request.raw_header) + .is_ok_and(|request| !secrets::contains_reserved_credential_marker(request)); + } + Err(_) => return false, + }; + let request_host = authority.authority.host().trim_end_matches('.'); let endpoint_host = ctx .host .trim() .trim_start_matches('[') .trim_end_matches(']') .trim_end_matches('.'); - request_host.eq_ignore_ascii_case(endpoint_host) - && authority.port_u16().is_none_or(|port| port == ctx.port) + request_host.eq_ignore_ascii_case(endpoint_host) && authority.effective_port == ctx.port } async fn reject_request_authority_mismatch(client: &mut W, ctx: &L7EvalContext) -> Result<()> @@ -532,7 +570,7 @@ where .await?; return Ok(()); }; - let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let scoped_ctx = scoped_context_for_request(ctx, &req); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { @@ -1055,7 +1093,7 @@ where reject_request_authority_mismatch(client, ctx).await?; return Ok(()); } - let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let scoped_ctx = scoped_context_for_request(ctx, &req); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { @@ -1361,7 +1399,7 @@ where reject_request_authority_mismatch(client, ctx).await?; return Ok(()); } - let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let scoped_ctx = scoped_context_for_request(ctx, &req); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); if close_if_stale(engine.generation_guard(), ctx) { @@ -1578,7 +1616,7 @@ where reject_request_authority_mismatch(client, ctx).await?; return Ok(()); } - let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let scoped_ctx = scoped_context_for_request(ctx, &req); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { @@ -2251,7 +2289,7 @@ where reject_request_authority_mismatch(client, ctx).await?; return Ok(()); } - let scoped_ctx = scoped_context_for_request(ctx, &req.target); + let scoped_ctx = scoped_context_for_request(ctx, &req); let ctx = scoped_ctx.as_ref().unwrap_or(ctx); let resolver = ctx.secret_resolver.as_deref(); @@ -2415,11 +2453,7 @@ mod tests { use openshell_core::provider_credentials::ProviderCredentialState; use std::collections::HashMap as TestHashMap; use std::path::PathBuf; - use std::sync::Mutex; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; - use tracing::instrument::WithSubscriber; - use tracing_subscriber::Layer; - use tracing_subscriber::layer::{Context as LayerContext, SubscriberExt}; const TEST_POLICY: &str = include_str!("../../data/sandbox-policy.rego"); @@ -2456,60 +2490,26 @@ mod tests { (state, resolver) } - #[derive(Clone, Default)] - struct OcsfCapture { - events: Arc>>, - } - - impl Layer for OcsfCapture - where - S: tracing::Subscriber, - { - fn on_event(&self, event: &tracing::Event<'_>, _ctx: LayerContext<'_, S>) { - if event.metadata().target() != openshell_ocsf::OCSF_TARGET { - return; - } - let Some(event) = openshell_ocsf::clone_current_event() else { - return; - }; - self.events - .lock() - .expect("OCSF capture lock") - .push(event.to_json().expect("OCSF event JSON")); - } - } - - impl OcsfCapture { - fn snapshot(&self) -> Vec { - self.events.lock().expect("OCSF capture lock").clone() - } - } - async fn run_single_config_credential_mismatch( config: L7EndpointConfig, engine: TunnelPolicyEngine, mut ctx: L7EvalContext, request: String, resolver: Arc, - ) -> (String, Vec, Vec) { + ) -> (String, Vec) { ctx.secret_resolver = Some(resolver); let (mut app, mut relay_client) = tokio::io::duplex(8192); let (mut relay_upstream, mut upstream) = tokio::io::duplex(8192); - let capture = OcsfCapture::default(); - let subscriber = tracing_subscriber::registry().with(capture.clone()); - let relay = tokio::spawn( - async move { - relay_with_inspection( - &config, - engine, - &mut relay_client, - &mut relay_upstream, - &ctx, - ) - .await - } - .with_subscriber(subscriber), - ); + let relay = tokio::spawn(async move { + relay_with_inspection( + &config, + engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); app.write_all(request.as_bytes()).await.unwrap(); let mut response = String::new(); @@ -2528,13 +2528,13 @@ mod tests { let mut forwarded = Vec::new(); upstream.read_to_end(&mut forwarded).await.unwrap(); - (response, forwarded, capture.snapshot()) + (response, forwarded) } fn assert_single_config_credential_mismatch( response: &str, forwarded: &[u8], - events: &[serde_json::Value], + ctx: &L7EvalContext, ) { assert!(response.contains("403 Forbidden"), "{response}"); assert!( @@ -2546,9 +2546,19 @@ mod tests { "credential mismatch must not write upstream" ); - let events = serde_json::to_string(events).unwrap(); - assert!(events.contains("\"status_detail\":\"credential_endpoint_mismatch\"")); - assert!(events.contains("openshell.provider_credential.endpoint_mismatch")); + let activity = build_credential_resolution_event(ctx, true) + .to_json() + .expect("serialize credential mismatch activity"); + assert_eq!(activity["status_detail"], "credential_endpoint_mismatch"); + assert_eq!(activity["action"], "Denied"); + assert_eq!(activity["disposition"], "Blocked"); + let finding = build_credential_endpoint_mismatch_finding(ctx) + .to_json() + .expect("serialize credential mismatch finding"); + assert_eq!( + finding["finding_info"]["uid"], + "openshell.provider_credential.endpoint_mismatch" + ); } async fn assert_credential_relay_rejected( @@ -3025,6 +3035,7 @@ network_policies: #[tokio::test] async fn single_config_jsonrpc_credential_mismatch_is_typed_and_telemetry_safe() { let (config, engine, ctx) = jsonrpc_test_relay_context_with_path("/rpc/**"); + let event_ctx = ctx.clone(); let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( "API_TOKEN".to_string(), "secret".to_string(), @@ -3035,24 +3046,27 @@ network_policies: body.len() ); - let (response, forwarded, events) = + let (response, forwarded) = run_single_config_credential_mismatch(config, engine, ctx, request, resolver).await; - assert_single_config_credential_mismatch(&response, &forwarded, &events); + assert_single_config_credential_mismatch(&response, &forwarded, &event_ctx); - let events = serde_json::to_string(&events).unwrap(); + let redacted_target = + secrets::redact_target_for_policy("/rpc/openshell:resolve:env:v1_API_TOKEN") + .expect("policy target redaction"); assert!( - events.contains("[CREDENTIAL]"), - "policy telemetry should contain the syntax-only redaction marker: {events}" + redacted_target.contains("[CREDENTIAL]"), + "policy telemetry should contain the syntax-only redaction marker: {redacted_target}" ); assert!( - !events.contains("API_TOKEN"), - "policy telemetry must not expose credential environment keys: {events}" + !redacted_target.contains("API_TOKEN"), + "policy telemetry must not expose credential environment keys: {redacted_target}" ); } #[tokio::test] async fn single_config_mcp_credential_mismatch_returns_typed_denial() { let (config, engine, ctx) = mcp_test_relay_context(); + let event_ctx = ctx.clone(); let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( "API_TOKEN".to_string(), "secret".to_string(), @@ -3063,14 +3077,15 @@ network_policies: body.len() ); - let (response, forwarded, events) = + let (response, forwarded) = run_single_config_credential_mismatch(config, engine, ctx, request, resolver).await; - assert_single_config_credential_mismatch(&response, &forwarded, &events); + assert_single_config_credential_mismatch(&response, &forwarded, &event_ctx); } #[tokio::test] async fn single_config_graphql_credential_mismatch_returns_typed_denial() { let (config, engine, ctx) = graphql_test_relay_context(); + let event_ctx = ctx.clone(); let (_state, resolver) = endpoint_mismatch_resolver(TestHashMap::from([( "API_TOKEN".to_string(), "secret".to_string(), @@ -3081,9 +3096,9 @@ network_policies: body.len() ); - let (response, forwarded, events) = + let (response, forwarded) = run_single_config_credential_mismatch(config, engine, ctx, request, resolver).await; - assert_single_config_credential_mismatch(&response, &forwarded, &events); + assert_single_config_credential_mismatch(&response, &forwarded, &event_ctx); } fn authorization_header_count(headers: &str) -> usize { @@ -3592,6 +3607,197 @@ network_policies: ); } + async fn run_bound_credential_request( + port: u16, + request_default_port: u16, + request: impl FnOnce(&str) -> String, + ) -> (String, Vec) { + let engine = OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").unwrap(); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.test".to_string(), + port: u32::from(port), + path: "/v1/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let placeholder = state + .snapshot() + .child_env + .get("API_TOKEN") + .expect("placeholder") + .clone(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port, + request_default_port: Some(request_default_port), + policy_name: "passthrough_api".into(), + binary_path: "/usr/bin/curl".into(), + provider_credentials: Some(state), + ..Default::default() + }; + + 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_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + app.write_all(request(&placeholder).as_bytes()) + .await + .unwrap(); + let mut response = String::new(); + tokio::time::timeout( + std::time::Duration::from_secs(1), + app.read_to_string(&mut response), + ) + .await + .expect("credential denial should close the client stream") + .unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + (response, forwarded) + } + + #[tokio::test] + async fn connect_http10_without_authority_cannot_resolve_static_credential() { + let (response, forwarded) = run_bound_credential_request(80, 80, |placeholder| { + format!( + "GET /v1/messages HTTP/1.0\r\nAuthorization: Bearer {placeholder}\r\nConnection: close\r\n\r\n" + ) + }) + .await; + + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("request_authority_mismatch"), + "{response}" + ); + assert!( + forwarded.is_empty(), + "authority-less credential request must not write upstream" + ); + } + + #[tokio::test] + async fn connect_origin_form_omitted_port_rejects_non_default_tunnel() { + let (response, forwarded) = run_bound_credential_request(8080, 80, |placeholder| { + format!( + "GET /v1/messages HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer {placeholder}\r\nConnection: close\r\n\r\n" + ) + }) + .await; + + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("request_authority_mismatch"), + "{response}" + ); + assert!( + forwarded.is_empty(), + "origin-form request with the wrong effective port must not write upstream" + ); + } + + #[tokio::test] + async fn connect_absolute_form_omitted_port_rejects_non_default_tunnel() { + let (response, forwarded) = run_bound_credential_request(8080, 80, |placeholder| { + format!( + "GET http://api.example.test/v1/messages HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer {placeholder}\r\nConnection: close\r\n\r\n" + ) + }) + .await; + + assert!(response.contains("403 Forbidden"), "{response}"); + assert!( + response.contains("request_authority_mismatch"), + "{response}" + ); + assert!( + forwarded.is_empty(), + "absolute-form request with the wrong effective port must not write upstream" + ); + } + + #[tokio::test] + async fn connect_http10_without_authority_forwards_credential_free_request() { + let engine = OpaEngine::from_strings(TEST_POLICY, "network_policies: {}\n").unwrap(); + let generation_guard = engine + .generation_guard(engine.current_generation()) + .unwrap(); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 80, + request_default_port: Some(80), + policy_name: "passthrough_api".into(), + binary_path: "/usr/bin/curl".into(), + ..Default::default() + }; + 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_passthrough_with_credentials( + &mut relay_client, + &mut relay_upstream, + &ctx, + &generation_guard, + None, + ) + .await + }); + + app.write_all(b"GET /v1/messages HTTP/1.0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut forwarded = [0u8; 512]; + let n = tokio::time::timeout( + std::time::Duration::from_secs(1), + upstream.read(&mut forwarded), + ) + .await + .expect("credential-free HTTP/1.0 request should reach upstream") + .unwrap(); + assert!(String::from_utf8_lossy(&forwarded[..n]).starts_with("GET /v1/messages HTTP/1.0")); + upstream + .write_all(b"HTTP/1.0 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n") + .await + .unwrap(); + let mut response = String::new(); + app.read_to_string(&mut response).await.unwrap(); + assert!(response.contains("204 No Content"), "{response}"); + tokio::time::timeout(std::time::Duration::from_secs(1), relay) + .await + .expect("relay should finish") + .unwrap() + .unwrap(); + } + #[tokio::test] async fn audit_endpoint_forwards_policy_denied_request_through_healthy_chain() { // Baseline for audit semantics: a request the L7 policy denies is diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index f2511ed129..cc3ecaf147 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -390,7 +390,16 @@ pub(crate) fn validate_http_request_header_block(headers: &[u8]) -> Result<()> { Ok(()) } -pub(crate) fn request_host_authority(raw_header: &[u8]) -> Result> { +#[derive(Debug)] +pub(crate) struct RequestAuthority { + pub(crate) authority: http::uri::Authority, + pub(crate) effective_port: u16, +} + +pub(crate) fn request_authority( + raw_header: &[u8], + transport_default_port: Option, +) -> Result> { let header_end = raw_header .windows(4) .position(|window| window == b"\r\n\r\n") @@ -398,7 +407,40 @@ pub(crate) fn request_host_authority(raw_header: &[u8]) -> Result()) + .transpose() + .map_err(|_| miette!("HTTP absolute-form request target contains an invalid URI"))?; + let (authority, default_port) = if let Some(uri) = absolute_uri { + let authority = uri + .authority() + .ok_or_else(|| miette!("HTTP absolute-form request target is missing an authority"))? + .clone(); + let default_port = default_port_for_scheme(uri.scheme_str()).ok_or_else(|| { + miette!("HTTP absolute-form request target uses an unsupported scheme") + })?; + (authority, default_port) + } else { + let default_port = transport_default_port + .ok_or_else(|| miette!("HTTP origin-form request transport scheme is unavailable"))?; + (host_authority, default_port) + }; + + let effective_port = authority.port_u16().unwrap_or(default_port); + Ok(Some(RequestAuthority { + authority, + effective_port, + })) } fn request_host_authority_from_str(headers: &str) -> Result> { @@ -451,12 +493,16 @@ fn authorities_match( { return false; } - let default_port = match scheme { + let default_port = default_port_for_scheme(scheme); + left.port_u16().or(default_port) == right.port_u16().or(default_port) +} + +fn default_port_for_scheme(scheme: Option<&str>) -> Option { + match scheme { Some(scheme) if scheme.eq_ignore_ascii_case("http") => Some(80), Some(scheme) if scheme.eq_ignore_ascii_case("https") => Some(443), _ => None, - }; - left.port_u16().or(default_port) == right.port_u16().or(default_port) + } } fn normalized_authority_host(host: &str) -> &str { @@ -784,21 +830,14 @@ where client.flush().await.into_diagnostic()?; } if let Some(resolver) = options.resolver { - let access_key_placeholder = - openshell_core::secrets::placeholder_for_env_key("AWS_ACCESS_KEY_ID"); - let secret_key_placeholder = - openshell_core::secrets::placeholder_for_env_key("AWS_SECRET_ACCESS_KEY"); - let session_token_placeholder = - openshell_core::secrets::placeholder_for_env_key("AWS_SESSION_TOKEN"); - let access_key = resolver - .resolve_placeholder_checked(&access_key_placeholder, "sigv4") + .resolve_current_env_key_checked("AWS_ACCESS_KEY_ID", "sigv4") .map_err(miette::Report::new)?; let secret_key = resolver - .resolve_placeholder_checked(&secret_key_placeholder, "sigv4") + .resolve_current_env_key_checked("AWS_SECRET_ACCESS_KEY", "sigv4") .map_err(miette::Report::new)?; let session_token = resolver - .resolve_placeholder_checked(&session_token_placeholder, "sigv4") + .resolve_current_env_key_checked("AWS_SESSION_TOKEN", "sigv4") .map_err(miette::Report::new)?; match (access_key, secret_key) { diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 0c0faeae27..31e2d40635 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -1549,7 +1549,7 @@ async fn handle_tcp_connection( // gate needs it) and drives the raw-tunnel branch below. // Build request-processing context shared by CONNECT and forward HTTP. - let ctx = relay::http_context( + let mut ctx = relay::http_context( &decision, provider_credentials, secret_resolver.clone(), @@ -1609,6 +1609,7 @@ async fn handle_tcp_connection( if tunnel_protocol == TunnelProtocol::Tls { // TLS detected — terminate unconditionally. if let Some(ref tls) = tls_state { + ctx.request_default_port = Some(443); let tls_result = async { let mut tls_client = crate::l7::tls::tls_terminate_client(client, tls, &host_lc).await?; @@ -1688,6 +1689,7 @@ async fn handle_tcp_connection( } } else if tunnel_protocol == TunnelProtocol::Http1 { // Plaintext HTTP detected. + ctx.request_default_port = Some(80); let is_l7_relay = l7_route.is_some_and(|route| !route.configs.is_empty()); let Some(relay_context) = relay::prepare_http_relay(l7_route, &opa_engine, &decision, &ctx) else { @@ -4336,7 +4338,7 @@ async fn handle_forward_proxy( crate::l7::rest::parse_query_params(query).unwrap_or_default() }); let secret_resolver = prepared_target.secret_resolver; - let l7_ctx = relay::http_context( + let mut l7_ctx = relay::http_context( &decision, provider_credentials, secret_resolver.clone(), @@ -4344,6 +4346,11 @@ async fn handle_forward_proxy( dynamic_credentials.clone(), agent_proposals, ); + l7_ctx.request_default_port = match scheme.as_str() { + "http" => Some(80), + "https" => Some(443), + _ => None, + }; if let Some(route) = decision .endpoint .l7_route diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index 81de24fc57..d3dbda0767 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -54,6 +54,7 @@ pub(super) fn http_context( L7EvalContext { host: decision.intent.destination.host.clone(), port: decision.intent.destination.port, + request_default_port: None, policy_name, binary_path: decision .binary @@ -335,6 +336,7 @@ mod tests { L7EvalContext { host: "example.com".to_string(), port: 80, + request_default_port: Some(80), policy_name: "test".to_string(), binary_path: String::new(), ancestors: vec![], From a5b493f40d0410ef6f715e13ac045aaf41409182 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:03:32 -0700 Subject: [PATCH 18/32] fix(credentials): snapshot provider environment atomically Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-server/src/grpc/policy.rs | 272 ++++++++++++++++++- crates/openshell-server/src/grpc/provider.rs | 178 +++++++----- 2 files changed, 378 insertions(+), 72 deletions(-) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 1777442db0..569e2d6153 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -1602,7 +1602,7 @@ pub(super) async fn compute_provider_env_revision_with_catalog( })? { Some(record) => { hasher.update(record.id.as_bytes()); - hasher.update(record.updated_at_ms.to_le_bytes()); + hasher.update(record.resource_version.to_le_bytes()); let provider = Provider::decode(record.payload.as_slice()).map_err(|e| { Status::internal(format!("decode provider '{provider_name}' failed: {e}")) @@ -1639,6 +1639,46 @@ pub(super) async fn compute_provider_env_revision_with_catalog( )?)) } +fn compute_provider_env_revision_from_records( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], +) -> Result { + let mut hasher = Sha256::new(); + hasher.update(b"openshell-provider-env-revision-v2"); + + for record in records { + hasher.update(record.name.as_bytes()); + hasher.update(record.object_id.as_bytes()); + hasher.update(record.resource_version.to_le_bytes()); + + let provider = &record.provider; + hasher.update(provider.r#type.as_bytes()); + hash_provider_profile_revision( + catalog, + &provider.r#type, + &provider.profile_workspace, + &mut hasher, + ); + + let mut credential_keys: Vec<_> = provider.credentials.keys().collect(); + credential_keys.sort(); + for key in credential_keys { + hasher.update(key.as_bytes()); + } + let mut expiry_keys: Vec<_> = provider.credential_expires_at_ms.keys().collect(); + expiry_keys.sort(); + for key in expiry_keys { + hasher.update(key.as_bytes()); + hasher.update(provider.credential_expires_at_ms[key].to_le_bytes()); + } + } + + let digest = hasher.finalize(); + Ok(u64::from_le_bytes(digest[..8].try_into().map_err( + |_| Status::internal("provider env revision digest too short"), + )?)) +} + fn hash_provider_profile_revision( catalog: &EffectiveProviderProfileCatalog, provider_type: &str, @@ -1749,18 +1789,18 @@ pub(super) async fn handle_get_sandbox_provider_environment( .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) .await?; - let provider_env_revision = compute_provider_env_revision_with_catalog( + let provider_records = super::provider::load_provider_environment_records( state.store.as_ref(), - &provider_profile_catalog, &workspace, &provider_names, ) .await?; - let mut provider_environment = super::provider::resolve_provider_environment_with_catalog( + let provider_env_revision = + compute_provider_env_revision_from_records(&provider_profile_catalog, &provider_records)?; + let mut provider_environment = super::provider::resolve_provider_environment_from_records( state.store.as_ref(), &provider_profile_catalog, - &workspace, - &provider_names, + &provider_records, ) .await?; @@ -6775,13 +6815,19 @@ mod tests { } #[tokio::test] - async fn provider_env_revision_changes_when_attached_provider_record_changes() { + async fn provider_env_revision_changes_on_consecutive_provider_updates_without_delay() { use openshell_core::proto::GetSandboxProviderEnvironmentRequest; - use std::time::Duration; let state = test_server_state().await; let mut provider = test_provider("work-github", "github"); state.store.put_message(&provider).await.unwrap(); + let first_resource_version = state + .store + .get_by_name(Provider::object_type(), "default", "work-github") + .await + .unwrap() + .unwrap() + .resource_version; state .store .put_message(&test_sandbox( @@ -6804,11 +6850,33 @@ mod tests { .unwrap() .into_inner(); - tokio::time::sleep(Duration::from_millis(2)).await; provider .credentials .insert("GITHUB_TOKEN".to_string(), "rotated".to_string()); - state.store.put_message(&provider).await.unwrap(); + state + .store + .put_if( + Provider::object_type(), + provider.object_id(), + provider.object_name(), + provider.object_workspace(), + &provider.encode_to_vec(), + None, + crate::persistence::WriteCondition::Unconditional, + ) + .await + .unwrap(); + let second_resource_version = state + .store + .get_by_name(Provider::object_type(), "default", "work-github") + .await + .unwrap() + .unwrap() + .resource_version; + assert_ne!( + first_resource_version, second_resource_version, + "consecutive writes must advance the authoritative resource version" + ); let second = handle_get_sandbox_provider_environment( &state, @@ -6831,6 +6899,190 @@ mod tests { ); } + #[tokio::test] + async fn provider_environment_revision_and_payload_share_immutable_record_snapshot() { + use openshell_core::proto::{ + ProviderCredentialTokenGrant, ProviderProfile, ProviderProfileCategory, + ProviderProfileCredential, StoredProviderProfile, + }; + + fn dynamic_profile( + id: &str, + endpoint_host: &str, + token_endpoint: &str, + ) -> StoredProviderProfile { + StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: format!("profile-{id}"), + name: id.to_string(), + created_at_ms: 1_000_000, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + profile: Some(ProviderProfile { + id: id.to_string(), + display_name: id.to_string(), + category: ProviderProfileCategory::Other as i32, + credentials: vec![ProviderProfileCredential { + name: "access_token".to_string(), + auth_style: "bearer".to_string(), + header_name: "authorization".to_string(), + token_grant: Some(ProviderCredentialTokenGrant { + token_endpoint: token_endpoint.to_string(), + audience: "api://snapshot".to_string(), + ..Default::default() + }), + ..Default::default() + }], + endpoints: vec![NetworkEndpoint { + host: endpoint_host.to_string(), + port: 443, + path: "/**".to_string(), + ..Default::default() + }], + ..Default::default() + }), + } + } + + let state = test_server_state().await; + state + .store + .put_message(&dynamic_profile( + "snapshot-a", + "api.snapshot-a.example", + "https://auth.snapshot-a.example/token", + )) + .await + .unwrap(); + state + .store + .put_message(&dynamic_profile( + "snapshot-b", + "api.snapshot-b.example", + "https://auth.snapshot-b.example/token", + )) + .await + .unwrap(); + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), "default") + .await + .unwrap(); + + let mut first_provider = test_provider("replaceable", "snapshot-a"); + first_provider.metadata.as_mut().unwrap().id = "provider-identity-a".to_string(); + first_provider.credentials = + HashMap::from([("GITHUB_TOKEN".to_string(), "secret-a".to_string())]); + state.store.put_message(&first_provider).await.unwrap(); + + let provider_names = vec!["replaceable".to_string()]; + let first_records = crate::grpc::provider::load_provider_environment_records( + state.store.as_ref(), + "default", + &provider_names, + ) + .await + .unwrap(); + let first_revision = + compute_provider_env_revision_from_records(&catalog, &first_records).unwrap(); + let mut next_version_records = first_records.clone(); + next_version_records[0].resource_version += 1; + assert_ne!( + first_revision, + compute_provider_env_revision_from_records(&catalog, &next_version_records).unwrap(), + "resource version alone must advance the provider environment revision" + ); + + state + .store + .delete_by_name(Provider::object_type(), "default", "replaceable") + .await + .unwrap(); + let mut replacement = test_provider("replaceable", "snapshot-b"); + replacement.metadata.as_mut().unwrap().id = "provider-identity-b".to_string(); + replacement.credentials = + HashMap::from([("GITHUB_TOKEN".to_string(), "secret-b".to_string())]); + state.store.put_message(&replacement).await.unwrap(); + + let first_environment = crate::grpc::provider::resolve_provider_environment_from_records( + state.store.as_ref(), + &catalog, + &first_records, + ) + .await + .unwrap(); + assert_eq!( + first_environment.environment.get("GITHUB_TOKEN"), + Some(&"secret-a".to_string()) + ); + assert_eq!( + first_environment + .static_credential_bindings + .get("GITHUB_TOKEN") + .map(|binding| binding.credential_identity.as_str()), + Some("provider-identity-a:GITHUB_TOKEN") + ); + assert_eq!(first_environment.dynamic_credentials.len(), 1); + assert!( + first_environment + .dynamic_credentials + .values() + .all(|credential| { + credential.token_grant.as_ref().is_some_and(|grant| { + grant.token_endpoint == "https://auth.snapshot-a.example/token" + }) + }), + "dynamic grants must come from the first loaded provider snapshot" + ); + + let replacement_records = crate::grpc::provider::load_provider_environment_records( + state.store.as_ref(), + "default", + &provider_names, + ) + .await + .unwrap(); + let replacement_revision = + compute_provider_env_revision_from_records(&catalog, &replacement_records).unwrap(); + let replacement_environment = + crate::grpc::provider::resolve_provider_environment_from_records( + state.store.as_ref(), + &catalog, + &replacement_records, + ) + .await + .unwrap(); + + assert_ne!(first_revision, replacement_revision); + assert_eq!( + replacement_environment.environment.get("GITHUB_TOKEN"), + Some(&"secret-b".to_string()) + ); + assert_eq!( + replacement_environment + .static_credential_bindings + .get("GITHUB_TOKEN") + .map(|binding| binding.credential_identity.as_str()), + Some("provider-identity-b:GITHUB_TOKEN") + ); + assert_eq!(replacement_environment.dynamic_credentials.len(), 1); + assert!( + replacement_environment + .dynamic_credentials + .values() + .all(|credential| { + credential.token_grant.as_ref().is_some_and(|grant| { + grant.token_endpoint == "https://auth.snapshot-b.example/token" + }) + }), + "dynamic grants must change only after loading the replacement record" + ); + } + #[tokio::test] async fn provider_env_revision_changes_when_custom_profile_token_grant_changes() { use crate::grpc::provider::handle_update_provider_profiles; diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index a23743836a..225b0336bb 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -55,6 +55,19 @@ pub(super) struct ProviderEnvironment { pub static_credential_keys: HashSet, } +/// Immutable provider records used to build one provider-environment response. +/// +/// The persistence metadata is kept alongside the decoded provider so callers +/// can derive both the revision and credential identities from the exact same +/// records used to resolve environment values and dynamic grants. +#[derive(Debug, Clone)] +pub(super) struct ProviderEnvironmentRecord { + pub name: String, + pub object_id: String, + pub resource_version: u64, + pub provider: Provider, +} + impl ProviderEnvironment { #[cfg(test)] fn is_empty(&self) -> bool { @@ -539,13 +552,47 @@ pub(super) async fn resolve_provider_environment( resolve_provider_environment_with_catalog(store, &catalog, workspace, provider_names).await } -pub(super) async fn resolve_provider_environment_with_catalog( +#[cfg(test)] +async fn resolve_provider_environment_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider_names: &[String], ) -> Result { - if provider_names.is_empty() { + let records = load_provider_environment_records(store, workspace, provider_names).await?; + resolve_provider_environment_from_records(store, catalog, &records).await +} + +pub(super) async fn load_provider_environment_records( + store: &Store, + workspace: &str, + provider_names: &[String], +) -> Result, Status> { + let mut records = Vec::with_capacity(provider_names.len()); + for name in provider_names { + let record = store + .get_by_name(Provider::object_type(), workspace, name) + .await + .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? + .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; + let provider = Provider::decode(record.payload.as_slice()) + .map_err(|e| Status::internal(format!("failed to decode provider '{name}': {e}")))?; + records.push(ProviderEnvironmentRecord { + name: name.clone(), + object_id: record.id, + resource_version: record.resource_version, + provider, + }); + } + Ok(records) +} + +pub(super) async fn resolve_provider_environment_from_records( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], +) -> Result { + if records.is_empty() { return Ok(ProviderEnvironment::default()); } @@ -554,24 +601,12 @@ pub(super) async fn resolve_provider_environment_with_catalog( let mut static_credential_bindings = HashMap::new(); let mut static_credential_keys = HashSet::new(); let now_ms = crate::persistence::current_time_ms(); - validate_provider_environment_keys_unique_at( - store, - catalog, - workspace, - provider_names, - None, - now_ms, - ) - .await?; + validate_provider_environment_records_unique_at(store, catalog, records, now_ms).await?; let registry = openshell_providers::ProviderRegistry::new(); - for name in provider_names { - let provider = store - .get_message_by_name::(workspace, name) - .await - .map_err(|e| Status::internal(format!("failed to fetch provider '{name}': {e}")))? - .ok_or_else(|| Status::failed_precondition(format!("provider '{name}' not found")))?; - + for record in records { + let name = &record.name; + let provider = &record.provider; let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); let profile = @@ -594,7 +629,7 @@ pub(super) async fn resolve_provider_environment_with_catalog( }); for (key, value) in &provider.credentials { - if is_non_injectable_provider_credential(&provider, key) { + if is_non_injectable_provider_credential(provider, key) { warn!( provider_name = %name, key = %key, @@ -623,8 +658,7 @@ pub(super) async fn resolve_provider_environment_with_catalog( env.entry(key.clone()).or_insert_with(|| value.clone()); static_credential_keys.insert(key.clone()); if let Some(endpoints) = &profile_endpoints { - let provider_identity = provider.object_id(); - if provider_identity.is_empty() { + if record.object_id.is_empty() { return Err(Status::failed_precondition(format!( "provider '{name}' has no stable object identity" ))); @@ -633,7 +667,7 @@ pub(super) async fn resolve_provider_environment_with_catalog( key.clone(), StaticCredentialBinding { endpoints: endpoints.clone(), - credential_identity: format!("{provider_identity}:{key}"), + credential_identity: format!("{}:{key}", record.object_id), }, ); } @@ -646,52 +680,27 @@ pub(super) async fn resolve_provider_environment_with_catalog( } } - registry.inject_env(&provider, &mut env); + registry.inject_env(provider, &mut env); } Ok(ProviderEnvironment { environment: env, credential_expires_at_ms: expires, - dynamic_credentials: resolve_dynamic_credentials_with_catalog( - store, - catalog, - workspace, - provider_names, - ) - .await?, + dynamic_credentials: resolve_dynamic_credentials_from_records(catalog, records), static_credential_bindings, static_credential_keys, }) } -/// Resolve dynamic credentials (token grants) from provider profiles. -/// -/// Returns a map of endpoint-bound keys to credential metadata for credentials -/// that have `token_grant` configuration. Keys are internal supervisor metadata: -/// host, port, endpoint path, and provider credential identity. -pub(super) async fn resolve_dynamic_credentials_with_catalog( - store: &Store, +/// Resolve dynamic credentials (token grants) from the same records used for +/// the provider-environment revision and static credential bindings. +fn resolve_dynamic_credentials_from_records( catalog: &EffectiveProviderProfileCatalog, - workspace: &str, - provider_names: &[String], -) -> Result, Status> { - if provider_names.is_empty() { - return Ok(HashMap::new()); - } - + records: &[ProviderEnvironmentRecord], +) -> HashMap { let mut dynamic_creds = HashMap::new(); - - for provider_name in provider_names { - let provider = store - .get_message_by_name::(workspace, provider_name) - .await - .map_err(|e| { - Status::internal(format!("failed to fetch provider '{provider_name}': {e}")) - })? - .ok_or_else(|| { - Status::failed_precondition(format!("provider '{provider_name}' not found")) - })?; - + for record in records { + let provider = &record.provider; let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); let Some(profile) = @@ -699,15 +708,13 @@ pub(super) async fn resolve_dynamic_credentials_with_catalog( else { continue; }; - insert_dynamic_credentials_for_profile( &mut dynamic_creds, &profile.to_proto(), - provider_name, + &record.name, ); } - - Ok(dynamic_creds) + dynamic_creds } fn insert_dynamic_credentials_for_profile( @@ -1113,6 +1120,43 @@ async fn validate_provider_environment_keys_unique_at( Ok(()) } +async fn validate_provider_environment_records_unique_at( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], + now_ms: i64, +) -> Result<(), Status> { + let mut seen = HashMap::::new(); + let mut dynamic_bindings = Vec::new(); + for record in records { + let provider = &record.provider; + for key in active_provider_environment_keys_for_identity( + store, + provider, + &record.object_id, + now_ms, + ) + .await? + { + if let Some(first_provider) = seen.get(&key) { + if first_provider != &record.name { + return Err(Status::failed_precondition(format!( + "credential env key '{key}' is provided by both provider '{first_provider}' and provider '{}'; use provider-specific env names", + record.name + ))); + } + } else { + seen.insert(key, record.name.clone()); + } + } + dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( + catalog, provider, + )); + } + validate_dynamic_token_grant_bindings_unambiguous(&dynamic_bindings)?; + Ok(()) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct DynamicTokenGrantBinding { provider_name: String, @@ -1270,11 +1314,21 @@ async fn active_provider_environment_keys( store: &Store, provider: &Provider, now_ms: i64, +) -> Result, Status> { + active_provider_environment_keys_for_identity(store, provider, provider.object_id(), now_ms) + .await +} + +async fn active_provider_environment_keys_for_identity( + store: &Store, + provider: &Provider, + provider_identity: &str, + now_ms: i64, ) -> Result, Status> { let mut keys = active_provider_credential_keys(provider, now_ms); - if !provider.object_id().is_empty() { + if !provider_identity.is_empty() { for state in - crate::provider_refresh::list_refresh_states_for_provider(store, provider.object_id()) + crate::provider_refresh::list_refresh_states_for_provider(store, provider_identity) .await? { // The primary key plus every co-minted output key this refresh owns, From e897bcf034085b17629d8f847340c339abe4770b Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:04:05 -0700 Subject: [PATCH 19/32] test(e2e): include authority port in query proxy requests Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- e2e/python/test_sandbox_policy.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/e2e/python/test_sandbox_policy.py b/e2e/python/test_sandbox_policy.py index 82e32a9b34..573aebf06e 100644 --- a/e2e/python/test_sandbox_policy.py +++ b/e2e/python/test_sandbox_policy.py @@ -319,7 +319,11 @@ def log_message(self, *args): {"connect_status": connect_resp.strip(), "http_status": 0} ) - request = f"{method} {path} HTTP/1.1\r\nHost: {target_host}\r\nConnection: close\r\n\r\n" + request = ( + f"{method} {path} HTTP/1.1\r\n" + f"Host: {target_host}:{target_port}\r\n" + "Connection: close\r\n\r\n" + ) conn.sendall(request.encode()) data = b"" From c6b5e67083a9a6fd07001121d93ade9916609bf0 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:02:08 -0700 Subject: [PATCH 20/32] fix(credentials): close credential revocation gaps Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/provider_credentials.rs | 115 ++++++- crates/openshell-sandbox/src/lib.rs | 8 +- .../src/l7/relay.rs | 235 +++++++++++-- .../src/l7/rest.rs | 39 +++ .../openshell-supervisor-network/src/proxy.rs | 314 +++++++++++++++--- .../src/proxy/relay.rs | 9 + 6 files changed, 648 insertions(+), 72 deletions(-) diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index f4e201c992..87b2c3fc4c 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -28,6 +28,7 @@ struct ProviderCredentialStateInner { current_resolver: Option>, combined_resolver: Option>, suppressed_keys: HashSet, + non_secret_environment_keys: HashSet, static_credential_bindings: HashMap, known_static_credential_keys: HashSet, static_credential_identity_epochs: HashMap, @@ -86,6 +87,7 @@ impl ProviderCredentialState { current_resolver, combined_resolver, suppressed_keys: HashSet::new(), + non_secret_environment_keys: HashSet::new(), static_credential_bindings: HashMap::new(), known_static_credential_keys: HashSet::new(), static_credential_identity_epochs: HashMap::new(), @@ -121,6 +123,7 @@ impl ProviderCredentialState { revision, &static_credential_bindings, ); + inner.non_secret_environment_keys = non_secret_environment_keys.into_iter().collect(); inner.static_credential_bindings = static_credential_bindings; } Ok(state) @@ -148,6 +151,7 @@ impl ProviderCredentialState { current_resolver: None, combined_resolver: None, suppressed_keys: HashSet::new(), + non_secret_environment_keys: HashSet::new(), static_credential_bindings: HashMap::new(), known_static_credential_keys: HashSet::new(), static_credential_identity_epochs: HashMap::new(), @@ -183,6 +187,7 @@ impl ProviderCredentialState { inner.generations.clear(); inner.current_resolver = None; inner.combined_resolver = None; + inner.non_secret_environment_keys.clear(); inner.static_credential_bindings.clear(); inner.known_static_credential_keys.clear(); inner.static_credential_identity_epochs.clear(); @@ -302,10 +307,13 @@ impl ProviderCredentialState { .expect("provider credential state poisoned"); let mut env = inner.current.child_env.clone(); - let has_gcp_metadata = env.contains_key("GCE_METADATA_HOST"); + let has_gcp_metadata = env.contains_key("GCE_METADATA_HOST") + && inner + .non_secret_environment_keys + .contains("GCE_METADATA_HOST"); let has_gcp_config = google_cloud::STATIC_CONFIG_KEYS .iter() - .any(|k| env.contains_key(*k)); + .any(|key| env.contains_key(*key) && inner.non_secret_environment_keys.contains(*key)); if !has_gcp_metadata && !has_gcp_config { return env; @@ -335,7 +343,10 @@ impl ProviderCredentialState { // Un-placeholderize non-secret config vars so SDKs can read them // at process startup before any HTTP flows through the proxy. if let Some(ref resolver) = inner.combined_resolver { - for key in google_cloud::STATIC_CONFIG_KEYS { + for key in google_cloud::STATIC_CONFIG_KEYS + .iter() + .filter(|key| inner.non_secret_environment_keys.contains(**key)) + { let placeholder = crate::secrets::placeholder_for_env_key(key); if let Some(value) = resolver.resolve_placeholder(&placeholder) { env.insert(key.to_string(), value.to_string()); @@ -416,6 +427,7 @@ impl ProviderCredentialState { } inner.combined_resolver = merge_resolvers(&inner.generations, inner.current_resolver.as_ref()); + inner.non_secret_environment_keys.clear(); inner.current.child_env.len() } @@ -478,6 +490,7 @@ impl ProviderCredentialState { revision, &static_credential_bindings, ); + inner.non_secret_environment_keys = non_secret_environment_keys.into_iter().collect(); inner.static_credential_bindings = static_credential_bindings; Ok(inner.current.child_env.len()) } @@ -509,6 +522,7 @@ impl ProviderCredentialState { inner.generations.clear(); inner.current_resolver = None; inner.combined_resolver = None; + inner.non_secret_environment_keys.clear(); inner.static_credential_bindings.clear(); inner.static_credential_identity_epochs.clear(); } @@ -1052,7 +1066,7 @@ mod tests { #[test] fn child_env_with_gcp_resolved_overrides_gcp_static_vars() { - let state = ProviderCredentialState::from_environment( + let state = ProviderCredentialState::from_bound_environment( 1, HashMap::from([ ("GCE_METADATA_HOST".to_string(), "marker".to_string()), @@ -1065,7 +1079,17 @@ mod tests { ]), HashMap::new(), HashMap::new(), - ); + HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + binding("oauth2.googleapis.com", 443, "/**"), + )]), + vec![ + "GCE_METADATA_HOST".to_string(), + "GCP_PROJECT_ID".to_string(), + "CLOUD_ML_REGION".to_string(), + ], + ) + .expect("classified GCP environment"); let env = state.child_env_with_gcp_resolved(); assert_eq!( @@ -1096,7 +1120,7 @@ mod tests { #[test] fn child_env_with_gcp_resolved_handles_missing_config_keys() { - let state = ProviderCredentialState::from_environment( + let state = ProviderCredentialState::from_bound_environment( 1, HashMap::from([ ("GCE_METADATA_HOST".to_string(), "marker".to_string()), @@ -1104,7 +1128,13 @@ mod tests { ]), HashMap::new(), HashMap::new(), - ); + HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + binding("oauth2.googleapis.com", 443, "/**"), + )]), + vec!["GCE_METADATA_HOST".to_string()], + ) + .expect("classified GCP environment"); let env = state.child_env_with_gcp_resolved(); assert_eq!( @@ -1222,7 +1252,7 @@ mod tests { #[test] fn child_env_with_gcp_resolved_resolves_vertex_vars_without_metadata_host() { - let state = ProviderCredentialState::from_environment( + let state = ProviderCredentialState::from_bound_environment( 1, HashMap::from([ ("GOOSE_PROVIDER".to_string(), "gcp_vertex_ai".to_string()), @@ -1234,7 +1264,14 @@ mod tests { ]), HashMap::new(), HashMap::new(), - ); + HashMap::new(), + vec![ + "GOOSE_PROVIDER".to_string(), + "ANTHROPIC_VERTEX_PROJECT_ID".to_string(), + "VERTEX_LOCATION".to_string(), + ], + ) + .expect("classified Vertex environment"); let env = state.child_env_with_gcp_resolved(); assert_eq!( env.get("GOOSE_PROVIDER").map(String::as_str), @@ -1255,6 +1292,66 @@ mod tests { ); } + #[test] + fn child_env_with_gcp_resolved_only_unwraps_explicitly_non_secret_config() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([ + ( + "GOOGLE_CLOUD_PROJECT".to_string(), + "initial-project-config".to_string(), + ), + ( + "GCP_PROJECT_ID".to_string(), + "visible-project-config".to_string(), + ), + ]), + HashMap::new(), + HashMap::new(), + HashMap::new(), + vec![ + "GOOGLE_CLOUD_PROJECT".to_string(), + "GCP_PROJECT_ID".to_string(), + ], + ) + .expect("classified GCP environment"); + + state + .install_bound_environment( + 2, + HashMap::from([ + ( + "GOOGLE_CLOUD_PROJECT".to_string(), + "bound-project-secret".to_string(), + ), + ( + "GCP_PROJECT_ID".to_string(), + "visible-project-config".to_string(), + ), + ]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "GOOGLE_CLOUD_PROJECT".to_string(), + binding("example.googleapis.com", 443, "/**"), + )]), + vec!["GCP_PROJECT_ID".to_string()], + ) + .expect("refreshed GCP environment"); + + let env = state.child_env_with_gcp_resolved(); + assert_eq!( + env.get("GCP_PROJECT_ID").map(String::as_str), + Some("visible-project-config"), + "explicitly non-secret GCP config should be visible to the workload" + ); + assert_eq!( + env.get("GOOGLE_CLOUD_PROJECT").map(String::as_str), + Some("openshell:resolve:env:v2_GOOGLE_CLOUD_PROJECT"), + "a reserved GCP name classified as a bound credential must stay placeholderized" + ); + } + #[test] fn suppressed_keys_survive_install_environment() { let state = ProviderCredentialState::from_environment( diff --git a/crates/openshell-sandbox/src/lib.rs b/crates/openshell-sandbox/src/lib.rs index a996fbfe21..b49db89e47 100644 --- a/crates/openshell-sandbox/src/lib.rs +++ b/crates/openshell-sandbox/src/lib.rs @@ -310,7 +310,7 @@ pub async fn run_sandbox( .status(StatusId::Failure) .state(StateId::Disabled, "fail_closed") .message(format!( - "Rejected provider environment bindings; no provider credentials are active: {error}" + "Rejected provider environment bindings; static provider credentials were revoked; fetched dynamic token grants remain active: {error}" )) .build() ); @@ -3042,7 +3042,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { .status(StatusId::Failure) .state(StateId::Disabled, "fail_closed") .message(format!( - "Rejected provider environment refresh; previous provider credentials are not active: {error}" + "Rejected provider environment refresh; static provider credentials were revoked; fetched dynamic token grants remain active: {error}" )) .build() ); @@ -3076,7 +3076,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { warn!( error = %e, provider_env_revision = result.provider_env_revision, - "Settings poll: failed to refresh provider environment; previous provider credentials are not active" + "Settings poll: failed to refresh provider environment; static provider credentials were revoked; previous dynamic token grants remain active" ); ocsf_emit!( ConfigStateChangeBuilder::new(ocsf_ctx()) @@ -3084,7 +3084,7 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> { .status(StatusId::Failure) .state(StateId::Disabled, "fail_closed") .message( - "Provider environment refresh failed; previous provider credentials are not active" + "Provider environment refresh failed; static provider credentials were revoked; previous dynamic token grants remain active" ) .build() ); diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 089c5e67e1..3a18396cfc 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -58,6 +58,10 @@ pub struct L7EvalContext { /// Live provider state used to scope static credentials to each request. pub(crate) provider_credentials: Option, + /// Provider credential revision captured atomically with the request-scoped + /// resolver. Used to reject a request if credentials change again before + /// its first upstream write. + pub(crate) provider_credential_revision: Option, /// Anonymous activity counter channel. pub(crate) activity_tx: Option, /// Dynamic credentials (token grants) keyed by endpoint-bound provider metadata. @@ -106,14 +110,34 @@ fn scoped_context_for_request( // credential use. Clearing the resolver makes any placeholder or // signing attempt fail closed before an upstream write. scoped.secret_resolver = None; + scoped.provider_credential_revision = None; return Some(scoped); } let credentials = ctx.provider_credentials.as_ref()?; - scoped.secret_resolver = - credentials.resolver_for_endpoint(&ctx.host, ctx.port, &request.target); + let revision_before = credentials.revision(); + let resolver = credentials.resolver_for_endpoint(&ctx.host, ctx.port, &request.target); + let revision_after = credentials.revision(); + if revision_before != revision_after { + // The resolver and revision were not observed from one stable + // generation. Fail closed instead of retaining either snapshot. + scoped.secret_resolver = None; + scoped.provider_credential_revision = None; + return Some(scoped); + } + scoped.secret_resolver = resolver; + scoped.provider_credential_revision = Some(revision_before); Some(scoped) } +fn credential_generation_guard( + ctx: &L7EvalContext, +) -> Option> { + Some(crate::l7::rest::CredentialGenerationGuard::new( + ctx.provider_credentials.as_ref()?, + ctx.provider_credential_revision?, + )) +} + fn request_authority_matches_endpoint( request: &crate::l7::provider::L7Request, ctx: &L7EvalContext, @@ -570,9 +594,6 @@ where .await?; return Ok(()); }; - let scoped_ctx = scoped_context_for_request(ctx, &req); - let ctx = scoped_ctx.as_ref().unwrap_or(ctx); - if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); } @@ -752,12 +773,15 @@ where return Ok(()); } }; + let scoped_ctx = scoped_context_for_request(ctx, &req); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); let Some(outcome) = relay_http_request_with_credential_rejection( &req, client, upstream, crate::l7::rest::RelayRequestOptions { resolver: ctx.secret_resolver.as_deref(), + credential_generation: credential_generation_guard(ctx), generation_guard: Some(engine.generation_guard()), websocket_extensions: websocket_extension_mode(config), request_body_credential_rewrite: config.protocol == L7Protocol::Rest @@ -1093,9 +1117,6 @@ where reject_request_authority_mismatch(client, ctx).await?; return Ok(()); } - let scoped_ctx = scoped_context_for_request(ctx, &req); - let ctx = scoped_ctx.as_ref().unwrap_or(ctx); - if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); } @@ -1248,6 +1269,8 @@ where return Ok(()); } }; + let scoped_ctx = scoped_context_for_request(ctx, &req_with_auth); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); // Forward request to upstream and relay response let Some(outcome) = relay_http_request_with_credential_rejection( @@ -1256,6 +1279,7 @@ where upstream, crate::l7::rest::RelayRequestOptions { resolver: ctx.secret_resolver.as_deref(), + credential_generation: credential_generation_guard(ctx), generation_guard: Some(engine.generation_guard()), websocket_extensions: websocket_extension_mode(config), request_body_credential_rewrite: config.protocol == L7Protocol::Rest @@ -1399,9 +1423,6 @@ where reject_request_authority_mismatch(client, ctx).await?; return Ok(()); } - let scoped_ctx = scoped_context_for_request(ctx, &req); - let ctx = scoped_ctx.as_ref().unwrap_or(ctx); - if close_if_stale(engine.generation_guard(), ctx) { return Ok(()); } @@ -1515,6 +1536,8 @@ where return Ok(()); } }; + let scoped_ctx = scoped_context_for_request(ctx, &req); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); // Future MCP response/SSE introspection or rewrite would hook here // before returning upstream bytes. The current policy schema has no // trusted-annotations or version-profile field, so MCP responses and @@ -1526,6 +1549,7 @@ where upstream, crate::l7::rest::RelayRequestOptions { resolver: ctx.secret_resolver.as_deref(), + credential_generation: credential_generation_guard(ctx), generation_guard: Some(engine.generation_guard()), ..Default::default() }, @@ -1616,9 +1640,6 @@ where reject_request_authority_mismatch(client, ctx).await?; return Ok(()); } - let scoped_ctx = scoped_context_for_request(ctx, &req); - let ctx = scoped_ctx.as_ref().unwrap_or(ctx); - if deny_h2c_upgrade_if_requested(&req, config, ctx, client).await? { return Ok(()); } @@ -1738,12 +1759,15 @@ where return Ok(()); } }; + let scoped_ctx = scoped_context_for_request(ctx, &req); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); let Some(outcome) = relay_http_request_with_credential_rejection( &req, client, upstream, crate::l7::rest::RelayRequestOptions { resolver: ctx.secret_resolver.as_deref(), + credential_generation: credential_generation_guard(ctx), generation_guard: Some(engine.generation_guard()), ..Default::default() }, @@ -2289,10 +2313,6 @@ where reject_request_authority_mismatch(client, ctx).await?; return Ok(()); } - let scoped_ctx = scoped_context_for_request(ctx, &req); - let ctx = scoped_ctx.as_ref().unwrap_or(ctx); - let resolver = ctx.secret_resolver.as_deref(); - if close_if_stale(generation_guard, ctx) { return Ok(()); } @@ -2310,7 +2330,7 @@ where // Log for observability via OCSF HTTP Activity event. // Uses redacted_target (path only, no query params) to avoid logging secrets. - let has_creds = resolver.is_some(); + let has_creds = ctx.provider_credentials.is_some() || ctx.secret_resolver.is_some(); { let event = HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) .activity(ActivityId::Other) @@ -2388,6 +2408,9 @@ where return Ok(()); } }; + let scoped_ctx = scoped_context_for_request(ctx, &req_with_auth); + let ctx = scoped_ctx.as_ref().unwrap_or(ctx); + let resolver = ctx.secret_resolver.as_deref(); // Forward request with credential rewriting and relay the response. // relay_http_request_with_resolver handles both directions: it sends @@ -2398,6 +2421,7 @@ where upstream, crate::l7::rest::RelayRequestOptions { resolver, + credential_generation: credential_generation_guard(ctx), generation_guard: Some(generation_guard), ..Default::default() }, @@ -4172,6 +4196,179 @@ network_policies: replacement: &'static [u8], } + struct BlockingAllowService { + entered: Arc, + release: Arc, + } + + #[tonic::async_trait] + impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware + for BlockingAllowService + { + async fn describe( + &self, + _request: tonic::Request<()>, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::MiddlewareManifest { + name: "test/blocking-allow".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_body_bytes: 8192, + timeout: String::new(), + }], + }, + )) + } + + async fn validate_config( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.entered.notify_one(); + self.release.notified().await; + Ok(tonic::Response::new( + openshell_core::proto::HttpRequestResult { + decision: openshell_core::proto::Decision::Allow as i32, + ..Default::default() + }, + )) + } + } + + #[tokio::test] + async fn connect_reacquires_static_credentials_after_blocked_middleware() { + let data = r#" +network_middlewares: + blocker: + middleware: test/blocking-allow + on_error: fail_closed + endpoints: + include: ["api.example.test"] +network_policies: + rest_api: + name: rest_api + endpoints: + - host: api.example.test + port: 443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: GET + path: "/allowed" + binaries: + - { path: /usr/bin/node } +"#; + let engine = OpaEngine::from_strings(TEST_POLICY, data).unwrap(); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + engine.set_middleware_runner_for_tests(openshell_supervisor_middleware::ChainRunner::new( + Arc::new(BlockingAllowService { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }), + )); + let input = NetworkInput { + host: "api.example.test".into(), + port: 443, + binary_path: PathBuf::from("/usr/bin/node"), + binary_sha256: "unused".into(), + ancestors: vec![], + cmdline_paths: vec![], + }; + let (endpoint, generation) = engine + .query_endpoint_config_with_generation(&input) + .expect("endpoint config"); + let config = crate::l7::parse_l7_config(&endpoint.expect("REST endpoint")) + .expect("parse REST config"); + let tunnel_engine = engine.clone_engine_for_tunnel(generation).unwrap(); + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "real-secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.test".to_string(), + port: 443, + path: "/allowed".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let ctx = L7EvalContext { + host: "api.example.test".into(), + port: 443, + request_default_port: Some(443), + policy_name: "rest_api".into(), + binary_path: "/usr/bin/node".into(), + provider_credentials: Some(state.clone()), + secret_resolver: state.resolver(), + ..Default::default() + }; + 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_rest( + &config, + &tunnel_engine, + &mut relay_client, + &mut relay_upstream, + &ctx, + ) + .await + }); + + app.write_all( + b"GET /allowed HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\nConnection: close\r\n\r\n", + ) + .await + .unwrap(); + entered.notified().await; + state.revoke_static_provider_environment(2); + release.notify_one(); + + relay.await.unwrap().expect("relay should fail closed"); + drop(app); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "revoked CONNECT credential request must not reach upstream" + ); + } + #[tonic::async_trait] impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware for BodyReplacingService diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index cc3ecaf147..31386e983b 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -717,6 +717,7 @@ where upstream, RelayRequestOptions { resolver, + credential_generation: None, generation_guard, websocket_extensions: WebSocketExtensionMode::Preserve, request_body_credential_rewrite: false, @@ -740,6 +741,7 @@ pub(crate) enum WebSocketExtensionMode { #[derive(Clone, Copy, Default)] pub(crate) struct RelayRequestOptions<'a> { pub(crate) resolver: Option<&'a SecretResolver>, + pub(crate) credential_generation: Option>, pub(crate) generation_guard: Option<&'a PolicyGenerationGuard>, pub(crate) websocket_extensions: WebSocketExtensionMode, pub(crate) request_body_credential_rewrite: bool, @@ -750,6 +752,38 @@ pub(crate) struct RelayRequestOptions<'a> { pub(crate) port: u16, } +#[derive(Clone, Copy)] +pub(crate) struct CredentialGenerationGuard<'a> { + state: &'a openshell_core::provider_credentials::ProviderCredentialState, + revision: u64, +} + +impl<'a> CredentialGenerationGuard<'a> { + pub(crate) fn new( + state: &'a openshell_core::provider_credentials::ProviderCredentialState, + revision: u64, + ) -> Self { + Self { state, revision } + } + + pub(crate) fn ensure_current(self) -> Result<()> { + if self.state.revision() == self.revision { + Ok(()) + } else { + Err(miette!( + "provider credential generation changed before upstream write" + )) + } + } +} + +fn ensure_credential_generation_current(options: RelayRequestOptions<'_>) -> Result<()> { + if let Some(guard) = options.credential_generation { + guard.ensure_current()?; + } + Ok(()) +} + pub(crate) async fn relay_http_request_with_options_guarded( req: &L7Request, client: &mut C, @@ -760,6 +794,7 @@ where C: AsyncRead + AsyncWrite + Unpin, U: AsyncRead + AsyncWrite + Unpin, { + ensure_credential_generation_current(options)?; let header_end = req .raw_header .windows(4) @@ -938,6 +973,7 @@ where secret_key, session_token, )?; + ensure_credential_generation_current(options)?; upstream.write_all(&signed).await.into_diagnostic()?; } else { // Sign headers only, stream body through. @@ -957,6 +993,7 @@ where session_token, signable_body, )?; + ensure_credential_generation_current(options)?; upstream .write_all(&signed_headers) .await @@ -1040,11 +1077,13 @@ where 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 { + ensure_credential_generation_current(options)?; upstream .write_all(&rewrite_result.rewritten) .await diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 31e2d40635..1da93814a2 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3612,6 +3612,7 @@ fn forward_telemetry_path(target_uri: &str) -> String { .unwrap_or_else(|_| "/[INVALID_REQUEST_TARGET]".to_string()) } +#[cfg(test)] fn endpoint_secret_resolver( provider_credentials: Option<&ProviderCredentialState>, fallback: Option>, @@ -3619,9 +3620,41 @@ fn endpoint_secret_resolver( port: u16, canonical_path: &str, ) -> Option> { - provider_credentials.map_or(fallback, |credentials| { - credentials.resolver_for_endpoint(host, port, canonical_path) - }) + endpoint_credentials_for_request(provider_credentials, fallback, host, port, canonical_path) + .resolver +} + +struct ForwardEndpointCredentials { + resolver: Option>, + revision: Option, +} + +fn endpoint_credentials_for_request( + provider_credentials: Option<&ProviderCredentialState>, + fallback: Option>, + host: &str, + port: u16, + canonical_path: &str, +) -> ForwardEndpointCredentials { + let Some(credentials) = provider_credentials else { + return ForwardEndpointCredentials { + resolver: fallback, + revision: None, + }; + }; + let revision_before = credentials.revision(); + let resolver = credentials.resolver_for_endpoint(host, port, canonical_path); + let revision_after = credentials.revision(); + if revision_before != revision_after { + return ForwardEndpointCredentials { + resolver: None, + revision: None, + }; + } + ForwardEndpointCredentials { + resolver, + revision: Some(revision_before), + } } struct PreparedForwardTarget { @@ -3629,16 +3662,11 @@ struct PreparedForwardTarget { raw_query: Option, upstream_target: String, telemetry_path: String, - secret_resolver: Option>, } fn prepare_forward_target( target: &str, canonicalize_options: crate::l7::path::CanonicalizeOptions, - provider_credentials: Option<&ProviderCredentialState>, - fallback: Option>, - host: &str, - port: u16, ) -> Result { let (canonical, raw_query) = crate::l7::path::canonicalize_request_target(target, &canonicalize_options)?; @@ -3651,15 +3679,11 @@ fn prepare_forward_target( || canonical.path.clone(), |query| format!("{}?{query}", canonical.path), ); - let secret_resolver = - endpoint_secret_resolver(provider_credentials, fallback, host, port, &canonical.path); - Ok(PreparedForwardTarget { canonical_path: canonical.path, raw_query, upstream_target, telemetry_path, - secret_resolver, }) } @@ -3857,18 +3881,16 @@ fn rewrite_forward_request( } // Fail-closed: scan for any remaining unresolved placeholders - if secret_resolver.is_some() { - let scan_end = if request_body_credential_rewrite { - rewritten_header_end - } else { - output.len() - }; - let output_str = String::from_utf8_lossy(&output[..scan_end]); - if output_str.contains(secrets::PLACEHOLDER_PREFIX_PUBLIC) - || output_str.contains(secrets::PROVIDER_ALIAS_MARKER_PUBLIC) - { - return Err(secrets::UnresolvedPlaceholderError::unavailable("header")); - } + let scan_end = if request_body_credential_rewrite { + rewritten_header_end + } else { + output.len() + }; + let output_str = String::from_utf8_lossy(&output[..scan_end]); + if output_str.contains(secrets::PLACEHOLDER_PREFIX_PUBLIC) + || output_str.contains(secrets::PROVIDER_ALIAS_MARKER_PUBLIC) + { + return Err(secrets::UnresolvedPlaceholderError::unavailable("header")); } Ok(output) @@ -3936,6 +3958,7 @@ fn complete_chunked_body_prefix_len(bytes: &[u8]) -> Option { struct ForwardRelayOptions<'a> { generation_guard: &'a PolicyGenerationGuard, + credential_generation: Option>, websocket_extensions: crate::l7::rest::WebSocketExtensionMode, secret_resolver: Option<&'a SecretResolver>, request_body_credential_rewrite: bool, @@ -3979,6 +4002,7 @@ where upstream, crate::l7::rest::RelayRequestOptions { resolver: options.secret_resolver, + credential_generation: options.credential_generation, generation_guard: Some(options.generation_guard), websocket_extensions: options.websocket_extensions, request_body_credential_rewrite: options.request_body_credential_rewrite, @@ -4294,14 +4318,7 @@ async fn handle_forward_proxy( }), ..Default::default() }; - let prepared_target = match prepare_forward_target( - &path, - canonicalize_options, - provider_credentials.as_ref(), - secret_resolver, - &host_lc, - port, - ) { + let prepared_target = match prepare_forward_target(&path, canonicalize_options) { Ok(prepared) => prepared, Err(error) => { let event = NetworkActivityBuilder::new(openshell_ocsf::ctx::ctx()) @@ -4337,11 +4354,10 @@ async fn handle_forward_proxy( .map_or_else(std::collections::HashMap::new, |query| { crate::l7::rest::parse_query_params(query).unwrap_or_default() }); - let secret_resolver = prepared_target.secret_resolver; let mut l7_ctx = relay::http_context( &decision, provider_credentials, - secret_resolver.clone(), + secret_resolver, activity_tx.cloned(), dynamic_credentials.clone(), agent_proposals, @@ -4979,6 +4995,30 @@ async fn handle_forward_proxy( return Ok(()); } }; + // Static credentials are intentionally acquired only after every + // asynchronous admission step. Holding an endpoint-scoped resolver across + // middleware or token-grant awaits would let a revoked generation reach + // the upstream. + let endpoint_credentials = endpoint_credentials_for_request( + l7_ctx.provider_credentials.as_ref(), + l7_ctx.secret_resolver.clone(), + &host_lc, + port, + &path, + ); + let secret_resolver = endpoint_credentials.resolver; + let credential_generation = match ( + l7_ctx.provider_credentials.as_ref(), + endpoint_credentials.revision, + ) { + (Some(state), Some(revision)) => Some(crate::l7::rest::CredentialGenerationGuard::new( + state, revision, + )), + _ => None, + }; + if let Some(guard) = credential_generation { + guard.ensure_current()?; + } // 9. Rewrite request and forward to upstream let rewritten = match rewrite_forward_request( @@ -5066,6 +5106,7 @@ async fn handle_forward_proxy( &mut upstream, ForwardRelayOptions { generation_guard: &forward_generation_guard, + credential_generation, websocket_extensions, secret_resolver: secret_resolver.as_deref(), request_body_credential_rewrite, @@ -5356,6 +5397,71 @@ mod tests { use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; use tokio::net::{TcpListener, TcpStream}; + struct BlockingForwardMiddleware { + entered: Arc, + release: Arc, + } + + #[tonic::async_trait] + impl openshell_core::proto::middleware::v1::supervisor_middleware_server::SupervisorMiddleware + for BlockingForwardMiddleware + { + async fn describe( + &self, + _request: tonic::Request<()>, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::MiddlewareManifest { + name: "test/blocking-forward".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_body_bytes: 8192, + timeout: String::new(), + }], + }, + )) + } + + async fn validate_config( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + Ok(tonic::Response::new( + openshell_core::proto::ValidateConfigResponse { + valid: true, + reason: String::new(), + }, + )) + } + + async fn evaluate_http_request( + &self, + _request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.entered.notify_one(); + self.release.notified().await; + Ok(tonic::Response::new( + openshell_core::proto::HttpRequestResult { + decision: openshell_core::proto::Decision::Allow as i32, + ..Default::default() + }, + )) + } + } + async fn drive_raw_request_through_handler(raw: Vec) -> Vec { let policy = include_str!("../data/sandbox-policy.rego"); let data = r#" @@ -6050,6 +6156,127 @@ network_policies: } } + #[tokio::test] + async fn forward_reacquires_static_credentials_after_blocked_middleware() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + let policy = include_str!("../data/sandbox-policy.rego"); + let engine = OpaEngine::from_strings(policy, "network_policies: {}\n").unwrap(); + let guard = engine + .generation_guard(engine.current_generation()) + .expect("generation guard"); + let entered = Arc::new(tokio::sync::Notify::new()); + let release = Arc::new(tokio::sync::Notify::new()); + let runner = openshell_supervisor_middleware::ChainRunner::new(Arc::new( + BlockingForwardMiddleware { + entered: Arc::clone(&entered), + release: Arc::clone(&release), + }, + )); + let state = ProviderCredentialState::from_bound_environment( + 1, + TestHashMap::from([("API_TOKEN".to_string(), "real-secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.test".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + let ctx = crate::l7::relay::L7EvalContext { + host: "api.example.test".into(), + port: 80, + request_default_port: Some(80), + policy_name: "forward".into(), + binary_path: "/usr/bin/node".into(), + provider_credentials: Some(state.clone()), + secret_resolver: state.resolver(), + ..Default::default() + }; + let raw = b"GET http://api.example.test/allowed/../outside HTTP/1.1\r\nHost: api.example.test\r\nAuthorization: Bearer openshell:resolve:env:v1_API_TOKEN\r\n\r\n"; + let prepared = prepare_forward_target( + "/allowed/../outside", + crate::l7::path::CanonicalizeOptions::default(), + ) + .expect("canonical target"); + assert_eq!(prepared.canonical_path, "/outside"); + let request = crate::l7::rest::request_from_buffered_http( + "GET", + &prepared.canonical_path, + &prepared.upstream_target, + canonicalize_forward_host_header(raw, "api.example.test").unwrap(), + ) + .unwrap(); + let pipeline = ForwardMiddlewarePipeline { + ctx: &ctx, + scheme: "http", + runner: &runner, + generation_guard: &guard, + l7_reevaluation: None, + }; + let chain = vec![openshell_supervisor_middleware::ChainEntry { + name: "blocker".into(), + implementation: "test/blocking-forward".into(), + order: 0, + config: prost_types::Struct::default(), + on_error: openshell_supervisor_middleware::OnError::FailClosed, + }]; + let (_app, mut client) = tokio::io::duplex(8192); + let revoke = async { + entered.notified().await; + state.revoke_static_provider_environment(2); + release.notify_one(); + }; + let (outcome, ()) = tokio::join!(pipeline.apply(request, &mut client, chain), revoke); + let request = match outcome.expect("middleware pipeline") { + crate::l7::middleware::MiddlewareApplyResult::Allowed(request) => request, + crate::l7::middleware::MiddlewareApplyResult::Denied { .. } => { + panic!("blocking middleware should allow after release") + } + }; + + let credentials = endpoint_credentials_for_request( + ctx.provider_credentials.as_ref(), + ctx.secret_resolver.clone(), + &ctx.host, + ctx.port, + &prepared.canonical_path, + ); + assert!( + credentials.resolver.is_none(), + "revoked live state must supersede the connection-open resolver" + ); + let rewrite = rewrite_forward_request( + &request.raw_header, + request.raw_header.len(), + &prepared.upstream_target, + "api.example.test", + credentials.resolver.as_deref(), + false, + ); + assert!( + rewrite.is_err(), + "revoked credential placeholder must fail before upstream relay" + ); + + let (proxy_upstream, mut upstream) = tokio::io::duplex(8192); + drop(proxy_upstream); + let mut forwarded = Vec::new(); + upstream.read_to_end(&mut forwarded).await.unwrap(); + assert!( + forwarded.is_empty(), + "revoked forward credential request must not reach upstream" + ); + } + #[test] fn forward_l7_allowed_activity_is_deferred_until_after_ssrf() { let (tx, mut rx) = mpsc::channel(4); @@ -6193,6 +6420,7 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, + credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: resolver, request_body_credential_rewrite, @@ -6382,6 +6610,7 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: guard, + credential_generation: None, websocket_extensions, secret_resolver: None, request_body_credential_rewrite: false, @@ -8619,17 +8848,18 @@ network_policies: let placeholder = "openshell:resolve:env:v1_API_TOKEN"; for raw_path in ["/allowed/../outside", "/allowed/%2e%2e/outside"] { - let prepared = prepare_forward_target( - raw_path, - crate::l7::path::CanonicalizeOptions::default(), + let prepared = + prepare_forward_target(raw_path, crate::l7::path::CanonicalizeOptions::default()) + .expect("prepared target"); + assert_eq!(prepared.canonical_path, "/outside"); + let resolver = endpoint_secret_resolver( Some(&state), state.resolver(), "api.example.com", 80, + &prepared.canonical_path, ) - .expect("prepared target"); - assert_eq!(prepared.canonical_path, "/outside"); - let resolver = prepared.secret_resolver.expect("scoped resolver"); + .expect("scoped resolver"); let error = resolver .rewrite_header_value(placeholder) .expect_err("canonical endpoint must deny traversal"); @@ -9382,6 +9612,7 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, + credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: Some(&resolver), request_body_credential_rewrite: true, @@ -9460,6 +9691,7 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, + credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: Some(&resolver), request_body_credential_rewrite: false, @@ -9547,6 +9779,7 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, + credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, @@ -9595,6 +9828,7 @@ network_policies: &mut proxy_to_upstream, ForwardRelayOptions { generation_guard: &guard, + credential_generation: None, websocket_extensions: crate::l7::rest::WebSocketExtensionMode::Preserve, secret_resolver: None, request_body_credential_rewrite: false, diff --git a/crates/openshell-supervisor-network/src/proxy/relay.rs b/crates/openshell-supervisor-network/src/proxy/relay.rs index d3dbda0767..314ab53312 100644 --- a/crates/openshell-supervisor-network/src/proxy/relay.rs +++ b/crates/openshell-supervisor-network/src/proxy/relay.rs @@ -46,6 +46,13 @@ pub(super) fn http_context( dynamic_credentials: Option, agent_proposals: openshell_core::proposals::AgentProposals, ) -> L7EvalContext { + // Provider-backed credentials must be acquired from the live state for + // each request after middleware/token-grant awaits. Keep only the legacy + // resolver fallback when no live provider state exists. + let secret_resolver = provider_credentials + .is_none() + .then_some(secret_resolver) + .flatten(); let policy_name = match &decision.action { NetworkAction::Allow { matched_policy } => matched_policy.clone().unwrap_or_default(), NetworkAction::Deny { .. } => String::new(), @@ -73,6 +80,7 @@ pub(super) fn http_context( .collect(), secret_resolver, provider_credentials, + provider_credential_revision: None, activity_tx, dynamic_credentials: dynamic_credentials.clone(), token_grant_resolver: dynamic_credentials @@ -343,6 +351,7 @@ mod tests { cmdline_paths: vec![], secret_resolver: None, provider_credentials: None, + provider_credential_revision: None, activity_tx: None, dynamic_credentials: None, token_grant_resolver: None, From feec81dd8d67be710b6a5ef6a56ab116aa3c809f Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:02:41 -0700 Subject: [PATCH 21/32] docs(proxy): explain authority mismatch diagnostics Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .agents/skills/debug-inference/SKILL.md | 8 ++++++++ .agents/skills/openshell-cli/SKILL.md | 7 +++++++ docs/sandboxes/policies.mdx | 1 + 3 files changed, 16 insertions(+) diff --git a/.agents/skills/debug-inference/SKILL.md b/.agents/skills/debug-inference/SKILL.md index 71da517b28..08462a4751 100644 --- a/.agents/skills/debug-inference/SKILL.md +++ b/.agents/skills/debug-inference/SKILL.md @@ -252,6 +252,13 @@ the mismatch: policy admission and credential endpoint authorization are separate checks, and the provider profile should authorize only intended credential recipients. +If the response reports `request_authority_mismatch`, compare the HTTP request +authority with the CONNECT tunnel endpoint. The host and effective port must +match. For a tunnel to `api.example.com:8443`, send +`Host: api.example.com:8443`; omitting the non-default port makes the request +authority use the transport default and OpenShell rejects it. An absolute-form +request target must use the same authority. + Attach or detach a provider on an existing sandbox with `openshell sandbox provider attach ` and `openshell sandbox provider detach `. Use the `generate-sandbox-policy` skill when the user needs help authoring policy YAML. @@ -362,6 +369,7 @@ Both commands should return the upstream model list. | `inference.local` works but a platform function fails | User route is configured but `sandbox-system` is missing or wrong | `openshell inference get --system`; configure or update with `--system`; inspect supervisor logs | | Direct call to external host is denied | Missing policy or provider attachment | Update `network_policies` and launch sandbox with the right provider | | Direct call returns `credential_endpoint_mismatch` | Attached provider profile does not authorize the request host, port, or path | Inspect the provider profile endpoints; select or update the profile only if it intentionally authorizes that recipient | +| Direct call returns `request_authority_mismatch` | HTTP authority does not match the CONNECT host and effective port | Include the explicit non-default port in `Host` and use the same authority in absolute-form targets | | SDK fails on empty auth token | Client requires a non-empty API key even though OpenShell injects the real one | Use any placeholder token such as `test` | | Upstream timeout from container to host-local backend | Host firewall or network config blocks container-to-host traffic | Allow the Docker bridge subnet to reach the inference port on the host gateway IP (see firewall fix section above) | diff --git a/.agents/skills/openshell-cli/SKILL.md b/.agents/skills/openshell-cli/SKILL.md index 9c03ed05f2..38dcba5857 100644 --- a/.agents/skills/openshell-cli/SKILL.md +++ b/.agents/skills/openshell-cli/SKILL.md @@ -125,6 +125,13 @@ when a placeholder is present but requests receive `credential_endpoint_mismatch`. A profileless static provider fails closed because the gateway cannot construct a binding. +When an inspected request receives `request_authority_mismatch`, compare its +HTTP authority with the CONNECT tunnel endpoint. The host and effective port +must match. For a tunnel to `api.example.com:8443`, send +`Host: api.example.com:8443`; `Host: api.example.com` omits the non-default +port and is rejected. An absolute-form request target must use the same +authority. + Profile-backed provider policy composition is controlled by the gateway-global `providers_v2_enabled` setting. Static credential endpoint binding remains active even when policy composition is disabled: diff --git a/docs/sandboxes/policies.mdx b/docs/sandboxes/policies.mdx index 0342803893..3f3222723b 100644 --- a/docs/sandboxes/policies.mdx +++ b/docs/sandboxes/policies.mdx @@ -545,6 +545,7 @@ When triaging denied requests, check: - Calling binary path to confirm which `binaries` entry needs to be added or adjusted. - HTTP method and path for REST endpoints, or `GET` / `WEBSOCKET_TEXT` and the upgraded request path for WebSocket endpoints, to confirm which `rules` entry needs to be added or adjusted. - `credential_endpoint_mismatch` in sandbox logs to confirm that policy admitted the request but the attached provider profile did not authorize its credential for that host, port, and path. +- `request_authority_mismatch` in the response or sandbox logs to confirm that the HTTP request authority differs from the authorized tunnel endpoint. For a CONNECT tunnel to `api.example.com:8443`, send `Host: api.example.com:8443`; omitting the non-default port makes the request authority use the transport default and OpenShell rejects it. Absolute-form request targets must use the same host and port. Then push the updated policy as described above. From 0a719e5f66e6e934e9603b269c10cb317d3bfa70 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:51:13 -0700 Subject: [PATCH 22/32] fix(credentials): enforce binding lifecycle invariants Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/provider_credentials.rs | 248 +++++++++++++++++- .../src/l7/websocket.rs | 146 +++++++++-- 2 files changed, 362 insertions(+), 32 deletions(-) diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 87b2c3fc4c..87fa13df78 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -220,22 +220,45 @@ impl ProviderCredentialState { port: u16, path: &str, ) -> Option> { + self.resolver_for_endpoint_with_revision(host, port, path).0 + } + + /// Resolve provider placeholders for one endpoint and return the provider + /// revision observed from the same locked state snapshot. + /// + /// Callers that materialize credential-bearing requests asynchronously + /// use the revision to reject stale material immediately before its first + /// upstream write. + #[must_use] + pub fn resolver_for_endpoint_with_revision( + &self, + host: &str, + port: u16, + path: &str, + ) -> (Option>, u64) { let inner = self .inner .read() .expect("provider credential state poisoned"); + let revision = inner.current.revision; let request_path = path.split_once('?').map_or(path, |(path, _)| path); + let Ok(request_path) = crate::secrets::redact_target_for_policy(request_path) else { + // Binding authorization must not depend on real credential + // material. Malformed placeholder syntax cannot be normalized + // safely, so expose no endpoint-scoped resolver. + return (None, revision); + }; let allowed: HashSet = inner .static_credential_bindings .iter() .filter(|(_, binding)| { binding.endpoints.iter().any(|endpoint| { - static_credential_endpoint_matches(endpoint, host, port, request_path) + static_credential_endpoint_matches(endpoint, host, port, &request_path) }) }) .map(|(key, _)| key.clone()) .collect(); - inner.combined_resolver.as_ref().map(|resolver| { + let resolver = inner.combined_resolver.as_ref().map(|resolver| { let revision_fallback_allowed_revisions = inner .static_credential_identity_epochs .iter() @@ -253,7 +276,8 @@ impl ProviderCredentialState { &allowed, revision_fallback_allowed_revisions, )) - }) + }); + (resolver, revision) } #[must_use] @@ -498,7 +522,12 @@ impl ProviderCredentialState { /// Atomically remove static provider material after a failed refresh. /// /// Dynamic token grants retain their independently endpoint-bound state - /// unless the caller supplies a newer dynamic snapshot. + /// unless the caller supplies a newer dynamic snapshot. Identity-only + /// revision membership remains as a tombstone so a later successful + /// refresh can restore placeholders issued by the same provider identity. + /// With no resolver or active bindings, the tombstone cannot resolve + /// credentials while the refresh is failed. A successful empty provider + /// environment removes it through the normal epoch update path. pub fn revoke_static_provider_environment(&self, revision: u64) { self.revoke_static_provider_environment_inner(revision, None); } @@ -524,7 +553,6 @@ impl ProviderCredentialState { inner.combined_resolver = None; inner.non_secret_environment_keys.clear(); inner.static_credential_bindings.clear(); - inner.static_credential_identity_epochs.clear(); } } @@ -706,6 +734,87 @@ mod tests { } } + #[test] + fn revisioned_path_placeholder_matches_exact_redacted_binding() { + let state = ProviderCredentialState::from_bound_environment( + 7, + HashMap::from([("API_KEY".to_string(), "secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/bot[CREDENTIAL]/sendMessage"), + )]), + Vec::new(), + ) + .expect("valid bindings"); + let placeholder = "openshell:resolve:env:v7_API_KEY"; + + let resolver = state + .resolver_for_endpoint( + "api.example.com", + 443, + &format!("/bot{placeholder}/sendMessage?stream=true"), + ) + .expect("syntax-redacted path should select the binding"); + assert_eq!(resolver.resolve_placeholder(placeholder), Some("secret")); + } + + #[test] + fn provider_alias_path_placeholder_matches_glob_redacted_binding() { + let state = ProviderCredentialState::from_bound_environment( + 7, + HashMap::from([("API_KEY".to_string(), "secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/v1/*/messages"), + )]), + Vec::new(), + ) + .expect("valid bindings"); + + let resolver = state + .resolver_for_endpoint( + "api.example.com", + 443, + "/v1/vendor-OPENSHELL-RESOLVE-ENV-API_KEY/messages", + ) + .expect("syntax-redacted alias path should select the binding"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v7_API_KEY"), + Some("secret") + ); + } + + #[test] + fn malformed_path_placeholder_exposes_no_endpoint_resolver() { + let state = ProviderCredentialState::from_bound_environment( + 7, + HashMap::from([("API_KEY".to_string(), "secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("valid bindings"); + + assert!( + state + .resolver_for_endpoint( + "api.example.com", + 443, + "/v1/openshell:resolve:env:/messages", + ) + .is_none(), + "malformed placeholder syntax must fail closed before path matching" + ); + } + #[test] fn non_secret_provider_config_is_not_endpoint_scoped() { let state = ProviderCredentialState::from_bound_environment( @@ -757,6 +866,81 @@ mod tests { assert!(state.snapshot().child_env.is_empty()); assert!(state.resolver().is_none()); assert!(state.snapshot().dynamic_credentials.contains_key("dynamic")); + + state + .install_bound_environment( + 3, + HashMap::from([("API_KEY".to_string(), "recovered".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("same-identity recovery"); + + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + Some("recovered"), + "a metadata failure must not permanently strand placeholders from the same provider identity" + ); + } + + #[test] + fn failed_fetch_revokes_secrets_then_same_identity_retry_recovers_running_placeholder() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "old".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("initial bindings"); + + state.revoke_static_provider_environment(2); + + assert!( + state.resolver().is_none(), + "no static secret resolver may remain active during the failed refresh" + ); + assert!( + state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .is_none(), + "an identity tombstone must not authorize requests without active bindings and secrets" + ); + + state + .install_bound_environment( + 3, + HashMap::from([("API_KEY".to_string(), "new".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([( + "API_KEY".to_string(), + binding("api.example.com", 443, "/**"), + )]), + Vec::new(), + ) + .expect("same-identity retry"); + + let resolver = state + .resolver_for_endpoint("api.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + Some("new"), + "the running process placeholder should recover against the current same-identity secret" + ); } #[test] @@ -895,7 +1079,7 @@ mod tests { } #[test] - fn detach_then_attach_with_reused_key_cannot_resolve_detached_secret() { + fn failed_refresh_then_replacement_with_reused_key_rejects_old_placeholder() { let state = ProviderCredentialState::from_bound_environment( 1, HashMap::from([("API_KEY".to_string(), "provider-a-secret".to_string())]), @@ -931,12 +1115,60 @@ mod tests { assert_eq!( resolver.resolve_placeholder("openshell:resolve:env:API_KEY"), None, - "an identityless canonical placeholder must not cross detach and reattach" + "an identityless canonical placeholder must not cross a failed refresh into a replacement identity" ); assert_eq!( resolver.resolve_placeholder("vendor-OPENSHELL-RESOLVE-ENV-API_KEY"), None, - "an identityless provider alias must not cross detach and reattach" + "an identityless provider alias must not cross a failed refresh into a replacement identity" + ); + } + + #[test] + fn successful_empty_environment_clears_failed_refresh_identity_tombstones() { + let state = ProviderCredentialState::from_bound_environment( + 1, + HashMap::from([("API_KEY".to_string(), "provider-a-secret".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding("a.example.com", 443, "/**"))]), + Vec::new(), + ) + .expect("initial bindings"); + + state.revoke_static_provider_environment(2); + state + .install_bound_environment( + 3, + HashMap::new(), + HashMap::new(), + HashMap::new(), + HashMap::new(), + Vec::new(), + ) + .expect("successful detached environment"); + state + .install_bound_environment( + 4, + HashMap::from([("API_KEY".to_string(), "reattached".to_string())]), + HashMap::new(), + HashMap::new(), + HashMap::from([("API_KEY".to_string(), binding("a.example.com", 443, "/**"))]), + Vec::new(), + ) + .expect("reattached environment"); + + let resolver = state + .resolver_for_endpoint("a.example.com", 443, "/v1") + .expect("resolver"); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v1_API_KEY"), + None, + "a successful empty environment represents detach and must invalidate old membership" + ); + assert_eq!( + resolver.resolve_placeholder("openshell:resolve:env:v4_API_KEY"), + Some("reattached") ); } diff --git a/crates/openshell-supervisor-network/src/l7/websocket.rs b/crates/openshell-supervisor-network/src/l7/websocket.rs index 5aaf964e0e..871020dfe4 100644 --- a/crates/openshell-supervisor-network/src/l7/websocket.rs +++ b/crates/openshell-supervisor-network/src/l7/websocket.rs @@ -18,6 +18,7 @@ use openshell_ocsf::{ NetworkActivityBuilder, SeverityId, StatusId, ocsf_emit, }; use std::collections::HashMap; +use std::future::Future; use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; const MAX_TEXT_MESSAGE_BYTES: usize = 1024 * 1024; @@ -545,6 +546,36 @@ async fn relay_text_payload( port: u16, options: &RelayOptions<'_>, ) -> Result<()> { + relay_text_payload_with_before_credential_write( + writer, + frame, + payload, + force_reframe, + compressed, + host, + port, + options, + std::future::ready(()), + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn relay_text_payload_with_before_credential_write( + writer: &mut W, + frame: &FrameHeader, + payload: Vec, + force_reframe: bool, + compressed: bool, + host: &str, + port: u16, + options: &RelayOptions<'_>, + before_credential_write: F, +) -> Result<()> +where + W: AsyncWrite + Unpin, + F: Future, +{ let message_payload = if compressed { decompress_permessage_deflate(&payload)? } else { @@ -552,12 +583,17 @@ async fn relay_text_payload( }; let mut text = String::from_utf8(message_payload) .map_err(|_| miette!("websocket text message is not valid UTF-8"))?; - let live_resolver = options - .provider_credentials - .map(|credentials| credentials.resolver_for_endpoint(host, port, options.target)); + let live_resolver = options.provider_credentials.map(|credentials| { + let (resolver, revision) = + credentials.resolver_for_endpoint_with_revision(host, port, options.target); + ( + resolver, + crate::l7::rest::CredentialGenerationGuard::new(credentials, revision), + ) + }); let resolver = live_resolver .as_ref() - .map_or(options.resolver, |resolver| resolver.as_deref()); + .map_or(options.resolver, |(resolver, _)| resolver.as_deref()); let replacements = if let Some(resolver) = resolver { resolver .rewrite_websocket_text_placeholders(&mut text) @@ -598,11 +634,19 @@ async fn relay_text_payload( if replacements > 0 { emit_rewrite_event(host, port, options.policy_name, replacements); } - if compressed { - let compressed_payload = compress_permessage_deflate(text.as_bytes())?; - return write_masked_frame_with_rsv(writer, OPCODE_TEXT, 0x40, &compressed_payload).await; + let (rsv, frame_payload) = if compressed { + (0x40, compress_permessage_deflate(text.as_bytes())?) + } else { + (0, text.into_bytes()) + }; + let rewritten_frame = masked_frame_bytes(OPCODE_TEXT, rsv, &frame_payload); + if replacements > 0 { + before_credential_write.await; + if let Some((_, guard)) = live_resolver { + guard.ensure_current()?; + } } - write_masked_frame(writer, OPCODE_TEXT, text.as_bytes()).await + write_frame_bytes(writer, &rewritten_frame).await } fn inspect_websocket_text_message( @@ -889,20 +933,7 @@ where Ok(()) } -async fn write_masked_frame( - writer: &mut W, - opcode: u8, - payload: &[u8], -) -> Result<()> { - write_masked_frame_with_rsv(writer, opcode, 0, payload).await -} - -async fn write_masked_frame_with_rsv( - writer: &mut W, - opcode: u8, - rsv: u8, - payload: &[u8], -) -> Result<()> { +fn masked_frame_bytes(opcode: u8, rsv: u8, payload: &[u8]) -> Vec { let mut header = Vec::with_capacity(14); header.push(0x80 | rsv | opcode); match payload.len() { @@ -925,8 +956,12 @@ async fn write_masked_frame_with_rsv( let mut masked = payload.to_vec(); apply_mask(&mut masked, mask_key); - writer.write_all(&header).await.into_diagnostic()?; - writer.write_all(&masked).await.into_diagnostic()?; + header.extend_from_slice(&masked); + header +} + +async fn write_frame_bytes(writer: &mut W, frame: &[u8]) -> Result<()> { + writer.write_all(frame).await.into_diagnostic()?; writer.flush().await.into_diagnostic()?; Ok(()) } @@ -1421,6 +1456,69 @@ network_policies: ); } + #[tokio::test] + async fn websocket_rewrite_rejects_revocation_before_frame_write() { + let state = bound_websocket_provider_state(); + let fallback = state.resolver().expect("upgrade-time resolver"); + let placeholder = b"openshell:resolve:env:v1_DISCORD_BOT_TOKEN"; + let compressed = compress_permessage_deflate(placeholder).expect("compress placeholder"); + let frame = FrameHeader { + fin: true, + rsv: 0x40, + opcode: OPCODE_TEXT, + masked: true, + payload_len: compressed.len() as u64, + mask_key: Some([0x37, 0xfa, 0x21, 0x3d]), + raw_header: Vec::new(), + }; + let options = RelayOptions { + policy_name: "test-policy", + resolver: Some(fallback.as_ref()), + provider_credentials: Some(&state), + target: "/socket", + inspector: None, + compression: WebSocketCompression::PermessageDeflate, + }; + let reached_write = tokio::sync::Barrier::new(2); + let release_write = tokio::sync::Barrier::new(2); + let (mut relay_write, mut upstream_read) = tokio::io::duplex(4096); + + let relay = relay_text_payload_with_before_credential_write( + &mut relay_write, + &frame, + compressed, + false, + true, + "gateway.example.test", + 443, + &options, + async { + reached_write.wait().await; + release_write.wait().await; + }, + ); + let revoke = async { + reached_write.wait().await; + state.revoke_static_provider_environment(2); + release_write.wait().await; + }; + let (result, ()) = tokio::join!(relay, revoke); + assert!( + result + .as_ref() + .is_err_and(|error| error.to_string().contains("generation changed")), + "revoked credential generation must fail before the frame write: {result:?}" + ); + + drop(relay_write); + let mut output = Vec::new(); + upstream_read.read_to_end(&mut output).await.unwrap(); + assert!( + output.is_empty(), + "credential revoked before the write guard must not reach upstream" + ); + } + async fn run_client_to_server_with_graphql_policy( input: Vec, resolver: Option<&SecretResolver>, From 8f656f412351c9126349274034bcf5616499c2d7 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:51:43 -0700 Subject: [PATCH 23/32] fix(provider): reject credential config collisions Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-server/src/grpc/provider.rs | 332 +++++++++++++++++-- 1 file changed, 298 insertions(+), 34 deletions(-) diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 225b0336bb..d2958ac3e3 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -607,6 +607,7 @@ pub(super) async fn resolve_provider_environment_from_records( for record in records { let name = &record.name; let provider = &record.provider; + let mut provider_env = HashMap::new(); let profile_id = normalize_provider_type(&provider.r#type).unwrap_or(provider.r#type.as_str()); let profile = @@ -655,7 +656,7 @@ pub(super) async fn resolve_provider_environment_from_records( if expires_at_ms > 0 { expires.entry(key.clone()).or_insert(expires_at_ms); } - env.entry(key.clone()).or_insert_with(|| value.clone()); + provider_env.insert(key.clone(), value.clone()); static_credential_keys.insert(key.clone()); if let Some(endpoints) = &profile_endpoints { if record.object_id.is_empty() { @@ -680,7 +681,14 @@ pub(super) async fn resolve_provider_environment_from_records( } } - registry.inject_env(provider, &mut env); + // Build each provider's emitted environment independently so another + // provider's earlier output cannot change how this provider classifies + // or populates its own keys. Cross-provider credential/config + // collisions have already been rejected by the validation above. + registry.inject_env(provider, &mut provider_env); + for (key, value) in provider_env { + env.entry(key).or_insert(value); + } } Ok(ProviderEnvironment { @@ -1087,7 +1095,8 @@ async fn validate_provider_environment_keys_unique_at( candidate_provider: Option<&Provider>, now_ms: i64, ) -> Result<(), Status> { - let mut seen = HashMap::::new(); + let mut seen_credentials = HashMap::::new(); + let mut seen_plugin_config = HashMap::::new(); let mut dynamic_bindings = Vec::new(); for name in provider_names { let provider = match candidate_provider { @@ -1101,17 +1110,13 @@ async fn validate_provider_environment_keys_unique_at( })?, }; let provider_name = provider.object_name().to_string(); - for key in active_provider_environment_keys(store, &provider, now_ms).await? { - if let Some(first_provider) = seen.get(&key) { - if first_provider != &provider_name { - return Err(Status::failed_precondition(format!( - "credential env key '{key}' is provided by both provider '{first_provider}' and provider '{provider_name}'; use provider-specific env names" - ))); - } - } else { - seen.insert(key, provider_name.clone()); - } - } + validate_provider_environment_key_ownership( + &mut seen_credentials, + &mut seen_plugin_config, + &provider_name, + active_provider_environment_keys(store, &provider, now_ms).await?, + provider_plugin_environment_keys(&provider), + )?; dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( catalog, &provider, )); @@ -1126,29 +1131,24 @@ async fn validate_provider_environment_records_unique_at( records: &[ProviderEnvironmentRecord], now_ms: i64, ) -> Result<(), Status> { - let mut seen = HashMap::::new(); + let mut seen_credentials = HashMap::::new(); + let mut seen_plugin_config = HashMap::::new(); let mut dynamic_bindings = Vec::new(); for record in records { let provider = &record.provider; - for key in active_provider_environment_keys_for_identity( - store, - provider, - &record.object_id, - now_ms, - ) - .await? - { - if let Some(first_provider) = seen.get(&key) { - if first_provider != &record.name { - return Err(Status::failed_precondition(format!( - "credential env key '{key}' is provided by both provider '{first_provider}' and provider '{}'; use provider-specific env names", - record.name - ))); - } - } else { - seen.insert(key, record.name.clone()); - } - } + validate_provider_environment_key_ownership( + &mut seen_credentials, + &mut seen_plugin_config, + &record.name, + active_provider_environment_keys_for_identity( + store, + provider, + &record.object_id, + now_ms, + ) + .await?, + provider_plugin_environment_keys(provider), + )?; dynamic_bindings.extend(dynamic_token_grant_bindings_for_provider_with_catalog( catalog, provider, )); @@ -1157,6 +1157,67 @@ async fn validate_provider_environment_records_unique_at( Ok(()) } +fn provider_plugin_environment_keys(provider: &Provider) -> Vec { + let mut plugin_environment = HashMap::new(); + openshell_providers::ProviderRegistry::new().inject_env(provider, &mut plugin_environment); + plugin_environment.into_keys().collect() +} + +fn validate_provider_environment_key_ownership( + seen_credentials: &mut HashMap, + seen_plugin_config: &mut HashMap, + provider_name: &str, + credential_keys: Vec, + plugin_config_keys: Vec, +) -> Result<(), Status> { + for key in credential_keys { + if let Some(first_provider) = seen_credentials.get(&key) { + if first_provider != provider_name { + return Err(Status::failed_precondition(format!( + "credential env key '{key}' is provided by both provider '{first_provider}' and provider '{provider_name}'; use provider-specific env names" + ))); + } + } else { + seen_credentials.insert(key.clone(), provider_name.to_string()); + } + if let Some(config_provider) = seen_plugin_config.get(&key) + && config_provider != provider_name + { + return Err(provider_credential_config_key_collision( + &key, + provider_name, + config_provider, + )); + } + } + + for key in plugin_config_keys { + if let Some(credential_provider) = seen_credentials.get(&key) + && credential_provider != provider_name + { + return Err(provider_credential_config_key_collision( + &key, + credential_provider, + provider_name, + )); + } + seen_plugin_config + .entry(key) + .or_insert_with(|| provider_name.to_string()); + } + Ok(()) +} + +fn provider_credential_config_key_collision( + key: &str, + credential_provider: &str, + config_provider: &str, +) -> Status { + Status::failed_precondition(format!( + "credential env key '{key}' from provider '{credential_provider}' conflicts with provider-generated config from provider '{config_provider}'; use provider-specific env names" + )) +} + #[derive(Debug, Clone, PartialEq, Eq)] struct DynamicTokenGrantBinding { provider_name: String, @@ -6814,6 +6875,93 @@ mod tests { assert!(err.message().contains("provider-b")); } + #[tokio::test] + async fn provider_environment_rejects_plugin_config_credential_collision_in_both_orders() { + let store = test_store().await; + create_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "google-config".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "google-cloud".to_string(), + credentials: std::iter::once(( + "GCP_ACCESS_TOKEN".to_string(), + "google-token".to_string(), + )) + .collect(), + config: std::iter::once(("project_id".to_string(), "config-project".to_string())) + .collect(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }, + ) + .await + .unwrap(); + create_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "static-credential".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "gitlab".to_string(), + credentials: std::iter::once(( + "GCP_PROJECT_ID".to_string(), + "credential-value".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }, + ) + .await + .unwrap(); + + let attachment_orders = [ + vec!["google-config".to_string(), "static-credential".to_string()], + vec!["static-credential".to_string(), "google-config".to_string()], + ]; + let mut messages = Vec::new(); + for providers in attachment_orders { + let validation_error = + validate_provider_environment_keys_unique(&store, "default", &providers) + .await + .unwrap_err(); + assert_eq!(validation_error.code(), Code::FailedPrecondition); + + let resolution_error = resolve_provider_environment(&store, "default", &providers) + .await + .unwrap_err(); + assert_eq!(resolution_error.code(), Code::FailedPrecondition); + assert_eq!(validation_error.message(), resolution_error.message()); + assert!(resolution_error.message().contains("GCP_PROJECT_ID")); + assert!(resolution_error.message().contains("static-credential")); + assert!(resolution_error.message().contains("google-config")); + messages.push(resolution_error.message().to_string()); + } + assert_eq!( + messages[0], messages[1], + "collision rejection must not depend on attachment order" + ); + } + #[tokio::test] async fn resolve_provider_env_injects_vertex_agent_config() { let store = test_store().await; @@ -7202,6 +7350,122 @@ mod tests { assert!(err.message().contains("MS_GRAPH_ACCESS_TOKEN")); } + #[tokio::test] + async fn update_provider_rejects_plugin_config_credential_collision() { + let store = test_store().await; + create_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "google-config".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "google-cloud".to_string(), + credentials: std::iter::once(( + "GCP_ACCESS_TOKEN".to_string(), + "google-token".to_string(), + )) + .collect(), + config: std::iter::once(("project_id".to_string(), "config-project".to_string())) + .collect(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }, + ) + .await + .unwrap(); + create_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "credential-provider".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: "gitlab".to_string(), + credentials: std::iter::once(( + "GITLAB_TOKEN".to_string(), + "gitlab-token".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }, + ) + .await + .unwrap(); + store + .put_message(&Sandbox { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "sandbox-plugin-config-collision".to_string(), + name: "plugin-config-collision".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + spec: Some(SandboxSpec { + providers: vec![ + "google-config".to_string(), + "credential-provider".to_string(), + ], + ..SandboxSpec::default() + }), + ..Default::default() + }) + .await + .unwrap(); + + let err = update_provider_record( + &store, + "default", + Provider { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: String::new(), + name: "credential-provider".to_string(), + created_at_ms: 0, + labels: HashMap::new(), + resource_version: 0, + annotations: HashMap::new(), + workspace: "default".to_string(), + deletion_timestamp_ms: 0, + }), + r#type: String::new(), + credentials: std::iter::once(( + "GCP_PROJECT_ID".to_string(), + "credential-value".to_string(), + )) + .collect(), + config: HashMap::new(), + credential_expires_at_ms: HashMap::new(), + profile_workspace: "default".to_string(), + }, + ) + .await + .unwrap_err(); + + assert_eq!(err.code(), Code::FailedPrecondition); + assert!(err.message().contains("GCP_PROJECT_ID")); + assert!(err.message().contains("credential-provider")); + assert!(err.message().contains("google-config")); + } + #[tokio::test] async fn handler_flow_resolves_credentials_from_sandbox_providers() { use openshell_core::proto::{Sandbox, SandboxPhase, SandboxSpec}; From 146dc1bc914adc5cd2aac2d3c6bf34183d263dbb Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:33:31 -0700 Subject: [PATCH 24/32] fix(network): capture credential scope atomically Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/l7/relay.rs | 53 ++++++++++++++---- .../openshell-supervisor-network/src/proxy.rs | 54 +++++++++++++++---- 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 3a18396cfc..78e23237d4 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -114,18 +114,10 @@ fn scoped_context_for_request( return Some(scoped); } let credentials = ctx.provider_credentials.as_ref()?; - let revision_before = credentials.revision(); - let resolver = credentials.resolver_for_endpoint(&ctx.host, ctx.port, &request.target); - let revision_after = credentials.revision(); - if revision_before != revision_after { - // The resolver and revision were not observed from one stable - // generation. Fail closed instead of retaining either snapshot. - scoped.secret_resolver = None; - scoped.provider_credential_revision = None; - return Some(scoped); - } + let (resolver, revision) = + credentials.resolver_for_endpoint_with_revision(&ctx.host, ctx.port, &request.target); scoped.secret_resolver = resolver; - scoped.provider_credential_revision = Some(revision_before); + scoped.provider_credential_revision = Some(revision); Some(scoped) } @@ -2514,6 +2506,45 @@ mod tests { (state, resolver) } + #[test] + fn scoped_context_captures_endpoint_resolver_and_revision_together() { + let state = ProviderCredentialState::from_bound_environment( + 42, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + endpoint_binding("provider-a:API_TOKEN"), + )]), + Vec::new(), + ) + .expect("bound provider state"); + let ctx = L7EvalContext { + host: "allowed.example.test".to_string(), + port: 443, + provider_credentials: Some(state), + ..Default::default() + }; + let request = crate::l7::provider::L7Request { + action: "GET".to_string(), + target: "/allowed/v1".to_string(), + query_params: TestHashMap::new(), + raw_header: b"GET /allowed/v1 HTTP/1.1\r\nHost: allowed.example.test\r\n\r\n".to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + + let scoped = scoped_context_for_request(&ctx, &request).expect("scoped context"); + assert_eq!(scoped.provider_credential_revision, Some(42)); + assert_eq!( + scoped + .secret_resolver + .expect("endpoint resolver") + .resolve_placeholder("openshell:resolve:env:v42_API_TOKEN"), + Some("secret") + ); + } + async fn run_single_config_credential_mismatch( config: L7EndpointConfig, engine: TunnelPolicyEngine, diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 1da93814a2..c7ca7042d4 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -3642,18 +3642,11 @@ fn endpoint_credentials_for_request( revision: None, }; }; - let revision_before = credentials.revision(); - let resolver = credentials.resolver_for_endpoint(host, port, canonical_path); - let revision_after = credentials.revision(); - if revision_before != revision_after { - return ForwardEndpointCredentials { - resolver: None, - revision: None, - }; - } + let (resolver, revision) = + credentials.resolver_for_endpoint_with_revision(host, port, canonical_path); ForwardEndpointCredentials { resolver, - revision: Some(revision_before), + revision: Some(revision), } } @@ -8822,6 +8815,47 @@ network_policies: assert!(!malformed.contains("real-secret")); } + #[test] + fn forward_credentials_capture_endpoint_resolver_and_revision_together() { + use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; + + let state = ProviderCredentialState::from_bound_environment( + 42, + TestHashMap::from([("API_TOKEN".to_string(), "secret".to_string())]), + TestHashMap::new(), + TestHashMap::new(), + TestHashMap::from([( + "API_TOKEN".to_string(), + StaticCredentialBinding { + endpoints: vec![StaticCredentialEndpointBinding { + host: "api.example.com".to_string(), + port: 80, + path: "/allowed/**".to_string(), + }], + credential_identity: "provider-a:API_TOKEN".to_string(), + }, + )]), + Vec::new(), + ) + .expect("bound provider state"); + + let credentials = endpoint_credentials_for_request( + Some(&state), + None, + "api.example.com", + 80, + "/allowed/v1", + ); + assert_eq!(credentials.revision, Some(42)); + assert_eq!( + credentials + .resolver + .expect("endpoint resolver") + .resolve_placeholder("openshell:resolve:env:v42_API_TOKEN"), + Some("secret") + ); + } + #[test] fn forward_binding_uses_canonical_path_for_dot_segment_traversal() { use openshell_core::proto::{StaticCredentialBinding, StaticCredentialEndpointBinding}; From dc7d26aa6014f1564714d75b79649f23583a2459 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:34:08 -0700 Subject: [PATCH 25/32] fix(network): distinguish origin and absolute targets Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/l7/path.rs | 39 +++++++++-- .../src/l7/rest.rs | 69 ++++++++++++++++--- 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/path.rs b/crates/openshell-supervisor-network/src/l7/path.rs index db2e4e9847..b1c8a5df5d 100644 --- a/crates/openshell-supervisor-network/src/l7/path.rs +++ b/crates/openshell-supervisor-network/src/l7/path.rs @@ -135,13 +135,24 @@ pub fn canonicalize_request_target( None => (target, None), }; - // 4. Handle absolute-form by stripping scheme://authority. - let raw_path = path_part.find("://").map_or(path_part, |idx| { - let after_scheme = &path_part[idx + 3..]; - after_scheme - .find('/') - .map_or("/", |slash| &after_scheme[slash..]) - }); + // 4. Handle absolute-form by stripping the URI authority. Origin-form + // targets may legitimately embed `://` in a path segment, so only a URI + // with a scheme is absolute-form. + let absolute_form_uri = path_part + .parse::() + .ok() + .filter(|uri| uri.scheme().is_some()); + let raw_path = absolute_form_uri + .as_ref() + .map(http::Uri::path) + .filter(|path| !path.is_empty()) + .unwrap_or_else(|| { + if absolute_form_uri.is_some() { + "/" + } else { + path_part + } + }); // 5. Empty is equivalent to "/". let raw_path = if raw_path.is_empty() { "/" } else { raw_path }; @@ -443,6 +454,20 @@ mod tests { assert_eq!(canon("http://host:443/foo").unwrap(), "/foo"); } + #[test] + fn origin_form_with_embedded_url_is_not_stripped_as_absolute_form() { + let (path, query) = canonicalize_request_target( + "/fetch/http://example.test?next=http://other.test", + &CanonicalizeOptions::default(), + ) + .expect("origin-form target with embedded URLs must be accepted"); + // Repeated slashes are canonicalized everywhere, but the embedded + // URL must remain part of the origin-form path rather than being + // treated as a new absolute-form authority. + assert_eq!(path.path, "/fetch/http:/example.test"); + assert_eq!(query.as_deref(), Some("next=http://other.test")); + } + #[test] fn legitimate_percent_encoded_bytes_round_trip() { assert_eq!( diff --git a/crates/openshell-supervisor-network/src/l7/rest.rs b/crates/openshell-supervisor-network/src/l7/rest.rs index 31386e983b..29377fdf85 100644 --- a/crates/openshell-supervisor-network/src/l7/rest.rs +++ b/crates/openshell-supervisor-network/src/l7/rest.rs @@ -416,11 +416,7 @@ pub(crate) fn request_authority( .next() .and_then(|line| line.split_whitespace().nth(1)) .ok_or_else(|| miette!("HTTP request is missing a request target"))?; - let absolute_uri = request_target - .contains("://") - .then(|| request_target.parse::()) - .transpose() - .map_err(|_| miette!("HTTP absolute-form request target contains an invalid URI"))?; + let absolute_uri = absolute_form_uri(request_target)?; let (authority, default_port) = if let Some(uri) = absolute_uri { let authority = uri .authority() @@ -464,12 +460,9 @@ fn validate_absolute_form_authority( target: &str, host_authority: Option<&http::uri::Authority>, ) -> Result<()> { - if !target.contains("://") { + let Some(uri) = absolute_form_uri(target)? else { return Ok(()); - } - let uri = target - .parse::() - .map_err(|_| miette!("HTTP absolute-form request target contains an invalid URI"))?; + }; let request_authority = uri .authority() .ok_or_else(|| miette!("HTTP absolute-form request target is missing an authority"))?; @@ -483,6 +476,18 @@ fn validate_absolute_form_authority( Ok(()) } +/// Return a URI only when the request-target uses HTTP absolute form. +/// +/// Origin-form request-targets can legitimately contain `://` in a path or +/// query value, so a substring check would incorrectly make those requests +/// participate in authority validation. +fn absolute_form_uri(target: &str) -> Result> { + let uri = target + .parse::() + .map_err(|_| miette!("HTTP absolute-form request target contains an invalid URI"))?; + Ok(uri.scheme().is_some().then_some(uri)) +} + fn authorities_match( left: &http::uri::Authority, right: &http::uri::Authority, @@ -4897,6 +4902,50 @@ mod tests { ); } + #[test] + fn origin_form_targets_with_embedded_urls_use_host_authority() { + let host: http::uri::Authority = "api.example.test".parse().unwrap(); + + for target in ["/fetch/http://example.test", "/?next=http://example.test"] { + assert!( + absolute_form_uri(target).unwrap().is_none(), + "{target} must remain origin-form" + ); + validate_absolute_form_authority(target, Some(&host)) + .expect("embedded URL must not trigger absolute-form validation"); + + let raw = format!("GET {target} HTTP/1.1\r\nHost: api.example.test\r\n\r\n"); + let authority = request_authority(raw.as_bytes(), Some(443)) + .unwrap() + .expect("origin-form request with Host must have an authority"); + assert_eq!(authority.authority, host); + assert_eq!(authority.effective_port, 443); + } + } + + #[tokio::test] + async fn parse_http_request_keeps_embedded_url_in_origin_form_path() { + let (mut client, mut peer) = tokio::io::duplex(1024); + peer.write_all( + b"GET /fetch/http://example.test?next=http://other.test HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + ) + .await + .unwrap(); + + let request = parse_http_request( + &mut client, + &crate::l7::path::CanonicalizeOptions::default(), + ) + .await + .expect("embedded URL origin-form request must parse") + .expect("request must be present"); + assert_eq!(request.target, "/fetch/http:/example.test"); + assert_eq!( + request.raw_header, + b"GET /fetch/http:/example.test?next=http://other.test HTTP/1.1\r\nHost: api.example.test\r\n\r\n", + ); + } + #[tokio::test] async fn parse_http_request_canonicalization_preserves_query_string() { let (mut client, mut writer) = tokio::io::duplex(4096); From 18fbf27bab6b734213ab3b8f3a381ce6b7745051 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:05:56 -0700 Subject: [PATCH 26/32] fix(provider): isolate endpointless profile credentials Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-server/src/grpc/provider.rs | 92 ++++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index d2958ac3e3..918ad4ceac 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -628,6 +628,7 @@ pub(super) async fn resolve_provider_environment_from_records( }) .collect::>() }); + let profile_has_no_usable_endpoint = profile_endpoints.as_ref().is_some_and(Vec::is_empty); for (key, value) in &provider.credentials { if is_non_injectable_provider_credential(provider, key) { @@ -639,6 +640,18 @@ pub(super) async fn resolve_provider_environment_from_records( continue; } if is_valid_env_key(key) { + if profile_has_no_usable_endpoint { + // Static credentials need a complete binding. Do not send + // endpointless profile credentials as invalid metadata, + // because one rejected key would revoke every unrelated + // static credential in the supervisor snapshot. + warn!( + provider_name = %name, + key = %key, + "withholding static provider credential from endpointless profile" + ); + continue; + } let expires_at_ms = provider .credential_expires_at_ms .get(key) @@ -6624,6 +6637,85 @@ mod tests { ); } + #[tokio::test] + async fn resolve_provider_env_withholds_endpointless_profile_credentials_independently() { + let store = test_store().await; + let expires_at_ms = crate::persistence::current_time_ms() + 60_000; + let mut google_cloud = google_cloud_provider(HashMap::from([( + "project_id".to_string(), + "sandbox-project".to_string(), + )])); + google_cloud.credentials = HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "google-token".to_string(), + )]); + google_cloud.credential_expires_at_ms = + HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), expires_at_ms)]); + create_provider_record(&store, "default", google_cloud) + .await + .unwrap(); + + let mut github = provider_with_values("bound-github", "github"); + github.credentials = + HashMap::from([("GITHUB_TOKEN".to_string(), "github-token".to_string())]); + github.config.clear(); + create_provider_record(&store, "default", github) + .await + .unwrap(); + + let result = resolve_provider_environment( + &store, + "default", + &["my-google-cloud".to_string(), "bound-github".to_string()], + ) + .await + .unwrap(); + + assert!( + !result.contains_key("GCP_ADC_ACCESS_TOKEN"), + "endpointless static credentials must be withheld" + ); + assert!( + !result + .credential_expires_at_ms + .contains_key("GCP_ADC_ACCESS_TOKEN"), + "withheld static credentials must not retain expiry metadata" + ); + assert!( + !result + .static_credential_keys + .contains("GCP_ADC_ACCESS_TOKEN"), + "withheld static credentials must not be classified as static" + ); + assert!( + !result + .static_credential_bindings + .contains_key("GCP_ADC_ACCESS_TOKEN"), + "endpointless static credentials must not emit an invalid binding" + ); + assert!( + result.contains_key("GCE_METADATA_HOST"), + "provider-generated non-secret GCP configuration must be retained" + ); + + assert_eq!( + result.get("GITHUB_TOKEN"), + Some(&"github-token".to_string()), + "a valid provider must not be suppressed by an endpointless provider" + ); + assert!( + result.static_credential_keys.contains("GITHUB_TOKEN"), + "the valid credential must retain its static classification" + ); + assert!( + result + .static_credential_bindings + .get("GITHUB_TOKEN") + .is_some_and(|binding| !binding.endpoints.is_empty()), + "the valid credential must retain its endpoint binding" + ); + } + #[tokio::test] async fn resolve_provider_env_allows_static_provider_without_profile() { let store = test_store().await; From da06ea5260f37035219f92ddba273d6c161f62c2 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:06:33 -0700 Subject: [PATCH 27/32] fix(network): normalize IPv6 request authorities Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/l7/relay.rs | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/openshell-supervisor-network/src/l7/relay.rs b/crates/openshell-supervisor-network/src/l7/relay.rs index 78e23237d4..fea1592a1e 100644 --- a/crates/openshell-supervisor-network/src/l7/relay.rs +++ b/crates/openshell-supervisor-network/src/l7/relay.rs @@ -143,14 +143,16 @@ fn request_authority_matches_endpoint( } Err(_) => return false, }; - let request_host = authority.authority.host().trim_end_matches('.'); - let endpoint_host = ctx - .host - .trim() + let request_host = normalized_endpoint_host(authority.authority.host()); + let endpoint_host = normalized_endpoint_host(&ctx.host); + request_host.eq_ignore_ascii_case(endpoint_host) && authority.effective_port == ctx.port +} + +fn normalized_endpoint_host(host: &str) -> &str { + host.trim() .trim_start_matches('[') .trim_end_matches(']') - .trim_end_matches('.'); - request_host.eq_ignore_ascii_case(endpoint_host) && authority.effective_port == ctx.port + .trim_end_matches('.') } async fn reject_request_authority_mismatch(client: &mut W, ctx: &L7EvalContext) -> Result<()> @@ -2545,6 +2547,29 @@ mod tests { ); } + #[test] + fn bracketed_ipv6_host_matches_bracket_free_connect_endpoint() { + let request = crate::l7::provider::L7Request { + action: "GET".to_string(), + target: "/v1".to_string(), + query_params: TestHashMap::new(), + raw_header: b"GET /v1 HTTP/1.1\r\nHost: [2001:db8::1]:8443\r\n\r\n".to_vec(), + body_length: crate::l7::provider::BodyLength::None, + }; + let ctx = L7EvalContext { + host: "2001:db8::1".to_string(), + port: 8443, + request_default_port: Some(8443), + ..Default::default() + }; + + let authority = crate::l7::rest::request_authority(&request.raw_header, Some(8443)) + .expect("valid authority") + .expect("Host header"); + assert_eq!(authority.authority.host(), "[2001:db8::1]"); + assert!(request_authority_matches_endpoint(&request, &ctx)); + } + async fn run_single_config_credential_mismatch( config: L7EndpointConfig, engine: TunnelPolicyEngine, From 1387eac778bffeff70b05252679b9910ec8f27b0 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Wed, 29 Jul 2026 19:26:58 -0700 Subject: [PATCH 28/32] docs(credentials): clarify endpointless profile isolation Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- architecture/gateway.md | 11 ++++++----- docs/sandboxes/providers-v2.mdx | 15 ++++++++------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/architecture/gateway.md b/architecture/gateway.md index 1cd2aa8638..ef207878a9 100644 --- a/architecture/gateway.md +++ b/architecture/gateway.md @@ -405,11 +405,12 @@ gateway classifies each returned environment entry as either a credential or non-secret provider configuration and associates every credential key with the host, port, and path selectors from its effective provider profile. It withholds static credential material from supervisors that do not advertise binding -support. If a static credential has no usable endpoint, the gateway returns its -incomplete binding metadata with the valid dynamic credential snapshot. The -supervisor then revokes all static material while keeping the dynamic snapshot -active. Provider environment revisions include profile endpoint and binding -changes. +support. If a selected provider profile has no usable endpoint, the gateway +withholds only that profile's static credential keys and their expiry and +binding metadata. It continues to return provider-generated non-secret +configuration, valid endpoint-bound static credentials from other attached +providers, and the dynamic credential snapshot. Provider environment revisions +include profile endpoint and binding changes. ## Inference Resolution diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index 7f3b09bd62..ae2369ee90 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -130,12 +130,13 @@ the provider revokes resolution for placeholders already held by running processes. -Static credentials require a provider profile with at least one usable -endpoint. If any attached static provider lacks complete binding metadata, the -sandbox rejects the provider environment update and keeps no static provider -credentials active. Import a custom profile, create a replacement provider -whose `--type` is that profile ID, attach the replacement, and detach the -profileless provider. +Static credentials from a selected provider profile require at least one usable +endpoint. OpenShell withholds only the static credential keys and associated +expiry and binding metadata from an endpointless selected profile. It retains +that provider's generated non-secret configuration and valid endpoint-bound +static credentials from other attached providers. For a custom static +credential, import a profile with the intended endpoint selectors and use that +profile as the provider type. After upgrading a gateway and supervisor to a release with endpoint binding, @@ -526,7 +527,7 @@ Use an ISO/RFC3339 timestamp or Unix epoch milliseconds. Use `0` as the timestam OpenShell skips expired provider credentials when it builds a sandbox provider environment. Running sandboxes also reject expired retained credential generations during placeholder resolution, so stale placeholders fail closed instead of forwarding unresolved or expired credential material. -The gateway sends a complete host, port, and path binding for every static credential key. Supervisors reject incomplete binding metadata and clear previously active provider material when a refresh fails validation. Refer to [Static Credential Endpoint Binding](#understand-static-credential-endpoint-binding) for matching, denial, lifecycle, and migration behavior. +The gateway sends a complete host, port, and path binding for every emitted static credential key. It withholds static credential keys from endpointless selected profiles with their expiry and binding metadata. Supervisors reject other incomplete binding metadata and clear previously active provider material when a refresh fails validation. Refer to [Static Credential Endpoint Binding](#understand-static-credential-endpoint-binding) for matching, denial, lifecycle, and migration behavior. ## Configure Credential Refresh From 2d5a4dc5a5b11834c150e1870d812f3e743bb511 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:23:02 -0700 Subject: [PATCH 29/32] feat(policy): bind endpointless provider credentials Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-policy/src/ambiguity.rs | 40 +- crates/openshell-policy/src/lib.rs | 49 ++ crates/openshell-providers/src/profiles.rs | 3 + crates/openshell-server/src/grpc/policy.rs | 595 +++++++++++++++++- crates/openshell-server/src/grpc/provider.rs | 159 ++++- crates/openshell-server/src/grpc/sandbox.rs | 83 ++- .../src/policy_local.rs | 2 + e2e/python/test_sandbox_providers.py | 46 ++ proto/sandbox.proto | 11 + 9 files changed, 946 insertions(+), 42 deletions(-) diff --git a/crates/openshell-policy/src/ambiguity.rs b/crates/openshell-policy/src/ambiguity.rs index 05f8744855..bf97c7e736 100644 --- a/crates/openshell-policy/src/ambiguity.rs +++ b/crates/openshell-policy/src/ambiguity.rs @@ -190,7 +190,10 @@ fn connection_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec< /// cannot compete with the single L7/connection-config endpoint selected for /// that request. fn endpoint_contributes_request_pipeline_metadata(endpoint: &NetworkEndpoint) -> bool { - !endpoint.protocol.is_empty() || !endpoint.allowed_ips.is_empty() || !endpoint.tls.is_empty() + !endpoint.protocol.is_empty() + || !endpoint.allowed_ips.is_empty() + || !endpoint.tls.is_empty() + || endpoint.credential_binding.is_some() } fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) -> Vec { @@ -253,6 +256,20 @@ fn request_pipeline_conflicts(left: &NetworkEndpoint, right: &NetworkEndpoint) - &left.signing_region, &right.signing_region, ); + push_conflict( + &mut conflicts, + "credential_binding.provider", + &left + .credential_binding + .as_ref() + .map(|binding| binding.provider.as_str()) + .unwrap_or_default(), + &right + .credential_binding + .as_ref() + .map(|binding| binding.provider.as_str()) + .unwrap_or_default(), + ); if left.protocol.eq_ignore_ascii_case("graphql") && right.protocol.eq_ignore_ascii_case("graphql") @@ -896,6 +913,27 @@ mod tests { ); } + #[test] + fn credential_binding_conflicts_are_rejected_on_same_endpoint() { + let mut left = endpoint("api.example.com", 443); + left.credential_binding = Some(openshell_core::proto::NetworkCredentialBinding { + provider: "provider-a".to_string(), + }); + let mut right = endpoint("api.example.com", 443); + right.credential_binding = Some(openshell_core::proto::NetworkCredentialBinding { + provider: "provider-b".to_string(), + }); + + let ambiguities = find_endpoint_ambiguities(&policy_with(left, right)); + assert_eq!(ambiguities.len(), 1); + assert!( + ambiguities[0] + .conflicts + .iter() + .any(|field| field.contains("credential_binding.provider")) + ); + } + #[test] fn json_rpc_body_limit_is_compared_only_within_the_same_protocol() { let mut json_rpc = endpoint("api.example.com", 443); diff --git a/crates/openshell-policy/src/lib.rs b/crates/openshell-policy/src/lib.rs index d23acee137..4dbb2f6984 100644 --- a/crates/openshell-policy/src/lib.rs +++ b/crates/openshell-policy/src/lib.rs @@ -157,11 +157,19 @@ struct NetworkEndpointDef { #[serde(default, skip_serializing_if = "String::is_empty")] signing_region: String, #[serde(default, skip_serializing_if = "Option::is_none")] + credential_binding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] json_rpc: Option, #[serde(default, skip_serializing_if = "Option::is_none")] mcp: Option, } +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct NetworkCredentialBindingDef { + provider: String, +} + // Signature dictated by serde's `skip_serializing_if`, which requires `&T`. #[allow(clippy::trivially_copy_pass_by_ref)] fn is_zero(v: &u16) -> bool { @@ -759,6 +767,11 @@ fn to_proto(raw: PolicyFile) -> Result { credential_signing: e.credential_signing, signing_service: e.signing_service, signing_region: e.signing_region, + credential_binding: e.credential_binding.map(|binding| { + openshell_core::proto::NetworkCredentialBinding { + provider: binding.provider, + } + }), json_rpc_max_body_bytes: json_rpc_max_body_bytes(&e.json_rpc, &e.mcp), mcp: mcp_options(&e.mcp), } @@ -907,6 +920,11 @@ fn from_proto(policy: &SandboxPolicy) -> PolicyFile { credential_signing: e.credential_signing.clone(), signing_service: e.signing_service.clone(), signing_region: e.signing_region.clone(), + credential_binding: e.credential_binding.as_ref().map(|binding| { + NetworkCredentialBindingDef { + provider: binding.provider.clone(), + } + }), json_rpc, mcp, } @@ -2991,6 +3009,37 @@ network_policies: assert_eq!(ep1.path, ep2.path); } + #[test] + fn round_trip_preserves_endpoint_credential_binding() { + let yaml = r" +version: 1 +network_policies: + gcp_storage: + endpoints: + - host: storage.googleapis.com + port: 443 + protocol: rest + credential_binding: + provider: work-gcp +"; + + let proto1 = parse_sandbox_policy(yaml).expect("parse failed"); + let endpoint = &proto1.network_policies["gcp_storage"].endpoints[0]; + assert_eq!( + endpoint + .credential_binding + .as_ref() + .map(|binding| binding.provider.as_str()), + Some("work-gcp") + ); + + let yaml_out = serialize_sandbox_policy(&proto1).expect("serialize failed"); + let proto2 = parse_sandbox_policy(&yaml_out).expect("re-parse failed"); + assert_eq!(proto1, proto2); + assert!(yaml_out.contains("credential_binding:")); + assert!(yaml_out.contains("provider: work-gcp")); + } + #[test] fn round_trip_preserves_multi_port() { let yaml = r" diff --git a/crates/openshell-providers/src/profiles.rs b/crates/openshell-providers/src/profiles.rs index 84131a1c77..42c82c0c2a 100644 --- a/crates/openshell-providers/src/profiles.rs +++ b/crates/openshell-providers/src/profiles.rs @@ -1088,6 +1088,9 @@ fn endpoint_to_proto(endpoint: &EndpointProfile) -> NetworkEndpoint { credential_signing: endpoint.credential_signing.clone(), signing_service: endpoint.signing_service.clone(), signing_region: endpoint.signing_region.clone(), + // Credential bindings reference a concrete sandbox provider instance + // and therefore cannot be authored by a reusable provider profile. + credential_binding: None, } } diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 569e2d6153..35d2a736b6 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -41,7 +41,7 @@ use openshell_core::proto::{ }; use openshell_core::proto::{ L7DenyRule, L7Rule, NetworkBinary, NetworkEndpoint, NetworkPolicyRule, Provider, Sandbox, - SandboxPolicy as ProtoSandboxPolicy, + SandboxPolicy as ProtoSandboxPolicy, StaticCredentialEndpointBinding, }; use openshell_core::telemetry::{ LifecycleOperation, LifecycleResource, PolicyDecisionOperation, TelemetryOutcome, @@ -1132,6 +1132,144 @@ pub(super) fn validate_candidate_effective_policy( validate_endpoint_ambiguities(&effective_policy) } +fn policy_static_credential_endpoint_bindings( + policy: Option<&ProtoSandboxPolicy>, +) -> Result>, Status> { + let mut bindings = HashMap::>::new(); + let Some(policy) = policy else { + return Ok(bindings); + }; + + for rule in policy.network_policies.values() { + for endpoint in &rule.endpoints { + let Some(binding) = endpoint.credential_binding.as_ref() else { + continue; + }; + let provider = binding.provider.trim(); + if provider.is_empty() { + return Err(Status::invalid_argument(format!( + "credential_binding.provider is required for endpoint '{}'", + endpoint.host + ))); + } + if provider != binding.provider { + return Err(Status::invalid_argument(format!( + "credential_binding.provider '{}' must not contain leading or trailing whitespace", + binding.provider + ))); + } + if endpoint.host.trim().is_empty() { + return Err(Status::invalid_argument(format!( + "credential-bound endpoint for provider '{provider}' must define a host" + ))); + } + let ports = if endpoint.ports.is_empty() { + vec![endpoint.port] + } else { + endpoint.ports.clone() + }; + if ports + .iter() + .any(|port| *port == 0 || *port > u32::from(u16::MAX)) + { + return Err(Status::invalid_argument(format!( + "credential-bound endpoint '{}' for provider '{provider}' must define ports in range 1..=65535", + endpoint.host + ))); + } + let provider_bindings = bindings.entry(provider.to_string()).or_default(); + for port in ports { + let candidate = StaticCredentialEndpointBinding { + host: endpoint.host.clone(), + port, + path: endpoint.path.clone(), + }; + if !provider_bindings.contains(&candidate) { + provider_bindings.push(candidate); + } + } + } + } + + for endpoints in bindings.values_mut() { + endpoints.sort_by(|left, right| { + (&left.host, left.port, &left.path).cmp(&(&right.host, right.port, &right.path)) + }); + } + Ok(bindings) +} + +pub(super) fn policy_has_credential_binding_for_provider( + policy: &ProtoSandboxPolicy, + provider_name: &str, +) -> bool { + policy.network_policies.values().any(|rule| { + rule.endpoints.iter().any(|endpoint| { + endpoint + .credential_binding + .as_ref() + .is_some_and(|binding| binding.provider == provider_name) + }) + }) +} + +fn validate_policy_credential_binding_context( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], + bindings: &HashMap>, +) -> Result<(), Status> { + for provider_name in bindings.keys() { + let record = records + .iter() + .find(|record| record.name == *provider_name) + .ok_or_else(|| { + Status::failed_precondition(format!( + "credential_binding references provider '{provider_name}', but that provider is not attached to the sandbox" + )) + })?; + let profile_id = normalize_provider_type(&record.provider.r#type) + .unwrap_or(record.provider.r#type.as_str()); + let profile = super::provider::get_provider_type_profile_for_scope( + catalog, + profile_id, + &record.provider.profile_workspace, + ) + .ok_or_else(|| { + Status::failed_precondition(format!( + "credential_binding provider '{provider_name}' has no provider profile" + )) + })?; + if !profile.to_proto().endpoints.is_empty() { + return Err(Status::failed_precondition(format!( + "credential_binding provider '{provider_name}' profile already defines endpoints; \ + profile endpoints remain the credential boundary" + ))); + } + } + Ok(()) +} + +async fn validate_policy_credential_bindings_for_sandbox( + state: &ServerState, + catalog: &EffectiveProviderProfileCatalog, + workspace: &str, + provider_names: &[String], + policy: &ProtoSandboxPolicy, +) -> Result>, Status> { + let bindings = policy_static_credential_endpoint_bindings(Some(policy))?; + if bindings.is_empty() { + return Ok(bindings); + } + let records = super::provider::load_provider_environment_records( + state.store.as_ref(), + workspace, + provider_names, + ) + .await?; + validate_policy_credential_binding_context(catalog, &records, &bindings)?; + Ok(bindings) +} + async fn provider_policy_layers_for_sandbox( state: &ServerState, workspace: &str, @@ -1191,7 +1329,25 @@ pub(super) async fn validate_candidate_provider_attachments( let base_policy = current_base_policy_for_sandbox(state.store.as_ref(), sandbox).await?; let provider_layers = provider_policy_layers_for_sandbox(state, workspace, sandbox, provider_names).await?; - validate_candidate_effective_policy(&base_policy, &provider_layers) + validate_candidate_effective_policy(&base_policy, &provider_layers)?; + let effective_policy = if provider_layers.is_empty() { + base_policy + } else { + compose_effective_policy(&base_policy, &provider_layers) + }; + let catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), workspace) + .await?; + validate_policy_credential_bindings_for_sandbox( + state, + &catalog, + workspace, + provider_names, + &effective_policy, + ) + .await?; + Ok(()) } pub(super) async fn provider_policy_composition_enabled(store: &Store) -> Result { @@ -1544,11 +1700,23 @@ pub(super) async fn handle_get_sandbox_config( &supervisor_middleware_services, state.config.policy_validation_failure_mode, ); - let provider_env_revision = compute_provider_env_revision_with_catalog( + let policy_credential_bindings = policy_static_credential_endpoint_bindings(policy.as_ref())?; + if let Some(policy) = policy.as_ref() { + validate_policy_credential_bindings_for_sandbox( + state.as_ref(), + &provider_profile_catalog, + &workspace, + &sandbox_provider_names, + policy, + ) + .await?; + } + let provider_env_revision = compute_provider_env_revision_with_catalog_and_policy_bindings( state.store.as_ref(), &provider_profile_catalog, &workspace, &sandbox_provider_names, + &policy_credential_bindings, ) .await?; @@ -1583,14 +1751,32 @@ async fn compute_provider_env_revision( compute_provider_env_revision_with_catalog(store, &catalog, workspace, provider_names).await } +#[cfg(test)] pub(super) async fn compute_provider_env_revision_with_catalog( store: &Store, catalog: &EffectiveProviderProfileCatalog, workspace: &str, provider_names: &[String], +) -> Result { + compute_provider_env_revision_with_catalog_and_policy_bindings( + store, + catalog, + workspace, + provider_names, + &HashMap::new(), + ) + .await +} + +async fn compute_provider_env_revision_with_catalog_and_policy_bindings( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + workspace: &str, + provider_names: &[String], + policy_bindings: &HashMap>, ) -> Result { let mut hasher = Sha256::new(); - hasher.update(b"openshell-provider-env-revision-v2"); + hasher.update(b"openshell-provider-env-revision-v3"); for provider_name in provider_names { hasher.update(provider_name.as_bytes()); @@ -1633,18 +1819,33 @@ pub(super) async fn compute_provider_env_revision_with_catalog( } } + hash_policy_credential_bindings(policy_bindings, &mut hasher); + let digest = hasher.finalize(); Ok(u64::from_le_bytes(digest[..8].try_into().map_err( |_| Status::internal("provider env revision digest too short"), )?)) } +#[cfg(test)] fn compute_provider_env_revision_from_records( catalog: &EffectiveProviderProfileCatalog, records: &[super::provider::ProviderEnvironmentRecord], +) -> Result { + compute_provider_env_revision_from_records_and_policy_bindings( + catalog, + records, + &HashMap::new(), + ) +} + +fn compute_provider_env_revision_from_records_and_policy_bindings( + catalog: &EffectiveProviderProfileCatalog, + records: &[super::provider::ProviderEnvironmentRecord], + policy_bindings: &HashMap>, ) -> Result { let mut hasher = Sha256::new(); - hasher.update(b"openshell-provider-env-revision-v2"); + hasher.update(b"openshell-provider-env-revision-v3"); for record in records { hasher.update(record.name.as_bytes()); @@ -1673,12 +1874,34 @@ fn compute_provider_env_revision_from_records( } } + hash_policy_credential_bindings(policy_bindings, &mut hasher); + let digest = hasher.finalize(); Ok(u64::from_le_bytes(digest[..8].try_into().map_err( |_| Status::internal("provider env revision digest too short"), )?)) } +fn hash_policy_credential_bindings( + bindings: &HashMap>, + hasher: &mut Sha256, +) { + let mut provider_names: Vec<_> = bindings.keys().collect(); + provider_names.sort(); + for provider_name in provider_names { + hasher.update(provider_name.as_bytes()); + let mut endpoints = bindings[provider_name].clone(); + endpoints.sort_by(|left, right| { + (&left.host, left.port, &left.path).cmp(&(&right.host, right.port, &right.path)) + }); + for endpoint in endpoints { + hasher.update(endpoint.host.as_bytes()); + hasher.update(endpoint.port.to_le_bytes()); + hasher.update(endpoint.path.as_bytes()); + } + } +} + fn hash_provider_profile_revision( catalog: &EffectiveProviderProfileCatalog, provider_type: &str, @@ -1782,9 +2005,10 @@ pub(super) async fn handle_get_sandbox_provider_environment( let spec = sandbox .spec + .as_ref() .ok_or_else(|| Status::internal("sandbox has no spec"))?; - let provider_names = spec.providers; + let provider_names = spec.providers.clone(); let provider_profile_catalog = state .provider_profile_sources .snapshot_catalog(state.store.as_ref(), &workspace) @@ -1795,14 +2019,34 @@ pub(super) async fn handle_get_sandbox_provider_environment( &provider_names, ) .await?; - let provider_env_revision = - compute_provider_env_revision_from_records(&provider_profile_catalog, &provider_records)?; - let mut provider_environment = super::provider::resolve_provider_environment_from_records( - state.store.as_ref(), + let effective_policy = current_effective_policy_for_sandbox( + state.as_ref(), &provider_profile_catalog, - &provider_records, + &workspace, + &sandbox, + &sandbox_id, ) .await?; + let policy_credential_bindings = + policy_static_credential_endpoint_bindings(Some(&effective_policy))?; + validate_policy_credential_binding_context( + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + )?; + let provider_env_revision = compute_provider_env_revision_from_records_and_policy_bindings( + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + )?; + let mut provider_environment = + super::provider::resolve_provider_environment_from_records_with_policy_bindings( + state.store.as_ref(), + &provider_profile_catalog, + &provider_records, + &policy_credential_bindings, + ) + .await?; if !supports_static_credential_bindings { for key in &provider_environment.static_credential_keys { @@ -1928,6 +2172,11 @@ async fn handle_update_config_inner( crate::middleware::validate_policy(state.middleware_registry.as_ref(), &new_policy) .await?; validate_candidate_effective_policy(&new_policy, &[])?; + if !policy_static_credential_endpoint_bindings(Some(&new_policy))?.is_empty() { + return Err(Status::failed_precondition( + "credential_binding is sandbox-scoped and cannot be used in a global policy", + )); + } let payload = new_policy.encode_to_vec(); let hash = deterministic_policy_hash(&new_policy); @@ -2197,6 +2446,20 @@ async fn handle_update_config_inner( let provider_layers = provider_policy_layers_for_sandbox(state, &workspace, &sandbox, &spec.providers) .await?; + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await?; + let provider_records = super::provider::load_provider_environment_records( + state.store.as_ref(), + &workspace, + &spec.providers, + ) + .await?; + let credential_binding_context = PolicyCredentialBindingValidationContext { + catalog: &provider_profile_catalog, + records: &provider_records, + }; let atomic_context = AtomicPolicyWriteContext { expected_resource_version: req.expected_resource_version, provenance: &req.annotations, @@ -2208,7 +2471,10 @@ async fn handle_update_config_inner( &workspace, spec.policy.as_ref(), &merge_ops, - &provider_layers, + PolicyMergeValidationContext { + provider_layers: &provider_layers, + credential_binding: Some(&credential_binding_context), + }, Some(&atomic_context), ) .await?; @@ -2310,6 +2576,23 @@ async fn handle_update_config_inner( let provider_layers = provider_policy_layers_for_sandbox(state, &workspace, &sandbox, &spec.providers).await?; validate_candidate_effective_policy(&new_policy, &provider_layers)?; + let provider_profile_catalog = state + .provider_profile_sources + .snapshot_catalog(state.store.as_ref(), &workspace) + .await?; + let effective_policy = if provider_layers.is_empty() { + new_policy.clone() + } else { + compose_effective_policy(&new_policy, &provider_layers) + }; + validate_policy_credential_bindings_for_sandbox( + state.as_ref(), + &provider_profile_catalog, + &workspace, + &spec.providers, + &effective_policy, + ) + .await?; let _sandbox_sync_guard = if backfill_policy.is_some() { Some(state.compute.sandbox_sync_guard().await) @@ -4291,15 +4574,26 @@ struct AtomicPolicyWriteContext<'a> { annotations: &'a HashMap, } +struct PolicyCredentialBindingValidationContext<'a> { + catalog: &'a EffectiveProviderProfileCatalog, + records: &'a [super::provider::ProviderEnvironmentRecord], +} + +struct PolicyMergeValidationContext<'a> { + provider_layers: &'a [ProviderPolicyLayer], + credential_binding: Option<&'a PolicyCredentialBindingValidationContext<'a>>, +} + async fn apply_merge_operations_with_retry( store: &Store, sandbox_id: &str, workspace: &str, baseline_policy: Option<&ProtoSandboxPolicy>, operations: &[PolicyMergeOp], - provider_layers: &[ProviderPolicyLayer], + validation_context: PolicyMergeValidationContext<'_>, atomic_context: Option<&AtomicPolicyWriteContext<'_>>, ) -> Result<(i64, String, Option), Status> { + let provider_layers = validation_context.provider_layers; for attempt in 1..=MERGE_RETRY_LIMIT { let latest = store .get_latest_policy(sandbox_id) @@ -4322,6 +4616,19 @@ async fn apply_merge_operations_with_retry( } validate_policy_safety(&new_policy)?; validate_candidate_effective_policy(&new_policy, provider_layers)?; + let effective_policy = if provider_layers.is_empty() { + new_policy.clone() + } else { + compose_effective_policy(&new_policy, provider_layers) + }; + let bindings = policy_static_credential_endpoint_bindings(Some(&effective_policy))?; + if let Some(context) = validation_context.credential_binding { + validate_policy_credential_binding_context( + context.catalog, + context.records, + &bindings, + )?; + } if let Some(ref current) = latest && current.policy_hash == hash @@ -4432,7 +4739,10 @@ pub(super) async fn merge_chunk_into_policy( workspace, None, &operations, - provider_layers, + PolicyMergeValidationContext { + provider_layers, + credential_binding: None, + }, None, ) .await @@ -4454,7 +4764,10 @@ async fn remove_chunk_from_policy( rule_name: chunk.rule_name.clone(), binary_path: chunk.binary.clone(), }], - &[], + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, None, ) .await @@ -5414,6 +5727,23 @@ mod tests { } } + fn test_policy_with_credential_binding( + rule_name: &str, + host: &str, + provider: &str, + ) -> ProtoSandboxPolicy { + let mut policy = test_policy_with_rule(rule_name, host); + policy + .network_policies + .get_mut(rule_name) + .unwrap() + .endpoints[0] + .credential_binding = Some(openshell_core::proto::NetworkCredentialBinding { + provider: provider.to_string(), + }); + policy + } + fn test_ambiguous_policy() -> ProtoSandboxPolicy { let mut left = test_policy_with_rule("left", "api.example.com"); left.network_policies.get_mut("left").unwrap().endpoints[0].tls = "skip".to_string(); @@ -6182,6 +6512,83 @@ mod tests { ); } + #[tokio::test] + async fn update_config_rejects_credential_binding_to_unattached_provider() { + let state = test_server_state().await; + let mut sandbox = test_sandbox( + "sb-unattached-binding", + "unattached-binding", + ProtoSandboxPolicy::default(), + Vec::new(), + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "unattached-binding".to_string(), + workspace: "default".to_string(), + policy: Some(test_policy_with_credential_binding( + "cloud", + "api.cloud.example", + "missing-provider", + )), + ..Default::default() + })), + ) + .await + .expect_err("unattached provider binding must fail before persistence"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("not attached")); + assert!( + state + .store + .get_latest_policy("sb-unattached-binding") + .await + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn update_config_rejects_policy_binding_for_endpointful_profile() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-github", "github")) + .await + .unwrap(); + let mut sandbox = test_sandbox( + "sb-double-binding", + "double-binding", + ProtoSandboxPolicy::default(), + vec!["work-github".to_string()], + ); + sandbox.spec.as_mut().unwrap().policy = None; + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "double-binding".to_string(), + workspace: "default".to_string(), + policy: Some(test_policy_with_credential_binding( + "cloud", + "api.cloud.example", + "work-github", + )), + ..Default::default() + })), + ) + .await + .expect_err("profile and policy must not both define credential endpoints"); + + assert_eq!(error.code(), Code::FailedPrecondition); + assert!(error.message().contains("already defines endpoints")); + } + #[tokio::test] async fn merge_operations_reject_ambiguity_before_persisting_revision() { let state = test_server_state().await; @@ -6201,7 +6608,10 @@ mod tests { "default", None, &operations, - &[], + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, None, ) .await @@ -6725,6 +7135,149 @@ mod tests { assert!(response.static_credential_bindings.is_empty()); } + #[tokio::test] + async fn provider_environment_uses_policy_binding_for_endpointless_profile() { + use openshell_core::proto::{ + GetSandboxConfigRequest, GetSandboxProviderEnvironmentRequest, + NetworkCredentialBinding, ProviderProfile, ProviderProfileCategory, + StoredProviderProfile, + }; + + let state = test_server_state().await; + state + .store + .put_message(&StoredProviderProfile { + metadata: Some(openshell_core::proto::datamodel::v1::ObjectMeta { + id: "profile-endpointless".to_string(), + name: "endpointless".to_string(), + workspace: "default".to_string(), + ..Default::default() + }), + profile: Some(ProviderProfile { + id: "endpointless".to_string(), + display_name: "Endpointless".to_string(), + category: ProviderProfileCategory::Other as i32, + endpoints: Vec::new(), + ..Default::default() + }), + }) + .await + .unwrap(); + let mut provider = test_provider("work-cloud", "endpointless"); + provider.credentials = + HashMap::from([("CLOUD_TOKEN".to_string(), "cloud-secret".to_string())]); + state.store.put_message(&provider).await.unwrap(); + + let mut policy = test_policy_with_rule("cloud_api", "api.cloud.example"); + policy + .network_policies + .get_mut("cloud_api") + .unwrap() + .endpoints[0] + .credential_binding = Some(NetworkCredentialBinding { + provider: "work-cloud".to_string(), + }); + openshell_policy::ensure_sandbox_process_identity(&mut policy); + state + .store + .put_message(&test_sandbox( + "sb-policy-binding", + "policy-binding", + policy.clone(), + vec!["work-cloud".to_string()], + )) + .await + .unwrap(); + + let config = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-policy-binding".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + let environment = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-policy-binding".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .unwrap() + .into_inner(); + + assert_eq!( + environment.environment.get("CLOUD_TOKEN"), + Some(&"cloud-secret".to_string()) + ); + assert_eq!( + environment.static_credential_bindings["CLOUD_TOKEN"].endpoints, + vec![StaticCredentialEndpointBinding { + host: "api.cloud.example".to_string(), + port: 443, + path: String::new(), + }] + ); + assert_eq!( + config.provider_env_revision, environment.provider_env_revision, + "config and provider environment must advertise one atomic revision" + ); + + let mut next_policy = policy; + next_policy + .network_policies + .get_mut("cloud_api") + .unwrap() + .endpoints[0] + .host = "api2.cloud.example".to_string(); + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "policy-binding".to_string(), + workspace: "default".to_string(), + policy: Some(next_policy), + ..Default::default() + })), + ) + .await + .expect("policy binding update must succeed"); + let next_config = handle_get_sandbox_config( + &state, + with_user(Request::new(GetSandboxConfigRequest { + sandbox_id: "sb-policy-binding".to_string(), + })), + ) + .await + .unwrap() + .into_inner(); + let next_environment = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-policy-binding".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .unwrap() + .into_inner(); + + assert_ne!( + config.provider_env_revision, next_config.provider_env_revision, + "changing the policy binding must rotate the provider environment revision" + ); + assert_eq!( + next_config.provider_env_revision, + next_environment.provider_env_revision + ); + assert_eq!( + next_environment.static_credential_bindings["CLOUD_TOKEN"].endpoints[0].host, + "api2.cloud.example" + ); + } + #[tokio::test] async fn invalid_static_binding_does_not_suppress_valid_dynamic_credentials() { use openshell_core::proto::{ @@ -11728,7 +12281,10 @@ mod tests { "default", None, &add_allow, - &[], + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, None ), apply_merge_operations_with_retry( @@ -11737,7 +12293,10 @@ mod tests { "default", None, &add_deny, - &[], + PolicyMergeValidationContext { + provider_layers: &[], + credential_binding: None, + }, None ), ); diff --git a/crates/openshell-server/src/grpc/provider.rs b/crates/openshell-server/src/grpc/provider.rs index 918ad4ceac..88f6ea1df2 100644 --- a/crates/openshell-server/src/grpc/provider.rs +++ b/crates/openshell-server/src/grpc/provider.rs @@ -587,10 +587,26 @@ pub(super) async fn load_provider_environment_records( Ok(records) } +#[cfg(test)] pub(super) async fn resolve_provider_environment_from_records( store: &Store, catalog: &EffectiveProviderProfileCatalog, records: &[ProviderEnvironmentRecord], +) -> Result { + resolve_provider_environment_from_records_with_policy_bindings( + store, + catalog, + records, + &HashMap::new(), + ) + .await +} + +pub(super) async fn resolve_provider_environment_from_records_with_policy_bindings( + store: &Store, + catalog: &EffectiveProviderProfileCatalog, + records: &[ProviderEnvironmentRecord], + policy_bindings: &HashMap>, ) -> Result { if records.is_empty() { return Ok(ProviderEnvironment::default()); @@ -628,7 +644,28 @@ pub(super) async fn resolve_provider_environment_from_records( }) .collect::>() }); - let profile_has_no_usable_endpoint = profile_endpoints.as_ref().is_some_and(Vec::is_empty); + let policy_endpoints = policy_bindings.get(name); + let effective_endpoints = match (&profile_endpoints, policy_endpoints) { + (Some(profile_endpoints), Some(_policy_endpoints)) if !profile_endpoints.is_empty() => { + return Err(Status::failed_precondition(format!( + "provider '{name}' profile already defines credential endpoints; \ + remove credential_binding from the sandbox policy endpoint" + ))); + } + (Some(profile_endpoints), Some(policy_endpoints)) => { + debug_assert!(profile_endpoints.is_empty()); + Some(policy_endpoints) + } + (Some(profile_endpoints), None) => Some(profile_endpoints), + (None, Some(_)) => { + return Err(Status::failed_precondition(format!( + "provider '{name}' has no provider profile; policy credential binding \ + requires an endpointless provider profile" + ))); + } + (None, None) => None, + }; + let has_no_usable_endpoint = effective_endpoints.is_some_and(Vec::is_empty); for (key, value) in &provider.credentials { if is_non_injectable_provider_credential(provider, key) { @@ -640,7 +677,7 @@ pub(super) async fn resolve_provider_environment_from_records( continue; } if is_valid_env_key(key) { - if profile_has_no_usable_endpoint { + if has_no_usable_endpoint { // Static credentials need a complete binding. Do not send // endpointless profile credentials as invalid metadata, // because one rejected key would revoke every unrelated @@ -671,7 +708,7 @@ pub(super) async fn resolve_provider_environment_from_records( } provider_env.insert(key.clone(), value.clone()); static_credential_keys.insert(key.clone()); - if let Some(endpoints) = &profile_endpoints { + if let Some(endpoints) = effective_endpoints { if record.object_id.is_empty() { return Err(Status::failed_precondition(format!( "provider '{name}' has no stable object identity" @@ -2118,6 +2155,17 @@ fn profiles_from_import_items( }); continue; }; + for (index, endpoint) in profile.endpoints.iter().enumerate() { + if endpoint.credential_binding.is_some() { + diagnostics.push(ProfileValidationDiagnostic { + source: source.clone(), + profile_id: profile.id.clone(), + field: format!("endpoints[{index}].credential_binding"), + message: "credential_binding references a concrete sandbox provider and is only valid in sandbox policy".to_string(), + severity: "error".to_string(), + }); + } + } profiles.push((source, ProviderTypeProfile::from_proto(profile))); } (profiles, diagnostics) @@ -2310,6 +2358,7 @@ async fn profile_attached_sandbox_diagnostics( let sandbox_name = sandbox.object_name().to_string(); let sandbox_workspace = sandbox.object_workspace().to_string(); let spec = sandbox.spec.as_ref().expect("filtered by scan_sandboxes"); + let base_policy = super::policy::current_base_policy_for_sandbox(store, &sandbox).await?; let mut bindings = Vec::new(); let mut provider_layers = Vec::new(); let mut imported_profiles_used = Vec::<(String, String)>::new(); @@ -2359,7 +2408,11 @@ async fn profile_attached_sandbox_diagnostics( !endpoint_ports(endpoint.port, &endpoint.ports).is_empty() && !endpoint.host.trim().is_empty() }); - if has_static_credentials && !has_usable_endpoint { + let has_policy_binding = super::policy::policy_has_credential_binding_for_provider( + &base_policy, + provider_name, + ); + if has_static_credentials && !has_usable_endpoint && !has_policy_binding { diagnostics.push(ProfileValidationDiagnostic { source: source.clone(), profile_id: profile_id.to_string(), @@ -2370,6 +2423,17 @@ async fn profile_attached_sandbox_diagnostics( severity: "error".to_string(), }); } + if has_usable_endpoint && has_policy_binding { + diagnostics.push(ProfileValidationDiagnostic { + source: source.clone(), + profile_id: profile_id.to_string(), + field: "endpoints".to_string(), + message: format!( + "{operation} would give provider '{provider_name}' both profile endpoint bindings and sandbox policy credential bindings on sandbox '{sandbox_name}'" + ), + severity: "error".to_string(), + }); + } bindings.extend(dynamic_token_grant_bindings_for_profile( provider.object_name(), &profile.to_proto(), @@ -2422,25 +2486,22 @@ async fn profile_attached_sandbox_diagnostics( }); } } - if validate_policy_composition { - let base_policy = - super::policy::current_base_policy_for_sandbox(store, &sandbox).await?; - if let Err(error) = + if validate_policy_composition + && let Err(error) = super::policy::validate_candidate_effective_policy(&base_policy, &provider_layers) - { - for (source, profile_id) in &imported_profiles_used { - diagnostics.push(ProfileValidationDiagnostic { - source: source.clone(), - profile_id: profile_id.clone(), - field: "endpoints".to_string(), - message: format!( - "{operation} would create ambiguous network endpoints on sandbox \ - '{sandbox_name}': {}", - error.message() - ), - severity: "error".to_string(), - }); - } + { + for (source, profile_id) in &imported_profiles_used { + diagnostics.push(ProfileValidationDiagnostic { + source: source.clone(), + profile_id: profile_id.clone(), + field: "endpoints".to_string(), + message: format!( + "{operation} would create ambiguous network endpoints on sandbox \ + '{sandbox_name}': {}", + error.message() + ), + severity: "error".to_string(), + }); } } } @@ -6716,6 +6777,60 @@ mod tests { ); } + #[tokio::test] + async fn resolve_provider_env_binds_endpointless_profile_from_policy() { + let store = test_store().await; + let mut google_cloud = google_cloud_provider(HashMap::from([( + "project_id".to_string(), + "sandbox-project".to_string(), + )])); + google_cloud.credentials = HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "google-token".to_string(), + )]); + create_provider_record(&store, "default", google_cloud) + .await + .unwrap(); + + let provider_names = ["my-google-cloud".to_string()]; + let catalog = ProviderProfileSources::with_default_sources() + .snapshot_catalog(&store, "default") + .await + .unwrap(); + let records = load_provider_environment_records(&store, "default", &provider_names) + .await + .unwrap(); + let policy_bindings = HashMap::from([( + "my-google-cloud".to_string(), + vec![StaticCredentialEndpointBinding { + host: "storage.googleapis.com".to_string(), + port: 443, + path: "/**".to_string(), + }], + )]); + + let result = resolve_provider_environment_from_records_with_policy_bindings( + &store, + &catalog, + &records, + &policy_bindings, + ) + .await + .unwrap(); + + assert_eq!( + result.get("GCP_ADC_ACCESS_TOKEN"), + Some(&"google-token".to_string()) + ); + assert_eq!( + result + .static_credential_bindings + .get("GCP_ADC_ACCESS_TOKEN") + .map(|binding| binding.endpoints.as_slice()), + Some(policy_bindings["my-google-cloud"].as_slice()) + ); + } + #[tokio::test] async fn resolve_provider_env_allows_static_provider_without_profile() { let store = test_store().await; diff --git a/crates/openshell-server/src/grpc/sandbox.rs b/crates/openshell-server/src/grpc/sandbox.rs index a2c0191c29..a12868eb91 100644 --- a/crates/openshell-server/src/grpc/sandbox.rs +++ b/crates/openshell-server/src/grpc/sandbox.rs @@ -208,6 +208,17 @@ async fn handle_create_sandbox_inner( // Ensure metadata is valid (defense in depth - should always be true for server-constructed metadata) super::validation::validate_object_metadata(sandbox.metadata.as_ref(), "sandbox")?; + super::policy::validate_candidate_provider_attachments( + state, + sandbox.object_workspace(), + &sandbox, + sandbox + .spec + .as_ref() + .map(|spec| spec.providers.as_slice()) + .unwrap_or_default(), + ) + .await?; state .compute @@ -504,10 +515,22 @@ pub(super) async fn handle_detach_sandbox_provider( .clone(); // Pre-check: fail fast if sandbox spec is missing (invariant violation) - let _spec = sandbox + let spec = sandbox .spec .as_ref() .ok_or_else(|| Status::internal("sandbox spec is missing"))?; + let mut candidate_spec = spec.clone(); + candidate_spec + .providers + .retain(|name| name != &request.provider_name); + dedupe_provider_names(&mut candidate_spec.providers); + super::policy::validate_candidate_provider_attachments( + state, + &workspace, + &sandbox, + &candidate_spec.providers, + ) + .await?; let provider_name = request.provider_name.clone(); let detached = Arc::new(AtomicBool::new(false)); @@ -2658,6 +2681,64 @@ mod tests { assert!(!response.detached); } + #[tokio::test] + async fn detach_rejects_provider_referenced_by_policy_credential_binding() { + let state = test_server_state().await; + state + .store + .put_message(&test_provider("work-gcp", "google-cloud")) + .await + .unwrap(); + + let mut sandbox = test_sandbox("work", vec!["work-gcp".to_string()]); + let policy = sandbox + .spec + .as_mut() + .and_then(|spec| spec.policy.as_mut()) + .unwrap(); + policy.network_policies.insert( + "gcp_storage".to_string(), + openshell_core::proto::NetworkPolicyRule { + name: "gcp_storage".to_string(), + endpoints: vec![openshell_core::proto::NetworkEndpoint { + host: "storage.googleapis.com".to_string(), + port: 443, + credential_binding: Some(openshell_core::proto::NetworkCredentialBinding { + provider: "work-gcp".to_string(), + }), + ..Default::default() + }], + ..Default::default() + }, + ); + state.store.put_message(&sandbox).await.unwrap(); + + let error = handle_detach_sandbox_provider( + &state, + Request::new(DetachSandboxProviderRequest { + sandbox_name: "work".to_string(), + provider_name: "work-gcp".to_string(), + expected_resource_version: 0, + workspace: String::new(), + }), + ) + .await + .expect_err("a referenced provider must remain attached"); + + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + assert!(error.message().contains("not attached")); + let providers = state + .store + .get_message_by_name::("default", "work") + .await + .unwrap() + .unwrap() + .spec + .unwrap() + .providers; + assert_eq!(providers, vec!["work-gcp"]); + } + #[tokio::test] async fn list_sandbox_providers_returns_attached_provider_records() { let state = test_server_state().await; diff --git a/crates/openshell-supervisor-network/src/policy_local.rs b/crates/openshell-supervisor-network/src/policy_local.rs index e915c18c9b..b3c60aef9b 100644 --- a/crates/openshell-supervisor-network/src/policy_local.rs +++ b/crates/openshell-supervisor-network/src/policy_local.rs @@ -1179,6 +1179,8 @@ fn network_endpoint_from_json( credential_signing: String::new(), signing_service: String::new(), signing_region: String::new(), + // policy.local proposals cannot reference a concrete sandbox provider. + credential_binding: None, }) } diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 3295f380ac..b083041906 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -229,6 +229,52 @@ def read_generic_env_vars() -> str: assert url == "NOT_SET" +def test_endpointless_profile_credentials_use_explicit_policy_binding( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + """An endpointless profile emits credentials only with an explicit binding.""" + with provider( + sandbox_client._stub, + name="e2e-test-google-cloud-policy-binding", + provider_type="google-cloud", + credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-e2e-token"}, + ) as provider_name: + policy = _default_policy() + policy.network_policies["gcp_storage"].CopyFrom( + sandbox_pb2.NetworkPolicyRule( + name="gcp_storage", + endpoints=[ + sandbox_pb2.NetworkEndpoint( + host="storage.googleapis.com", + port=443, + protocol="rest", + access="full", + credential_binding=sandbox_pb2.NetworkCredentialBinding( + provider=provider_name + ), + ) + ], + ) + ) + spec = datamodel_pb2.SandboxSpec( + policy=policy, + providers=[provider_name], + ) + + def read_gcp_token() -> str: + import os + + return os.environ.get("GCP_ADC_ACCESS_TOKEN", "NOT_SET") + + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python(read_gcp_token) + assert result.exit_code == 0, result.stderr + assert _is_placeholder_for_env_key( + result.stdout.strip(), "GCP_ADC_ACCESS_TOKEN" + ) + + def test_nvidia_provider_injects_nvidia_api_key_env_var( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, diff --git a/proto/sandbox.proto b/proto/sandbox.proto index 9ccefadefb..af90032dd2 100644 --- a/proto/sandbox.proto +++ b/proto/sandbox.proto @@ -93,6 +93,13 @@ message MiddlewareEndpointSelector { repeated string exclude = 2; } +// Binds a policy endpoint to static credentials from a sandbox provider. +message NetworkCredentialBinding { + // Name of the provider attached to this sandbox whose static credentials + // may be resolved for requests admitted by this endpoint. + string provider = 1; +} + // A network endpoint (host + port) with optional L7 inspection config. message NetworkEndpoint { // Hostname or host glob pattern. Exact match is case-insensitive. @@ -175,6 +182,10 @@ message NetworkEndpoint { uint32 json_rpc_max_body_bytes = 22; // MCP-only policy and inspection options. Only used when protocol is "mcp". McpOptions mcp = 23; + // Explicit binding authority for static credentials from an attached + // endpointless provider profile. Profiles that already define endpoints + // continue to use those profile endpoints as their credential boundary. + NetworkCredentialBinding credential_binding = 24; } // MCP options are grouped so MCP-specific policy can grow without adding more From cb21225671aaf58e21bbcfcf06f39c3e74bc128a Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:23:43 -0700 Subject: [PATCH 30/32] fix(credentials): use current GCP placeholder revision Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- .../src/provider_credentials.rs | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 87fa13df78..fa78102804 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -389,9 +389,15 @@ impl ProviderCredentialState { /// expired. The `expires_in` defaults to 3600 when expiry is unknown. pub fn gcp_token_response(&self) -> Option<(String, i64)> { const DEFAULT_EXPIRES_IN: i64 = 3600; - let resolver = self.resolver()?; + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + let resolver = inner.current_resolver.as_ref()?; for key in crate::google_cloud::TOKEN_ENV_KEYS { - let placeholder = crate::secrets::placeholder_for_env_key(key); + let Some(placeholder) = inner.current.child_env.get(*key).cloned() else { + continue; + }; if resolver.resolve_placeholder(&placeholder).is_none() { continue; } @@ -1395,9 +1401,9 @@ mod tests { HashMap::new(), ); let (placeholder, _) = state.gcp_token_response().expect("should find token"); - assert!( - placeholder.contains("GCP_SA_ACCESS_TOKEN"), - "SA token should win over ADC, got: {placeholder}" + assert_eq!( + placeholder, "openshell:resolve:env:v1_GCP_SA_ACCESS_TOKEN", + "metadata must return the current revision-scoped SA placeholder" ); } @@ -1410,7 +1416,10 @@ mod tests { HashMap::new(), ); let (placeholder, _) = state.gcp_token_response().expect("should find ADC token"); - assert!(placeholder.contains("GCP_ADC_ACCESS_TOKEN")); + assert_eq!( + placeholder, "openshell:resolve:env:v1_GCP_ADC_ACCESS_TOKEN", + "metadata must return the current revision-scoped ADC placeholder" + ); } #[test] From a71b0e75ca1c983b99777f97aab3d65bffff6da3 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:24:20 -0700 Subject: [PATCH 31/32] docs(providers): explain policy credential bindings Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- architecture/security-policy.md | 8 +++ docs/providers/aws-sigv4.mdx | 19 ++++++- docs/reference/policy-schema.mdx | 26 +++++++++- docs/sandboxes/manage-providers.mdx | 45 +++++++++-------- docs/sandboxes/providers-v2.mdx | 77 ++++++++++++++++++++++------- 5 files changed, 134 insertions(+), 41 deletions(-) diff --git a/architecture/security-policy.md b/architecture/security-policy.md index c68e9a9a1b..ff285a6a11 100644 --- a/architecture/security-policy.md +++ b/architecture/security-policy.md @@ -81,6 +81,14 @@ with the sandbox's ephemeral CA and inspect method/path or protocol-specific metadata before forwarding. The proxy also supports credential injection on terminated HTTP streams when policy allows the endpoint. +Static provider credentials have an independent endpoint-binding boundary. +Provider profile endpoints define that boundary by default. An endpointless +profile can delegate binding authority to sandbox policy through an endpoint +that names the concrete attached provider instance. The gateway rejects +unattached, profileless, endpointful, and gateway-global uses of that policy +binding. Policy endpoint changes rotate the provider-environment revision so +the supervisor installs policy and credential binding snapshots atomically. + Raw streams and long-lived response bodies are connection scoped. Policy generation changes close relays pinned to the previous generation instead of allowing them to continue under stale authorization. HTTP upgrades switch to diff --git a/docs/providers/aws-sigv4.mdx b/docs/providers/aws-sigv4.mdx index 61465fa1b8..f1753a2acf 100644 --- a/docs/providers/aws-sigv4.mdx +++ b/docs/providers/aws-sigv4.mdx @@ -12,7 +12,8 @@ AWS SigV4 credential signing lets sandbox agents call AWS services (Bedrock, S3, ## Prerequisites - A provider with `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` credentials configured. Optionally include `AWS_SESSION_TOKEN` for STS temporary credentials. -- A sandbox policy with `credential_signing` enabled on the target endpoint. +- An endpointless `aws` provider profile when the sandbox policy defines the service endpoints, or an endpoint-bearing service profile such as `aws-s3`. +- A sandbox policy with `credential_signing` enabled on the target endpoint. For the endpointless `aws` profile, the endpoint must also set `credential_binding.provider` to the attached provider name. ## Provider Setup @@ -21,6 +22,7 @@ Create a provider with AWS credentials: ```shell openshell provider create \ --name aws-prod \ + --type aws \ --credential AWS_ACCESS_KEY_ID=AKIA... \ --credential AWS_SECRET_ACCESS_KEY=wJalr... ``` @@ -30,6 +32,7 @@ For STS temporary credentials, include the session token: ```shell openshell provider create \ --name aws-sts \ + --type aws \ --credential AWS_ACCESS_KEY_ID=ASIA... \ --credential AWS_SECRET_ACCESS_KEY=secret... \ --credential AWS_SESSION_TOKEN=FwoGZX... @@ -43,10 +46,13 @@ signer reads. See [Manage Providers](/sandboxes/manage-providers#aws-sts). ## Policy Configuration -Enable SigV4 signing on a per-endpoint basis using three policy fields: +Enable SigV4 signing on a per-endpoint basis. The binding selects which +provider instance supplies credentials, while the signing fields control how +the proxy applies them: | Field | Type | Required | Description | |---|---|---|---| +| `credential_binding.provider` | string | For endpointless profiles | Exact name of the attached provider instance that supplies AWS credentials. | | `credential_signing` | string | Yes | Signing mode: `sigv4`, `sigv4:body`, or `sigv4:no_body`. | | `signing_service` | string | Yes | AWS service name for the SigV4 signature (e.g. `bedrock`, `s3`, `sts`). | | `signing_region` | string | No | AWS region override. When omitted, extracted from the endpoint hostname. Required for non-standard endpoints. | @@ -60,6 +66,8 @@ network_policies: - host: bedrock-runtime.us-east-1.amazonaws.com port: 443 protocol: rest + credential_binding: + provider: aws-prod credential_signing: sigv4 signing_service: bedrock rules: @@ -82,6 +90,8 @@ network_policies: port: 443 protocol: rest access: full + credential_binding: + provider: aws-prod credential_signing: sigv4 signing_service: s3 ``` @@ -96,6 +106,8 @@ network_policies: port: 443 protocol: rest access: full + credential_binding: + provider: aws-prod credential_signing: sigv4 signing_service: sts ``` @@ -133,6 +145,8 @@ endpoints: port: 443 protocol: rest access: full + credential_binding: + provider: aws-prod credential_signing: sigv4 signing_service: s3 signing_region: us-west-2 @@ -141,6 +155,7 @@ endpoints: ## Restrictions - `credential_signing` and `request_body_credential_rewrite` are mutually exclusive on the same endpoint. The policy validator rejects policies that set both. +- `credential_binding.provider` must name a provider attached to that sandbox. Use it only when the selected provider profile has no endpoints. Endpoint-bearing profiles already define their credential boundary. - The `sigv4:body` mode buffers at most 10 MiB. Requests with larger bodies are rejected. Use `sigv4:no_body` or `sigv4` (auto-detect) for large payloads. - The proxy requires `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in the provider. If either is missing, the request fails with an error. diff --git a/docs/reference/policy-schema.mdx b/docs/reference/policy-schema.mdx index 8ab8f636f8..4c7a172f1f 100644 --- a/docs/reference/policy-schema.mdx +++ b/docs/reference/policy-schema.mdx @@ -170,6 +170,8 @@ Each endpoint defines a reachable destination and optional inspection rules. | `credential_signing` | string | No | Proxy-side credential signing mode. When set, the proxy strips the sandbox client's `Authorization` header and re-signs with real provider credentials. Values: `sigv4` (auto-detect payload mode from client headers), `sigv4:body` (buffer and hash body, max 10 MiB), `sigv4:no_body` (unsigned payload, stream body). Mutually exclusive with `request_body_credential_rewrite`. See [AWS SigV4](/providers/aws-sigv4). | | `signing_service` | string | No | AWS service name for SigV4 signing (e.g. `bedrock`, `s3`, `sts`). Required when `credential_signing` is set. | | `signing_region` | string | No | AWS region override for SigV4 signing (e.g. `us-east-1`). When omitted, the region is extracted from the endpoint hostname. Required for non-standard AWS endpoints where the region cannot be inferred. | +| `credential_binding` | object | No | Binds static credentials from an attached provider to this endpoint when that provider's profile defines no endpoints. This field is valid only in a sandbox-scoped policy. | +| `credential_binding.provider` | string | Yes with `credential_binding` | Exact name of the provider instance attached to the sandbox. The referenced provider must have a profile, and that profile must define no endpoints. | | `persisted_queries` | string | No | GraphQL hash-only behavior for `protocol: graphql` and GraphQL-over-WebSocket operation policy. Default is `deny`; use `allow_registered` only with `graphql_persisted_queries`. | | `graphql_persisted_queries` | map | No | Trusted GraphQL persisted-query registry keyed by hash or saved-query ID. Values contain `operation_type`, optional `operation_name`, and optional root `fields`. | | `graphql_max_body_bytes` | integer | No | Maximum GraphQL-over-HTTP request body bytes buffered for inspection. Defaults to `65536`. | @@ -194,11 +196,31 @@ Each endpoint defines a reachable destination and optional inspection rules. Credential rewrite recognizes the canonical `openshell:resolve:env:KEY` placeholder form and whole-token provider-shaped aliases such as `provider-OPENSHELL-RESOLVE-ENV-API_TOKEN` when the referenced environment key exists in the configured provider credentials. Static provider placeholders also require the request host, port, and path to -match an endpoint in the provider profile. Network policy admission does not -expand that credential boundary. OpenShell rejects a mismatch with HTTP 403 and +match their credential binding. Profile endpoints supply this boundary by +default. An endpointless profile can instead use a sandbox policy endpoint with +`credential_binding.provider` set to the exact attached provider name. OpenShell +rejects unattached providers, profileless providers, endpointful profiles, and +global policies that use this field. Network policy admission does not expand +the credential boundary unless the endpoint explicitly supplies this binding. +OpenShell rejects a request mismatch with HTTP 403 and `credential_endpoint_mismatch`. Refer to [Static Credential Endpoint Binding](/sandboxes/providers-v2#understand-static-credential-endpoint-binding). +This example allows the sandbox to reach Google Cloud Storage and binds the +static credentials from the attached `work-gcp` provider to that endpoint: + +```yaml showLineNumbers={false} +network_policies: + gcp_storage: + endpoints: + - host: storage.googleapis.com + port: 443 + protocol: rest + access: full + credential_binding: + provider: work-gcp +``` + #### Access Levels The `access` field accepts one of the following values on REST, WebSocket, and GraphQL endpoints. MCP and JSON-RPC endpoints reject `access` because HTTP method/path presets cannot authorize JSON-RPC safely. Use explicit MCP rules, set `mcp.allow_all_known_mcp_methods: true` for the MCP method profile, or use explicit JSON-RPC rules. diff --git a/docs/sandboxes/manage-providers.mdx b/docs/sandboxes/manage-providers.mdx index 5cb8125066..8de44a950c 100644 --- a/docs/sandboxes/manage-providers.mdx +++ b/docs/sandboxes/manage-providers.mdx @@ -93,12 +93,11 @@ one file at a time and rejects stale resource versions. When instances of that type on the next sandbox config sync. -Static credentials require a built-in or imported provider profile with at -least one endpoint. OpenShell does not activate static credentials from a -profileless provider because it cannot determine where those credentials are -safe to use. The legacy `generic` provider type has no built-in endpoint -profile. Import a custom profile and use its ID as `--type` for custom -credentials. +Static credentials require a built-in or imported provider profile. Profiles +normally define at least one endpoint. An endpointless profile requires an +explicit `credential_binding.provider` on a sandbox policy endpoint. OpenShell +does not activate static credentials from a profileless provider because it +cannot determine which credential definition applies. ## Manage Providers @@ -260,7 +259,8 @@ openshell sandbox create --provider my-claude --provider my-github -- claude Each `--provider` flag attaches one provider. The sandbox receives eligible credentials from every attached provider as placeholders at runtime. Each -static credential resolves only for endpoints in that provider's profile. +static credential resolves only for endpoints in that provider's profile, or +for explicitly bound sandbox policy endpoints when the profile is endpointless. Profile-managed providers also contribute provider-generated network policy entries when `providers_v2_enabled` is enabled at the gateway. When the setting is disabled, endpoint binding still applies, but provider-generated policy does @@ -303,18 +303,22 @@ placeholder, the proxy resolves it immediately before forwarding the request. Static credential resolution has two independent authorization boundaries: 1. Network policy must allow the calling binary and request destination. -2. The credential's provider profile must include the request host, port, and - path. +2. The credential binding must include the request host, port, and path. Profile + endpoints provide the binding by default. An endpointless profile can use a + sandbox policy endpoint that names the attached provider instance through + `credential_binding.provider`. -Both checks must pass. Adding an endpoint to sandbox policy does not expand -where an attached provider credential can resolve. Likewise, a provider profile -endpoint does not grant network access unless provider policy composition or -the sandbox's own policy allows the request. +Both checks must pass. A provider profile endpoint does not grant network access +unless provider policy composition or the sandbox's own policy allows the +request. A plain sandbox policy endpoint does not grant credential use unless +the profile already covers it or the endpoint explicitly binds an endpointless +provider. Endpoint binding applies whether `providers_v2_enabled` is enabled or disabled. The setting controls provider policy composition only. Every static credential -declared by a provider currently receives the complete endpoint set from that -provider's profile. +declared by a provider receives the complete endpoint set from that provider's +profile, or the explicitly bound sandbox policy endpoints for an endpointless +profile. Credential resolution requires the proxy to handle the request as HTTP. Raw `tls: skip` and non-HTTP tunnels remain opaque and do not support credential @@ -340,7 +344,7 @@ bodies, or WebSocket binary frames. ### Fail-closed behavior -If policy allows a request but the credential's profile does not include the +If policy allows a request but the credential binding does not include the request endpoint, the proxy rejects the request with HTTP 403 and the `credential_endpoint_mismatch` reason. It emits a denied activity event and a security finding without recording the secret, placeholder, environment key, or @@ -362,10 +366,11 @@ the profile's `api.github.com:443` and `github.com:443` endpoints. Even if a sandbox policy allows `uploads.example.com:443`, sending either placeholder there returns `credential_endpoint_mismatch`. -For a custom service, define the credential and its allowed endpoints in a -custom provider profile, import the profile, and create the provider with the -profile ID as its type. Refer to [Provider Profiles](/sandboxes/providers-v2#provider-profiles) -for the profile workflow and schema. +For a custom service, define the credential in a custom provider profile. Put +stable endpoints in the profile, or leave the profile endpointless and bind +each concrete provider instance from sandbox policy. Refer to [Provider +Profiles](/sandboxes/providers-v2#provider-profiles) for the profile workflow +and schema. ## Supported Provider Types diff --git a/docs/sandboxes/providers-v2.mdx b/docs/sandboxes/providers-v2.mdx index ae2369ee90..84bcad219a 100644 --- a/docs/sandboxes/providers-v2.mdx +++ b/docs/sandboxes/providers-v2.mdx @@ -25,7 +25,7 @@ Providers v2 keeps those pieces together: | Custom provider definitions | You can export, edit, lint, import, list, and delete custom profiles. | | Runtime provider lifecycle | You can list, attach, and detach providers on existing sandboxes. | | Credential rotation | Provider refresh metadata lets the gateway refresh short-lived access tokens and update provider records. | -| Credential transport | Credential delivery uses environment placeholders and proxy rewrite. Static placeholders resolve only at profile endpoints. | +| Credential transport | Credential delivery uses environment placeholders and proxy rewrite. Static placeholders resolve only at profile endpoints or explicitly bound sandbox policy endpoints for endpointless profiles. | ## Enable Providers v2 @@ -62,14 +62,16 @@ Providers v2 currently includes these user-facing features: - Credential refresh configuration with `openshell provider refresh status|configure|rotate|delete`. - Credential expiry metadata with `openshell provider update --credential-expires-at`; values accept Unix epoch milliseconds or ISO/RFC3339 timestamps. - Dynamic token grants that use the sandbox's SPIFFE JWT-SVID as an OAuth2 client assertion and inject short-lived tokens into supported headers for matching profile endpoints. -- Endpoint-bound static credential placeholders. The sandbox proxy resolves a static credential only for request hosts, ports, and paths declared by its provider profile. +- Endpoint-bound static credential placeholders. The sandbox proxy resolves a static credential only for request hosts, ports, and paths declared by its provider profile or explicitly bound in sandbox policy for an endpointless profile. ## Understand Static Credential Endpoint Binding Static credential endpoint binding prevents a placeholder for one service from resolving on a different policy-allowed service. OpenShell associates every -static credential environment key with the endpoints in its provider profile. -The proxy checks that association before it substitutes the real value. +static credential environment key with an endpoint boundary. Profile endpoints +supply that boundary by default. For an endpointless profile, a sandbox policy +endpoint can name the attached provider instance explicitly. The proxy checks +the resulting association before it substitutes the real value. A request can use a static credential only when all of these checks pass: @@ -77,14 +79,14 @@ A request can use a static credential only when all of these checks pass: |---|---| | The placeholder belongs to the current attached provider state. | Sandbox provider attachment and current provider record. | | The calling binary and destination are allowed. | Effective sandbox network policy. | -| The host, port, and canonical request path match the credential binding. | Provider profile endpoints. | +| The host, port, and canonical request path match the credential binding. | Provider profile endpoints, or an explicit sandbox policy binding for an endpointless profile. | | The HTTP method, path, or protocol operation is allowed when L7 inspection is configured. | Effective sandbox network policy. | | The credential has not expired. | Provider credential expiry metadata. | Network policy and credential binding serve different purposes. Network policy -authorizes traffic. Provider profile endpoints authorize use of the provider's -credentials. A sandbox policy allow cannot widen a credential binding, and a -credential binding cannot widen sandbox network policy. +authorizes traffic. A credential binding authorizes use of one provider +instance's credentials at an admitted endpoint. A credential binding cannot +widen sandbox network policy. For example, this profile endpoint binds all static credential environment keys from the profile to `api.example.com:443` under `/v1`: @@ -102,6 +104,49 @@ glob matching. OpenShell removes the query string and uses a canonical, secret-redacted path for this check, so a credential embedded in a request path does not need to be revealed before authorization. +Use an explicit sandbox policy binding when the profile intentionally defines +credentials without defining service endpoints. The policy names the concrete +provider instance, not the profile type: + +```yaml showLineNumbers={false} +network_policies: + gcp_storage: + endpoints: + - host: storage.googleapis.com + port: 443 + protocol: rest + access: full + credential_binding: + provider: work-gcp +``` + +The provider must be attached to the sandbox and must select an endpointless +profile. OpenShell rejects the complete policy update if the provider is +unattached, has no profile, or selects a profile that already defines endpoints. +This keeps one source of credential-binding authority for each provider. +`credential_binding` is sandbox-scoped and is not accepted in a gateway-global +policy. + +For AWS endpoints, the binding and signing fields have separate jobs. +`credential_binding.provider` selects the provider instance that supplies +credentials. `credential_signing`, `signing_service`, and `signing_region` +control how the proxy applies those credentials: + +```yaml showLineNumbers={false} +network_policies: + aws_s3: + endpoints: + - host: s3.us-west-2.amazonaws.com + port: 443 + protocol: rest + access: full + credential_binding: + provider: work-aws + credential_signing: sigv4 + signing_service: s3 + signing_region: us-west-2 +``` + The binding applies to CONNECT and forward-proxy HTTP requests, including headers, Basic and Bearer authorization, URL paths, query parameters, opted-in request bodies, AWS SigV4 signing, and opted-in WebSocket text messages. Raw @@ -130,13 +175,11 @@ the provider revokes resolution for placeholders already held by running processes. -Static credentials from a selected provider profile require at least one usable -endpoint. OpenShell withholds only the static credential keys and associated -expiry and binding metadata from an endpointless selected profile. It retains -that provider's generated non-secret configuration and valid endpoint-bound -static credentials from other attached providers. For a custom static -credential, import a profile with the intended endpoint selectors and use that -profile as the provider type. +Static credentials require at least one usable binding. OpenShell withholds only +the static credential keys and associated expiry and binding metadata from an +endpointless selected profile when no sandbox policy endpoint explicitly binds +that provider. It retains that provider's generated non-secret configuration +and valid endpoint-bound static credentials from other attached providers. After upgrading a gateway and supervisor to a release with endpoint binding, @@ -365,7 +408,7 @@ binaries: `category` groups profiles in `openshell provider list-profiles`. Use one of the values in the category enum. -`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at profile endpoints. Every static credential environment key currently receives the full profile endpoint set. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. +`credentials` declares the credential names, environment variables, auth metadata, optional refresh metadata, and optional dynamic token grant metadata for the provider type. The `auth_style` field accepts `basic`, `bearer`, `header`, `query`, or `path`. When `auth_style` is `path`, set `path_template` to a URL path containing the `{credential}` placeholder exactly once (for example, `/v1/{credential}/resources`). Static credentials are exposed as placeholder environment variables and resolved in outbound HTTP requests only at their binding endpoints. Every static credential environment key receives the full profile endpoint set when the profile defines endpoints. An endpointless profile requires explicit sandbox policy bindings for each attached provider instance. Dynamic token grants are resolved by the sandbox proxy on demand for matching profile endpoints and support `bearer` or `header` placement. Credential environment variable names must not use the reserved `v_` prefix, such as `v10_GITHUB_TOKEN`, because OpenShell uses that namespace for revision-scoped placeholders. `discovery` controls what `--from-existing` scans when `providers_v2_enabled=true`. Each entry in `discovery.credentials` must name a @@ -527,7 +570,7 @@ Use an ISO/RFC3339 timestamp or Unix epoch milliseconds. Use `0` as the timestam OpenShell skips expired provider credentials when it builds a sandbox provider environment. Running sandboxes also reject expired retained credential generations during placeholder resolution, so stale placeholders fail closed instead of forwarding unresolved or expired credential material. -The gateway sends a complete host, port, and path binding for every emitted static credential key. It withholds static credential keys from endpointless selected profiles with their expiry and binding metadata. Supervisors reject other incomplete binding metadata and clear previously active provider material when a refresh fails validation. Refer to [Static Credential Endpoint Binding](#understand-static-credential-endpoint-binding) for matching, denial, lifecycle, and migration behavior. +The gateway sends a complete host, port, and path binding for every emitted static credential key. It derives bindings from profile endpoints or explicit sandbox policy endpoints for endpointless profiles. It withholds static credential keys from endpointless selected profiles that have no explicit policy binding. Supervisors reject other incomplete binding metadata and clear previously active provider material when a refresh fails validation. Refer to [Static Credential Endpoint Binding](#understand-static-credential-endpoint-binding) for matching, denial, lifecycle, and migration behavior. ## Configure Credential Refresh From addce2a7c463649807e78384817dec0bdb8aba75 Mon Sep 17 00:00:00 2001 From: John Myers <9696606+johntmyers@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:29:45 -0700 Subject: [PATCH 32/32] test(credentials): cover endpointless fail-closed invariant Signed-off-by: John Myers <9696606+johntmyers@users.noreply.github.com> --- crates/openshell-server/src/grpc/policy.rs | 46 +++++++++++++++++++++- e2e/python/test_sandbox_providers.py | 27 +++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/openshell-server/src/grpc/policy.rs b/crates/openshell-server/src/grpc/policy.rs index 35d2a736b6..a5cb910c45 100644 --- a/crates/openshell-server/src/grpc/policy.rs +++ b/crates/openshell-server/src/grpc/policy.rs @@ -7238,7 +7238,7 @@ mod tests { with_user(Request::new(UpdateConfigRequest { name: "policy-binding".to_string(), workspace: "default".to_string(), - policy: Some(next_policy), + policy: Some(next_policy.clone()), ..Default::default() })), ) @@ -7276,6 +7276,50 @@ mod tests { next_environment.static_credential_bindings["CLOUD_TOKEN"].endpoints[0].host, "api2.cloud.example" ); + + let mut unbound_policy = next_policy; + unbound_policy + .network_policies + .get_mut("cloud_api") + .unwrap() + .endpoints[0] + .credential_binding = None; + handle_update_config( + &state, + with_user(Request::new(UpdateConfigRequest { + name: "policy-binding".to_string(), + workspace: "default".to_string(), + policy: Some(unbound_policy), + ..Default::default() + })), + ) + .await + .expect("removing a policy binding must succeed"); + let unbound_environment = handle_get_sandbox_provider_environment( + &state, + with_user(Request::new(GetSandboxProviderEnvironmentRequest { + sandbox_id: "sb-policy-binding".to_string(), + supports_static_credential_bindings: true, + })), + ) + .await + .unwrap() + .into_inner(); + + assert_ne!( + next_environment.provider_env_revision, unbound_environment.provider_env_revision, + "removing the binding must rotate the provider environment revision" + ); + assert!( + !unbound_environment.environment.contains_key("CLOUD_TOKEN"), + "removing the only binding must withhold the static credential" + ); + assert!( + !unbound_environment + .static_credential_bindings + .contains_key("CLOUD_TOKEN"), + "an endpointless profile must not emit incomplete binding metadata" + ); } #[tokio::test] diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index b083041906..7262a52910 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -229,6 +229,33 @@ def read_generic_env_vars() -> str: assert url == "NOT_SET" +def test_endpointless_profile_credentials_fail_closed_without_policy_binding( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + """Endpointless profile credentials are withheld without an explicit binding.""" + with provider( + sandbox_client._stub, + name="e2e-test-google-cloud-without-policy-binding", + provider_type="google-cloud", + credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-e2e-token"}, + ) as provider_name: + spec = datamodel_pb2.SandboxSpec( + policy=_default_policy(), + providers=[provider_name], + ) + + def read_gcp_token() -> str: + import os + + return os.environ.get("GCP_ADC_ACCESS_TOKEN", "NOT_SET") + + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python(read_gcp_token) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "NOT_SET" + + def test_endpointless_profile_credentials_use_explicit_policy_binding( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient,