16 Commits

Author SHA1 Message Date
bb7db58848 chore: add tracing to inbox, delivery, actor cache
All checks were successful
CI / fmt (push) Successful in 31s
CI / clippy (push) Successful in 4m0s
CI / test (push) Successful in 5m55s
2026-07-11 10:39:43 +02:00
6e1e1357fb fix: treat fetched_at=None as stale, bump 0.4.4 2026-07-11 10:37:11 +02:00
00ec90fb03 feat: add publish target to Makefile for Gitea registry
All checks were successful
CI / fmt (push) Successful in 27s
CI / clippy (push) Successful in 4m16s
CI / test (push) Successful in 5m23s
2026-07-11 10:18:55 +02:00
997047fdbd feat: update NodeInfo 2.0 response to include $schema field and bump version to 0.4.3
Some checks failed
CI / fmt (push) Successful in 31s
CI / test (push) Has been cancelled
CI / clippy (push) Has been cancelled
2026-07-11 10:18:18 +02:00
902d38cc76 fix: clippy warnings, clean up accept.rs
All checks were successful
CI / fmt (push) Successful in 34s
CI / clippy (push) Successful in 4m29s
CI / test (push) Successful in 6m32s
2026-07-10 17:13:22 +02:00
f461f633b3 feat: emit OutboundFollowAccepted when remote accepts our follow
v0.4.2 — consumers can now react to outbound follow acceptance
(e.g. import_remote_outbox to backfill). Previously the accept
handler silently updated the DB with no event for consumers.
2026-07-10 16:59:46 +02:00
9355f20616 fix: send Undo(Block) on unblock_actor
All checks were successful
CI / fmt (push) Successful in 20s
CI / clippy (push) Successful in 2m33s
CI / test (push) Successful in 3m31s
2026-06-30 01:49:30 +02:00
d1ce277ff5 chore: bump to 0.4.0, update changelog
All checks were successful
CI / fmt (push) Successful in 21s
CI / clippy (push) Successful in 2m51s
CI / test (push) Successful in 3m49s
2026-05-30 02:50:46 +02:00
4cb8efb6ce feat: SSRF protection — block private IP ranges on outgoing requests
Some checks failed
CI / fmt (push) Successful in 23s
CI / test (push) Has been cancelled
CI / clippy (push) Has been cancelled
SsrfVerifier rejects private/reserved IPs (loopback, RFC1918, link-local,
CGNAT, ULA) on all federation fetches. Raw reqwest calls in webfinger and
backfill also validated. Debug mode bypasses via PermissiveVerifier.

Closes #4
2026-05-30 02:48:35 +02:00
7171a1791a feat: actor cache TTL with staleness-aware re-fetch
Adds fetched_at to RemoteActor, configurable TTL via builder
(.actor_cache_ttl_secs, default 24h), and get_or_refresh_remote_actor
helper that re-fetches stale actors from origin.

Closes #3
2026-05-30 02:46:54 +02:00
f08d11034d feat: expose signed_fetch for authorized-fetch / Secure Mode
Builder: .signed_fetch_actor_id(uuid) sets instance-level signing actor.
Service: .signed_fetch(&url) performs a signed GET returning raw JSON.

Closes #2
2026-05-30 02:43:51 +02:00
9f9c4e769b fix: persist inbound Block to blocklist, clear on Undo(Block)
Closes #1
2026-05-30 02:39:14 +02:00
ca949691e4 ci: add Gitea CI workflow (fmt, clippy, tests)
All checks were successful
CI / fmt (push) Successful in 18s
CI / clippy (push) Successful in 2m37s
CI / test (push) Successful in 3m43s
2026-05-30 02:26:54 +02:00
62c9bf2e4e fix: add missing RemoteActor fields in get_blocked_actors fallback 2026-05-29 04:04:08 +02:00
485c407edb feat(RemoteActor): add bio, banner_url, followers_url, following_url, also_known_as fields
Bump to 0.3.1. These fields are available on DbActor at follow/ingest
time but were discarded when constructing RemoteActor. Now populated
in from_json and follow(), so consuming repos can store and return
rich actor profiles without extra queries.
2026-05-29 04:03:23 +02:00
fad95f0550 docs: update README with Gitea registry installation 2026-05-29 03:36:22 +02:00
25 changed files with 575 additions and 45 deletions

