Compare commits

..

17 commits

Author SHA1 Message Date
754b2a0f3d Revert Object trait id() ref 2026-04-25 22:40:47 -07:00
Felix Ableitner
588f431266 Version 0.7.0-beta.11 2026-04-24 11:31:10 +02:00
Hong Minhee (洪 民憙)
838dd9e501
Add a public-aware deserializer for recipient URLs (#165)
* Accept Public aliases in URL deserializer

Update deserialize_one_or_many to deserialize recipient URL fields while
accepting `Public` and `as:Public` as aliases for the canonical
ActivityStreams public URL.

Add focused tests for single and array inputs, and verify that unrelated
string fields such as `content` are left unchanged.

https://github.com/LemmyNet/lemmy/issues/6465

* Deduplicate deserialized recipients

Drop repeated recipient URLs after deserialization so equivalent public
aliases such as `Public`, `as:Public`, and the canonical public URL do
not produce duplicate entries.

Update the helper documentation and tests to match the deduplicated
result.
2026-04-24 11:25:06 +02:00
Felix Ableitner
279d29d350 Version 0.7.0-beta.10 2026-04-15 13:39:03 +02:00
Nutomic
fcb69ebffe
Make IP check public (#164)
* Make IP check public

* change
2026-04-15 13:38:29 +02:00
Felix Ableitner
5e8e918003 Version 0.7.0-beta.9 2026-03-16 11:39:46 +01:00
Nutomic
4ae8532b17
Add some more IP checks (#162) 2026-03-16 11:11:01 +01:00
Nutomic
f47fe58285
Better IP check (#161) 2026-02-05 07:04:08 -05:00
Nutomic
f60afae428
Add to_canonical() for ip check (#160) 2026-02-04 12:05:59 +01:00
Nutomic
11f95ff384
Improve error message, allow local IP federation via env var (#158)
* Improve error message, allow local IP federation via env var (fixes #152)

* fix
2026-01-28 08:44:39 -05:00
Nutomic
9d7bd965a4
Upgrade reqwest (#159) 2026-01-28 14:40:03 +01:00
Nutomic
b5dd86ab07
Update deps (#157) 2026-01-12 08:44:16 -05:00
Nutomic
a7da04c2d8
Revert parse order for webfinger results so community comes first (#156) 2025-12-05 08:42:53 -05:00
Brad Dunbar
2acf037d79
Fix example path params (#153)
Resolves the following error:

    thread 'main' (6023907) panicked at examples/live_federation/main.rs:58:10:
    Path segments must not start with `:`. For capture groups, use `{capture}`. If you meant to literally match a segment starting with a colon, call `without_v07_checks` on the router.
2025-11-21 10:03:20 +01:00
Brad Dunbar
99505b9567
Webfinger: impl PartialEq (#155)
It'd be nice to be able to compare these in tests.
2025-11-21 09:53:13 +01:00
Brad Dunbar
06df2bc1d1
Fix future incompatibility warning (#154)
These warnings are fixed in the [0.8.x branch][commits] of
`num-bigint-dig`.

    warning: the following packages contain code that will be rejected by a future version of Rust: num-bigint-dig v0.8.4

[commits]: https://github.com/dignifiedquire/num-bigint/commits/0-8
2025-11-21 09:52:53 +01:00
Nutomic
8b2b746707
Handle null values with deserialize_last (#151) 2025-10-17 21:09:32 +08:00
20 changed files with 1261 additions and 921 deletions

View file

@ -1,5 +1,5 @@
variables:
- &rust_image "rust:1.81-bullseye"
- &rust_image "rust:1.91-bullseye"
steps:
cargo_fmt:

1821
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
[package]
name = "activitypub_federation"
version = "0.7.0-beta.8"
version = "0.7.0-beta.11"
edition = "2021"
description = "High-level Activitypub framework"
keywords = ["activitypub", "activitystreams", "federation", "fediverse"]
@ -14,10 +14,6 @@ actix-web = ["dep:actix-web", "dep:http02"]
axum = ["dep:axum", "dep:tower"]
axum-original-uri = ["dep:axum", "axum/original-uri"]
[lints.rust]
warnings = "deny"
deprecated = "deny"
[lints.clippy]
perf = { level = "deny", priority = -1 }
complexity = { level = "deny", priority = -1 }
@ -32,69 +28,71 @@ redundant_closure_for_method_calls = "deny"
unwrap_used = "deny"
[dependencies]
chrono = { version = "0.4.41", features = ["clock"], default-features = false }
serde = { version = "1.0.219", features = ["derive"] }
async-trait = "0.1.88"
url = { version = "2.5.4", features = ["serde"] }
serde_json = { version = "1.0.140", features = ["preserve_order"] }
reqwest = { version = "0.12.18", default-features = false, features = [
chrono = { version = "0.4.42", features = ["clock"], default-features = false }
serde = { version = "1.0.228", features = ["derive"] }
async-trait = "0.1.89"
url = { version = "2.5.8", features = ["serde"] }
serde_json = { version = "1.0.149", features = ["preserve_order"] }
reqwest = { version = "0.13.1", default-features = false, features = [
"json",
"stream",
"rustls-tls",
] }
reqwest-middleware = "0.4.2"
tracing = "0.1.41"
reqwest-middleware = "0.5.0"
tracing = "0.1.44"
base64 = "0.22.1"
rand = "0.8.5"
rsa = "0.9.8"
http = "1.3.1"
rsa = "0.9.10"
http = "1.4.0"
sha2 = { version = "0.10.9", features = ["oid"] }
thiserror = "2.0.12"
thiserror = "2.0.17"
derive_builder = "0.20.2"
itertools = "0.14.0"
dyn-clone = "1.0.19"
dyn-clone = "1.0.20"
enum_delegate = "0.2.0"
httpdate = "1.0.3"
http-signature-normalization-reqwest = { version = "0.13.0", default-features = false, features = [
http-signature-normalization-reqwest = { version = "0.14.0", default-features = false, features = [
"sha-2",
"middleware",
"default-spawner",
] }
http-signature-normalization = "0.7.0"
bytes = "1.10.1"
bytes = "1.11.0"
futures-core = { version = "0.3.31", default-features = false }
pin-project-lite = "0.2.16"
activitystreams-kinds = "0.3.0"
regex = { version = "1.11.1", default-features = false, features = [
regex = { version = "1.12.2", default-features = false, features = [
"std",
"unicode",
] }
tokio = { version = "1.45.0", features = [
tokio = { version = "1.49.0", features = [
"sync",
"rt",
"rt-multi-thread",
"time",
] }
futures = "0.3.31"
moka = { version = "0.12.10", features = ["future"] }
moka = { version = "0.12.12", features = ["future"] }
either = "1.15.0"
# Actix-web
actix-web = { version = "4.11.0", default-features = false, optional = true }
actix-web = { version = "4.12.1", default-features = false, optional = true }
http02 = { package = "http", version = "0.2.12", optional = true }
# Axum
axum = { version = "0.8.4", features = [
axum = { version = "0.8.8", features = [
"json",
], default-features = false, optional = true }
tower = { version = "0.5.2", optional = true }
[dev-dependencies]
anyhow = "1.0.98"
axum = { version = "0.8.4", features = ["macros"] }
axum-extra = { version = "0.10.1", features = ["typed-header"] }
anyhow = "1.0.100"
axum = { version = "0.8.8", features = ["macros"] }
axum-extra = { version = "0.12.5", features = ["typed-header"] }
env_logger = "0.11.8"
tokio = { version = "1.45.0", features = ["full"] }
tokio = { version = "1.49.0", features = ["full"] }
reqwest = { version = "0.13.1",features = [
"rustls"
] }
[profile.dev]
strip = "symbols"

View file

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

View file

@ -55,8 +55,8 @@ async fn main() -> Result<(), Error> {
info!("Listen with HTTP server on {BIND_ADDRESS}");
let config = config.clone();
let app = Router::new()
.route("/:user", get(http_get_user))
.route("/:user/inbox", post(http_post_user_inbox))
.route("/{user}", get(http_get_user))
.route("/{user}/inbox", post(http_post_user_inbox))
.route("/.well-known/webfinger", get(webfinger))
.layer(FederationMiddleware::new(config));

View file

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

View file

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

View file

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

View file

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

View file

@ -33,10 +33,10 @@ use url::Url;
///
/// - `activity`: The activity to be sent, gets converted to json
/// - `private_key`: Private key belonging to the actor who sends the activity, for signing HTTP
/// signature. Generated with [crate::http_signatures::generate_actor_keypair].
/// signature. Generated with [crate::http_signatures::generate_actor_keypair].
/// - `inboxes`: List of remote actor inboxes that should receive the activity. Ignores local actor
/// inboxes. Should be built by calling [crate::traits::Actor::shared_inbox_or_inbox]
/// for each target actor.
/// inboxes. Should be built by calling [crate::traits::Actor::shared_inbox_or_inbox]
/// for each target actor.
pub async fn queue_activity<A, Datatype, ActorType>(
activity: &A,
actor: &ActorType,

View file

@ -52,8 +52,8 @@ impl SendActivityTask {
///
/// - `activity`: The activity to be sent, gets converted to json
/// - `inboxes`: List of remote actor inboxes that should receive the activity. Ignores local actor
/// inboxes. Should be built by calling [crate::traits::Actor::shared_inbox_or_inbox]
/// for each target actor.
/// inboxes. Should be built by calling [crate::traits::Actor::shared_inbox_or_inbox]
/// for each target actor.
pub async fn prepare<A, Datatype, ActorType>(
activity: &A,
actor: &ActorType,
@ -190,7 +190,7 @@ where
// PKey is internally like an Arc<>, so cloning is ok
data.config
.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(|| {
Error::Other(format!(
"Actor {actor_id} does not contain a private key for signing"

View file

@ -20,6 +20,7 @@ use crate::{
http_signatures::sign_request,
protocol::verification::verify_domains_match,
traits::{Activity, Actor},
utils::validate_ip,
};
use async_trait::async_trait;
use bytes::Bytes;
@ -32,7 +33,6 @@ use reqwest_middleware::{ClientWithMiddleware, RequestBuilder};
use rsa::{pkcs8::DecodePrivateKey, RsaPrivateKey};
use serde::de::DeserializeOwned;
use std::{
net::IpAddr,
ops::Deref,
sync::{
atomic::{AtomicU32, Ordering},
@ -41,7 +41,6 @@ use std::{
},
time::Duration,
};
use tokio::net::lookup_host;
use url::Url;
/// Configuration for this library, with various federation related settings
@ -183,30 +182,9 @@ impl<T: Clone> FederationConfig<T> {
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 invalid_ip =
lookup_host((domain.to_owned(), 80))
.await?
.any(|addr| match addr.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 {
return Err(Error::UrlVerificationError(
"Localhost is only allowed in debug mode",
));
let allow_local = std::env::var("DANGER_FEDERATION_ALLOW_LOCAL_IP").is_ok();
if !allow_local && validate_ip(&url).await.is_err() {
return Err(Error::DomainResolveError(domain.to_string()));
}
}
@ -400,6 +378,15 @@ impl<T: Clone> Data<T> {
)
.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> {

View file

@ -28,6 +28,9 @@ pub enum Error {
/// url verification error
#[error("URL failed verification: {0}")]
UrlVerificationError(&'static str),
/// 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")]
DomainResolveError(String),
/// Incoming activity has invalid digest for body
#[error("Incoming activity has invalid digest for body")]
ActivityBodyDigestInvalid,

View file

@ -88,6 +88,7 @@ where
}
})
.filter_map(|l| l.href.clone())
.rev()
.collect();
for l in links {
@ -222,7 +223,7 @@ pub fn build_webfinger_response_with_type(
}
/// A webfinger response with information about a `Person` or other type of actor.
#[derive(Serialize, Deserialize, Debug, Default)]
#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
pub struct Webfinger {
/// The actor which is described here, for example `acct:LemmyDev@mastodon.social`
pub subject: String,
@ -237,7 +238,7 @@ pub struct Webfinger {
}
/// A single link included as part of a [Webfinger] response.
#[derive(Serialize, Deserialize, Debug, Default)]
#[derive(Serialize, Deserialize, Debug, Default, PartialEq)]
pub struct WebfingerLink {
/// Relationship of the link, such as `self` or `http://webfinger.net/rel/profile-page`
pub rel: Option<String>,

View file

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

View file

@ -1,13 +1,22 @@
//! Serde deserialization functions which help to receive differently shaped data
use serde::{Deserialize, Deserializer};
use activitystreams_kinds::public;
use itertools::Itertools;
use serde::{de::Error, Deserialize, Deserializer};
use serde_json::Value;
use url::Url;
/// Deserialize JSON single value or array into Vec.
/// Deserialize JSON single value or array into `Vec<Url>`.
///
/// Useful if your application can handle multiple values for a field, but another federated
/// 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 url::Url;
/// #[derive(serde::Deserialize)]
@ -25,24 +34,39 @@ use serde::{Deserialize, Deserializer};
/// "https://lemmy.ml/u/bob"
/// ]}"#)?;
/// assert_eq!(multiple.to.len(), 2);
/// Ok::<(), anyhow::Error>(())
pub fn deserialize_one_or_many<'de, T, D>(deserializer: D) -> Result<Vec<T>, D::Error>
///
/// let note: Note = serde_json::from_str(r#"{"to": ["Public", "as:Public"]}"#)?;
/// 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
T: Deserialize<'de>,
D: Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany<T> {
One(T),
Many(Vec<T>),
enum OneOrMany {
Many(Vec<Value>),
One(Value),
}
let result: OneOrMany<T> = Deserialize::deserialize(deserializer)?;
Ok(match result {
OneOrMany::Many(list) => list,
let result: OneOrMany = Deserialize::deserialize(deserializer)?;
let values = match result {
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.
@ -127,17 +151,24 @@ where
enum MaybeArray<T> {
Simple(T),
Array(Vec<T>),
None,
}
let result: MaybeArray<T> = Deserialize::deserialize(deserializer)?;
let result = Deserialize::deserialize(deserializer)?;
Ok(match result {
MaybeArray::Simple(value) => Some(value),
MaybeArray::Array(value) => value.into_iter().last(),
MaybeArray::None => None,
})
}
#[cfg(test)]
mod tests {
use super::deserialize_one_or_many;
use activitystreams_kinds::public;
use anyhow::Result;
use serde::Deserialize;
#[test]
fn deserialize_one_multiple_values() {
use crate::protocol::helpers::deserialize_one;
@ -153,4 +184,70 @@ mod tests {
);
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;
/// `id` field of the object
fn id(&self) -> &Url {
fn id(&self) -> Url {
match self {
Either::Left(l) => l.id(),
Either::Right(r) => r.id(),

View file

@ -53,7 +53,7 @@ pub mod tests;
/// type Kind = Note;
/// type Error = anyhow::Error;
///
/// fn id(&self) -> &Url { self.ap_id.inner() }
/// fn id(&self) -> Url { self.ap_id.inner().clone() }
///
/// 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.
@ -110,7 +110,7 @@ pub trait Object: Sized + Debug {
type Error;
/// `id` field of the object
fn id(&self) -> &Url;
fn id(&self) -> Url;
/// Returns the last time this object was updated.
///
@ -194,8 +194,8 @@ pub trait Object: Sized + Debug {
redirect_remote_object,
};
let id = self.id();
let res = if !data.config.is_local_url(id) {
redirect_remote_object(id)
let res = if !data.config.is_local_url(&id) {
redirect_remote_object(&id)
} else if !self.is_deleted() {
let json = self.into_json(data).await?;
create_http_response(json, federation_context)?

View file

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

78
src/utils.rs Normal file
View file

@ -0,0 +1,78 @@
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(())
}
}