Skip to main content

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}