Make uv’s first-index strategy more secure by default by failing early on authentication failure (#12805)

uv’s default index strategy was designed with dependency confusion
attacks in mind. [According to the
docs](https://docs.astral.sh/uv/configuration/indexes/#searching-across-multiple-indexes),
“if a package exists on an internal index, it should always be installed
from the internal index, and never from PyPI”. Unfortunately, this is
not true in the case where authentication fails on that internal index.
In that case, uv will simply try the next index (even on the
`first-index` strategy). This means that uv is not secure by default in
this common scenario.

This PR causes uv to stop searching for a package if it encounters an
authentication failure at an index. It is possible to opt out of this
behavior for an index with a new `pyproject.toml` option
`ignore-error-codes`. For example:

```
[[tool.uv.index]]
name = "my-index"
url = "<index-url>"
ignore-error-codes = [401, 403]
```

This will also enable users to handle idiosyncratic registries in a more
fine-grained way. For example, PyTorch registries return a 403 when a
package is not found. In this PR, we special-case PyTorch registries to
ignore 403s, but users can use `ignore-error-codes` to handle similar
behaviors if they encounter them on internal registries.

Depends on #12651

Closes #9429
Closes #12362
This commit is contained in:
Zanie Blue 2025-04-29 12:57:19 -05:00
parent 11d00d21f7
commit f84faf726a
16 changed files with 784 additions and 99 deletions

View file

@ -7,12 +7,12 @@ use std::time::Duration;
use async_http_range_reader::AsyncHttpRangeReader;
use futures::{FutureExt, StreamExt, TryStreamExt};
use http::HeaderMap;
use http::{HeaderMap, StatusCode};
use itertools::Either;
use reqwest::{Proxy, Response, StatusCode};
use reqwest::{Proxy, Response};
use rustc_hash::FxHashMap;
use tokio::sync::{Mutex, Semaphore};
use tracing::{info_span, instrument, trace, warn, Instrument};
use tracing::{debug, info_span, instrument, trace, warn, Instrument};
use url::Url;
use uv_auth::Indexes;
@ -22,7 +22,7 @@ use uv_configuration::{IndexStrategy, TrustedHost};
use uv_distribution_filename::{DistFilename, SourceDistFilename, WheelFilename};
use uv_distribution_types::{
BuiltDist, File, FileLocation, IndexCapabilities, IndexFormat, IndexLocations,
IndexMetadataRef, IndexUrl, IndexUrls, Name,
IndexMetadataRef, IndexStatusCodeDecision, IndexStatusCodeStrategy, IndexUrl, IndexUrls, Name,
};
use uv_metadata::{read_metadata_async_seek, read_metadata_async_stream};
use uv_normalize::PackageName;
@ -332,12 +332,29 @@ impl RegistryClient {
let _permit = download_concurrency.acquire().await;
match index.format {
IndexFormat::Simple => {
if let Some(metadata) = self
.simple_single_index(package_name, index.url, capabilities)
let status_code_strategy =
self.index_urls.status_code_strategy_for(index.url);
match self
.simple_single_index(
package_name,
index.url,
capabilities,
&status_code_strategy,
)
.await?
{
results.push((index.url, MetadataFormat::Simple(metadata)));
break;
SimpleMetadataSearchOutcome::Found(metadata) => {
results.push((index.url, MetadataFormat::Simple(metadata)));
break;
}
// Package not found, so we will continue on to the next index (if there is one)
SimpleMetadataSearchOutcome::NotFound => {}
// The search failed because of an HTTP status code that we don't ignore for
// this index. We end our search here.
SimpleMetadataSearchOutcome::StatusCodeFailure(status_code) => {
debug!("Indexes search failed because of status code failure: {status_code}");
break;
}
}
}
IndexFormat::Flat => {
@ -358,9 +375,21 @@ impl RegistryClient {
let _permit = download_concurrency.acquire().await;
match index.format {
IndexFormat::Simple => {
let metadata = self
.simple_single_index(package_name, index.url, capabilities)
.await?;
// For unsafe matches, ignore authentication failures.
let status_code_strategy =
IndexStatusCodeStrategy::ignore_authentication_error_codes();
let metadata = match self
.simple_single_index(
package_name,
index.url,
capabilities,
&status_code_strategy,
)
.await?
{
SimpleMetadataSearchOutcome::Found(metadata) => Some(metadata),
_ => None,
};
Ok((index.url, metadata.map(MetadataFormat::Simple)))
}
IndexFormat::Flat => {
@ -439,14 +468,13 @@ impl RegistryClient {
///
/// The index can either be a PEP 503-compatible remote repository, or a local directory laid
/// out in the same format.
///
/// Returns `Ok(None)` if the package is not found in the index.
async fn simple_single_index(
&self,
package_name: &PackageName,
index: &IndexUrl,
capabilities: &IndexCapabilities,
) -> Result<Option<OwnedArchive<SimpleMetadata>>, Error> {
status_code_strategy: &IndexStatusCodeStrategy,
) -> Result<SimpleMetadataSearchOutcome, Error> {
// Format the URL for PyPI.
let mut url = index.url().clone();
url.path_segments_mut()
@ -488,27 +516,31 @@ impl RegistryClient {
};
match result {
Ok(metadata) => Ok(Some(metadata)),
Ok(metadata) => Ok(SimpleMetadataSearchOutcome::Found(metadata)),
Err(err) => match err.into_kind() {
// The package could not be found in the remote index.
ErrorKind::WrappedReqwestError(url, err) => match err.status() {
Some(StatusCode::NOT_FOUND) => Ok(None),
Some(StatusCode::UNAUTHORIZED) => {
capabilities.set_unauthorized(index.clone());
Ok(None)
ErrorKind::WrappedReqwestError(url, err) => {
let Some(status_code) = err.status() else {
return Err(ErrorKind::WrappedReqwestError(url, err).into());
};
let decision =
status_code_strategy.handle_status_code(status_code, index, capabilities);
if let IndexStatusCodeDecision::Fail(status_code) = decision {
if !matches!(
status_code,
StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN
) {
return Err(ErrorKind::WrappedReqwestError(url, err).into());
}
}
Some(StatusCode::FORBIDDEN) => {
capabilities.set_forbidden(index.clone());
Ok(None)
}
_ => Err(ErrorKind::WrappedReqwestError(url, err).into()),
},
Ok(SimpleMetadataSearchOutcome::from(decision))
}
// The package is unavailable due to a lack of connectivity.
ErrorKind::Offline(_) => Ok(None),
ErrorKind::Offline(_) => Ok(SimpleMetadataSearchOutcome::NotFound),
// The package could not be found in the local index.
ErrorKind::FileNotFound(_) => Ok(None),
ErrorKind::FileNotFound(_) => Ok(SimpleMetadataSearchOutcome::NotFound),
err => Err(err.into()),
},
@ -961,6 +993,26 @@ impl RegistryClient {
}
}
#[derive(Debug)]
pub(crate) enum SimpleMetadataSearchOutcome {
/// Simple metadata was found
Found(OwnedArchive<SimpleMetadata>),
/// Simple metadata was not found
NotFound,
/// A status code failure was encountered when searching for
/// simple metadata and our strategy did not ignore it
StatusCodeFailure(StatusCode),
}
impl From<IndexStatusCodeDecision> for SimpleMetadataSearchOutcome {
fn from(item: IndexStatusCodeDecision) -> Self {
match item {
IndexStatusCodeDecision::Ignore => Self::NotFound,
IndexStatusCodeDecision::Fail(status_code) => Self::StatusCodeFailure(status_code),
}
}
}
/// A map from [`IndexUrl`] to [`FlatIndexEntry`] entries found at the given URL, indexed by
/// [`PackageName`].
#[derive(Default, Debug, Clone)]