fix: harden Workspace issuer bootstrap
This commit is contained in:
@@ -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)]
|
||||
struct RuntimeAuthFile {
|
||||
#[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 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<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(
|
||||
args: &mut VecDeque<String>,
|
||||
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 _;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -112,7 +112,7 @@ Workspace signing identity is Workspace-scoped. Provision the identity through t
|
||||
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
|
||||
yoi-runtime trust-workspace add \
|
||||
|
||||
Reference in New Issue
Block a user