Add shared inbox support for ActivityPub delivery (#4755)

* feat(ap): add support for shared inboxes to reduce outbound load

* feat(db): refactor ap followers db into followers repository

* fix(ap): use the updated activity library to pull out the shared inbox endpoint

* chore(deps): point at updated build of owncast/activity

* fix(ap): typeless endpoints

* feat(test): update ActivityPub test to support shared inboxes

* chore(test): remove unused variable

* fix: feedback from review. Guard against SSRF/non-HTTPS/local and handle transaction errors
This commit is contained in:
Gabe Kangas
2026-01-22 15:33:22 -08:00
committed by GitHub
parent 7a735e6902
commit de6468ad89
31 changed files with 1033 additions and 410 deletions
+33 -10
View File
@@ -13,6 +13,7 @@ import (
"github.com/owncast/owncast/activitypub/apmodels"
"github.com/owncast/owncast/activitypub/crypto"
"github.com/owncast/owncast/activitypub/persistence"
"github.com/owncast/owncast/activitypub/persistence/followersrepository"
"github.com/owncast/owncast/activitypub/requests"
"github.com/owncast/owncast/activitypub/resolvers"
"github.com/owncast/owncast/activitypub/webfinger"
@@ -234,15 +235,17 @@ func getHashtagLinkHTMLFromTagString(baseHashtag string) string {
}
// SendToFollowers will send an arbitrary payload to all follower inboxes.
// Requests are batched to prevent resource exhaustion when there are many followers.
// It uses shared inboxes when available to reduce the number of outbound requests.
func SendToFollowers(payload []byte) error {
configRepository := configrepository.Get()
followersRepo := followersrepository.Get()
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())
followers, _, err := persistence.GetFederationFollowers(-1, 0)
// Get unique delivery inboxes (prefers shared inboxes over individual inboxes)
inboxes, err := followersRepo.GetUniqueDeliveryInboxes()
if err != nil {
log.Errorln("unable to fetch followers to send to", err)
return errors.New("unable to fetch followers to send payload to")
log.Errorln("unable to fetch delivery inboxes", err)
return errors.New("unable to fetch delivery inboxes to send payload to")
}
// Batch size and delay to prevent resource exhaustion during delivery.
@@ -253,10 +256,22 @@ func SendToFollowers(payload []byte) error {
queued := 0
skipped := 0
for i, follower := range followers {
inbox, err := url.Parse(follower.Inbox)
for i, inboxURL := range inboxes {
inbox, err := url.Parse(inboxURL)
if err != nil {
log.Errorln("unable to parse follower inbox URL", follower.Inbox, err)
log.Warnln("unable to parse inbox URL", inboxURL, err)
continue
}
// SSRF protection: reject non-HTTPS schemes and internal/loopback hosts.
// A malicious remote actor could set their inbox to an internal address
// to trick this server into making requests to internal services.
if inbox.Scheme != "https" {
log.Warnln("rejecting non-HTTPS inbox URL for SSRF protection:", inboxURL)
continue
}
if utils.IsHostnameInternal(inbox.Hostname()) {
log.Warnln("rejecting internal/loopback inbox URL for SSRF protection:", inboxURL)
continue
}
@@ -269,8 +284,8 @@ func SendToFollowers(payload []byte) error {
req, err := crypto.CreateSignedRequest(payload, inbox, localActor)
if err != nil {
log.Errorln("unable to create outbox request", follower.Inbox, err)
return errors.New("unable to create outbox request: " + follower.Inbox)
log.Errorln("unable to create outbox request", inboxURL, err)
continue
}
workerpool.AddToOutboundQueue(req)
@@ -280,7 +295,7 @@ func SendToFollowers(payload []byte) error {
// This helps prevent ActivityPub delivery from competing with video encoding.
// Use queued count (not loop index) to ensure consistent rate limiting
// even when followers are skipped due to circuit breaker or parse errors.
if queued%batchSize == 0 && i+1 < len(followers) {
if queued%batchSize == 0 && i+1 < len(inboxes) {
time.Sleep(batchDelay)
}
}
@@ -294,6 +309,14 @@ func SendToFollowers(payload []byte) error {
// SendToUser will send a payload to a single specific inbox.
func SendToUser(inbox *url.URL, payload []byte) error {
// SSRF protection: reject non-HTTPS schemes and internal/loopback hosts.
if inbox.Scheme != "https" {
return errors.Errorf("rejecting non-HTTPS inbox URL for SSRF protection: %s", inbox.String())
}
if utils.IsHostnameInternal(inbox.Hostname()) {
return errors.Errorf("rejecting internal/loopback inbox URL for SSRF protection: %s", inbox.String())
}
configRepository := configrepository.Get()
localActor := apmodels.MakeLocalIRIForAccount(configRepository.GetDefaultFederationUsername())