32
.gitea/workflows/ci.yml Normal file
View 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

View File

@@ -1,6 +1,88 @@
# Changelog
## [0.3.0] — unreleased
## [0.4.4] — 2026-07-11
### Bug fixes
- Actor cache TTL now treats `fetched_at: None` as always-stale instead of always-fresh. Consumers who never populated `fetched_at` were silently getting no cache invalidation.
### Other improvements
- Structured tracing on key code paths: inbound activity dispatch (activity ID, source domain), outbound delivery (inbox count, retry attempts), actor cache lookups (hit/miss, staleness).
---
## [0.4.3] — 2026-07-11
### Bug fixes
- NodeInfo 2.0 response now includes the required `$schema` field (`http://nodeinfo.diaspora.software/ns/schema/2.0#`). Validators like browser.pub rejected the previous response for missing it.
---
## [0.4.2] — 2026-07-10
### New features
- `FederationEvent::OutboundFollowAccepted { local_user_id, remote_actor_url, outbox_url }` — emitted when a remote actor accepts our outbound follow request. Consumers should call `import_remote_outbox(outbox_url, actor_url)` to backfill the remote user's content. Without this event, outbound follow acceptance was silent and consumers had no hook to trigger backfill.
---
## [0.4.1] — 2026-06-30
### Bug fixes
- `unblock_actor` now sends an `Undo(Block)` activity to the remote actor's inbox before removing them from the local blocklist, making unblocking AP-compliant.
---
## [0.4.0] — 2026-05-30
### Breaking changes
**`RemoteActor` has a new required field `fetched_at: Option<DateTime<Utc>>`** — set to `Some(Utc::now())` when fetched from a remote instance, or `None` for locally-constructed actors. Consumers must add this column to their `upsert_remote_actor` / `get_remote_actor` implementations.
**`ApFederationConfig::new()` signature changed** — now takes an additional `signing_actor: Option<&DbActor>` parameter. Internal to consumers using `ApFederationConfig` directly; builder users are unaffected.
**`FederationData::new()` takes an additional `actor_cache_ttl: Duration` parameter** — only affects consumers constructing `FederationData` directly (e.g. tests).
---
### New features
**Signed fetch for authorized-fetch / Secure Mode** — set `.signed_fetch_actor_id(uuid)` on the builder to sign all outgoing GET requests with that actor's keypair. Call `service.signed_fetch(&url)` to fetch any remote AP resource with signatures.
**Actor cache TTL**`fetched_at` is now tracked on `RemoteActor`. Configure staleness via `.actor_cache_ttl_secs(secs)` (default: 24h). Use `get_or_refresh_remote_actor(actor_url)` for TTL-aware lookups that re-fetch stale actors from origin.
**SSRF protection** — all outgoing HTTP requests (federation fetches, WebFinger, backfill) now validate resolved IPs against private/reserved ranges (127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, CGNAT 100.64/10, ::1, fc00::/7, fe80::/10). Debug mode bypasses this check.
---
### Bug fixes
**Inbound `Block` now persists to `BlocklistRepository`**`BlockActivity::receive()` calls `add_blocked_actor()` after removing follower/following relationships. `Undo(Block)` clears the record via `remove_blocked_actor()`.
---
## [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

2
Cargo.lock generated
View File

