diff --git a/crates/worker-runtime/src/main.rs b/crates/worker-runtime/src/main.rs index c7b8179c..3e5d1e3c 100644 --- a/crates/worker-runtime/src/main.rs +++ b/crates/worker-runtime/src/main.rs @@ -27,6 +27,19 @@ use worker_runtime::working_directory::RuntimeGitCacheMaterializer; 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") { + arguments.remove(0); + return match worker_runtime::working_directory::run_repository_read_only_ssh_client( + &arguments, + ) { + Ok(status) => ExitCode::from(u8::try_from(status).unwrap_or(1)), + Err(error) => { + eprintln!("{error}"); + ExitCode::from(1) + } + }; + } match run() { Ok(()) => ExitCode::SUCCESS, Err(error) => { diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index e7636e9d..adfbe04a 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -8,11 +8,16 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::{BTreeMap, HashMap}; use std::fs; -use std::io::Write; +use std::io::{BufRead, BufReader, Read, Write}; +#[cfg(unix)] +use std::net::Shutdown; +#[cfg(unix)] +use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Component, Path, PathBuf}; use std::process::{Command, Stdio}; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; +use std::thread::JoinHandle; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use workdir::WorkdirSessionResource; @@ -1133,11 +1138,419 @@ impl Drop for RepositorySshAgent { } } +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "channel", rename_all = "snake_case")] +enum RepositorySshBrokerHeader { + Data { + request_id: String, + args: Vec, + }, + Stderr { + request_id: String, + }, + Status { + request_id: String, + }, +} + +#[derive(Default)] +struct PendingRepositorySshRequest { + created_at: Option, + args: Option>, + data: Option, + stderr: Option, + status: Option, +} + +impl std::fmt::Debug for PendingRepositorySshRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("PendingRepositorySshRequest") + .field("args", &self.args) + .field("data_ready", &self.data.is_some()) + .field("stderr_ready", &self.stderr.is_some()) + .field("status_ready", &self.status.is_some()) + .finish() + } +} + +#[derive(Debug)] +struct RepositoryReadOnlySshBroker { + socket: PathBuf, + stopped: Arc, + thread: Mutex>>, +} + +impl RepositoryReadOnlySshBroker { + fn start( + root: &Path, + agent: Arc, + known_hosts: PathBuf, + ) -> 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", + ) + })?; + listener.set_nonblocking(true).map_err(|_| { + WorkingDirectoryDiagnostic::new( + "working_directory_repository_access_setup_failed", + "read-only Repository SSH broker could not be prepared", + ) + })?; + let stopped = Arc::new(AtomicBool::new(false)); + let broker_stopped = Arc::clone(&stopped); + let thread = std::thread::spawn(move || { + let mut pending = HashMap::::new(); + while !broker_stopped.load(Ordering::Acquire) { + pending.retain(|_, request| { + request + .created_at + .is_some_and(|created_at| created_at.elapsed() < Duration::from_secs(5)) + }); + match listener.accept() { + Ok((stream, _)) => { + if let Some((request_id, request)) = + receive_repository_ssh_channel(stream, &mut pending) + { + let request_agent = Arc::clone(&agent); + let request_known_hosts = known_hosts.clone(); + std::thread::spawn(move || { + run_brokered_read_only_ssh( + request, + &request_agent.socket, + &request_known_hosts, + ); + }); + pending.remove(&request_id); + } + } + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + std::thread::sleep(Duration::from_millis(10)); + } + Err(_) => break, + } + } + }); + Ok(Self { + socket, + stopped, + thread: Mutex::new(Some(thread)), + }) + } + + fn stop(&self) { + if self.stopped.swap(true, Ordering::AcqRel) { + return; + } + let _ = UnixStream::connect(&self.socket); + if let Ok(mut thread) = self.thread.lock() + && let Some(thread) = thread.take() + { + let _ = thread.join(); + } + let _ = fs::remove_file(&self.socket); + } +} + +impl Drop for RepositoryReadOnlySshBroker { + fn drop(&mut self) { + self.stop(); + } +} + +fn receive_repository_ssh_channel( + stream: UnixStream, + pending: &mut HashMap, +) -> Option<(String, PendingRepositorySshRequest)> { + let mut header = String::new(); + if BufReader::new(stream.try_clone().ok()?) + .take(8_193) + .read_line(&mut header) + .ok()? + == 0 + || header.len() > 8_192 + { + return None; + } + let header: RepositorySshBrokerHeader = serde_json::from_str(header.trim_end()).ok()?; + let request_id = match &header { + RepositorySshBrokerHeader::Data { request_id, .. } + | RepositorySshBrokerHeader::Stderr { request_id } + | RepositorySshBrokerHeader::Status { request_id } => request_id.clone(), + }; + if request_id.is_empty() || request_id.len() > 128 { + return None; + } + if !pending.contains_key(&request_id) && pending.len() >= 16 { + return None; + } + let request = pending.entry(request_id.clone()).or_default(); + request.created_at.get_or_insert_with(Instant::now); + match header { + RepositorySshBrokerHeader::Data { args, .. } => { + request.args = Some(args); + request.data = Some(stream); + } + RepositorySshBrokerHeader::Stderr { .. } => request.stderr = Some(stream), + RepositorySshBrokerHeader::Status { .. } => request.status = Some(stream), + } + if request.args.is_some() + && request.data.is_some() + && request.stderr.is_some() + && request.status.is_some() + { + pending + .remove(&request_id) + .map(|request| (request_id, request)) + } else { + None + } +} + +fn run_brokered_read_only_ssh( + mut request: PendingRepositorySshRequest, + agent_socket: &Path, + known_hosts: &Path, +) { + let args = request.args.take().unwrap_or_default(); + let mut data = request.data.take().expect("complete broker request data"); + let mut stderr_stream = request + .stderr + .take() + .expect("complete broker request stderr"); + let mut status_stream = request + .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 _ = stderr_stream.shutdown(Shutdown::Write); + let _ = data.shutdown(Shutdown::Both); + 126 + } else if args.iter().any(|argument| argument == "-G") { + let _ = stderr_stream.shutdown(Shutdown::Write); + let _ = data.shutdown(Shutdown::Both); + 0 + } else { + let mut command = Command::new("ssh"); + command + .args(["-F", "/dev/null"]) + .args(["-o", "BatchMode=yes"]) + .args(["-o", "IdentitiesOnly=no"]) + .args(["-o", "IdentityFile=/dev/null"]) + .args(["-o", "IdentityAgent=SSH_AUTH_SOCK"]) + .args(["-o", "StrictHostKeyChecking=yes"]) + .arg("-o") + .arg(format!( + "UserKnownHostsFile={}", + known_hosts.to_string_lossy() + )) + .args(["-o", "ClearAllForwardings=yes"]) + .args(["-o", "PermitLocalCommand=no"]) + .args(&args) + .env("SSH_AUTH_SOCK", agent_socket) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + match command.spawn() { + Ok(mut child) => { + let mut child_stdin = child.stdin.take().expect("piped ssh stdin"); + let mut input = data.try_clone().expect("clone broker data stream"); + let input_thread = std::thread::spawn(move || { + let _ = std::io::copy(&mut input, &mut child_stdin); + }); + let mut child_stdout = child.stdout.take().expect("piped ssh stdout"); + let output_thread = std::thread::spawn(move || { + let _ = std::io::copy(&mut child_stdout, &mut data); + let _ = data.shutdown(Shutdown::Write); + }); + let mut child_stderr = child.stderr.take().expect("piped ssh stderr"); + let error_thread = std::thread::spawn(move || { + let _ = std::io::copy(&mut child_stderr, &mut stderr_stream); + let _ = stderr_stream.shutdown(Shutdown::Write); + }); + let status = child + .wait() + .ok() + .and_then(|status| status.code()) + .unwrap_or(1); + let _ = input_thread.join(); + let _ = output_thread.join(); + let _ = error_thread.join(); + status + } + Err(_) => 1, + } + }; + let _ = writeln!(status_stream, "{status}"); + let _ = status_stream.shutdown(Shutdown::Write); +} + +fn validate_read_only_ssh_args(args: &[String]) -> bool { + let mut index = 0; + let mut probe = false; + 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()) + { + return false; + } + } + "-l" => { + index += 1; + if index >= args.len() || !is_safe_ssh_destination(&args[index]) { + return false; + } + } + "-o" => { + index += 1; + if index >= args.len() || !is_safe_git_ssh_option(&args[index]) { + return false; + } + } + option if option.starts_with("-o") => { + if !is_safe_git_ssh_option(&option[2..]) { + return false; + } + } + option if option.starts_with('-') => return false, + value => positional.push(value), + } + index += 1; + } + if probe { + positional.len() == 1 && is_safe_ssh_destination(positional[0]) + } else { + positional.len() == 2 + && is_safe_ssh_destination(positional[0]) + && is_safe_read_only_git_command(positional[1]) + } +} + +fn is_safe_git_ssh_option(option: &str) -> bool { + option == "SendEnv=GIT_PROTOCOL" + || option + .strip_prefix("SetEnv=GIT_PROTOCOL=") + .is_some_and(|value| { + !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + }) +} + +fn is_safe_ssh_destination(value: &str) -> bool { + !value.is_empty() + && value.bytes().all(|byte| { + byte.is_ascii_alphanumeric() + || matches!(byte, b'.' | b'-' | b'_' | b'@' | b':' | b'[' | b']') + }) +} + +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| { + byte.is_ascii_alphanumeric() + || matches!( + byte, + b'/' | b'.' | b'-' | b'_' | b'@' | b':' | b'+' | b'~' | b' ' + ) + }) +} + +pub fn run_repository_read_only_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())?; + let request_id = format!( + "{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let mut data = connect_repository_ssh_broker_channel( + socket, + &RepositorySshBrokerHeader::Data { + request_id: request_id.clone(), + args: ssh_args.to_vec(), + }, + )?; + let mut stderr_stream = connect_repository_ssh_broker_channel( + socket, + &RepositorySshBrokerHeader::Stderr { + request_id: request_id.clone(), + }, + )?; + let mut status_stream = connect_repository_ssh_broker_channel( + socket, + &RepositorySshBrokerHeader::Status { request_id }, + )?; + let mut input = data + .try_clone() + .map_err(|_| "read-only Repository SSH broker input failed".to_string())?; + let input_thread = std::thread::spawn(move || { + let _ = std::io::copy(&mut std::io::stdin(), &mut input); + let _ = input.shutdown(Shutdown::Write); + }); + let error_thread = std::thread::spawn(move || { + let _ = std::io::copy(&mut stderr_stream, &mut std::io::stderr()); + }); + std::io::copy(&mut data, &mut std::io::stdout()) + .map_err(|_| "read-only Repository SSH broker output failed".to_string())?; + let _ = input_thread.join(); + let _ = error_thread.join(); + let mut status = String::new(); + status_stream + .read_to_string(&mut status) + .map_err(|_| "read-only Repository SSH broker status failed".to_string())?; + status + .trim() + .parse::() + .map_err(|_| "read-only Repository SSH broker status was invalid".to_string()) +} + +fn connect_repository_ssh_broker_channel( + socket: &str, + header: &RepositorySshBrokerHeader, +) -> Result { + let mut stream = UnixStream::connect(socket) + .map_err(|_| "read-only 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())?; + stream + .write_all(b"\n") + .map_err(|_| "read-only Repository SSH broker request failed".to_string())?; + Ok(stream) +} + #[derive(Debug)] struct RepositoryCommandAccess { root: PathBuf, ssh_command: PathBuf, - agent: RepositorySshAgent, + agent: Arc, + read_only_broker: Option, } impl RepositoryCommandAccess { @@ -1189,27 +1602,49 @@ impl RepositoryCommandAccess { let known_hosts = root.join("known_hosts"); let ssh_command = root.join("ssh-command"); write_owner_only(&known_hosts, ssh.known_hosts_entry.expose().as_bytes())?; - let agent = RepositorySshAgent::start(runtime_root, operation_id, ssh)?; - let read_only_guard = if ssh.access == workspace_api::RepositoryAccessMode::ReadOnly { - "case \" $* \" in\n *\" -G \"*|*\" git-upload-pack \"*|*\" git-upload-archive \"*) ;;\n *) echo 'read-only Repository SSH operation denied' >&2; exit 126 ;;\nesac\n" + 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)?, + ) + } 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\n{read_only_guard}export 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)?, - ); write_owner_only(&ssh_command, script.as_bytes())?; set_file_owner_executable(&ssh_command)?; Ok(Self { root, ssh_command, agent, + read_only_broker, }) } fn stop(&self) { + if let Some(broker) = self.read_only_broker.as_ref() { + broker.stop(); + } self.agent.stop(); let _ = fs::remove_dir_all(&self.root); } @@ -2127,12 +2562,51 @@ mod tests { assert_eq!(rebound_environment["YOI_REPOSITORY_ACCESS"], "read_only"); assert!(!rebound_environment.contains_key("SSH_AUTH_SOCK")); let read_only_ssh = &rebound_environment["GIT_SSH_COMMAND"]; - let denied = Command::new(read_only_ssh) - .args(["example.test", "git-receive-pack 'repo.git'"]) - .env_remove("SSH_AUTH_SOCK") - .status() - .unwrap(); - assert_eq!(denied.code(), Some(126)); + 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("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 broker_socket = fs::read_dir(runtime_root.path().join(REPOSITORY_ACCESS_DIR)) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.path().join("b.sock")) + .find(|path| path.exists()) + .expect("read-only broker socket"); + assert_eq!( + run_repository_read_only_ssh_client(&[ + broker_socket.to_string_lossy().to_string(), + "-G".to_string(), + "example.test".to_string(), + ]) + .unwrap(), + 0 + ); + assert_eq!( + run_repository_read_only_ssh_client(&[ + broker_socket.to_string_lossy().to_string(), + "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_eq!( git_stdout( rebound.root(), @@ -2142,6 +2616,7 @@ mod tests { "yoi-read-only://repository-push-disabled" ); drop(rebound); + assert!(!broker_socket.exists()); let mut read_write = rotated; read_write.operation_id = "operation-agent-read-write".to_string();