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