@@ -1368,7 +1368,7 @@ dependencies = [
[[package]]
name = "k-ap"
version = "0.3.0"
version = "0.4.4"
dependencies = [
"activitypub_federation",
"anyhow",

View File

@@ -1,6 +1,6 @@
[package]
name = "k-ap"
version = "0.3.0"
version = "0.4.4"
edition = "2024"
description = "Generic ActivityPub protocol layer"
license = "MIT"

View File

@@ -25,4 +25,8 @@ fix:
cargo fmt
cargo clippy --fix --allow-dirty --allow-staged
.PHONY: check fmt fmt-check clippy test fix
# Publish to the gitea registry.
publish: check
cargo publish --registry gitea
.PHONY: check fmt fmt-check clippy test fix publish

View File

@@ -8,6 +8,22 @@ Not domain-specific — no opinions about what your content type looks like.
## 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
[dependencies]
k-ap = { git = "https://git.gabrielkaszewski.dev/GKaszewski/k-ap.git", tag = "v0.3.0" }

View File

@@ -49,14 +49,28 @@ impl Activity for AcceptActivity {
}
let local_user_id = crate::urls::extract_user_id_from_url(self.object.actor.inner())
.ok_or_else(|| Error::bad_request(anyhow::anyhow!("invalid actor URL in Follow")))?;
let remote_actor_url = self.actor.inner().as_str().to_string();
data.follow_repo
.update_following_status(
local_user_id,
self.actor.inner().as_str(),
FollowingStatus::Accepted,
)
.update_following_status(local_user_id, &remote_actor_url, FollowingStatus::Accepted)
.await?;
tracing::info!(remote_actor = %self.actor.inner(), "follow accepted by remote");
tracing::info!(remote_actor = %remote_actor_url, "follow accepted by remote");
if let Some(publisher) = &data.event_publisher {
let outbox_url = data
.actor_repo
.get_remote_actor(&remote_actor_url)
.await
.ok()
.flatten()
.and_then(|a| a.outbox_url);
let _ = publisher
.publish(crate::data::FederationEvent::OutboundFollowAccepted {
local_user_id,
remote_actor_url,
outbox_url,
})
.await;
}
Ok(())
}
}

View File

@@ -46,17 +46,22 @@ impl Activity for BlockActivity {
if check_guards(&self.id, self.actor.inner(), data).await? {
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) {
let _ = data
.follow_repo
.remove_following(local_user_id, self.actor.inner().as_str())
.remove_following(local_user_id, actor_url)
.await;
let _ = data
.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;
}
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(())
}
}

View File

