@@ -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+
184207impl 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