From 4ebc465e8dad3dcc3e1a96e8348fb79de0f481c2 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 26 Aug 2026 19:02:20 +0900 Subject: [PATCH] fix: constrain repository SSH commands to authorized source --- crates/worker-runtime/src/http_server.rs | 3 +- crates/worker-runtime/src/main.rs | 6 +- crates/worker-runtime/src/runtime.rs | 223 ++++++++-- .../worker-runtime/src/working_directory.rs | 404 +++++++++++++----- 4 files changed, 471 insertions(+), 165 deletions(-) diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index 57adb0d9..20b9e442 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -575,7 +575,8 @@ async fn create_working_directory( } let working_directory = state .runtime - .create_working_directory(request) + .create_working_directory_from_resource(request) + .await .map_err(RuntimeHttpRestError::runtime)?; Ok(Json(RuntimeHttpWorkingDirectoryResponse { working_directory, diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index d4ce88be..a07c2bb2 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -28,11 +28,9 @@ use worker_runtime::{Runtime, RuntimeOptions}; fn main() -> ExitCode { let mut arguments = std::env::args().skip(1).collect::>(); - if arguments.first().map(String::as_str) == Some("__repository-read-only-ssh") { + if arguments.first().map(String::as_str) == Some("__repository-ssh") { arguments.remove(0); - return match worker_runtime::working_directory::run_repository_read_only_ssh_client( - &arguments, - ) { + return match worker_runtime::working_directory::run_repository_ssh_client(&arguments) { Ok(status) => ExitCode::from(u8::try_from(status).unwrap_or(1)), Err(error) => { eprintln!("{error}"); diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index a84ad4de..5bc2ad5e 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -378,6 +378,20 @@ impl Runtime { .map_err(RuntimeError::from) } + pub async fn create_working_directory_from_resource( + &self, + mut request: WorkingDirectoryRequest, + ) -> Result { + if let Some(ssh) = request + .materialization + .as_mut() + .and_then(|materialization| materialization.ssh.as_mut()) + { + self.resolve_repository_access_resource(ssh).await?; + } + self.create_working_directory(request) + } + pub fn authorize_working_directory_repository_access( &self, request: WorkingDirectoryRepositoryAccessRequest, @@ -397,6 +411,59 @@ impl Runtime { .map_err(RuntimeError::from) } + async fn resolve_repository_access_resource( + &self, + ssh: &mut crate::catalog::RepositorySshMaterializationAccess, + ) -> Result<(), RuntimeError> { + if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() { + return Ok(()); + } + let (client, runtime_id) = { + let state = self.lock()?; + let client = state.backend_resource_client.clone().ok_or_else(|| { + RuntimeError::InvalidRequest( + "Backend Repository access resource client is unavailable".to_string(), + ) + })?; + let runtime_id = state.runtime_identity.clone().ok_or_else(|| { + RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string()) + })?; + (client, runtime_id) + }; + let mut response = client + .0 + .fetch_resource(BackendResourceFetchRequest { + handle: ssh.secret_resource.clone(), + runtime_id, + worker_id: None, + audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(), + }) + .await + .map_err(repository_resource_error)?; + if response.kind != BackendResourceKind::RepositorySshAccess + || response.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE + || response.resource_id != ssh.secret_resource.resource_id + || response.digest != ssh.secret_resource.digest + || response.bytes.len() as u64 > ssh.secret_resource.max_bytes + { + return Err(RuntimeError::InvalidRequest( + "Backend Repository SSH access resource response was invalid".to_string(), + )); + } + let secret = serde_json::from_slice::(&response.bytes); + response.bytes.fill(0); + let mut secret = secret.map_err(|_| { + RuntimeError::InvalidRequest( + "Backend Repository SSH access resource payload was invalid".to_string(), + ) + })?; + ssh.private_key = + crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key)); + ssh.known_hosts_entry = + crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry)); + Ok(()) + } + pub async fn authorize_working_directory_repository_access_from_resource( &self, mut request: WorkingDirectoryRepositoryAccessRequest, @@ -404,51 +471,7 @@ impl Runtime { let ssh = request.materialization.ssh.as_mut().ok_or_else(|| { RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string()) })?; - if ssh.private_key.expose().is_empty() || ssh.known_hosts_entry.expose().is_empty() { - let (client, runtime_id) = { - let state = self.lock()?; - let client = state.backend_resource_client.clone().ok_or_else(|| { - RuntimeError::InvalidRequest( - "Backend Repository access resource client is unavailable".to_string(), - ) - })?; - let runtime_id = state.runtime_identity.clone().ok_or_else(|| { - RuntimeError::InvalidRequest("Runtime identity is unavailable".to_string()) - })?; - (client, runtime_id) - }; - let mut response = client - .0 - .fetch_resource(BackendResourceFetchRequest { - handle: ssh.secret_resource.clone(), - runtime_id, - worker_id: None, - audit_correlation_id: ssh.secret_resource.audit_correlation_id.clone(), - }) - .await - .map_err(repository_resource_error)?; - if response.kind != BackendResourceKind::RepositorySshAccess - || response.content_type != REPOSITORY_SSH_ACCESS_CONTENT_TYPE - || response.resource_id != ssh.secret_resource.resource_id - || response.digest != ssh.secret_resource.digest - || response.bytes.len() as u64 > ssh.secret_resource.max_bytes - { - return Err(RuntimeError::InvalidRequest( - "Backend Repository SSH access resource response was invalid".to_string(), - )); - } - let secret = serde_json::from_slice::(&response.bytes); - response.bytes.fill(0); - let mut secret = secret.map_err(|_| { - RuntimeError::InvalidRequest( - "Backend Repository SSH access resource payload was invalid".to_string(), - ) - })?; - ssh.private_key = - crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key)); - ssh.known_hosts_entry = - crate::catalog::SensitiveString::new(std::mem::take(&mut secret.known_hosts_entry)); - } + self.resolve_repository_access_resource(ssh).await?; self.authorize_working_directory_repository_access(request) } @@ -3335,6 +3358,9 @@ mod tests { #[tokio::test] async fn repository_access_resource_is_fetched_before_provider_authorization() { let (runtime, backend) = runtime_and_backend(); + backend + .repository_access_available + .store(true, Ordering::SeqCst); runtime.bind_runtime_identity("runtime-1").unwrap(); let handle = repository_resource_handle(); runtime @@ -3398,6 +3424,88 @@ mod tests { assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry"); } + #[tokio::test] + async fn working_directory_create_fetches_repository_access_before_provider_call() { + let (runtime, backend) = runtime_and_backend(); + backend + .repository_access_available + .store(true, Ordering::SeqCst); + runtime.bind_runtime_identity("runtime-1").unwrap(); + let handle = repository_resource_handle(); + runtime + .install_backend_resource_client(Arc::new(TestRepositoryResourceClient { + response: Mutex::new(Some(crate::resource::BackendResourceFetchResponse { + kind: crate::resource::BackendResourceKind::RepositorySshAccess, + resource_id: handle.resource_id.clone(), + digest: handle.digest.clone(), + content_type: crate::resource::REPOSITORY_SSH_ACCESS_CONTENT_TYPE.to_string(), + bytes: serde_json::to_vec(&RepositorySshAccessSecret { + private_key: "create-private-key-bytes".to_string(), + known_hosts_entry: "create-known-hosts-entry".to_string(), + }) + .unwrap(), + audit_correlation_id: handle.audit_correlation_id.clone(), + })), + })) + .unwrap(); + let request = WorkingDirectoryRequest { + repository: WorkingDirectoryRepository { + id: "repository-1".to_string(), + provider: "git".to_string(), + source: workspace_api::RepositorySource { + kind: workspace_api::RepositorySourceKind::Ssh, + uri: "ssh://git@example.test/repo.git".to_string(), + }, + source_revision: 1, + source_fingerprint: "sha256:source".to_string(), + selector: None, + }, + materializer: MaterializerKind::RuntimeGitCache, + backend_workdir_id: Some("working-directory-1".to_string()), + materialization: Some(RepositoryMaterializationContext { + workspace_id: "workspace-1".to_string(), + runtime_id: "runtime-1".to_string(), + operation_id: "operation-create".to_string(), + config_revision: 1, + config_projection_digest: "sha256:projection".to_string(), + cache_generation: 0, + ssh: Some(RepositorySshMaterializationAccess { + credential_id: "credential-1".to_string(), + credential_revision: 1, + host_trust_id: "host-trust-1".to_string(), + host_trust_revision: 1, + access: workspace_api::RepositoryAccessMode::ReadOnly, + expires_at_epoch_seconds: u64::MAX, + repository_id: "repository-1".to_string(), + repository_source_fingerprint: "sha256:source".to_string(), + repository_uri: "ssh://git@example.test/repo.git".to_string(), + secret_resource: handle, + private_key: SensitiveString::default(), + known_hosts_entry: SensitiveString::default(), + }), + }), + }; + + assert!( + runtime + .create_working_directory_from_resource(request) + .await + .is_err() + ); + + let requests = backend.working_directory_requests.lock().unwrap(); + let access = requests[0] + .materialization + .as_ref() + .and_then(|materialization| materialization.ssh.as_ref()) + .unwrap(); + assert_eq!(access.private_key.expose(), "create-private-key-bytes"); + assert_eq!( + access.known_hosts_entry.expose(), + "create-known-hosts-entry" + ); + } + fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest { let mut request = task_request(objective); request.workspace_api = Some(WorkspaceApiRef { @@ -3480,6 +3588,8 @@ mod tests { contexts: Mutex>, dispatched_inputs: Mutex>, repository_accesses: Mutex>, + repository_access_available: AtomicBool, + working_directory_requests: Mutex>, preserve_commit_ack_submission_id: AtomicBool, #[cfg(feature = "ws-server")] snapshots: Mutex>, @@ -3520,6 +3630,20 @@ mod tests { "test-execution-backend" } + fn create_working_directory( + &self, + request: &WorkingDirectoryRequest, + ) -> Result { + self.working_directory_requests + .lock() + .unwrap() + .push(request.clone()); + Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_unsupported", + "Worker execution backend does not support working directory materialization", + )) + } + fn authorize_working_directory_repository_access( &self, request: &WorkingDirectoryRepositoryAccessRequest, @@ -3528,7 +3652,14 @@ mod tests { .lock() .unwrap() .push(request.clone()); - Ok(()) + if self.repository_access_available.load(Ordering::SeqCst) { + Ok(()) + } else { + Err(WorkingDirectoryDiagnostic::rejected( + "working_directory_repository_access_unsupported", + "Worker execution backend does not support Repository access authorization", + )) + } } fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult { diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index edaa8f98..06702830 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -488,12 +488,9 @@ impl RuntimeGitCacheMaterializer { access.stop(); } }); - if access.access == workspace_api::RepositoryAccessMode::ReadWrite { - binding.command_environment.insert( - "SSH_AUTH_SOCK".to_string(), - command_access.agent.socket.to_string_lossy().to_string(), - ); - } + binding + .command_environment + .insert("SSH_AUTH_SOCK".to_string(), "/dev/null".to_string()); binding.command_environment.insert( "GIT_SSH_COMMAND".to_string(), command_access.ssh_command.to_string_lossy().to_string(), @@ -703,14 +700,23 @@ impl RuntimeGitCacheMaterializer { let mut request = request.clone(); if request.repository.provider != "git" || request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh - || request - .materialization - .as_ref() - .and_then(|materialization| materialization.ssh.as_ref()) - .is_some() { return Ok(request); } + if let Some(access) = request + .materialization + .as_ref() + .and_then(|materialization| materialization.ssh.as_ref()) + { + validate_ssh_materialization_access(access)?; + validate_repository_access_binding( + access, + &request.repository.id, + &request.repository.source_fingerprint, + Some(request.repository.source.uri.as_str()), + )?; + return Ok(request); + } let access = self .repository_access .lock() @@ -1266,29 +1272,30 @@ impl std::fmt::Debug for PendingRepositorySshRequest { } #[derive(Debug)] -struct RepositoryReadOnlySshBroker { +struct RepositorySshBroker { socket: PathBuf, stopped: Arc, thread: Mutex>>, } -impl RepositoryReadOnlySshBroker { +impl RepositorySshBroker { fn start( root: &Path, agent: Arc, known_hosts: PathBuf, + policy: RepositorySshCommandPolicy, ) -> Result { let socket = root.join("b.sock"); let listener = UnixListener::bind(&socket).map_err(|_| { WorkingDirectoryDiagnostic::new( "working_directory_repository_access_setup_failed", - "read-only Repository SSH broker could not be prepared", + "Repository SSH broker could not be prepared", ) })?; listener.set_nonblocking(true).map_err(|_| { WorkingDirectoryDiagnostic::new( "working_directory_repository_access_setup_failed", - "read-only Repository SSH broker could not be prepared", + "Repository SSH broker could not be prepared", ) })?; let stopped = Arc::new(AtomicBool::new(false)); @@ -1308,11 +1315,13 @@ impl RepositoryReadOnlySshBroker { { let request_agent = Arc::clone(&agent); let request_known_hosts = known_hosts.clone(); + let request_policy = policy.clone(); std::thread::spawn(move || { - run_brokered_read_only_ssh( + run_brokered_repository_ssh( request, &request_agent.socket, &request_known_hosts, + &request_policy, ); }); pending.remove(&request_id); @@ -1346,7 +1355,7 @@ impl RepositoryReadOnlySshBroker { } } -impl Drop for RepositoryReadOnlySshBroker { +impl Drop for RepositorySshBroker { fn drop(&mut self) { self.stop(); } @@ -1401,10 +1410,11 @@ fn receive_repository_ssh_channel( } } -fn run_brokered_read_only_ssh( +fn run_brokered_repository_ssh( mut request: PendingRepositorySshRequest, agent_socket: &Path, known_hosts: &Path, + policy: &RepositorySshCommandPolicy, ) { let args = request.args.take().unwrap_or_default(); let mut data = request.data.take().expect("complete broker request data"); @@ -1416,8 +1426,8 @@ fn run_brokered_read_only_ssh( .status .take() .expect("complete broker request status"); - let status = if !validate_read_only_ssh_args(&args) { - let _ = stderr_stream.write_all(b"read-only Repository SSH operation denied\n"); + let status = if !validate_repository_ssh_args(&args, policy) { + let _ = stderr_stream.write_all(b"Repository SSH operation denied\n"); let _ = stderr_stream.shutdown(Shutdown::Write); let _ = data.shutdown(Shutdown::Both); 126 @@ -1480,28 +1490,114 @@ fn run_brokered_read_only_ssh( let _ = status_stream.shutdown(Shutdown::Write); } -fn validate_read_only_ssh_args(args: &[String]) -> bool { +#[derive(Clone, Debug)] +struct RepositorySshCommandPolicy { + host: String, + username: Option, + port: Option, + repository_path: String, + access: workspace_api::RepositoryAccessMode, +} + +impl RepositorySshCommandPolicy { + fn from_access( + access: &RepositorySshMaterializationAccess, + ) -> Result { + let uri = url::Url::parse(&access.repository_uri).map_err(|_| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_binding_mismatch", + "Repository SSH access URI is invalid", + ) + })?; + if uri.scheme() != "ssh" + || uri.password().is_some() + || uri.query().is_some() + || uri.fragment().is_some() + { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_binding_mismatch", + "Repository SSH access URI is not an authorized SSH endpoint", + )); + } + let host = uri + .host_str() + .filter(|host| !host.is_empty()) + .ok_or_else(|| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_binding_mismatch", + "Repository SSH access host is missing", + ) + })?; + let username = (!uri.username().is_empty()).then(|| uri.username().to_string()); + if username + .as_deref() + .is_some_and(|username| !is_safe_ssh_destination(username)) + || uri.path().is_empty() + || !is_safe_repository_path(uri.path()) + { + return Err(WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_binding_mismatch", + "Repository SSH access endpoint or path is invalid", + )); + } + Ok(Self { + host: host.to_string(), + username, + port: uri.port(), + repository_path: uri.path().to_string(), + access: access.access, + }) + } + + fn destination_matches(&self, destination: &str, login: Option<&str>) -> bool { + let host = if self.host.contains(':') { + format!("[{}]", self.host) + } else { + self.host.clone() + }; + match self.username.as_deref() { + Some(username) => { + (login.is_none() && destination == format!("{username}@{host}")) + || (login == Some(username) && destination == host) + } + None => login.is_none() && destination == host, + } + } +} + +fn validate_repository_ssh_args(args: &[String], policy: &RepositorySshCommandPolicy) -> bool { let mut index = 0; let mut probe = false; + let mut port = None; + let mut login = None; let mut positional = Vec::new(); while index < args.len() { match args[index].as_str() { "-G" => probe = true, "-4" | "-6" | "-v" | "-vv" | "-vvv" => {} "-p" => { - index += 1; - if index >= args.len() - || args[index].is_empty() - || !args[index].bytes().all(|byte| byte.is_ascii_digit()) - { + if port.is_some() { return false; } + index += 1; + let Some(value) = args.get(index).and_then(|value| value.parse::().ok()) + else { + return false; + }; + port = Some(value); } "-l" => { - index += 1; - if index >= args.len() || !is_safe_ssh_destination(&args[index]) { + if login.is_some() { return false; } + index += 1; + let Some(value) = args + .get(index) + .filter(|value| is_safe_ssh_destination(value)) + else { + return false; + }; + login = Some(value.as_str()); } "-o" => { index += 1; @@ -1519,12 +1615,16 @@ fn validate_read_only_ssh_args(args: &[String]) -> bool { } index += 1; } + if port != policy.port + || positional.is_empty() + || !policy.destination_matches(positional[0], login) + { + return false; + } if probe { - positional.len() == 1 && is_safe_ssh_destination(positional[0]) + positional.len() == 1 } else { - positional.len() == 2 - && is_safe_ssh_destination(positional[0]) - && is_safe_read_only_git_command(positional[1]) + positional.len() == 2 && repository_command_matches(positional[1], policy) } } @@ -1548,31 +1648,43 @@ fn is_safe_ssh_destination(value: &str) -> bool { }) } -fn is_safe_read_only_git_command(command: &str) -> bool { - let Some(argument) = command - .strip_prefix("git-upload-pack ") - .or_else(|| command.strip_prefix("git-upload-archive ")) - else { - return false; - }; - let argument = argument - .strip_prefix('\'') - .and_then(|argument| argument.strip_suffix('\'')) - .unwrap_or(argument); - !argument.is_empty() - && argument.bytes().all(|byte| { +fn is_safe_repository_path(path: &str) -> bool { + !path.is_empty() + && path.bytes().all(|byte| { byte.is_ascii_alphanumeric() || matches!( byte, - b'/' | b'.' | b'-' | b'_' | b'@' | b':' | b'+' | b'~' | b' ' + b'/' | b'.' | b'-' | b'_' | b'@' | b':' | b'+' | b'~' | b' ' | b'%' ) }) } -pub fn run_repository_read_only_ssh_client(arguments: &[String]) -> Result { +fn repository_command_matches(command: &str, policy: &RepositorySshCommandPolicy) -> bool { + let (operation, argument) = if let Some(argument) = command.strip_prefix("git-upload-pack ") { + ("upload-pack", argument) + } else if let Some(argument) = command.strip_prefix("git-upload-archive ") { + ("upload-archive", argument) + } else if let Some(argument) = command.strip_prefix("git-receive-pack ") { + ("receive-pack", argument) + } else { + return false; + }; + if operation == "receive-pack" + && policy.access != workspace_api::RepositoryAccessMode::ReadWrite + { + return false; + } + let argument = argument + .strip_prefix('\'') + .and_then(|argument| argument.strip_suffix('\'')) + .unwrap_or(argument); + is_safe_repository_path(argument) && argument == policy.repository_path +} + +pub fn run_repository_ssh_client(arguments: &[String]) -> Result { let (socket, ssh_args) = arguments .split_first() - .ok_or_else(|| "read-only Repository SSH broker socket is missing".to_string())?; + .ok_or_else(|| "Repository SSH broker socket is missing".to_string())?; let request_id = format!( "{}-{}", std::process::id(), @@ -1600,7 +1712,7 @@ pub fn run_repository_read_only_ssh_client(arguments: &[String]) -> Result Result() - .map_err(|_| "read-only Repository SSH broker status was invalid".to_string()) + .map_err(|_| "Repository SSH broker status was invalid".to_string()) } fn connect_repository_ssh_broker_channel( @@ -1627,12 +1739,12 @@ fn connect_repository_ssh_broker_channel( header: &RepositorySshBrokerHeader, ) -> Result { let mut stream = UnixStream::connect(socket) - .map_err(|_| "read-only Repository SSH broker is unavailable".to_string())?; + .map_err(|_| "Repository SSH broker is unavailable".to_string())?; serde_json::to_writer(&mut stream, header) - .map_err(|_| "read-only Repository SSH broker request failed".to_string())?; + .map_err(|_| "Repository SSH broker request failed".to_string())?; stream .write_all(b"\n") - .map_err(|_| "read-only Repository SSH broker request failed".to_string())?; + .map_err(|_| "Repository SSH broker request failed".to_string())?; Ok(stream) } @@ -1641,7 +1753,7 @@ struct RepositoryCommandAccess { root: PathBuf, ssh_command: PathBuf, agent: Arc, - read_only_broker: Option, + ssh_broker: Option, } impl RepositoryCommandAccess { @@ -1694,46 +1806,37 @@ impl RepositoryCommandAccess { let ssh_command = root.join("ssh-command"); write_owner_only(&known_hosts, ssh.known_hosts_entry.expose().as_bytes())?; let agent = Arc::new(RepositorySshAgent::start(runtime_root, operation_id, ssh)?); - let read_only_broker = if ssh.access == workspace_api::RepositoryAccessMode::ReadOnly { - Some(RepositoryReadOnlySshBroker::start( - &root, - Arc::clone(&agent), - known_hosts.clone(), - )?) - } else { - None - }; - let script = if let Some(broker) = read_only_broker.as_ref() { - let executable = std::env::current_exe().map_err(|_| { - WorkingDirectoryDiagnostic::new( - "working_directory_repository_access_setup_failed", - "read-only Repository SSH broker client could not be resolved", - ) - })?; - format!( - "#!/bin/sh\nexec {} __repository-read-only-ssh {} \"$@\"\n", - shell_quote_path(&executable)?, - shell_quote_path(&broker.socket)?, + let policy = RepositorySshCommandPolicy::from_access(ssh)?; + let ssh_broker = Some(RepositorySshBroker::start( + &root, + Arc::clone(&agent), + known_hosts.clone(), + policy, + )?); + let broker = ssh_broker.as_ref().expect("Repository SSH broker"); + let executable = std::env::current_exe().map_err(|_| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_setup_failed", + "Repository SSH broker client could not be resolved", ) - } else { - format!( - "#!/bin/sh\nexport SSH_AUTH_SOCK={}\nexec ssh -F /dev/null -o BatchMode=yes -o IdentitiesOnly=no -o IdentityFile=/dev/null -o StrictHostKeyChecking=yes -o UserKnownHostsFile={} \"$@\"\n", - shell_quote_path(&agent.socket)?, - shell_quote_path(&known_hosts)?, - ) - }; + })?; + let script = format!( + "#!/bin/sh\nexec {} __repository-ssh {} \"$@\"\n", + shell_quote_path(&executable)?, + shell_quote_path(&broker.socket)?, + ); write_owner_only(&ssh_command, script.as_bytes())?; set_file_owner_executable(&ssh_command)?; Ok(Self { root, ssh_command, agent, - read_only_broker, + ssh_broker, }) } fn stop(&self) { - if let Some(broker) = self.read_only_broker.as_ref() { + if let Some(broker) = self.ssh_broker.as_ref() { broker.stop(); } self.agent.stop(); @@ -2727,17 +2830,27 @@ mod tests { ); let binding = materializer.bind_working_directory(&id, None).unwrap(); let environment = binding.command_environment(); - let socket = PathBuf::from(environment["SSH_AUTH_SOCK"].clone()); + assert_eq!(environment["SSH_AUTH_SOCK"], "/dev/null"); let ssh_command = PathBuf::from(environment["GIT_SSH_COMMAND"].clone()); - assert!(socket.exists()); assert!(ssh_command.exists()); let ssh_policy = fs::read_to_string(&ssh_command).unwrap(); - assert!(ssh_policy.contains("StrictHostKeyChecking=yes")); - assert!(ssh_policy.contains("UserKnownHostsFile=")); + assert!(ssh_policy.contains("__repository-ssh")); + assert!(!ssh_policy.contains("SSH_AUTH_SOCK")); + assert_eq!( + fs::read_dir(runtime_root.path().join(".repository-agents")) + .map(|entries| entries.count()) + .unwrap_or_default(), + 1 + ); assert_eq!(environment["YOI_REPOSITORY_ACCESS"], "read_write"); drop(binding); - assert!(!socket.exists()); assert!(!ssh_command.exists()); + assert_eq!( + fs::read_dir(runtime_root.path().join(".repository-agents")) + .map(|entries| entries.count()) + .unwrap_or_default(), + 0 + ); assert_eq!( materializer .bind_working_directory(&id, None) @@ -2762,17 +2875,22 @@ mod tests { ); let rebound_environment = rebound.command_environment(); assert_eq!(rebound_environment["YOI_REPOSITORY_ACCESS"], "read_only"); - assert!(!rebound_environment.contains_key("SSH_AUTH_SOCK")); + assert_eq!(rebound_environment["SSH_AUTH_SOCK"], "/dev/null"); let read_only_ssh = &rebound_environment["GIT_SSH_COMMAND"]; let read_only_policy = fs::read_to_string(read_only_ssh).unwrap(); - assert!(read_only_policy.contains("__repository-read-only-ssh")); + assert!(read_only_policy.contains("__repository-ssh")); assert!(!read_only_policy.contains("SSH_AUTH_SOCK")); assert!(!read_only_policy.contains(".repository-agents")); assert!(!read_only_policy.contains("known_hosts")); - assert!(!validate_read_only_ssh_args(&[ - "example.test".to_string(), - "git-receive-pack 'repo.git'".to_string(), - ])); + let read_only_command_policy = + RepositorySshCommandPolicy::from_access(rotated.ssh.as_ref().unwrap()).unwrap(); + assert!(!validate_repository_ssh_args( + &[ + "git@example.test".to_string(), + "git-receive-pack '/repo.git'".to_string(), + ], + &read_only_command_policy, + )); let broker_socket = fs::read_dir(runtime_root.path().join(REPOSITORY_ACCESS_DIR)) .unwrap() .filter_map(Result::ok) @@ -2780,35 +2898,77 @@ mod tests { .find(|path| path.exists()) .expect("read-only broker socket"); assert_eq!( - run_repository_read_only_ssh_client(&[ + run_repository_ssh_client(&[ broker_socket.to_string_lossy().to_string(), "-G".to_string(), - "example.test".to_string(), + "git@example.test".to_string(), ]) .unwrap(), 0 ); assert_eq!( - run_repository_read_only_ssh_client(&[ + run_repository_ssh_client(&[ broker_socket.to_string_lossy().to_string(), - "example.test".to_string(), - "git-receive-pack 'repo.git'".to_string(), + "git@example.test".to_string(), + "git-receive-pack '/repo.git'".to_string(), ]) .unwrap(), 126 ); - assert!(!validate_read_only_ssh_args(&[ - "-o".to_string(), - "ProxyCommand=sh -c exploit".to_string(), - "example.test".to_string(), - "git-upload-pack 'repo.git'".to_string(), - ])); - assert!(validate_read_only_ssh_args(&[ - "-o".to_string(), - "SendEnv=GIT_PROTOCOL".to_string(), - "example.test".to_string(), - "git-upload-pack 'repo.git'".to_string(), - ])); + assert!(!validate_repository_ssh_args( + &[ + "-o".to_string(), + "ProxyCommand=sh -c exploit".to_string(), + "git@example.test".to_string(), + "git-upload-pack '/repo.git'".to_string(), + ], + &read_only_command_policy, + )); + assert!(validate_repository_ssh_args( + &[ + "-o".to_string(), + "SendEnv=GIT_PROTOCOL".to_string(), + "git@example.test".to_string(), + "git-upload-pack '/repo.git'".to_string(), + ], + &read_only_command_policy, + )); + assert!(!validate_repository_ssh_args( + &[ + "git@other.test".to_string(), + "git-upload-pack '/repo.git'".to_string(), + ], + &read_only_command_policy, + )); + let mut port_bound_access = rotated.ssh.as_ref().unwrap().clone(); + port_bound_access.repository_uri = "ssh://git@example.test:2222/repo.git".to_string(); + let port_bound_policy = + RepositorySshCommandPolicy::from_access(&port_bound_access).unwrap(); + assert!(validate_repository_ssh_args( + &[ + "-p".to_string(), + "2222".to_string(), + "git@example.test".to_string(), + "git-upload-pack '/repo.git'".to_string(), + ], + &port_bound_policy, + )); + assert!(!validate_repository_ssh_args( + &[ + "-p".to_string(), + "22".to_string(), + "git@example.test".to_string(), + "git-upload-pack '/repo.git'".to_string(), + ], + &port_bound_policy, + )); + assert!(!validate_repository_ssh_args( + &[ + "git@example.test".to_string(), + "git-upload-pack '/other.git'".to_string(), + ], + &read_only_command_policy, + )); assert_eq!( git_stdout( rebound.root(), @@ -2824,6 +2984,8 @@ mod tests { read_write.operation_id = "operation-agent-read-write".to_string(); read_write.ssh.as_mut().unwrap().credential_revision = 3; read_write.ssh.as_mut().unwrap().access = workspace_api::RepositoryAccessMode::ReadWrite; + let read_write_command_policy = + RepositorySshCommandPolicy::from_access(read_write.ssh.as_ref().unwrap()).unwrap(); materializer .authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest { working_directory_id: id.clone(), @@ -2835,7 +2997,21 @@ mod tests { rebound.command_environment()["YOI_REPOSITORY_ACCESS"], "read_write" ); - assert!(rebound.command_environment().contains_key("SSH_AUTH_SOCK")); + assert_eq!(rebound.command_environment()["SSH_AUTH_SOCK"], "/dev/null"); + assert!(validate_repository_ssh_args( + &[ + "git@example.test".to_string(), + "git-receive-pack '/repo.git'".to_string(), + ], + &read_write_command_policy, + )); + assert!(!validate_repository_ssh_args( + &[ + "git@example.test".to_string(), + "git-receive-pack '/other.git'".to_string(), + ], + &read_write_command_policy, + )); assert!( git_stdout( rebound.root(),