@@ -35,10 +35,11 @@ pub(crate) async fn check_guards(
actor: &Url,
data: &Data<FederationData>,
) -> Result<bool, Error> {
let domain = actor.host_str().unwrap_or("");
tracing::info!(activity_id = %id, source_domain = domain, "processing inbound activity");
if already_processed(id, data).await {
return Ok(true);
}
let domain = actor.host_str().unwrap_or("");
if data.blocklist_repo.is_domain_blocked(domain).await? {
tracing::info!(actor = %actor, "ignoring activity from blocked domain");
return Ok(true);

View File

@@ -128,11 +128,18 @@ impl Activity for UndoActivity {
tracing::info!(actor = %self.actor.inner(), "received Undo(Announce)");
}
"Block" => {
// Remote actor unblocked a local user. No automatic relationship
// restoration — the blocked user would need to re-follow manually.
if let Some(obj_url) = self.object.get("object").and_then(|o| o.as_str())
&& 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!(
actor = %self.actor.inner(),
"received Undo(Block) — no automatic action taken"
"received Undo(Block) — removed from blocklist"
);
}
other => {

View File

@@ -119,11 +119,11 @@ impl ActorUrls {
fn build(base_url: &str, user_id: uuid::Uuid) -> Self {
let ap_id = crate::urls::actor_url(base_url, user_id);
Self {
inbox_url: Url::parse(&format!("{}/inbox", &ap_id)).expect("valid url"),
inbox_url: Url::parse(&format!("{}/inbox", ap_id)).expect("valid url"),
shared_inbox_url: Url::parse(&format!("{}/inbox", base_url)).ok(),
outbox_url: Url::parse(&format!("{}/outbox", &ap_id)).expect("valid url"),
followers_url: Url::parse(&format!("{}/followers", &ap_id)).expect("valid url"),
following_url: Url::parse(&format!("{}/following", &ap_id)).expect("valid url"),
outbox_url: Url::parse(&format!("{}/outbox", ap_id)).expect("valid url"),
followers_url: Url::parse(&format!("{}/followers", ap_id)).expect("valid url"),
following_url: Url::parse(&format!("{}/following", ap_id)).expect("valid url"),
ap_id,
}
}
@@ -133,21 +133,36 @@ pub async fn get_local_actor(
user_id: uuid::Uuid,
data: &Data<FederationData>,
) -> Result<DbActor, Error> {
let user = data
.user_repo
.find_by_id(user_id)
build_local_actor(
user_id,
&data.base_url,
data.user_repo.as_ref(),
data.actor_repo.as_ref(),
)
.await
.map_err(Error::from)?
.ok_or_else(|| Error::not_found(anyhow::anyhow!("user not found: {}", user_id)))?;
.map_err(|e| Error::not_found(anyhow::anyhow!("{e}")))
}
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,
None => {
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());
data.actor_repo
actor_repo
.save_local_actor_keypair(
user_id,
kp.public_key.clone(),
@@ -166,7 +181,7 @@ pub async fn get_local_actor(
outbox_url,
followers_url,
following_url,
} = ActorUrls::build(&data.base_url, user_id);
} = ActorUrls::build(base_url, user_id);
Ok(DbActor {
user_id,
@@ -270,7 +285,7 @@ impl Object for DbActor {
async fn into_json(self, data: &Data<Self::DataType>) -> Result<Self::Kind, Self::Error> {
let public_key = PublicKey {
id: format!("{}#main-key", &self.ap_id),
id: format!("{}#main-key", self.ap_id),
owner: self.ap_id.clone(),
public_key_pem: self.public_key_pem.clone(),
};
@@ -355,6 +370,12 @@ impl Object for DbActor {
display_name: json.name.clone(),
avatar_url: json.icon.as_ref().map(|i| i.url.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?;

View File

@@ -41,6 +41,13 @@ pub enum FederationEvent {
owner_user_id: uuid::Uuid,
follower_inbox_url: String,
},
/// A remote actor accepted our outbound follow request.
/// Call `ActivityPubService::import_remote_outbox(outbox_url, actor_url)` to backfill.
OutboundFollowAccepted {
local_user_id: uuid::Uuid,
remote_actor_url: String,
outbox_url: Option<String>,
},
}
/// Receives typed federation events.
@@ -63,6 +70,7 @@ pub struct FederationData {
pub(crate) allow_registration: bool,
pub(crate) software_name: String,
pub(crate) event_publisher: Option<Arc<dyn EventPublisher>>,
pub(crate) actor_cache_ttl: std::time::Duration,
}
impl FederationData {
@@ -79,6 +87,7 @@ impl FederationData {
allow_registration: bool,
software_name: String,
event_publisher: Option<Arc<dyn EventPublisher>>,
actor_cache_ttl: std::time::Duration,
) -> Self {
let domain = base_url
.trim_start_matches("https://")
@@ -100,6 +109,7 @@ impl FederationData {
allow_registration,
software_name,
event_publisher,
actor_cache_ttl,
}
}
}

View File

@@ -2,6 +2,7 @@ use activitypub_federation::config::{Data, FederationConfig, FederationMiddlewar
use activitypub_federation::error::Error as FedError;
use url::Url;
use crate::actors::DbActor;
use crate::data::FederationData;
#[derive(Clone)]
@@ -27,7 +28,15 @@ impl ApFederationConfig {
/// and accepts any URL. **Never use in production.**
///
/// 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 {
FederationConfig::builder()
.domain(&data.domain)
@@ -38,12 +47,16 @@ impl ApFederationConfig {
.build()
.await?
} else {
FederationConfig::builder()
let mut builder = FederationConfig::builder();
builder
.domain(&data.domain)
.url_verifier(Box::new(crate::security::SsrfVerifier))
.app_data(data)
.debug(false)
.build()
.await?
.debug(false);
if let Some(actor) = signing_actor {
builder.signed_fetch_actor(actor);
}
builder.build().await?
};
Ok(Self(config))
}

View File

@@ -11,6 +11,7 @@ pub mod inbox;
pub mod nodeinfo;
pub mod outbox;
pub mod repository;
pub(crate) mod security;
pub mod service;
pub(crate) mod urls;
pub mod user;

View File

@@ -6,6 +6,7 @@ use crate::data::FederationData;
use crate::error::Error;
const NODEINFO_2_0_REL: &str = "http://nodeinfo.diaspora.software/ns/schema/2.0";
const NODEINFO_2_0_SCHEMA: &str = "http://nodeinfo.diaspora.software/ns/schema/2.0#";
#[derive(Serialize)]
pub struct NodeInfoWellKnown {
@@ -39,6 +40,8 @@ pub struct NodeInfoUsers {
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct NodeInfo {
#[serde(rename = "$schema")]
pub schema: String,
pub version: String,
pub software: NodeInfoSoftware,
pub protocols: Vec<String>,
@@ -63,6 +66,7 @@ pub async fn nodeinfo_handler(data: Data<FederationData>) -> Result<Json<NodeInf
let local_posts = data.content_reader.count_local_posts().await.unwrap_or(0);
Ok(Json(NodeInfo {
schema: NODEINFO_2_0_SCHEMA.to_string(),
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: data.software_name.clone(),

View File

@@ -8,6 +8,8 @@ pub use actor::ActorRepository;
pub use blocklist::BlocklistRepository;
pub use follow::FollowRepository;
use chrono::{DateTime, Utc};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FollowerStatus {
Pending,
@@ -30,6 +32,15 @@ pub struct RemoteActor {
pub display_name: Option<String>,
pub avatar_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)]

132
src/security.rs Normal file
View 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()));
}
}

View File

@@ -21,6 +21,8 @@ impl ActivityPubService {
outbox_url: &str,
actor_url: &str,
) -> anyhow::Result<()> {
let outbox_parsed = url::Url::parse(outbox_url)?;
crate::security::validate_url(&outbox_parsed).await?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(
super::HTTP_FETCH_TIMEOUT_SECS,
@@ -49,6 +51,12 @@ impl ActivityPubService {
tracing::warn!(url = %current_url, "backfill: loop detected, stopping");
break;
}
if let Ok(page_url) = url::Url::parse(&current_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
.get(&current_url)
.header("Accept", "application/activity+json")

View File

@@ -16,6 +16,11 @@ pub(crate) async fn send_with_retry(
max_attempts: u32,
initial_delay_secs: u64,
) -> Vec<anyhow::Error> {
tracing::info!(
inbox_count = sends.len(),
max_attempts,
"starting outbound delivery"
);
let mut failures = vec![];
for send in sends {
let mut delay = std::time::Duration::from_secs(initial_delay_secs);

73
src/service/fetch.rs Normal file
View File

@@ -0,0 +1,73 @@
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_else(|| {
tracing::debug!(actor_url, "fetched_at is None, treating as stale — consider populating fetched_at in get_remote_actor()");
false
});
tracing::debug!(
actor_url,
cache_hit = true,
fresh = is_fresh,
"actor cache lookup"
);
if is_fresh {
return Ok(cached);
}
} else {
tracing::debug!(actor_url, cache_hit = false, "actor cache lookup");
}
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)
}
}
}
}

View File

@@ -37,9 +37,18 @@ impl ActivityPubService {
.shared_inbox_url
.as_ref()
.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()),
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.
data.follow_repo
@@ -319,7 +328,32 @@ impl ActivityPubService {
let data = self.federation_config.to_request_data();
data.blocklist_repo
.remove_blocked_actor(local_user_id, actor_url)
.await?;
let local_actor = get_local_actor(local_user_id, &data)
.await
.map_err(|e| anyhow::anyhow!("{e}"))?;
if let Ok(Some(remote_actor)) = data.actor_repo.get_remote_actor(actor_url).await {
let block = crate::activities::BlockActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: Url::parse(actor_url)?,
};
let undo = UndoActivity {
id: activity_url(&self.base_url).map_err(|e| anyhow::anyhow!("{e}"))?,
kind: Default::default(),
actor: ObjectId::from(local_actor.ap_id.clone()),
object: serde_json::to_value(&block).map_err(|e| anyhow::anyhow!("{e}"))?,
};
let inbox = Url::parse(&remote_actor.inbox_url)?;
let (json, sends, inboxes) = self
.prepare_broadcast(&data, &local_actor, vec![inbox], undo)
.await?;
self.dispatch_deliveries(&data, &local_actor, inboxes, sends, json)
.await?;
tracing::info!(actor = %actor_url, "sent Undo(Block)");
}
Ok(())
}
pub async fn get_blocked_actors(
@@ -343,6 +377,12 @@ impl ActivityPubService {
display_name: None,
avatar_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);
@@ -382,9 +422,15 @@ impl ActivityPubService {
handle: format!("{}@{}", target.username, data.domain),
inbox_url: format!("{}/inbox", target_actor_url),
shared_inbox_url: None,
display_name: Some(target.username),
avatar_url: None,
outbox_url: None,
display_name: target.display_name.or(Some(target.username)),
avatar_url: target.avatar_url.as_ref().map(|u| u.to_string()),
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
.add_following(local_user_id, target_as_remote, &follow_id)

View File

@@ -23,6 +23,7 @@ use crate::{
mod backfill;
pub(crate) mod broadcast;
pub(super) mod delivery;
mod fetch;
mod follow;
/// 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;
/// Sleep between backfill send batches.
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)]
pub struct ActivityPubService {
@@ -57,6 +60,8 @@ pub struct ActivityPubServiceBuilder {
event_publisher: Option<Arc<dyn crate::data::EventPublisher>>,
delivery_max_attempts: u32,
delivery_initial_delay_secs: u64,
signed_fetch_actor_id: Option<uuid::Uuid>,
actor_cache_ttl_secs: u64,
}
impl ActivityPubServiceBuilder {
@@ -113,6 +118,21 @@ impl ActivityPubServiceBuilder {
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> {
let activity_repo = self
.activity_repo
@@ -138,17 +158,31 @@ impl ActivityPubServiceBuilder {
let data = FederationData::new(
activity_repo,
follow_repo,
actor_repo,
actor_repo.clone(),
blocklist_repo,
user_repo,
user_repo.clone(),
content_reader,
object_handler,
self.base_url.clone(),
self.allow_registration,
self.software_name,
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 {
federation_config,
base_url: self.base_url,
@@ -175,6 +209,8 @@ impl ActivityPubService {
event_publisher: None,
delivery_max_attempts: DELIVERY_MAX_ATTEMPTS,
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
);
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()
.get(&wf_url)
.header("Accept", "application/jrd+json, application/json")

View File

@@ -520,6 +520,7 @@ async fn setup(blocklist: MemBlocklistRepo, local_user_id: uuid::Uuid) -> TestSe
false,
"test".to_string(),
None,
std::time::Duration::from_secs(24 * 60 * 60),
);
let config = FederationConfig::builder()

View File

@@ -374,6 +374,7 @@ fn make_data(
false,
"test".to_string(),
None,
std::time::Duration::from_secs(24 * 60 * 60),
)
}

View File

@@ -19,6 +19,7 @@ fn nodeinfo_well_known_serializes_correctly() {
#[test]
fn nodeinfo_serializes_camel_case() {
let doc = NodeInfo {
schema: "http://nodeinfo.diaspora.software/ns/schema/2.0#".to_string(),
version: "2.0".to_string(),
software: NodeInfoSoftware {
name: "my-app".to_string(),
@@ -32,6 +33,10 @@ fn nodeinfo_serializes_camel_case() {
open_registrations: false,
};
let json = serde_json::to_value(&doc).unwrap();
assert_eq!(
json["$schema"],
"http://nodeinfo.diaspora.software/ns/schema/2.0#"
);
assert_eq!(json["version"], "2.0");
assert_eq!(json["software"]["name"], "my-app");
assert_eq!(json["usage"]["users"]["total"], 3);