Skip to content

Commit 313fbee

Browse files
authored
Redact password in RPC URLs printed by network display. (#2532)
1 parent c6557d3 commit 313fbee

4 files changed

Lines changed: 125 additions & 32 deletions

File tree

cmd/soroban-cli/src/commands/doctor.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ use crate::{
99
config::{
1010
self, data,
1111
locator::{self, KeyType},
12-
network::{Network, DEFAULTS as DEFAULT_NETWORKS},
12+
network::{redact_rpc_url, Network, DEFAULTS as DEFAULT_NETWORKS},
1313
},
1414
print::Print,
1515
rpc,
@@ -98,7 +98,10 @@ async fn print_network(
9898
"Network"
9999
};
100100

101-
print.globeln(format!("{prefix} {name:?} ({})", network.rpc_url));
101+
print.globeln(format!(
102+
"{prefix} {name:?} ({})",
103+
redact_rpc_url(&network.rpc_url)
104+
));
102105
print.blankln(format!("protocol {}", version_info.protocol_version));
103106
print.blankln(format!("rpc {}", version_info.version));
104107

@@ -120,7 +123,7 @@ async fn inspect_networks(print: &Print, config_locator: &locator::Args) -> Resu
120123
if print_network(true, print, &name, &network).await.is_err() {
121124
print.warnln(format!(
122125
"Default network {name:?} ({}) is unreachable",
123-
network.rpc_url
126+
redact_rpc_url(&network.rpc_url)
124127
));
125128
}
126129
}
@@ -130,7 +133,7 @@ async fn inspect_networks(print: &Print, config_locator: &locator::Args) -> Resu
130133
if print_network(false, print, name, &network).await.is_err() {
131134
print.warnln(format!(
132135
"Network {name:?} ({}) is unreachable",
133-
network.rpc_url
136+
redact_rpc_url(&network.rpc_url)
134137
));
135138
}
136139
}

cmd/soroban-cli/src/commands/network/ls.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,63 @@ impl Cmd {
4747

4848
format!(
4949
"Name: {name}\nRPC url: {rpc_url}\nRPC headers:{headers}\nNetwork passphrase: {passphrase}",
50-
rpc_url = network.rpc_url,
50+
rpc_url = crate::config::network::redact_rpc_url(&network.rpc_url),
5151
passphrase = network.network_passphrase,
5252
)
5353
})
5454
.collect())
5555
}
5656
}
57+
58+
#[cfg(test)]
59+
mod tests {
60+
use super::*;
61+
use crate::config::network::Network;
62+
use crate::test_utils::{with_cwd_guard, with_env_guard};
63+
use serial_test::serial;
64+
65+
#[test]
66+
#[serial]
67+
fn ls_l_redacts_rpc_url_password() {
68+
let tmp = tempfile::tempdir().unwrap();
69+
70+
with_env_guard(&["STELLAR_CONFIG_HOME", "XDG_CONFIG_HOME"], || {
71+
with_cwd_guard(|| {
72+
let global_cfg = tmp.path().join("global");
73+
std::fs::create_dir_all(&global_cfg).unwrap();
74+
std::env::set_var("STELLAR_CONFIG_HOME", &global_cfg);
75+
76+
let work = tmp.path().join("work");
77+
std::fs::create_dir_all(&work).unwrap();
78+
std::env::set_current_dir(&work).unwrap();
79+
80+
let cmd = Cmd {
81+
config_locator: locator::Args { config_dir: None },
82+
long: true,
83+
};
84+
85+
let network = Network {
86+
rpc_url: "https://alice:supersecret@rpc.example.com/soroban".to_string(),
87+
rpc_headers: Vec::new(),
88+
network_passphrase: "Test SDF Network ; September 2015".to_string(),
89+
};
90+
cmd.config_locator.write_network("corp", &network).unwrap();
91+
92+
let rendered = cmd.ls_l().unwrap().join("\n\n");
93+
94+
assert!(
95+
!rendered.contains("supersecret"),
96+
"password leaked into `network ls -l` output: {rendered}"
97+
);
98+
assert!(
99+
rendered.contains("alice:redacted"),
100+
"expected `alice:redacted` in `network ls -l` output: {rendered}"
101+
);
102+
assert!(
103+
rendered.contains("rpc.example.com/soroban"),
104+
"expected host and path preserved: {rendered}"
105+
);
106+
});
107+
});
108+
}
109+
}

cmd/soroban-cli/src/config/data.rs

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use serde::{Deserialize, Serialize};
44
use std::str::FromStr;
55
use url::Url;
66

7+
use super::network::redact_rpc_url;
78
use crate::xdr::{self, WriteXdr};
89

