From 5080d7860ef954030c6ec093ec7a226b091d32f4 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 8 Sep 2026 04:54:23 +0900 Subject: [PATCH] fix: preserve Runtime binding trust boundaries --- crates/workspace-server/src/hosts.rs | 162 ++++++++++++++++-- crates/workspace-server/src/main.rs | 6 +- crates/workspace-server/src/server.rs | 74 ++++---- .../runtimes/[runtimeId]/+page.svelte | 19 +- 4 files changed, 212 insertions(+), 49 deletions(-) diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index c00adf25..6e6cdcbd 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -14,6 +14,7 @@ use std::{ error::Error as _, future::Future, io::Read as _, + net::{IpAddr, SocketAddr, ToSocketAddrs}, path::PathBuf, pin::Pin, sync::{Arc, RwLock}, @@ -2850,6 +2851,7 @@ pub struct RemoteRuntimeConfig { pub base_url: String, pub bearer_token: Option, pub auth: Option, + pub strict_public_egress: bool, pub cached_worker_creation_available: bool, pub cached_os: String, pub cached_arch: String, @@ -2874,6 +2876,7 @@ impl std::fmt::Debug for RemoteRuntimeConfig { &self.bearer_token.as_ref().map(|_| ""), ) .field("auth", &self.auth.as_ref().map(|_| "")) + .field("strict_public_egress", &self.strict_public_egress) .field( "cached_worker_creation_available", &self.cached_worker_creation_available, @@ -2900,6 +2903,7 @@ impl RemoteRuntimeConfig { base_url: base_url.into(), bearer_token, auth: None, + strict_public_egress: false, cached_worker_creation_available: false, cached_os: "unknown".to_string(), cached_arch: "unknown".to_string(), @@ -2918,6 +2922,11 @@ impl RemoteRuntimeConfig { self } + pub fn with_strict_public_egress(mut self, strict: bool) -> Self { + self.strict_public_egress = strict; + self + } + pub fn with_cached_status(mut self, status: impl Into) -> Self { self.cached_status = status.into(); self @@ -2975,6 +2984,78 @@ impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization { } } +fn resolve_strict_remote_runtime_endpoint( + endpoint: &str, +) -> Result<(String, Vec), String> { + let endpoint = + Url::parse(endpoint).map_err(|_| "endpoint must be an absolute https URL".to_string())?; + if endpoint.scheme() != "https" + || endpoint.host_str().is_none() + || !endpoint.username().is_empty() + || endpoint.password().is_some() + || endpoint.query().is_some() + || endpoint.fragment().is_some() + { + return Err( + "endpoint must be an https origin without credentials, query, or fragment".to_string(), + ); + } + let host = endpoint.host_str().expect("checked above").to_string(); + if host.eq_ignore_ascii_case("localhost") + || host.ends_with(".localhost") + || host + .parse::() + .is_ok_and(is_disallowed_remote_runtime_address) + { + return Err("endpoint host is not public".to_string()); + } + let port = endpoint.port_or_known_default().unwrap_or(443); + let addresses = (host.as_str(), port) + .to_socket_addrs() + .map_err(|_| "endpoint DNS resolution failed".to_string())? + .collect::>(); + if addresses.is_empty() + || addresses + .iter() + .any(|address| is_disallowed_remote_runtime_address(address.ip())) + { + return Err("endpoint DNS resolution included a non-public address".to_string()); + } + Ok((host, addresses)) +} + +pub(crate) fn is_disallowed_remote_runtime_address(address: IpAddr) -> bool { + match address { + IpAddr::V4(address) => { + let octets = address.octets(); + address.is_private() + || address.is_loopback() + || address.is_link_local() + || address.is_unspecified() + || address.is_broadcast() + || address.is_documentation() + || address.is_multicast() + || octets[0] == 0 + || (octets[0] == 100 && (64..=127).contains(&octets[1])) + || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) + || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) + || octets[0] >= 240 + } + IpAddr::V6(address) => { + let segments = address.segments(); + address.is_loopback() + || address.is_unspecified() + || address.is_multicast() + || (segments[0] & 0xfe00) == 0xfc00 + || (segments[0] & 0xffc0) == 0xfe80 + || (segments[0] == 0x2001 && segments[1] == 0x0db8) + || address.to_ipv4_mapped().is_some_and(|address| { + is_disallowed_remote_runtime_address(IpAddr::V4(address)) + }) + } + } +} + #[derive(Clone)] pub struct RemoteWorkerRuntime { runtime_id: String, @@ -3060,13 +3141,30 @@ impl RemoteWorkerRuntime { ) -> Result { validate_backend_identifier("runtime_id", &config.runtime_id)?; let base_url = config.base_url.trim_end_matches('/').to_string(); + let pinned_endpoint = if config.strict_public_egress { + Some( + resolve_strict_remote_runtime_endpoint(&base_url).map_err(|message| { + RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: config.runtime_id.clone(), + code: "remote_runtime_endpoint_not_allowed".to_string(), + message, + } + })?, + ) + } else { + None + }; let timeout = config.timeout; + let blocking_resolution = pinned_endpoint.clone(); let http = run_blocking_http(move || { - BlockingHttpClient::builder() + let mut builder = BlockingHttpClient::builder() .timeout(timeout) .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .build() + .no_proxy(); + if let Some((host, addresses)) = &blocking_resolution { + builder = builder.resolve_to_addrs(host, addresses); + } + builder.build() }) .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { runtime_id: config.runtime_id.clone(), @@ -3076,16 +3174,21 @@ impl RemoteWorkerRuntime { // Workdir command-output waits are bounded to 20 seconds by Runtime; // leave transport margin while retaining a finite client timeout. let workdir_timeout = timeout.max(Duration::from_secs(30)); - let async_http = AsyncHttpClient::builder() + let mut async_builder = AsyncHttpClient::builder() .timeout(workdir_timeout) .redirect(reqwest::redirect::Policy::none()) - .no_proxy() - .build() - .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { - runtime_id: config.runtime_id.clone(), - code: "remote_runtime_async_client_build_failed".to_string(), - message: err.to_string(), - })?; + .no_proxy(); + if let Some((host, addresses)) = &pinned_endpoint { + async_builder = async_builder.resolve_to_addrs(host, addresses); + } + let async_http = + async_builder + .build() + .map_err(|err| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: config.runtime_id.clone(), + code: "remote_runtime_async_client_build_failed".to_string(), + message: err.to_string(), + })?; Ok(Self { host_id: host_id_for_remote_runtime(&config.runtime_id), runtime_id: config.runtime_id, @@ -4772,6 +4875,43 @@ mod tests { use std::sync::{Arc, Mutex}; use std::thread; + #[test] + fn strict_remote_runtime_egress_rejects_disallowed_endpoint_before_client_use() { + let config = RemoteRuntimeConfig::new( + "runtime-private", + "Private Runtime", + "https://169.254.169.254/latest/meta-data", + None, + ) + .with_strict_public_egress(true); + let error = match RemoteWorkerRuntime::new( + config, + "workspace-a".to_string(), + "http://127.0.0.1:1".to_string(), + ) { + Err(error) => error, + Ok(_) => panic!("disallowed endpoint unexpectedly produced a Runtime client"), + }; + assert!(matches!( + error, + RuntimeRegistryError::RuntimeOperationFailed { code, .. } + if code == "remote_runtime_endpoint_not_allowed" + )); + } + + #[test] + fn strict_remote_runtime_egress_accepts_and_pins_public_https_address() { + let config = + RemoteRuntimeConfig::new("runtime-public", "Public Runtime", "https://8.8.8.8", None) + .with_strict_public_egress(true); + RemoteWorkerRuntime::new( + config, + "workspace-a".to_string(), + "http://127.0.0.1:1".to_string(), + ) + .unwrap(); + } + #[test] fn remote_worker_create_timeout_covers_runtime_phase_budgets() { assert!(REMOTE_WORKER_CREATE_TIMEOUT > Duration::from_secs(60 + 10 + 5)); diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index eb50e29c..89208232 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -646,6 +646,7 @@ fn append_workspace_runtime_sources( .into_iter() .filter(|binding| { binding.runtime_id != yoi_workspace_server::hosts::EMBEDDED_RUNTIME_ID + && binding.state == WorkspaceRuntimeBindingState::Verified }) .collect::>() }) @@ -674,7 +675,10 @@ fn append_workspace_runtime_sources( None, ) .with_workspace_id(runtime.workspace_id.clone()) - .with_auth(auth); + .with_auth(auth) + .with_strict_public_egress( + runtime.authentication_mode == WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + ); remote_runtime_sources.retain(|existing| { existing.workspace_id.as_deref() != Some(runtime.workspace_id.as_str()) || existing.runtime_id != runtime.runtime_id diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 0cf6b2a9..87ff4ed5 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -128,7 +128,8 @@ use crate::hosts::{ WorkerLifecycleRequest, WorkerLifecycleResult, WorkerOperationState, WorkerRestoreResult, WorkerSpawnAcceptanceRequirement, WorkerSpawnIntent, WorkerSpawnRequest, WorkerSpawnResult, WorkerSpawnWorkingDirectoryRequest, WorkerSummary, WorkerTicketAssignmentRequest, - WorkerWorkspaceSummary, worker_spawn_create_fingerprint, workspace_worker_summary, + WorkerWorkspaceSummary, is_disallowed_remote_runtime_address, worker_spawn_create_fingerprint, + workspace_worker_summary, }; use crate::identity::WorkspaceIdentity; use crate::memory_backend::execute_memory_backend_operation_with_authority; @@ -11815,6 +11816,14 @@ async fn scoped_put_runtime_trust_key( .store .get_workspace_runtime_binding(&path.workspace_id, &path.runtime_id) .await?; + if existing.as_ref().is_some_and(|binding| { + binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity + }) { + return Err(settings_bad_request( + "workspace_identity_runtime_key_managed_by_binding", + "replace a Workspace identity Runtime public bundle through the Runtime registration operation with expected_revision", + )); + } let source = api .config .remote_runtime_sources @@ -16025,7 +16034,7 @@ async fn validate_runtime_connection_request( || host.ends_with(".localhost") || host .parse::() - .is_ok_and(is_disallowed_runtime_address) + .is_ok_and(is_disallowed_remote_runtime_address) { return Err(settings_bad_request( "remote_runtime_endpoint_not_allowed", @@ -16054,7 +16063,7 @@ async fn validate_runtime_connection_request( if addresses.is_empty() || addresses .iter() - .any(|address| is_disallowed_runtime_address(address.ip())) + .any(|address| is_disallowed_remote_runtime_address(address.ip())) { return Err(settings_bad_request( "remote_runtime_endpoint_not_allowed", @@ -16074,38 +16083,6 @@ async fn validate_runtime_connection_request( Ok(endpoint) } -fn is_disallowed_runtime_address(address: IpAddr) -> bool { - match address { - IpAddr::V4(address) => { - let octets = address.octets(); - address.is_private() - || address.is_loopback() - || address.is_link_local() - || address.is_unspecified() - || address.is_broadcast() - || address.is_documentation() - || address.is_multicast() - || octets[0] == 0 - || (octets[0] == 100 && (64..=127).contains(&octets[1])) - || (octets[0] == 192 && octets[1] == 0 && octets[2] == 0) - || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) - || octets[0] >= 240 - } - IpAddr::V6(address) => { - let segments = address.segments(); - address.is_loopback() - || address.is_unspecified() - || address.is_multicast() - || (segments[0] & 0xfe00) == 0xfc00 - || (segments[0] & 0xffc0) == 0xfe80 - || (segments[0] == 0x2001 && segments[1] == 0x0db8) - || address - .to_ipv4_mapped() - .is_some_and(|address| is_disallowed_runtime_address(IpAddr::V4(address))) - } - } -} - fn validate_public_runtime_id(runtime_id: &str) -> ApiResult<()> { if runtime_id.is_empty() { return Err(settings_bad_request( @@ -16136,7 +16113,10 @@ fn remote_runtime_config_from_binding( binding.base_url.clone(), None, ) - .with_workspace_id(binding.workspace_id.clone()); + .with_workspace_id(binding.workspace_id.clone()) + .with_strict_public_egress( + binding.authentication_mode == StoredRuntimeAuthenticationMode::WorkspaceIdentity, + ); Ok(remote) } @@ -18270,6 +18250,7 @@ mod tests { server_id: "server-test".to_owned(), server_private_key: "unused".to_owned(), }), + strict_public_egress: false, cached_worker_creation_available: true, cached_os: "test".to_owned(), cached_arch: "test".to_owned(), @@ -20008,6 +19989,25 @@ mod tests { assert!(binding.workspace_key_id.is_some()); assert_eq!(binding.workspace_key_generation, Some(1)); assert!(!created.runtime.worker_creation_available); + let replacement_identity = RuntimeIdentityMaterial::generate("configured-runtime").unwrap(); + let generic_put = scoped_put_runtime_trust_key( + State(api.clone()), + AxumPath(ScopedRuntimePath { + workspace_id: api.config.workspace_id.clone(), + runtime_id: "configured-runtime".to_string(), + }), + Extension(actor.clone()), + Json(PutRuntimeTrustKeyRequest { + public_key: replacement_identity.public_key, + expected_revision: Some(1), + }), + ) + .await + .unwrap_err(); + assert_eq!( + generic_put.into_response().status(), + StatusCode::BAD_REQUEST + ); let configured_test = scoped_test_runtime_connection( State(api.clone()), AxumPath(ScopedRuntimePath { @@ -24783,6 +24783,7 @@ mod tests { server_id: "server-main".to_string(), server_private_key: identity.private_key.clone(), }), + strict_public_egress: false, cached_worker_creation_available: true, cached_os: "test".to_string(), cached_arch: "test".to_string(), @@ -26309,6 +26310,7 @@ mod tests { base_url: endpoint, bearer_token: Some("test-connection-token".to_string()), auth: None, + strict_public_egress: false, cached_worker_creation_available: true, cached_os: "linux".to_string(), cached_arch: "x86_64".to_string(), diff --git a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte index c4901da5..1add8368 100644 --- a/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/settings/runtimes/[runtimeId]/+page.svelte @@ -6,6 +6,7 @@ RuntimeTrustKeyStatus, } from '$lib/generated/workspace-api'; import { + createRemoteRuntime, previewRuntimePublicKeyFingerprint, putRuntimeTrustKey, revealRuntimeTrustKey, @@ -152,7 +153,23 @@ const operation = routeFence.capture(data.runtimeId); busyAction = 'save'; try { - await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request); + const binding = data.runtimeDetail.runtime.management.binding; + if (binding?.authentication_mode === 'workspace_identity') { + if (!data.runtimeDetail.endpoint) { + throw new RuntimeTrustRequestError('The authoritative Runtime endpoint is unavailable.'); + } + await createRemoteRuntime(data.workspaceId, { + public_bundle: { + identity_id: operation.runtimeId, + public_key: key, + }, + display_name: data.runtimeDetail.runtime.label, + endpoint: data.runtimeDetail.endpoint, + expected_revision: binding.revision, + }); + } else { + await putRuntimeTrustKey(data.workspaceId, operation.runtimeId, request); + } if (!isCurrentRoute(operation)) return; publicKey = ''; fingerprintConfirmation = '';