deps_core/net_policy.rs
1//! Reachability policy for registry index URLs declared by a workspace file.
2//!
3//! A `.cargo/config.toml`/`Cargo.toml` value is attacker-controlled the moment a hostile
4//! repository is cloned and opened — this LSP fetches on parse, before any build ever runs
5//! (spec `.local/specs/023-cargo-custom-registries/spec.md` NFR-003). [`classify_host`]
6//! answers "is this URL's host the kind no legitimate registry index or redirect ever
7//! targets" from the URL alone (no DNS resolution — see [`classify_host`]'s docs for why),
8//! and [`RegistryAccessPolicy`] is the live-updatable, process-wide switch a caller checks
9//! before ever fetching a workspace-declared URL.
10//!
11//! Placed in `deps-core`, not an ecosystem crate: [`RegistryAccessPolicy`] must be held by
12//! `ServerState` without a `#[cfg(feature = "cargo")]` gate, and host classification belongs
13//! beside [`crate::cache`]'s existing `ensure_https`/loopback checks, which already perform
14//! the same class of validation (DRY). [`crate::cache`]'s redirect-hop hardening also needs
15//! this exact classifier — see [`HostClass::never_a_registry`].
16
17use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
18use std::sync::atomic::{AtomicU8, Ordering};
19
20/// Classification of a URL's host, for [`RegistryAccessPolicy`] to evaluate against
21/// [`WorkspaceRegistryAccess`].
22///
23/// Computed from the URL alone (see [`classify_host`]) — never from a DNS resolution, so an
24/// attacker-controlled hostname that merely *resolves* to a blocked range is not caught here
25/// (the residual risk spec NFR-003/§5 of the plan documents; closing it needs a
26/// `reqwest::dns::Resolve` filter, deferred as a follow-up).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum HostClass {
29 /// `127.0.0.0/8`, `::1`, `localhost`, `*.localhost`.
30 Loopback,
31 /// `169.254.0.0/16`, `fe80::/10` — includes [`HostClass::CloudMetadata`]'s narrower range.
32 LinkLocal,
33 /// `169.254.169.254` / `fd00:ec2::254`, or the names cloud providers document for their
34 /// instance-metadata endpoint (`metadata.google.internal`, `metadata.goog`) — a
35 /// deliberately narrower label inside [`HostClass::LinkLocal`]/[`HostClass::InternalName`],
36 /// kept separate only so a blocked-host warning can name it specifically.
37 CloudMetadata,
38 /// `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`.
39 PrivateV4,
40 /// `100.64.0.0/10` — carrier-grade NAT.
41 Cgnat,
42 /// `fc00::/7`.
43 UniqueLocalV6,
44 /// `0.0.0.0`, `::`.
45 Unspecified,
46 /// A name ending in `.internal`/`.local`/`.home.arpa`, or any single-label host (no dot) —
47 /// never a real public registry's hostname.
48 InternalName,
49 /// Everything else: a public IP literal, or a multi-label name not matching any of the
50 /// above suffixes.
51 Global,
52}
53
54impl HostClass {
55 /// Whether this class is one no legitimate registry index (or a redirect from one) could
56 /// ever legitimately target.
57 ///
58 /// Used unconditionally by [`crate::cache`]'s redirect-hop hardening, independent of
59 /// [`WorkspaceRegistryAccess`]: deliberately narrower than [`WorkspaceRegistryAccess::PublicOnly`]
60 /// blocks outright, since [`HostClass::PrivateV4`]/[`HostClass::Cgnat`]/
61 /// [`HostClass::UniqueLocalV6`]/[`HostClass::InternalName`] are legitimate redirect
62 /// targets for a corporate registry's own network — only the classes below are never a
63 /// registry under any policy.
64 #[must_use]
65 pub const fn never_a_registry(self) -> bool {
66 matches!(
67 self,
68 Self::Loopback | Self::LinkLocal | Self::CloudMetadata | Self::Unspecified
69 )
70 }
71}
72
73impl std::fmt::Display for HostClass {
74 /// A human-readable label for this class, used in user-facing warnings/diagnostics —
75 /// never the `{:?}` derive, which renders the Rust identifier rather than prose.
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
77 f.write_str(match self {
78 Self::Loopback => "loopback",
79 Self::LinkLocal => "link-local",
80 Self::CloudMetadata => "cloud metadata",
81 Self::PrivateV4 => "private (RFC1918)",
82 Self::Cgnat => "carrier-grade NAT",
83 Self::UniqueLocalV6 => "unique-local IPv6",
84 Self::Unspecified => "unspecified",
85 Self::InternalName => "internal name",
86 Self::Global => "global",
87 })
88 }
89}
90
91/// Unwraps an IPv4-mapped (`::ffff:a.b.c.d`) or NAT64-embedded (`64:ff9b::a.b.c.d`, RFC 6052
92/// well-known prefix) IPv6 address to its embedded IPv4 form, so classification cannot be
93/// bypassed by writing the same address in either v4-in-v6 form (e.g. `::ffff:169.254.169.254`
94/// or `64:ff9b::a9fe:a9fe`). The NAT64 case matters here specifically because an attacker's DNS
95/// answer can return any AAAA record it likes, and a client behind a NAT64/DNS64 gateway (or a
96/// local 464XLAT/CLAT translator) treats `64:ff9b::/96` as routable to the embedded IPv4 address
97/// (impl-critic finding, verified empirically: `64:ff9b::a9fe:a9fe` classified `Global` before
98/// this fix).
99///
100/// Deliberately does **not** additionally unwrap:
101/// - The deprecated IPv4-*compatible* form (`::a.b.c.d`, RFC 4291 §2.5.5.1, no `ffff` prefix):
102/// `Ipv6Addr::to_ipv4()` treats *any* address with its first 96 bits zero as embedding an
103/// IPv4 address, which would misclassify `::1` (loopback) as `0.0.0.1` and `::` (unspecified)
104/// as `0.0.0.0` — a narrower, *new* bypass in exchange for closing a narrower, legacy one.
105/// Modern network stacks generally do not route this deprecated form at all, so it is
106/// accepted as low-real-world-risk (impl-critic finding, unwrap-mapped-v6/NAT64 are the
107/// actively-exploitable forms and are handled above).
108/// - 6to4 (`2002::/16`, RFC 3056), which also embeds an IPv4 address in its prefix: a narrower,
109/// largely-deprecated IPv6-transition mechanism — NAT64/DNS64 remains commonly deployed
110/// today, 6to4 does not — documented as a residual, not fixed by this pass.
111fn unwrap_mapped_v4(addr: IpAddr) -> IpAddr {
112 match addr {
113 IpAddr::V6(v6) => v6
114 .to_ipv4_mapped()
115 .or_else(|| nat64_embedded_v4(v6))
116 .map_or(addr, IpAddr::V4),
117 IpAddr::V4(_) => addr,
118 }
119}
120
121/// Extracts the IPv4 address embedded in a NAT64 well-known-prefix (RFC 6052 `64:ff9b::/96`)
122/// IPv6 address, e.g. `64:ff9b::a9fe:a9fe` -> `169.254.169.254`.
123fn nat64_embedded_v4(v6: Ipv6Addr) -> Option<Ipv4Addr> {
124 let segments = v6.segments();
125 if segments[0] == 0x0064 && segments[1] == 0xff9b && segments[2..6] == [0, 0, 0, 0] {
126 let [a, b] = segments[6].to_be_bytes();
127 let [c, d] = segments[7].to_be_bytes();
128 Some(Ipv4Addr::new(a, b, c, d))
129 } else {
130 None
131 }
132}
133
134/// Classifies `addr` (already unwrapped of any IPv4-mapping) into a [`HostClass`].
135fn classify_ip(addr: IpAddr) -> HostClass {
136 match addr {
137 IpAddr::V4(v4) => {
138 if v4.is_loopback() {
139 HostClass::Loopback
140 } else if v4 == Ipv4Addr::new(169, 254, 169, 254) {
141 HostClass::CloudMetadata
142 } else if v4.is_link_local() {
143 HostClass::LinkLocal
144 } else if v4.is_unspecified() {
145 HostClass::Unspecified
146 } else if v4.is_private() {
147 HostClass::PrivateV4
148 } else if v4.octets()[0] == 100 && (v4.octets()[1] & 0b1100_0000) == 0b0100_0000 {
149 // 100.64.0.0/10
150 HostClass::Cgnat
151 } else {
152 HostClass::Global
153 }
154 }
155 IpAddr::V6(v6) => {
156 if v6.is_loopback() {
157 HostClass::Loopback
158 } else if v6.segments() == [0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x254] {
159 HostClass::CloudMetadata
160 } else if (v6.segments()[0] & 0xffc0) == 0xfe80 {
161 // fe80::/10
162 HostClass::LinkLocal
163 } else if v6.is_unspecified() {
164 HostClass::Unspecified
165 } else if (v6.segments()[0] & 0xfe00) == 0xfc00 {
166 // fc00::/7
167 HostClass::UniqueLocalV6
168 } else {
169 HostClass::Global
170 }
171 }
172 }
173}
174
175/// Classifies a hostname (never an IP literal — those go through [`classify_ip`]) into a
176/// [`HostClass`].
177fn classify_name(host: &str) -> HostClass {
178 let lower = host.to_ascii_lowercase();
179 // `url::Url` preserves a trailing root-label dot (`https://localhost./` parses to the
180 // host `"localhost."`, not `"localhost"`) — every suffix/equality check below must see
181 // the FQDN with that label stripped, or a single appended `.` walks straight past this
182 // entire classifier into `Global` (security review S1). `trim_end_matches` (not
183 // `strip_suffix`, which removes only one) also closes the `localhost..` double-dot
184 // edge case for free — not independently exploitable (an empty DNS label never
185 // resolves), but belt-and-braces at zero extra cost.
186 let lower = lower.trim_end_matches('.');
187 if lower == "localhost" || lower.ends_with(".localhost") {
188 return HostClass::Loopback;
189 }
190 if lower == "metadata.google.internal" || lower == "metadata.goog" {
191 return HostClass::CloudMetadata;
192 }
193 if lower.ends_with(".internal") || lower.ends_with(".local") || lower.ends_with(".home.arpa") {
194 return HostClass::InternalName;
195 }
196 if !lower.contains('.') {
197 // A single-label host (no dot at all) can never be a real public registry name.
198 return HostClass::InternalName;
199 }
200 HostClass::Global
201}
202
203/// Classifies a DNS-resolved socket address into a [`HostClass`].
204///
205/// The counterpart to [`classify_host`] used by [`crate::cache`]'s connect-time resolver guard
206/// (issue #449) to close the DNS-rebinding TOCTOU gap the module docs describe: a hostname's
207/// *resolved* address, not just its string form, needs the same classification. Reuses this
208/// module's own private IP-classification and mapped-address-unwrapping helpers rather than
209/// duplicating their match arms (DRY).
210///
211/// # Examples
212///
213/// ```
214/// use deps_core::net_policy::{HostClass, classify_addr};
215///
216/// let addr = "169.254.169.254".parse().unwrap();
217/// assert_eq!(classify_addr(addr), HostClass::CloudMetadata);
218/// ```
219#[must_use]
220pub fn classify_addr(addr: IpAddr) -> HostClass {
221 classify_ip(unwrap_mapped_v4(addr))
222}
223
224/// Classifies `url`'s host into a [`HostClass`], from the URL alone — **no DNS resolution**
225/// is performed (see the module docs' residual-risk note).
226///
227/// # Examples
228///
229/// ```
230/// use deps_core::net_policy::{HostClass, classify_host};
231/// use url::Url;
232///
233/// let url = Url::parse("https://169.254.169.254/latest/meta-data/").unwrap();
234/// assert_eq!(classify_host(&url), HostClass::CloudMetadata);
235///
236/// let url = Url::parse("https://index.crates.io/").unwrap();
237/// assert_eq!(classify_host(&url), HostClass::Global);
238/// ```
239#[must_use]
240pub fn classify_host(url: &url::Url) -> HostClass {
241 match url.host() {
242 Some(url::Host::Ipv4(v4)) => classify_ip(unwrap_mapped_v4(IpAddr::V4(v4))),
243 Some(url::Host::Ipv6(v6)) => classify_ip(unwrap_mapped_v4(IpAddr::V6(v6))),
244 Some(url::Host::Domain(name)) => classify_name(name),
245 None => HostClass::InternalName,
246 }
247}
248
249/// The user-facing policy governing whether a workspace-declared registry index is ever
250/// fetched at all.
251///
252/// Applied **only** to workspace-provenance URLs (a `Cargo.toml`/`.cargo/config.toml` value
253/// found inside the opened workspace) — a `$CARGO_HOME`-provenance index is the user's own
254/// trusted configuration and is never policy-checked, under any variant here.
255#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
256pub enum WorkspaceRegistryAccess {
257 /// Block every workspace-declared index — the only complete boundary. Also blocks the
258 /// `registry`/`registry-index` alias path, not only `[source]` replace-with.
259 Off,
260 /// Allow only [`HostClass::Global`] hosts — blocks the observed attack shape (an IP
261 /// literal in a metadata/RFC1918 range) while leaving a corporate `https://index.mycorp.dev`
262 /// working, since a DNS name cannot be classified without resolving it (the residual risk
263 /// this variant's name is honest about — see the module docs).
264 #[default]
265 PublicOnly,
266 /// Allow every class — today's pre-hardening behavior, the escape hatch for a workspace
267 /// that legitimately points at an RFC1918/loopback registry.
268 All,
269}
270
271impl WorkspaceRegistryAccess {
272 /// Whether a workspace-declared URL classified as `class` may be fetched under this
273 /// policy.
274 #[must_use]
275 pub const fn allows(self, class: HostClass) -> bool {
276 match self {
277 Self::Off => false,
278 Self::PublicOnly => matches!(class, HostClass::Global),
279 Self::All => true,
280 }
281 }
282
283 /// Numeric encoding for [`RegistryAccessPolicy`]'s lock-free storage, and — via
284 /// `crate::cache`'s workspace-tier cache-key computation — for the digit distinguishing
285 /// one policy era's workspace cache entries from another's.
286 pub(crate) const fn to_u8(self) -> u8 {
287 match self {
288 Self::Off => 0,
289 Self::PublicOnly => 1,
290 Self::All => 2,
291 }
292 }
293
294 /// Inverse of [`Self::to_u8`]; any value the atomic could not have produced falls back to
295 /// the safe default rather than panicking.
296 const fn from_u8(value: u8) -> Self {
297 match value {
298 0 => Self::Off,
299 2 => Self::All,
300 _ => Self::PublicOnly,
301 }
302 }
303}
304
305/// Live-updatable, `Arc`-shareable handle to the current [`WorkspaceRegistryAccess`] setting.
306///
307/// Backed by an `AtomicU8` rather than a lock: the manifest parse path that reads this is a
308/// synchronous call inside an async fn, where a `tokio::sync::RwLock` cannot be awaited and a
309/// `std::sync::RwLock` would be unnecessary ceremony for one small `Copy` enum. `initialize`
310/// and `workspace/didChangeConfiguration` call [`Self::set`]; every manifest parse calls
311/// [`Self::get`].
312///
313/// # Examples
314///
315/// ```
316/// use deps_core::net_policy::{RegistryAccessPolicy, WorkspaceRegistryAccess};
317///
318/// let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::Off);
319/// assert_eq!(policy.get(), WorkspaceRegistryAccess::Off);
320/// policy.set(WorkspaceRegistryAccess::PublicOnly);
321/// assert_eq!(policy.get(), WorkspaceRegistryAccess::PublicOnly);
322/// ```
323#[derive(Debug)]
324pub struct RegistryAccessPolicy(AtomicU8);
325
326impl RegistryAccessPolicy {
327 /// Creates a handle initialized to `initial`.
328 #[must_use]
329 pub fn new(initial: WorkspaceRegistryAccess) -> Self {
330 Self(AtomicU8::new(initial.to_u8()))
331 }
332
333 /// The current policy.
334 #[must_use]
335 pub fn get(&self) -> WorkspaceRegistryAccess {
336 WorkspaceRegistryAccess::from_u8(self.0.load(Ordering::Relaxed))
337 }
338
339 /// Updates the current policy, effective for every parse after this call returns.
340 ///
341 /// A tightening (e.g. `All` -> `PublicOnly`/`Off`) only gates *future* parses: it does not
342 /// purge state a looser policy already produced, such as `deps-cargo`'s
343 /// `CargoRegistry::alternates` map — an already-registered alternate-registry client for a
344 /// now-blocked host stays reachable until its owning document is next re-parsed (today,
345 /// `workspace/didChangeConfiguration` does not trigger a re-parse of open documents). This
346 /// is pre-existing behavior, unrelated to this type's own storage, and unchanged by it.
347 ///
348 /// # Warning
349 ///
350 /// Calling this directly on a handle already bound to an
351 /// [`crate::cache::HttpCache`] (via [`crate::cache::HttpCache::with_policy`]) updates this
352 /// value but does not rebuild that cache's workspace transport, leaving its `AddrGuard` and
353 /// cache-key namespace on the stale policy. For a bound cache, always mutate through
354 /// [`crate::cache::HttpCache::set_registry_policy`] instead, which updates this handle and
355 /// rebuilds the transport together.
356 pub fn set(&self, value: WorkspaceRegistryAccess) {
357 self.0.store(value.to_u8(), Ordering::Relaxed);
358 }
359}
360
361impl Default for RegistryAccessPolicy {
362 fn default() -> Self {
363 Self::new(WorkspaceRegistryAccess::default())
364 }
365}
366
367/// Why a candidate registry/index URL failed [`validate_index_url`].
368///
369/// Shared by `deps-cargo`, `deps-npm`, and `deps-pypi` — each ecosystem crate either
370/// re-exports this directly (`deps-cargo`, `deps-pypi`) or wraps it in its own
371/// `From`-mapped error enum (`deps-npm`, which needs an extra `${VAR}`-expansion variant).
372///
373/// # Examples
374///
375/// ```
376/// use deps_core::net_policy::{IndexUrlError, PolicyGate, validate_index_url};
377///
378/// let err = validate_index_url("not a url", "not a url", "cargo", PolicyGate::Skip).unwrap_err();
379/// assert_eq!(err, IndexUrlError::InvalidUrl("not a url".to_string()));
380/// ```
381#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
382pub enum IndexUrlError {
383 /// The value did not parse as a URL at all.
384 #[error("not a valid URL: {0}")]
385 InvalidUrl(String),
386 /// The URL's scheme is not `https`.
387 #[error("registry index must use https, got scheme {0:?}")]
388 NotHttps(String),
389 /// The URL carries a `user:pass@`/`user@` component.
390 #[error("registry index URL must not carry userinfo")]
391 UserInfoPresent,
392 /// The candidate's host is blocked by the current [`WorkspaceRegistryAccess`] policy.
393 #[error("registry index host class {class} blocked by registries.workspace_registries policy")]
394 BlockedHost {
395 /// The blocked host's classification.
396 class: HostClass,
397 },
398}
399
400/// Whether [`validate_index_url`] must check a candidate's host against a live
401/// [`RegistryAccessPolicy`].
402///
403/// An explicit enum, not `Option`/`bool`: a trusted-provenance candidate (e.g. `deps-cargo`'s
404/// `$CARGO_HOME`-sourced `IndexTrust::Trusted`) skipping the policy check entirely is a
405/// security-relevant decision each call site must make visibly, not something that can be
406/// expressed by a `None` a reader might mistake for "no policy configured yet".
407///
408/// # Examples
409///
410/// ```
411/// use deps_core::net_policy::{PolicyGate, RegistryAccessPolicy, WorkspaceRegistryAccess, validate_index_url};
412///
413/// let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::Off);
414/// assert!(
415/// validate_index_url("https://index.mycorp.dev", "https://index.mycorp.dev", "cargo", PolicyGate::Skip)
416/// .is_ok()
417/// );
418/// assert!(
419/// validate_index_url(
420/// "https://index.mycorp.dev",
421/// "https://index.mycorp.dev",
422/// "cargo",
423/// PolicyGate::Enforce(&policy)
424/// )
425/// .is_err()
426/// );
427/// ```
428#[derive(Debug, Clone, Copy)]
429pub enum PolicyGate<'a> {
430 /// Skip the policy check entirely — the candidate's provenance is already trusted (e.g.
431 /// the user's own `$CARGO_HOME` configuration), not something a cloned repository
432 /// controls.
433 Skip,
434 /// Check the candidate's host against `policy` — the candidate's provenance is a
435 /// workspace file, which an opened repository fully controls.
436 Enforce(&'a RegistryAccessPolicy),
437}
438
439/// Replaces any embedded `user:pass@`/`user@` userinfo component in `raw` with a fixed
440/// `***@` marker, for a caller to log or retain instead of the raw credential-bearing value.
441///
442/// A userinfo-bearing index URL is always rejected ([`IndexUrlError::UserInfoPresent`]), but
443/// the *raw* value naming what was rejected must never itself carry the credential through to
444/// a `tracing::warn!` line or an `InvalidEntry`-shaped struct's `raw` field a user might see
445/// surfaced as `DependencySource::CustomRegistry`'s `url` in hover/diagnostics text. A fixed
446/// marker (rather than stripping the component outright) keeps the redacted value visibly
447/// distinct from a URL that never carried userinfo at all, so a user can still tell *that* a
448/// credential was present and removed, without ever seeing what it was. Shared by
449/// `deps-npm`'s and `deps-pypi`'s `resolve_entry` (M1 fix).
450///
451/// `raw` failing [`url::Url::parse`] is not proof it carries no userinfo (S1 finding) — an
452/// otherwise-valid `user:pass@host` can still fail to parse for a reason unrelated to the
453/// userinfo component itself (an invalid port, a malformed IPv6 literal, a non-ASCII host, or
454/// simply a missing scheme — #536 C2), so this falls back to a parse-independent redaction
455/// rather than returning `raw` untouched; the fallback scans from the `://` scheme separator
456/// when one is present, or from the very start of `raw` otherwise. Returns `raw` unchanged only
457/// when that scan finds no `@` at all — nothing looks like a userinfo component to redact.
458///
459/// # Examples
460///
461/// ```
462/// use deps_core::net_policy::redact_userinfo;
463///
464/// assert_eq!(
465/// redact_userinfo("https://user:hunter2@registry.example/simple"),
466/// "https://***@registry.example/simple"
467/// );
468/// assert_eq!(
469/// redact_userinfo("https://registry.example/simple"),
470/// "https://registry.example/simple"
471/// );
472/// assert_eq!(
473/// redact_userinfo("user:hunter2@registry.example/simple"),
474/// "***@registry.example/simple"
475/// );
476/// ```
477#[must_use]
478pub fn redact_userinfo(raw: &str) -> String {
479 let Ok(mut url) = url::Url::parse(raw) else {
480 return redact_userinfo_unparseable(raw);
481 };
482 // A schemeless `user:pass@host` literal (no `://`) does not fail `Url::parse` outright
483 // (#536 C2): the word before the first `:` parses as a valid opaque scheme (e.g.
484 // `"user"`), and with no `//` following it the whole rest becomes a cannot-be-a-base
485 // opaque path — `username()`/`password()` never see the literal userinfo that follows,
486 // since there is no authority component at all from the parser's point of view. Fall
487 // back to the same parse-independent scan used for an outright parse failure.
488 if url.cannot_be_a_base() {
489 return redact_userinfo_unparseable(raw);
490 }
491 if url.username().is_empty() && url.password().is_none() {
492 return raw.to_string();
493 }
494 // `set_username`/`set_password` only fail for a cannot-be-a-base URL — never true here,
495 // since a URL with `username()`/`password()` set is always base-having by construction —
496 // but a hardcoded fallback marker is used instead of ever risking the original,
497 // credential-bearing string leaking through an unexpected `Err` path.
498 if url.set_username("***").is_err() || url.set_password(None).is_err() {
499 return "<redacted: index URL contained userinfo>".to_string();
500 }
501 url.as_str().to_string()
502}
503
504/// [`redact_userinfo`]'s fallback for a `raw` that fails `Url::parse` outright (S1 finding):
505/// locates the `://` scheme separator when present, then the *last* `@` before the next `/`,
506/// `?`, or `#` (matching how a URL parser resolves multiple unescaped `@`s in the authority —
507/// everything up to it is userinfo, never part of the host), and replaces that whole userinfo
508/// span with `***@`. A `raw` with no `://` at all (e.g. a schemeless `user:pass@host` literal,
509/// which fails `Url::parse` for lacking a scheme rather than for any userinfo-related reason —
510/// #536 C2) is treated the same way, scanning from the very start of `raw` instead of skipping
511/// a scheme. Returns `raw` unchanged only when no `@` is found in the searched span — nothing
512/// looks like a userinfo component to redact.
513fn redact_userinfo_unparseable(raw: &str) -> String {
514 let authority_start = raw.find("://").map_or(0, |scheme_end| scheme_end + 3);
515 let authority = &raw[authority_start..];
516 let host_boundary = authority.find(['/', '?', '#']).unwrap_or(authority.len());
517 let Some(at) = authority[..host_boundary].rfind('@') else {
518 return raw.to_string();
519 };
520 format!("{}***@{}", &raw[..authority_start], &authority[at + 1..])
521}
522
523/// Whether `url`'s host is loopback (`127.0.0.1`, `localhost`, or `::1`) with an `http`
524/// scheme — the shape every `mockito::Server` binds to.
525///
526/// Only compiled into test builds (see [`validate_index_url`]): a non-loopback host must
527/// never be allowed to bypass the https requirement, even under `cfg(test)`/`test-util`.
528#[cfg(any(test, feature = "test-util"))]
529fn is_loopback_url(url: &url::Url) -> bool {
530 url.scheme() == "http" && matches!(url.host_str(), Some("127.0.0.1" | "localhost" | "::1"))
531}
532
533/// Validates a candidate registry/index URL: `https` scheme, no userinfo, and — when `gate`
534/// is [`PolicyGate::Enforce`] — a host the live [`RegistryAccessPolicy`] allows.
535///
536/// `candidate` is the string actually parsed (e.g. `deps-npm`'s already `${VAR}`-expanded
537/// value); `raw_for_log` is what an error payload and the blocked-host `tracing::warn!`
538/// name instead — the pre-expansion `.npmrc` value for `deps-npm`, or the same string as
539/// `candidate` for `deps-cargo`/`deps-pypi` (neither has an expansion step). This split
540/// keeps an environment variable's expanded value out of any log line or error a caller
541/// might surface in a diagnostic. `ecosystem` is carried on the blocked-host warning only,
542/// to tell `deps-cargo`/`deps-npm`/`deps-pypi` call sites apart in the logs.
543///
544/// The check order — parse, then https, then userinfo, then the policy gate — is
545/// load-bearing: userinfo is rejected *before* the policy gate runs, which is what lets a
546/// caller safely log `raw_for_log` unredacted on a [`IndexUrlError::BlockedHost`] warning,
547/// since a userinfo-bearing candidate can never reach that point. Do not reorder.
548///
549/// [`IndexUrlError::InvalidUrl`] is the one variant this invariant can't cover — `candidate`
550/// failed to parse *before* any userinfo check could run, so `raw_for_log` might still carry
551/// one (S1 finding: an otherwise-valid `user:pass@host` URL can fail to parse for an unrelated
552/// reason, e.g. an invalid port). [`redact_userinfo`] is applied to `raw_for_log` before it is
553/// wrapped in [`IndexUrlError::InvalidUrl`], so every caller — `deps-cargo`, `deps-npm`,
554/// `deps-pypi` — gets this for free, whether or not it separately redacts its own `raw` before
555/// logging.
556///
557/// # Errors
558///
559/// Returns [`IndexUrlError`] if `candidate` does not parse as a URL, is not `https` (outside
560/// the `cfg(test)`/`test-util` loopback carve-out), carries a userinfo component, or (under
561/// [`PolicyGate::Enforce`]) resolves to a host class the current policy blocks.
562///
563/// # Examples
564///
565/// ```
566/// use deps_core::net_policy::{PolicyGate, validate_index_url};
567///
568/// let url = validate_index_url(
569/// "https://index.mycorp.dev",
570/// "https://index.mycorp.dev",
571/// "cargo",
572/// PolicyGate::Skip,
573/// )
574/// .unwrap();
575/// assert_eq!(url.as_str(), "https://index.mycorp.dev/");
576///
577/// assert!(
578/// validate_index_url("http://example.com", "http://example.com", "cargo", PolicyGate::Skip)
579/// .is_err()
580/// );
581/// ```
582pub fn validate_index_url(
583 candidate: &str,
584 raw_for_log: &str,
585 ecosystem: &'static str,
586 gate: PolicyGate<'_>,
587) -> Result<url::Url, IndexUrlError> {
588 let url = url::Url::parse(candidate)
589 .map_err(|_| IndexUrlError::InvalidUrl(redact_userinfo(raw_for_log)))?;
590 let is_https = url.scheme() == "https";
591 #[cfg(any(test, feature = "test-util"))]
592 let is_https = is_https || is_loopback_url(&url);
593 if !is_https {
594 return Err(IndexUrlError::NotHttps(url.scheme().to_string()));
595 }
596 if !url.username().is_empty() || url.password().is_some() {
597 return Err(IndexUrlError::UserInfoPresent);
598 }
599 if let PolicyGate::Enforce(policy) = gate {
600 let class = classify_host(&url);
601 if !policy.get().allows(class) {
602 tracing::warn!(
603 url = raw_for_log,
604 ?class,
605 ecosystem,
606 "workspace-declared registry index host blocked by registries.workspace_registries policy"
607 );
608 return Err(IndexUrlError::BlockedHost { class });
609 }
610 }
611 Ok(url)
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 use std::assert_matches;
619
620 fn host_class(url: &str) -> HostClass {
621 classify_host(&url::Url::parse(url).unwrap())
622 }
623
624 #[test]
625 fn test_classify_cloud_metadata_ipv4() {
626 assert_eq!(
627 host_class("https://169.254.169.254/"),
628 HostClass::CloudMetadata
629 );
630 }
631
632 #[test]
633 fn test_classify_cloud_metadata_ipv4_mapped_v6_bypass() {
634 // The mapped-address bypass: written as an IPv6 literal embedding the same IPv4
635 // address, this must classify identically to the bare IPv4 form, not fall through
636 // to `Global` as an unrecognized v6 address.
637 assert_eq!(
638 host_class("https://[::ffff:169.254.169.254]/"),
639 HostClass::CloudMetadata
640 );
641 }
642
643 #[test]
644 fn test_classify_cloud_metadata_nat64_bypass() {
645 // The NAT64 well-known-prefix bypass (impl-critic finding, verified empirically):
646 // `64:ff9b::/96` embeds an IPv4 address and must classify identically to the bare
647 // IPv4 form, not fall through to `Global`.
648 assert_eq!(
649 host_class("https://[64:ff9b::a9fe:a9fe]/"),
650 HostClass::CloudMetadata
651 );
652 }
653
654 #[test]
655 fn test_classify_cloud_metadata_ec2_ipv6() {
656 assert_eq!(
657 host_class("https://[fd00:ec2::254]/"),
658 HostClass::CloudMetadata
659 );
660 }
661
662 #[test]
663 fn test_classify_link_local_ipv6() {
664 assert_eq!(host_class("https://[fe80::1]/"), HostClass::LinkLocal);
665 }
666
667 #[test]
668 fn test_classify_private_v4() {
669 assert_eq!(host_class("https://10.0.0.1/"), HostClass::PrivateV4);
670 }
671
672 #[test]
673 fn test_classify_cgnat() {
674 assert_eq!(host_class("https://100.64.0.1/"), HostClass::Cgnat);
675 }
676
677 #[test]
678 fn test_classify_private_v4_192_168() {
679 assert_eq!(host_class("https://192.168.1.1/"), HostClass::PrivateV4);
680 }
681
682 #[test]
683 fn test_classify_unique_local_v6() {
684 assert_eq!(host_class("https://[fc00::1]/"), HostClass::UniqueLocalV6);
685 }
686
687 #[test]
688 fn test_classify_unspecified_v4() {
689 assert_eq!(host_class("https://0.0.0.0/"), HostClass::Unspecified);
690 }
691
692 #[test]
693 fn test_classify_localhost_name() {
694 assert_eq!(host_class("https://localhost/"), HostClass::Loopback);
695 }
696
697 #[test]
698 fn test_classify_localhost_subdomain() {
699 assert_eq!(host_class("https://foo.localhost/"), HostClass::Loopback);
700 }
701
702 #[test]
703 fn test_classify_google_metadata_name() {
704 assert_eq!(
705 host_class("https://metadata.google.internal/"),
706 HostClass::CloudMetadata
707 );
708 }
709
710 #[test]
711 fn test_classify_internal_suffix_name() {
712 assert_eq!(
713 host_class("https://registry.internal/"),
714 HostClass::InternalName
715 );
716 }
717
718 #[test]
719 fn test_classify_single_label_name() {
720 assert_eq!(host_class("https://single-label/"), HostClass::InternalName);
721 }
722
723 /// S1 (security + impl-critic): `url::Url` preserves a trailing root-label dot, so a
724 /// workspace file can append one FQDN-terminating `.` and walk straight past every
725 /// name-based classification (both `PublicOnly` and the unconditional S5 redirect-hop
726 /// guard) unless `classify_name` strips it before matching.
727 #[test]
728 fn test_classify_localhost_trailing_dot() {
729 assert_eq!(host_class("https://localhost./"), HostClass::Loopback);
730 }
731
732 #[test]
733 fn test_classify_google_metadata_trailing_dot() {
734 assert_eq!(
735 host_class("https://metadata.google.internal./"),
736 HostClass::CloudMetadata
737 );
738 }
739
740 #[test]
741 fn test_classify_internal_suffix_trailing_dot() {
742 assert_eq!(
743 host_class("https://registry.internal./"),
744 HostClass::InternalName
745 );
746 }
747
748 /// Belt-and-braces (review nit): `trim_end_matches` closes the double-trailing-dot case
749 /// too, not just a single one.
750 #[test]
751 fn test_classify_localhost_double_trailing_dot() {
752 assert_eq!(host_class("https://localhost../"), HostClass::Loopback);
753 }
754
755 #[test]
756 fn test_classify_global_public_name() {
757 assert_eq!(host_class("https://index.crates.io/"), HostClass::Global);
758 }
759
760 #[test]
761 fn test_classify_addr_cloud_metadata() {
762 let addr: IpAddr = "169.254.169.254".parse().unwrap();
763 assert_eq!(classify_addr(addr), HostClass::CloudMetadata);
764 }
765
766 #[test]
767 fn test_classify_addr_private_v4() {
768 let addr: IpAddr = "10.0.0.1".parse().unwrap();
769 assert_eq!(classify_addr(addr), HostClass::PrivateV4);
770 }
771
772 #[test]
773 fn test_classify_addr_unwraps_mapped_v4() {
774 let addr: IpAddr = "::ffff:169.254.169.254".parse().unwrap();
775 assert_eq!(classify_addr(addr), HostClass::CloudMetadata);
776 }
777
778 #[test]
779 fn test_classify_addr_unwraps_nat64_cloud_metadata() {
780 // impl-critic S2: verified empirically that this classified `Global` before the fix.
781 let addr: IpAddr = "64:ff9b::a9fe:a9fe".parse().unwrap();
782 assert_eq!(classify_addr(addr), HostClass::CloudMetadata);
783 }
784
785 #[test]
786 fn test_classify_addr_unwraps_nat64_loopback() {
787 // impl-critic S2's second verified example: `64:ff9b::7f00:1` embeds `127.0.0.1`.
788 let addr: IpAddr = "64:ff9b::7f00:1".parse().unwrap();
789 assert_eq!(classify_addr(addr), HostClass::Loopback);
790 }
791
792 #[test]
793 fn test_classify_addr_global() {
794 let addr: IpAddr = "1.1.1.1".parse().unwrap();
795 assert_eq!(classify_addr(addr), HostClass::Global);
796 }
797
798 #[test]
799 fn test_never_a_registry_classes() {
800 assert!(HostClass::Loopback.never_a_registry());
801 assert!(HostClass::LinkLocal.never_a_registry());
802 assert!(HostClass::CloudMetadata.never_a_registry());
803 assert!(HostClass::Unspecified.never_a_registry());
804 assert!(!HostClass::PrivateV4.never_a_registry());
805 assert!(!HostClass::Cgnat.never_a_registry());
806 assert!(!HostClass::UniqueLocalV6.never_a_registry());
807 assert!(!HostClass::InternalName.never_a_registry());
808 assert!(!HostClass::Global.never_a_registry());
809 }
810
811 #[test]
812 fn test_workspace_registry_access_off_blocks_everything() {
813 let policy = WorkspaceRegistryAccess::Off;
814 assert!(!policy.allows(HostClass::Global));
815 assert!(!policy.allows(HostClass::PrivateV4));
816 assert!(!policy.allows(HostClass::Loopback));
817 }
818
819 #[test]
820 fn test_workspace_registry_access_public_only_allows_global_only() {
821 let policy = WorkspaceRegistryAccess::PublicOnly;
822 assert!(policy.allows(HostClass::Global));
823 assert!(!policy.allows(HostClass::PrivateV4));
824 assert!(!policy.allows(HostClass::CloudMetadata));
825 }
826
827 #[test]
828 fn test_workspace_registry_access_all_allows_everything() {
829 let policy = WorkspaceRegistryAccess::All;
830 assert!(policy.allows(HostClass::Global));
831 assert!(policy.allows(HostClass::PrivateV4));
832 assert!(policy.allows(HostClass::Loopback));
833 }
834
835 #[test]
836 fn test_registry_access_policy_default_is_public_only() {
837 let policy = RegistryAccessPolicy::default();
838 assert_eq!(policy.get(), WorkspaceRegistryAccess::PublicOnly);
839 }
840
841 #[test]
842 fn test_registry_access_policy_live_update() {
843 let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::All);
844 assert_eq!(policy.get(), WorkspaceRegistryAccess::All);
845 policy.set(WorkspaceRegistryAccess::Off);
846 assert_eq!(policy.get(), WorkspaceRegistryAccess::Off);
847 }
848
849 /// Load-bearing check order: userinfo must be rejected *before* the policy gate runs —
850 /// this is what lets a caller safely log `raw_for_log` unredacted on a `BlockedHost`
851 /// warning, since a userinfo-bearing candidate can never reach that point. This URL's
852 /// host (`169.254.169.254`) would also fail as `BlockedHost` under `Off`, so a
853 /// `UserInfoPresent` result here proves the order, not just that one check fires.
854 #[test]
855 fn test_validate_index_url_userinfo_rejected_before_policy_gate() {
856 let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::Off);
857 let result = validate_index_url(
858 "https://user:pass@169.254.169.254/",
859 "https://user:pass@169.254.169.254/",
860 "cargo",
861 PolicyGate::Enforce(&policy),
862 );
863 assert_eq!(result, Err(IndexUrlError::UserInfoPresent));
864 }
865
866 /// `PolicyGate::Skip` bypasses the policy check entirely — the same candidate accepted
867 /// under `Skip` is rejected under `Enforce` against a policy that blocks its host class,
868 /// proving the gate is truly skipped rather than defaulting to a permissive policy.
869 #[test]
870 fn test_validate_index_url_policy_gate_skip_vs_enforce() {
871 let policy = RegistryAccessPolicy::new(WorkspaceRegistryAccess::Off);
872 assert!(
873 validate_index_url(
874 "https://169.254.169.254/",
875 "https://169.254.169.254/",
876 "cargo",
877 PolicyGate::Skip
878 )
879 .is_ok()
880 );
881 assert_matches!(
882 validate_index_url(
883 "https://169.254.169.254/",
884 "https://169.254.169.254/",
885 "cargo",
886 PolicyGate::Enforce(&policy)
887 ),
888 Err(IndexUrlError::BlockedHost { .. })
889 );
890 }
891
892 #[test]
893 fn test_redact_userinfo_noop_cases() {
894 assert_eq!(
895 redact_userinfo("https://registry.example/simple"),
896 "https://registry.example/simple"
897 );
898 assert_eq!(redact_userinfo("not-a-valid-url"), "not-a-valid-url");
899 }
900
901 #[test]
902 fn test_redact_userinfo_strips_username_and_password() {
903 let redacted = redact_userinfo("https://user:hunter2@registry.example/simple");
904 assert!(!redacted.contains("hunter2"));
905 assert!(!redacted.contains("user:"));
906 assert_eq!(redacted, "https://***@registry.example/simple");
907 }
908
909 /// S1: an otherwise-userinfo-bearing URL that fails `Url::parse` for an unrelated reason
910 /// (an invalid port here) must still be redacted — `redact_userinfo` cannot rely on
911 /// `Url::parse` succeeding to find the userinfo component.
912 #[test]
913 fn test_redact_userinfo_redacts_unparseable_url_with_userinfo() {
914 let redacted = redact_userinfo("https://user:hunter2@registry.example:99999/simple");
915 assert!(!redacted.contains("hunter2"));
916 assert!(!redacted.contains("user:"));
917 assert_eq!(redacted, "https://***@registry.example:99999/simple");
918 }
919
920 /// #536 C2: a schemeless literal (no `://` at all) fails `Url::parse` for lacking a
921 /// scheme, not for any userinfo-related reason — the pre-fix fallback bailed out as soon
922 /// as it found no `://`, letting the credential through unredacted.
923 #[test]
924 fn test_redact_userinfo_redacts_schemeless_userinfo() {
925 let redacted = redact_userinfo("user:hunter2@registry.example/simple");
926 assert!(!redacted.contains("hunter2"));
927 assert!(!redacted.contains("user:"));
928 assert_eq!(redacted, "***@registry.example/simple");
929 }
930
931 #[test]
932 fn test_redact_userinfo_unparseable_no_userinfo_is_noop() {
933 assert_eq!(
934 redact_userinfo("https://registry.example:99999/simple"),
935 "https://registry.example:99999/simple"
936 );
937 }
938
939 /// S1: `IndexUrlError::InvalidUrl`'s payload is where the leak actually surfaced — every
940 /// caller (`deps-cargo`, `deps-npm`, `deps-pypi`) logs/retains this error's `%error`/
941 /// `Display`, so the redaction must happen inside `validate_index_url` itself, not rely on
942 /// each caller to redact separately.
943 #[test]
944 fn test_validate_index_url_redacts_userinfo_in_invalid_url_error() {
945 let raw = "https://user:hunter2@registry.example:99999/simple";
946 let err = validate_index_url(raw, raw, "cargo", PolicyGate::Skip).unwrap_err();
947 let IndexUrlError::InvalidUrl(redacted) = &err else {
948 panic!("expected InvalidUrl, got {err:?}");
949 };
950 assert!(!redacted.contains("hunter2"), "redacted: {redacted}");
951 assert!(!err.to_string().contains("hunter2"), "Display: {err}");
952 }
953}