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..f2f196c925 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,75 @@ 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 { + use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; + + let pending = tokio::time::timeout(Duration::from_secs(30), 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(); + 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..f4e29ece88 --- /dev/null +++ b/crates/openshell-supervisor-network/src/google_cloud_metadata.rs @@ -0,0 +1,790 @@ +// 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) + .filter(|email| !email.is_empty()) + .unwrap_or_else(|| "default".to_string()); + + 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 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( + 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..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 @@ -125,9 +128,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 +146,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 +166,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 +183,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..4c39bae95f 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,99 @@ 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=f"e2e-google-metadata-sdk-{provider_suffix}", + provider_type="google-cloud", + credentials={"GCP_ADC_ACCESS_TOKEN": "gcp-metadata-real-secret"}, + config=config, + ) 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"] == (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") + 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