Skip to content

fix(pnpr): reject link-local registry hosts to block resolver SSRF#12675

Closed
YESHYUNGSEOK wants to merge 4 commits into
pnpm:mainfrom
YESHYUNGSEOK:fix/pnpr-resolver-block-link-local
Closed

fix(pnpr): reject link-local registry hosts to block resolver SSRF#12675
YESHYUNGSEOK wants to merge 4 commits into
pnpm:mainfrom
YESHYUNGSEOK:fix/pnpr-resolver-block-link-local

Conversation

@YESHYUNGSEOK

@YESHYUNGSEOK YESHYUNGSEOK commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Summary

To shape a lockfile, POST /-/pnpr/v0/resolve and
POST /-/pnpr/v0/verify-lockfile fetch package metadata server-side
from the registry the client supplies — the registry default and every
namedRegistries alias. Those URLs were used verbatim, so an
authenticated caller could point them at the link-local range that fronts
cloud instance metadata (e.g. http://169.254.169.254/) and make the
server issue requests from its own network position — SSRF, including the
classic IMDS credential-theft target.

This rejects, at the request boundary, any registry whose host is:

  • a link-local IPv4 address (169.254.0.0/16),
  • a link-local IPv6 address (fe80::/10), or an IPv4-mapped form of a
    link-local v4 address, or
  • a well-known metadata hostname (metadata.google.internal).

Private and loopback addresses are deliberately still allowed
resolving against an internal registry (often on 10.x/192.168.x or
behind localhost in dev) is pnpr's core use case, so blanket private-IP
blocking would break legitimate deployments.

Scope / residual risk

This is a check on the URL string the client sends. A hostname that
resolves to a link-local address only at connect time (DNS rebinding)
is not covered here — closing that needs a connect-time guard in the
HTTP client's connector, which is a larger change left for a follow-up.
The check is applied before any server-side fetch on both endpoints.

Squash Commit Body

The resolve and verify-lockfile endpoints fetch package metadata
server-side from the registry the client sends (registry and the
namedRegistries aliases). Those URLs were used verbatim, so an
authenticated caller could point them at the link-local range that fronts
cloud instance metadata (e.g. http://169.254.169.254/) and make the
server issue requests from its own network position (SSRF).

Reject, at the request boundary, any registry whose host is a link-local
address (169.254.0.0/16, fe80::/10, or an IPv4-mapped link-local
address) or a well-known metadata hostname. Private and loopback
addresses stay allowed: resolving against an internal registry is pnpr's
core use case.

This is a boundary check on the URL the client sends; a hostname that
only resolves to a link-local address at connect time (DNS rebinding) is
not covered and would need a connect-time guard in the HTTP client.

Checklist

  • Added or updated tests.
  • Updated the documentation if needed (doc comments on the new guard).

Written by an agent (Claude Code, claude-opus-4-8).

Summary by CodeRabbit

  • Bug Fixes
    • Blocked client-supplied registry hosts that target link-local or instance-metadata address ranges, validating during both resolution and lockfile verification.
    • Prevented registry-controlled redirects to blocked link-local/instance-metadata destinations.
    • When blocked, the API now returns a generic bad-request error without echoing sensitive registry URL details.
  • Tests
    • Added resolver and network coverage for blocked vs. allowed hosts, including case-insensitive and trailing-dot forms, plus redirect rejection behavior.

To shape a lockfile, the resolve and verify-lockfile endpoints fetch
package metadata server-side from the registry the client sends
(`registry` and the `namedRegistries` aliases). Those URLs were used
verbatim, so an authenticated caller could point them at the link-local
range that fronts cloud instance metadata — e.g.
`http://169.254.169.254/` — and make the server issue requests from its
own network position (SSRF).

Reject, at the request boundary, any registry whose host is a link-local
address (`169.254.0.0/16`, `fe80::/10`, or an IPv4-mapped link-local
address) or a well-known metadata hostname. Private and loopback
addresses stay allowed on purpose: resolving against an internal
registry is pnpr's core use case, so blanket private-IP blocking would
break legitimate setups.

This is a boundary check on the URL the client sends; a hostname that
only resolves to a link-local address at connect time (DNS rebinding) is
not covered and would need a connect-time guard in the HTTP client.

Co-authored-by: Claude <noreply@anthropic.com>
@YESHYUNGSEOK YESHYUNGSEOK requested a review from zkochan as a code owner June 26, 2026 01:52
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 592ceb3d-3ff5-4421-9eed-43c65454419e

📥 Commits

Reviewing files that changed from the base of the PR and between 8cf21e1 and 74fff66.

📒 Files selected for processing (1)
  • pacquet/crates/network/src/tests.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • pacquet/crates/network/src/tests.rs

📝 Walkthrough

Walkthrough

The resolver now rejects requests whose supplied registries point to link-local or metadata hosts. The shared network client also blocks redirects to the same host set, and tests cover blocked and allowed host cases.

Changes

Registry host blocking

Layer / File(s) Summary
Blocked-host validation
pacquet/crates/network/Cargo.toml, pacquet/crates/network/src/lib.rs, pacquet/crates/network/src/tests.rs
Adds URL parsing support, blocks redirects to link-local and metadata hosts, and tests blocked versus allowed host cases.
Request guards and tests
pnpr/crates/pnpr/src/resolver.rs, pnpr/crates/pnpr/src/resolver/tests.rs
Rejects blocked registries in resolve and lockfile verification, and tests default, named, blocked, and allowed registry inputs.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related issues

Suggested labels

product: pnpr

Suggested reviewers

  • zkochan
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Block link-local registry hosts in pnpr resolver to prevent SSRF
🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

Description

• Reject client-supplied registries targeting link-local/IMDS hosts to prevent SSRF.
• Apply the guard to both resolve and verify-lockfile request paths.
• Add unit tests for link-local/metadata blocking and private/loopback allowlisting.
Diagram

graph TD
  C[Client] --> API["resolver handlers"] --> G["reject_blocked_registries"] --> D{{"Blocked host?"}}
  D -->|"Yes"| E["400 JSON error"]
  D -->|"No"| P["pacquet resolve/verify"] --> R[("Registry metadata")]

  subgraph Legend
    direction LR
    _proc["Process"] ~~~ _dec{{"Decision"}} ~~~ _db[("External system")]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Connect-time IP filtering in the HTTP connector
  • ➕ Covers DNS rebinding and hostnames that resolve to link-local at connect time
  • ➕ Centralizes SSRF protections for all outbound requests made by the resolver
  • ➖ Larger change surface (client/connector plumbing), higher regression risk
  • ➖ More complex to implement correctly with redirects, proxies, and IPv6 nuances
2. Server-configured registry allowlist (ignore client registries)
  • ➕ Strongest SSRF containment by removing attacker-controlled destinations
  • ➕ Operationally simple in locked-down deployments
  • ➖ Breaks pnpr’s core model of resolving against the client’s configured registries
  • ➖ Harder to support multi-registry and per-scope registries across teams
3. Resolve hostname to IP and block link-local pre-request
  • ➕ Blocks many hostname-based link-local targets without connector changes
  • ➕ Can be implemented as an extension of the current boundary validation
  • ➖ Still incomplete without connect-time enforcement (rebinding between resolve/connect)
  • ➖ Introduces DNS dependency and caching/TTL correctness concerns

Recommendation: Keep this PR’s request-boundary blocking as an immediate, low-risk mitigation (it blocks the classic IMDS link-local targets and a known metadata hostname while preserving private/loopback registries). Follow up with connect-time IP filtering in the outbound HTTP connector to address DNS rebinding and any hostname->link-local resolution at connect time.

Files changed (2) +108 / -0

Bug fix (1) +57 / -0
resolver.rsBlock link-local/metadata registry hosts before resolver fetches +57/-0

Block link-local/metadata registry hosts before resolver fetches

• Introduces registry-host validation that rejects link-local IPv4/IPv6 (including IPv4-mapped IPv6) and a known metadata hostname. Applies the guard to both /resolve and /verify-lockfile handlers and returns a generic 400 to avoid echoing credential-bearing URLs.

pnpr/crates/pnpr/src/resolver.rs

Tests (1) +51 / -0
tests.rsAdd tests for blocked link-local registries and allowed private hosts +51/-0

Add tests for blocked link-local registries and allowed private hosts

• Adds unit tests covering link-local IPv4/IPv6, IPv4-mapped IPv6, and metadata hostname blocking, plus explicit allowlisting of private and loopback registries. Verifies the guard checks both the default registry and named registries.

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

@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Jun 26, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Redirect SSRF bypass ✓ Resolved 🐞 Bug ⛨ Security
Description
reject_blocked_registries only validates the user-supplied registry URL string, but registry
metadata fetches use a redirect-following reqwest client; an attacker can point
registry/namedRegistries at an allowed host they control that 302-redirects to a blocked
link-local/metadata host, causing the server to still connect to IMDS. This defeats the intended
SSRF mitigation for both /resolve and /verify-lockfile.
Code

pnpr/crates/pnpr/src/resolver.rs[R216-251]

+fn is_blocked_registry_host(registry: &str) -> bool {
+    let Ok(url) = url::Url::parse(registry) else {
+        return false;
+    };
+    match url.host() {
+        Some(url::Host::Ipv4(addr)) => addr.is_link_local(),
+        Some(url::Host::Ipv6(addr)) => {
+            // fe80::/10, plus any IPv4-mapped form of a link-local v4 address.
+            (addr.segments()[0] & 0xffc0) == 0xfe80
+                || addr.to_ipv4_mapped().is_some_and(|v4| v4.is_link_local())
+        }
+        Some(url::Host::Domain(host)) => {
+            BLOCKED_METADATA_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
+        }
+        None => false,
+    }
+}
+
+/// Reject (as `400`) a request that would point the resolver's
+/// server-side metadata fetches at a blocked host — see
+/// [`is_blocked_registry_host`]. Both the default registry and every
+/// named-registry alias are checked. The message stays generic so a
+/// registry URL carrying credentials isn't echoed back.
+fn reject_blocked_registries(request: &ResolveRequest) -> Option<Response> {
+    let registries =
+        request.registry.iter().chain(request.named_registries.values()).map(String::as_str);
+    for registry in registries {
+        if is_blocked_registry_host(registry) {
+            return Some(json_error(
+                StatusCode::BAD_REQUEST,
+                "registry host is not allowed (link-local and instance-metadata addresses are blocked)",
+            ));
+        }
+    }
+    None
+}
Evidence
The PR adds only a request-boundary host check (is_blocked_registry_host /
reject_blocked_registries) and does not account for redirect targets. The shared HTTP client
builder does not set any redirect policy, and other code paths rely on reqwest following redirects
(e.g., tarball resolution reads response.url() as the post-redirect URL), indicating redirects are
followed in practice; metadata fetches use the same client via client.get(&url).

pnpr/crates/pnpr/src/resolver.rs[202-251]
pacquet/crates/network/src/lib.rs[405-424]
pacquet/crates/resolving-tarball-resolver/src/tarball_resolver.rs[119-147]
pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs[90-111]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new guard (`reject_blocked_registries`/`is_blocked_registry_host`) only checks the original registry URL the client sends. However, the HTTP client used for server-side metadata fetches follows redirects, so an attacker-controlled registry can 302/307 to a link-local/metadata host and still trigger SSRF.
## Issue Context
Because reqwest redirect-follow happens *during* the request, validating only the initial URL at the request boundary is insufficient; the redirect target must be rejected before the client issues the follow-up request.
## Fix Focus Areas
- pacquet/crates/network/src/lib.rs[405-424]
- pnpr/crates/pnpr/src/resolver.rs[202-251]
- pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs[90-111]
### Implementation sketch
- Add a reqwest redirect policy on the shared client builder (or on a dedicated “registry-metadata client”) using `reqwest::redirect::Policy::custom`.
- In the policy callback, inspect the *next* URL (`attempt.url()`) and **deny** the redirect if its host is link-local / instance-metadata (use the same logic as `is_blocked_registry_host`, ideally refactored into a shared helper that takes a `Url`/`Host`).
- Ensure this blocks both IPv4/IPv6 link-local and the metadata hostname(s), without breaking legitimate non-blocked redirects.
- Add a test that sets `registry` to an attacker-controlled URL that redirects to `http://169.254.169.254/` and assert the resolver/verify request is rejected (or fails closed without issuing the redirect-follow request).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

2. Metadata shortname unblocked 🐞 Bug ⛨ Security
Description
pacquet_network::is_blocked_request_host only blocks the exact domain metadata.google.internal,
so a registry URL with host metadata is not rejected by either pnpr’s request-boundary check or
the shared redirect policy. In deployments where DNS/search domains resolve metadata to the
instance-metadata service, this can re-enable SSRF to IMDS despite the new guard.
Code

pacquet/crates/network/src/lib.rs[R442-469]

+const BLOCKED_REQUEST_HOSTS: &[&str] = &["metadata.google.internal"];
+
+/// Whether a URL targets a host the install / resolver clients must never
+/// connect to: the link-local range that fronts cloud instance metadata
+/// (`169.254.0.0/16`, `fe80::/10`, and IPv4-mapped link-local addresses)
+/// plus the well-known metadata hostnames that resolve into it.
+///
+/// Used both as a request-boundary check (on a URL a client supplies) and
+/// as the client redirect policy in `default_client_builder`, so a
+/// redirect can't reach a blocked host that a boundary check on the
+/// *original* URL would miss (e.g. an allowed host that `302`s to
+/// `http://169.254.169.254/`). A trailing dot on a domain
+/// (`metadata.google.internal.`) is normalized away before matching.
+/// Private and loopback addresses are deliberately allowed — resolving
+/// against an internal registry (or a loopback dev registry) is legitimate.
+#[must_use]
+pub fn is_blocked_request_host(url: &url::Url) -> bool {
+    match url.host() {
+        Some(url::Host::Ipv4(addr)) => addr.is_link_local(),
+        Some(url::Host::Ipv6(addr)) => {
+            // fe80::/10, plus any IPv4-mapped form of a link-local v4 address.
+            (addr.segments()[0] & 0xffc0) == 0xfe80
+                || addr.to_ipv4_mapped().is_some_and(|v4| v4.is_link_local())
+        }
+        Some(url::Host::Domain(host)) => {
+            let host = host.strip_suffix('.').unwrap_or(host);
+            BLOCKED_REQUEST_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
+        }
Evidence
The blocklist constant contains only metadata.google.internal, and domain blocking is performed by
an equality check against that list, so metadata will not match. pnpr’s boundary check delegates
to this predicate, so the same hostname gap applies at the request boundary too.

pacquet/crates/network/src/lib.rs[439-471]
pnpr/crates/pnpr/src/resolver.rs[202-215]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`is_blocked_request_host` only matches domain hosts against a tiny exact blocklist (`metadata.google.internal`). This leaves `metadata` (a common shortname used in some environments via DNS search domains) unblocked, which can undermine the intended “block instance metadata hostnames” protection.
### Issue Context
- The blocklist is intended to cover metadata hostnames that don’t “look like” link-local IPs.
- pnpr’s request-boundary check delegates to `pacquet_network::is_blocked_request_host`, so any hostname gap affects both `/resolve` and `/verify-lockfile`.
### Fix Focus Areas
- pacquet/crates/network/src/lib.rs[442-469]
- pacquet/crates/network/src/tests.rs[729-756]
- pnpr/crates/pnpr/src/resolver/tests.rs[208-232]
### Suggested change
- Add `"metadata"` to `BLOCKED_REQUEST_HOSTS`.
- Add test coverage asserting `https://metadata/` is blocked (both in pacquet_network and pnpr resolver tests).
- Keep comparisons case-insensitive and retain trailing-dot normalization.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Incomplete dot normalization 🐞 Bug ⛨ Security
Description
pacquet_network::is_blocked_request_host only strips one trailing '.' from domain hosts before
comparing to BLOCKED_REQUEST_HOSTS, so inputs ending with multiple trailing dots will not match
the metadata hostname blocklist. Because pnpr’s boundary check and pacquet’s redirect policy both
delegate to this predicate, any missed match here weakens the intended SSRF protection for metadata
hostnames.
Code

pacquet/crates/network/src/lib.rs[R466-468]

+        Some(url::Host::Domain(host)) => {
+            let host = host.strip_suffix('.').unwrap_or(host);
+            BLOCKED_REQUEST_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
Evidence
The blocklist match for domain hosts only removes a single trailing dot before comparing to
BLOCKED_REQUEST_HOSTS, so any domain string ending with more than one dot will not be normalized
to the blocked hostname. pnpr’s registry-host rejection explicitly delegates to this shared
predicate, and pacquet’s redirect policy also uses it, so the shared normalization behavior directly
affects SSRF defenses in both places.

pacquet/crates/network/src/lib.rs[424-436]
pacquet/crates/network/src/lib.rs[458-471]
pacquet/crates/network/src/tests.rs[730-755]
pnpr/crates/pnpr/src/resolver.rs[205-215]
pnpr/crates/pnpr/src/resolver.rs[217-233]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`is_blocked_request_host` normalizes `metadata.google.internal.` by stripping a single trailing dot, but it does not normalize domain hosts with **multiple** trailing dots (e.g. `metadata.google.internal..`). This can cause the metadata-hostname blocklist check to miss equivalent root-terminated forms.
### Issue Context
This predicate is security-sensitive and is shared by:
- pacquet’s reqwest redirect policy (to prevent redirect-based SSRF)
- pnpr’s request-boundary validation of client-supplied registry URLs
### Fix Focus Areas
- pacquet/crates/network/src/lib.rs[466-469]
- pacquet/crates/network/src/tests.rs[730-755]
### Suggested fix
- Replace `strip_suffix('.')` with `trim_end_matches('.')` (or an equivalent loop) so *all* trailing dots are removed before comparison.
- Add a unit test case asserting the double-trailing-dot form is blocked (e.g. `https://metadata.google.internal../`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

4. Misleading guard doc comment ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The doc comment above is_blocked_registry_host describes a local hostname blocklist and an IP
check “below”, but the function actually delegates all logic to
pacquet_network::is_blocked_request_host. This mismatch can mislead future edits in this
security-sensitive area.
Code

pnpr/crates/pnpr/src/resolver.rs[R202-215]

+/// Hostnames that resolve into the link-local instance-metadata range but
+/// don't look like a link-local address. Kept tiny and explicit — the IP
+/// check below covers the addresses themselves.
+/// Whether a client-supplied registry URL string points at a host the
+/// resolver must never fetch from. Delegates to
+/// [`pacquet_network::is_blocked_request_host`] so this request-boundary
+/// check and the install client's redirect policy share one definition —
+/// an attacker can't slip past the boundary by pointing `registry` at an
+/// allowed host that redirects to a link-local / instance-metadata host.
+/// A value that doesn't parse as a URL can't drive a fetch, so it isn't
+/// blocked here.
+fn is_blocked_registry_host(registry: &str) -> bool {
+    url::Url::parse(registry).is_ok_and(|url| pacquet_network::is_blocked_request_host(&url))
+}
Evidence
The added comment claims local logic (“hostnames … kept tiny … the IP check below”), but the
implementation is a thin wrapper around pacquet_network::is_blocked_request_host.

pnpr/crates/pnpr/src/resolver.rs[202-215]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pnpr::resolver::is_blocked_registry_host` delegates to `pacquet_network::is_blocked_request_host`, but its doc comment currently reads like this file owns a hostname blocklist and an IP check. This is misleading in a security guard path.
### Issue Context
This comment sits immediately above the request-boundary SSRF validation.
### Fix Focus Areas
- pnpr/crates/pnpr/src/resolver.rs[202-215]
### Suggested fix
Update the comment to explicitly say the predicate delegates to `pacquet_network::is_blocked_request_host` (where the hostname list and IP logic live), or remove the stale sentences about a local hostname list/IP check.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Brittle redirect test assertion 🐞 Bug ☼ Reliability
Description
The new redirect-policy test asserts on format!("{err:?}") containing a substring; Debug
formatting for reqwest::Error is not a stable contract and may change on dependency upgrades. This
can cause test-only failures even if the redirect policy behavior is still correct.
Code

pacquet/crates/network/src/tests.rs[R781-783]

+    let debug = format!("{err:?}");
+    eprintln!("err={debug}");
+    assert!(debug.contains("link-local or instance-metadata host"), "err={debug}");
Evidence
The test currently formats the error with {:?} and matches a substring, coupling the test to debug
formatting rather than a stable error kind or API surface.

pacquet/crates/network/src/tests.rs[773-785]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test `redirect_to_a_link_local_host_is_rejected_by_the_client` checks the `Debug` string of a `reqwest::Error` for a substring. This is brittle because `Debug` output can change even when behavior is correct.
### Issue Context
This is an end-to-end test ensuring the custom redirect policy rejects redirects to blocked hosts.
### Fix Focus Areas
- pacquet/crates/network/src/tests.rs[773-784]
### Suggested fix
Prefer assertions on more stable signals, e.g.:
- `assert!(err.is_redirect())` (reqwest provides helpers for error kinds), and/or
- check `err.to_string()` (still stringly, but typically more stable than `Debug`), and/or
- if available, assert on an underlying source/type rather than formatting.
Keep the existing behavioral assertion that the initial mock endpoint was hit.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Previous review results

Review updated until commit 74fff66

Results up to commit 74fff66


🐞 Bugs (3) 📘 Rule violations (0) 📜 Skill insights (0)


Action required
1. Redirect SSRF bypass ✓ Resolved 🐞 Bug ⛨ Security
Description
reject_blocked_registries only validates the user-supplied registry URL string, but registry
metadata fetches use a redirect-following reqwest client; an attacker can point
registry/namedRegistries at an allowed host they control that 302-redirects to a blocked
link-local/metadata host, causing the server to still connect to IMDS. This defeats the intended
SSRF mitigation for both /resolve and /verify-lockfile.
Code

pnpr/crates/pnpr/src/resolver.rs[R216-251]

+fn is_blocked_registry_host(registry: &str) -> bool {
+    let Ok(url) = url::Url::parse(registry) else {
+        return false;
+    };
+    match url.host() {
+        Some(url::Host::Ipv4(addr)) => addr.is_link_local(),
+        Some(url::Host::Ipv6(addr)) => {
+            // fe80::/10, plus any IPv4-mapped form of a link-local v4 address.
+            (addr.segments()[0] & 0xffc0) == 0xfe80
+                || addr.to_ipv4_mapped().is_some_and(|v4| v4.is_link_local())
+        }
+        Some(url::Host::Domain(host)) => {
+            BLOCKED_METADATA_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
+        }
+        None => false,
+    }
+}
+
+/// Reject (as `400`) a request that would point the resolver's
+/// server-side metadata fetches at a blocked host — see
+/// [`is_blocked_registry_host`]. Both the default registry and every
+/// named-registry alias are checked. The message stays generic so a
+/// registry URL carrying credentials isn't echoed back.
+fn reject_blocked_registries(request: &ResolveRequest) -> Option<Response> {
+    let registries =
+        request.registry.iter().chain(request.named_registries.values()).map(String::as_str);
+    for registry in registries {
+        if is_blocked_registry_host(registry) {
+            return Some(json_error(
+                StatusCode::BAD_REQUEST,
+                "registry host is not allowed (link-local and instance-metadata addresses are blocked)",
+            ));
+        }
+    }
+    None
+}
Evidence
The PR adds only a request-boundary host check (is_blocked_registry_host /
reject_blocked_registries) and does not account for redirect targets. The shared HTTP client
builder does not set any redirect policy, and other code paths rely on reqwest following redirects
(e.g., tarball resolution reads response.url() as the post-redirect URL), indicating redirects are
followed in practice; metadata fetches use the same client via client.get(&url).

pnpr/crates/pnpr/src/resolver.rs[202-251]
pacquet/crates/network/src/lib.rs[405-424]
pacquet/crates/resolving-tarball-resolver/src/tarball_resolver.rs[119-147]
pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs[90-111]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new guard (`reject_blocked_registries`/`is_blocked_registry_host`) only checks the original registry URL the client sends. However, the HTTP client used for server-side metadata fetches follows redirects, so an attacker-controlled registry can 302/307 to a link-local/metadata host and still trigger SSRF.
## Issue Context
Because reqwest redirect-follow happens *during* the request, validating only the initial URL at the request boundary is insufficient; the redirect target must be rejected before the client issues the follow-up request.
## Fix Focus Areas
- pacquet/crates/network/src/lib.rs[405-424]
- pnpr/crates/pnpr/src/resolver.rs[202-251]
- pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs[90-111]
### Implementation sketch
- Add a reqwest redirect policy on the shared client builder (or on a dedicated “registry-metadata client”) using `reqwest::redirect::Policy::custom`.
- In the policy callback, inspect the *next* URL (`attempt.url()`) and **deny** the redirect if its host is link-local / instance-metadata (use the same logic as `is_blocked_registry_host`, ideally refactored into a shared helper that takes a `Url`/`Host`).
- Ensure this blocks both IPv4/IPv6 link-local and the metadata hostname(s), without breaking legitimate non-blocked redirects.
- Add a test that sets `registry` to an attacker-controlled URL that redirects to `http://169.254.169.254/` and assert the resolver/verify request is rejected (or fails closed without issuing the redirect-follow request).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. Metadata shortname unblocked 🐞 Bug ⛨ Security ⭐ New
Description
pacquet_network::is_blocked_request_host only blocks the exact domain metadata.google.internal,
so a registry URL with host metadata is not rejected by either pnpr’s request-boundary check or
the shared redirect policy. In deployments where DNS/search domains resolve metadata to the
instance-metadata service, this can re-enable SSRF to IMDS despite the new guard.
Code

pacquet/crates/network/src/lib.rs[R442-469]

+const BLOCKED_REQUEST_HOSTS: &[&str] = &["metadata.google.internal"];
+
+/// Whether a URL targets a host the install / resolver clients must never
+/// connect to: the link-local range that fronts cloud instance metadata
+/// (`169.254.0.0/16`, `fe80::/10`, and IPv4-mapped link-local addresses)
+/// plus the well-known metadata hostnames that resolve into it.
+///
+/// Used both as a request-boundary check (on a URL a client supplies) and
+/// as the client redirect policy in `default_client_builder`, so a
+/// redirect can't reach a blocked host that a boundary check on the
+/// *original* URL would miss (e.g. an allowed host that `302`s to
+/// `http://169.254.169.254/`). A trailing dot on a domain
+/// (`metadata.google.internal.`) is normalized away before matching.
+/// Private and loopback addresses are deliberately allowed — resolving
+/// against an internal registry (or a loopback dev registry) is legitimate.
+#[must_use]
+pub fn is_blocked_request_host(url: &url::Url) -> bool {
+    match url.host() {
+        Some(url::Host::Ipv4(addr)) => addr.is_link_local(),
+        Some(url::Host::Ipv6(addr)) => {
+            // fe80::/10, plus any IPv4-mapped form of a link-local v4 address.
+            (addr.segments()[0] & 0xffc0) == 0xfe80
+                || addr.to_ipv4_mapped().is_some_and(|v4| v4.is_link_local())
+        }
+        Some(url::Host::Domain(host)) => {
+            let host = host.strip_suffix('.').unwrap_or(host);
+            BLOCKED_REQUEST_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
+        }
Evidence
The blocklist constant contains only metadata.google.internal, and domain blocking is performed by
an equality check against that list, so metadata will not match. pnpr’s boundary check delegates
to this predicate, so the same hostname gap applies at the request boundary too.

pacquet/crates/network/src/lib.rs[439-471]
pnpr/crates/pnpr/src/resolver.rs[202-215]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`is_blocked_request_host` only matches domain hosts against a tiny exact blocklist (`metadata.google.internal`). This leaves `metadata` (a common shortname used in some environments via DNS search domains) unblocked, which can undermine the intended “block instance metadata hostnames” protection.

### Issue Context
- The blocklist is intended to cover metadata hostnames that don’t “look like” link-local IPs.
- pnpr’s request-boundary check delegates to `pacquet_network::is_blocked_request_host`, so any hostname gap affects both `/resolve` and `/verify-lockfile`.

### Fix Focus Areas
- pacquet/crates/network/src/lib.rs[442-469]
- pacquet/crates/network/src/tests.rs[729-756]
- pnpr/crates/pnpr/src/resolver/tests.rs[208-232]

### Suggested change
- Add `"metadata"` to `BLOCKED_REQUEST_HOSTS`.
- Add test coverage asserting `https://metadata/` is blocked (both in pacquet_network and pnpr resolver tests).
- Keep comparisons case-insensitive and retain trailing-dot normalization.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Incomplete dot normalization 🐞 Bug ⛨ Security
Description
pacquet_network::is_blocked_request_host only strips one trailing '.' from domain hosts before
comparing to BLOCKED_REQUEST_HOSTS, so inputs ending with multiple trailing dots will not match
the metadata hostname blocklist. Because pnpr’s boundary check and pacquet’s redirect policy both
delegate to this predicate, any missed match here weakens the intended SSRF protection for metadata
hostnames.
Code

pacquet/crates/network/src/lib.rs[R466-468]

+        Some(url::Host::Domain(host)) => {
+            let host = host.strip_suffix('.').unwrap_or(host);
+            BLOCKED_REQUEST_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
Evidence
The blocklist match for domain hosts only removes a single trailing dot before comparing to
BLOCKED_REQUEST_HOSTS, so any domain string ending with more than one dot will not be normalized
to the blocked hostname. pnpr’s registry-host rejection explicitly delegates to this shared
predicate, and pacquet’s redirect policy also uses it, so the shared normalization behavior directly
affects SSRF defenses in both places.

pacquet/crates/network/src/lib.rs[424-436]
pacquet/crates/network/src/lib.rs[458-471]
pacquet/crates/network/src/tests.rs[730-755]
pnpr/crates/pnpr/src/resolver.rs[205-215]
pnpr/crates/pnpr/src/resolver.rs[217-233]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`is_blocked_request_host` normalizes `metadata.google.internal.` by stripping a single trailing dot, but it does not normalize domain hosts with **multiple** trailing dots (e.g. `metadata.google.internal..`). This can cause the metadata-hostname blocklist check to miss equivalent root-terminated forms.
### Issue Context
This predicate is security-sensitive and is shared by:
- pacquet’s reqwest redirect policy (to prevent redirect-based SSRF)
- pnpr’s request-boundary validation of client-supplied registry URLs
### Fix Focus Areas
- pacquet/crates/network/src/lib.rs[466-469]
- pacquet/crates/network/src/tests.rs[730-755]
### Suggested fix
- Replace `strip_suffix('.')` with `trim_end_matches('.')` (or an equivalent loop) so *all* trailing dots are removed before comparison.
- Add a unit test case asserting the double-trailing-dot form is blocked (e.g. `https://metadata.google.internal../`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational
4. Misleading guard doc comment ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The doc comment above is_blocked_registry_host describes a local hostname blocklist and an IP
check “below”, but the function actually delegates all logic to
pacquet_network::is_blocked_request_host. This mismatch can mislead future edits in this
security-sensitive area.
Code

pnpr/crates/pnpr/src/resolver.rs[R202-215]

+/// Hostnames that resolve into the link-local instance-metadata range but
+/// don't look like a link-local address. Kept tiny and explicit — the IP
+/// check below covers the addresses themselves.
+/// Whether a client-supplied registry URL string points at a host the
+/// resolver must never fetch from. Delegates to
+/// [`pacquet_network::is_blocked_request_host`] so this request-boundary
+/// check and the install client's redirect policy share one definition —
+/// an attacker can't slip past the boundary by pointing `registry` at an
+/// allowed host that redirects to a link-local / instance-metadata host.
+/// A value that doesn't parse as a URL can't drive a fetch, so it isn't
+/// blocked here.
+fn is_blocked_registry_host(registry: &str) -> bool {
+    url::Url::parse(registry).is_ok_and(|url| pacquet_network::is_blocked_request_host(&url))
+}
Evidence
The added comment claims local logic (“hostnames … kept tiny … the IP check below”), but the
implementation is a thin wrapper around pacquet_network::is_blocked_request_host.

pnpr/crates/pnpr/src/resolver.rs[202-215]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pnpr::resolver::is_blocked_registry_host` delegates to `pacquet_network::is_blocked_request_host`, but its doc comment currently reads like this file owns a hostname blocklist and an IP check. This is misleading in a security guard path.
### Issue Context
This comment sits immediately above the request-boundary SSRF validation.
### Fix Focus Areas
- pnpr/crates/pnpr/src/resolver.rs[202-215]
### Suggested fix
Update the comment to explicitly say the predicate delegates to `pacquet_network::is_blocked_request_host` (where the hostname list and IP logic live), or remove the stale sentences about a local hostname list/IP check.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. Brittle redirect test assertion 🐞 Bug ☼ Reliability
Description
The new redirect-policy test asserts on format!("{err:?}") containing a substring; Debug
formatting for reqwest::Error is not a stable contract and may change on dependency upgrades. This
can cause test-only failures even if the redirect policy behavior is still correct.
Code

pacquet/crates/network/src/tests.rs[R781-783]

+    let debug = format!("{err:?}");
+    eprintln!("err={debug}");
+    assert!(debug.contains("link-local or instance-metadata host"), "err={debug}");
Evidence
The test currently formats the error with {:?} and matches a substring, coupling the test to debug
formatting rather than a stable error kind or API surface.

pacquet/crates/network/src/tests.rs[773-785]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test `redirect_to_a_link_local_host_is_rejected_by_the_client` checks the `Debug` string of a `reqwest::Error` for a substring. This is brittle because `Debug` output can change even when behavior is correct.
### Issue Context
This is an end-to-end test ensuring the custom redirect policy rejects redirects to blocked hosts.
### Fix Focus Areas
- pacquet/crates/network/src/tests.rs[773-784]
### Suggested fix
Prefer assertions on more stable signals, e.g.:
- `assert!(err.is_redirect())` (reqwest provides helpers for error kinds), and/or
- check `err.to_string()` (still stringly, but typically more stable than `Debug`), and/or
- if available, assert on an underlying source/type rather than formatting.
Keep the existing behavioral assertion that the initial mock endpoint was hit.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Results up to commit N/A


🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


Action required
1. Redirect SSRF bypass ✓ Resolved 🐞 Bug ⛨ Security
Description
reject_blocked_registries only validates the user-supplied registry URL string, but registry
metadata fetches use a redirect-following reqwest client; an attacker can point
registry/namedRegistries at an allowed host they control that 302-redirects to a blocked
link-local/metadata host, causing the server to still connect to IMDS. This defeats the intended
SSRF mitigation for both /resolve and /verify-lockfile.
Code

pnpr/crates/pnpr/src/resolver.rs[R216-251]

+fn is_blocked_registry_host(registry: &str) -> bool {
+    let Ok(url) = url::Url::parse(registry) else {
+        return false;
+    };
+    match url.host() {
+        Some(url::Host::Ipv4(addr)) => addr.is_link_local(),
+        Some(url::Host::Ipv6(addr)) => {
+            // fe80::/10, plus any IPv4-mapped form of a link-local v4 address.
+            (addr.segments()[0] & 0xffc0) == 0xfe80
+                || addr.to_ipv4_mapped().is_some_and(|v4| v4.is_link_local())
+        }
+        Some(url::Host::Domain(host)) => {
+            BLOCKED_METADATA_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
+        }
+        None => false,
+    }
+}
+
+/// Reject (as `400`) a request that would point the resolver's
+/// server-side metadata fetches at a blocked host — see
+/// [`is_blocked_registry_host`]. Both the default registry and every
+/// named-registry alias are checked. The message stays generic so a
+/// registry URL carrying credentials isn't echoed back.
+fn reject_blocked_registries(request: &ResolveRequest) -> Option<Response> {
+    let registries =
+        request.registry.iter().chain(request.named_registries.values()).map(String::as_str);
+    for registry in registries {
+        if is_blocked_registry_host(registry) {
+            return Some(json_error(
+                StatusCode::BAD_REQUEST,
+                "registry host is not allowed (link-local and instance-metadata addresses are blocked)",
+            ));
+        }
+    }
+    None
+}
Evidence
The PR adds only a request-boundary host check (is_blocked_registry_host /
reject_blocked_registries) and does not account for redirect targets. The shared HTTP client
builder does not set any redirect policy, and other code paths rely on reqwest following redirects
(e.g., tarball resolution reads response.url() as the post-redirect URL), indicating redirects are
followed in practice; metadata fetches use the same client via client.get(&url).

pnpr/crates/pnpr/src/resolver.rs[202-251]
pacquet/crates/network/src/lib.rs[405-424]
pacquet/crates/resolving-tarball-resolver/src/tarball_resolver.rs[119-147]
pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs[90-111]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new guard (`reject_blocked_registries`/`is_blocked_registry_host`) only checks the original registry URL the client sends. However, the HTTP client used for server-side metadata fetches follows redirects, so an attacker-controlled registry can 302/307 to a link-local/metadata host and still trigger SSRF.
## Issue Context
Because reqwest redirect-follow happens *during* the request, validating only the initial URL at the request boundary is insufficient; the redirect target must be rejected before the client issues the follow-up request.
## Fix Focus Areas
- pacquet/crates/network/src/lib.rs[405-424]
- pnpr/crates/pnpr/src/resolver.rs[202-251]
- pacquet/crates/resolving-npm-resolver/src/fetch_full_metadata.rs[90-111]
### Implementation sketch
- Add a reqwest redirect policy on the shared client builder (or on a dedicated “registry-metadata client”) using `reqwest::redirect::Policy::custom`.
- In the policy callback, inspect the *next* URL (`attempt.url()`) and **deny** the redirect if its host is link-local / instance-metadata (use the same logic as `is_blocked_registry_host`, ideally refactored into a shared helper that takes a `Url`/`Host`).
- Ensure this blocks both IPv4/IPv6 link-local and the metadata hostname(s), without breaking legitimate non-blocked redirects.
- Add a test that sets `registry` to an attacker-controlled URL that redirects to `http://169.254.169.254/` and assert the resolver/verify request is rejected (or fails closed without issuing the redirect-follow request).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended
2. Incomplete dot normalization 🐞 Bug ⛨ Security
Description
pacquet_network::is_blocked_request_host only strips one trailing '.' from domain hosts before
comparing to BLOCKED_REQUEST_HOSTS, so inputs ending with multiple trailing dots will not match
the metadata hostname blocklist. Because pnpr’s boundary check and pacquet’s redirect policy both
delegate to this predicate, any missed match here weakens the intended SSRF protection for metadata
hostnames.
Code

pacquet/crates/network/src/lib.rs[R466-468]

+        Some(url::Host::Domain(host)) => {
+            let host = host.strip_suffix('.').unwrap_or(host);
+            BLOCKED_REQUEST_HOSTS.iter().any(|blocked| host.eq_ignore_ascii_case(blocked))
Evidence
The blocklist match for domain hosts only removes a single trailing dot before comparing to
BLOCKED_REQUEST_HOSTS, so any domain string ending with more than one dot will not be normalized
to the blocked hostname. pnpr’s registry-host rejection explicitly delegates to this shared
predicate, and pacquet’s redirect policy also uses it, so the shared normalization behavior directly
affects SSRF defenses in both places.

pacquet/crates/network/src/lib.rs[424-436]
pacquet/crates/network/src/lib.rs[458-471]
pacquet/crates/network/src/tests.rs[730-755]
pnpr/crates/pnpr/src/resolver.rs[205-215]
pnpr/crates/pnpr/src/resolver.rs[217-233]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`is_blocked_request_host` normalizes `metadata.google.internal.` by stripping a single trailing dot, but it does not normalize domain hosts with **multiple** trailing dots (e.g. `metadata.google.internal..`). This can cause the metadata-hostname blocklist check to miss equivalent root-terminated forms.
### Issue Context
This predicate is security-sensitive and is shared by:
- pacquet’s reqwest redirect policy (to prevent redirect-based SSRF)
- pnpr’s request-boundary validation of client-supplied registry URLs
### Fix Focus Areas
- pacquet/crates/network/src/lib.rs[466-469]
- pacquet/crates/network/src/tests.rs[730-755]
### Suggested fix
- Replace `strip_suffix('.')` with `trim_end_matches('.')` (or an equivalent loop) so *all* trailing dots are removed before comparison.
- Add a unit test case asserting the double-trailing-dot form is blocked (e.g. `https://metadata.google.internal../`).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational
3. Misleading guard doc comment ✓ Resolved 🐞 Bug ⚙ Maintainability
Description
The doc comment above is_blocked_registry_host describes a local hostname blocklist and an IP
check “below”, but the function actually delegates all logic to
pacquet_network::is_blocked_request_host. This mismatch can mislead future edits in this
security-sensitive area.
Code

pnpr/crates/pnpr/src/resolver.rs[R202-215]

+/// Hostnames that resolve into the link-local instance-metadata range but
+/// don't look like a link-local address. Kept tiny and explicit — the IP
+/// check below covers the addresses themselves.
+/// Whether a client-supplied registry URL string points at a host the
+/// resolver must never fetch from. Delegates to
+/// [`pacquet_network::is_blocked_request_host`] so this request-boundary
+/// check and the install client's redirect policy share one definition —
+/// an attacker can't slip past the boundary by pointing `registry` at an
+/// allowed host that redirects to a link-local / instance-metadata host.
+/// A value that doesn't parse as a URL can't drive a fetch, so it isn't
+/// blocked here.
+fn is_blocked_registry_host(registry: &str) -> bool {
+    url::Url::parse(registry).is_ok_and(|url| pacquet_network::is_blocked_request_host(&url))
+}
Evidence
The added comment claims local logic (“hostnames … kept tiny … the IP check below”), but the
implementation is a thin wrapper around pacquet_network::is_blocked_request_host.

pnpr/crates/pnpr/src/resolver.rs[202-215]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`pnpr::resolver::is_blocked_registry_host` delegates to `pacquet_network::is_blocked_request_host`, but its doc comment currently reads like this file owns a hostname blocklist and an IP check. This is misleading in a security guard path.
### Issue Context
This comment sits immediately above the request-boundary SSRF validation.
### Fix Focus Areas
- pnpr/crates/pnpr/src/resolver.rs[202-215]
### Suggested fix
Update the comment to explicitly say the predicate delegates to `pacquet_network::is_blocked_request_host` (where the hostname list and IP logic live), or remove the stale sentences about a local hostname list/IP check.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Brittle redirect test assertion 🐞 Bug ☼ Reliability
Description
The new redirect-policy test asserts on format!("{err:?}") containing a substring; Debug
formatting for reqwest::Error is not a stable contract and may change on dependency upgrades. This
can cause test-only failures even if the redirect policy behavior is still correct.
Code

pacquet/crates/network/src/tests.rs[R781-783]

+    let debug = format!("{err:?}");
+    eprintln!("err={debug}");
+    assert!(debug.contains("link-local or instance-metadata host"), "err={debug}");
Evidence
The test currently formats the error with {:?} and matches a substring, coupling the test to debug
formatting rather than a stable error kind or API surface.

pacquet/crates/network/src/tests.rs[773-785]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The test `redirect_to_a_link_local_host_is_rejected_by_the_client` checks the `Debug` string of a `reqwest::Error` for a substring. This is brittle because `Debug` output can change even when behavior is correct.
### Issue Context
This is an end-to-end test ensuring the custom redirect policy rejects redirects to blocked hosts.
### Fix Focus Areas
- pacquet/crates/network/src/tests.rs[773-784]
### Suggested fix
Prefer assertions on more stable signals, e.g.:
- `assert!(err.is_redirect())` (reqwest provides helpers for error kinds), and/or
- check `err.to_string()` (still stringly, but typically more stable than `Debug`), and/or
- if available, assert on an underlying source/type rather than formatting.
Keep the existing behavioral assertion that the initial mock endpoint was hit.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Qodo Logo

Comment thread pnpr/crates/pnpr/src/resolver.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 1c5e7d8

@codecov-commenter

codecov-commenter commented Jun 26, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 89.74359% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.02%. Comparing base (6312916) to head (74fff66).
⚠️ Report is 4 commits behind head on main.

Files with missing lines Patch % Lines
pacquet/crates/network/src/lib.rs 88.88% 2 Missing ⚠️
pnpr/crates/pnpr/src/resolver.rs 90.47% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #12675      +/-   ##
==========================================
- Coverage   87.48%   87.02%   -0.46%     
==========================================
  Files         370      375       +5     
  Lines       55767    57895    +2128     
==========================================
+ Hits        48789    50386    +1597     
- Misses       6978     7509     +531     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pnpr/crates/pnpr/src/resolver.rs`:
- Around line 227-229: The blocked metadata host check in the resolver
host-matching logic should normalize trailing-dot FQDNs before comparison, since
`url::Url::parse` can preserve a final dot and let `metadata.google.internal.`
bypass the `eq_ignore_ascii_case` match. Update the host handling in
`resolver.rs` where `BLOCKED_METADATA_HOSTS` is checked so
`Some(url::Host::Domain(host))` compares against a version with one trailing dot
removed, and add a regression test covering the trailing-dot URL form to verify
it is still blocked.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 91c7ff62-9acf-4ffd-a62f-11e63f268305

📥 Commits

Reviewing files that changed from the base of the PR and between f38e696 and 1c5e7d8.

📒 Files selected for processing (2)
  • pnpr/crates/pnpr/src/resolver.rs
  • pnpr/crates/pnpr/src/resolver/tests.rs

Comment thread pnpr/crates/pnpr/src/resolver.rs Outdated
@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

Integrated-Benchmark Report (Linux)

Commit: 74fff664482d

Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD.

Scenario: Isolated linker: fresh restore, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.930 ± 0.135 4.638 5.055 1.74 ± 0.07
pacquet@main 4.912 ± 0.135 4.750 5.169 1.73 ± 0.07
pnpr@HEAD 2.838 ± 0.089 2.729 3.030 1.00 ± 0.04
pnpr@main 2.837 ± 0.073 2.745 2.996 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.92950888718,
      "stddev": 0.13470881980597807,
      "median": 4.9720207724800005,
      "user": 4.063177999999999,
      "system": 3.4578849000000007,
      "min": 4.63819636298,
      "max": 5.05479338198,
      "times": [
        5.05479338198,
        4.92598507398,
        4.98849603198,
        5.01243590998,
        4.74697151798,
        4.63819636298,
        5.0536453439799995,
        4.93052370398,
        4.96728207598,
        4.97675946898
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.91205486848,
      "stddev": 0.13528192357474697,
      "median": 4.907683456979999,
      "user": 4.022289,
      "system": 3.4601044,
      "min": 4.75034711398,
      "max": 5.1689533459799994,
      "times": [
        5.00368307898,
        5.1689533459799994,
        4.80519218898,
        4.88930051598,
        5.05250490898,
        4.75753952298,
        4.82516717398,
        4.9417944369799995,
        4.75034711398,
        4.92606639798
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 2.8379713333799996,
      "stddev": 0.08870700536830214,
      "median": 2.81438609698,
      "user": 2.802105,
      "system": 2.9302307999999995,
      "min": 2.72931985498,
      "max": 3.03010335998,
      "times": [
        2.92157778698,
        2.8577131159799998,
        2.7650730159799997,
        2.8829328399799996,
        2.78194716498,
        2.7822740009799998,
        2.72931985498,
        3.03010335998,
        2.81040929198,
        2.8183629019799996
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 2.83687999558,
      "stddev": 0.07314952428521178,
      "median": 2.82235369048,
      "user": 2.7763576,
      "system": 2.9112869999999997,
      "min": 2.7449558979799997,
      "max": 2.99563882498,
      "times": [
        2.99563882498,
        2.8311430509799997,
        2.7449558979799997,
        2.8135643299799997,
        2.8033261429799996,
        2.83802511098,
        2.92790438898,
        2.78403569798,
        2.83476479898,
        2.7954417119799997
      ]
    }
  ]
}

Scenario: Isolated linker: fresh restore, hot cache + hot store

Command Mean [ms] Min [ms] Max [ms] Relative
pacquet@HEAD 631.8 ± 11.9 616.8 651.4 1.00
pacquet@main 670.6 ± 90.1 618.8 922.4 1.06 ± 0.14
pnpr@HEAD 753.6 ± 43.7 712.5 848.3 1.19 ± 0.07
pnpr@main 687.7 ± 22.1 665.2 739.4 1.09 ± 0.04
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 0.6317782246400001,
      "stddev": 0.011916920148570284,
      "median": 0.6314077677400001,
      "user": 0.36812549999999994,
      "system": 1.3249988599999998,
      "min": 0.61678375774,
      "max": 0.6513605317400001,
      "times": [
        0.61916572674,
        0.6427499777400001,
        0.64180984574,
        0.6386078447400001,
        0.6290857887400001,
        0.61723235174,
        0.61678375774,
        0.62725667474,
        0.6513605317400001,
        0.63372974674
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 0.67057895174,
      "stddev": 0.09012505441240652,
      "median": 0.64875215874,
      "user": 0.37570809999999993,
      "system": 1.33662636,
      "min": 0.61883790774,
      "max": 0.92236668874,
      "times": [
        0.61929418274,
        0.61883790774,
        0.6673649757400001,
        0.6455187347400001,
        0.62473697074,
        0.6536644767400001,
        0.63958920174,
        0.6519855827400001,
        0.92236668874,
        0.6624307957400001
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 0.75358245934,
      "stddev": 0.04367923167494536,
      "median": 0.7398277332400001,
      "user": 0.39096490000000006,
      "system": 1.4119845599999998,
      "min": 0.7124740637400001,
      "max": 0.8482644707400001,
      "times": [
        0.72419149274,
        0.81689018274,
        0.72585568574,
        0.74724528674,
        0.7391972697400001,
        0.7328574147400001,
        0.8482644707400001,
        0.7483905297400001,
        0.74045819674,
        0.7124740637400001
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 0.6877020827400002,
      "stddev": 0.022119925072300806,
      "median": 0.6842512432400001,
      "user": 0.3801176,
      "system": 1.35026526,
      "min": 0.6652478727400001,
      "max": 0.7393789647400001,
      "times": [
        0.6907886967400001,
        0.70051612474,
        0.67356456474,
        0.6798319887400001,
        0.6995675597400001,
        0.6680721497400001,
        0.68867049774,
        0.6652478727400001,
        0.7393789647400001,
        0.67138240774
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + cold store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 4.841 ± 0.063 4.706 4.934 1.55 ± 0.04
pacquet@main 4.811 ± 0.056 4.725 4.892 1.54 ± 0.04
pnpr@HEAD 3.159 ± 0.091 3.022 3.335 1.01 ± 0.04
pnpr@main 3.119 ± 0.066 3.025 3.214 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 4.8410288346,
      "stddev": 0.06295252609726955,
      "median": 4.8529303342,
      "user": 3.97459734,
      "system": 3.37886162,
      "min": 4.7060933632,
      "max": 4.9339086712,
      "times": [
        4.7060933632,
        4.8575255782,
        4.9339086712,
        4.8483350902,
        4.8725824352,
        4.8453244122,
        4.8875089372,
        4.8710776682,
        4.7942811772,
        4.7936510132
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 4.810674023700001,
      "stddev": 0.05615897471270419,
      "median": 4.8043225512,
      "user": 3.9279543400000003,
      "system": 3.3581437199999997,
      "min": 4.7246685161999995,
      "max": 4.8918485352,
      "times": [
        4.8918485352,
        4.8366324682,
        4.8243745222,
        4.8765083472,
        4.8621056502,
        4.7780984881999995,
        4.7842705802,
        4.7488881512,
        4.7246685161999995,
        4.7793449782
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 3.158654158,
      "stddev": 0.0911934233950481,
      "median": 3.1490158472,
      "user": 2.88052814,
      "system": 3.11714012,
      "min": 3.0215642901999997,
      "max": 3.3346778952,
      "times": [
        3.2206130182,
        3.1017001761999996,
        3.1963315182,
        3.2202515982,
        3.1984810472,
        3.0215642901999997,
        3.3346778952,
        3.1005922602,
        3.0976663961999997,
        3.0946633801999996
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 3.1186221572,
      "stddev": 0.06592127673447988,
      "median": 3.0977735926999994,
      "user": 2.82768444,
      "system": 3.0851198199999996,
      "min": 3.0250427542,
      "max": 3.2142231172,
      "times": [
        3.1358362572,
        3.0881226562,
        3.2081605501999997,
        3.0919402191999996,
        3.2142231172,
        3.1903883062,
        3.1036069661999997,
        3.0618726501999998,
        3.0670280952,
        3.0250427542
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, hot cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 1.365 ± 0.016 1.345 1.394 1.00
pacquet@main 1.366 ± 0.012 1.348 1.383 1.00 ± 0.02
pnpr@HEAD 1.434 ± 0.067 1.387 1.612 1.05 ± 0.05
pnpr@main 1.433 ± 0.026 1.395 1.482 1.05 ± 0.02
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 1.36528171414,
      "stddev": 0.01628858749870687,
      "median": 1.36355695214,
      "user": 1.3134442800000001,
      "system": 1.7026363999999998,
      "min": 1.34493567264,
      "max": 1.3940102376399999,
      "times": [
        1.3651837866399998,
        1.36048802264,
        1.34493567264,
        1.3649315606399999,
        1.38748569664,
        1.37489659164,
        1.36218234364,
        1.34597103364,
        1.3940102376399999,
        1.35273219564
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 1.36565224194,
      "stddev": 0.012448851416448449,
      "median": 1.3661842441399998,
      "user": 1.30627388,
      "system": 1.7026387999999997,
      "min": 1.3480451066399999,
      "max": 1.38345021864,
      "times": [
        1.38308416364,
        1.37010793864,
        1.3480451066399999,
        1.35814692164,
        1.38345021864,
        1.36226054964,
        1.3548092886399998,
        1.3710964906399998,
        1.35245315864,
        1.37306858264
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 1.43420866094,
      "stddev": 0.06705566343517771,
      "median": 1.4143686041399999,
      "user": 0.54925738,
      "system": 1.5500988999999998,
      "min": 1.38672688564,
      "max": 1.61171958664,
      "times": [
        1.43815071864,
        1.61171958664,
        1.46401165264,
        1.38672688564,
        1.40338758064,
        1.42920282164,
        1.39165428664,
        1.39411407064,
        1.39776937864,
        1.42534962764
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 1.4334769996399996,
      "stddev": 0.026319135793674665,
      "median": 1.4305666506399999,
      "user": 0.5657377799999999,
      "system": 1.538873,
      "min": 1.3951541756399999,
      "max": 1.48158516064,
      "times": [
        1.45243905764,
        1.41642215464,
        1.48158516064,
        1.41289121764,
        1.43570399164,
        1.4351007836399998,
        1.46458065864,
        1.42603251764,
        1.41486027864,
        1.3951541756399999
      ]
    }
  ]
}

Scenario: Isolated linker: fresh install, cold cache + hot store

Command Mean [s] Min [s] Max [s] Relative
pacquet@HEAD 3.088 ± 0.083 3.003 3.255 2.18 ± 0.08
pacquet@main 3.162 ± 0.075 3.077 3.335 2.23 ± 0.07
pnpr@HEAD 1.442 ± 0.014 1.421 1.467 1.02 ± 0.03
pnpr@main 1.417 ± 0.033 1.391 1.493 1.00
BENCHMARK_REPORT.json
{
  "results": [
    {
      "command": "pacquet@HEAD",
      "mean": 3.0877655965599997,
      "stddev": 0.08292850482223402,
      "median": 3.05086635976,
      "user": 1.80430618,
      "system": 1.9936045200000003,
      "min": 3.00286872826,
      "max": 3.25544199126,
      "times": [
        3.00286872826,
        3.04527891626,
        3.0216335722600003,
        3.05645380326,
        3.08382325026,
        3.1837490552600003,
        3.15644024826,
        3.0287381252600003,
        3.25544199126,
        3.04322827526
      ]
    },
    {
      "command": "pacquet@main",
      "mean": 3.1622334957600007,
      "stddev": 0.07464187037520818,
      "median": 3.1502971842600003,
      "user": 1.82795148,
      "system": 2.07040272,
      "min": 3.0769191722600002,
      "max": 3.3347590692600004,
      "times": [
        3.13468580026,
        3.20767978326,
        3.1355578692600004,
        3.17407927726,
        3.1961415372600004,
        3.10878649126,
        3.0769191722600002,
        3.3347590692600004,
        3.08868945826,
        3.16503649926
      ]
    },
    {
      "command": "pnpr@HEAD",
      "mean": 1.44235980616,
      "stddev": 0.014338465317232417,
      "median": 1.4431695602599999,
      "user": 0.56056548,
      "system": 1.5760636199999998,
      "min": 1.42051351526,
      "max": 1.46744048326,
      "times": [
        1.44909174826,
        1.45103222626,
        1.43242357326,
        1.44313473526,
        1.42690926526,
        1.43327114726,
        1.4565769822599999,
        1.46744048326,
        1.44320438526,
        1.42051351526
      ]
    },
    {
      "command": "pnpr@main",
      "mean": 1.4173323285600001,
      "stddev": 0.03341977967717305,
      "median": 1.40926809226,
      "user": 0.5565030799999999,
      "system": 1.55967012,
      "min": 1.39058742726,
      "max": 1.4930281002599999,
      "times": [
        1.45586173626,
        1.4930281002599999,
        1.4175474372599999,
        1.39153317526,
        1.42180419126,
        1.39203389926,
        1.39058742726,
        1.41398521826,
        1.40455096626,
        1.39239113426
      ]
    }
  ]
}

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12675
Testbedpacquet
Click to view all benchmark results
BenchmarkLatencyBenchmark Result
milliseconds (ms)
(Result Δ%)
Upper Boundary
milliseconds (ms)
(Limit %)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
🚷 view threshold
4,841.03 ms
(+1.76%)Baseline: 4,757.14 ms
5,708.57 ms
(84.80%)
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
🚷 view threshold
3,087.77 ms
(+1.51%)Baseline: 3,041.97 ms
3,650.36 ms
(84.59%)
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
🚷 view threshold
1,365.28 ms
(+0.65%)Baseline: 1,356.45 ms
1,627.74 ms
(83.88%)
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
🚷 view threshold
4,929.51 ms
(+3.08%)Baseline: 4,782.09 ms
5,738.51 ms
(85.90%)
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
🚷 view threshold
631.78 ms
(-3.76%)Baseline: 656.45 ms
787.74 ms
(80.20%)
🐰 View full continuous benchmarking report in Bencher

@github-actions

github-actions Bot commented Jun 26, 2026

Copy link
Copy Markdown
Contributor

🐰 Bencher Report

Branchpr/12675
Testbedpnpr

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

Click here to create a new Threshold
For more information, see the Threshold documentation.
To only post results if a Threshold exists, set the --ci-only-thresholds flag.

Click to view all benchmark results
BenchmarkLatencymilliseconds (ms)
isolated-linker.fresh-install.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
3,158.65 ms
isolated-linker.fresh-install.cold-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
1,442.36 ms
isolated-linker.fresh-install.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
1,434.21 ms
isolated-linker.fresh-restore.cold-cache.cold-store📈 view plot
⚠️ NO THRESHOLD
2,837.97 ms
isolated-linker.fresh-restore.hot-cache.hot-store📈 view plot
⚠️ NO THRESHOLD
753.58 ms
🐰 View full continuous benchmarking report in Bencher

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>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 4e257e4

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pnpr/crates/pnpr/src/resolver.rs (1)

381-383: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Run the blocked-registry guard before the trust_lockfile fast path.

With the guard after the fast path, verify-lockfile can return without rejecting a blocked registry when trust_lockfile is set. Move reject_blocked_registries(&request) immediately after parsing so this endpoint consistently enforces the new request-boundary contract.

As per the PR objective, POST /-/pnpr/v0/verify-lockfile should reject registry URLs whose host is link-local or a known metadata hostname.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pnpr/crates/pnpr/src/resolver.rs` around lines 381 - 383, The
blocked-registry check in `verify_lockfile` is running too late, allowing the
`trust_lockfile` fast path to bypass it. Move the
`reject_blocked_registries(&request)` guard in `resolver.rs` so it runs
immediately after request parsing and before any `trust_lockfile` early return,
ensuring `POST /-/pnpr/v0/verify-lockfile` always rejects link-local or metadata
registry hosts.
🧹 Nitpick comments (1)
pacquet/crates/network/src/tests.rs (1)

730-734: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the test doc comment that repeats the assertions.

This private test’s name and assertions already document the cases; keep the shared “why” on is_blocked_request_host instead.

Proposed cleanup
-/// [`super::is_blocked_request_host`] blocks the link-local instance-metadata
-/// range and well-known metadata hostnames (including the trailing-dot FQDN
-/// form), while allowing public, private, and loopback registries. This is
-/// the predicate the install client's redirect policy and pnpr's
-/// request-boundary check share.
 #[test]
 fn is_blocked_request_host_blocks_link_local_and_metadata_only() {

As per coding guidelines, “Tests are documentation. Do not duplicate test scenarios, edge cases, failure modes, or worked examples in doc comments if they are already captured by tests.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@pacquet/crates/network/src/tests.rs` around lines 730 - 734, Remove the
private test doc comment near the test in tests.rs that restates the same
scenarios already covered by the test name and assertions. Keep the explanatory
“why” only on is_blocked_request_host, and leave the test itself to document the
cases through its assertions and name.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@pacquet/crates/network/src/lib.rs`:
- Around line 428-436: Add an integration test that exercises the redirect chain
in default_client_builder(), not just is_blocked_request_host(), by using a mock
server that returns a 302 to a blocked URL like http://169.254.169.254/ and then
sending a real request through the reqwest client. Verify the request fails
during Policy::custom redirect handling and assert the exact error message
"redirect to a link-local or instance-metadata host is not allowed" so the
blocked-host enforcement is covered end-to-end.

---

Outside diff comments:
In `@pnpr/crates/pnpr/src/resolver.rs`:
- Around line 381-383: The blocked-registry check in `verify_lockfile` is
running too late, allowing the `trust_lockfile` fast path to bypass it. Move the
`reject_blocked_registries(&request)` guard in `resolver.rs` so it runs
immediately after request parsing and before any `trust_lockfile` early return,
ensuring `POST /-/pnpr/v0/verify-lockfile` always rejects link-local or metadata
registry hosts.

---

Nitpick comments:
In `@pacquet/crates/network/src/tests.rs`:
- Around line 730-734: Remove the private test doc comment near the test in
tests.rs that restates the same scenarios already covered by the test name and
assertions. Keep the explanatory “why” only on is_blocked_request_host, and
leave the test itself to document the cases through its assertions and name.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 45fde87d-d2a2-496d-aca4-863225c1ad70

📥 Commits

Reviewing files that changed from the base of the PR and between 1c5e7d8 and 4e257e4.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock, !Cargo.lock
📒 Files selected for processing (5)
  • pacquet/crates/network/Cargo.toml
  • pacquet/crates/network/src/lib.rs
  • pacquet/crates/network/src/tests.rs
  • pnpr/crates/pnpr/src/resolver.rs
  • pnpr/crates/pnpr/src/resolver/tests.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • pnpr/crates/pnpr/src/resolver/tests.rs

Comment thread pacquet/crates/network/src/lib.rs
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 4e257e4

Addresses a follow-up review on this PR: the unit tests proved
`is_blocked_request_host`, but not that the reqwest client's redirect
policy actually enforces it. Add an end-to-end test where a loopback mock
server 302-redirects to `http://169.254.169.254/` and assert the request
fails during redirect handling with the blocked-host error, so the policy
wiring can't silently regress.

Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the reviewed: coderabbit CodeRabbit submitted an approving review label Jun 26, 2026
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8cf21e1

1 similar comment
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 8cf21e1

The new `is_blocked_request_host` test used a single-letter closure
parameter `|u|`, which `perfectionist::single-letter-closure-param`
(run in Rust CI's Dylint job, not in clippy) rejects. Rename it to
`url_str`.

Co-authored-by: Claude <noreply@anthropic.com>
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 74fff66

1 similar comment
@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Code review by qodo was updated up to the latest commit 74fff66

@zkochan zkochan left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of a blocklist maybe we should have an allowlist of registries that is added into the settings of pnpr.

@YESHYUNGSEOK

Copy link
Copy Markdown
Contributor Author

Superseded by #12700. The reject_off_allowlist_fetches / RouteContext::allows_registry mechanism there is a default-deny registry allowlist, enforced at the resolve and verify-lockfile boundaries and re-checked on every redirect hop, over registry, namedRegistries, http(s)/git/scp deps, and overrides.

That covers everything this PR's link-local/metadata blocklist did, and strictly more — link-local and IMDS hosts aren't on the allowlist, and neither are the forms a denylist structurally misses: IPv4-compatible IPv6 ([::169.254.169.254]), NAT64/6to4 embeddings, fd00:ec2::254, Alibaba 100.100.100.200, or any metadata hostname other than metadata.google.internal.

The one class neither approach catches at the URL-string layer is an allowlisted host that resolves to a link-local address at connect time (DNS rebinding) — closing that needs a resolved-IP guard in the HTTP connector rather than a host-string check. I can open a separate PR for that if it's wanted; otherwise the allowlist already closes the SSRF this PR targeted.

Closing as redundant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

product: pnpr reviewed: coderabbit CodeRabbit submitted an approving review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants