From ed234d71ee5f9677e6bce4fed21d93b3b01e21b4 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Wed, 30 Sep 2026 18:46:36 +0200 Subject: [PATCH 1/3] fix(providers): restore supervisor-backed GCP metadata discovery Relay the reserved metadata endpoint to the supervisor and restore project, account, and placeholder token responses from live provider state. Cover Google SDK discovery and repeated refresh with provider E2E tests. Closes #3860 Signed-off-by: Philippe Martin --- crates/openshell-core/src/google_cloud.rs | 22 +- .../src/provider_credentials.rs | 20 + .../openshell-sandbox/src/network_broker.rs | 74 +- .../src/google_cloud_metadata.rs | 765 ++++++++++++++++++ .../openshell-supervisor-network/src/lib.rs | 1 + .../openshell-supervisor-network/src/proxy.rs | 250 +++++- docs/how-it-works/providers/google.mdx | 25 +- e2e/python/Dockerfile.workload | 2 +- e2e/python/test_sandbox_providers.py | 106 +++ skills/generate-sandbox-policy/SKILL.md | 4 +- 10 files changed, 1259 insertions(+), 10 deletions(-) create mode 100644 crates/openshell-supervisor-network/src/google_cloud_metadata.rs diff --git a/crates/openshell-core/src/google_cloud.rs b/crates/openshell-core/src/google_cloud.rs index fcab45ae08..efc15a6527 100644 --- a/crates/openshell-core/src/google_cloud.rs +++ b/crates/openshell-core/src/google_cloud.rs @@ -14,10 +14,15 @@ /// Hostname served by the GCE metadata emulator via proxy interception. pub const METADATA_HOST: &str = "gcp.metadata.openshell.internal"; -/// Loopback address for the GCE metadata server inside sandbox namespaces. +/// Reserved loopback destination relayed to the supervisor metadata emulator. /// Go's metadata client dials this directly (bypasses `HTTP_PROXY`). pub const METADATA_LOOPBACK_ADDR: &str = "127.0.0.1:8174"; +/// Match only the reserved metadata service, never a host cloud metadata IP. +pub fn is_metadata_destination(destination: std::net::SocketAddr) -> bool { + destination == std::net::SocketAddr::from(([127, 0, 0, 1], 8174)) +} + // ── Env var alias arrays ──────────────────────────────────────────────────── /// Env vars that carry the GCP project ID inside sandboxes. @@ -85,6 +90,21 @@ mod tests { use super::*; use std::collections::HashSet; + #[test] + fn metadata_destination_matches_only_reserved_loopback_endpoint() { + assert!(is_metadata_destination( + METADATA_LOOPBACK_ADDR.parse().unwrap() + )); + for address in [ + "127.0.0.1:8175", + "127.0.0.2:8174", + "169.254.169.254:80", + "[::1]:8174", + ] { + assert!(!is_metadata_destination(address.parse().unwrap())); + } + } + #[test] fn static_config_keys_matches_alias_arrays_and_vertex_vars() { let expected: HashSet<&str> = PROJECT_ID_ENV_VARS diff --git a/crates/openshell-core/src/provider_credentials.rs b/crates/openshell-core/src/provider_credentials.rs index 6988d1f4be..8e3a5fe850 100644 --- a/crates/openshell-core/src/provider_credentials.rs +++ b/crates/openshell-core/src/provider_credentials.rs @@ -573,6 +573,26 @@ impl ProviderCredentialState { Ok(revision) } + /// Read current provider configuration only when explicitly classified non-secret. + /// + /// Local metadata adapters must not unwrap credential values just because their + /// environment names match a conventional configuration key. + pub fn current_non_secret_environment_value(&self, key: &str) -> Option { + let inner = self + .inner + .read() + .expect("provider credential state poisoned"); + if !inner.non_secret_environment_keys.contains(key) { + return None; + } + let placeholder = inner.current.child_env.get(key)?; + inner + .current_resolver + .as_ref()? + .resolve_placeholder(placeholder) + .map(str::to_string) + } + /// Return the GCP token placeholder and its remaining lifetime in seconds. /// /// Searches `google_cloud::TOKEN_ENV_KEYS` in priority order (SA before diff --git a/crates/openshell-sandbox/src/network_broker.rs b/crates/openshell-sandbox/src/network_broker.rs index 56c857ce9c..9cdb064bcf 100644 --- a/crates/openshell-sandbox/src/network_broker.rs +++ b/crates/openshell-sandbox/src/network_broker.rs @@ -837,7 +837,11 @@ fn connect_socket( entry.release_preconnect(); return listener.respond_value(notification.id, 0); } - if destination.ip().is_loopback() { + // The metadata service lives in the supervisor, even though SDKs address + // it through loopback. Relay it before the ordinary local socket path. + if destination.ip().is_loopback() + && !openshell_core::google_cloud::is_metadata_destination(destination) + { let mut registry = lock(®istry); let entry = registry.resolve_mut(notification.tid, fd)?; connect_exact(entry.retained_preconnect()?.as_raw_fd(), destination)?; @@ -2090,6 +2094,74 @@ mod tests { ); } + #[test] + fn metadata_reservation_preserves_other_loopback_and_rejects_udp() { + let (launcher, listener) = + openshell_isolation_interface::linux::workload_launcher::start().unwrap(); + let _broker = NetworkBroker::start_for_test(listener).unwrap(); + let local_server = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = local_server.local_addr().unwrap(); + let connection = launcher + .execute(move || TcpStream::connect(address)) + .unwrap() + .unwrap(); + assert_eq!(connection.peer_addr().unwrap(), address); + let error = launcher + .execute(|| { + let socket = UdpSocket::bind("127.0.0.1:0")?; + socket.connect(openshell_core::google_cloud::METADATA_LOOPBACK_ADDR) + }) + .unwrap() + .unwrap_err(); + assert_eq!(error.raw_os_error(), Some(libc::EACCES)); + } + + #[test] + fn metadata_loopback_connect_is_relayed_to_supervisor() { + use std::io::{Read as _, Write as _}; + let (launcher, listener) = + openshell_isolation_interface::linux::workload_launcher::start().unwrap(); + let broker = NetworkBroker::start_for_test(listener).unwrap(); + let client = std::thread::spawn(move || { + launcher + .execute(|| { + let mut stream = + TcpStream::connect(openshell_core::google_cloud::METADATA_LOOPBACK_ADDR)?; + stream.write_all(b"metadata-probe")?; + let mut reply = [0; 2]; + stream.read_exact(&mut reply)?; + Ok::<_, io::Error>(reply) + }) + .unwrap() + }); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async { + let pending = tokio::time::timeout(Duration::from_secs(5), broker.accept()) + .await + .unwrap() + .unwrap(); + assert!(openshell_core::google_cloud::is_metadata_destination( + pending.destination + )); + let stream = pending + .complete(TcpOpenDecision::RelayReady) + .await + .unwrap() + .unwrap(); + stream.set_nonblocking(true).unwrap(); + let mut stream = tokio::net::TcpStream::from_std(stream).unwrap(); + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + let mut probe = [0; 14]; + stream.read_exact(&mut probe).await.unwrap(); + assert_eq!(&probe, b"metadata-probe"); + stream.write_all(b"ok").await.unwrap(); + }); + assert_eq!(&client.join().unwrap().unwrap(), b"ok"); + } + #[test] fn external_connect_times_out_when_supervisor_retains_the_decision() { let (launcher, listener) = openshell_isolation_interface::linux::workload_launcher::start() diff --git a/crates/openshell-supervisor-network/src/google_cloud_metadata.rs b/crates/openshell-supervisor-network/src/google_cloud_metadata.rs new file mode 100644 index 0000000000..c5278601f0 --- /dev/null +++ b/crates/openshell-supervisor-network/src/google_cloud_metadata.rs @@ -0,0 +1,765 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! GCE metadata server emulator for sandbox credential injection. +//! +//! Implements a subset of the GCE instance metadata API so that GCP client +//! libraries (Go, Python, Node.js) can obtain `OAuth2` tokens natively inside +//! sandboxes. Tokens are served from the existing `ProviderCredentialState` +//! store — no separate refresh mechanism is needed. +//! +//! The sandbox broker relays the reserved loopback metadata destination to +//! this supervisor-owned handler. SDKs discover it through `GCE_METADATA_HOST`; +//! real credentials remain outside the workload boundary. + +use http::StatusCode; +use miette::{IntoDiagnostic, Result}; +use openshell_core::provider_credentials::ProviderCredentialState; +use openshell_ocsf::{ + ActivityId, HttpActivityBuilder, HttpRequest, SeverityId, StatusId, ocsf_emit, +}; +use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt}; + +type MetadataResponse = (u16, &'static str, String); + +const PATH_SERVICE_ACCOUNTS: &str = "/computeMetadata/v1/instance/service-accounts"; +const PATH_SERVICE_ACCOUNT_DEFAULT: &str = "/computeMetadata/v1/instance/service-accounts/default"; +const PATH_TOKEN: &str = "/computeMetadata/v1/instance/service-accounts/default/token"; +const PATH_EMAIL: &str = "/computeMetadata/v1/instance/service-accounts/default/email"; +const PATH_SCOPES: &str = "/computeMetadata/v1/instance/service-accounts/default/scopes"; +const PATH_ALIASES: &str = "/computeMetadata/v1/instance/service-accounts/default/aliases"; +const PATH_PROJECT_ID: &str = "/computeMetadata/v1/project/project-id"; + +const ENV_GCP_PROJECT_ID: &str = openshell_core::google_cloud::PROJECT_ID_ENV_VARS[0]; +const ENV_GCP_SERVICE_ACCOUNT_EMAIL: &str = + openshell_core::google_cloud::SERVICE_ACCOUNT_EMAIL_ENV_VARS[0]; + +const METADATA_FLAVOR_HEADER: &str = "metadata-flavor"; +const METADATA_FLAVOR_VALUE: &str = "Google"; +const X_FORWARDED_FOR_HEADER: &str = "x-forwarded-for"; + +#[derive(Debug, Clone)] +pub struct MetadataContext { + credentials: ProviderCredentialState, +} + +impl MetadataContext { + pub fn new(credentials: ProviderCredentialState) -> Self { + Self { credentials } + } +} + +pub async fn handle_forward_request( + ctx: &MetadataContext, + method: &str, + path: &str, + initial_request: &[u8], + client: &mut S, +) -> Result<()> +where + S: AsyncRead + AsyncWrite + Unpin, +{ + let headers = parse_request_headers(initial_request); + let (status, content_type, body) = route_request(ctx, method, path, &headers); + write_metadata_response(client, status, content_type, &body).await +} + +fn route_request( + ctx: &MetadataContext, + method: &str, + path: &str, + headers: &[(String, String)], +) -> MetadataResponse { + if method != "GET" { + let status = StatusCode::METHOD_NOT_ALLOWED.as_u16(); + emit_metadata_event( + method, + status, + SeverityId::Low, + StatusId::Failure, + &format!("metadata: unsupported method {method}"), + ); + return (status, "text/html", "Method Not Allowed".to_string()); + } + + if let Err(resp) = validate_metadata_headers(headers) { + emit_metadata_event( + method, + resp.0, + SeverityId::Medium, + StatusId::Failure, + &format!( + "metadata: header validation failed for {}", + path.split('?').next().unwrap_or(path) + ), + ); + return resp; + } + + let (route, query) = path.split_once('?').map_or((path, ""), |(r, q)| (r, q)); + let route = route.strip_suffix('/').unwrap_or(route); + let recursive = query.split('&').any(|p| p == "recursive=true"); + let account_route = ctx + .credentials + .current_non_secret_environment_value(ENV_GCP_SERVICE_ACCOUNT_EMAIL) + .filter(|email| !email.is_empty() && !email.contains('/')) + .and_then(|email| { + let suffix = route.strip_prefix(&format!("{PATH_SERVICE_ACCOUNTS}/{email}"))?; + (suffix.is_empty() || suffix.starts_with('/')) + .then(|| format!("{PATH_SERVICE_ACCOUNT_DEFAULT}{suffix}")) + }); + let route = account_route.as_deref().unwrap_or(route); + + match route { + PATH_TOKEN => handle_token(ctx, method), + PATH_EMAIL => handle_env(ctx, method, ENV_GCP_SERVICE_ACCOUNT_EMAIL), + PATH_PROJECT_ID => handle_env(ctx, method, ENV_GCP_PROJECT_ID), + PATH_ALIASES => (200, "text/plain", "default\n".to_string()), + PATH_SCOPES => ( + 200, + "text/plain", + "https://www.googleapis.com/auth/cloud-platform".to_string(), + ), + PATH_SERVICE_ACCOUNT_DEFAULT => { + if recursive { + handle_service_account_recursive(ctx) + } else { + ( + 200, + "text/plain", + "aliases\nemail\nscopes\ntoken\n".to_string(), + ) + } + } + PATH_SERVICE_ACCOUNTS => (200, "text/plain", "default/\n".to_string()), + "" | "/" | "/computeMetadata" | "/computeMetadata/v1" => { + (200, "text/plain", "computeMetadata/\n".to_string()) + } + "/computeMetadata/v1/instance" => (200, "text/plain", "service-accounts/\n".to_string()), + _ => { + let status = StatusCode::NOT_FOUND.as_u16(); + emit_metadata_event( + method, + status, + SeverityId::Low, + StatusId::Failure, + &format!("metadata: unknown path {route}"), + ); + ( + status, + "application/json", + serde_json::json!({"error": "not_found"}).to_string(), + ) + } + } +} + +fn handle_token(ctx: &MetadataContext, method: &str) -> MetadataResponse { + let Some((placeholder, expires_in)) = ctx.credentials.gcp_token_response() else { + let status = StatusCode::SERVICE_UNAVAILABLE.as_u16(); + let has_resolver = ctx.credentials.resolver().is_some(); + let (msg, error_key) = if has_resolver { + ( + "metadata: no GCP access token available or expired", + "token_unavailable", + ) + } else { + ( + "metadata: token request but no credentials configured", + "credentials_unavailable", + ) + }; + emit_metadata_event(method, status, SeverityId::Medium, StatusId::Failure, msg); + return ( + status, + "application/json", + serde_json::json!({"error": error_key}).to_string(), + ); + }; + + let status = StatusCode::OK.as_u16(); + emit_metadata_event( + method, + status, + SeverityId::Informational, + StatusId::Success, + "metadata: token placeholder served", + ); + + let body = serde_json::json!({ + "access_token": placeholder, + "expires_in": expires_in, + "token_type": "Bearer" + }); + (status, "application/json", body.to_string()) +} + +fn handle_service_account_recursive(ctx: &MetadataContext) -> MetadataResponse { + let email = ctx + .credentials + .current_non_secret_environment_value(ENV_GCP_SERVICE_ACCOUNT_EMAIL) + .unwrap_or_default(); + + let scopes = "https://www.googleapis.com/auth/cloud-platform"; + + let body = serde_json::json!({ + "aliases": ["default"], + "email": email, + "scopes": [scopes], + }); + (200, "application/json", body.to_string()) +} + +/// Serve a non-secret config value (project ID, SA email) as plain text. +/// +/// Unlike `handle_token` which serves placeholders, this resolves to the real +/// value. This matches real GCE metadata server behavior and is safe because +/// these values are non-secret configuration (project IDs, email addresses). +fn handle_env(ctx: &MetadataContext, method: &str, env_key: &str) -> MetadataResponse { + ctx.credentials + .current_non_secret_environment_value(env_key) + .map_or_else( + || { + let status = StatusCode::NOT_FOUND.as_u16(); + emit_metadata_event( + method, + status, + SeverityId::Low, + StatusId::Failure, + &format!("metadata: {env_key} not configured as non-secret"), + ); + ( + status, + "application/json", + serde_json::json!({"error": "not_found"}).to_string(), + ) + }, + |value| (200, "text/plain", value), + ) +} + +fn validate_metadata_headers(headers: &[(String, String)]) -> Result<(), MetadataResponse> { + if headers + .iter() + .any(|(name, _)| name.eq_ignore_ascii_case(X_FORWARDED_FOR_HEADER)) + { + return Err((403, "text/html", "Forbidden".to_string())); + } + + let has_flavor = headers.iter().any(|(name, value)| { + name.eq_ignore_ascii_case(METADATA_FLAVOR_HEADER) + && value.trim().eq_ignore_ascii_case(METADATA_FLAVOR_VALUE) + }); + if !has_flavor { + return Err((403, "text/html", "Forbidden".to_string())); + } + + Ok(()) +} + +fn parse_request_headers(raw: &[u8]) -> Vec<(String, String)> { + let request = String::from_utf8_lossy(raw); + let mut headers = Vec::new(); + for line in request.split("\r\n").skip(1) { + if line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + headers.push((name.trim().to_string(), value.trim().to_string())); + } + } + headers +} + +fn status_text(status: u16) -> &'static str { + match status { + 403 => "Forbidden", + 404 => "Not Found", + 405 => "Method Not Allowed", + 503 => "Service Unavailable", + _ => "OK", + } +} + +async fn write_metadata_response( + client: &mut S, + status: u16, + content_type: &str, + body: &str, +) -> Result<()> +where + S: AsyncWrite + Unpin, +{ + let response = format!( + "HTTP/1.1 {status} {}\r\nContent-Type: {content_type}\r\nMetadata-Flavor: Google\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + status_text(status), + body.len(), + ); + client + .write_all(response.as_bytes()) + .await + .into_diagnostic()?; + client.flush().await.into_diagnostic()?; + Ok(()) +} + +fn emit_metadata_event( + method: &str, + response_code: u16, + severity: SeverityId, + status: StatusId, + message: &str, +) { + ocsf_emit!(build_metadata_event( + method, + response_code, + severity, + status, + message + )); +} + +fn build_metadata_event( + method: &str, + response_code: u16, + severity: SeverityId, + status: StatusId, + message: &str, +) -> openshell_ocsf::OcsfEvent { + HttpActivityBuilder::new(openshell_ocsf::ctx::ctx()) + .activity(ActivityId::for_http_method(method)) + .http_request(HttpRequest { + http_method: method.parse().expect("HTTP method parsing is infallible"), + url: None, + }) + .http_response(openshell_ocsf::HttpResponse { + code: response_code, + }) + .severity(severity) + .status(status) + .message(message.to_string()) + .build() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn token_binding() -> openshell_core::proto::StaticCredentialBinding { + openshell_core::proto::StaticCredentialBinding { + endpoints: vec![openshell_core::proto::StaticCredentialEndpointBinding { + host: "storage.googleapis.com".into(), + port: 443, + path: "/**".into(), + }], + credential_identity: "test-google:token".into(), + workload_credential_handle: String::new(), + } + } + + fn make_context(env: HashMap) -> MetadataContext { + let config_keys = [ENV_GCP_PROJECT_ID, ENV_GCP_SERVICE_ACCOUNT_EMAIL] + .into_iter() + .filter(|key| env.contains_key(*key)) + .map(str::to_string) + .collect(); + let bindings = openshell_core::google_cloud::TOKEN_ENV_KEYS + .iter() + .filter(|key| env.contains_key(**key)) + .map(|key| ((*key).to_string(), token_binding())) + .collect(); + let state = ProviderCredentialState::from_bound_environment( + 0, + env, + HashMap::new(), + HashMap::new(), + bindings, + config_keys, + ) + .unwrap(); + MetadataContext::new(state) + } + + fn make_context_with_expiry( + env: HashMap, + expires: HashMap, + ) -> MetadataContext { + let state = ProviderCredentialState::from_environment(0, env, expires, HashMap::new()); + MetadataContext::new(state) + } + + fn flavor_headers() -> Vec<(String, String)> { + vec![("Metadata-Flavor".to_string(), "Google".to_string())] + } + + #[test] + fn metadata_events_include_response_for_ocsf18() { + use openshell_ocsf::tracing_layers::OcsfJsonlLayer; + use openshell_ocsf::validation::{ + load_class_schema, validate_enum_value, validate_required_fields, + }; + use tracing_subscriber::prelude::*; + + let schema = load_class_schema("http_activity"); + for (method, path, headers, expected_code, expected_activity_id) in [ + ("GET", PATH_TOKEN, flavor_headers(), 200, 3), + ("GET", "/?token=secret-query", Vec::new(), 403, 3), + ("GET", "/unknown", flavor_headers(), 404, 3), + ("POST", PATH_TOKEN, flavor_headers(), 405, 6), + ("GET", PATH_TOKEN, flavor_headers(), 503, 3), + ("GET", PATH_EMAIL, flavor_headers(), 404, 3), + ] { + let env = if expected_code == 503 { + HashMap::new() + } else { + HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), "test-token".to_string())]) + }; + let ctx = make_context(env); + let log = tempfile::NamedTempFile::new().unwrap(); + let subscriber = + tracing_subscriber::registry().with(OcsfJsonlLayer::new(log.reopen().unwrap())); + let response = tracing::subscriber::with_default(subscriber, || { + route_request(&ctx, method, path, &headers) + }); + assert_eq!(response.0, expected_code); + let output = std::fs::read_to_string(log.path()).unwrap(); + let json: serde_json::Value = serde_json::from_str(&output).unwrap(); + assert_eq!(json["http_response"]["code"], response.0); + assert!(!output.contains("secret-query"), "{output}"); + assert_eq!( + json["activity_id"], expected_activity_id, + "method: {method}" + ); + assert_eq!(json["http_request"]["http_method"], method); + assert!(json["http_request"].get("url").is_none()); + validate_required_fields(&json, &schema); + validate_enum_value(&json, "activity_id", &schema); + } + } + + #[test] + fn metadata_tracks_current_credentials_and_provider_removal() { + let ctx = make_context(HashMap::from([ + ("GCP_ADC_ACCESS_TOKEN".into(), "old-secret".into()), + ("GCP_PROJECT_ID".into(), "old-project".into()), + ("GCP_SERVICE_ACCOUNT_EMAIL".into(), "old@example.com".into()), + ])); + let (_, _, old_body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + ctx.credentials + .install_bound_environment( + 2, + HashMap::from([ + ("GCP_ADC_ACCESS_TOKEN".into(), "new-secret".into()), + ("GCP_PROJECT_ID".into(), "new-project".into()), + ("GCP_SERVICE_ACCOUNT_EMAIL".into(), "new@example.com".into()), + ]), + HashMap::new(), + HashMap::new(), + HashMap::from([("GCP_ADC_ACCESS_TOKEN".into(), token_binding())]), + vec![ + ENV_GCP_PROJECT_ID.into(), + ENV_GCP_SERVICE_ACCOUNT_EMAIL.into(), + ], + ) + .unwrap(); + let (_, _, new_body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + assert_ne!(old_body, new_body); + assert!(!new_body.contains("new-secret")); + assert_eq!( + route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()).2, + "new-project" + ); + let (_, _, recursive) = route_request( + &ctx, + "GET", + &format!("{PATH_SERVICE_ACCOUNT_DEFAULT}/?recursive=true"), + &flavor_headers(), + ); + let recursive: serde_json::Value = serde_json::from_str(&recursive).unwrap(); + assert_eq!(recursive["email"], "new@example.com"); + assert_eq!( + recursive["scopes"][0], + "https://www.googleapis.com/auth/cloud-platform" + ); + assert!(recursive.get("token").is_none()); + ctx.credentials + .install_environment(3, HashMap::new(), HashMap::new(), HashMap::new()); + assert_eq!( + route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()).0, + 503 + ); + assert_eq!( + route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()).0, + 404 + ); + assert_eq!( + route_request(&ctx, "GET", PATH_EMAIL, &flavor_headers()).0, + 404 + ); + let (_, _, recursive) = route_request( + &ctx, + "GET", + &format!("{PATH_SERVICE_ACCOUNT_DEFAULT}?recursive=true"), + &flavor_headers(), + ); + assert!(!recursive.contains("new@example.com")); + } + + #[test] + fn metadata_does_not_unwrap_config_names_classified_as_credentials() { + let ctx = MetadataContext::new(ProviderCredentialState::from_environment( + 0, + HashMap::from([ + (ENV_GCP_PROJECT_ID.into(), "secret-project".into()), + (ENV_GCP_SERVICE_ACCOUNT_EMAIL.into(), "secret-email".into()), + ]), + HashMap::new(), + HashMap::new(), + )); + assert_eq!( + route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()).0, + 404 + ); + assert_eq!( + route_request(&ctx, "GET", PATH_EMAIL, &flavor_headers()).0, + 404 + ); + let (_, _, body) = route_request( + &ctx, + "GET", + &format!("{PATH_SERVICE_ACCOUNT_DEFAULT}?recursive=true"), + &flavor_headers(), + ); + assert!(!body.contains("secret-email")); + } + + #[test] + fn configured_email_alias_supports_repeated_sdk_refresh() { + let ctx = make_context(HashMap::from([ + ( + ENV_GCP_SERVICE_ACCOUNT_EMAIL.into(), + "sdk@project.iam.gserviceaccount.com".into(), + ), + ("GCP_ADC_ACCESS_TOKEN".into(), "real-secret".into()), + ])); + for suffix in ["?recursive=true", "/token", "/email", "/scopes"] { + let alias = + format!("{PATH_SERVICE_ACCOUNTS}/sdk@project.iam.gserviceaccount.com{suffix}"); + let default = format!("{PATH_SERVICE_ACCOUNT_DEFAULT}{suffix}"); + assert_eq!( + route_request(&ctx, "GET", &alias, &flavor_headers()), + route_request(&ctx, "GET", &default, &flavor_headers()) + ); + } + let other = format!("{PATH_SERVICE_ACCOUNTS}/other@project.iam.gserviceaccount.com/token"); + assert_eq!(route_request(&ctx, "GET", &other, &flavor_headers()).0, 404); + } + + #[test] + fn expired_token_is_unavailable() { + let ctx = make_context_with_expiry( + HashMap::from([("GCP_ADC_ACCESS_TOKEN".into(), "expired-secret".into())]), + HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".into(), + openshell_core::time::now_ms() - 1000, + )]), + ); + assert_eq!( + route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()).0, + 503 + ); + } + + #[test] + fn token_returns_placeholder_not_real_value() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.test-token".to_string(), + )])); + let (status, ct, body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + assert_eq!(status, 200); + assert_eq!(ct, "application/json"); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let token = json["access_token"].as_str().unwrap(); + assert!( + token.starts_with("openshell:resolve:env:"), + "token should be a placeholder, got: {token}" + ); + assert!(!token.contains("ya29"), "real token must not be served"); + assert_eq!(json["token_type"], "Bearer"); + assert!(json["expires_in"].is_number()); + } + + #[test] + fn token_expires_in_computed_from_credential_expiry() { + let now_ms = openshell_core::time::now_ms(); + let expires_at = now_ms + 1_800_000; // 30 minutes from now + let ctx = make_context_with_expiry( + HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), "ya29.tok".to_string())]), + HashMap::from([("GCP_ADC_ACCESS_TOKEN".to_string(), expires_at)]), + ); + let (status, _, body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + assert_eq!(status, 200); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + let expires_in = json["expires_in"].as_i64().unwrap(); + assert!( + expires_in > 1700 && expires_in <= 1800, + "expires_in={expires_in}" + ); + } + + #[test] + fn token_no_expiry_defaults_to_3600() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + let (_, _, body) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + let json: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(json["expires_in"], 3600); + } + + #[test] + fn missing_metadata_flavor_header_403() { + let ctx = make_context(HashMap::new()); + let (status, _, _) = route_request(&ctx, "GET", PATH_TOKEN, &[]); + assert_eq!(status, 403); + } + + #[test] + fn x_forwarded_for_header_403() { + let ctx = make_context(HashMap::new()); + let headers = vec![ + ("Metadata-Flavor".to_string(), "Google".to_string()), + ("X-Forwarded-For".to_string(), "10.0.0.1".to_string()), + ]; + let (status, _, _) = route_request(&ctx, "GET", PATH_TOKEN, &headers); + assert_eq!(status, 403); + } + + #[test] + fn unknown_path_404() { + let ctx = make_context(HashMap::new()); + let (status, _, _) = route_request( + &ctx, + "GET", + "/computeMetadata/v1/unknown", + &flavor_headers(), + ); + assert_eq!(status, 404); + } + + #[test] + fn no_credentials_503() { + let ctx = make_context(HashMap::new()); + let (status, _, _) = route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()); + assert_eq!(status, 503); + } + + #[test] + fn post_method_405() { + let ctx = make_context(HashMap::new()); + let (status, _, _) = route_request(&ctx, "POST", PATH_TOKEN, &flavor_headers()); + assert_eq!(status, 405); + } + + #[test] + fn project_id_served_as_plain_text() { + let ctx = make_context(HashMap::from([( + "GCP_PROJECT_ID".to_string(), + "my-project-123".to_string(), + )])); + let (status, ct, body) = route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()); + assert_eq!(status, 200); + assert_eq!(ct, "text/plain"); + assert_eq!(body, "my-project-123"); + } + + #[test] + fn email_served_as_plain_text() { + let ctx = make_context(HashMap::from([( + "GCP_SERVICE_ACCOUNT_EMAIL".to_string(), + "sa@project.iam.gserviceaccount.com".to_string(), + )])); + let (status, ct, body) = route_request(&ctx, "GET", PATH_EMAIL, &flavor_headers()); + assert_eq!(status, 200); + assert_eq!(ct, "text/plain"); + assert_eq!(body, "sa@project.iam.gserviceaccount.com"); + } + + #[test] + fn scopes_returns_cloud_platform() { + let ctx = make_context(HashMap::new()); + let (status, _, body) = route_request(&ctx, "GET", PATH_SCOPES, &flavor_headers()); + assert_eq!(status, 200); + assert_eq!(body, "https://www.googleapis.com/auth/cloud-platform"); + } + + #[test] + fn query_parameters_ignored_for_routing() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + let path = format!("{PATH_TOKEN}?scopes=cloud-platform"); + let (status, _, _) = route_request(&ctx, "GET", &path, &flavor_headers()); + assert_eq!(status, 200); + } + + #[test] + fn metadata_flavor_case_insensitive() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + let headers = vec![("metadata-FLAVOR".to_string(), "google".to_string())]; + let (status, _, _) = route_request(&ctx, "GET", PATH_TOKEN, &headers); + assert_eq!(status, 200); + } + + #[test] + fn missing_env_var_returns_404() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + // project-id not set + let (status, _, _) = route_request(&ctx, "GET", PATH_PROJECT_ID, &flavor_headers()); + assert_eq!(status, 404); + } + + #[test] + fn trailing_slash_handled_for_service_account_default() { + let ctx = make_context(HashMap::from([( + "GCP_ADC_ACCESS_TOKEN".to_string(), + "ya29.tok".to_string(), + )])); + let with_slash = route_request( + &ctx, + "GET", + "/computeMetadata/v1/instance/service-accounts/default/", + &flavor_headers(), + ); + let without_slash = route_request( + &ctx, + "GET", + "/computeMetadata/v1/instance/service-accounts/default", + &flavor_headers(), + ); + assert_eq!(with_slash.0, 200); + assert_eq!(without_slash.0, 200); + assert_eq!(with_slash.2, without_slash.2); + } + + #[test] + fn parse_request_headers_extracts_correctly() { + let raw = b"GET /path HTTP/1.1\r\nHost: example.com\r\nMetadata-Flavor: Google\r\n\r\n"; + let headers = parse_request_headers(raw); + assert_eq!(headers.len(), 2); + assert_eq!(headers[0].0, "Host"); + assert_eq!(headers[0].1, "example.com"); + assert_eq!(headers[1].0, "Metadata-Flavor"); + assert_eq!(headers[1].1, "Google"); + } +} diff --git a/crates/openshell-supervisor-network/src/lib.rs b/crates/openshell-supervisor-network/src/lib.rs index 458e236f01..8115e33e09 100644 --- a/crates/openshell-supervisor-network/src/lib.rs +++ b/crates/openshell-supervisor-network/src/lib.rs @@ -8,6 +8,7 @@ //! owned by the orchestrator; this crate produces denials but does not //! aggregate them. +mod google_cloud_metadata; #[cfg(target_os = "windows")] pub mod host; pub mod identity; diff --git a/crates/openshell-supervisor-network/src/proxy.rs b/crates/openshell-supervisor-network/src/proxy.rs index 4f273817d7..c242e9e640 100644 --- a/crates/openshell-supervisor-network/src/proxy.rs +++ b/crates/openshell-supervisor-network/src/proxy.rs @@ -610,6 +610,35 @@ async fn preauthorize_transparent_open( timing, operation: "tcp", }; + if openshell_core::google_cloud::is_metadata_destination(destination) { + let identity_check = binary_identity + .as_ref() + .map_err(|_| TcpOpenDenial::IdentityUnavailable) + .and_then(|identity| { + identity_cache + .verify_or_cache_supplied_identity(identity) + .map_err(|error| match error { + SuppliedIdentityError::Unavailable(_) => TcpOpenDenial::IdentityUnavailable, + SuppliedIdentityError::CapacityExhausted => { + TcpOpenDenial::ResourceExhausted + } + }) + }); + if let Err(denial) = identity_check { + let _ = completion.send(TcpOpenDecision::Denied(denial)); + return None; + } + completion.send(TcpOpenDecision::RelayReady).ok()?; + return Some(( + stream, + Some(binary_identity), + None, + Some(TransparentOpen { + destination, + authorization: None, + }), + )); + } if destination.ip() == IpAddr::V4(crate::policy_dns::POLICY_LOCAL_ADDRESS) { if destination.port() != 80 || !has_policy_local { emit_staged_transparent_denial( @@ -2416,11 +2445,15 @@ async fn handle_mediated_connection( .as_ref() .and_then(EndpointObservationSender::capture); let mut policy_local_transparent = false; + let mut metadata_transparent = false; let (mut preauthorized_decision, prevalidated_connector) = if let Some(transparent) = transparent_open { let destination = transparent.destination; - if destination.ip() == IpAddr::V4(crate::policy_dns::POLICY_LOCAL_ADDRESS) + if openshell_core::google_cloud::is_metadata_destination(destination) { + metadata_transparent = true; + (None, None) + } else if destination.ip() == IpAddr::V4(crate::policy_dns::POLICY_LOCAL_ADDRESS) && destination.port() == 80 { policy_local_transparent = true; @@ -2442,6 +2475,8 @@ async fn handle_mediated_connection( } else { (None, None) }; + let metadata_deadline = metadata_transparent + .then(|| tokio::time::Instant::now() + std::time::Duration::from_secs(5)); let mut buf = vec![0u8; MAX_HEADER_BYTES]; let mut used = 0usize; @@ -2458,7 +2493,16 @@ async fn handle_mediated_connection( // A mediated open's first workload bytes follow the synthesized CONNECT header. // Take only the header out of the reader so the bytes behind it stay buffered for // the relay; overlap three bytes so a terminator split across fills is found. - let available = client.fill_buf().await.into_diagnostic()?; + let available = if let Some(deadline) = metadata_deadline { + if let Ok(result) = tokio::time::timeout_at(deadline, client.fill_buf()).await { + result.into_diagnostic()? + } else { + respond(&mut client, b"HTTP/1.1 408 Request Timeout\r\nConnection: close\r\nContent-Length: 0\r\n\r\n").await?; + return Ok(()); + } + } else { + client.fill_buf().await.into_diagnostic()? + }; if available.is_empty() { return Ok(()); } @@ -2498,6 +2542,25 @@ async fn handle_mediated_connection( let method = parts.next().unwrap_or(""); let target = parts.next().unwrap_or(""); + if metadata_transparent { + let credentials = provider_credentials.unwrap_or_else(|| { + ProviderCredentialState::from_environment( + 0, + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + ) + }); + return crate::google_cloud_metadata::handle_forward_request( + &crate::google_cloud_metadata::MetadataContext::new(credentials), + method, + target, + &buf[..used], + &mut client, + ) + .await; + } + if policy_local_transparent { if !valid_policy_local_request(method, target, request) { respond(&mut client, b"HTTP/1.1 400 Bad Request\r\n\r\n").await?; @@ -5224,6 +5287,28 @@ async fn handle_forward_proxy( let host = normalize_host(&raw_host); let host_lc = host.to_ascii_lowercase(); + if scheme == "http" + && ((host_lc == openshell_core::google_cloud::METADATA_HOST && port == 80) + || (host_lc == "127.0.0.1" && port == 8174)) + { + let credentials = provider_credentials.unwrap_or_else(|| { + ProviderCredentialState::from_environment( + 0, + std::collections::HashMap::new(), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + ) + }); + return crate::google_cloud_metadata::handle_forward_request( + &crate::google_cloud_metadata::MetadataContext::new(credentials), + method, + &path, + &buf[..used], + client, + ) + .await; + } + if host_lc == POLICY_LOCAL_HOST { if scheme != "http" || port != 80 { respond( @@ -7374,6 +7459,167 @@ process: { run_as_user: sandbox, run_as_group: sandbox } ) } + async fn drive_metadata_request(raw: &[u8], transparent: bool) -> Vec { + let engine = Arc::new( + OpaEngine::from_strings( + include_str!("../data/sandbox-policy.rego"), + "network_policies: {}", + ) + .unwrap(), + ); + let (server, mut workload) = tokio::io::duplex(32768); + let credentials = ProviderCredentialState::from_environment( + 1, + std::collections::HashMap::from([ + ("GCP_ADC_ACCESS_TOKEN".into(), "real-secret-token".into()), + ("GCP_PROJECT_ID".into(), "test-project".into()), + ( + "GCP_SERVICE_ACCOUNT_EMAIL".into(), + "sa@test-project.iam.gserviceaccount.com".into(), + ), + ]), + std::collections::HashMap::new(), + std::collections::HashMap::new(), + ); + workload.write_all(raw).await.unwrap(); + let response = async move { + let mut bytes = Vec::new(); + workload.read_to_end(&mut bytes).await.unwrap(); + bytes + }; + let handler = async move { + Box::pin(handle_mediated_connection( + tokio::io::BufReader::new(Box::new(server)), + None, + None, + transparent.then(|| TransparentOpen { + destination: openshell_core::google_cloud::METADATA_LOOPBACK_ADDR + .parse() + .unwrap(), + authorization: None, + }), + None, + engine, + Arc::new(BinaryIdentityCache::new()), + Arc::new(AtomicU32::new(1)), + None, + None, + AgentProposals::default(), + Arc::new(None), + Arc::new(None), + Arc::new(None), + Some(credentials), + None, + None, + None, + None, + None, + )) + .await + .unwrap(); + }; + let ((), response) = tokio::join!(handler, response); + response + } + + #[tokio::test] + async fn metadata_transparent_and_forward_requests_terminate_locally() { + for (target, transparent) in [ + ( + "/computeMetadata/v1/instance/service-accounts/default/token", + true, + ), + ( + "http://127.0.0.1:8174/computeMetadata/v1/instance/service-accounts/default/token", + false, + ), + ( + "http://gcp.metadata.openshell.internal/computeMetadata/v1/instance/service-accounts/default/token", + false, + ), + ] { + let raw = format!( + "GET {target} HTTP/1.1\r\nHost: unrelated.example\r\nMetadata-Flavor: Google\r\n\r\n" + ); + let response = drive_metadata_request(raw.as_bytes(), transparent).await; + let response = String::from_utf8(response).unwrap(); + assert!(response.starts_with("HTTP/1.1 200 OK"), "{response}"); + assert!(response.contains("Metadata-Flavor: Google")); + assert!(response.contains("openshell:resolve:env:")); + assert!(!response.contains("real-secret-token")); + } + } + + #[tokio::test] + async fn metadata_ingress_rejects_malformed_and_oversized_headers() { + for (raw, status) in [ + (b"GET / HTTP/1.1\r\nHost: bad\0host\r\n\r\n".to_vec(), "400"), + ( + format!( + "GET / HTTP/1.1\r\nHost: local\r\nX-Padding: {}\r\n\r\n", + "a".repeat(MAX_HEADER_BYTES) + ) + .into_bytes(), + "431", + ), + ] { + let response = drive_metadata_request(&raw, true).await; + assert!( + String::from_utf8(response) + .unwrap() + .starts_with(&format!("HTTP/1.1 {status}")) + ); + } + } + + #[tokio::test(start_paused = true)] + async fn metadata_ingress_times_out_incomplete_headers() { + let response = drive_metadata_request(b"GET / HTTP/1.1\r\n", true).await; + assert!(response.starts_with(b"HTTP/1.1 408 Request Timeout")); + } + + #[tokio::test] + async fn metadata_staging_requires_verified_identity_without_egress_rules() { + let engine = OpaEngine::from_strings( + include_str!("../data/sandbox-policy.rego"), + "network_policies: {}", + ) + .unwrap(); + for valid_identity in [true, false] { + let (mut open, completion) = staged_curl_open( + openshell_core::google_cloud::METADATA_LOOPBACK_ADDR + .parse() + .unwrap(), + engine.current_generation(), + ); + if !valid_identity { + open.binary_identity = Err(ResolveError::Failed("unavailable".into())); + } + let accepted = preauthorize_transparent_open( + open, + None, + &engine, + &BinaryIdentityCache::new(), + None, + None, + false, + None, + ) + .await; + if valid_identity { + let (_, _, _, transparent) = accepted.expect("metadata is local"); + assert!(transparent.unwrap().authorization.is_none()); + assert_eq!(completion.await.unwrap(), TcpOpenDecision::RelayReady); + } else { + assert!(accepted.is_none()); + assert_eq!( + completion.await.unwrap(), + TcpOpenDecision::Denied(TcpOpenDenial::IdentityUnavailable) + ); + } + } + } + #[tokio::test] async fn staged_transparent_open_dials_only_pinned_policy_dns_addresses() { let engine = OpaEngine::from_strings( diff --git a/docs/how-it-works/providers/google.mdx b/docs/how-it-works/providers/google.mdx index 2c7091476c..1ae6f88b1c 100644 --- a/docs/how-it-works/providers/google.mdx +++ b/docs/how-it-works/providers/google.mdx @@ -125,9 +125,10 @@ When a sandbox starts with the `google-cloud` provider attached: 1. The gateway mints a fresh GCP access token and stores it in the sandbox proxy's credential resolver. -2. A loopback HTTP server on `127.0.0.1:8174` emulates the GCE instance - metadata API, serving **credential placeholders** (not real tokens) to - GCP SDKs. The sandbox process never holds a real GCP credential. +2. The sandbox relays HTTP requests to the reserved `127.0.0.1:8174` + metadata endpoint over its authenticated supervisor connection. The + supervisor emulates the GCE instance metadata API and serves credential + placeholders to GCP SDKs. Real access tokens remain in the supervisor. 3. When the SDK makes an API call, it sends the placeholder in the `Authorization` header. The sandbox proxy TLS-terminates the outbound connection, resolves the placeholder to the real token, @@ -142,6 +143,18 @@ environment variables and are served by the metadata endpoint. These are non-secret identifiers, not credentials. Access tokens are never exposed; only placeholders reach the sandbox process. +The emulator supports Linux sandboxes using the Docker, Podman, Kubernetes, +and VM runtimes. Windows/MXC does not provide this metadata endpoint. SDKs must +honor the injected metadata discovery variables below. Requests require the +`Metadata-Flavor: Google` header; requests with `X-Forwarded-For` are rejected. +The endpoint serves project ID, service-account email, scopes, and token +placeholders, including recursive service-account discovery. + +OpenShell reserves `127.0.0.1:8174` for this service. Connections to this +address reach the emulator even if a workload binds its own listener there. +Requests to the host's cloud metadata service remain blocked; the emulator does not forward requests to +`metadata.google.internal` or `169.254.169.254`. + ### Injected Environment Variables The provider automatically injects these into the sandbox. Non-secret @@ -150,7 +163,7 @@ as placeholders for proxy-time resolution. | Variable | Value | Purpose | |----------|-------|---------| -| `GCE_METADATA_HOST` | `127.0.0.1:8174` | GCP SDK metadata discovery (loopback server) | +| `GCE_METADATA_HOST` | `127.0.0.1:8174` | GCP SDK metadata discovery (supervisor emulator) | | `GCE_METADATA_IP` | `127.0.0.1:8174` | Python google-auth ping detection | | `METADATA_SERVER_DETECTION` | `assume-present` | Node.js gcp-metadata skip detection | | `GCP_PROJECT_ID` | from `project_id` config | GCP SDK project | @@ -167,6 +180,10 @@ permissions for. Add the target API hosts to your sandbox network policy: Attach `my-gcp` to the sandbox first. Because the `google-cloud` profile has no endpoints, each API endpoint must bind to that provider instance. Without the binding, OpenShell withholds its access token from the sandbox. +The metadata token endpoint returns HTTP `503` when no bound access token is +available or the token has expired. Attach the provider, add a credential +binding, and check `openshell provider refresh status my-gcp` before testing +token discovery. ```yaml network_policies: diff --git a/e2e/python/Dockerfile.workload b/e2e/python/Dockerfile.workload index 0da6a88f6d..f25c05d7cf 100644 --- a/e2e/python/Dockerfile.workload +++ b/e2e/python/Dockerfile.workload @@ -19,7 +19,7 @@ RUN apt-get update \ && useradd --uid 1000 --gid sandbox --create-home --shell /bin/bash sandbox \ && UV_PYTHON_INSTALL_DIR=/sandbox/.uv/python uv python install "${PYTHON_VERSION}" \ && UV_PYTHON_INSTALL_DIR=/sandbox/.uv/python uv venv --python "${PYTHON_VERSION}" --seed /sandbox/.venv \ - && uv pip install --python /sandbox/.venv/bin/python cloudpickle==3.1.2 \ + && uv pip install --python /sandbox/.venv/bin/python cloudpickle==3.1.2 google-auth==2.40.3 requests==2.32.5 \ && chown -R sandbox:sandbox /sandbox \ && uv cache clean diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index 43badec291..d2bc040b32 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -76,6 +76,7 @@ def provider( provider_type: str, credentials: dict[str, str], profile_workspace: str = "", + config: dict[str, str] | None = None, ) -> Iterator[str]: """Create a provider for the duration of the block, then delete it.""" _delete_provider(stub, name) @@ -86,6 +87,7 @@ def provider( metadata=datamodel_pb2.ObjectMeta(name=name), type=provider_type, credentials=credentials, + config=config or {}, profile_workspace=profile_workspace, ), ) @@ -365,6 +367,26 @@ def read_gcp_token() -> str: assert result.exit_code == 0, result.stderr assert result.stdout.strip() == "NOT_SET" + def read_metadata_token_status() -> int: + import os + import urllib.error + import urllib.request + + request = urllib.request.Request( + f"http://{os.environ['GCE_METADATA_HOST']}/computeMetadata/v1/instance/service-accounts/default/token", + headers={"Metadata-Flavor": "Google"}, + ) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + try: + with opener.open(request, timeout=5) as response: + return response.status + except urllib.error.HTTPError as error: + return error.code + + result = sb.exec_python(read_metadata_token_status) + assert result.exit_code == 0, result.stderr + assert result.stdout.strip() == "503" + def test_endpointless_profile_credentials_use_explicit_policy_binding( sandbox: Callable[..., Sandbox], @@ -412,6 +434,90 @@ def read_gcp_token() -> str: ) +def test_google_metadata_sdk_discovery( + sandbox: Callable[..., Sandbox], + sandbox_client: SandboxClient, +) -> None: + """Google's SDK discovers project/account metadata and refreshes a placeholder.""" + with provider( + sandbox_client._stub, + name="e2e-google-metadata-sdk", + provider_type="google-cloud", + credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-metadata-real-secret"}, + config={ + "project_id": "metadata-test-project", + "service_account_email": "sdk@metadata-test-project.iam.gserviceaccount.com", + }, + ) as provider_name: + policy = _default_policy() + policy.network_policies["gcp_api"].CopyFrom( + sandbox_pb2.NetworkPolicyRule( + name="gcp_api", + endpoints=[ + sandbox_pb2.NetworkEndpoint( + host="storage.googleapis.com", + port=443, + protocol="rest", + access=sandbox_pb2.NETWORK_ACCESS_PRESET_FULL, + credential_binding=sandbox_pb2.NetworkCredentialBinding( + provider=provider_name + ), + ) + ], + ) + ) + spec = datamodel_pb2.SandboxSpec(policy=policy, providers=[provider_name]) + + def discover_metadata() -> str: + import json + import os + + import google.auth + import requests + from google.auth.compute_engine import _metadata + from google.auth.transport.requests import Request + + # Force ADC to use SDK metadata detection rather than a local key file. + for key in ( + "GOOGLE_APPLICATION_CREDENTIALS", + "GOOGLE_CLOUD_PROJECT", + "GCLOUD_PROJECT", + ): + os.environ.pop(key, None) + session = requests.Session() + # Exercise the direct TCP path used by metadata clients. + session.trust_env = False + request = Request(session=session) + if not _metadata.ping(request): + raise RuntimeError("Google SDK could not detect the metadata endpoint") + credentials, project = google.auth.default(request=request) + credentials.refresh(request) + credentials.refresh(request) + return json.dumps( + { + "project": project, + "account": credentials.service_account_email, + "token": credentials.token, + "expiry_present": credentials.expiry is not None, + "metadata_host": os.environ["GCE_METADATA_HOST"], + "metadata_ip": os.environ["GCE_METADATA_IP"], + } + ) + + with sandbox(spec=spec, delete_on_exit=True) as sb: + result = sb.exec_python(discover_metadata) + assert result.exit_code == 0, result.stderr + data = json.loads(result.stdout) + assert data["project"] == "metadata-test-project" + assert ( + data["account"] == "sdk@metadata-test-project.iam.gserviceaccount.com" + ) + assert data["metadata_host"] == data["metadata_ip"] == "127.0.0.1:8174" + assert data["expiry_present"] + assert _is_placeholder_for_env_key(data["token"], "GCP_ADC_ACCESS_TOKEN") + assert "gcp-metadata-real-secret" not in result.stdout + + def test_nvidia_provider_injects_nvidia_api_key_env_var( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, diff --git a/skills/generate-sandbox-policy/SKILL.md b/skills/generate-sandbox-policy/SKILL.md index b5a0f6f4b4..ac03c8576b 100644 --- a/skills/generate-sandbox-policy/SKILL.md +++ b/skills/generate-sandbox-policy/SKILL.md @@ -342,7 +342,9 @@ Use `allowed_ips` to pin the addresses an endpoint may reach. When it is set, ev - **Host + allowlist**: `host` + `allowed_ips` — domain must resolve to an IP in the allowlist - **Hostless allowlist**: `allowed_ips` only (no `host`) — any domain on the port is allowed if it resolves to an IP in the allowlist -Loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), unspecified, and cloud metadata addresses are **always blocked** regardless of `allowed_ips`. +Loopback (`127.0.0.0/8`), link-local (`169.254.0.0/16`), unspecified, and cloud metadata addresses are **always blocked** as upstream destinations regardless of `allowed_ips`. + +The Google Cloud metadata emulator reserves `127.0.0.1:8174` in Linux sandboxes. OpenShell handles SDK discovery locally through the supervisor; do not add an `allowed_ips` exception or grant access to the host cloud metadata service. See the [Google provider documentation](https://docs.nvidia.com/openshell/latest/how-it-works/providers/google.md). ```yaml # Example: Pin an internal service to a known private IP range From c158fff6b7562b59a536420d23cc3574eba63640 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 1 Oct 2026 00:57:24 +0200 Subject: [PATCH 2/3] fix(providers): preserve default metadata account without an email Use the default account identifier when the optional service account email is missing or empty. Cover repeated SDK refresh for missing, empty, and configured email values with distinct providers for parallel E2E execution. Signed-off-by: Philippe Martin --- .../src/google_cloud_metadata.rs | 27 ++++++++++++++++++- docs/how-it-works/providers/google.mdx | 5 +++- e2e/python/test_sandbox_providers.py | 25 +++++++++++------ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/crates/openshell-supervisor-network/src/google_cloud_metadata.rs b/crates/openshell-supervisor-network/src/google_cloud_metadata.rs index c5278601f0..f4e29ece88 100644 --- a/crates/openshell-supervisor-network/src/google_cloud_metadata.rs +++ b/crates/openshell-supervisor-network/src/google_cloud_metadata.rs @@ -198,7 +198,8 @@ fn handle_service_account_recursive(ctx: &MetadataContext) -> MetadataResponse { let email = ctx .credentials .current_non_secret_environment_value(ENV_GCP_SERVICE_ACCOUNT_EMAIL) - .unwrap_or_default(); + .filter(|email| !email.is_empty()) + .unwrap_or_else(|| "default".to_string()); let scopes = "https://www.googleapis.com/auth/cloud-platform"; @@ -556,6 +557,30 @@ mod tests { assert_eq!(route_request(&ctx, "GET", &other, &flavor_headers()).0, 404); } + #[test] + fn missing_or_empty_email_keeps_a_usable_account_for_repeated_sdk_refresh() { + for email in [None, Some("")] { + let mut env = HashMap::from([("GCP_ADC_ACCESS_TOKEN".into(), "real-secret".into())]); + if let Some(email) = email { + env.insert(ENV_GCP_SERVICE_ACCOUNT_EMAIL.into(), email.into()); + } + let ctx = make_context(env); + let mut account = "default".to_string(); + for _ in 0..2 { + let path = format!("{PATH_SERVICE_ACCOUNTS}/{account}?recursive=true"); + let (status, _, body) = route_request(&ctx, "GET", &path, &flavor_headers()); + assert_eq!(status, 200); + let info: serde_json::Value = serde_json::from_str(&body).unwrap(); + account = info["email"].as_str().unwrap().to_string(); + assert_eq!(account, "default"); + assert_eq!( + route_request(&ctx, "GET", PATH_TOKEN, &flavor_headers()).0, + 200 + ); + } + } + } + #[test] fn expired_token_is_unavailable() { let ctx = make_context_with_expiry( diff --git a/docs/how-it-works/providers/google.mdx b/docs/how-it-works/providers/google.mdx index 1ae6f88b1c..6d5b0c1256 100644 --- a/docs/how-it-works/providers/google.mdx +++ b/docs/how-it-works/providers/google.mdx @@ -117,7 +117,10 @@ Set these with `--config key=value` during provider creation: |-----|-------------|---------| | `project_id` | GCP project ID | `my-project-123` | | `region` | GCP region | `us-central1` | -| `service_account_email` | SA email for metadata endpoint | `sa@proj.iam.gserviceaccount.com` | +| `service_account_email` | Optional SA email for metadata endpoint | `sa@proj.iam.gserviceaccount.com` | + +When `service_account_email` is omitted or empty, recursive metadata account +discovery returns the `default` identifier so SDKs can refresh repeatedly. ## How It Works diff --git a/e2e/python/test_sandbox_providers.py b/e2e/python/test_sandbox_providers.py index d2bc040b32..4c39bae95f 100644 --- a/e2e/python/test_sandbox_providers.py +++ b/e2e/python/test_sandbox_providers.py @@ -434,20 +434,31 @@ def read_gcp_token() -> str: ) +@pytest.mark.parametrize( + ("service_account_email", "provider_suffix"), + [ + (None, "no-email"), + ("", "empty-email"), + ("sdk@metadata-test-project.iam.gserviceaccount.com", "configured-email"), + ], + ids=["no-email", "empty-email", "configured-email"], +) def test_google_metadata_sdk_discovery( sandbox: Callable[..., Sandbox], sandbox_client: SandboxClient, + service_account_email: str | None, + provider_suffix: str, ) -> None: """Google's SDK discovers project/account metadata and refreshes a placeholder.""" + config = {"project_id": "metadata-test-project"} + if service_account_email is not None: + config["service_account_email"] = service_account_email with provider( sandbox_client._stub, - name="e2e-google-metadata-sdk", + name=f"e2e-google-metadata-sdk-{provider_suffix}", provider_type="google-cloud", credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-metadata-real-secret"}, - config={ - "project_id": "metadata-test-project", - "service_account_email": "sdk@metadata-test-project.iam.gserviceaccount.com", - }, + config=config, ) as provider_name: policy = _default_policy() policy.network_policies["gcp_api"].CopyFrom( @@ -509,9 +520,7 @@ def discover_metadata() -> str: assert result.exit_code == 0, result.stderr data = json.loads(result.stdout) assert data["project"] == "metadata-test-project" - assert ( - data["account"] == "sdk@metadata-test-project.iam.gserviceaccount.com" - ) + assert data["account"] == (service_account_email or "default") assert data["metadata_host"] == data["metadata_ip"] == "127.0.0.1:8174" assert data["expiry_present"] assert _is_placeholder_for_env_key(data["token"], "GCP_ADC_ACCESS_TOKEN") From ec01442e51ff943679ab0e5b1647881b794bfa31 Mon Sep 17 00:00:00 2001 From: Philippe Martin Date: Thu, 1 Oct 2026 01:37:44 +0200 Subject: [PATCH 3/3] test(sandbox): fix metadata relay lint and timeout Signed-off-by: Philippe Martin --- crates/openshell-sandbox/src/network_broker.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/openshell-sandbox/src/network_broker.rs b/crates/openshell-sandbox/src/network_broker.rs index 9cdb064bcf..f2f196c925 100644 --- a/crates/openshell-sandbox/src/network_broker.rs +++ b/crates/openshell-sandbox/src/network_broker.rs @@ -2139,7 +2139,9 @@ mod tests { .build() .unwrap(); runtime.block_on(async { - let pending = tokio::time::timeout(Duration::from_secs(5), broker.accept()) + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let pending = tokio::time::timeout(Duration::from_secs(30), broker.accept()) .await .unwrap() .unwrap(); @@ -2153,7 +2155,6 @@ mod tests { .unwrap(); stream.set_nonblocking(true).unwrap(); let mut stream = tokio::net::TcpStream::from_std(stream).unwrap(); - use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; let mut probe = [0; 14]; stream.read_exact(&mut probe).await.unwrap(); assert_eq!(&probe, b"metadata-probe");