mirror of
https://github.com/astral-sh/uv.git
synced 2025-11-24 05:17:05 +00:00
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:
parent
11d00d21f7
commit
f84faf726a
16 changed files with 784 additions and 99 deletions
|
|
@ -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)]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue