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
.runtime
.create_working_directory(request)
.create_working_directory_from_resource(request)
.await
.map_err(RuntimeHttpRestError::runtime)?;
Ok(Json(RuntimeHttpWorkingDirectoryResponse {
working_directory,
+2 -4
View File
@@ -28,11 +28,9 @@ use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode {
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);
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}");
+177 -46
View File
@@ -378,6 +378,20 @@ impl Runtime {
.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(
&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::<RepositorySshAccessSecret>(&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::<RepositorySshAccessSecret>(&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<BTreeMap<WorkerId, WorkerExecutionContext>>,
dispatched_inputs: Mutex<Vec<WorkerInput>>,
repository_accesses: Mutex<Vec<WorkingDirectoryRepositoryAccessRequest>>,
repository_access_available: AtomicBool,
working_directory_requests: Mutex<Vec<WorkingDirectoryRequest>>,
preserve_commit_ack_submission_id: AtomicBool,
#[cfg(feature = "ws-server")]
snapshots: Mutex<BTreeMap<WorkerId, protocol::Event>>,
@@ -3520,6 +3630,20 @@ mod tests {
"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(
&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 {
+290 -114
View File
@@ -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<AtomicBool>,
thread: Mutex<Option<JoinHandle<()>>>,
}
impl RepositoryReadOnlySshBroker {
impl RepositorySshBroker {
fn start(
root: &Path,
agent: Arc<RepositorySshAgent>,
known_hosts: PathBuf,
policy: RepositorySshCommandPolicy,
) -> Result<Self, WorkingDirectoryDiagnostic> {
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<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 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::<u16>().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<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
.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<i32,
)?;
let mut input = data
.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 _ = std::io::copy(&mut std::io::stdin(), &mut input);
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());
});
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 _ = 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())?;
.map_err(|_| "Repository SSH broker status failed".to_string())?;
status
.trim()
.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(
@@ -1627,12 +1739,12 @@ fn connect_repository_ssh_broker_channel(
header: &RepositorySshBrokerHeader,
) -> Result<UnixStream, String> {
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<RepositorySshAgent>,
read_only_broker: Option<RepositoryReadOnlySshBroker>,
ssh_broker: Option<RepositorySshBroker>,
}
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(),