fix: preserve Runtime binding trust boundaries

This commit is contained in:
2026-09-08 04:54:23 +09:00
parent 7fb1d4056c
commit 5080d7860e
4 changed files with 212 additions and 49 deletions
+151 -11
View File
@@ -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<String>,
pub auth: Option<RemoteRuntimeAuthConfig>,
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(|_| "<redacted>"),
)
.field("auth", &self.auth.as_ref().map(|_| "<capability-signer>"))
.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<String>) -> 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<SocketAddr>), 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::<IpAddr>()
.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::<Vec<_>>();
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<Self, RuntimeRegistryError> {
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));
+5 -1
View File
@@ -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::<Vec<_>>()
})
@@ -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
+38 -36
View File
@@ -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::<IpAddr>()
.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(),