Skip to content
Merged
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
11 changes: 7 additions & 4 deletions cmd/soroban-cli/src/commands/doctor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ use crate::{
config::{
self, data,
locator::{self, KeyType},
network::{Network, DEFAULTS as DEFAULT_NETWORKS},
network::{redact_rpc_url, Network, DEFAULTS as DEFAULT_NETWORKS},
},
print::Print,
rpc,
Expand Down Expand Up @@ -98,7 +98,10 @@ async fn print_network(
"Network"
};

print.globeln(format!("{prefix} {name:?} ({})", network.rpc_url));
print.globeln(format!(
"{prefix} {name:?} ({})",
redact_rpc_url(&network.rpc_url)
));
print.blankln(format!("protocol {}", version_info.protocol_version));
print.blankln(format!("rpc {}", version_info.version));

Expand All @@ -120,7 +123,7 @@ async fn inspect_networks(print: &Print, config_locator: &locator::Args) -> Resu
if print_network(true, print, &name, &network).await.is_err() {
print.warnln(format!(
"Default network {name:?} ({}) is unreachable",
network.rpc_url
redact_rpc_url(&network.rpc_url)
));
}
}
Expand All @@ -130,7 +133,7 @@ async fn inspect_networks(print: &Print, config_locator: &locator::Args) -> Resu
if print_network(false, print, name, &network).await.is_err() {
print.warnln(format!(
"Network {name:?} ({}) is unreachable",
network.rpc_url
redact_rpc_url(&network.rpc_url)
));
}
}
Expand Down
55 changes: 54 additions & 1 deletion cmd/soroban-cli/src/commands/network/ls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,63 @@ impl Cmd {

format!(
"Name: {name}\nRPC url: {rpc_url}\nRPC headers:{headers}\nNetwork passphrase: {passphrase}",
rpc_url = network.rpc_url,
rpc_url = crate::config::network::redact_rpc_url(&network.rpc_url),
passphrase = network.network_passphrase,
)
})
.collect())
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::config::network::Network;
use crate::test_utils::{with_cwd_guard, with_env_guard};
use serial_test::serial;

#[test]
#[serial]
fn ls_l_redacts_rpc_url_password() {
let tmp = tempfile::tempdir().unwrap();

with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
with_cwd_guard(|| {
let global_cfg = tmp.path().join("global");
std::fs::create_dir_all(&global_cfg).unwrap();
std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);

let work = tmp.path().join("work");
std::fs::create_dir_all(&work).unwrap();
std::env::set_current_dir(&work).unwrap();

let cmd = Cmd {
config_locator: locator::Args { config_dir: None },
long: true,
};

let network = Network {
rpc_url: "https://alice:supersecret@rpc.example.com/soroban".to_string(),
rpc_headers: Vec::new(),
network_passphrase: "Test SDF Network ; September 2015".to_string(),
};
cmd.config_locator.write_network("corp", &network).unwrap();

let rendered = cmd.ls_l().unwrap().join("\n\n");

assert!(
!rendered.contains("supersecret"),
"password leaked into `network ls -l` output: {rendered}"
);
assert!(
rendered.contains("alice:redacted"),
"expected `alice:redacted` in `network ls -l` output: {rendered}"
);
assert!(
rendered.contains("rpc.example.com/soroban"),
"expected host and path preserved: {rendered}"
);
});
});
}
}
28 changes: 2 additions & 26 deletions cmd/soroban-cli/src/config/data.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
use std::str::FromStr;
use url::Url;

use super::network::redact_rpc_url;
use crate::xdr::{self, WriteXdr};

#[derive(thiserror::Error, Debug)]
Expand Down Expand Up @@ -60,22 +61,14 @@ pub fn bucket_dir() -> Result<std::path::PathBuf, Error> {
pub fn write(action: Action, rpc_url: &Url) -> Result<ulid::Ulid, Error> {
let data = Data {
action,
rpc_url: redact_userinfo(rpc_url).to_string(),
rpc_url: redact_rpc_url(rpc_url.as_str()),
};
let id = ulid::Ulid::new();
let file = actions_dir()?.join(id.to_string()).with_extension("json");
std::fs::write(file, serde_json::to_string(&data)?)?;
Ok(id)
}

fn redact_userinfo(url: &Url) -> Url {
let mut redacted = url.clone();
if redacted.password().is_some() {
let _ = redacted.set_password(Some("redacted"));
}
redacted
}

pub fn read(id: &ulid::Ulid) -> Result<(Action, Url), Error> {
let file = actions_dir()?.join(id.to_string()).with_extension("json");
let data: Data = serde_json::from_str(&std::fs::read_to_string(file)?)?;
Expand Down Expand Up @@ -269,23 +262,6 @@ mod test {
});
}

