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
2 changes: 1 addition & 1 deletion crates/sage-apps/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ specta = { workspace = true, features = ["derive", "function", "url"] }
specta-typescript = { workspace = true }
tauri = { workspace = true, features = ["macos-private-api"] }
tauri-plugin-dialog = "2.7.1"
tokio = { workspace = true }
tokio = { workspace = true, features = ["net"] }
tracing = { workspace = true }
reqwest = { workspace = true, features = ["json", "rustls-tls"] }
anyhow = { workspace = true }
Expand Down
6 changes: 3 additions & 3 deletions crates/sage-apps/src/lifecycle/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ use anyhow::{Context, Result as AnyResult, anyhow};

use crate::{
MANIFEST_FILE_NAME, SageAppPackageManifest, SageAppSnapshot, SageAppUrl, bytes_sha256_hex,
security::get_with_ssrf_guard,
};

pub(crate) async fn download_bytes_with_limit(url: &str, max_bytes: u64) -> AnyResult<Vec<u8>> {
let response = reqwest::get(url)
.await
.with_context(|| format!("failed to GET {url}"))?
let response = get_with_ssrf_guard(url)
.await?
.error_for_status()
.with_context(|| format!("request failed for {url}"))?;

Expand Down
2 changes: 2 additions & 0 deletions crates/sage-apps/src/security.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
mod bridge;
mod csp;
mod net;
mod protocol;

pub use csp::*;
pub use protocol::*;

pub(crate) use bridge::*;
pub(crate) use net::*;
246 changes: 246 additions & 0 deletions crates/sage-apps/src/security/net.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
//! SSRF-guarded HTTP fetching for app installs and updates.
//!
//! App URLs are user-supplied, so a download must never be allowed to reach
//! internal infrastructure (cloud metadata services, routers, LAN hosts, ...).
//! Every request hop resolves the target host up front, rejects
//! private/link-local/otherwise non-public addresses, and pins the connection
//! to the vetted addresses so DNS cannot be rebound between validation and
//! connect. Redirects are followed manually so each hop is re-validated.
//!
//! Loopback is only allowed when the URL host is explicitly loopback
//! (`localhost` / `127.0.0.1` / `::1`), which is the supported local dev flow.

use std::net::{IpAddr, Ipv4Addr, SocketAddr};

use anyhow::{Context, Result as AnyResult};
use url::{Host, Url};

const MAX_DOWNLOAD_REDIRECTS: usize = 5;

/// Performs a GET request for an app-related URL, following up to
/// [`MAX_DOWNLOAD_REDIRECTS`] redirects while validating every hop against the
/// SSRF guard. Returns the final (non-redirect) response; status handling is
/// left to the caller.
pub(crate) async fn get_with_ssrf_guard(url: &str) -> AnyResult<reqwest::Response> {
let mut current = Url::parse(url).with_context(|| format!("invalid download URL {url}"))?;
let mut redirects = 0usize;

loop {
let response = send_validated_get(&current).await?;

if !response.status().is_redirection() {
return Ok(response);
}

redirects += 1;
if redirects > MAX_DOWNLOAD_REDIRECTS {
anyhow::bail!("too many redirects while downloading {url}");
}

let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|value| value.to_str().ok())
.with_context(|| format!("redirect from {current} is missing a location header"))?;

current = current
.join(location)
.with_context(|| format!("invalid redirect location from {current}"))?;
}
}

async fn send_validated_get(url: &Url) -> AnyResult<reqwest::Response> {
let pinned_addrs = validate_download_target(url).await?;

let mut builder = reqwest::Client::builder().redirect(reqwest::redirect::Policy::none());

if let (Some(domain), Some(addrs)) = (url.domain(), pinned_addrs.as_deref()) {
// Pin the connection to the addresses we just vetted so DNS cannot be
// re-resolved (rebound) between validation and connect.
builder = builder.resolve_to_addrs(domain, addrs);
}

let client = builder.build().context("failed to build download client")?;

client
.get(url.clone())
.send()
.await
.with_context(|| format!("failed to GET {url}"))
}

/// Validates the URL's scheme and target address. For domain hosts, returns
/// the resolved addresses so the caller can pin the connection to them.
async fn validate_download_target(url: &Url) -> AnyResult<Option<Vec<SocketAddr>>> {
let allow_loopback = is_explicit_loopback_host(url);

match url.scheme() {
"https" => {}
"http" if allow_loopback => {}
scheme => anyhow::bail!("refusing to download {url}: unsupported scheme '{scheme}'"),
}

let host = url
.host()
.with_context(|| format!("download URL {url} must include a host"))?;

match host {
Host::Ipv4(ip) => {
ensure_ip_allowed(IpAddr::V4(ip), allow_loopback, url)?;
Ok(None)
}
Host::Ipv6(ip) => {
ensure_ip_allowed(IpAddr::V6(ip), allow_loopback, url)?;
Ok(None)
}
Host::Domain(domain) => {
let port = url.port_or_known_default().unwrap_or(443);

let addrs: Vec<SocketAddr> = tokio::net::lookup_host((domain, port))
.await
.with_context(|| format!("failed to resolve download host {domain}"))?
.collect();

if addrs.is_empty() {
anyhow::bail!("download host {domain} did not resolve to any address");
}

for addr in &addrs {
ensure_ip_allowed(addr.ip(), allow_loopback, url)?;
}

Ok(Some(addrs))
}
}
}

fn is_explicit_loopback_host(url: &Url) -> bool {
match url.host() {
Some(Host::Domain(domain)) => domain.eq_ignore_ascii_case("localhost"),
Some(Host::Ipv4(ip)) => ip.is_loopback(),
Some(Host::Ipv6(ip)) => ip.is_loopback(),
None => false,
}
}

