Skip to content
111 changes: 110 additions & 1 deletion desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -901,6 +901,115 @@ fn model_probe_never_allowed_host(host: &str) -> bool {
}
}

fn forbidden_resolved_probe_ip(ip: std::net::IpAddr) -> bool {
// Keep loopback and ordinary RFC1918 IPv4 available for local/self-hosted model servers, but
// refuse address classes that a credential-bearing setup probe has no legitimate reason to
// contact. The textual metadata floor above remains in force too.
if model_probe_never_allowed_host(&ip.to_string()) {
return true;
}

match ip {
std::net::IpAddr::V4(ip) => {
let octets = ip.octets();
ip.is_unspecified()
|| ip.is_link_local()
|| ip.is_multicast()
|| ip.is_broadcast()
// Carrier-grade NAT space contains metadata endpoints on some clouds.
|| (octets[0] == 100 && (64..=127).contains(&octets[1]))
}
std::net::IpAddr::V6(ip) => {
// IPv4 can be carried inside IPv6 syntax. Apply the exact same resolved-address
// policy to mapped, legacy compatible and well-known NAT64 forms so an AAAA answer
// cannot turn a blocked IPv4 destination into an allowed credential-bearing probe.
let bytes = ip.octets();
let mapped =
bytes[..10].iter().all(|byte| *byte == 0) && bytes[10] == 0xff && bytes[11] == 0xff;
let compatible = bytes[..12].iter().all(|byte| *byte == 0);
let nat64 = bytes[..4] == [0x00, 0x64, 0xff, 0x9b]
&& bytes[4..12].iter().all(|byte| *byte == 0);
if mapped || compatible || nat64 {
return forbidden_resolved_probe_ip(std::net::IpAddr::V4(std::net::Ipv4Addr::new(
bytes[12], bytes[13], bytes[14], bytes[15],
)));
}

let first = ip.segments()[0];
ip.is_unspecified()
// fe80::/10. Written explicitly because Ipv6Addr::is_unicast_link_local is newer
// than this desktop crate's Rust 1.77 MSRV.
|| (first & 0xffc0) == 0xfe80
|| ip.is_multicast()
// Unique-local IPv6 can expose machine-local infrastructure. Local model servers
// remain available over loopback and ordinary private IPv4.
|| (first & 0xfe00) == 0xfc00
}
}
}

fn protected_model_probe_client(url: &reqwest::Url) -> Result<reqwest::Client, Problem> {
use std::net::ToSocketAddrs;

if !url.username().is_empty() || url.password().is_some() {
return Err("Do not put credentials in the model endpoint URL.".into());
}

let host = url
.host_str()
.ok_or_else(|| Problem::plain("The model endpoint has no host."))?;
if model_probe_never_allowed_host(host) {
return Err(
"That model endpoint is reserved for machine metadata and cannot be tested.".into(),
);
}
let port = url
.port_or_known_default()
.ok_or_else(|| Problem::plain("The model endpoint has no usable port."))?;

let addresses: Vec<std::net::SocketAddr> = if let Ok(ip) = host.parse::<std::net::IpAddr>() {
vec![std::net::SocketAddr::new(ip, port)]
} else {
(host, port)
.to_socket_addrs()
.map_err(|error| {
Problem::with(
"OpenBot could not resolve the model endpoint.",
error.to_string(),
)
})?
.collect()
};

if addresses.is_empty() {
return Err("The model endpoint did not resolve to an address.".into());
}
if addresses
.iter()
.any(|address| forbidden_resolved_probe_ip(address.ip()))
{
return Err(
"That model endpoint resolves to a machine-metadata or special-use address that OpenBot will not probe."
.into(),
);
}

// Resolve once, validate every result, then pin this client to those exact addresses. This
// closes the DNS-rebinding window between validation and the credential-bearing request while
// retaining the original hostname for TLS SNI/certificate verification.
reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.timeout(std::time::Duration::from_secs(10))
.resolve_to_addrs(host, &addresses)
.build()
.map_err(|error| {
Problem::with(
"OpenBot could not prepare the protected endpoint test.",
error.to_string(),
)
})
}

