Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 143 additions & 2 deletions src/core/wxc_common/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@
// Licensed under the MIT License.

use serde::{Deserialize, Serialize};
use std::net::IpAddr;

use crate::error::WxcError;

/// Selects which containment backend to use for script execution.
#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
Expand Down Expand Up @@ -378,6 +381,56 @@ impl From<crate::wire::NetworkEnforcement> for NetworkEnforcementMode {
}
}

/// A hostname-to-IP mapping that makes a sandbox resolve the proxy to exactly
/// the address the firewall authorized.
///
/// This exists instead of rewriting the proxy URL's host to the resolved IP.
/// Rewriting the host breaks TLS for an `https://`-scheme proxy: the client
/// then contacts an IP literal, so SNI and certificate validation fail unless
/// the proxy certificate carries an IP SAN. Pinning the name resolution
/// instead keeps the hostname in the URL, so TLS identity is preserved, while
/// still guaranteeing the sandbox and the firewall agree on one endpoint.
///
/// Without a pin the sandbox re-resolves the hostname itself, and under
/// round-robin or split-horizon DNS it can select an address the firewall
/// never allowed.
///
/// The fields are private and the address is an [`IpAddr`], so a pin that does
/// not denote exactly one mapping cannot be constructed. This matters because
/// [`Self::hosts_line`] is written to a hosts file: a newline or space in
/// either field would inject additional entries, letting an attacker redirect
/// names the policy never mentioned. Construct one with
/// [`ProxyAddress::host_pin`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyHostPin {
hostname: String,
ip: IpAddr,
}

impl ProxyHostPin {
/// The proxy hostname as it appears in the URL handed to the sandbox.
pub fn hostname(&self) -> &str {
&self.hostname
}

/// The address the hostname is pinned to, and the only address the
/// firewall authorizes for it.
pub fn ip(&self) -> IpAddr {
self.ip
}

/// Render this pin as a `/etc/hosts` line.
///
/// The address is written bare. A hosts file takes an unbracketed IPv6
/// literal, unlike a URL host component, and [`IpAddr`]'s `Display` is
/// already that bare form -- so the difference from
/// [`ProxyAddress::to_url`], which brackets, is structural rather than a
/// convention a caller could forget.
pub fn hosts_line(&self) -> String {
format!("{} {}", self.ip, self.hostname)
}
}

#[derive(Debug, Clone)]
pub struct ProxyAddress {
pub address: String,
Expand Down Expand Up @@ -413,12 +466,100 @@ impl ProxyAddress {
}

/// Returns the proxy URL. Uses the original URL if one was provided,
/// otherwise constructs `http://127.0.0.1:{port}` for localhost proxies.
/// otherwise constructs one from this address and port.
///
/// The constructed form names [`Self::address`] rather than assuming
/// loopback. A proxy bound to a non-loopback address is reachable through
/// [`ProxyAddress::new`], and reporting `127.0.0.1` for it would hand the
/// sandbox a different endpoint from the one the firewall authorized.
pub fn to_url(&self) -> String {
if let Some(url) = &self.original_url {
return url.clone();
}
format!("http://127.0.0.1:{}", self.port)
format!(
"http://{}:{}",
Self::bracket_if_ipv6(&self.address),
self.port
)
}

/// Returns the pin required for a sandbox to resolve this proxy's hostname
/// to `ip`.
///
/// `Ok(None)` means no pin is needed: the address is already an IP
/// literal, so there is nothing to resolve and nothing a sandbox could
/// resolve differently.
///
/// `Err` means a pin is needed but cannot be produced, because the address
/// is empty or contains characters that are not valid in a hostname. This
/// is deliberately an error rather than `None`. Returning `None` would tell
/// the caller "no hosts entry required", so a malformed address would
/// silently skip the pin and let the sandbox re-resolve the name freely --
/// failing open, which is the defect review objected to elsewhere in this
/// work. A proxy whose endpoint cannot be pinned must not run.
///
/// Taking an [`IpAddr`] rather than a string means the caller has already
/// resolved the name, and makes an unparseable address unrepresentable
/// here.
///
/// The URL is deliberately left untouched. See [`ProxyHostPin`] for why
/// rewriting the host to `ip` instead would break TLS.
pub fn host_pin(&self, ip: IpAddr) -> Result<Option<ProxyHostPin>, WxcError> {
let hostname = Self::unbracket(&self.address);

// An IP literal needs no pin. Unbracket first so a bracketed IPv6
// literal is recognized as a literal rather than mistaken for a
// hostname: `IpAddr::from_str` rejects brackets, so `[::1]` would
// otherwise be pinned as though it were a name.
if hostname.parse::<IpAddr>().is_ok() {
return Ok(None);
}

if !Self::is_pinnable_hostname(hostname) {
return Err(WxcError::NetworkProxy(format!(
"proxy address {:?} cannot be pinned to {}: not a valid hostname",
self.address, ip
)));
}

Ok(Some(ProxyHostPin {
hostname: hostname.to_string(),
ip,
}))
}

/// Whether `host` is safe to write as the name column of a hosts file
/// entry.
///
/// Rejects the empty string and anything outside the letter, digit, `-`,
/// and `.` set. That set excludes whitespace and newlines, which is the
/// property that matters: either would end the record and inject a second,
/// unauthorized mapping.
fn is_pinnable_hostname(host: &str) -> bool {
!host.is_empty()
&& host
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.')
}

/// Wraps `host` in `[` `]` when it is a bare IPv6 literal, so it is valid
/// as a URL host component.
///
/// An already-bracketed literal needs no special case. `IpAddr::from_str`
/// does not accept brackets, so a bracketed host fails to parse and falls
/// through unchanged rather than being bracketed twice.
fn bracket_if_ipv6(host: &str) -> std::borrow::Cow<'_, str> {
match host.parse::<IpAddr>() {
Ok(IpAddr::V6(_)) => std::borrow::Cow::Owned(format!("[{host}]")),
_ => std::borrow::Cow::Borrowed(host),
}
}

/// Strips one pair of surrounding `[` `]` from a bracketed IPv6 literal.
fn unbracket(host: &str) -> &str {
host.strip_prefix('[')
.and_then(|rest| rest.strip_suffix(']'))
.unwrap_or(host)
}
Comment on lines +559 to 563
}

Expand Down
Loading
Loading