fix(pnpr): reject link-local registry hosts to block resolver SSRF#12675
fix(pnpr): reject link-local registry hosts to block resolver SSRF#12675YESHYUNGSEOK wants to merge 4 commits into
Conversation
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>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughWalkthroughThe 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. ChangesRegistry host blocking
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related issues
Suggested labels
Suggested reviewers
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
PR Summary by QodoBlock link-local registry hosts in pnpr resolver to prevent SSRF Description
Diagram
High-Level Assessment
Files changed (2)
|
Code Review by Qodo
1.
|
|
Code review by qodo was updated up to the latest commit 1c5e7d8 |
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
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
📒 Files selected for processing (2)
pnpr/crates/pnpr/src/resolver.rspnpr/crates/pnpr/src/resolver/tests.rs
Integrated-Benchmark Report (Linux)Commit: Each scenario reports direct installs and pnpr installs. Bencher consumes pacquet@HEAD and pnpr@HEAD. Scenario: Isolated linker: fresh restore, cold cache + cold store
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
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
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
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
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
]
}
]
} |
|
| Branch | pr/12675 |
| Testbed | pacquet |
Click to view all benchmark results
| Benchmark | Latency | Benchmark 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%) |
|
| Branch | pr/12675 |
| Testbed | pnpr |
⚠️ 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-thresholdsflag.
Click to view all benchmark results
| Benchmark | Latency | milliseconds (ms) |
|---|---|---|
| isolated-linker.fresh-install.cold-cache.cold-store | 📈 view plot | 3,158.65 ms |
| isolated-linker.fresh-install.cold-cache.hot-store | 📈 view plot | 1,442.36 ms |
| isolated-linker.fresh-install.hot-cache.hot-store | 📈 view plot | 1,434.21 ms |
| isolated-linker.fresh-restore.cold-cache.cold-store | 📈 view plot | 2,837.97 ms |
| isolated-linker.fresh-restore.hot-cache.hot-store | 📈 view plot | 753.58 ms |
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>
|
Code review by qodo was updated up to the latest commit 4e257e4 |
There was a problem hiding this comment.
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 winRun the blocked-registry guard before the
trust_lockfilefast path.With the guard after the fast path,
verify-lockfilecan return without rejecting a blockedregistrywhentrust_lockfileis set. Movereject_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-lockfileshould 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 winRemove 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_hostinstead.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
⛔ Files ignored due to path filters (1)
Cargo.lockis excluded by!**/*.lock,!Cargo.lock
📒 Files selected for processing (5)
pacquet/crates/network/Cargo.tomlpacquet/crates/network/src/lib.rspacquet/crates/network/src/tests.rspnpr/crates/pnpr/src/resolver.rspnpr/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
|
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>
|
Code review by qodo was updated up to the latest commit 8cf21e1 |
1 similar comment
|
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>
|
Code review by qodo was updated up to the latest commit 74fff66 |
1 similar comment
|
Code review by qodo was updated up to the latest commit 74fff66 |
zkochan
left a comment
There was a problem hiding this comment.
Instead of a blocklist maybe we should have an allowlist of registries that is added into the settings of pnpr.
|
Superseded by #12700. The 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 ( 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. |
Summary
To shape a lockfile,
POST /-/pnpr/v0/resolveandPOST /-/pnpr/v0/verify-lockfilefetch package metadata server-sidefrom the registry the client supplies — the
registrydefault and everynamedRegistriesalias. Those URLs were used verbatim, so anauthenticated caller could point them at the link-local range that fronts
cloud instance metadata (e.g.
http://169.254.169.254/) and make theserver 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:
169.254.0.0/16),fe80::/10), or an IPv4-mapped form of alink-local v4 address, or
metadata.google.internal).Private and loopback addresses are deliberately still allowed —
resolving against an internal registry (often on
10.x/192.168.xorbehind
localhostin dev) is pnpr's core use case, so blanket private-IPblocking 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
Checklist
Written by an agent (Claude Code, claude-opus-4-8).
Summary by CodeRabbit