mod common;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::str::FromStr;
use std::sync::Arc;
use common::{TestServer, WiremockUpstream, config_with_open_blocklist, fake_origins};
use futures_util::StreamExt;
use probation::upstream::origins::{OriginKind, UrlRejection};
use probation::upstream::reqwest_transport::refusal_in;
use probation::upstream::resolver::{GuardedResolver, is_public};
use probation::upstream::{
ArtifactRequest, MetadataRequest, MetadataResponse, OriginSet, Transport, UpstreamError,
UpstreamValidators,
};
use reqwest::dns::{Name, Resolve};
use url::Url;
use wiremock::matchers::{header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
static ENVIRONMENT: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
const NPM_ACCEPT: &str = "application/json";
const A_MEGABYTE: u64 = 1024 * 1024;
fn metadata(url: Url) -> MetadataRequest {
MetadataRequest {
url,
accept: NPM_ACCEPT,
validators: None,
max_bytes: A_MEGABYTE,
}
}
fn parse(text: &str) -> Url {
Url::parse(text).expect("a test URL")
}
async fn upstream() -> WiremockUpstream {
let _guard = ENVIRONMENT.lock().await;
WiremockUpstream::start().await
}
#[test]
fn test_origins_work_through_the_constructor() {
let npm = parse("http://127.0.0.1:8080");
let origins = OriginSet::for_tests(
npm.clone(),
parse("http://127.0.0.1:8081"),
parse("http://127.0.0.1:8082"),
);
assert!(
origins.allows_private_addresses(),
"a test origin set is the one place the private-address gate opens"
);
assert_eq!(
origins.admit(
&parse("http://127.0.0.1:8080/left-pad"),
OriginKind::NpmMetadata
),
Ok(()),
"the constructor's own origin is admitted"
);
assert_eq!(
origins.kind_of(&npm),
Some(OriginKind::NpmMetadata),
"the first origin the constructor was given is the npm one"
);
assert_eq!(
origins
.url_for(OriginKind::NpmMetadata, &["left-pad"])
.map(String::from),
Ok("http://127.0.0.1:8080/left-pad".to_owned())
);
assert_eq!(
origins.admit(
&parse("http://127.0.0.1:8081/left-pad"),
OriginKind::NpmMetadata
),
Err(UrlRejection::ForeignOrigin),
"the PyPI socket is a different origin even on the same loopback host"
);
assert_eq!(
origins.admit(
&parse("http://127.0.0.1:9999/left-pad"),
OriginKind::NpmMetadata
),
Err(UrlRejection::Port)
);
assert_eq!(
origins.admit(
&parse("https://127.0.0.1:8080/left-pad"),
OriginKind::NpmMetadata
),
Err(UrlRejection::Scheme),
"the relaxation is per origin: this one was configured as http"
);
assert_eq!(
origins.admit(&parse("http://evil.test/left-pad"), OriginKind::NpmMetadata),
Err(UrlRejection::Host)
);
let production = OriginSet::production();
assert!(!production.allows_private_addresses());
assert_eq!(
production.admit(
&parse("http://127.0.0.1:8080/left-pad"),
OriginKind::NpmMetadata
),
Err(UrlRejection::Scheme)
);
assert_eq!(
production.admit(
&parse("http://registry.npmjs.org/left-pad"),
OriginKind::NpmMetadata
),
Err(UrlRejection::Scheme),
"even the right host is refused over plain http"
);
assert_eq!(
production
.url_for(OriginKind::NpmMetadata, &["left-pad"])
.map(String::from),
Ok("https://registry.npmjs.org/left-pad".to_owned())
);
}
#[tokio::test]
async fn production_origin_set_rejects_private_addresses() {
let refused: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3)),
IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)),
IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)),
IpAddr::V4(Ipv4Addr::new(169, 254, 169, 254)),
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)),
IpAddr::V4(Ipv4Addr::new(0, 0, 0, 1)),
IpAddr::V4(Ipv4Addr::new(0, 1, 2, 3)),
IpAddr::V4(Ipv4Addr::new(0, 255, 255, 255)),
IpAddr::V4(Ipv4Addr::new(100, 64, 0, 1)),
IpAddr::V4(Ipv4Addr::new(198, 18, 0, 1)),
IpAddr::V4(Ipv4Addr::new(240, 0, 0, 1)),
IpAddr::V6(Ipv6Addr::LOCALHOST),
IpAddr::V6(Ipv6Addr::UNSPECIFIED),
"fd00::1".parse().expect("a unique-local address"),
"fe80::1".parse().expect("a link-local address"),
"::ffff:127.0.0.1"
.parse()
.expect("a mapped loopback address"),
];
for addr in refused {
assert!(!is_public(*addr), "{addr} must not be treated as public");
}
let allowed: &[IpAddr] = &[
IpAddr::V4(Ipv4Addr::new(104, 16, 0, 1)),
IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8)),
"2606:4700::1".parse().expect("a public v6 address"),
"2002:6810:0101::".parse().expect("6to4 over 104.16.1.1"),
];
for addr in allowed {
assert!(is_public(*addr), "{addr} is an ordinary public address");
}
let resolver = GuardedResolver::new(false);
let refusal = resolver
.resolve(Name::from_str("localhost").expect("a resolvable name"))
.await
.err()
.expect("the production resolver refuses a loopback answer");
assert!(
matches!(
refusal_in(refusal.as_ref()),
Some(UpstreamError::RejectedAddress { .. })
),
"the refusal says which address it was: {refusal}"
);
let permissive = GuardedResolver::new(true);
assert!(
permissive
.resolve(Name::from_str("localhost").expect("a resolvable name"))
.await
.is_ok(),
"the relaxed resolver still resolves the same name"
);
}
#[test]
fn an_ipv6_address_cannot_hide_a_private_ipv4_target() {
let hiding: &[(&str, &str)] = &[
("64:ff9b::7f00:1", "NAT64 over 127.0.0.1"),
("64:ff9b::a00:1", "NAT64 over 10.0.0.1"),
("64:ff9b::a9fe:a9fe", "NAT64 over 169.254.169.254"),
("2002:7f00:1::", "6to4 over 127.0.0.1"),
("2002:a00:1::", "6to4 over 10.0.0.1"),
("2002:c0a8:1::1", "6to4 over 192.168.0.1"),
("::7f00:1", "IPv4-compatible 127.0.0.1"),
("::a00:1", "IPv4-compatible 10.0.0.1"),
];
for (text, what) in hiding {
let addr: IpAddr = text.parse().expect("a test address");
assert!(
!is_public(addr),
"{what} ({text}) must not be treated as public"
);
}
}
#[test]
fn every_special_purpose_ipv6_range_is_refused() {
let refused: &[(&str, &str)] = &[
("fec0::1", "site-local, deprecated by RFC 3879"),
("feff::1", "the top of fec0::/10"),
("64:ff9b:1::1", "local-use NAT64, RFC 8215"),
("100::1", "discard-only, RFC 6666"),
("5f00::1", "SRv6 SIDs, RFC 9602"),
("2001::1", "Teredo, inside IETF protocol assignments"),
("2001:2::1", "benchmarking, RFC 5180"),
(
"2001:1ff:ffff:ffff:ffff:ffff:ffff:ffff",
"the top of 2001::/23",
),
("3fff::1", "documentation, RFC 9637"),
(
"3fff:fff:ffff:ffff:ffff:ffff:ffff:ffff",
"the top of 3fff::/20",
),
("2620:4f:8000::1", "AS112-v6 direct delegation"),
("4000::1", "outside global unicast"),
("c000::1", "outside global unicast"),
];
for (text, what) in refused {
let addr: IpAddr = text.parse().expect("a test address");
assert!(
!is_public(addr),
"{text} ({what}) must not be treated as public"
);
}
assert!(is_public("2000::1".parse().expect("an address")));
assert!(is_public("3ffe::1".parse().expect("an address")));
assert!(!is_public("1fff::1".parse().expect("an address")));
}
#[tokio::test]
async fn no_config_key_or_env_var_relaxes_origins() {
use probation::config::Config;
let sample = std::fs::read_to_string("config.sample.toml").expect("the shipped sample");
for forbidden in [
"allow_private_addresses",
"npm_registry_url",
"upstream_origin",
"insecure",
"allow_http",
"proxy",
] {
assert!(
!sample.contains(forbidden),
"the shipped configuration must not carry `{forbidden}`"
);
let relaxed = format!("{sample}\n{forbidden} = true\n");
assert!(
Config::from_toml_str(&relaxed).is_err(),
"a configuration carrying `{forbidden}` must be refused"
);
}
let guard = ENVIRONMENT.lock().await;
let variables = [
("PROBATION_ALLOW_PRIVATE_ADDRESSES", "1"),
("ALLOW_PRIVATE_ADDRESSES", "true"),
("PROBATION_NPM_ORIGIN", "http://127.0.0.1:1/"),
("NPM_CONFIG_REGISTRY", "http://127.0.0.1:1/"),
("HTTP_PROXY", "http://127.0.0.1:1"),
("ALL_PROXY", "http://127.0.0.1:1"),
];
unsafe {
for (name, value) in variables {
std::env::set_var(name, value);
}
}
let production = OriginSet::production();
assert!(
!production.allows_private_addresses(),
"no environment variable opens the private-address gate"
);
assert_eq!(
production.admit(
&parse("http://127.0.0.1:1/left-pad"),
OriginKind::NpmMetadata
),
Err(UrlRejection::Scheme)
);
assert_eq!(
production
.url_for(OriginKind::NpmMetadata, &["left-pad"])
.map(String::from),
Ok("https://registry.npmjs.org/left-pad".to_owned()),
"the origin the URL is built from is still the compiled-in one"
);
let upstream = WiremockUpstream::start().await;
Mock::given(method("GET"))
.and(path("/left-pad"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&upstream.server)
.await;
let url = upstream
.origins
.url_for(OriginKind::NpmMetadata, &["left-pad"])
.expect("the test origin builds its own URL");
let fetched = upstream.transport.fetch_metadata(metadata(url)).await;
unsafe {
for (name, _) in variables {
std::env::remove_var(name);
}
}
drop(guard);
assert!(
matches!(fetched, Ok(MetadataResponse::Fresh { .. })),
"the request went to the configured origin, not through the proxy the \
environment asked for"
);
let mut mentions = Vec::new();
visit_rust_files("src".as_ref(), &mut |path, source| {
let code: String = source
.lines()
.map(|line| line.split("//").next().unwrap_or(""))
.collect::<Vec<_>>()
.join("\n");
if code.contains("for_tests") {
mentions.push(path.display().to_string());
}
});
assert_eq!(
mentions,
vec!["src/upstream/origins.rs".to_owned()],
"`for_tests` is named only where it is defined; anything else is a release \
path into the one constructor that can relax the boundary"
);
}
fn visit_rust_files(dir: &std::path::Path, seen: &mut impl FnMut(&std::path::Path, &str)) {
let mut entries: Vec<_> = std::fs::read_dir(dir)
.expect("the source directory is readable")
.map(|entry| entry.expect("a directory entry").path())
.collect();
entries.sort();
for path in entries {
if path.is_dir() {
visit_rust_files(&path, seen);
} else if path.extension().is_some_and(|ext| ext == "rs") {
let source = std::fs::read_to_string(&path).expect("a readable source file");
seen(&path, &source);
}
}
}
#[tokio::test]
async fn cross_origin_redirect_rejected() {
let upstream = upstream().await;
let foreign = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/left-pad"))
.respond_with(
ResponseTemplate::new(302)
.insert_header("location", format!("{}/left-pad", foreign.uri()).as_str()),
)
.mount(&upstream.server)
.await;
Mock::given(method("GET"))
.and(path("/left-pad"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"stolen":true}"#))
.mount(&foreign)
.await;
let url = upstream
.origins
.url_for(OriginKind::NpmMetadata, &["left-pad"])
.expect("the test origin builds its own URL");
let refusal = upstream
.transport
.fetch_metadata(metadata(url))
.await
.err()
.expect("a cross-origin redirect is refused");
match refusal {
UpstreamError::RejectedRedirect { to } => {
assert_eq!(to.as_str(), format!("{}/left-pad", foreign.uri()))
}
other => panic!("expected a refused redirect, got {other:?}"),
}
assert_eq!(
foreign
.received_requests()
.await
.expect("the recorded requests")
.len(),
0,
"the foreign origin was never contacted"
);
}
#[tokio::test]
async fn a_refused_redirect_names_its_resolved_target() {
let upstream = upstream().await;
let foreign = MockServer::start().await;
let foreign_authority = foreign
.uri()
.strip_prefix("http://")
.expect("a wiremock http origin")
.to_owned();
Mock::given(method("GET"))
.and(path("/protocol-relative"))
.respond_with(
ResponseTemplate::new(302)
.insert_header("location", format!("//{foreign_authority}/stolen").as_str()),
)
.mount(&upstream.server)
.await;
let url = upstream
.origins
.url_for(OriginKind::NpmMetadata, &["protocol-relative"])
.expect("the test origin builds its own URL");
match upstream.transport.fetch_metadata(metadata(url)).await {
Err(UpstreamError::RejectedRedirect { to }) => assert_eq!(
to.as_str(),
format!("http://{foreign_authority}/stolen"),
"the refusal must name where it was being sent"
),
other => panic!(
"expected a refused redirect, got {other:?}",
other = other.err()
),
}
assert_eq!(
foreign
.received_requests()
.await
.expect("the recorded requests")
.len(),
0
);
for hop in 0..10 {
Mock::given(method("GET"))
.and(path(format!("/hop{hop}")))
.respond_with(
ResponseTemplate::new(302)
.insert_header("location", format!("/hop{}", hop + 1).as_str()),
)
.mount(&upstream.server)
.await;
}
let url = upstream
.origins
.url_for(OriginKind::NpmMetadata, &["hop0"])
.expect("the test origin builds its own URL");
match upstream.transport.fetch_metadata(metadata(url)).await {
Err(UpstreamError::RejectedRedirect { to }) => {
assert_eq!(
to.host_str(),
upstream.origins.origin(OriginKind::NpmMetadata).host_str()
);
let hop: u32 = to
.path()
.strip_prefix("/hop")
.and_then(|n| n.parse().ok())
.unwrap_or_else(|| panic!("expected a resolved /hopN target, got {to}"));
assert!(
hop > 0,
"the refusal named the hop it was at, not the hop it was sent to: {to}"
);
}
other => panic!(
"expected a refused redirect, got {other:?}",
other = other.err()
),
}
}
#[tokio::test]
async fn url_credentials_rejected() {
let upstream = upstream().await;
let authority = upstream
.server
.uri()
.strip_prefix("http://")
.expect("a wiremock http origin")
.to_owned();
let credentialed = parse(&format!("http://npm:secret@{authority}/left-pad"));
assert_eq!(
upstream
.origins
.admit(&credentialed, OriginKind::NpmMetadata),
Err(UrlRejection::Credentials)
);
assert_eq!(
upstream
.transport
.fetch_metadata(metadata(credentialed))
.await
.err()
.expect("a credentialed URL is refused"),
UpstreamError::RejectedUrl(UrlRejection::Credentials)
);
assert_eq!(
upstream
.server
.received_requests()
.await
.expect("the recorded requests")
.len(),
0,
"nothing left the process"
);
Mock::given(method("GET"))
.and(path("/left-pad"))
.respond_with(ResponseTemplate::new(307).insert_header(
"location",
format!("http://npm:secret@{authority}/left-pad-2").as_str(),
))
.mount(&upstream.server)
.await;
let url = upstream
.origins
.url_for(OriginKind::NpmMetadata, &["left-pad"])
.expect("the test origin builds its own URL");
let refusal = upstream
.transport
.fetch_metadata(metadata(url))
.await
.err()
.expect("a redirect to a credentialed URL is refused");
assert!(
matches!(refusal, UpstreamError::RejectedRedirect { .. }),
"expected a refused redirect, got {refusal:?}"
);
}
#[tokio::test]
async fn unexpected_port_rejected() {
let upstream = upstream().await;
let other = MockServer::start().await;
let other_port = parse(&other.uri()).port().expect("wiremock binds a port");
let wrong_port = parse(&format!("http://127.0.0.1:{other_port}/left-pad"));
assert_eq!(
upstream.origins.admit(&wrong_port, OriginKind::NpmMetadata),
Err(UrlRejection::Port)
);
assert_eq!(
upstream
.transport
.fetch_metadata(metadata(wrong_port))
.await
.err()
.expect("an unexpected port is refused"),
UpstreamError::RejectedUrl(UrlRejection::Port)
);
Mock::given(method("GET"))
.and(path("/left-pad"))
.respond_with(
ResponseTemplate::new(302)
.insert_header("location", format!("{}/left-pad", other.uri()).as_str()),
)
.mount(&upstream.server)
.await;
Mock::given(method("GET"))
.and(path("/left-pad"))
.respond_with(ResponseTemplate::new(200).set_body_string("{}"))
.mount(&other)
.await;
let url = upstream
.origins
.url_for(OriginKind::NpmMetadata, &["left-pad"])
.expect("the test origin builds its own URL");
let refusal = upstream
.transport
.fetch_metadata(metadata(url))
.await
.err()
.expect("a redirect to another port is refused");
assert!(
matches!(refusal, UpstreamError::RejectedRedirect { .. }),
"expected a refused redirect, got {refusal:?}"
);
assert_eq!(
other
.received_requests()
.await
.expect("the recorded requests")
.len(),
0,
"the other port was never contacted"
);
}
#[tokio::test]
async fn client_authorization_cookies_and_proxy_credentials_are_never_forwarded() {
let upstream = upstream().await;
Mock::given(method("GET"))
.and(path("/left-pad"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"{"name":"left-pad"}"#))
.mount(&upstream.server)
.await;
let blocklist_dir = tempfile::tempdir().expect("a blocklist directory");
let server = TestServer::start_with_upstream(
config_with_open_blocklist(blocklist_dir.path()),
Arc::new(probation::clock::SystemClock),
Arc::clone(&upstream.transport),
upstream.origins.clone(),
)
.await;
let response = server
.get_with_headers(
"/npm/left-pad",
&[
("authorization", "Bearer downstream-secret"),
("cookie", "session=downstream-secret"),
("proxy-authorization", "Basic ZG93bnN0cmVhbTpzZWNyZXQ="),
],
)
.await;
assert_ne!(
response.status().as_u16(),
503,
"the blocklist is loaded, so the request reached the outbound boundary"
);
let received = upstream
.server
.received_requests()
.await
.expect("the recorded requests");
assert_eq!(received.len(), 1, "exactly one upstream request was made");
for forbidden in ["authorization", "cookie", "proxy-authorization"] {
assert!(
received[0].headers.get(forbidden).is_none(),
"`{forbidden}` reached upstream: {:?}",
received[0].headers
);
}
let headers = format!("{:?}", received[0].headers);
assert!(
!headers.contains("downstream-secret") && !headers.contains("ZG93bnN0cmVhbTpzZWNyZXQ"),
"a downstream credential reached upstream under some other header: {headers}"
);
server.shutdown().await;
}
#[tokio::test]
async fn a_conditional_request_answered_304_is_not_modified_not_a_redirect() {
let upstream = upstream().await;
Mock::given(method("GET"))
.and(path("/left-pad"))
.and(header("if-none-match", "\"v1\""))
.respond_with(ResponseTemplate::new(304).insert_header("etag", "\"v1\""))
.mount(&upstream.server)
.await;
let url = upstream
.origins
.url_for(OriginKind::NpmMetadata, &["left-pad"])
.expect("the test origin builds its own URL");
let mut request = metadata(url);
request.validators = Some(UpstreamValidators {
etag: Some("\"v1\"".to_owned()),
last_modified: None,
});
match upstream.transport.fetch_metadata(request).await {
Ok(MetadataResponse::NotModified { validators }) => {
assert_eq!(validators.etag.as_deref(), Some("\"v1\""))
}
Ok(MetadataResponse::Fresh { .. }) => panic!("a 304 carries no body to call fresh"),
Ok(MetadataResponse::Missing) => panic!("a 304 is not an upstream miss"),
Err(err) => panic!("a 304 must not be an error, got {err:?}"),
}
}
#[tokio::test]
async fn an_oversized_upstream_body_is_refused_by_the_counted_read() {
let upstream = upstream().await;
Mock::given(method("GET"))
.and(path("/huge"))
.respond_with(ResponseTemplate::new(200).set_body_string("x".repeat(4096)))
.mount(&upstream.server)
.await;
let url = upstream
.origins
.url_for(OriginKind::NpmMetadata, &["huge"])
.expect("the test origin builds its own URL");
let mut request = metadata(url.clone());
request.max_bytes = 1024;
assert_eq!(
upstream.transport.fetch_metadata(request).await.err(),
Some(UpstreamError::TooLarge { limit: 1024 }),
);
let mut request = metadata(url);
request.max_bytes = 8192;
match upstream.transport.fetch_metadata(request).await {
Ok(MetadataResponse::Fresh { body, .. }) => assert_eq!(body.len(), 4096),
other => panic!("expected the whole body, got {:?}", other.err()),
}
}
#[tokio::test]
async fn the_artifact_client_does_not_decode_what_the_metadata_client_does() {
const GZIPPED: &[u8] = &[
31, 139, 8, 0, 0, 0, 0, 0, 2, 3, 203, 72, 205, 201, 201, 87, 40, 45, 40, 46, 41, 74, 77,
204, 5, 0, 221, 41, 155, 225, 14, 0, 0, 0,
];
let upstream = upstream().await;
Mock::given(method("GET"))
.and(path("/tarball.tgz"))
.respond_with(
ResponseTemplate::new(200)
.insert_header("content-encoding", "gzip")
.set_body_bytes(GZIPPED),
)
.mount(&upstream.server)
.await;
let url = upstream
.origins
.url_for(OriginKind::PypiArtifacts, &["tarball.tgz"])
.expect("the test origin builds its own URL");
let body = upstream
.transport
.open_artifact(ArtifactRequest {
url: url.clone(),
max_bytes: A_MEGABYTE,
})
.await
.expect("the artifact is served");
let bytes: Vec<u8> = body
.stream
.fold(Vec::new(), |mut bytes, chunk| async move {
bytes.extend_from_slice(&chunk.expect("a body chunk"));
bytes
})
.await;
assert_eq!(
bytes, GZIPPED,
"the artifact client handed back the transferred bytes, undecoded"
);
match upstream.transport.fetch_metadata(metadata(url)).await {
Ok(MetadataResponse::Fresh { body, .. }) => {
assert_eq!(body.as_ref(), b"hello upstream")
}
other => panic!("expected a decoded metadata body, got {:?}", other.err()),
}
}
#[test]
fn a_dot_or_dot_dot_package_name_is_refused_not_dropped() {
for origins in [OriginSet::production(), fake_origins()] {
let origin = origins.origin(OriginKind::NpmMetadata).clone();
for name in [".", ".."] {
assert_eq!(
origins.url_for(OriginKind::NpmMetadata, &[name]),
Err(UrlRejection::PathEscape),
"`{name}` must be refused, not dropped into {origin}"
);
}
assert!(
origins
.url_for(OriginKind::NpmMetadata, &["../../etc/passwd"])
.is_ok(),
"only an exactly-dot segment is dropped by push, so only it is refused"
);
}
}
#[tokio::test]
async fn a_dot_dot_package_name_never_reaches_upstream() {
use common::FakeRegistry;
let registry = FakeRegistry::new();
let blocklist_dir = tempfile::tempdir().expect("a blocklist directory");
let server = TestServer::start_with_upstream(
config_with_open_blocklist(blocklist_dir.path()),
Arc::new(probation::clock::SystemClock),
Arc::clone(®istry) as Arc<dyn Transport>,
fake_origins(),
)
.await;
for raw in ["/npm/..", "/npm/%2E%2E", "/npm/."] {
let status = server.raw_get_status(raw).await;
assert_eq!(status, 400, "{raw} answered {status}");
}
assert_eq!(
registry.calls(),
Vec::<url::Url>::new(),
"a dot-segment name must be refused before any upstream call"
);
server.shutdown().await;
}
#[test]
fn a_package_name_cannot_change_the_upstream_host() {
let hostile = [
"//evil.test",
"../../etc/passwd",
"evil.test:8080",
"https://evil.test/left-pad",
"a%2fb",
"%2f%2fevil.test",
"@evil.test",
"left-pad?x=1#y",
];
for origins in [OriginSet::production(), fake_origins()] {
let origin = origins.origin(OriginKind::NpmMetadata).clone();
for name in hostile {
let built = origins
.url_for(OriginKind::NpmMetadata, &[name])
.unwrap_or_else(|rejection| {
panic!("`{name}` was refused rather than encoded: {rejection}")
});
assert_eq!(
built.host_str(),
origin.host_str(),
"`{name}` moved the host"
);
assert_eq!(
built.scheme(),
origin.scheme(),
"`{name}` changed the scheme"
);
assert_eq!(
built.port_or_known_default(),
origin.port_or_known_default(),
"`{name}` changed the port"
);
assert!(
built.username().is_empty() && built.password().is_none(),
"`{name}` introduced credentials"
);
assert_eq!(built.query(), None, "`{name}` introduced a query");
assert_eq!(built.fragment(), None, "`{name}` introduced a fragment");
assert_eq!(
origins.kind_of(&built),
Some(OriginKind::NpmMetadata),
"`{name}` built a URL on some other origin"
);
assert_eq!(
origins.admit(&built, OriginKind::NpmMetadata),
Ok(()),
"`{name}` built a URL the second gate refuses"
);
assert!(
!built.path().contains("//evil") && !built.as_str().contains("://evil"),
"`{name}` left an unencoded authority in {built}"
);
}
}
let origin = parse("https://registry.npmjs.org/");
assert_eq!(
origin.join("//evil.test").expect("join parses").host_str(),
Some("evil.test"),
"this is the footgun the construction rule exists to avoid"
);
assert_eq!(
origin
.join("https://evil.test/left-pad")
.expect("join parses")
.host_str(),
Some("evil.test")
);
}
#[tokio::test]
async fn the_npm_route_reports_upstream_outcomes_apart() {
use common::{FakeAnswer, FakeRegistry};
let registry = FakeRegistry::new();
registry.answer("/slow", FakeAnswer::Fail(UpstreamError::Timeout));
registry.answer("/broken", FakeAnswer::Fail(UpstreamError::Status(500)));
registry.answer("/garbage", FakeAnswer::Body("not json".to_owned()));
registry.answer("/fine", FakeAnswer::Body(r#"{"name":"fine"}"#.to_owned()));
let blocklist_dir = tempfile::tempdir().expect("a blocklist directory");
let server = TestServer::start_with_upstream(
config_with_open_blocklist(blocklist_dir.path()),
Arc::new(probation::clock::SystemClock),
Arc::clone(®istry) as Arc<dyn Transport>,
fake_origins(),
)
.await;
assert_eq!(
server.status("/npm/slow").await,
504,
"a timeout is its own row"
);
assert_eq!(server.status("/npm/broken").await, 502);
assert_eq!(server.status("/npm/garbage").await, 502);
assert_eq!(
server.status("/npm/missing").await,
404,
"upstream not having the package is a 404, not a 502"
);
assert_eq!(server.status("/npm/fine").await, 404);
let asked: Vec<String> = registry
.calls()
.iter()
.map(|url| url.path().to_owned())
.collect();
assert_eq!(asked, ["/slow", "/broken", "/garbage", "/missing", "/fine"]);
server.shutdown().await;
}