910
#[derive(thiserror::Error, Debug)]
@@ -60,22 +61,14 @@ pub fn bucket_dir() -> Result<std::path::PathBuf, Error> {
6061
pub fn write(action: Action, rpc_url: &Url) -> Result<ulid::Ulid, Error> {
6162
let data = Data {
6263
action,
63-
rpc_url: redact_userinfo(rpc_url).to_string(),
64+
rpc_url: redact_rpc_url(rpc_url.as_str()),
6465
};
6566
let id = ulid::Ulid::new();
6667
let file = actions_dir()?.join(id.to_string()).with_extension("json");
6768
std::fs::write(file, serde_json::to_string(&data)?)?;
6869
Ok(id)
6970
}
7071

71-
fn redact_userinfo(url: &Url) -> Url {
72-
let mut redacted = url.clone();
73-
if redacted.password().is_some() {
74-
let _ = redacted.set_password(Some("redacted"));
75-
}
76-
redacted
77-
}
78-
7972
pub fn read(id: &ulid::Ulid) -> Result<(Action, Url), Error> {
8073
let file = actions_dir()?.join(id.to_string()).with_extension("json");
8174
let data: Data = serde_json::from_str(&std::fs::read_to_string(file)?)?;
@@ -269,23 +262,6 @@ mod test {
269262
});
270263
}
271264

272-
#[test]
273-
fn redact_userinfo_leaves_url_without_password_unchanged() {
274-
let plain = Url::from_str("https://rpc.example.com/soroban/rpc").unwrap();
275-
assert_eq!(redact_userinfo(&plain), plain);
276-
277-
let user_only = Url::from_str("https://alice@rpc.example.com/soroban/rpc").unwrap();
278-
assert_eq!(redact_userinfo(&user_only), user_only);
279-
280-
let with_password =
281-
Url::from_str("https://alice:supersecret@rpc.example.com/soroban/rpc").unwrap();
282-
let redacted = redact_userinfo(&with_password);
283-
assert_eq!(redacted.username(), "alice");
284-
assert_eq!(redacted.password(), Some("redacted"));
285-
assert_eq!(redacted.host_str(), Some("rpc.example.com"));
286-
assert_eq!(redacted.path(), "/soroban/rpc");
287-
}
288-
289265
#[test]
290266
#[serial]
291267
fn actionlog_list_actions_renders_redacted_rpc_url() {

cmd/soroban-cli/src/config/network.rs

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,13 +165,23 @@ impl std::fmt::Debug for Network {
165165
.map(|(k, _)| (k.as_str(), "<concealed>"))
166166
.collect();
167167
f.debug_struct("Network")
168-
.field("rpc_url", &self.rpc_url)
168+
.field("rpc_url", &redact_rpc_url(&self.rpc_url))
169169
.field("rpc_headers", &concealed)
170170
.field("network_passphrase", &self.network_passphrase)
171171
.finish()
172172
}
173173
}
174174

175+
pub fn redact_rpc_url(rpc_url: &str) -> String {
176+
let Ok(mut url) = Url::parse(rpc_url) else {
177+
return rpc_url.to_string();
178+
};
179+
if url.password().is_some() {
180+
let _ = url.set_password(Some("redacted"));
181+
}
182+
url.to_string()
183+
}
184+
175185
fn parse_http_header(header: &str) -> Result<(String, String), Error> {
176186
let header_components = header.splitn(2, ':');
177187

@@ -664,4 +674,55 @@ mod tests {
664674
r#"Network { rpc_url: "http://localhost:8000/rpc", rpc_headers: [("Authorization", "<concealed>"), ("X-Api-Key", "<concealed>")], network_passphrase: "Test Network" }"#
665675
);
666676
}
677+
678+
#[test]
679+
fn test_debug_conceals_rpc_url_password() {
680+
let network = Network {
681+
rpc_url: "https://alice:supersecret@rpc.example.com/soroban".to_string(),
682+
network_passphrase: "Test Network".to_string(),
683+
rpc_headers: Vec::new(),
684+
};
685+
let rendered = format!("{network:?}");
686+
assert!(
687+
!rendered.contains("supersecret"),
688+
"password leaked into Debug output: {rendered}"
689+
);
690+
assert!(
691+
rendered.contains("alice:redacted"),
692+
"expected `alice:redacted` in Debug output: {rendered}"
693+
);
694+
}
695+
696+
#[test]
697+
fn redact_rpc_url_leaves_url_without_password_unchanged() {
698+
let plain = "https://rpc.example.com/soroban";
699+
assert_eq!(redact_rpc_url(plain), plain);
700+
701+
let user_only = "https://alice@rpc.example.com/soroban";
702+
assert_eq!(redact_rpc_url(user_only), user_only);
703+
}
704+
705+
#[test]
706+
fn redact_rpc_url_replaces_password_with_placeholder() {
707+
let with_password = "https://alice:supersecret@rpc.example.com/soroban";
708+
let redacted = redact_rpc_url(with_password);
709+
assert!(
710+
!redacted.contains("supersecret"),
711+
"password leaked: {redacted}"
712+
);
713+
assert!(
714+
redacted.contains("alice:redacted"),
715+
"expected `alice:redacted`: {redacted}"
716+
);
717+
assert!(
718+
redacted.contains("rpc.example.com/soroban"),
719+
"expected host and path preserved: {redacted}"
720+
);
721+
}
722+
723+
#[test]
724+
fn redact_rpc_url_returns_input_when_unparseable() {
725+
let bad = "not a url";
726+
assert_eq!(redact_rpc_url(bad), bad);
727+
}
667728
}

0 commit comments

Comments
 (0)