@@ -7,6 +7,12 @@ use std::str::FromStr;
77use thiserror:: Error ;
88use url:: Url ;
99
10+ const SENSITIVE_QUERY_PARAMETERS : & [ & str ] = & [
11+ "X-Amz-Credential" ,
12+ "X-Amz-Security-Token" ,
13+ "X-Amz-Signature" ,
14+ ] ;
15+
1016#[ derive( Error , Debug , Clone , PartialEq , Eq ) ]
1117pub enum DisplaySafeUrlError {
1218 /// Failed to parse a URL.
@@ -19,7 +25,7 @@ pub enum DisplaySafeUrlError {
1925 AmbiguousAuthority ( String ) ,
2026}
2127
22- /// A [`Url`] wrapper that redacts credentials when displaying the URL.
28+ /// A [`Url`] wrapper that redacts credentials and sensitive query parameters when displaying the URL.
2329///
2430/// `DisplaySafeUrl` wraps the standard [`url::Url`] type, providing functionality to mask
2531/// secrets by default when the URL is displayed or logged. This helps prevent accidental
@@ -260,6 +266,8 @@ impl Debug for DisplaySafeUrl {
260266 } else {
261267 ( "" , None )
262268 } ;
269+ let query = url. query ( ) . map ( redact_sensitive_query_values) ;
270+ let query = query. as_ref ( ) . map ( std:: convert:: AsRef :: as_ref) ;
263271
264272 f. debug_struct ( "DisplaySafeUrl" )
265273 . field ( "scheme" , & url. scheme ( ) )
@@ -269,7 +277,7 @@ impl Debug for DisplaySafeUrl {
269277 . field ( "host" , & url. host ( ) )
270278 . field ( "port" , & url. port ( ) )
271279 . field ( "path" , & url. path ( ) )
272- . field ( "query" , & url . query ( ) )
280+ . field ( "query" , & query)
273281 . field ( "fragment" , & url. fragment ( ) )
274282 . finish ( )
275283 }
@@ -301,17 +309,64 @@ fn is_ssh_git_username(url: &Url) -> bool {
301309 && url. password ( ) . is_none ( )
302310}
303311
304- fn display_with_redacted_credentials (
312+ fn is_sensitive_query_parameter ( key : & str ) -> bool {
313+ SENSITIVE_QUERY_PARAMETERS
314+ . iter ( )
315+ . any ( |sensitive| key. eq_ignore_ascii_case ( sensitive) )
316+ }
317+
318+ fn redact_sensitive_query_values ( query : & str ) -> Cow < ' _ , str > {
319+ let mut redacted = String :: with_capacity ( query. len ( ) ) ;
320+ let mut changed = false ;
321+
322+ for ( index, pair) in query. split ( '&' ) . enumerate ( ) {
323+ if index > 0 {
324+ redacted. push ( '&' ) ;
325+ }
326+
327+ let Some ( ( key, value) ) = pair. split_once ( '=' ) else {
328+ redacted. push_str ( pair) ;
329+ continue ;
330+ } ;
331+
332+ redacted. push_str ( key) ;
333+ redacted. push ( '=' ) ;
334+ if is_sensitive_query_parameter ( key) {
335+ redacted. push_str ( "****" ) ;
336+ changed = true ;
337+ } else {
338+ redacted. push_str ( value) ;
339+ }
340+ }
341+
342+ if changed {
343+ Cow :: Owned ( redacted)
344+ } else {
345+ Cow :: Borrowed ( query)
346+ }
347+ }
348+
349+ fn display_with_query (
305350 url : & Url ,
351+ query : Option < & str > ,
306352 f : & mut std:: fmt:: Formatter < ' _ > ,
307353) -> std:: fmt:: Result {
308- if url. password ( ) . is_none ( ) && url. username ( ) == "" {
309- return write ! ( f, "{url}" ) ;
310- }
354+ let mut url = url. clone ( ) ;
355+ url. set_query ( query) ;
356+ write ! ( f, "{url}" )
357+ }
311358
359+ fn display_with_redacted_credentials (
360+ url : & Url ,
361+ f : & mut std:: fmt:: Formatter < ' _ > ,
362+ ) -> std:: fmt:: Result {
363+ let query = url. query ( ) . map ( redact_sensitive_query_values) ;
312364 // For URLs that use the `git` convention (i.e., `ssh://git@github.com/...`), avoid dropping the
313365 // username.
314- if is_ssh_git_username ( url) {
366+ if is_ssh_git_username ( url) || ( url. username ( ) . is_empty ( ) && url. password ( ) . is_none ( ) ) {
367+ if let Some ( Cow :: Owned ( query) ) = query {
368+ return display_with_query ( url, Some ( & query) , f) ;
369+ }
315370 return write ! ( f, "{url}" ) ;
316371 }
317372
@@ -333,7 +388,7 @@ fn display_with_redacted_credentials(
333388 }
334389
335390 write ! ( f, "{}" , url. path( ) ) ?;
336- if let Some ( query) = url . query ( ) {
391+ if let Some ( query) = query {
337392 write ! ( f, "?{query}" ) ?;
338393 }
339394 if let Some ( fragment) = url. fragment ( ) {
@@ -461,6 +516,56 @@ mod tests {
461516 ) ;
462517 }
463518
519+ #[ test]
520+ fn redact_aws_presigned_query_values ( ) {
521+ let log_safe_url = DisplaySafeUrl :: parse (
522+ "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential&X-Amz-Date=20260424T120000Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=signature&X-Amz-Security-Token=token" ,
523+ )
524+ . unwrap ( ) ;
525+
526+ assert_eq ! (
527+ log_safe_url. to_string( ) ,
528+ "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=****&X-Amz-Date=20260424T120000Z&X-Amz-Expires=300&X-Amz-SignedHeaders=host&X-Amz-Signature=****&X-Amz-Security-Token=****"
529+ ) ;
530+ }
531+
532+ #[ test]
533+ fn redact_aws_presigned_query_values_case_insensitive ( ) {
534+ let log_safe_url = DisplaySafeUrl :: parse (
535+ "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=credential&x-amz-signature=signature&x-amz-security-token=token" ,
536+ )
537+ . unwrap ( ) ;
538+
539+ assert_eq ! (
540+ log_safe_url. to_string( ) ,
541+ "https://bucket.s3.amazonaws.com/dist.whl?x-amz-credential=****&x-amz-signature=****&x-amz-security-token=****"
542+ ) ;
543+ }
544+
545+ #[ test]
546+ fn redact_aws_presigned_query_values_in_debug ( ) {
547+ let log_safe_url = DisplaySafeUrl :: parse (
548+ "https://bucket.s3.amazonaws.com/dist.whl?X-Amz-Credential=credential&X-Amz-Signature=signature" ,
549+ )
550+ . unwrap ( ) ;
551+
552+ let debug = format ! ( "{log_safe_url:?}" ) ;
553+ assert ! ( debug. contains( r#"query: Some("X-Amz-Credential=****&X-Amz-Signature=****")"# ) ) ;
554+ assert ! ( !debug. contains( "credential" ) ) ;
555+ assert ! ( !debug. contains( "signature" ) ) ;
556+ }
557+
558+ #[ test]
559+ fn does_not_redact_unknown_query_values ( ) {
560+ let log_safe_url =
561+ DisplaySafeUrl :: parse ( "https://bucket.s3.amazonaws.com/dist.whl?token=secret" ) . unwrap ( ) ;
562+
563+ assert_eq ! (
564+ log_safe_url. to_string( ) ,
565+ "https://bucket.s3.amazonaws.com/dist.whl?token=secret"
566+ ) ;
567+ }
568+
464569 #[ test]
465570 fn url_join ( ) {
466571 let url_str = "https://token@example.com/abc/" ;
0 commit comments