Skip to content

Commit 8784259

Browse files
authored
Rpc header secret leak fix (#2522)
1 parent 74c92b1 commit 8784259

3 files changed

Lines changed: 131 additions & 9 deletions

File tree

cmd/crates/soroban-test/tests/it/config.rs

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,6 +720,29 @@ fn cannot_create_key_with_alias() {
720720
.failure();
721721
}
722722

723+
#[test]
724+
fn malformed_rpc_header_error_does_not_expose_secret() {
725+
let sandbox = TestEnv::default();
726+
let secret = "Authorization Bearer secret_poc_token_12345";
727+
sandbox
728+
.new_assert_cmd("network")
729+
.args([
730+
"add",
731+
"--rpc-url",
732+
"https://example.invalid",
733+
"--network-passphrase",
734+
"Test SDF Network ; September 2015",
735+
"testcorp",
736+
])
737+
.env("STELLAR_RPC_HEADERS", secret)
738+
.assert()
739+
.stderr(predicate::str::contains("secret_poc_token_12345").not())
740+
.stderr(predicate::str::contains(
741+
"invalid HTTP header: must be in the form 'key:value'",
742+
))
743+
.failure();
744+
}
745+
723746
#[test]
724747
fn env_does_not_display_rpc_headers() {
725748
let sandbox = TestEnv::default();

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ pub enum Error {
77

88
#[error(transparent)]
99
Config(#[from] locator::Error),
10+
11+
#[error(transparent)]
12+
Network(#[from] network::Error),
1013
}
1114

1215
#[derive(Debug, clap::Parser, Clone)]
@@ -24,6 +27,7 @@ pub struct Cmd {
2427

2528
impl Cmd {
2629
pub fn run(&self) -> Result<(), Error> {
30+
self.network.validate_headers()?;
2731
self.config_locator
2832
.write_network(&self.name, &self.network)?;
2933
Ok(())

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

Lines changed: 104 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,9 @@ pub struct Args {
7676
num_args = 1,
7777
action = clap::ArgAction::Append,
7878
value_delimiter = '\n',
79-
value_parser = parse_http_header,
8079
hide_env_values = true,
8180
)]
82-
pub rpc_headers: Vec<(String, String)>,
81+
pub rpc_headers: Vec<String>,
8382
/// Network passphrase to sign the transaction sent to the rpc server
8483
#[arg(
8584
long = "network-passphrase",
@@ -111,11 +110,18 @@ impl Args {
111110
(_, Some(_), None) => Err(Error::MissingNetworkPassphrase),
112111
(_, None, Some(_)) => Err(Error::MissingRpcUrl),
113112
(Some(network), None, None) => Ok(locator.read_network(network)?),
114-
(_, Some(rpc_url), Some(network_passphrase)) => Ok(Network {
115-
rpc_url,
116-
rpc_headers: self.rpc_headers.clone(),
117-
network_passphrase,
118-
}),
113+
(_, Some(rpc_url), Some(network_passphrase)) => {
114+
let rpc_headers = self
115+
.rpc_headers
116+
.iter()
117+
.map(|h| parse_http_header(h))
118+
.collect::<Result<Vec<_>, _>>()?;
119+
Ok(Network {
120+
rpc_url,
121+
rpc_headers,
122+
network_passphrase,
123+
})
124+
}
119125
}
120126
}
121127
}
@@ -138,7 +144,7 @@ pub struct Network {
138144
num_args = 1,
139145
action = clap::ArgAction::Append,
140146
value_delimiter = '\n',
141-
value_parser = parse_http_header,
147+
value_parser = accept_raw_rpc_header,
142148
hide_env_values = true,
143149
)]
144150
pub rpc_headers: Vec<(String, String)>,
@@ -174,14 +180,35 @@ fn parse_http_header(header: &str) -> Result<(String, String), Error> {
174180
.next_tuple()
175181
.ok_or_else(|| Error::InvalidHeader)?;
176182

177-
// Check that the headers are properly formatted
178183
HeaderName::from_str(key)?;
179184
HeaderValue::from_str(value)?;
180185

181186
Ok((key.to_string(), value.to_string()))
182187
}
183188

189+
/// Clap value_parser for `Network::rpc_headers` that always succeeds, deferring
190+
/// validation to application code so clap never echoes the raw value in error messages.
191+
#[allow(clippy::unnecessary_wraps)]
192+
fn accept_raw_rpc_header(header: &str) -> Result<(String, String), std::convert::Infallible> {
193+
match header.split_once(':') {
194+
Some((key, value)) => Ok((key.trim().to_string(), value.trim().to_string())),
195+
None => Ok((String::new(), header.to_string())),
196+
}
197+
}
198+
199+
fn validate_rpc_headers(headers: &[(String, String)]) -> Result<(), Error> {
200+
for (key, value) in headers {
201+
HeaderName::from_str(key).map_err(|_| Error::InvalidHeader)?;
202+
HeaderValue::from_str(value).map_err(|_| Error::InvalidHeader)?;
203+
}
204+
Ok(())
205+
}
206+
184207
impl Network {
208+
pub fn validate_headers(&self) -> Result<(), Error> {
209+
validate_rpc_headers(&self.rpc_headers)
210+
}
211+
185212
pub async fn helper_url(&self, addr: &str) -> Result<Url, Error> {
186213
tracing::debug!("address {addr:?}");
187214
let rpc_url =
@@ -554,6 +581,74 @@ mod tests {
554581
}
555582
}
556583

584+
#[test]
585+
fn test_malformed_rpc_header_accepted_by_clap_without_error() {
586+
use crate::test_utils::with_env_guard;
587+
use clap::Parser;
588+
589+
#[derive(clap::Parser)]
590+
struct TestCmd {
591+
#[command(flatten)]
592+
args: Args,
593+
}
594+
595+
let secret = "Authorization Bearer secret_poc_token_12345";
596+
with_env_guard(&["STELLAR_RPC_HEADERS"], || {
597+
std::env::set_var("STELLAR_RPC_HEADERS", secret);
598+
let result = TestCmd::try_parse_from(["stellar"]);
599+
assert!(
600+
result.is_ok(),
601+
"Clap must accept malformed RPC headers without error — validation is deferred to application code to prevent secrets from being echoed in clap error messages"
602+
);
603+
});
604+
}
605+
606+
#[test]
607+
fn test_validate_headers_rejects_missing_colon_without_exposing_value() {
608+
// Simulates what accept_raw_rpc_header stores when no ':' is present.
609+
let network = Network {
610+
rpc_url: "http://localhost:8000".to_string(),
611+
network_passphrase: "Test".to_string(),
612+
rpc_headers: vec![(
613+
String::new(),
614+
"Authorization Bearer secret_token_xyz".to_string(),
615+
)],
616+
};
617+
618+
let result = network.validate_headers();
619+
assert!(result.is_err());
620+
let error_msg = result.unwrap_err().to_string();
621+
assert_eq!(
622+
error_msg,
623+
"invalid HTTP header: must be in the form 'key:value'"
624+
);
625+
assert!(
626+
!error_msg.contains("secret_token_xyz"),
627+
"Error must not expose the raw header value, got: {error_msg}"
628+
);
629+
}
630+
631+
#[test]
632+
fn test_malformed_rpc_header_app_error_does_not_expose_value() {
633+
use super::super::locator;
634+
635+
let secret = "Authorization Bearer secret_poc_token_12345";
636+
let args = Args {
637+
rpc_url: Some("https://example.com".to_string()),
638+
rpc_headers: vec![secret.to_string()],
639+
network_passphrase: Some("Test SDF Network ; September 2015".to_string()),
640+
network: None,
641+
};
642+
643+
let result = args.get(&locator::Args::default());
644+
assert!(result.is_err());
645+
let error_msg = result.unwrap_err().to_string();
646+
assert!(
647+
!error_msg.contains("secret_poc_token_12345"),
648+
"Application error must not expose secret header value, got: {error_msg}"
649+
);
650+
}
651+
557652
#[test]
558653
fn test_debug_conceals_rpc_header_values() {
559654
let network = Network {

0 commit comments

Comments
 (0)