Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 81 additions & 33 deletions src/tls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,51 +262,99 @@ impl CertificateManager {

/// Generate environment variables for common tools to use the CA certificate
pub fn get_ca_env_vars() -> Result<Vec<(String, String)>> {
// Try multiple possible locations for the CA certificate
// This handles cases where the effective user changes (e.g., sudo in CI)
let mut ca_path = Self::get_ca_cert_path()?;

if !ca_path.exists() {
// If not found in current user's config, check common locations
let possible_paths = [
// Check SUDO_USER's config directory
std::env::var("SUDO_USER").ok().and_then(|sudo_user| {
dirs::home_dir().map(|home| {
home.parent()
.unwrap_or(&home)
.join(sudo_user)
.join(".config/httpjail/ca-cert.pem")
})
}),
// Check /home/runner for CI
Some(PathBuf::from("/home/runner/.config/httpjail/ca-cert.pem")),
// Check root's config
Some(PathBuf::from("/root/.config/httpjail/ca-cert.pem")),
];
// Resolve the most appropriate CA certificate path for the target process.
// Prefer the non-root SUDO_USER's config if available, since we may drop privileges
// before executing the jailed command.
let current_path = Self::get_ca_cert_path()?; // Typically the path under the current user (often root under sudo)

// Build candidate paths in priority order
let mut candidates: Vec<PathBuf> = Vec::new();

// If running under sudo, prefer the invoking user's config directory
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
#[cfg(target_os = "linux")]
{
candidates.push(PathBuf::from(format!(
"/home/{}/.config/httpjail/ca-cert.pem",
sudo_user
)));
}
#[cfg(target_os = "macos")]
{
candidates.push(PathBuf::from(format!(
"/Users/{}/Library/Application Support/httpjail/ca-cert.pem",
sudo_user
)));
}
}

for path in possible_paths.iter().flatten() {
if path.exists() {
ca_path = Utf8PathBuf::try_from(path.clone())
.context("CA cert path is not valid UTF-8")?;
debug!("Found CA certificate at alternate location: {}", ca_path);
break;
// Always consider the path for the current effective user (may be /root/.config/...)
candidates.push(PathBuf::from(current_path.as_str()));

// Common CI/runner locations
candidates.push(PathBuf::from("/home/runner/.config/httpjail/ca-cert.pem"));
candidates.push(PathBuf::from("/root/.config/httpjail/ca-cert.pem"));

// Pick the first existing path
let mut chosen = candidates
.iter()
.find(|p| p.exists())
.cloned()
.unwrap_or_else(|| PathBuf::from(current_path.as_str()));

// If we're under sudo and the chosen path is under /root, try to materialize a readable
// copy for the invoking user so the jailed (non-root) process can read it.
if let Ok(sudo_user) = std::env::var("SUDO_USER") {
#[cfg(target_os = "linux")]
let user_target =
PathBuf::from(format!("/home/{}/.config/httpjail/ca-cert.pem", sudo_user));
#[cfg(target_os = "macos")]
let user_target = PathBuf::from(format!(
"/Users/{}/Library/Application Support/httpjail/ca-cert.pem",
sudo_user
));

let chosen_str = chosen.to_string_lossy();
if chosen_str.starts_with("/root/") {
if !user_target.exists() {
if let Some(parent) = user_target.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(bytes) = fs::read(&chosen) {
// Best-effort write; world-readable cert (default umask 022) is fine
if fs::write(&user_target, bytes).is_ok() {
debug!(
"Copied CA certificate to invoking user's config: {}",
user_target.to_string_lossy()
);
chosen = user_target;
}
}
} else {
// Prefer the user's existing CA cert if present
chosen = user_target;
}
}
}

if !ca_path.exists() {
anyhow::bail!(
"CA certificate not found. Searched: {:?} and common locations",
ca_path
);
}
// Ensure we have a valid, existing file
if !chosen.exists() {
anyhow::bail!(
"CA certificate not found. Looked for {} and common locations",
chosen.to_string_lossy()
);
}

let ca_path = Utf8PathBuf::try_from(chosen).context("CA cert path is not valid UTF-8")?;

let ca_path_str = ca_path.to_string();
let ca_dir = ca_path
.parent()
.map(|p| p.to_string())
.unwrap_or_else(|| ".".to_string());

debug!("Using CA certificate at {}", ca_path_str);

let env_vars = vec![
// OpenSSL/LibreSSL-based tools (generic)
("SSL_CERT_FILE".to_string(), ca_path_str.clone()),
Expand Down
47 changes: 47 additions & 0 deletions tests/weak_gh_integration.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
mod common;

#[cfg(not(target_os = "macos"))]
use common::HttpjailCommand;
#[cfg(not(target_os = "macos"))]
use std::process::Command;

// macOS' Go toolchain uses the platform verifier which ignores SSL_CERT_FILE, so
// tls interception in weak mode will fail for Go clients unless we tunnel. Until
// behavior is adjusted, run this on non-macOS only.
#[cfg(not(target_os = "macos"))]
#[test]
fn test_weak_mode_gh_api_zen() {
// Skip if gh is not available in the environment
if Command::new("gh").arg("--version").output().is_err() {
eprintln!("Skipping test: gh CLI not installed");
return;
}

// Allow GitHub API hosts. Use a very permissive allowlist for this test to
// avoid flakes if gh makes auxiliary calls.
let allow_js = "['api.github.com','github.com','uploads.github.com','raw.githubusercontent.com'].includes(r.host)";

let result = HttpjailCommand::new()
.weak()
.js(allow_js)
.verbose(1)
.command(vec!["gh", "api", "-X", "GET", "/zen"])
.execute();

match result {
Ok((exit_code, stdout, stderr)) => {
println!("exit={}\nstderr={}\n", exit_code, stderr);
assert_eq!(exit_code, 0, "gh api exited non-zero: {}", stderr);
assert!(
!stderr.contains("x509:") && !stderr.to_lowercase().contains("certificate signed by unknown authority"),
"TLS verification failed under httpjail: {}",
stderr
);
assert!(
!stdout.trim().is_empty(),
"Expected non-empty /zen response, got empty stdout"
);
}
Err(e) => panic!("Failed to execute httpjail with gh: {}", e),
}
}
Loading