fix: constrain repository SSH commands to authorized source
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user