Add DisplaySafeUrl newtype to prevent leaking of credentials by default (#13560)

Prior to this PR, there were numerous places where uv would leak
credentials in logs. We had a way to mask credentials by calling methods
or a recently-added `redact_url` function, but this was not secure by
default. There were a number of other types (like `GitUrl`) that would
leak credentials on display.

This PR adds a `DisplaySafeUrl` newtype to prevent leaking credentials
when logging by default. It takes a maximalist approach, replacing the
use of `Url` almost everywhere. This includes when first parsing config
files, when storing URLs in types like `GitUrl`, and also when storing
URLs in types that in practice will never contain credentials (like
`DirectorySourceUrl`). The idea is to make it easy for developers to do
the right thing and for the compiler to support this (and to minimize
ever having to manually convert back and forth). Displaying credentials
now requires an active step. Note that despite this maximalist approach,
the use of the newtype should be zero cost.

One conspicuous place this PR does not use `DisplaySafeUrl` is in the
`uv-auth` crate. That would require new clones since there are calls to
`request.url()` that return a `&Url`. One option would have been to make
`DisplaySafeUrl` wrap a `Cow`, but this would lead to lifetime
annotations all over the codebase. I've created a separate PR based on
this one (#13576) that updates `uv-auth` to use `DisplaySafeUrl` with
one new clone. We can discuss the tradeoffs there.

Most of this PR just replaces `Url` with `DisplaySafeUrl`. The core is
`uv_redacted/lib.rs`, where the newtype is implemented. To make it
easier to review the rest, here are some points of note:

* `DisplaySafeUrl` has a `Display` implementation that masks
credentials. Currently, it will still display the username when there is
both a username and password. If we think is the wrong choice, it can
now be changed in one place.
* `DisplaySafeUrl` has a `remove_credentials()` method and also a
`.to_string_with_credentials()` method. This allows us to use it in a
variety of scenarios.
* `IndexUrl::redacted()` was renamed to
`IndexUrl::removed_credentials()` to make it clearer that we are not
masking.
* We convert from a `DisplaySafeUrl` to a `Url` when calling `reqwest`
methods like `.get()` and `.head()`.
* We convert from a `DisplaySafeUrl` to a `Url` when creating a
`uv_auth::Index`. That is because, as mentioned above, I will be
updating the `uv_auth` crate to use this newtype in a separate PR.
* A number of tests (e.g., in `pip_install.rs`) that formerly used
filters to mask tokens in the test output no longer need those filters
since tokens in URLs are now masked automatically.
* The one place we are still knowingly writing credentials to
`pyproject.toml` is when a URL with credentials is passed to `uv add`
with `--raw`. Since displaying credentials is no longer automatic, I
have added a `to_string_with_credentials()` method to the `Pep508Url`
trait. This is used when `--raw` is passed. Adding it to that trait is a
bit weird, but it's the simplest way to achieve the goal. I'm open to
suggestions on how to improve this, but note that because of the way
we're using generic bounds, it's not as simple as just creating a
separate trait for that method.
This commit is contained in:
John Mumm 2025-05-27 00:05:30 +02:00 committed by GitHub
parent b80cafd5e8
commit c19a294a48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
100 changed files with 1266 additions and 2249 deletions

View file

@ -27,6 +27,7 @@ uv-options-metadata = { workspace = true }
uv-pep440 = { workspace = true }
uv-pep508 = { workspace = true }
uv-pypi-types = { workspace = true }
uv-redacted = { workspace = true }
uv-static = { workspace = true }
uv-warnings = { workspace = true }
@ -42,7 +43,6 @@ tokio = { workspace = true }
toml = { workspace = true }
toml_edit = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
[dev-dependencies]
anyhow = { workspace = true }
@ -52,7 +52,7 @@ regex = { workspace = true }
tempfile = { workspace = true }
[features]
schemars = ["dep:schemars", "uv-pypi-types/schemars"]
schemars = ["dep:schemars", "uv-pypi-types/schemars", "uv-redacted/schemars"]
[package.metadata.cargo-shear]
ignored = ["uv-options-metadata"]

View file

@ -17,7 +17,6 @@ use owo_colors::OwoColorize;
use rustc_hash::{FxBuildHasher, FxHashSet};
use serde::{Deserialize, Deserializer, Serialize, de::IntoDeserializer, de::SeqAccess};
use thiserror::Error;
use url::Url;
use uv_build_backend::BuildBackendSettings;
use uv_distribution_types::{Index, IndexName, RequirementSource};
use uv_fs::{PortablePathBuf, relative_to};
@ -29,6 +28,7 @@ use uv_pep508::MarkerTree;
use uv_pypi_types::{
Conflicts, DependencyGroups, SchemaConflicts, SupportedEnvironments, VerbatimParsedUrl,
};
use uv_redacted::DisplaySafeUrl;
#[derive(Error, Debug)]
pub enum PyprojectTomlError {
@ -891,7 +891,7 @@ pub enum Source {
/// ```
Git {
/// The repository URL (without the `git+` prefix).
git: Url,
git: DisplaySafeUrl,
/// The path to the directory with the `pyproject.toml`, if it's not in the archive root.
subdirectory: Option<PortablePathBuf>,
// Only one of the three may be used; we'll validate this later and emit a custom error.
@ -915,7 +915,7 @@ pub enum Source {
/// flask = { url = "https://files.pythonhosted.org/packages/61/80/ffe1da13ad9300f87c93af113edd0638c75138c42a0994becfacac078c06/flask-3.0.3-py3-none-any.whl" }
/// ```
Url {
url: Url,
url: DisplaySafeUrl,
/// For source distributions, the path to the directory with the `pyproject.toml`, if it's
/// not in the archive root.
subdirectory: Option<PortablePathBuf>,
@ -989,12 +989,12 @@ impl<'de> Deserialize<'de> for Source {
#[derive(Deserialize, Debug, Clone)]
#[serde(rename_all = "kebab-case", deny_unknown_fields)]
struct CatchAll {
git: Option<Url>,
git: Option<DisplaySafeUrl>,
subdirectory: Option<PortablePathBuf>,
rev: Option<String>,
tag: Option<String>,
branch: Option<String>,
url: Option<Url>,
url: Option<DisplaySafeUrl>,
path: Option<PortablePathBuf>,
editable: Option<bool>,
package: Option<bool>,
@ -1083,7 +1083,7 @@ impl<'de> Deserialize<'de> for Source {
// If the user prefixed the URL with `git+`, strip it.
let git = if let Some(git) = git.as_str().strip_prefix("git+") {
Url::parse(git).map_err(serde::de::Error::custom)?
DisplaySafeUrl::parse(git).map_err(serde::de::Error::custom)?
} else {
git
};

View file

@ -7,7 +7,6 @@ use thiserror::Error;
use toml_edit::{
Array, ArrayOfTables, DocumentMut, Formatted, Item, RawString, Table, TomlError, Value,
};
use url::Url;
use uv_cache_key::CanonicalUrl;
use uv_distribution_types::Index;
@ -15,6 +14,7 @@ use uv_fs::PortablePath;
use uv_normalize::GroupName;
use uv_pep440::{Version, VersionParseError, VersionSpecifier, VersionSpecifiers};
use uv_pep508::{ExtraName, MarkerTree, PackageName, Requirement, VersionOrUrl};
use uv_redacted::DisplaySafeUrl;
use crate::pyproject::{DependencyType, Source};
@ -171,6 +171,7 @@ impl PyProjectTomlMut {
&mut self,
req: &Requirement,
source: Option<&Source>,
raw: bool,
) -> Result<ArrayEdit, Error> {
// Get or create `project.dependencies`.
let dependencies = self
@ -180,7 +181,7 @@ impl PyProjectTomlMut {
.as_array_mut()
.ok_or(Error::MalformedDependencies)?;
let edit = add_dependency(req, dependencies, source.is_some())?;
let edit = add_dependency(req, dependencies, source.is_some(), raw)?;
if let Some(source) = source {
self.add_source(&req.name, source)?;
@ -196,6 +197,7 @@ impl PyProjectTomlMut {
&mut self,
req: &Requirement,
source: Option<&Source>,
raw: bool,
) -> Result<ArrayEdit, Error> {
// Get or create `tool.uv.dev-dependencies`.
let dev_dependencies = self
@ -213,7 +215,7 @@ impl PyProjectTomlMut {
.as_array_mut()
.ok_or(Error::MalformedDependencies)?;
let edit = add_dependency(req, dev_dependencies, source.is_some())?;
let edit = add_dependency(req, dev_dependencies, source.is_some(), raw)?;
if let Some(source) = source {
self.add_source(&req.name, source)?;
@ -267,7 +269,7 @@ impl PyProjectTomlMut {
if table
.get("url")
.and_then(|item| item.as_str())
.and_then(|url| Url::parse(url).ok())
.and_then(|url| DisplaySafeUrl::parse(url).ok())
.is_some_and(|url| {
CanonicalUrl::new(&url) == CanonicalUrl::new(index.url.url())
})
@ -304,10 +306,10 @@ impl PyProjectTomlMut {
if table
.get("url")
.and_then(|item| item.as_str())
.and_then(|url| Url::parse(url).ok())
.and_then(|url| DisplaySafeUrl::parse(url).ok())
.is_none_or(|url| CanonicalUrl::new(&url) != CanonicalUrl::new(index.url.url()))
{
let mut formatted = Formatted::new(index.url.redacted().to_string());
let mut formatted = Formatted::new(index.url.without_credentials().to_string());
if let Some(value) = table.get("url").and_then(Item::as_value) {
if let Some(prefix) = value.decor().prefix() {
formatted.decor_mut().set_prefix(prefix.clone());
@ -365,7 +367,7 @@ impl PyProjectTomlMut {
if table
.get("url")
.and_then(|item| item.as_str())
.and_then(|url| Url::parse(url).ok())
.and_then(|url| DisplaySafeUrl::parse(url).ok())
.is_some_and(|url| CanonicalUrl::new(&url) == CanonicalUrl::new(index.url.url()))
{
return false;
@ -400,6 +402,7 @@ impl PyProjectTomlMut {
group: &ExtraName,
req: &Requirement,
source: Option<&Source>,
raw: bool,
) -> Result<ArrayEdit, Error> {
// Get or create `project.optional-dependencies`.
let optional_dependencies = self
@ -428,7 +431,7 @@ impl PyProjectTomlMut {
.as_array_mut()
.ok_or(Error::MalformedDependencies)?;
let added = add_dependency(req, group, source.is_some())?;
let added = add_dependency(req, group, source.is_some(), raw)?;
// If `project.optional-dependencies` is an inline table, reformat it.
//
@ -457,6 +460,7 @@ impl PyProjectTomlMut {
group: &GroupName,
req: &Requirement,
source: Option<&Source>,
raw: bool,
) -> Result<ArrayEdit, Error> {
// Get or create `dependency-groups`.
let dependency_groups = self
@ -492,7 +496,7 @@ impl PyProjectTomlMut {
.as_array_mut()
.ok_or(Error::MalformedDependencies)?;
let added = add_dependency(req, group, source.is_some())?;
let added = add_dependency(req, group, source.is_some(), raw)?;
// To avoid churn in pyproject.toml, we only sort new group keys if the
// existing keys were sorted.
@ -999,6 +1003,7 @@ pub fn add_dependency(
req: &Requirement,
deps: &mut Array,
has_source: bool,
raw: bool,
) -> Result<ArrayEdit, Error> {
let mut to_replace = find_dependencies(&req.name, Some(&req.marker), deps);
@ -1057,7 +1062,11 @@ pub fn add_dependency(
Sort::Unsorted
};
let req_string = req.to_string();
let req_string = if raw {
req.displayable_with_credentials().to_string()
} else {
req.to_string()
};
let index = match sort {
Sort::CaseInsensitive => deps.iter().position(|dep| {
dep.as_str().is_some_and(|dep| {