#[test]
fn redact_userinfo_leaves_url_without_password_unchanged() {
let plain = Url::from_str("https://rpc.example.com/soroban/rpc").unwrap();
assert_eq!(redact_userinfo(&plain), plain);

let user_only = Url::from_str("https://alice@rpc.example.com/soroban/rpc").unwrap();
assert_eq!(redact_userinfo(&user_only), user_only);

let with_password =
Url::from_str("https://alice:supersecret@rpc.example.com/soroban/rpc").unwrap();
let redacted = redact_userinfo(&with_password);
assert_eq!(redacted.username(), "alice");
assert_eq!(redacted.password(), Some("redacted"));
assert_eq!(redacted.host_str(), Some("rpc.example.com"));
assert_eq!(redacted.path(), "/soroban/rpc");
}

#[test]
#[serial]
fn actionlog_list_actions_renders_redacted_rpc_url() {
Expand Down
63 changes: 62 additions & 1 deletion cmd/soroban-cli/src/config/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,13 +165,23 @@ impl std::fmt::Debug for Network {
.map(|(k, _)| (k.as_str(), "<concealed>"))
.collect();
f.debug_struct("Network")
.field("rpc_url", &self.rpc_url)
.field("rpc_url", &redact_rpc_url(&self.rpc_url))
.field("rpc_headers", &concealed)
.field("network_passphrase", &self.network_passphrase)
.finish()
}
}

pub fn redact_rpc_url(rpc_url: &str) -> String {
let Ok(mut url) = Url::parse(rpc_url) else {
return rpc_url.to_string();
};
Comment thread
fnando marked this conversation as resolved.
if url.password().is_some() {
let _ = url.set_password(Some("redacted"));
}
url.to_string()
}

fn parse_http_header(header: &str) -> Result<(String, String), Error> {
let header_components = header.splitn(2, ':');

Expand Down Expand Up @@ -664,4 +674,55 @@ mod tests {
r#"Network { rpc_url: "http://localhost:8000/rpc", rpc_headers: [("Authorization", "<concealed>"), ("X-Api-Key", "<concealed>")], network_passphrase: "Test Network" }"#
);
}

#[test]
fn test_debug_conceals_rpc_url_password() {
let network = Network {
rpc_url: "https://alice:supersecret@rpc.example.com/soroban".to_string(),
network_passphrase: "Test Network".to_string(),
rpc_headers: Vec::new(),
};
let rendered = format!("{network:?}");
assert!(
!rendered.contains("supersecret"),
"password leaked into Debug output: {rendered}"
);
assert!(
rendered.contains("alice:redacted"),
"expected `alice:redacted` in Debug output: {rendered}"
);
}

#[test]
fn redact_rpc_url_leaves_url_without_password_unchanged() {
let plain = "https://rpc.example.com/soroban";
assert_eq!(redact_rpc_url(plain), plain);

let user_only = "https://alice@rpc.example.com/soroban";
assert_eq!(redact_rpc_url(user_only), user_only);
}

#[test]
fn redact_rpc_url_replaces_password_with_placeholder() {
let with_password = "https://alice:supersecret@rpc.example.com/soroban";
let redacted = redact_rpc_url(with_password);
assert!(
!redacted.contains("supersecret"),
"password leaked: {redacted}"
);
assert!(
redacted.contains("alice:redacted"),
"expected `alice:redacted`: {redacted}"
);
assert!(
redacted.contains("rpc.example.com/soroban"),
"expected host and path preserved: {redacted}"
);
}

#[test]
fn redact_rpc_url_returns_input_when_unparseable() {
let bad = "not a url";
assert_eq!(redact_rpc_url(bad), bad);
}
}
Loading