Skip to content

Commit 4e257e4

Browse files
YESHYUNGSEOKclaude
andcommitted
fix(pnpr): block link-local redirects and trailing-dot metadata hosts
Addresses the review on this PR: - The request-boundary check only validated the original registry URL, but the metadata-fetch client follows redirects: a registry on an allowed host could 302-redirect a server-side fetch to a link-local / instance-metadata host and still reach IMDS. Add a reqwest redirect policy that denies redirects whose target host is blocked (legitimate redirects to other hosts still follow, up to the default depth). - Move the blocked-host predicate into pacquet-network as `is_blocked_request_host`, the single source shared by the redirect policy and pnpr's `reject_blocked_registries` boundary check, so the two can't drift. - Normalize a trailing dot on a domain host so the root-zone FQDN form `metadata.google.internal.` can't bypass the metadata-host match. Tests cover the link-local IPv4/IPv6, IPv4-mapped, metadata-hostname, and trailing-dot cases, plus the allowed public/private/loopback hosts. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1c5e7d8 commit 4e257e4

6 files changed

Lines changed: 88 additions & 26 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pacquet/crates/network/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pipe-trait = { workspace = true }
1717
reqwest = { workspace = true }
1818
tokio = { workspace = true, features = ["time"] }
1919
tracing = { workspace = true }
20+
url = { workspace = true }
2021

2122
[dev-dependencies]
2223
mockito = { workspace = true }

pacquet/crates/network/src/lib.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,6 +421,54 @@ fn default_client_builder(settings: &NetworkSettings) -> reqwest::ClientBuilder
421421
.timeout(settings.fetch_timeout)
422422
.pool_idle_timeout(Duration::from_secs(4))
423423
.hickory_dns(true)
424+
// Reject redirects to link-local / instance-metadata hosts: a
425+
// (client-supplied) registry that 302-redirects there must not be
426+
// able to turn a server-side metadata fetch into SSRF. Legitimate
427+
// redirects to other hosts still follow, up to the default depth.
428+
.redirect(reqwest::redirect::Policy::custom(|attempt| {
429+
if attempt.previous().len() >= 10 {
430+
attempt.error("too many redirects")
431+
} else if is_blocked_request_host(attempt.url()) {
432+
attempt.error("redirect to a link-local or instance-metadata host is not allowed")
433+
} else {
434+
attempt.follow()
435+
}
436+
}))
437+
}
438+
439+
/// Hostnames that resolve into the link-local instance-metadata range but
440+
/// don't look like a link-local address. Kept tiny and explicit — the IP
441+
/// checks in [`is_blocked_request_host`] cover the addresses themselves.
442+
const BLOCKED_REQUEST_HOSTS: &[&str] = &["metadata.google.internal"];
443+
444+
/// Whether a URL targets a host the install / resolver clients must never
445+
/// connect to: the link-local range that fronts cloud instance metadata
446+
/// (`169.254.0.0/16`, `fe80::/10`, and IPv4-mapped link-local addresses)
447+
/// plus the well-known metadata hostnames that resolve into it.
448+
///
449+
/// Used both as a request-boundary check (on a URL a client supplies) and
450+
/// as the client redirect policy in `default_client_builder`, so a
451+
/// redirect can't reach a blocked host that a boundary check on the
452+
/// *original* URL would miss (e.g. an allowed host that `302`s to
453+
/// `http://169.254.169.254/`). A trailing dot on a domain
454+
/// (`metadata.google.internal.`) is normalized away before matching.
455+
/// Private and loopback addresses are deliberately allowed — resolving
456+
/// against an internal registry (or a loopback dev registry) is legitimate.
457+
#[must_use]
458+
pub fn is_blocked_request_host(url: &url::Url) -> bool {
459+
match url.host() {
460+
Some(url::Host::Ipv4(addr)) => addr.is_link_local(),
461+
Some(url::Host::Ipv6(addr)) => {
462+
// fe80::/10, plus any IPv4-mapped form of a link-local v4 address.
463+
(addr.segments()[0] & 0xffc0) == 0xfe80
464+
|| addr.to_ipv4_mapped().is_some_and(|v4| v4.is_link_local())
465+
}
466+
Some(url::Host::Domain(host)) => {
467+
let host = host.strip_suffix('.').unwrap_or(host);
468+
BLOCKED_REQUEST_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
469+
}
470+
None => false,
471+
}
424472
}
425473

426474
/// Load the PEM bundle named by `NODE_EXTRA_CA_CERTS` as extra trust

