Compare commits
8 Commits
v0.3.0
...
4cb8efb6ce
| Author | SHA1 | Date | |
|---|---|---|---|
| 4cb8efb6ce | |||
| 7171a1791a | |||
| f08d11034d | |||
| 9f9c4e769b | |||
| ca949691e4 | |||
| 62c9bf2e4e | |||
| 485c407edb | |||
| fad95f0550 |
32
.gitea/workflows/ci.yml
Normal file
32
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
fmt:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt
|
||||||
|
- run: cargo fmt --check
|
||||||
|
|
||||||
|
clippy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: clippy
|
||||||
|
- run: cargo clippy -- -D warnings
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- run: cargo test
|
||||||
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,6 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## [0.3.0] — unreleased
|
## [0.3.1] — 2026-05-29
|
||||||
|
|
||||||
|
### Breaking changes
|
||||||
|
|
||||||
|
**`RemoteActor` has five new required fields** — struct literals must include them:
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `bio` | `Option<String>` | Actor biography/summary |
|
||||||
|
| `banner_url` | `Option<String>` | Banner/header image URL |
|
||||||
|
| `followers_url` | `Option<String>` | AP followers collection URL |
|
||||||
|
| `following_url` | `Option<String>` | AP following collection URL |
|
||||||
|
| `also_known_as` | `Vec<String>` | Account aliases (for Move verification) |
|
||||||
|
|
||||||
|
These are populated automatically when k-ap fetches a remote actor (via `from_json`) and when the local `follow()` method constructs a `RemoteActor` from the fetched `DbActor`. Consuming applications only need to add the new fields to their `upsert_remote_actor` / `get_remote_actor` SQL and any custom `RemoteActor` construction sites.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [0.3.0] — 2026-05-28
|
||||||
|
|
||||||
### Breaking changes
|
### Breaking changes
|
||||||
|
|
||||||
|
|||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1368,7 +1368,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"activitypub_federation",
|
"activitypub_federation",
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "k-ap"
|
name = "k-ap"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
description = "Generic ActivityPub protocol layer"
|
description = "Generic ActivityPub protocol layer"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
16
README.md
16
README.md
@@ -8,6 +8,22 @@ Not domain-specific — no opinions about what your content type looks like.
|
|||||||
|
|
||||||
## Add as dependency
|
## Add as dependency
|
||||||
|
|
||||||
|
Via the private Gitea registry (recommended):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
k-ap = { version = "0.3.0", registry = "gitea" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Configure the registry in `.cargo/config.toml`:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[registries.gitea]
|
||||||
|
index = "sparse+https://git.gabrielkaszewski.dev/api/packages/GKaszewski/cargo/"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or via git if you don't have registry access:
|
||||||
|
|
||||||
```toml
|
```toml
|
||||||
[dependencies]
|
[dependencies]
|
||||||
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.3.0" }
|
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.3.0" }
|
||||||
|
|||||||
@@ -46,17 +46,22 @@ impl Activity for BlockActivity {
|
|||||||
if check_guards(&self.id, self.actor.inner(), data).await? {
|
if check_guards(&self.id, self.actor.inner(), data).await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
let actor_url = self.actor.inner().as_str();
|
||||||
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
|
if let Some(local_user_id) = crate::urls::extract_user_id_from_url(&self.object) {
|
||||||
let _ = data
|
let _ = data
|
||||||
.follow_repo
|
.follow_repo
|
||||||
.remove_following(local_user_id, self.actor.inner().as_str())
|
.remove_following(local_user_id, actor_url)
|
||||||
.await;
|
.await;
|
||||||
let _ = data
|
let _ = data
|
||||||
.follow_repo
|
.follow_repo
|
||||||
.remove_follower(local_user_id, self.actor.inner().as_str())
|
.remove_follower(local_user_id, actor_url)
|
||||||
|
.await;
|
||||||
|
let _ = data
|
||||||
|
.blocklist_repo
|
||||||
|
.add_blocked_actor(local_user_id, actor_url)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
tracing::info!(actor = %self.actor.inner(), "received block — removed following and follower");
|
tracing::info!(actor = %actor_url, "received block — removed relationships, recorded in blocklist");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,11 +128,18 @@ impl Activity for UndoActivity {
|
|||||||
tracing::info!(actor = %self.actor.inner(), "received Undo(Announce)");
|
tracing::info!(actor = %self.actor.inner(), "received Undo(Announce)");
|
||||||
}
|
}
|
||||||
"Block" => {
|
"Block" => {
|
||||||
// Remote actor unblocked a local user. No automatic relationship
|
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str())
|
||||||
// restoration — the blocked user would need to re-follow manually.
|
&& let Ok(url) = Url::parse(obj_url)
|
||||||
|
&& let Some(user_id) = crate::urls::extract_user_id_from_url(&url)
|
||||||
|
{
|
||||||
|
let _ = data
|
||||||
|
.blocklist_repo
|
||||||
|
.remove_blocked_actor(user_id, self.actor.inner().as_str())
|
||||||
|
.await;
|
||||||
|
}
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
actor = %self.actor.inner(),
|
actor = %self.actor.inner(),
|
||||||
"received Undo(Block) — no automatic action taken"
|
"received Undo(Block) — removed from blocklist"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
|
|||||||
@@ -133,21 +133,36 @@ pub async fn get_local_actor(
|
|||||||
user_id: uuid::Uuid,
|
user_id: uuid::Uuid,
|
||||||
data: &Data<FederationData>,
|
data: &Data<FederationData>,
|
||||||
) -> Result<DbActor, Error> {
|
) -> Result<DbActor, Error> {
|
||||||
let user = data
|
build_local_actor(
|
||||||
.user_repo
|
user_id,
|
||||||
.find_by_id(user_id)
|
&data.base_url,
|
||||||
|
data.user_repo.as_ref(),
|
||||||
|
data.actor_repo.as_ref(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(Error::from)?
|
.map_err(|e| Error::not_found(anyhow::anyhow!("{e}")))
|
||||||
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
|
}
|
||||||
|
|
||||||
let (public_key, private_key) = match data.actor_repo.get_local_actor_keypair(user_id).await? {
|
/// Build a local actor's `DbActor` from repository data. Generates a keypair
|
||||||
|
/// if one doesn't exist yet. Usable outside of a `FederationData` context
|
||||||
|
/// (e.g. during service construction).
|
||||||
|
pub async fn build_local_actor(
|
||||||
|
user_id: uuid::Uuid,
|
||||||
|
base_url: &str,
|
||||||
|
user_repo: &dyn crate::user::ApUserRepository,
|
||||||
|
actor_repo: &dyn crate::repository::ActorRepository,
|
||||||
|
) -> anyhow::Result<DbActor> {
|
||||||
|
let user = user_repo
|
||||||
|
.find_by_id(user_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("user not found: {}", user_id))?;
|
||||||
|
|
||||||
|
let (public_key, private_key) = match actor_repo.get_local_actor_keypair(user_id).await? {
|
||||||
Some(kp) => kp,
|
Some(kp) => kp,
|
||||||
None => {
|
None => {
|
||||||
let kp = generate_actor_keypair()?;
|
let kp = generate_actor_keypair()?;
|
||||||
// Zeroize the private key after storing it so the plaintext doesn't
|
|
||||||
// linger in memory beyond this scope.
|
|
||||||
let private_zeroized = Zeroizing::new(kp.private_key.clone());
|
let private_zeroized = Zeroizing::new(kp.private_key.clone());
|
||||||
data.actor_repo
|
actor_repo
|
||||||
.save_local_actor_keypair(
|
.save_local_actor_keypair(
|
||||||
user_id,
|
user_id,
|
||||||
kp.public_key.clone(),
|
kp.public_key.clone(),
|
||||||
@@ -166,7 +181,7 @@ pub async fn get_local_actor(
|
|||||||
outbox_url,
|
outbox_url,
|
||||||
followers_url,
|
followers_url,
|
||||||
following_url,
|
following_url,
|
||||||
} = ActorUrls::build(&data.base_url, user_id);
|
} = ActorUrls::build(base_url, user_id);
|
||||||
|
|
||||||
Ok(DbActor {
|
Ok(DbActor {
|
||||||
user_id,
|
user_id,
|
||||||
@@ -355,6 +370,12 @@ impl Object for DbActor {
|
|||||||
display_name: json.name.clone(),
|
display_name: json.name.clone(),
|
||||||
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
|
avatar_url: json.icon.as_ref().map(|i| i.url.to_string()),
|
||||||
outbox_url: json.outbox.as_ref().map(|u| u.to_string()),
|
outbox_url: json.outbox.as_ref().map(|u| u.to_string()),
|
||||||
|
bio: json.summary.clone(),
|
||||||
|
banner_url: json.image.as_ref().map(|i| i.url.to_string()),
|
||||||
|
followers_url: json.followers.as_ref().map(|u| u.to_string()),
|
||||||
|
following_url: json.following.as_ref().map(|u| u.to_string()),
|
||||||
|
also_known_as: json.also_known_as.clone(),
|
||||||
|
fetched_at: Some(Utc::now()),
|
||||||
};
|
};
|
||||||
data.actor_repo.upsert_remote_actor(actor).await?;
|
data.actor_repo.upsert_remote_actor(actor).await?;
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ pub struct FederationData {
|
|||||||
pub(crate) allow_registration: bool,
|
pub(crate) allow_registration: bool,
|
||||||
pub(crate) software_name: String,
|
pub(crate) software_name: String,
|
||||||
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
|
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||||
|
pub(crate) actor_cache_ttl: std::time::Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FederationData {
|
impl FederationData {
|
||||||
@@ -79,6 +80,7 @@ impl FederationData {
|
|||||||
allow_registration: bool,
|
allow_registration: bool,
|
||||||
software_name: String,
|
software_name: String,
|
||||||
event_publisher: Option<Arc<dyn EventPublisher>>,
|
event_publisher: Option<Arc<dyn EventPublisher>>,
|
||||||
|
actor_cache_ttl: std::time::Duration,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let domain = base_url
|
let domain = base_url
|
||||||
.trim_start_matches("https://")
|
.trim_start_matches("https://")
|
||||||
@@ -100,6 +102,7 @@ impl FederationData {
|
|||||||
allow_registration,
|
allow_registration,
|
||||||
software_name,
|
software_name,
|
||||||
event_publisher,
|
event_publisher,
|
||||||
|
actor_cache_ttl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use activitypub_federation::config::{Data, FederationConfig, FederationMiddlewar
|
|||||||
use activitypub_federation::error::Error as FedError;
|
use activitypub_federation::error::Error as FedError;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::actors::DbActor;
|
||||||
use crate::data::FederationData;
|
use crate::data::FederationData;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
@@ -27,7 +28,15 @@ impl ApFederationConfig {
|
|||||||
/// and accepts any URL. **Never use in production.**
|
/// and accepts any URL. **Never use in production.**
|
||||||
///
|
///
|
||||||
/// Outbound signing always uses Mastodon compat mode regardless of this flag.
|
/// Outbound signing always uses Mastodon compat mode regardless of this flag.
|
||||||
pub async fn new(data: FederationData, debug: bool) -> anyhow::Result<Self> {
|
///
|
||||||
|
/// When `signing_actor` is provided, all outgoing fetch requests (GETs) are
|
||||||
|
/// signed with that actor's keypair — required for instances with
|
||||||
|
/// authorized-fetch / Secure Mode enabled.
|
||||||
|
pub async fn new(
|
||||||
|
data: FederationData,
|
||||||
|
debug: bool,
|
||||||
|
signing_actor: Option<&DbActor>,
|
||||||
|
) -> anyhow::Result<Self> {
|
||||||
let config = if debug {
|
let config = if debug {
|
||||||
FederationConfig::builder()
|
FederationConfig::builder()
|
||||||
.domain(&data.domain)
|
.domain(&data.domain)
|
||||||
@@ -38,12 +47,16 @@ impl ApFederationConfig {
|
|||||||
.build()
|
.build()
|
||||||
.await?
|
.await?
|
||||||
} else {
|
} else {
|
||||||
FederationConfig::builder()
|
let mut builder = FederationConfig::builder();
|
||||||
|
builder
|
||||||
.domain(&data.domain)
|
.domain(&data.domain)
|
||||||
|
.url_verifier(Box::new(crate::security::SsrfVerifier))
|
||||||
.app_data(data)
|
.app_data(data)
|
||||||
.debug(false)
|
.debug(false);
|
||||||
.build()
|
if let Some(actor) = signing_actor {
|
||||||
.await?
|
builder.signed_fetch_actor(actor);
|
||||||
|
}
|
||||||
|
builder.build().await?
|
||||||
};
|
};
|
||||||
Ok(Self(config))
|
Ok(Self(config))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ pub mod inbox;
|
|||||||
pub mod nodeinfo;
|
pub mod nodeinfo;
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
pub mod repository;
|
pub mod repository;
|
||||||
|
pub(crate) mod security;
|
||||||
pub mod service;
|
pub mod service;
|
||||||
pub(crate) mod urls;
|
pub(crate) mod urls;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ pub use actor::ActorRepository;
|
|||||||
pub use blocklist::BlocklistRepository;
|
pub use blocklist::BlocklistRepository;
|
||||||
pub use follow::FollowRepository;
|
pub use follow::FollowRepository;
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum FollowerStatus {
|
pub enum FollowerStatus {
|
||||||
Pending,
|
Pending,
|
||||||
@@ -30,6 +32,15 @@ pub struct RemoteActor {
|
|||||||
pub display_name: Option<String>,
|
pub display_name: Option<String>,
|
||||||
pub avatar_url: Option<String>,
|
pub avatar_url: Option<String>,
|
||||||
pub outbox_url: Option<String>,
|
pub outbox_url: Option<String>,
|
||||||
|
pub bio: Option<String>,
|
||||||
|
pub banner_url: Option<String>,
|
||||||
|
pub followers_url: Option<String>,
|
||||||
|
pub following_url: Option<String>,
|
||||||
|
pub also_known_as: Vec<String>,
|
||||||
|
/// When this actor was last fetched from the origin instance.
|
||||||
|
/// `None` means unknown — treated as always-fresh to avoid
|
||||||
|
/// breaking existing consumers that don't populate this field.
|
||||||
|
pub fetched_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
|
|||||||
132
src/security.rs
Normal file
132
src/security.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
use std::net::IpAddr;
|
||||||
|
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
fn is_ip_private(ip: IpAddr) -> bool {
|
||||||
|
match ip {
|
||||||
|
IpAddr::V4(v4) => {
|
||||||
|
v4.is_loopback()
|
||||||
|
|| v4.is_private()
|
||||||
|
|| v4.is_link_local()
|
||||||
|
|| v4.is_broadcast()
|
||||||
|
|| v4.is_unspecified()
|
||||||
|
|| v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64 // 100.64.0.0/10
|
||||||
|
}
|
||||||
|
IpAddr::V6(v6) => {
|
||||||
|
v6.is_loopback()
|
||||||
|
|| v6.is_unspecified()
|
||||||
|
|| (v6.segments()[0] & 0xfe00) == 0xfc00 // fc00::/7 (ULA)
|
||||||
|
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // fe80::/10 (link-local)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a URL's hostname and reject private/reserved IP ranges.
|
||||||
|
pub(crate) async fn validate_url(url: &Url) -> anyhow::Result<()> {
|
||||||
|
let host = url
|
||||||
|
.host_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("URL has no host: {url}"))?;
|
||||||
|
let port = url.port_or_known_default().unwrap_or(443);
|
||||||
|
let addr = format!("{host}:{port}");
|
||||||
|
let resolved = tokio::net::lookup_host(&addr).await?;
|
||||||
|
for ip in resolved {
|
||||||
|
if is_ip_private(ip.ip()) {
|
||||||
|
anyhow::bail!("SSRF blocked: {url} resolves to private IP {}", ip.ip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct SsrfVerifier;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl activitypub_federation::config::UrlVerifier for SsrfVerifier {
|
||||||
|
async fn verify(&self, url: &Url) -> Result<(), activitypub_federation::error::Error> {
|
||||||
|
validate_url(url).await.map_err(|_| {
|
||||||
|
activitypub_federation::error::Error::UrlVerificationError(
|
||||||
|
"URL resolves to a private/reserved IP range",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv4_loopback() {
|
||||||
|
assert!(is_ip_private("127.0.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("127.255.255.255".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv4_private_10() {
|
||||||
|
assert!(is_ip_private("10.0.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("10.255.255.255".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv4_private_172() {
|
||||||
|
assert!(is_ip_private("172.16.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("172.31.255.255".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv4_private_192() {
|
||||||
|
assert!(is_ip_private("192.168.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("192.168.255.255".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv4_link_local() {
|
||||||
|
assert!(is_ip_private("169.254.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("169.254.255.255".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv4_unspecified() {
|
||||||
|
assert!(is_ip_private("0.0.0.0".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv4_cgnat() {
|
||||||
|
assert!(is_ip_private("100.64.0.1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("100.127.255.255".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_public_ipv4() {
|
||||||
|
assert!(!is_ip_private("8.8.8.8".parse().unwrap()));
|
||||||
|
assert!(!is_ip_private("1.1.1.1".parse().unwrap()));
|
||||||
|
assert!(!is_ip_private("93.184.216.34".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv6_loopback() {
|
||||||
|
assert!(is_ip_private("::1".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv6_unspecified() {
|
||||||
|
assert!(is_ip_private("::".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv6_ula() {
|
||||||
|
assert!(is_ip_private("fc00::1".parse().unwrap()));
|
||||||
|
assert!(is_ip_private("fd12:3456::1".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_ipv6_link_local() {
|
||||||
|
assert!(is_ip_private("fe80::1".parse().unwrap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_public_ipv6() {
|
||||||
|
assert!(!is_ip_private("2001:4860:4860::8888".parse().unwrap()));
|
||||||
|
assert!(!is_ip_private("2606:4700::1111".parse().unwrap()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ impl ActivityPubService {
|
|||||||
outbox_url: &str,
|
outbox_url: &str,
|
||||||
actor_url: &str,
|
actor_url: &str,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
|
let outbox_parsed = url::Url::parse(outbox_url)?;
|
||||||
|
crate::security::validate_url(&outbox_parsed).await?;
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
.timeout(std::time::Duration::from_secs(
|
.timeout(std::time::Duration::from_secs(
|
||||||
super::HTTP_FETCH_TIMEOUT_SECS,
|
super::HTTP_FETCH_TIMEOUT_SECS,
|
||||||
@@ -49,6 +51,12 @@ impl ActivityPubService {
|
|||||||
tracing::warn!(url = %current_url, "backfill: loop detected, stopping");
|
tracing::warn!(url = %current_url, "backfill: loop detected, stopping");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
if let Ok(page_url) = url::Url::parse(¤t_url)
|
||||||
|
&& let Err(e) = crate::security::validate_url(&page_url).await
|
||||||
|
{
|
||||||
|
tracing::warn!(url = %current_url, error = %e, "backfill: SSRF check failed");
|
||||||
|
break;
|
||||||
|
}
|
||||||
let page: serde_json::Value = match client
|
let page: serde_json::Value = match client
|
||||||
.get(¤t_url)
|
.get(¤t_url)
|
||||||
.header("Accept", "application/activity+json")
|
.header("Accept", "application/activity+json")
|
||||||
|
|||||||
62
src/service/fetch.rs
Normal file
62
src/service/fetch.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
use activitypub_federation::fetch::object_id::ObjectId;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::actors::DbActor;
|
||||||
|
use crate::repository::RemoteActor;
|
||||||
|
|
||||||
|
use super::ActivityPubService;
|
||||||
|
|
||||||
|
impl ActivityPubService {
|
||||||
|
/// Fetch a remote ActivityPub resource with HTTP Signatures.
|
||||||
|
///
|
||||||
|
/// Requires `signed_fetch_actor_id` to have been set on the builder.
|
||||||
|
/// Returns the raw JSON value of the remote resource.
|
||||||
|
pub async fn signed_fetch(&self, url: &Url) -> anyhow::Result<serde_json::Value> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let res = activitypub_federation::fetch::fetch_object_http::<
|
||||||
|
crate::data::FederationData,
|
||||||
|
serde_json::Value,
|
||||||
|
>(url, &data)
|
||||||
|
.await
|
||||||
|
.map_err(|e| anyhow::anyhow!("{e}"))?;
|
||||||
|
Ok(res.object)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get a cached remote actor, re-fetching from origin if stale.
|
||||||
|
///
|
||||||
|
/// Returns `None` if the actor has never been seen. Staleness is
|
||||||
|
/// determined by `actor_cache_ttl_secs` (builder config).
|
||||||
|
pub async fn get_or_refresh_remote_actor(
|
||||||
|
&self,
|
||||||
|
actor_url: &str,
|
||||||
|
) -> anyhow::Result<Option<RemoteActor>> {
|
||||||
|
let data = self.federation_config.to_request_data();
|
||||||
|
let cached = data.actor_repo.get_remote_actor(actor_url).await?;
|
||||||
|
if let Some(ref actor) = cached {
|
||||||
|
let is_fresh = actor
|
||||||
|
.fetched_at
|
||||||
|
.map(|t| {
|
||||||
|
let age = chrono::Utc::now().signed_duration_since(t);
|
||||||
|
age < chrono::Duration::from_std(data.actor_cache_ttl).unwrap_or_default()
|
||||||
|
})
|
||||||
|
.unwrap_or(true);
|
||||||
|
if is_fresh {
|
||||||
|
return Ok(cached);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let url = match Url::parse(actor_url) {
|
||||||
|
Ok(u) => u,
|
||||||
|
Err(_) => return Ok(cached),
|
||||||
|
};
|
||||||
|
match ObjectId::<DbActor>::from(url)
|
||||||
|
.dereference_forced(&data)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => Ok(data.actor_repo.get_remote_actor(actor_url).await?),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(actor_url, error = %e, "re-fetch failed, using stale cache");
|
||||||
|
Ok(cached)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,9 +37,18 @@ impl ActivityPubService {
|
|||||||
.shared_inbox_url
|
.shared_inbox_url
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|u| u.to_string()),
|
.map(|u| u.to_string()),
|
||||||
display_name: Some(remote_actor.username.clone()),
|
display_name: remote_actor
|
||||||
|
.display_name
|
||||||
|
.clone()
|
||||||
|
.or_else(|| Some(remote_actor.username.clone())),
|
||||||
avatar_url: remote_actor.avatar_url.as_ref().map(|u| u.to_string()),
|
avatar_url: remote_actor.avatar_url.as_ref().map(|u| u.to_string()),
|
||||||
outbox_url: Some(remote_actor.outbox_url.to_string()),
|
outbox_url: Some(remote_actor.outbox_url.to_string()),
|
||||||
|
bio: remote_actor.bio.clone(),
|
||||||
|
banner_url: remote_actor.banner_url.as_ref().map(|u| u.to_string()),
|
||||||
|
followers_url: Some(remote_actor.followers_url.to_string()),
|
||||||
|
following_url: Some(remote_actor.following_url.to_string()),
|
||||||
|
also_known_as: remote_actor.also_known_as.clone(),
|
||||||
|
fetched_at: Some(chrono::Utc::now()),
|
||||||
};
|
};
|
||||||
// Save BEFORE delivering — prevents lost state on process restart.
|
// Save BEFORE delivering — prevents lost state on process restart.
|
||||||
data.follow_repo
|
data.follow_repo
|
||||||
@@ -343,6 +352,12 @@ impl ActivityPubService {
|
|||||||
display_name: None,
|
display_name: None,
|
||||||
avatar_url: None,
|
avatar_url: None,
|
||||||
outbox_url: None,
|
outbox_url: None,
|
||||||
|
bio: None,
|
||||||
|
banner_url: None,
|
||||||
|
followers_url: None,
|
||||||
|
following_url: None,
|
||||||
|
also_known_as: vec![],
|
||||||
|
fetched_at: None,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
actors.push(actor);
|
actors.push(actor);
|
||||||
@@ -382,9 +397,15 @@ impl ActivityPubService {
|
|||||||
handle: format!("{}@{}", target.username, data.domain),
|
handle: format!("{}@{}", target.username, data.domain),
|
||||||
inbox_url: format!("{}/inbox", target_actor_url),
|
inbox_url: format!("{}/inbox", target_actor_url),
|
||||||
shared_inbox_url: None,
|
shared_inbox_url: None,
|
||||||
display_name: Some(target.username),
|
display_name: target.display_name.or(Some(target.username)),
|
||||||
avatar_url: None,
|
avatar_url: target.avatar_url.as_ref().map(|u| u.to_string()),
|
||||||
outbox_url: None,
|
outbox_url: Some(format!("{}/outbox", target_actor_url)),
|
||||||
|
bio: target.bio,
|
||||||
|
banner_url: target.banner_url.as_ref().map(|u| u.to_string()),
|
||||||
|
followers_url: Some(format!("{}/followers", target_actor_url)),
|
||||||
|
following_url: Some(format!("{}/following", target_actor_url)),
|
||||||
|
also_known_as: target.also_known_as,
|
||||||
|
fetched_at: None,
|
||||||
};
|
};
|
||||||
data.follow_repo
|
data.follow_repo
|
||||||
.add_following(local_user_id, target_as_remote, &follow_id)
|
.add_following(local_user_id, target_as_remote, &follow_id)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ use crate::{
|
|||||||
mod backfill;
|
mod backfill;
|
||||||
pub(crate) mod broadcast;
|
pub(crate) mod broadcast;
|
||||||
pub(super) mod delivery;
|
pub(super) mod delivery;
|
||||||
|
mod fetch;
|
||||||
mod follow;
|
mod follow;
|
||||||
|
|
||||||
/// Default max delivery retries per inbox (used as the builder default).
|
/// Default max delivery retries per inbox (used as the builder default).
|
||||||
@@ -33,6 +34,8 @@ pub const DELIVERY_INITIAL_DELAY_SECS: u64 = 1;
|
|||||||
pub const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
|
pub const HTTP_FETCH_TIMEOUT_SECS: u64 = 30;
|
||||||
/// Sleep between backfill send batches.
|
/// Sleep between backfill send batches.
|
||||||
pub const BATCH_FETCH_SLEEP_MS: u64 = 100;
|
pub const BATCH_FETCH_SLEEP_MS: u64 = 100;
|
||||||
|
/// Default actor cache TTL in seconds (24 hours).
|
||||||
|
pub const ACTOR_CACHE_TTL_SECS: u64 = 24 * 60 * 60;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub struct ActivityPubService {
|
pub struct ActivityPubService {
|
||||||
@@ -57,6 +60,8 @@ pub struct ActivityPubServiceBuilder {
|
|||||||
event_publisher: Option<Arc<dyn crate::data::EventPublisher>>,
|
event_publisher: Option<Arc<dyn crate::data::EventPublisher>>,
|
||||||
delivery_max_attempts: u32,
|
delivery_max_attempts: u32,
|
||||||
delivery_initial_delay_secs: u64,
|
delivery_initial_delay_secs: u64,
|
||||||
|
signed_fetch_actor_id: Option<uuid::Uuid>,
|
||||||
|
actor_cache_ttl_secs: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ActivityPubServiceBuilder {
|
impl ActivityPubServiceBuilder {
|
||||||
@@ -113,6 +118,21 @@ impl ActivityPubServiceBuilder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long cached remote actors are considered fresh (seconds, default 24h).
|
||||||
|
/// After this duration, the next access re-fetches the actor from origin.
|
||||||
|
pub fn actor_cache_ttl_secs(mut self, v: u64) -> Self {
|
||||||
|
self.actor_cache_ttl_secs = v;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set a local actor whose keypair signs all outgoing fetch requests
|
||||||
|
/// (HTTP Signature on GETs). Required for federating with instances
|
||||||
|
/// that enforce authorized-fetch / Secure Mode.
|
||||||
|
pub fn signed_fetch_actor_id(mut self, v: uuid::Uuid) -> Self {
|
||||||
|
self.signed_fetch_actor_id = Some(v);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn build(self) -> anyhow::Result<ActivityPubService> {
|
pub async fn build(self) -> anyhow::Result<ActivityPubService> {
|
||||||
let activity_repo = self
|
let activity_repo = self
|
||||||
.activity_repo
|
.activity_repo
|
||||||
@@ -138,17 +158,31 @@ impl ActivityPubServiceBuilder {
|
|||||||
let data = FederationData::new(
|
let data = FederationData::new(
|
||||||
activity_repo,
|
activity_repo,
|
||||||
follow_repo,
|
follow_repo,
|
||||||
actor_repo,
|
actor_repo.clone(),
|
||||||
blocklist_repo,
|
blocklist_repo,
|
||||||
user_repo,
|
user_repo.clone(),
|
||||||
content_reader,
|
content_reader,
|
||||||
object_handler,
|
object_handler,
|
||||||
self.base_url.clone(),
|
self.base_url.clone(),
|
||||||
self.allow_registration,
|
self.allow_registration,
|
||||||
self.software_name,
|
self.software_name,
|
||||||
self.event_publisher,
|
self.event_publisher,
|
||||||
|
std::time::Duration::from_secs(self.actor_cache_ttl_secs),
|
||||||
);
|
);
|
||||||
let federation_config = ApFederationConfig::new(data, self.debug).await?;
|
let signing_actor = if let Some(uid) = self.signed_fetch_actor_id {
|
||||||
|
let actor = crate::actors::build_local_actor(
|
||||||
|
uid,
|
||||||
|
&self.base_url,
|
||||||
|
user_repo.as_ref(),
|
||||||
|
actor_repo.as_ref(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Some(actor)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
let federation_config =
|
||||||
|
ApFederationConfig::new(data, self.debug, signing_actor.as_ref()).await?;
|
||||||
Ok(ActivityPubService {
|
Ok(ActivityPubService {
|
||||||
federation_config,
|
federation_config,
|
||||||
base_url: self.base_url,
|
base_url: self.base_url,
|
||||||
@@ -175,6 +209,8 @@ impl ActivityPubService {
|
|||||||
event_publisher: None,
|
event_publisher: None,
|
||||||
delivery_max_attempts: DELIVERY_MAX_ATTEMPTS,
|
delivery_max_attempts: DELIVERY_MAX_ATTEMPTS,
|
||||||
delivery_initial_delay_secs: DELIVERY_INITIAL_DELAY_SECS,
|
delivery_initial_delay_secs: DELIVERY_INITIAL_DELAY_SECS,
|
||||||
|
signed_fetch_actor_id: None,
|
||||||
|
actor_cache_ttl_secs: ACTOR_CACHE_TTL_SECS,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,6 +450,8 @@ impl ActivityPubService {
|
|||||||
domain_str, user, domain_str
|
domain_str, user, domain_str
|
||||||
);
|
);
|
||||||
tracing::debug!(handle, wf_url, "resolving webfinger");
|
tracing::debug!(handle, wf_url, "resolving webfinger");
|
||||||
|
let wf_parsed = Url::parse(&wf_url)?;
|
||||||
|
crate::security::validate_url(&wf_parsed).await?;
|
||||||
let wf: serde_json::Value = reqwest::Client::new()
|
let wf: serde_json::Value = reqwest::Client::new()
|
||||||
.get(&wf_url)
|
.get(&wf_url)
|
||||||
.header("Accept", "application/jrd+json, application/json")
|
.header("Accept", "application/jrd+json, application/json")
|
||||||
|
|||||||
@@ -520,6 +520,7 @@ async fn setup(blocklist: MemBlocklistRepo, local_user_id: uuid::Uuid) -> TestSe
|
|||||||
false,
|
false,
|
||||||
"test".to_string(),
|
"test".to_string(),
|
||||||
None,
|
None,
|
||||||
|
std::time::Duration::from_secs(24 * 60 * 60),
|
||||||
);
|
);
|
||||||
|
|
||||||
let config = FederationConfig::builder()
|
let config = FederationConfig::builder()
|
||||||
|
|||||||
@@ -374,6 +374,7 @@ fn make_data(
|
|||||||
false,
|
false,
|
||||||
"test".to_string(),
|
"test".to_string(),
|
||||||
None,
|
None,
|
||||||
|
std::time::Duration::from_secs(24 * 60 * 60),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user