use crate::{
agents::Agent,
commit::sign_message,
errors::AtomicResult,
parse::{parse_json_ad_string, ParseOpts},
storelike::ResourceResponse,
Resource, Storelike, Subject,
};
#[cfg(not(target_arch = "wasm32"))]
mod ssrf_guard {
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, ToSocketAddrs};
pub fn allow_private_fetch() -> bool {
static ALLOW: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ALLOW.get_or_init(|| {
matches!(
std::env::var("ATOMIC_ALLOW_PRIVATE_FETCH").as_deref(),
Ok("1") | Ok("true") | Ok("TRUE")
)
})
}
fn ipv4_is_blocked(v4: Ipv4Addr) -> bool {
let o = v4.octets();
v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_broadcast() || v4.is_unspecified() || v4.is_documentation()
|| (o[0] == 100 && (64..128).contains(&o[1])) }
fn ipv6_is_blocked(v6: Ipv6Addr) -> bool {
v6.is_loopback()
|| v6.is_unspecified()
|| v6.is_unique_local() || v6.is_unicast_link_local() || v6
.to_ipv4_mapped()
.map(ipv4_is_blocked)
.unwrap_or(false)
}
pub fn ip_is_blocked(ip: IpAddr) -> bool {
match ip {
IpAddr::V4(v4) => ipv4_is_blocked(v4),
IpAddr::V6(v6) => ipv6_is_blocked(v6),
}
}
pub fn preflight(url: &url::Url, allow_private: bool) -> Result<(), String> {
match url.scheme() {
"http" | "https" => {}
other => {
return Err(format!(
"Refusing to fetch '{url}': unsupported scheme '{other}' (only http/https allowed)"
))
}
}
if allow_private {
return Ok(());
}
let blocked_ip = match url.host() {
Some(url::Host::Ipv4(v4)) => ip_is_blocked(IpAddr::V4(v4)).then_some(IpAddr::V4(v4)),
Some(url::Host::Ipv6(v6)) => ip_is_blocked(IpAddr::V6(v6)).then_some(IpAddr::V6(v6)),
_ => None,
};
if let Some(ip) = blocked_ip {
return Err(format!(
"Refusing to fetch '{url}': target address {ip} is not a public address"
));
}
Ok(())
}
pub fn check_url(url: &url::Url) -> Result<(), String> {
preflight(url, allow_private_fetch())
}
type BoxErr = Box<dyn std::error::Error + Send + Sync>;
pub fn resolve_public(host: &str, allow_private: bool) -> std::io::Result<Vec<SocketAddr>> {
let addrs: Vec<SocketAddr> = (host, 0).to_socket_addrs()?.collect();
let public: Vec<SocketAddr> = addrs
.into_iter()
.filter(|addr| allow_private || !ip_is_blocked(addr.ip()))
.collect();
if public.is_empty() {
return Err(std::io::Error::other(format!(
"'{host}' resolved only to non-public addresses; refusing to connect"
)));
}
Ok(public)
}
#[derive(Clone, Copy, Default)]
pub struct PublicOnlyResolver;
impl reqwest::dns::Resolve for PublicOnlyResolver {
fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving {
let host = name.as_str().to_string();
Box::pin(async move {
let allow_private = allow_private_fetch();
let addrs =
tokio::task::spawn_blocking(move || resolve_public(&host, allow_private))
.await
.map_err(|e| std::io::Error::other(e.to_string()))?
.map_err(|e| Box::new(e) as BoxErr)?;
Ok(Box::new(addrs.into_iter()) as reqwest::dns::Addrs)
})
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn http_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder().timeout(std::time::Duration::from_secs(10))
}
#[cfg(target_arch = "wasm32")]
fn http_client_builder() -> reqwest::ClientBuilder {
reqwest::Client::builder()
}
#[cfg(not(target_arch = "wasm32"))]
fn untrusted_http_client_builder() -> reqwest::ClientBuilder {
let redirect_policy = reqwest::redirect::Policy::custom(|attempt| {
if attempt.previous().len() >= 10 {
return attempt.error("too many redirects");
}
match ssrf_guard::check_url(attempt.url()) {
Ok(()) => attempt.follow(),
Err(e) => attempt.error(e),
}
});
http_client_builder()
.redirect(redirect_policy)
.dns_resolver(std::sync::Arc::new(ssrf_guard::PublicOnlyResolver))
}
#[tracing::instrument(skip_all)]
pub async fn fetch_resource(
subject: &str,
store: &impl Storelike,
client_agent: Option<&Agent>,
) -> AtomicResult<ResourceResponse> {
let subject_obj = Subject::from_raw(subject, store.get_base_domain().as_deref());
let url = if subject_obj.is_did() {
let server = store.get_server_url();
format!("{}/{}", server.trim_end_matches('/'), subject)
} else {
subject.to_string()
};
let effective_agent = match client_agent {
Some(agent) if agent.subject.is_did() => {
let server = store.get_server_url();
if url.starts_with(server.trim_end_matches('/')) {
client_agent
} else {
None
}
}
_ => client_agent,
};
let body = fetch_body(&url, crate::parse::JSON_AD_MIME, effective_agent).await?;
let resources = Box::pin(parse_json_ad_string(&body, store, &ParseOpts::default()))
.await
.map_err(|e| format!("Error parsing body of {}. {}", subject, e))?;
if resources.len() == 1 {
Ok(ResourceResponse::Resource(resources[0].clone()))
} else {
let mut main_resource: Option<Resource> = None;
let mut referenced: Vec<Resource> = Vec::new();
let pure_subject = if subject_obj.is_did() {
subject_obj.pure_id()
} else {
subject.to_string()
};
for r in resources {
if r.get_subject_enum().pure_id() == pure_subject {
main_resource = Some(r);
} else {
referenced.push(r);
}
}
let Some(main_resource) = main_resource else {
return Err(format!(
"Requested subject not found in returned resources: {}",
subject
)
.into());
};
Ok(ResourceResponse::ResourceWithReferenced(
main_resource,
referenced,
))
}
}
pub fn get_authentication_headers(url: &str, agent: &Agent) -> AtomicResult<Vec<(String, String)>> {
let mut headers = Vec::new();
let now = crate::utils::now().to_string();
let message = format!("{} {}", url, now);
let signature = sign_message(
&message,
agent
.private_key
.as_ref()
.ok_or("No private key in agent")?,
&agent.public_key,
)?;
headers.push(("x-atomic-public-key".into(), agent.public_key.to_string()));
headers.push(("x-atomic-signature".into(), signature));
headers.push(("x-atomic-timestamp".into(), now));
headers.push(("x-atomic-agent".into(), agent.subject.to_string()));
Ok(headers)
}
#[tracing::instrument(skip_all)]
pub async fn fetch_body(
url: &str,
content_type: &str,
client_agent: Option<&Agent>,
) -> AtomicResult<String> {
if !url.starts_with("http") {
return Err(format!("Could not fetch url '{}', must start with http.", url).into());
}
let client = http_client_builder()
.build()
.map_err(|e| format!("Could not build HTTP client: {}", e))?;
fetch_body_with_client(&client, url, content_type, client_agent).await
}
#[tracing::instrument(skip_all)]
pub async fn fetch_body_untrusted(url: &str, content_type: &str) -> AtomicResult<String> {
#[cfg(not(target_arch = "wasm32"))]
{
let parsed = url::Url::parse(url).map_err(|e| format!("Invalid URL '{url}': {e}"))?;
if let Err(e) = ssrf_guard::check_url(&parsed) {
return Err(e.into());
}
let client = untrusted_http_client_builder()
.build()
.map_err(|e| format!("Could not build HTTP client: {}", e))?;
fetch_body_with_client(&client, url, content_type, None).await
}
#[cfg(target_arch = "wasm32")]
{
fetch_body(url, content_type, None).await
}
}
async fn fetch_body_with_client(
client: &reqwest::Client,
url: &str,
content_type: &str,
client_agent: Option<&Agent>,
) -> AtomicResult<String> {
let mut req = client.get(url).header("Accept", content_type);
if let Some(agent) = client_agent {
if should_sign_request(url, agent) {
let headers = get_authentication_headers(url, agent)?;
for (key, value) in headers {
req = req.header(key, value);
}
} else {
tracing::warn!(
"Skipping signed auth headers for cross-origin fetch. url={}, agent={}",
url,
agent.subject
);
}
}
let resp = req
.send()
.await
.map_err(|e| format!("Error when fetching {}: {}", url, e))?;
let status = resp.status().as_u16();
let body = resp
.text()
.await
.map_err(|e| format!("Could not parse HTTP response for {}: {}", url, e))?;
if status != 200 {
return Err(format!(
"Could not fetch url '{}'. Status: {}. Body: {}",
url, status, body
)
.into());
};
crate::metrics::external_fetch();
Ok(body)
}
fn should_sign_request(url: &str, agent: &Agent) -> bool {
if agent.subject.is_did() {
return true;
}
let Ok(target) = url::Url::parse(url) else {
return false;
};
let Ok(agent_url) = url::Url::parse(agent.subject.as_str()) else {
return false;
};
target.scheme() == agent_url.scheme()
&& target.host_str() == agent_url.host_str()
&& target.port_or_known_default() == agent_url.port_or_known_default()
}
pub async fn commit_to_wire_json(
commit: &crate::Commit,
store: &impl Storelike,
) -> AtomicResult<String> {
let mut json_val: serde_json::Value =
serde_json::from_str(&commit.into_resource(store).await?.to_json_ad(None)?)?;
if let Some(obj) = json_val.as_object_mut() {
obj.remove("@id");
}
Ok(serde_json::to_string(&json_val)?)
}
pub async fn post_commit(commit: &crate::Commit, store: &impl Storelike) -> AtomicResult<()> {
let subject_str = commit.get_subject();
let subject = Subject::from_raw(subject_str.as_str(), store.get_base_domain().as_deref());
let server_url = if subject.is_did() {
let mut url = store.get_server_url().to_string();
if !url.ends_with('/') {
url.push('/');
}
url
} else {
crate::utils::server_url(subject_str.as_str())?
};
let endpoint = format!("{}commit", server_url);
post_commit_custom_endpoint(&endpoint, commit, store).await
}
async fn post_commit_custom_endpoint(
endpoint: &str,
commit: &crate::Commit,
store: &impl Storelike,
) -> AtomicResult<()> {
let json = commit_to_wire_json(commit, store).await?;
let client = http_client_builder()
.build()
.map_err(|e| format!("Could not build HTTP client: {}", e))?;
let resp = client
.post(endpoint)
.header("Content-Type", "application/json")
.body(json)
.send()
.await
.map_err(|e| format!("Error when posting commit to {}: {}", endpoint, e))?;
let status = resp.status().as_u16();
if status != 200 {
let body = resp.text().await.unwrap_or_default();
Err(format!(
"Failed applying commit to {}. Status: {} Body: {}",
endpoint, status, body
)
.into())
} else {
Ok(())
}
}
#[cfg(test)]
mod test {
use super::*;
#[tokio::test]
#[ignore]
async fn fetch_resource_basic() {
let store = crate::Store::init().await.unwrap();
let resource = fetch_resource(crate::urls::SHORTNAME, &store, None)
.await
.unwrap()
.to_single();
let shortname = resource.get(crate::urls::SHORTNAME).unwrap();
assert!(shortname.to_string() == "shortname");
}
#[tokio::test]
#[ignore]
async fn post_commit_basic() {
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod ssrf_guard_tests {
use super::ssrf_guard::{ip_is_blocked, preflight, resolve_public};
use std::net::IpAddr;
fn url(s: &str) -> url::Url {
url::Url::parse(s).unwrap()
}
#[test]
fn blocks_internal_ips() {
for s in [
"127.0.0.1",
"169.254.169.254", "10.0.0.5",
"172.16.9.9",
"192.168.1.1",
"100.64.0.1", "0.0.0.0",
"::1",
"fc00::1", "fe80::1", "::ffff:127.0.0.1", ] {
let ip: IpAddr = s.parse().unwrap();
assert!(ip_is_blocked(ip), "{s} must be blocked");
}
}
#[test]
fn allows_public_ips() {
for s in [
"1.1.1.1",
"8.8.8.8",
"93.184.216.34",
"2606:4700:4700::1111",
] {
let ip: IpAddr = s.parse().unwrap();
assert!(!ip_is_blocked(ip), "{s} must be allowed");
}
}
#[test]
fn preflight_rejects_bad_scheme_and_literal_internal() {
assert!(preflight(&url("http://127.0.0.1/"), false).is_err());
assert!(preflight(&url("http://169.254.169.254/latest/meta-data/"), false).is_err());
assert!(preflight(&url("http://[::1]/"), false).is_err());
assert!(preflight(&url("http://192.168.0.1/admin"), false).is_err());
assert!(preflight(&url("ftp://example.com/"), false).is_err());
assert!(url::Url::parse("not a url").is_err());
}
#[test]
fn preflight_allows_public_literal_and_domains() {
assert!(preflight(&url("http://example.com/page"), false).is_ok());
assert!(preflight(&url("https://1.1.1.1/"), false).is_ok());
}
#[test]
fn preflight_escape_hatch_allows_internal_but_still_rejects_bad_scheme() {
assert!(preflight(&url("http://127.0.0.1/"), true).is_ok());
assert!(preflight(&url("ftp://example.com/"), true).is_err());
}
#[test]
fn resolver_rejects_loopback_domain() {
assert!(resolve_public("localhost", false).is_err());
}
#[test]
fn resolver_filters_but_keeps_public() {
let addrs = resolve_public("1.1.1.1", false).unwrap();
assert!(!addrs.is_empty());
assert!(addrs.iter().all(|sa| !ip_is_blocked(sa.ip())));
}
#[test]
fn resolver_escape_hatch_allows_loopback() {
assert!(resolve_public("localhost", true).is_ok());
}
#[tokio::test]
async fn fetch_body_untrusted_blocks_localhost_domain() {
let result = super::fetch_body_untrusted("http://localhost:1/", "text/html").await;
assert!(
result.is_err(),
"untrusted fetch of loopback-resolving domain must fail"
);
}
#[tokio::test]
async fn fetch_body_untrusted_blocks_bad_scheme() {
let result = super::fetch_body_untrusted("file:///etc/passwd", "text/html").await;
assert!(result.is_err());
}
}