fn ensure_ip_allowed(ip: IpAddr, allow_loopback: bool, url: &Url) -> AnyResult<()> {
if is_forbidden_ip(ip, allow_loopback) {
anyhow::bail!("refusing to download {url}: host resolves to disallowed address {ip}");
}

Ok(())
}

fn is_forbidden_ip(ip: IpAddr, allow_loopback: bool) -> bool {
match ip {
IpAddr::V4(ip) => is_forbidden_ipv4(ip, allow_loopback),
IpAddr::V6(ip) => {
if let Some(mapped) = ip.to_ipv4_mapped() {
return is_forbidden_ipv4(mapped, allow_loopback);
}

if ip.is_loopback() {
return !allow_loopback;
}

let first_segment = ip.segments()[0];

ip.is_unspecified()
|| ip.is_multicast()
// Unique-local addresses (fc00::/7).
|| (first_segment & 0xfe00) == 0xfc00
// Link-local addresses (fe80::/10).
|| (first_segment & 0xffc0) == 0xfe80
}
}
}

fn is_forbidden_ipv4(ip: Ipv4Addr, allow_loopback: bool) -> bool {
if ip.is_loopback() {
return !allow_loopback;
}

let octets = ip.octets();

ip.is_private()
|| ip.is_link_local()
|| ip.is_unspecified()
|| ip.is_broadcast()
|| ip.is_documentation()
|| ip.is_multicast()
// Carrier-grade NAT (100.64.0.0/10).
|| (octets[0] == 100 && (octets[1] & 0xc0) == 64)
}

#[cfg(test)]
mod tests {
use super::*;

fn forbidden(host: &str) -> bool {
let ip: IpAddr = host.parse().unwrap();
is_forbidden_ip(ip, false)
}

#[test]
fn rejects_non_public_addresses() {
for host in [
"127.0.0.1",
"10.0.0.1",
"172.16.0.1",
"192.168.1.1",
"169.254.169.254",
"100.64.0.1",
"0.0.0.0",
"255.255.255.255",
"::1",
"::",
"fc00::1",
"fd12:3456::1",
"fe80::1",
"::ffff:10.0.0.1",
"::ffff:127.0.0.1",
] {
assert!(forbidden(host), "expected {host} to be rejected");
}
}

#[test]
fn accepts_public_addresses() {
for host in ["1.1.1.1", "8.8.8.8", "104.16.0.1", "2606:4700::1111"] {
assert!(!forbidden(host), "expected {host} to be accepted");
}
}

#[test]
fn loopback_allowed_only_when_explicit() {
assert!(is_forbidden_ip("127.0.0.1".parse().unwrap(), false));
assert!(!is_forbidden_ip("127.0.0.1".parse().unwrap(), true));
assert!(is_forbidden_ip("::1".parse().unwrap(), false));
assert!(!is_forbidden_ip("::1".parse().unwrap(), true));

// Explicit loopback must not unlock other internal ranges.
assert!(is_forbidden_ip("10.0.0.1".parse().unwrap(), true));
assert!(is_forbidden_ip("169.254.169.254".parse().unwrap(), true));
}

#[test]
fn explicit_loopback_hosts_detected() {
for url in [
"http://localhost:4173/app/",
"http://127.0.0.1:4173/app/",
"http://[::1]:4173/app/",
] {
assert!(
is_explicit_loopback_host(&Url::parse(url).unwrap()),
"expected {url} to count as explicit loopback"
);
}

for url in ["https://example.com/app/", "https://10.0.0.1/app/"] {
assert!(
!is_explicit_loopback_host(&Url::parse(url).unwrap()),
"expected {url} not to count as explicit loopback"
);
}
}
}
3 changes: 2 additions & 1 deletion crates/sage-apps/src/types/app/view/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ use url::Url;
use crate::{
SageAppCommon, SageAppIdentity, SageAppPackageManifest, SageAppPackageManifestPreview,
SageAppSnapshotView, SageAppUrl, SageAppWalletScope, SageGrantedPermissionsView,
security::get_with_ssrf_guard,
};

const MAX_REMOTE_ICON_BYTES: u64 = 1024 * 1024;
Expand Down Expand Up @@ -113,7 +114,7 @@ impl SageAppIconView {
let base_url = Url::parse(base.as_str()).ok()?;
let resolved = base_url.join(icon_path).ok()?;

let resp = reqwest::get(resolved).await.ok()?;
let resp = get_with_ssrf_guard(resolved.as_str()).await.ok()?;
if !resp.status().is_success() {
return None;
}
Expand Down
31 changes: 29 additions & 2 deletions crates/sage-apps/src/types/network.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,27 @@ impl SageNetworkWhitelistEntry {
}

fn is_csp_safe_host(host: &str) -> bool {
!host.is_empty()
&& host.chars().all(|c| {
if host.is_empty()
|| !host.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '*' | ':' | '[' | ']')
})
{
return false;
}

// A wildcard is only allowed as a single leading label ("*.example.com").
// A bare "*" (or forms like "*:443" / "a*.example.com") matches every
// host in CSP, which would turn the whitelist into an open connect-src.
if host.contains('*')
&& host
.strip_prefix("*.")
.is_none_or(|rest| rest.contains('*'))
{
return false;
}

// Require at least one real label character so "*." and "." are rejected.
host.chars().any(char::is_alphanumeric)
}

#[cfg(test)]
Expand Down Expand Up @@ -171,4 +188,14 @@ mod tests {
);
}
}

#[test]
fn network_entry_rejects_match_all_wildcards() {
for host in ["*", "*:443", "*.", "a*.example.com", "*.*.example.com"] {
assert!(
SageNetworkWhitelistEntry::new("https", host).is_err(),
"expected wildcard host {host:?} to be rejected"
);
}
}
}
Loading