fn models_probe_url(base_url: &str) -> Result<reqwest::Url, Problem> {
let mut url = model_endpoint_url(base_url, "model endpoint")?;
if url.host_str().is_some_and(model_probe_never_allowed_host) {
Expand Down Expand Up @@ -995,8 +1104,8 @@ async fn test_model_connection(
model,
..
} => {
let client = model_probe_client()?;
let url = models_probe_url(&base_url)?;
let client = protected_model_probe_client(&url)?;
let mut request = client.get(url);
if !api_key.trim().is_empty() {
request = request.bearer_auth(api_key);
Expand Down
58 changes: 58 additions & 0 deletions desktop/src-tauri/src/model_connection_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,61 @@ fn provider_probe_client_never_follows_redirects() {
// so a future refactor cannot silently turn credential forwarding back on.
assert!(model_probe_client().is_ok());
}

#[test]
fn protected_probe_blocks_resolved_special_use_ranges() {
for ip in [
"0.0.0.0",
"169.254.1.1",
"169.254.169.254",
"169.254.170.2",
"224.0.0.1",
"100.64.0.1",
"100.100.100.200",
"100.127.255.254",
"::",
"fe80::1",
"fd00:ec2::254",
"ff02::1",
"::ffff:169.254.1.1",
"::ffff:100.64.0.1",
"::169.254.1.1",
"64:ff9b::6440:1",
] {
let parsed = ip.parse::<std::net::IpAddr>().expect("test IP");
assert!(
forbidden_resolved_probe_ip(parsed),
"{ip} unexpectedly passed resolved-address validation"
);
}
}

#[test]
fn protected_probe_keeps_loopback_and_private_model_hosts_available() {
for ip in [
"127.0.0.1",
"10.0.0.8",
"172.16.0.5",
"192.168.1.20",
"::1",
"::ffff:127.0.0.1",
"::ffff:10.0.0.8",
"64:ff9b::808:808",
] {
let parsed = ip.parse::<std::net::IpAddr>().expect("test IP");
assert!(
!forbidden_resolved_probe_ip(parsed),
"{ip} was unexpectedly blocked"
);
}

let local = reqwest::Url::parse("http://127.0.0.1:11434/v1/models").unwrap();
assert!(protected_model_probe_client(&local).is_ok());
}

#[test]
fn protected_probe_rejects_credentials_embedded_in_endpoint_url() {
let url = reqwest::Url::parse("https://user:secret@127.0.0.1:8443/v1/models").unwrap();
let error = protected_model_probe_client(&url).expect_err("userinfo must be refused");
assert!(error.said.contains("credentials"));
}
11 changes: 7 additions & 4 deletions docs/windows-desktop.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,13 @@ Per-coworker Model/API settings, Instructions, Skills and Knowledge can then be
without editing source files.

For API-key providers, **Test connection** makes a bounded native request to the provider without
saving the credential. Compatible endpoints are probed at their OpenAI-style `/models` route and
never forward a credential through an HTTP redirect. Plan sign-ins validate that their current or
saved session is still resolvable. The final setup question to the Bot remains the end-to-end check
that the local stack and selected model can actually answer together.
saving the credential. Compatible endpoints are probed at their OpenAI-style `/models` route,
never forward a credential through an HTTP redirect, and resolve once before the request so every
address can be checked and pinned against DNS rebinding. Machine-metadata, link-local, multicast,
unspecified and other special-use destinations are refused while ordinary loopback/private model
servers remain available. Plan sign-ins validate that their current or saved session is still
resolvable. The final setup question to the Bot remains the end-to-end check that the local stack
and selected model can actually answer together.

A failed migration or partially started local stack stops startup instead of presenting a
half-migrated deployment as ready.
Expand Down
4 changes: 4 additions & 0 deletions scripts/release-preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1279,6 +1279,10 @@ function checkProviderConnectionTest(): void {
"must not contain credentials",
"cannot be saved",
"model_probe_never_allowed_host",
"protected_model_probe_client",
"forbidden_resolved_probe_ip",
"return forbidden_resolved_probe_ip(std::net::IpAddr::V4(",
".resolve_to_addrs(host, &addresses)",
"metadata.google.internal",
"169, 254, 169, 254",
"0x00, 0x64, 0xff, 0x9b",
Expand Down
Loading