fix: broker read-only repository SSH operations

This commit is contained in:
2026-08-26 16:48:37 +09:00
parent 108d5b14d7
commit df34533765
2 changed files with 506 additions and 18 deletions
+13
View File
@@ -27,6 +27,19 @@ use worker_runtime::working_directory::RuntimeGitCacheMaterializer;
use worker_runtime::{Runtime, RuntimeOptions};
fn main() -> ExitCode {
let mut arguments = std::env::args().skip(1).collect::<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() {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
+493 -18
View File
@@ -8,11 +8,16 @@ use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::io::Write;
use std::io::{BufRead, BufReader, Read, Write};
#[cfg(unix)]
use std::net::Shutdown;
#[cfg(unix)]
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Component, Path, PathBuf};
use std::process::{Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::thread::JoinHandle;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use workdir::WorkdirSessionResource;
@@ -1133,11 +1138,419 @@ impl Drop for RepositorySshAgent {
}
}
#[derive(Debug, serde::Serialize, serde::Deserialize)]
#[serde(tag = "channel", rename_all = "snake_case")]
enum RepositorySshBrokerHeader {
Data {
request_id: String,
args: Vec<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)]
struct RepositoryCommandAccess {
root: PathBuf,
ssh_command: PathBuf,
agent: RepositorySshAgent,
agent: Arc<RepositorySshAgent>,
read_only_broker: Option<RepositoryReadOnlySshBroker>,
}
impl RepositoryCommandAccess {
@@ -1189,27 +1602,49 @@ impl RepositoryCommandAccess {
let known_hosts = root.join("known_hosts");
let ssh_command = root.join("ssh-command");
write_owner_only(&known_hosts, ssh.known_hosts_entry.expose().as_bytes())?;
let agent = RepositorySshAgent::start(runtime_root, operation_id, ssh)?;
let read_only_guard = if ssh.access == workspace_api::RepositoryAccessMode::ReadOnly {
"case \" $* \" in\n *\" -G \"*|*\" git-upload-pack \"*|*\" git-upload-archive \"*) ;;\n *) echo 'read-only Repository SSH operation denied' >&2; exit 126 ;;\nesac\n"
let agent = Arc::new(RepositorySshAgent::start(runtime_root, operation_id, ssh)?);
let read_only_broker = if ssh.access == workspace_api::RepositoryAccessMode::ReadOnly {
Some(RepositoryReadOnlySshBroker::start(
&root,
Arc::clone(&agent),
known_hosts.clone(),
)?)
} else {
""
None
};
let script = if let Some(broker) = read_only_broker.as_ref() {
let executable = std::env::current_exe().map_err(|_| {
WorkingDirectoryDiagnostic::new(
"working_directory_repository_access_setup_failed",
"read-only Repository SSH broker client could not be resolved",
)
})?;
format!(
"#!/bin/sh\nexec {} __repository-read-only-ssh {} \"$@\"\n",
shell_quote_path(&executable)?,
shell_quote_path(&broker.socket)?,
)
} else {
format!(
"#!/bin/sh\nexport SSH_AUTH_SOCK={}\nexec ssh -F /dev/null -o BatchMode=yes -o IdentitiesOnly=no -o IdentityFile=/dev/null -o StrictHostKeyChecking=yes -o UserKnownHostsFile={} \"$@\"\n",
shell_quote_path(&agent.socket)?,
shell_quote_path(&known_hosts)?,
)
};
let script = format!(
"#!/bin/sh\n{read_only_guard}export SSH_AUTH_SOCK={}\nexec ssh -F /dev/null -o BatchMode=yes -o IdentitiesOnly=no -o IdentityFile=/dev/null -o StrictHostKeyChecking=yes -o UserKnownHostsFile={} \"$@\"\n",
shell_quote_path(&agent.socket)?,
shell_quote_path(&known_hosts)?,
);
write_owner_only(&ssh_command, script.as_bytes())?;
set_file_owner_executable(&ssh_command)?;
Ok(Self {
root,
ssh_command,
agent,
read_only_broker,
})
}
fn stop(&self) {
if let Some(broker) = self.read_only_broker.as_ref() {
broker.stop();
}
self.agent.stop();
let _ = fs::remove_dir_all(&self.root);
}
@@ -2127,12 +2562,51 @@ mod tests {
assert_eq!(rebound_environment["YOI_REPOSITORY_ACCESS"], "read_only");
assert!(!rebound_environment.contains_key("SSH_AUTH_SOCK"));
let read_only_ssh = &rebound_environment["GIT_SSH_COMMAND"];
let denied = Command::new(read_only_ssh)
.args(["example.test", "git-receive-pack 'repo.git'"])
.env_remove("SSH_AUTH_SOCK")
.status()
.unwrap();
assert_eq!(denied.code(), Some(126));
let read_only_policy = fs::read_to_string(read_only_ssh).unwrap();
assert!(read_only_policy.contains("__repository-read-only-ssh"));
assert!(!read_only_policy.contains("SSH_AUTH_SOCK"));
assert!(!read_only_policy.contains(".repository-agents"));
assert!(!read_only_policy.contains("known_hosts"));
assert!(!validate_read_only_ssh_args(&[
"example.test".to_string(),
"git-receive-pack 'repo.git'".to_string(),
]));
let broker_socket = fs::read_dir(runtime_root.path().join(REPOSITORY_ACCESS_DIR))
.unwrap()
.filter_map(Result::ok)
.map(|entry| entry.path().join("b.sock"))
.find(|path| path.exists())
.expect("read-only broker socket");
assert_eq!(
run_repository_read_only_ssh_client(&[
broker_socket.to_string_lossy().to_string(),
"-G".to_string(),
"example.test".to_string(),
])
.unwrap(),
0
);
assert_eq!(
run_repository_read_only_ssh_client(&[
broker_socket.to_string_lossy().to_string(),
"example.test".to_string(),
"git-receive-pack 'repo.git'".to_string(),
])
.unwrap(),
126
);
assert!(!validate_read_only_ssh_args(&[
"-o".to_string(),
"ProxyCommand=sh -c exploit".to_string(),
"example.test".to_string(),
"git-upload-pack 'repo.git'".to_string(),
]));
assert!(validate_read_only_ssh_args(&[
"-o".to_string(),
"SendEnv=GIT_PROTOCOL".to_string(),
"example.test".to_string(),
"git-upload-pack 'repo.git'".to_string(),
]));
assert_eq!(
git_stdout(
rebound.root(),
@@ -2142,6 +2616,7 @@ mod tests {
"yoi-read-only://repository-push-disabled"
);
drop(rebound);
assert!(!broker_socket.exists());
let mut read_write = rotated;
read_write.operation_id = "operation-agent-read-write".to_string();