Compare commits

..

2 commits

Author SHA1 Message Date
Felix Ableitner
555902adec Merge branch 'main' into to-canonical 2026-02-04 11:49:19 +01:00
Felix Ableitner
ec34fc9e99 Add to_canonical() for ip check 2026-02-04 11:47:39 +01:00
16 changed files with 67 additions and 222 deletions

2
Cargo.lock generated
View file

@ -4,7 +4,7 @@ version = 4
[[package]] [[package]]
name = "activitypub_federation" name = "activitypub_federation"
version = "0.7.0-beta.11" version = "0.7.0-beta.8"
dependencies = [ dependencies = [
"activitystreams-kinds", "activitystreams-kinds",
"actix-web", "actix-web",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "activitypub_federation" name = "activitypub_federation"
version = "0.7.0-beta.11" version = "0.7.0-beta.8"
edition = "2021" edition = "2021"
description = "High-level Activitypub framework" description = "High-level Activitypub framework"
keywords = ["activitypub", "activitystreams", "federation", "fediverse"] keywords = ["activitypub", "activitystreams", "federation", "fediverse"]
@ -14,6 +14,10 @@ actix-web = ["dep:actix-web", "dep:http02"]
axum = ["dep:axum", "dep:tower"] axum = ["dep:axum", "dep:tower"]
axum-original-uri = ["dep:axum", "axum/original-uri"] axum-original-uri = ["dep:axum", "axum/original-uri"]
[lints.rust]
warnings = "deny"
deprecated = "deny"
[lints.clippy] [lints.clippy]
perf = { level = "deny", priority = -1 } perf = { level = "deny", priority = -1 }
complexity = { level = "deny", priority = -1 } complexity = { level = "deny", priority = -1 }

View file

@ -32,10 +32,10 @@ impl Object for SearchableDbObjects {
type Kind = SearchableObjects; type Kind = SearchableObjects;
type Error = anyhow::Error; type Error = anyhow::Error;
fn id(&self) -> Url { fn id(&self) -> &Url {
match self { match self {
SearchableDbObjects::User(p) => p.federation_id.clone(), SearchableDbObjects::User(p) => &p.federation_id,
SearchableDbObjects::Post(n) => n.federation_id.clone(), SearchableDbObjects::Post(n) => &n.federation_id,
} }
} }

View file

@ -69,8 +69,8 @@ impl Object for DbUser {
type Kind = Person; type Kind = Person;
type Error = Error; type Error = Error;
fn id(&self) -> Url { fn id(&self) -> &Url {
self.ap_id.inner().clone() self.ap_id.inner()
} }
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> { fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {

View file

@ -50,8 +50,8 @@ impl Object for DbPost {
type Kind = Note; type Kind = Note;
type Error = Error; type Error = Error;
fn id(&self) -> Url { fn id(&self) -> &Url {
self.ap_id.inner().clone() self.ap_id.inner()
} }
async fn read_from_id( async fn read_from_id(

View file

@ -134,8 +134,8 @@ impl Object for DbUser {
type Kind = Person; type Kind = Person;
type Error = Error; type Error = Error;
fn id(&self) -> Url { fn id(&self) -> &Url {
self.ap_id.inner().clone() self.ap_id.inner()
} }
fn last_refreshed_at(&self) -> Option<DateTime<Utc>> { fn last_refreshed_at(&self) -> Option<DateTime<Utc>> {

View file

@ -47,8 +47,8 @@ impl Object for DbPost {
type Kind = Note; type Kind = Note;
type Error = Error; type Error = Error;
fn id(&self) -> Url { fn id(&self) -> &Url {
self.ap_id.inner().clone() self.ap_id.inner()
} }
async fn read_from_id( async fn read_from_id(

View file

@ -190,7 +190,7 @@ where
// PKey is internally like an Arc<>, so cloning is ok // PKey is internally like an Arc<>, so cloning is ok
data.config data.config
.actor_pkey_cache .actor_pkey_cache
.try_get_with_by_ref(&actor_id, async { .try_get_with_by_ref(actor_id, async {
let private_key_pem = actor.private_key_pem().ok_or_else(|| { let private_key_pem = actor.private_key_pem().ok_or_else(|| {
Error::Other(format!( Error::Other(format!(
"Actor {actor_id} does not contain a private key for signing" "Actor {actor_id} does not contain a private key for signing"

View file

@ -20,12 +20,12 @@ use crate::{
http_signatures::sign_request, http_signatures::sign_request,
protocol::verification::verify_domains_match, protocol::verification::verify_domains_match,
traits::{Activity, Actor}, traits::{Activity, Actor},
utils::validate_ip,
}; };
use async_trait::async_trait; use async_trait::async_trait;
use bytes::Bytes; use bytes::Bytes;
use derive_builder::Builder; use derive_builder::Builder;
use dyn_clone::{clone_trait_object, DynClone}; use dyn_clone::{clone_trait_object, DynClone};
use itertools::Itertools;
use moka::future::Cache; use moka::future::Cache;
use regex::Regex; use regex::Regex;
use reqwest::{redirect::Policy, Client, Request}; use reqwest::{redirect::Policy, Client, Request};
@ -33,6 +33,7 @@ use reqwest_middleware::{ClientWithMiddleware, RequestBuilder};
use rsa::{pkcs8::DecodePrivateKey, RsaPrivateKey}; use rsa::{pkcs8::DecodePrivateKey, RsaPrivateKey};
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use std::{ use std::{
net::IpAddr,
ops::Deref, ops::Deref,
sync::{ sync::{
atomic::{AtomicU32, Ordering}, atomic::{AtomicU32, Ordering},
@ -41,6 +42,7 @@ use std::{
}, },
time::Duration, time::Duration,
}; };
use tokio::net::lookup_host;
use url::Url; use url::Url;
/// Configuration for this library, with various federation related settings /// Configuration for this library, with various federation related settings
@ -182,9 +184,31 @@ impl<T: Clone> FederationConfig<T> {
return Err(Error::UrlVerificationError("Explicit port is not allowed")); return Err(Error::UrlVerificationError("Explicit port is not allowed"));
} }
// Resolve domain and see if it points to private IP
// TODO: Use is_global() once stabilized
// https://doc.rust-lang.org/std/net/enum.IpAddr.html#method.is_global
let mut ips = lookup_host((domain.to_owned(), 80))
.await?
.map(|s| s.ip().to_canonical());
let allow_local = std::env::var("DANGER_FEDERATION_ALLOW_LOCAL_IP").is_ok(); let allow_local = std::env::var("DANGER_FEDERATION_ALLOW_LOCAL_IP").is_ok();
if !allow_local && validate_ip(&url).await.is_err() { let invalid_ip = !allow_local
return Err(Error::DomainResolveError(domain.to_string())); && ips.any(|ip| match ip {
IpAddr::V4(addr) => {
addr.is_private()
|| addr.is_link_local()
|| addr.is_loopback()
|| addr.is_multicast()
}
IpAddr::V6(addr) => {
addr.is_loopback()
|| addr.is_multicast()
|| ((addr.segments()[0] & 0xfe00) == 0xfc00) // is_unique_local
|| ((addr.segments()[0] & 0xffc0) == 0xfe80) // is_unicast_link_local
}
});
if invalid_ip {
let ip_addrs = ips.join(", ");
return Err(Error::DomainResolveError(domain.to_string(), ip_addrs));
} }
} }
@ -378,15 +402,6 @@ impl<T: Clone> Data<T> {
) )
.await .await
} }
/// Resolve domain of the url and throw error if it points to local/private IP.
pub async fn is_valid_ip(&self, url: &Url) -> Result<(), Error> {
if self.config.debug {
return Ok(());
}
validate_ip(url).await
}
} }
impl<T: Clone> Deref for Data<T> { impl<T: Clone> Deref for Data<T> {

View file

@ -29,8 +29,8 @@ pub enum Error {
#[error("URL failed verification: {0}")] #[error("URL failed verification: {0}")]
UrlVerificationError(&'static str), UrlVerificationError(&'static str),
/// Resolving domain points to local IP. /// Resolving domain points to local IP.
#[error("Resolving domain {0} points to local IP address. This may indicate an attacker attempting to access internal services. If intentional, you can ignore this error by setting DANGER_FEDERATION_ALLOW_LOCAL_IP=1")] #[error("Resolving domain {0} points to local IP {1}. This may indicate an attacker attempting to access internal services. If intentional, you can ignore this error by setting DANGER_FEDERATION_ALLOW_LOCAL_IP=1")]
DomainResolveError(String), DomainResolveError(String, String),
/// Incoming activity has invalid digest for body /// Incoming activity has invalid digest for body
#[error("Incoming activity has invalid digest for body")] #[error("Incoming activity has invalid digest for body")]
ActivityBodyDigestInvalid, ActivityBodyDigestInvalid,

View file

@ -23,7 +23,6 @@ pub mod http_signatures;
pub mod protocol; pub mod protocol;
pub(crate) mod reqwest_shim; pub(crate) mod reqwest_shim;
pub mod traits; pub mod traits;
mod utils;
use crate::{ use crate::{
config::Data, config::Data,

View file

@ -1,22 +1,13 @@
//! Serde deserialization functions which help to receive differently shaped data //! Serde deserialization functions which help to receive differently shaped data
use activitystreams_kinds::public; use serde::{Deserialize, Deserializer};
use itertools::Itertools;
use serde::{de::Error, Deserialize, Deserializer};
use serde_json::Value;
use url::Url;
/// Deserialize JSON single value or array into `Vec<Url>`. /// Deserialize JSON single value or array into Vec.
/// ///
/// Useful if your application can handle multiple values for a field, but another federated /// Useful if your application can handle multiple values for a field, but another federated
/// platform only sends a single one. /// platform only sends a single one.
/// ///
/// Also accepts common `Public` aliases for recipient fields. Some implementations send `Public`
/// or `as:Public` instead of the canonical `https://www.w3.org/ns/activitystreams#Public` URL
/// in fields such as `to` and `cc`.
///
/// ``` /// ```
/// # use activitypub_federation::kinds::public;
/// # use activitypub_federation::protocol::helpers::deserialize_one_or_many; /// # use activitypub_federation::protocol::helpers::deserialize_one_or_many;
/// # use url::Url; /// # use url::Url;
/// #[derive(serde::Deserialize)] /// #[derive(serde::Deserialize)]
@ -34,39 +25,24 @@ use url::Url;
/// "https://lemmy.ml/u/bob" /// "https://lemmy.ml/u/bob"
/// ]}"#)?; /// ]}"#)?;
/// assert_eq!(multiple.to.len(), 2); /// assert_eq!(multiple.to.len(), 2);
/// /// Ok::<(), anyhow::Error>(())
/// let note: Note = serde_json::from_str(r#"{"to": ["Public", "as:Public"]}"#)?; pub fn deserialize_one_or_many<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
/// assert_eq!(note.to, vec![public()]);
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn deserialize_one_or_many<'de, D>(deserializer: D) -> Result<Vec<Url>, D::Error>
where where
T: Deserialize<'de>,
D: Deserializer<'de>, D: Deserializer<'de>,
{ {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(untagged)] #[serde(untagged)]
enum OneOrMany { enum OneOrMany<T> {
Many(Vec<Value>), One(T),
One(Value), Many(Vec<T>),
} }
let result: OneOrMany = Deserialize::deserialize(deserializer)?; let result: OneOrMany<T> = Deserialize::deserialize(deserializer)?;
let values = match result { Ok(match result {
OneOrMany::Many(list) => list,
OneOrMany::One(value) => vec![value], OneOrMany::One(value) => vec![value],
OneOrMany::Many(values) => values, })
};
values
.into_iter()
.map(|value| match value {
Value::String(value) if matches!(value.as_str(), "Public" | "as:Public") => {
Ok(public())
}
Value::String(value) => Url::parse(&value).map_err(D::Error::custom),
value => Url::deserialize(value).map_err(D::Error::custom),
})
.collect::<Result<Vec<_>, _>>()
.map(|values| values.into_iter().unique().collect())
} }
/// Deserialize JSON single value or single element array into single value. /// Deserialize JSON single value or single element array into single value.
@ -164,11 +140,6 @@ where
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::deserialize_one_or_many;
use activitystreams_kinds::public;
use anyhow::Result;
use serde::Deserialize;
#[test] #[test]
fn deserialize_one_multiple_values() { fn deserialize_one_multiple_values() {
use crate::protocol::helpers::deserialize_one; use crate::protocol::helpers::deserialize_one;
@ -184,70 +155,4 @@ mod tests {
); );
assert!(note.is_err()); assert!(note.is_err());
} }
#[test]
fn deserialize_one_or_many_single_public_aliases() -> Result<()> {
use url::Url;
#[derive(Deserialize)]
struct Note {
#[serde(deserialize_with = "deserialize_one_or_many")]
to: Vec<Url>,
}
for alias in ["Public", "as:Public"] {
let note = serde_json::from_str::<Note>(&format!(r#"{{"to": "{alias}"}}"#))?;
assert_eq!(note.to, vec![public()]);
}
Ok(())
}
#[test]
fn deserialize_one_or_many_array() -> Result<()> {
use url::Url;
#[derive(Deserialize)]
struct Note {
#[serde(deserialize_with = "deserialize_one_or_many")]
to: Vec<Url>,
}
let note = serde_json::from_str::<Note>(
r#"{
"to": [
"https://example.com/c/main",
"Public",
"as:Public",
"https://www.w3.org/ns/activitystreams#Public"
]
}"#,
)?;
assert_eq!(
note.to,
vec![Url::parse("https://example.com/c/main")?, public(),]
);
Ok(())
}
#[test]
fn deserialize_one_or_many_leaves_other_strings_unchanged() -> Result<()> {
use url::Url;
#[derive(Deserialize)]
struct Note {
#[serde(deserialize_with = "deserialize_one_or_many")]
to: Vec<Url>,
content: String,
}
let note = serde_json::from_str::<Note>(r#"{"to": "Public", "content": "Public"}"#)?;
assert_eq!(note.to, vec![public()]);
assert_eq!(note.content, "Public");
Ok(())
}
} }

View file

@ -30,7 +30,7 @@ where
type Error = E; type Error = E;
/// `id` field of the object /// `id` field of the object
fn id(&self) -> Url { fn id(&self) -> &Url {
match self { match self {
Either::Left(l) => l.id(), Either::Left(l) => l.id(),
Either::Right(r) => r.id(), Either::Right(r) => r.id(),

View file

@ -53,7 +53,7 @@ pub mod tests;
/// type Kind = Note; /// type Kind = Note;
/// type Error = anyhow::Error; /// type Error = anyhow::Error;
/// ///
/// fn id(&self) -> Url { self.ap_id.inner().clone() } /// fn id(&self) -> &Url { self.ap_id.inner() }
/// ///
/// async fn read_from_id(object_id: Url, data: &Data<Self::DataType>) -> Result<Option<Self>, Self::Error> { /// async fn read_from_id(object_id: Url, data: &Data<Self::DataType>) -> Result<Option<Self>, Self::Error> {
/// // Attempt to read object from local database. Return Ok(None) if not found. /// // Attempt to read object from local database. Return Ok(None) if not found.
@ -110,7 +110,7 @@ pub trait Object: Sized + Debug {
type Error; type Error;
/// `id` field of the object /// `id` field of the object
fn id(&self) -> Url; fn id(&self) -> &Url;
/// Returns the last time this object was updated. /// Returns the last time this object was updated.
/// ///
@ -194,8 +194,8 @@ pub trait Object: Sized + Debug {
redirect_remote_object, redirect_remote_object,
}; };
let id = self.id(); let id = self.id();
let res = if !data.config.is_local_url(&id) { let res = if !data.config.is_local_url(id) {
redirect_remote_object(&id) redirect_remote_object(id)
} else if !self.is_deleted() { } else if !self.is_deleted() {
let json = self.into_json(data).await?; let json = self.into_json(data).await?;
create_http_response(json, federation_context)? create_http_response(json, federation_context)?

View file

@ -73,8 +73,8 @@ impl Object for DbUser {
type Kind = Person; type Kind = Person;
type Error = Error; type Error = Error;
fn id(&self) -> Url { fn id(&self) -> &Url {
self.federation_id.clone() &self.federation_id
} }
async fn read_from_id( async fn read_from_id(
@ -179,7 +179,7 @@ impl Object for DbPost {
type Kind = Note; type Kind = Note;
type Error = Error; type Error = Error;
fn id(&self) -> Url { fn id(&self) -> &Url {
todo!() todo!()
} }

View file

@ -1,78 +0,0 @@
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use crate::error::Error;
use tokio::net::lookup_host;
use url::{Host, Url};
// TODO: Use is_global() once stabilized
// https://doc.rust-lang.org/std/net/enum.IpAddr.html#method.is_global
pub(crate) async fn validate_ip(url: &Url) -> Result<(), Error> {
let mut ip = vec![];
let host = url
.host()
.ok_or(Error::UrlVerificationError("Url must have a domain"))?;
match host {
Host::Domain(domain) => ip.extend(
lookup_host((domain.to_owned(), 80))
.await?
.map(|s| s.ip().to_canonical()),
),
Host::Ipv4(ipv4) => ip.push(ipv4.into()),
Host::Ipv6(ipv6) => ip.push(ipv6.into()),
};
let invalid_ip = ip.into_iter().any(|addr| match addr {
IpAddr::V4(addr) => v4_is_invalid(addr),
IpAddr::V6(addr) => v6_is_invalid(addr),
});
if invalid_ip {
return Err(Error::DomainResolveError(host.to_string()));
}
Ok(())
}
fn v4_is_invalid(v4: Ipv4Addr) -> bool {
v4.is_private()
|| v4.is_loopback()
|| v4.is_link_local()
|| v4.is_multicast()
|| v4.is_documentation()
|| v4.is_unspecified()
|| v4.is_broadcast()
}
fn v6_is_invalid(v6: Ipv6Addr) -> bool {
v6.is_loopback()
|| v6.is_multicast()
|| v6.is_unique_local()
|| v6.is_unicast_link_local()
|| v6.is_unspecified()
|| v6_is_documentation(v6)
|| v6.to_ipv4_mapped().is_some_and(v4_is_invalid)
}
fn v6_is_documentation(v6: std::net::Ipv6Addr) -> bool {
matches!(
v6.segments(),
[0x2001, 0xdb8, ..] | [0x3fff, 0..=0x0fff, ..]
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod test {
use super::*;
#[tokio::test]
async fn test_is_valid_ip() -> Result<(), Error> {
assert!(validate_ip(&Url::parse("http://example.com")?)
.await
.is_ok());
assert!(validate_ip(&Url::parse("http://172.66.147.243")?)
.await
.is_ok());
assert!(validate_ip(&Url::parse("http://localhost")?).await.is_err());
assert!(validate_ip(&Url::parse("http://127.0.0.1")?).await.is_err());
Ok(())
}
}