pacquet/crates/network/src/tests.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -726,3 +726,30 @@ fn default_network_concurrency_stays_within_floor_and_cap() {
726726
let concurrency = super::default_network_concurrency();
727727
assert!((64..=96).contains(&concurrency), "got {concurrency}");
728728
}
729+
730+
/// [`super::is_blocked_request_host`] blocks the link-local instance-metadata
731+
/// range and well-known metadata hostnames (including the trailing-dot FQDN
732+
/// form), while allowing public, private, and loopback registries. This is
733+
/// the predicate the install client's redirect policy and pnpr's
734+
/// request-boundary check share.
735+
#[test]
736+
fn is_blocked_request_host_blocks_link_local_and_metadata_only() {
737+
let blocked = |u: &str| super::is_blocked_request_host(&url::Url::parse(u).unwrap());
738+
739+
// Link-local IPv4 (cloud instance metadata) and the range around it.
740+
assert!(blocked("http://169.254.169.254/"));
741+
assert!(blocked("http://169.254.0.1:8080/path/"));
742+
// Link-local IPv6 (fe80::/10) and an IPv4-mapped link-local address.
743+
assert!(blocked("http://[fe80::1]/"));
744+
assert!(blocked("http://[::ffff:169.254.169.254]/"));
745+
// Metadata hostname: case-insensitive and trailing-dot (root-zone) FQDN.
746+
assert!(blocked("https://metadata.google.internal/"));
747+
assert!(blocked("https://Metadata.Google.Internal/"));
748+
assert!(blocked("https://metadata.google.internal./"));
749+
750+
// Public, private, and loopback hosts are allowed.
751+
assert!(!blocked("https://registry.npmjs.org/"));
752+
assert!(!blocked("http://10.0.0.5:4873/"));
753+
assert!(!blocked("http://192.168.1.10/"));
754+
assert!(!blocked("http://127.0.0.1:4873/"));
755+
}

pnpr/crates/pnpr/src/resolver.rs

Lines changed: 9 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -202,33 +202,16 @@ impl Resolver {
202202
/// Hostnames that resolve into the link-local instance-metadata range but
203203
/// don't look like a link-local address. Kept tiny and explicit — the IP
204204
/// check below covers the addresses themselves.
205-
const BLOCKED_METADATA_HOSTS: &[&str] = &["metadata.google.internal"];
206-
207-
/// Whether a client-supplied registry URL points at a host the resolver
208-
/// must never fetch from: the link-local range that fronts cloud instance
209-
/// metadata (`169.254.0.0/16`, `fe80::/10`) plus the well-known metadata
210-
/// hostnames that resolve into it. Private and loopback addresses are
211-
/// deliberately *allowed* — resolving against an internal registry is
212-
/// pnpr's core use case. This is a request-boundary check on the URL the
213-
/// client sends; a hostname that only *resolves* to a link-local address
214-
/// at connect time (DNS rebinding) is out of scope here. A value that
215-
/// doesn't parse as a URL can't drive a fetch, so it isn't blocked.
205+
/// Whether a client-supplied registry URL string points at a host the
206+
/// resolver must never fetch from. Delegates to
207+
/// [`pacquet_network::is_blocked_request_host`] so this request-boundary
208+
/// check and the install client's redirect policy share one definition —
209+
/// an attacker can't slip past the boundary by pointing `registry` at an
210+
/// allowed host that redirects to a link-local / instance-metadata host.
211+
/// A value that doesn't parse as a URL can't drive a fetch, so it isn't
212+
/// blocked here.
216213
fn is_blocked_registry_host(registry: &str) -> bool {
217-
let Ok(url) = url::Url::parse(registry) else {
218-
return false;
219-
};
220-
match url.host() {
221-
Some(url::Host::Ipv4(addr)) => addr.is_link_local(),
222-
Some(url::Host::Ipv6(addr)) => {
223-
// fe80::/10, plus any IPv4-mapped form of a link-local v4 address.
224-
(addr.segments()[0] & 0xffc0) == 0xfe80
225-
|| addr.to_ipv4_mapped().is_some_and(|v4| v4.is_link_local())
226-
}
227-
Some(url::Host::Domain(host)) => {
228-
BLOCKED_METADATA_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
229-
}
230-
None => false,
231-
}
214+
url::Url::parse(registry).is_ok_and(|url| pacquet_network::is_blocked_request_host(&url))
232215
}
233216

234217
/// Reject (as `400`) a request that would point the resolver's

pnpr/crates/pnpr/src/resolver/tests.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,8 @@ fn link_local_and_metadata_registries_are_blocked_but_private_ones_are_not() {
218218
assert!(is_blocked_registry_host("http://[::ffff:169.254.169.254]/"));
219219
// Well-known metadata hostname, case-insensitively.
220220
assert!(is_blocked_registry_host("https://Metadata.Google.Internal/"));
221+
// A trailing dot (root-zone FQDN) must not bypass the metadata-host match.
222+
assert!(is_blocked_registry_host("https://metadata.google.internal./"));
221223

222224
// Private and loopback registries are deliberately allowed — resolving
223225
// against an internal registry is pnpr's core use case.

0 commit comments

Comments
 (0)