fix: constrain repository SSH commands to authorized source

This commit is contained in:
2026-08-26 19:02:20 +09:00
parent 4b132a21e9
commit 4ebc465e8d
4 changed files with 471 additions and 165 deletions
+2 -1
View File
@@ -575,7 +575,8 @@ async fn create_working_directory(
} }
let working_directory = state let working_directory = state
.runtime .runtime
.create_working_directory(request) .create_working_directory_from_resource(request)
.await
.map_err(RuntimeHttpRestError::runtime)?; .map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkingDirectoryResponse { Ok(Json(RuntimeHttpWorkingDirectoryResponse {
working_directory, working_directory,
+2 -4
View File
@@ -28,11 +28,9 @@ use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode { fn main() -> ExitCode {
let mut arguments = std::env::args().skip(1).collect::<Vec<_>>(); let mut arguments = std::env::args().skip(1).collect::<Vec<_>>();
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); arguments.remove(0);
return match worker_runtime::working_directory::run_repository_read_only_ssh_client( return match worker_runtime::working_directory::run_repository_ssh_client(&arguments) {
&arguments,
) {
Ok(status) => ExitCode::from(u8::try_from(status).unwrap_or(1)), Ok(status) => ExitCode::from(u8::try_from(status).unwrap_or(1)),
Err(error) => { Err(error) => {
eprintln!("{error}"); eprintln!("{error}");
+137 -6
View File
@@ -378,6 +378,20 @@ impl Runtime {
.map_err(RuntimeError::from) .map_err(RuntimeError::from)
} }
pub async fn create_working_directory_from_resource(
&self,
mut request: WorkingDirectoryRequest,
) -> Result<CatalogWorkingDirectoryStatus, RuntimeError> {
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( pub fn authorize_working_directory_repository_access(
&self, &self,
request: WorkingDirectoryRepositoryAccessRequest, request: WorkingDirectoryRepositoryAccessRequest,
@@ -397,14 +411,13 @@ impl Runtime {
.map_err(RuntimeError::from) .map_err(RuntimeError::from)
} }
pub async fn authorize_working_directory_repository_access_from_resource( async fn resolve_repository_access_resource(
&self, &self,
mut request: WorkingDirectoryRepositoryAccessRequest, ssh: &mut crate::catalog::RepositorySshMaterializationAccess,
) -> Result<(), RuntimeError> { ) -> Result<(), RuntimeError> {
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| { if !ssh.private_key.expose().is_empty() && !ssh.known_hosts_entry.expose().is_empty() {
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string()) return Ok(());
})?; }
if ssh.private_key.expose().is_empty() || ssh.known_hosts_entry.expose().is_empty() {
let (client, runtime_id) = { let (client, runtime_id) = {
let state = self.lock()?; let state = self.lock()?;
let client = state.backend_resource_client.clone().ok_or_else(|| { let client = state.backend_resource_client.clone().ok_or_else(|| {
@@ -448,7 +461,17 @@ impl Runtime {
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key)); crate::catalog::SensitiveString::new(std::mem::take(&mut secret.private_key));
ssh.known_hosts_entry = ssh.known_hosts_entry =
crate::catalog::SensitiveString::new(std::mem::take(&mut secret.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,
) -> Result<(), RuntimeError> {
let ssh = request.materialization.ssh.as_mut().ok_or_else(|| {
RuntimeError::InvalidRequest("Repository SSH access metadata is missing".to_string())
})?;
self.resolve_repository_access_resource(ssh).await?;
self.authorize_working_directory_repository_access(request) self.authorize_working_directory_repository_access(request)
} }
@@ -3335,6 +3358,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn repository_access_resource_is_fetched_before_provider_authorization() { async fn repository_access_resource_is_fetched_before_provider_authorization() {
let (runtime, backend) = runtime_and_backend(); let (runtime, backend) = runtime_and_backend();
backend
.repository_access_available
.store(true, Ordering::SeqCst);
runtime.bind_runtime_identity("runtime-1").unwrap(); runtime.bind_runtime_identity("runtime-1").unwrap();
let handle = repository_resource_handle(); let handle = repository_resource_handle();
runtime runtime
@@ -3398,6 +3424,88 @@ mod tests {
assert_eq!(access.known_hosts_entry.expose(), "known-hosts-entry"); 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 { fn scoped_task_request(objective: &str, workspace_id: &str) -> CreateWorkerRequest {
let mut request = task_request(objective); let mut request = task_request(objective);
request.workspace_api = Some(WorkspaceApiRef { request.workspace_api = Some(WorkspaceApiRef {
@@ -3480,6 +3588,8 @@ mod tests {
contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>, contexts: Mutex<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>, dispatched_inputs: Mutex<Vec<WorkerInput>>,
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>, repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
repository_access_available: AtomicBool,
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
preserve_commit_ack_submission_id: AtomicBool, preserve_commit_ack_submission_id: AtomicBool,
#[cfg(feature = "ws-server")] #[cfg(feature = "ws-server")]
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>, snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
@@ -3520,6 +3630,20 @@ mod tests {
"test-execution-backend" "test-execution-backend"
} }
fn create_working_directory(
&self,
request: &WorkingDirectoryRequest,
) -> Result<CatalogWorkingDirectoryStatus, WorkingDirectoryDiagnostic> {
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( fn authorize_working_directory_repository_access(
&self, &self,
request: &WorkingDirectoryRepositoryAccessRequest, request: &WorkingDirectoryRepositoryAccessRequest,
@@ -3528,7 +3652,14 @@ mod tests {
.lock() .lock()
.unwrap() .unwrap()
.push(request.clone()); .push(request.clone());
if self.repository_access_available.load(Ordering::SeqCst) {
Ok(()) 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 { fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult {
+274 -98
View File
@@ -488,12 +488,9 @@ impl RuntimeGitCacheMaterializer {
access.stop(); access.stop();
} }
}); });
if access.access == workspace_api::RepositoryAccessMode::ReadWrite { binding
binding.command_environment.insert( .command_environment
"SSH_AUTH_SOCK".to_string(), .insert("SSH_AUTH_SOCK".to_string(), "/dev/null".to_string());
command_access.agent.socket.to_string_lossy().to_string(),
);
}
binding.command_environment.insert( binding.command_environment.insert(
"GIT_SSH_COMMAND".to_string(), "GIT_SSH_COMMAND".to_string(),
command_access.ssh_command.to_string_lossy().to_string(), command_access.ssh_command.to_string_lossy().to_string(),
@@ -703,12 +700,21 @@ impl RuntimeGitCacheMaterializer {
let mut request = request.clone(); let mut request = request.clone();
if request.repository.provider != "git" if request.repository.provider != "git"
|| request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh || request.repository.source.kind != workspace_api::RepositorySourceKind::Ssh
|| request {
return Ok(request);
}
if let Some(access) = request
.materialization .materialization
.as_ref() .as_ref()
.and_then(|materialization| materialization.ssh.as_ref()) .and_then(|materialization| materialization.ssh.as_ref())
.is_some()
{ {
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); return Ok(request);
} }
let access = self let access = self
@@ -1266,29 +1272,30 @@ impl std::fmt::Debug for PendingRepositorySshRequest {
} }
#[derive(Debug)] #[derive(Debug)]
struct RepositoryReadOnlySshBroker { struct RepositorySshBroker {
socket: PathBuf, socket: PathBuf,
stopped: Arc<AtomicBool>, stopped: Arc<AtomicBool>,
thread: Mutex<Option<JoinHandle<()>>>, thread: Mutex<Option<JoinHandle<()>>>,
} }
impl RepositoryReadOnlySshBroker { impl RepositorySshBroker {
fn start( fn start(
root: &Path, root: &Path,
agent: Arc<RepositorySshAgent>, agent: Arc<RepositorySshAgent>,
known_hosts: PathBuf, known_hosts: PathBuf,
policy: RepositorySshCommandPolicy,
) -> Result<Self, WorkingDirectoryDiagnostic> { ) -> Result<Self, WorkingDirectoryDiagnostic> {
let socket = root.join("b.sock"); let socket = root.join("b.sock");
let listener = UnixListener::bind(&socket).map_err(|_| { let listener = UnixListener::bind(&socket).map_err(|_| {
WorkingDirectoryDiagnostic::new( WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_setup_failed", "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(|_| { listener.set_nonblocking(true).map_err(|_| {
WorkingDirectoryDiagnostic::new( WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_setup_failed", "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)); let stopped = Arc::new(AtomicBool::new(false));
@@ -1308,11 +1315,13 @@ impl RepositoryReadOnlySshBroker {
{ {
let request_agent = Arc::clone(&agent); let request_agent = Arc::clone(&agent);
let request_known_hosts = known_hosts.clone(); let request_known_hosts = known_hosts.clone();
let request_policy = policy.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
run_brokered_read_only_ssh( run_brokered_repository_ssh(
request, request,
&request_agent.socket, &request_agent.socket,
&request_known_hosts, &request_known_hosts,
&request_policy,
); );
}); });
pending.remove(&request_id); pending.remove(&request_id);
@@ -1346,7 +1355,7 @@ impl RepositoryReadOnlySshBroker {
} }
} }
impl Drop for RepositoryReadOnlySshBroker { impl Drop for RepositorySshBroker {
fn drop(&mut self) { fn drop(&mut self) {
self.stop(); 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, mut request: PendingRepositorySshRequest,
agent_socket: &Path, agent_socket: &Path,
known_hosts: &Path, known_hosts: &Path,
policy: &RepositorySshCommandPolicy,
) { ) {
let args = request.args.take().unwrap_or_default(); let args = request.args.take().unwrap_or_default();
let mut data = request.data.take().expect("complete broker request data"); let mut data = request.data.take().expect("complete broker request data");
@@ -1416,8 +1426,8 @@ fn run_brokered_read_only_ssh(
.status .status
.take() .take()
.expect("complete broker request status"); .expect("complete broker request status");
let status = if !validate_read_only_ssh_args(&args) { let status = if !validate_repository_ssh_args(&args, policy) {
let _ = stderr_stream.write_all(b"read-only Repository SSH operation denied\n"); let _ = stderr_stream.write_all(b"Repository SSH operation denied\n");
let _ = stderr_stream.shutdown(Shutdown::Write); let _ = stderr_stream.shutdown(Shutdown::Write);
let _ = data.shutdown(Shutdown::Both); let _ = data.shutdown(Shutdown::Both);
126 126
@@ -1480,28 +1490,114 @@ fn run_brokered_read_only_ssh(
let _ = status_stream.shutdown(Shutdown::Write); let _ = status_stream.shutdown(Shutdown::Write);
} }
fn validate_read_only_ssh_args(args: &[String]) -> bool { #[derive(Clone, Debug)]
struct RepositorySshCommandPolicy {
host: String,
username: Option<String>,
port: Option<u16>,
repository_path: String,
access: workspace_api::RepositoryAccessMode,
}
impl RepositorySshCommandPolicy {
fn from_access(
access: &RepositorySshMaterializationAccess,
) -> Result<Self, WorkingDirectoryDiagnostic> {
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 index = 0;
let mut probe = false; let mut probe = false;
let mut port = None;
let mut login = None;
let mut positional = Vec::new(); let mut positional = Vec::new();
while index < args.len() { while index < args.len() {
match args[index].as_str() { match args[index].as_str() {
"-G" => probe = true, "-G" => probe = true,
"-4" | "-6" | "-v" | "-vv" | "-vvv" => {} "-4" | "-6" | "-v" | "-vv" | "-vvv" => {}
"-p" => { "-p" => {
index += 1; if port.is_some() {
if index >= args.len()
|| args[index].is_empty()
|| !args[index].bytes().all(|byte| byte.is_ascii_digit())
{
return false; return false;
} }
index += 1;
let Some(value) = args.get(index).and_then(|value| value.parse::<u16>().ok())
else {
return false;
};
port = Some(value);
} }
"-l" => { "-l" => {
index += 1; if login.is_some() {
if index >= args.len() || !is_safe_ssh_destination(&args[index]) {
return false; 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" => { "-o" => {
index += 1; index += 1;
@@ -1519,12 +1615,16 @@ fn validate_read_only_ssh_args(args: &[String]) -> bool {
} }
index += 1; index += 1;
} }
if port != policy.port
|| positional.is_empty()
|| !policy.destination_matches(positional[0], login)
{
return false;
}
if probe { if probe {
positional.len() == 1 && is_safe_ssh_destination(positional[0]) positional.len() == 1
} else { } else {
positional.len() == 2 positional.len() == 2 && repository_command_matches(positional[1], policy)
&& is_safe_ssh_destination(positional[0])
&& is_safe_read_only_git_command(positional[1])
} }
} }
@@ -1548,31 +1648,43 @@ fn is_safe_ssh_destination(value: &str) -> bool {
}) })
} }
fn is_safe_read_only_git_command(command: &str) -> bool { fn is_safe_repository_path(path: &str) -> bool {
let Some(argument) = command !path.is_empty()
.strip_prefix("git-upload-pack ") && path.bytes().all(|byte| {
.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() byte.is_ascii_alphanumeric()
|| matches!( || matches!(
byte, 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<i32, String> { 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<i32, String> {
let (socket, ssh_args) = arguments let (socket, ssh_args) = arguments
.split_first() .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!( let request_id = format!(
"{}-{}", "{}-{}",
std::process::id(), std::process::id(),
@@ -1600,7 +1712,7 @@ pub fn run_repository_read_only_ssh_client(arguments: &[String]) -> Result<i32,
)?; )?;
let mut input = data let mut input = data
.try_clone() .try_clone()
.map_err(|_| "read-only Repository SSH broker input failed".to_string())?; .map_err(|_| "Repository SSH broker input failed".to_string())?;
let input_thread = std::thread::spawn(move || { let input_thread = std::thread::spawn(move || {
let _ = std::io::copy(&mut std::io::stdin(), &mut input); let _ = std::io::copy(&mut std::io::stdin(), &mut input);
let _ = input.shutdown(Shutdown::Write); let _ = input.shutdown(Shutdown::Write);
@@ -1609,17 +1721,17 @@ pub fn run_repository_read_only_ssh_client(arguments: &[String]) -> Result<i32,
let _ = std::io::copy(&mut stderr_stream, &mut std::io::stderr()); let _ = std::io::copy(&mut stderr_stream, &mut std::io::stderr());
}); });
std::io::copy(&mut data, &mut std::io::stdout()) std::io::copy(&mut data, &mut std::io::stdout())
.map_err(|_| "read-only Repository SSH broker output failed".to_string())?; .map_err(|_| "Repository SSH broker output failed".to_string())?;
let _ = input_thread.join(); let _ = input_thread.join();
let _ = error_thread.join(); let _ = error_thread.join();
let mut status = String::new(); let mut status = String::new();
status_stream status_stream
.read_to_string(&mut status) .read_to_string(&mut status)
.map_err(|_| "read-only Repository SSH broker status failed".to_string())?; .map_err(|_| "Repository SSH broker status failed".to_string())?;
status status
.trim() .trim()
.parse::<i32>() .parse::<i32>()
.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( fn connect_repository_ssh_broker_channel(
@@ -1627,12 +1739,12 @@ fn connect_repository_ssh_broker_channel(
header: &RepositorySshBrokerHeader, header: &RepositorySshBrokerHeader,
) -> Result<UnixStream, String> { ) -> Result<UnixStream, String> {
let mut stream = UnixStream::connect(socket) 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) 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 stream
.write_all(b"\n") .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) Ok(stream)
} }
@@ -1641,7 +1753,7 @@ struct RepositoryCommandAccess {
root: PathBuf, root: PathBuf,
ssh_command: PathBuf, ssh_command: PathBuf,
agent: Arc<RepositorySshAgent>, agent: Arc<RepositorySshAgent>,
read_only_broker: Option<RepositoryReadOnlySshBroker>, ssh_broker: Option<RepositorySshBroker>,
} }
impl RepositoryCommandAccess { impl RepositoryCommandAccess {
@@ -1694,46 +1806,37 @@ impl RepositoryCommandAccess {
let ssh_command = root.join("ssh-command"); let ssh_command = root.join("ssh-command");
write_owner_only(&known_hosts, ssh.known_hosts_entry.expose().as_bytes())?; write_owner_only(&known_hosts, ssh.known_hosts_entry.expose().as_bytes())?;
let agent = Arc::new(RepositorySshAgent::start(runtime_root, operation_id, ssh)?); let agent = Arc::new(RepositorySshAgent::start(runtime_root, operation_id, ssh)?);
let read_only_broker = if ssh.access == workspace_api::RepositoryAccessMode::ReadOnly { let policy = RepositorySshCommandPolicy::from_access(ssh)?;
Some(RepositoryReadOnlySshBroker::start( let ssh_broker = Some(RepositorySshBroker::start(
&root, &root,
Arc::clone(&agent), Arc::clone(&agent),
known_hosts.clone(), known_hosts.clone(),
)?) policy,
} else { )?);
None let broker = ssh_broker.as_ref().expect("Repository SSH broker");
};
let script = if let Some(broker) = read_only_broker.as_ref() {
let executable = std::env::current_exe().map_err(|_| { let executable = std::env::current_exe().map_err(|_| {
WorkingDirectoryDiagnostic::new( WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_setup_failed", "working_directory_repository_access_setup_failed",
"read-only Repository SSH broker client could not be resolved", "Repository SSH broker client could not be resolved",
) )
})?; })?;
format!( let script = format!(
"#!/bin/sh\nexec {} __repository-read-only-ssh {} \"$@\"\n", "#!/bin/sh\nexec {} __repository-ssh {} \"$@\"\n",
shell_quote_path(&executable)?, shell_quote_path(&executable)?,
shell_quote_path(&broker.socket)?, 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)?,
)
};
write_owner_only(&ssh_command, script.as_bytes())?; write_owner_only(&ssh_command, script.as_bytes())?;
set_file_owner_executable(&ssh_command)?; set_file_owner_executable(&ssh_command)?;
Ok(Self { Ok(Self {
root, root,
ssh_command, ssh_command,
agent, agent,
read_only_broker, ssh_broker,
}) })
} }
fn stop(&self) { fn stop(&self) {
if let Some(broker) = self.read_only_broker.as_ref() { if let Some(broker) = self.ssh_broker.as_ref() {
broker.stop(); broker.stop();
} }
self.agent.stop(); self.agent.stop();
@@ -2727,17 +2830,27 @@ mod tests {
); );
let binding = materializer.bind_working_directory(&id, None).unwrap(); let binding = materializer.bind_working_directory(&id, None).unwrap();
let environment = binding.command_environment(); 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()); let ssh_command = PathBuf::from(environment["GIT_SSH_COMMAND"].clone());
assert!(socket.exists());
assert!(ssh_command.exists()); assert!(ssh_command.exists());
let ssh_policy = fs::read_to_string(&ssh_command).unwrap(); let ssh_policy = fs::read_to_string(&ssh_command).unwrap();
assert!(ssh_policy.contains("StrictHostKeyChecking=yes")); assert!(ssh_policy.contains("__repository-ssh"));
assert!(ssh_policy.contains("UserKnownHostsFile=")); 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"); assert_eq!(environment["YOI_REPOSITORY_ACCESS"], "read_write");
drop(binding); drop(binding);
assert!(!socket.exists());
assert!(!ssh_command.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!( assert_eq!(
materializer materializer
.bind_working_directory(&id, None) .bind_working_directory(&id, None)
@@ -2762,17 +2875,22 @@ mod tests {
); );
let rebound_environment = rebound.command_environment(); let rebound_environment = rebound.command_environment();
assert_eq!(rebound_environment["YOI_REPOSITORY_ACCESS"], "read_only"); 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_ssh = &rebound_environment["GIT_SSH_COMMAND"];
let read_only_policy = fs::read_to_string(read_only_ssh).unwrap(); 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("SSH_AUTH_SOCK"));
assert!(!read_only_policy.contains(".repository-agents")); assert!(!read_only_policy.contains(".repository-agents"));
assert!(!read_only_policy.contains("known_hosts")); assert!(!read_only_policy.contains("known_hosts"));
assert!(!validate_read_only_ssh_args(&[ let read_only_command_policy =
"example.test".to_string(), RepositorySshCommandPolicy::from_access(rotated.ssh.as_ref().unwrap()).unwrap();
"git-receive-pack 'repo.git'".to_string(), 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)) let broker_socket = fs::read_dir(runtime_root.path().join(REPOSITORY_ACCESS_DIR))
.unwrap() .unwrap()
.filter_map(Result::ok) .filter_map(Result::ok)
@@ -2780,35 +2898,77 @@ mod tests {
.find(|path| path.exists()) .find(|path| path.exists())
.expect("read-only broker socket"); .expect("read-only broker socket");
assert_eq!( assert_eq!(
run_repository_read_only_ssh_client(&[ run_repository_ssh_client(&[
broker_socket.to_string_lossy().to_string(), broker_socket.to_string_lossy().to_string(),
"-G".to_string(), "-G".to_string(),
"example.test".to_string(), "git@example.test".to_string(),
]) ])
.unwrap(), .unwrap(),
0 0
); );
assert_eq!( assert_eq!(
run_repository_read_only_ssh_client(&[ run_repository_ssh_client(&[
broker_socket.to_string_lossy().to_string(), broker_socket.to_string_lossy().to_string(),
"example.test".to_string(), "git@example.test".to_string(),
"git-receive-pack 'repo.git'".to_string(), "git-receive-pack '/repo.git'".to_string(),
]) ])
.unwrap(), .unwrap(),
126 126
); );
assert!(!validate_read_only_ssh_args(&[ assert!(!validate_repository_ssh_args(
&[
"-o".to_string(), "-o".to_string(),
"ProxyCommand=sh -c exploit".to_string(), "ProxyCommand=sh -c exploit".to_string(),
"example.test".to_string(), "git@example.test".to_string(),
"git-upload-pack 'repo.git'".to_string(), "git-upload-pack '/repo.git'".to_string(),
])); ],
assert!(validate_read_only_ssh_args(&[ &read_only_command_policy,
));
assert!(validate_repository_ssh_args(
&[
"-o".to_string(), "-o".to_string(),
"SendEnv=GIT_PROTOCOL".to_string(), "SendEnv=GIT_PROTOCOL".to_string(),
"example.test".to_string(), "git@example.test".to_string(),
"git-upload-pack 'repo.git'".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!( assert_eq!(
git_stdout( git_stdout(
rebound.root(), rebound.root(),
@@ -2824,6 +2984,8 @@ mod tests {
read_write.operation_id = "operation-agent-read-write".to_string(); 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().credential_revision = 3;
read_write.ssh.as_mut().unwrap().access = workspace_api::RepositoryAccessMode::ReadWrite; 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 materializer
.authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest { .authorize_repository_access(&WorkingDirectoryRepositoryAccessRequest {
working_directory_id: id.clone(), working_directory_id: id.clone(),
@@ -2835,7 +2997,21 @@ mod tests {
rebound.command_environment()["YOI_REPOSITORY_ACCESS"], rebound.command_environment()["YOI_REPOSITORY_ACCESS"],
"read_write" "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!( assert!(
git_stdout( git_stdout(
rebound.root(), rebound.root(),