Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
95 changes: 93 additions & 2 deletions src/core/wxc_common/src/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,39 @@ 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.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProxyHostPin {
/// The proxy hostname as it appears in the URL handed to the sandbox.
pub hostname: String,
/// The address the host resolved that hostname to, and the only address
/// the firewall authorizes for it.
pub ip: String,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, and I agree -- this is a real hole, not a theoretical one. hosts_line is written into /etc/hosts, so a newline in either field ends the record and starts a second, unauthorized mapping. A type whose whole job is to make the sandbox and the firewall agree on one endpoint cannot allow a value that denotes two.

Fixed in c7cca5c (implementation) and 1ba5a1c (tests).

I took the strongest of the options you listed rather than the cheapest, since there is no caller yet and this only gets more expensive later:

  • Both fields are private now, read through hostname() and ip(). ProxyAddress::host_pin is the only way to obtain one.
  • ip is an IpAddr, not a String. That makes the invalid state unrepresentable rather than merely rejected, and it drops the bracket-stripping the old code needed. It also lands the IPv6 requirement structurally: Display for IpAddr renders bare, which is what a hosts file wants, so the asymmetry with to_url -- which brackets, because a URL host component requires it -- is no longer a convention someone can forget.
  • The hostname is validated against letters, digits, -, and .. That set is chosen for what it excludes: whitespace and control characters, which are the injection vector.

One thing worth flagging, because it changed the signature: host_pin now returns Result<Option<ProxyHostPin>, WxcError>. An empty or malformed hostname used to return None, and I did not want to keep that. None means "no hosts entry required", so a malformed address would silently skip the pin, the sandbox would re-resolve the name freely, and the firewall could be bypassed. That is fail-open, which is the same defect flagged on the config path in the earlier iteration of this work. Ok(None) now means only "the address is an IP literal, nothing to resolve"; anything unpinnable is Err.

Tests: 22 black-box tests, written against the documented contract by an author who has not read models.rs. The empty-address test matches on all three arms by name -- is_err() || is_none() would have passed either way, and that distinction is the entire point of the change. Verified with a mutation harness of 11 mutants, including "accept any hostname" and "return Ok(None) instead of Err": 11 caught by a failing test, 0 survivors.

}

impl ProxyHostPin {
/// 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, so this must not reuse the
/// bracketing applied by [`ProxyAddress::to_url`].
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 +446,70 @@ 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`, or `None` when no pin is needed or possible.
///
/// `None` is returned when the address is empty, and when it is an IP
/// literal, since there is then nothing to resolve. Callers treat `None`
/// as "no hosts entry required", not as an error.
///
/// The address is unbracketed before that classification 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.
///
/// `ip` is recorded bare for the same reason [`ProxyHostPin::hosts_line`]
/// writes it bare -- a hosts file takes an unbracketed IPv6 literal.
///
/// 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: &str) -> Option<ProxyHostPin> {
let hostname = Self::unbracket(&self.address);
if hostname.is_empty() || hostname.parse::<std::net::IpAddr>().is_ok() {
return None;
}

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

/// 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::<std::net::IpAddr>() {
Ok(std::net::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