Skip to content

Commit 1c95afc

Browse files
committed
Redact pre-signed upload URLs
1 parent 6e3cade commit 1c95afc

2 files changed

Lines changed: 174 additions & 8 deletions

File tree

crates/uv-redacted/src/lib.rs

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ use std::str::FromStr;
77
use thiserror::Error;
88
use 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)]
1117
pub 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/";

crates/uv/tests/it/publish.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,67 @@ async fn gitlab_trusted_publishing_testpypi_id_token() {
623623
);
624624
}
625625

626+
#[tokio::test]
627+
async fn direct_publish_redacts_presigned_upload_url() {
628+
let context = uv_test::test_context!("3.12");
629+
let server = MockServer::start().await;
630+
631+
let upload_url = format!(
632+
"{}/s3/ok-1.0.0-py3-none-any.whl?X-Amz-Credential=credential&X-Amz-Signature=signature&X-Amz-Security-Token=token",
633+
server.uri()
634+
);
635+
636+
Mock::given(method("POST"))
637+
.and(path("/upload/reserve"))
638+
.respond_with(ResponseTemplate::new(201).set_body_json(json!({
639+
"upload_url": upload_url,
640+
})))
641+
.mount(&server)
642+
.await;
643+
Mock::given(method("PUT"))
644+
.and(path("/s3/ok-1.0.0-py3-none-any.whl"))
645+
.respond_with(ResponseTemplate::new(200))
646+
.mount(&server)
647+
.await;
648+
Mock::given(method("POST"))
649+
.and(path("/upload/finalize"))
650+
.respond_with(ResponseTemplate::new(200))
651+
.mount(&server)
652+
.await;
653+
654+
uv_snapshot!(context.filters(), context.publish()
655+
.arg("--preview-features")
656+
.arg("direct-publish")
657+
.arg("--direct")
658+
.arg("-u")
659+
.arg("dummy")
660+
.arg("-p")
661+
.arg("dummy")
662+
.arg("--publish-url")
663+
.arg(format!("{}/upload", server.uri()))
664+
.arg(dummy_wheel())
665+
.env(EnvVars::RUST_LOG, "uv_publish=debug"), @"
666+
success: true
667+
exit_code: 0
668+
----- stdout -----
669+
670+
----- stderr -----
671+
Publishing 1 file to http://[LOCALHOST]/upload
672+
Hashing ok-1.0.0-py3-none-any.whl ([SIZE])
673+
DEBUG Hashing [WORKSPACE]/test/links/ok-1.0.0-py3-none-any.whl
674+
Uploading ok-1.0.0-py3-none-any.whl ([SIZE])
675+
DEBUG Reserving upload slot at http://[LOCALHOST]/upload/reserve
676+
DEBUG Using HTTP Basic authentication
677+
DEBUG Got pre-signed URL for upload: http://[LOCALHOST]/s3/ok-1.0.0-py3-none-any.whl?X-Amz-Credential=****&X-Amz-Signature=****&X-Amz-Security-Token=****
678+
DEBUG S3 upload complete for ok-1.0.0-py3-none-any.whl
679+
DEBUG Finalizing upload at http://[LOCALHOST]/upload/finalize
680+
DEBUG Using HTTP Basic authentication
681+
DEBUG Response code for http://[LOCALHOST]/upload/finalize: 200 OK
682+
DEBUG Upload finalized for ok-1.0.0-py3-none-any.whl
683+
"
684+
);
685+
}
686+
626687
/// PyPI returns `application/json` errors with a `code` field.
627688
#[tokio::test]
628689
async fn upload_error_pypi_json() {

0 commit comments

Comments
 (0)