diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index ad8ee95b7..7a00916f4 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -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 { + 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 = if let Ok(ip) = host.parse::() { + 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 { let mut url = model_endpoint_url(base_url, "model endpoint")?; if url.host_str().is_some_and(model_probe_never_allowed_host) { @@ -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); diff --git a/desktop/src-tauri/src/model_connection_tests.rs b/desktop/src-tauri/src/model_connection_tests.rs index 3e3cd62a7..57863c823 100644 --- a/desktop/src-tauri/src/model_connection_tests.rs +++ b/desktop/src-tauri/src/model_connection_tests.rs @@ -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::().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::().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")); +} diff --git a/docs/windows-desktop.md b/docs/windows-desktop.md index 1855fca15..4b0f2b5dd 100644 --- a/docs/windows-desktop.md +++ b/docs/windows-desktop.md @@ -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. diff --git a/scripts/release-preflight.ts b/scripts/release-preflight.ts index 35dbe446e..d315b90bc 100644 --- a/scripts/release-preflight.ts +++ b/scripts/release-preflight.ts @@ -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",