Skip to content

Commit 441b9fa

Browse files
committed
Redact sensitive parts of URLs in reqwest errors
1 parent 830a963 commit 441b9fa

6 files changed

Lines changed: 211 additions & 34 deletions

File tree

crates/uv-client/src/error.rs

Lines changed: 144 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
use std::fmt::{Display, Formatter};
1+
use std::fmt::{Debug, Display, Formatter};
22
use std::ops::Deref;
33
use std::path::PathBuf;
44
use std::time::{Duration, Instant};
@@ -514,9 +514,10 @@ impl ErrorKind {
514514
///
515515
/// Wraps a [`reqwest_middleware::Error`] instead of an [`reqwest::Error`] since the actual reqwest
516516
/// error may be below some context in the [`anyhow::Error`].
517-
#[derive(Debug)]
517+
///
518+
/// When displayed, URLs attached to the reqwest error are formatted through [`DisplaySafeUrl`].
518519
pub struct WrappedReqwestError {
519-
error: reqwest_middleware::Error,
520+
error: DisplaySafeReqwestMiddlewareError,
520521
problem_details: Option<Box<ProblemDetails>>,
521522
}
522523

@@ -527,7 +528,7 @@ impl WrappedReqwestError {
527528
problem_details: Option<ProblemDetails>,
528529
) -> Self {
529530
Self {
530-
error: Self::filter_retries_from_error(error),
531+
error: DisplaySafeReqwestMiddlewareError(Self::filter_retries_from_error(error)),
531532
problem_details: problem_details.map(Box::new),
532533
}
533534
}
@@ -554,7 +555,7 @@ impl WrappedReqwestError {
554555

555556
/// Return the inner [`reqwest::Error`] from the error chain, if it exists.
556557
pub fn inner(&self) -> Option<&reqwest::Error> {
557-
match &self.error {
558+
match &self.error.0 {
558559
reqwest_middleware::Error::Reqwest(err) => Some(err),
559560
reqwest_middleware::Error::Middleware(err) => err.chain().find_map(|err| {
560561
if let Some(err) = err.downcast_ref::<reqwest::Error>() {
@@ -618,7 +619,7 @@ impl From<reqwest::Error> for WrappedReqwestError {
618619
fn from(error: reqwest::Error) -> Self {
619620
Self {
620621
// No need to filter retries as this error does not have retries.
621-
error: error.into(),
622+
error: DisplaySafeReqwestMiddlewareError(error.into()),
622623
problem_details: None,
623624
}
624625
}
@@ -627,7 +628,7 @@ impl From<reqwest::Error> for WrappedReqwestError {
627628
impl From<reqwest_middleware::Error> for WrappedReqwestError {
628629
fn from(error: reqwest_middleware::Error) -> Self {
629630
Self {
630-
error: Self::filter_retries_from_error(error),
631+
error: DisplaySafeReqwestMiddlewareError(Self::filter_retries_from_error(error)),
631632
problem_details: None,
632633
}
633634
}
@@ -637,14 +638,14 @@ impl Deref for WrappedReqwestError {
637638
type Target = reqwest_middleware::Error;
638639

639640
fn deref(&self) -> &Self::Target {
640-
&self.error
641+
&self.error.0
641642
}
642643
}
643644

644645
impl Display for WrappedReqwestError {
645646
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
646647
if self.is_likely_offline() {
647-
// Insert an extra hint, we'll show the wrapped error through `source`
648+
// Insert an extra hint; the lower-level cause is still shown through `source`.
648649
f.write_str("Could not connect, are you offline?")
649650
} else if let Some(problem_details) = &self.problem_details {
650651
// Show problem details if available
@@ -662,22 +663,153 @@ impl Display for WrappedReqwestError {
662663
impl std::error::Error for WrappedReqwestError {
663664
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
664665
if self.is_likely_offline() {
665-
// `Display` is inserting an extra message, so we need to show the wrapped error
666+
// `Display` is inserting an extra message, so show the wrapped transport error.
666667
Some(&self.error)
667668
} else if self.problem_details.is_some() {
668-
// `Display` is showing problem details, so show the wrapped error as source
669+
// `Display` is showing problem details, so show the wrapped transport error.
669670
Some(&self.error)
670671
} else {
671-
// `Display` is showing the wrapped error, continue with its source
672+
// `Display` is showing the wrapped error, continue with its source.
672673
self.error.source()
673674
}
674675
}
675676
}
676677

678+
impl Debug for WrappedReqwestError {
679+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
680+
f.debug_struct("WrappedReqwestError")
681+
.field("error", &self.to_string())
682+
.field("problem_details", &self.problem_details)
683+
.finish()
684+
}
685+
}
686+
687+
struct DisplaySafeReqwestMiddlewareError(reqwest_middleware::Error);
688+
689+
impl Display for DisplaySafeReqwestMiddlewareError {
690+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
691+
display_reqwest_middleware_error(&self.0, f)
692+
}
693+
}
694+
695+
impl Debug for DisplaySafeReqwestMiddlewareError {
696+
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
697+
write!(f, "DisplaySafeReqwestMiddlewareError({self})")
698+
}
699+
}
700+
701+
impl std::error::Error for DisplaySafeReqwestMiddlewareError {
702+
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
703+
match &self.0 {
704+
reqwest_middleware::Error::Middleware(error) => error.source(),
705+
// Skip the outer reqwest error; its Display includes the unredacted URL.
706+
reqwest_middleware::Error::Reqwest(error) => error.source(),
707+
}
708+
}
709+
}
710+
711+
fn display_reqwest_middleware_error(
712+
error: &reqwest_middleware::Error,
713+
f: &mut Formatter<'_>,
714+
) -> std::fmt::Result {
715+
match error {
716+
reqwest_middleware::Error::Middleware(error) => write!(f, "{error}"),
717+
reqwest_middleware::Error::Reqwest(error) => display_reqwest_error(error, f),
718+
}
719+
}
720+
721+
fn display_reqwest_error(error: &reqwest::Error, f: &mut Formatter<'_>) -> std::fmt::Result {
722+
let Some(url) = error.url() else {
723+
return write!(f, "{error}");
724+
};
725+
726+
let message = error.to_string();
727+
let raw_url = url.as_str();
728+
let display_safe_url = DisplaySafeUrl::ref_cast(url).to_string();
729+
write!(f, "{}", message.replace(raw_url, &display_safe_url))
730+
}
731+
677732
#[cfg(test)]
678733
mod tests {
679734
use super::*;
680735

736+
use anyhow::{Result, bail};
737+
738+
async fn rejected_signed_url_error() -> Result<reqwest::Error> {
739+
let result = reqwest::Client::new()
740+
.get("ftp://user:password@example.com/s3/dist.whl?X-Amz-Credential=credential-secret&X-Amz-Signature=signature-secret&X-Amz-Security-Token=token-secret")
741+
.send()
742+
.await;
743+
match result {
744+
Ok(_) => bail!("expected reqwest to reject the URL scheme"),
745+
Err(error) => Ok(error),
746+
}
747+
}
748+
749+
#[tokio::test]
750+
async fn wrapped_reqwest_error_redacts_sensitive_url_parts() -> Result<()> {
751+
let error = WrappedReqwestError::from(rejected_signed_url_error().await?);
752+
753+
assert_eq!(
754+
error.to_string(),
755+
"builder error for url (ftp://example.com/s3/dist.whl?X-Amz-Credential=****&X-Amz-Signature=****&X-Amz-Security-Token=****)"
756+
);
757+
758+
let debug = format!("{error:?}");
759+
for message in [error.to_string(), debug] {
760+
assert!(!message.contains("credential-secret"));
761+
assert!(!message.contains("signature-secret"));
762+
assert!(!message.contains("token-secret"));
763+
assert!(!message.contains("password"));
764+
}
765+
766+
let mut source = std::error::Error::source(&error);
767+
while let Some(error) = source {
768+
let message = error.to_string();
769+
assert!(!message.contains("credential-secret"));
770+
assert!(!message.contains("signature-secret"));
771+
assert!(!message.contains("token-secret"));
772+
assert!(!message.contains("password"));
773+
source = error.source();
774+
}
775+
776+
Ok(())
777+
}
778+
779+
#[tokio::test]
780+
async fn wrapped_reqwest_error_keeps_safe_source_with_problem_details() -> Result<()> {
781+
let error = WrappedReqwestError::with_problem_details(
782+
rejected_signed_url_error().await?.into(),
783+
Some(ProblemDetails {
784+
problem_type: default_problem_type(),
785+
title: Some("problem title".to_string()),
786+
detail: None,
787+
status: Some(400),
788+
instance: None,
789+
}),
790+
);
791+
792+
assert_eq!(error.to_string(), "Server message: problem title");
793+
794+
let source = std::error::Error::source(&error).expect("source");
795+
assert_eq!(
796+
source.to_string(),
797+
"builder error for url (ftp://example.com/s3/dist.whl?X-Amz-Credential=****&X-Amz-Signature=****&X-Amz-Security-Token=****)"
798+
);
799+
800+
let mut source = Some(source);
801+
while let Some(error) = source {
802+
let message = error.to_string();
803+
assert!(!message.contains("credential-secret"));
804+
assert!(!message.contains("signature-secret"));
805+
assert!(!message.contains("token-secret"));
806+
assert!(!message.contains("password"));
807+
source = error.source();
808+
}
809+
810+
Ok(())
811+
}
812+
681813
#[test]
682814
fn test_problem_details_parsing() {
683815
let json = r#"{

crates/uv-publish/src/lib.rs

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ use uv_auth::{Credentials, PyxTokenStore, Realm};
2727
use uv_cache::{Cache, Refresh};
2828
use uv_client::{
2929
BaseClient, DEFAULT_MAX_REDIRECTS, MetadataFormat, OwnedArchive, RegistryClientBuilder,
30-
RequestBuilder, RetryParsingError, RetryState,
30+
RequestBuilder, RetryParsingError, RetryState, WrappedReqwestError,
3131
};
3232
use uv_configuration::{KeyringProviderType, TrustedPublishing};
3333
use uv_distribution_filename::{DistFilename, SourceDistExtension, SourceDistFilename};
@@ -125,9 +125,9 @@ pub enum PublishPrepareError {
125125
#[derive(Error, Debug)]
126126
pub enum PublishSendError {
127127
#[error("Failed to send POST request")]
128-
ReqwestMiddleware(#[source] reqwest_middleware::Error),
128+
ReqwestMiddleware(#[source] WrappedReqwestError),
129129
#[error("Server returned status code {0}")]
130-
StatusNoBody(StatusCode, #[source] reqwest::Error),
130+
StatusNoBody(StatusCode, #[source] WrappedReqwestError),
131131
#[error("Server returned status code {0}. Server says: {1}")]
132132
Status(StatusCode, String),
133133
#[error("Server returned status code {0}. {1}")]
@@ -603,7 +603,7 @@ pub async fn upload(
603603
return Err(PublishError::PublishSend(
604604
group.file.clone(),
605605
current_registry.clone().into(),
606-
PublishSendError::ReqwestMiddleware(err).into(),
606+
PublishSendError::ReqwestMiddleware(err.into()).into(),
607607
));
608608
}
609609
};
@@ -678,7 +678,7 @@ pub async fn validate(
678678
PublishError::Validate(
679679
file.to_path_buf(),
680680
registry.clone().into(),
681-
PublishSendError::ReqwestMiddleware(err).into(),
681+
PublishSendError::ReqwestMiddleware(err.into()).into(),
682682
)
683683
})?;
684684

@@ -767,7 +767,7 @@ pub async fn upload_two_phase(
767767
let response = reserve_request.send().await.map_err(|err| {
768768
PublishError::Reserve(
769769
group.file.clone(),
770-
PublishSendError::ReqwestMiddleware(err).into(),
770+
PublishSendError::ReqwestMiddleware(err.into()).into(),
771771
)
772772
})?;
773773

@@ -782,7 +782,7 @@ pub async fn upload_two_phase(
782782
let body = response.text().await.map_err(|err| {
783783
PublishError::Reserve(
784784
group.file.clone(),
785-
PublishSendError::StatusNoBody(status, err).into(),
785+
PublishSendError::StatusNoBody(status, err.into()).into(),
786786
)
787787
})?;
788788
serde_json::from_str(&body).map_err(|_| {
@@ -881,7 +881,7 @@ pub async fn upload_two_phase(
881881
}
882882
return Err(PublishError::S3Upload(
883883
group.file.clone(),
884-
PublishSendError::ReqwestMiddleware(err).into(),
884+
PublishSendError::ReqwestMiddleware(err.into()).into(),
885885
));
886886
}
887887
};
@@ -926,7 +926,7 @@ pub async fn upload_two_phase(
926926
let response = finalize_request.send().await.map_err(|err| {
927927
PublishError::Finalize(
928928
group.file.clone(),
929-
PublishSendError::ReqwestMiddleware(err).into(),
929+
PublishSendError::ReqwestMiddleware(err.into()).into(),
930930
)
931931
})?;
932932

@@ -1464,7 +1464,7 @@ async fn handle_response(
14641464
if status_code == StatusCode::METHOD_NOT_ALLOWED {
14651465
PublishSendError::MethodNotAllowedNoBody
14661466
} else {
1467-
PublishSendError::StatusNoBody(status_code, err)
1467+
PublishSendError::StatusNoBody(status_code, err.into())
14681468
}
14691469
})?;
14701470
let upload_error = String::from_utf8_lossy(&upload_error);

crates/uv-publish/src/trusted_publishing.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use serde::{Deserialize, Serialize};
88
use std::env;
99
use std::fmt::Display;
1010
use thiserror::Error;
11+
use uv_client::WrappedReqwestError;
1112
use uv_redacted::{DisplaySafeUrl, DisplaySafeUrlError};
1213
use uv_static::EnvVars;
1314

@@ -30,9 +31,9 @@ pub enum TrustedPublishingError {
3031
#[error("No OIDC token discovered: are you in a supported trusted publishing environment?")]
3132
NoToken,
3233
#[error("Failed to fetch: `{0}`")]
33-
Reqwest(DisplaySafeUrl, #[source] reqwest::Error),
34+
Reqwest(DisplaySafeUrl, #[source] WrappedReqwestError),
3435
#[error("Failed to fetch: `{0}`")]
35-
ReqwestMiddleware(DisplaySafeUrl, #[source] reqwest_middleware::Error),
36+
ReqwestMiddleware(DisplaySafeUrl, #[source] WrappedReqwestError),
3637
#[error(transparent)]
3738
SerdeJson(#[from] serde_json::error::Error),
3839
#[error(

crates/uv-publish/src/trusted_publishing/pypi.rs

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,13 +50,15 @@ impl TrustedPublishingService for PyPIPublishingService<'_> {
5050
.get(Url::from(audience_url.clone()))
5151
.send()
5252
.await
53-
.map_err(|err| TrustedPublishingError::ReqwestMiddleware(audience_url.clone(), err))?;
53+
.map_err(|err| {
54+
TrustedPublishingError::ReqwestMiddleware(audience_url.clone(), err.into())
55+
})?;
5456
let audience = response
5557
.error_for_status()
56-
.map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err))?
58+
.map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err.into()))?
5759
.json::<Audience>()
5860
.await
59-
.map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err))?;
61+
.map_err(|err| TrustedPublishingError::Reqwest(audience_url.clone(), err.into()))?;
6062
trace!("The audience is `{}`", &audience.audience);
6163
Ok(audience.audience)
6264
}
@@ -87,15 +89,15 @@ impl TrustedPublishingService for PyPIPublishingService<'_> {
8789
.send()
8890
.await
8991
.map_err(|err| {
90-
TrustedPublishingError::ReqwestMiddleware(mint_token_url.clone(), err)
92+
TrustedPublishingError::ReqwestMiddleware(mint_token_url.clone(), err.into())
9193
})?;
9294

9395
// reqwest's implementation of `.json()` also goes through `.bytes()`
9496
let status = response.status();
9597
let body = response
9698
.bytes()
9799
.await
98-
.map_err(|err| TrustedPublishingError::Reqwest(mint_token_url.clone(), err))?;
100+
.map_err(|err| TrustedPublishingError::Reqwest(mint_token_url.clone(), err.into()))?;
99101

100102
if status.is_success() {
101103
let publish_token: PublishToken = serde_json::from_slice(&body)?;

0 commit comments

Comments
 (0)