fix: harden Workspace issuer bootstrap

This commit is contained in:
2026-09-08 02:00:19 +09:00
parent e035df9e7b
commit fba5ecf54c
3 changed files with 56 additions and 4 deletions
+38 -3
View File
@@ -516,6 +516,8 @@ impl From<std::io::Error> for ProcessError {
} }
} }
const MAX_WORKSPACE_IDENTITY_BUNDLE_BYTES: u64 = 64 * 1024;
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
struct RuntimeAuthFile { struct RuntimeAuthFile {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@@ -551,9 +553,7 @@ fn run_trust_workspace_command(mut args: VecDeque<String>) -> Result<(), Process
let config = parse_auth_storage_flags(&mut args)?; let config = parse_auth_storage_flags(&mut args)?;
let auth_path = runtime_auth_path(&config); let auth_path = runtime_auth_path(&config);
let mut auth = read_runtime_auth_file(&auth_path)?; let mut auth = read_runtime_auth_file(&auth_path)?;
let bundle_bytes = std::fs::read(&bundle_path).map_err(|_| { let bundle_bytes = read_workspace_identity_bundle(Path::new(&bundle_path))?;
ProcessError::auth("Workspace signing public identity bundle is unavailable")
})?;
let bundle = serde_json::from_slice(&bundle_bytes).map_err(|_| { let bundle = serde_json::from_slice(&bundle_bytes).map_err(|_| {
ProcessError::auth("Workspace signing public identity bundle is invalid") ProcessError::auth("Workspace signing public identity bundle is invalid")
})?; })?;
@@ -622,6 +622,26 @@ fn run_trust_workspace_command(mut args: VecDeque<String>) -> Result<(), Process
} }
} }
fn read_workspace_identity_bundle(path: &Path) -> Result<Vec<u8>, 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( fn take_required_auth_option(
args: &mut VecDeque<String>, args: &mut VecDeque<String>,
expected: &str, expected: &str,
@@ -1400,6 +1420,21 @@ mod tests {
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked 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)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt as _; use std::os::unix::fs::PermissionsExt as _;
@@ -16,7 +16,9 @@ const WORKSPACE_SIGNING_INPUT_PREFIX: &str = "yoi.workspace.capability.v1.";
const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519"; const WORKSPACE_SIGNING_ALGORITHM: &str = "ed25519";
const MAX_TOKEN_BYTES: usize = 16 * 1024; const MAX_TOKEN_BYTES: usize = 16 * 1024;
const MAX_ID_BYTES: usize = 256; const MAX_ID_BYTES: usize = 256;
const MAX_ISSUER_BYTES: usize = 2 * 1024;
const MAX_OPERATION_BYTES: usize = 128; const MAX_OPERATION_BYTES: usize = 128;
const MAX_REPLAY_ENTRIES: usize = 65_536;
const MAX_TOKEN_LIFETIME_SECONDS: i64 = 300; const MAX_TOKEN_LIFETIME_SECONDS: i64 = 300;
const MAX_CLOCK_SKEW_SECONDS: i64 = 30; const MAX_CLOCK_SKEW_SECONDS: i64 = 30;
@@ -212,6 +214,12 @@ fn validate_bundle(
) -> Result<(), WorkspaceIssuerTrustError> { ) -> Result<(), WorkspaceIssuerTrustError> {
validate_id(&bundle.workspace_id)?; validate_id(&bundle.workspace_id)?;
validate_id(&bundle.key_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) let backend_url = url::Url::parse(&bundle.backend_url)
.map_err(|_| WorkspaceIssuerTrustError::InvalidBackendUrl)?; .map_err(|_| WorkspaceIssuerTrustError::InvalidBackendUrl)?;
if !matches!(backend_url.scheme(), "http" | "https") if !matches!(backend_url.scheme(), "http" | "https")
@@ -332,6 +340,9 @@ impl WorkspaceClaimReplayProtection for InMemoryWorkspaceClaimReplayProtection {
if consumed.contains_key(&key) { if consumed.contains_key(&key) {
return Ok(false); return Ok(false);
} }
if consumed.len() >= MAX_REPLAY_ENTRIES {
return Err(WorkspaceCapabilityVerificationError::ReplayAuthorityUnavailable);
}
consumed.insert(key, expires_at); consumed.insert(key, expires_at);
Ok(true) Ok(true)
} }
@@ -580,6 +591,12 @@ fn validate_trust_record(
fn validate_claim_shape( fn validate_claim_shape(
claims: &WorkspaceCapabilityClaims, claims: &WorkspaceCapabilityClaims,
) -> Result<(), WorkspaceCapabilityVerificationError> { ) -> 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) let issuer = url::Url::parse(&claims.issuer)
.map_err(|_| WorkspaceCapabilityVerificationError::WrongIssuer)?; .map_err(|_| WorkspaceCapabilityVerificationError::WrongIssuer)?;
if !matches!(issuer.scheme(), "http" | "https") if !matches!(issuer.scheme(), "http" | "https")
+1 -1
View File
@@ -112,7 +112,7 @@ Workspace signing identity is Workspace-scoped. Provision the identity through t
GET /api/w/<WORKSPACE_ID>/settings/workspace/signing-identity GET /api/w/<WORKSPACE_ID>/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 ```bash
yoi-runtime trust-workspace add \ yoi-runtime trust-workspace add \