From fba5ecf54ceadc292c0c34dfe5a7e875c2a2d405 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 8 Sep 2026 02:00:19 +0900 Subject: [PATCH] fix: harden Workspace issuer bootstrap --- crates/worker-runtime/src/main.rs | 41 +++++++++++++++++-- crates/worker-runtime/src/workspace_issuer.rs | 17 ++++++++ docs/development/server-runtime-auth.md | 2 +- 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index 3c2f3c04..1d2fa107 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -516,6 +516,8 @@ impl From for ProcessError { } } +const MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES: u64 = 64 * 1024; + #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] struct RuntimeAuthFile { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -551,9 +553,7 @@ fn run_trust_workspace_command(mut args: VecDeque) -> Result<(), Process let config = parse_auth_storage_flags(&mut args)?; let auth_path = runtime_auth_path(&config); let mut auth = read_runtime_auth_file(&auth_path)?; - let bundle_bytes = std::fs::read(&bundle_path).map_err(|_| { - ProcessError::auth("Workspace signing public identity bundle is unavailable") - })?; + let bundle_bytes = read_workspace_identity_bundle(Path::new(&bundle_path))?; let bundle = serde_json::from_slice(&bundle_bytes).map_err(|_| { ProcessError::auth("Workspace signing public identity bundle is invalid") })?; @@ -622,6 +622,26 @@ fn run_trust_workspace_command(mut args: VecDeque) -> Result<(), Process } } +fn read_workspace_identity_bundle(path: &Path) -> Result, ProcessError> { + use std::io::Read as _; + + let file = std::fs::File::open(path).map_err(|_| { + ProcessError::auth("Workspace signing public identity bundle is unavailable") + })?; + let mut bytes = Vec::new(); + file.take(MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES + 1) + .read_to_end(&mut bytes) + .map_err(|_| { + ProcessError::auth("Workspace signing public identity bundle is unavailable") + })?; + if bytes.len() as u64 > MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES { + return Err(ProcessError::auth( + "Workspace signing public identity bundle is too large", + )); + } + Ok(bytes) +} + fn take_required_auth_option( args: &mut VecDeque, expected: &str, @@ -1400,6 +1420,21 @@ mod tests { worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked ); + let oversized_bundle = temp.path().join("oversized-public-bundle.json"); + std::fs::write( + &oversized_bundle, + vec![b'x'; MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES as usize + 1], + ) + .unwrap(); + let error = read_workspace_identity_bundle(&oversized_bundle) + .unwrap_err() + .to_string(); + assert_eq!( + error, + "Workspace signing public identity bundle is too large" + ); + assert!(!error.contains(&oversized_bundle.to_string_lossy().into_owned())); + #[cfg(unix)] { use std::os::unix::fs::PermissionsExt as _; diff --git a/crates/worker-runtime/src/workspace_issuer.rs b/crates/worker-runtime/src/workspace_issuer.rs index 07dbc47c..fcd33cb9 100644 --- a/crates/worker-runtime/src/workspace_issuer.rs +++ b/crates/worker-runtime/src/workspace_issuer.rs @@ -16,7 +16,9 @@ const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v1."; const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519"; const MAX_TOKEN_BYTES: usize = 16 * 1024; const MAX_ID_BYTES: usize = 256; +const MAX_ISSUER_BYTES: usize = 2 * 1024; const MAX_OPERATION_BYTES: usize = 128; +const MAX_REPLAY_ENTRIES: usize = 65_536; const MAX_TOKEN_LIFETIME_SECONDS: i64 = 300; const MAX_CLOCK_SKEW_SECONDS: i64 = 30; @@ -212,6 +214,12 @@ fn validate_bundle( ) -> Result<(), WorkspaceIssuerTrustError> { validate_id(&bundle.workspace_id)?; validate_id(&bundle.key_id)?; + if bundle.backend_url.len() > MAX_ISSUER_BYTES + || bundle.backend_url.trim() != bundle.backend_url + || bundle.backend_url.chars().any(char::is_control) + { + return Err(WorkspaceIssuerTrustError::InvalidBackendUrl); + } let backend_url = url::Url::parse(&bundle.backend_url) .map_err(|_| WorkspaceIssuerTrustError::InvalidBackendUrl)?; if !matches!(backend_url.scheme(), "http" | "https") @@ -332,6 +340,9 @@ impl WorkspaceClaimReplayProtection for InMemoryWorkspaceClaimReplayProtection { if consumed.contains_key(&key) { return Ok(false); } + if consumed.len() >= MAX_REPLAY_ENTRIES { + return Err(WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable); + } consumed.insert(key, expires_at); Ok(true) } @@ -580,6 +591,12 @@ fn validate_trust_record( fn validate_claim_shape( claims: &WorkspaceCapabilityClaims, ) -> Result<(), WorkspaceCapabilityVerificationError> { + if claims.issuer.len() > MAX_ISSUER_BYTES + || claims.issuer.trim() != claims.issuer + || claims.issuer.chars().any(char::is_control) + { + return Err(WorkspaceCapabilityVerificationError::WrongIssuer); + } let issuer = url::Url::parse(&claims.issuer) .map_err(|_| WorkspaceCapabilityVerificationError::WrongIssuer)?; if !matches!(issuer.scheme(), "http" | "https") diff --git a/docs/development/server-runtime-auth.md b/docs/development/server-runtime-auth.md index b92a6175..a36d3c61 100644 --- a/docs/development/server-runtime-auth.md +++ b/docs/development/server-runtime-auth.md @@ -112,7 +112,7 @@ Workspace signing identity is Workspace-scoped. Provision the identity through t GET /api/w//settings/workspace/signing-identity ``` -Save the response's `identity` object—not the outer response wrapper—as `workspace-public-identity.json`, and transfer only that document to the Runtime host: +Save the response's non-null `public_bundle` object—not the outer response wrapper—as `workspace-public-identity.json`, and transfer only that document to the Runtime host: ```bash yoi-runtime trust-workspace add \