diff --git a/pub/fed.go b/pub/fed.go index 45b4514..e7dadae 100644 --- a/pub/fed.go +++ b/pub/fed.go @@ -49,7 +49,7 @@ type Pubber interface { GetOutbox(c context.Context, w http.ResponseWriter, r *http.Request) (bool, error) } -// NewPubber provides a Pubber that implements only the Social API in +// NewSocialPubber provides a Pubber that implements only the Social API in // ActivityPub. func NewSocialPubber(clock Clock, app Application, socialApp SocialApp, cb Callbacker) Pubber { return &federator{ @@ -61,38 +61,40 @@ func NewSocialPubber(clock Clock, app Application, socialApp SocialApp, cb Callb } } -// NewPubber provides a Pubber that implements only the Federating API in -// ActivityPub. -func NewFederatingPubber(clock Clock, app Application, fApp FederateApp, cb Callbacker, d Deliverer, client HttpClient, userAgent string, maxDeliveryDepth int) Pubber { +// NewFederatingPubber provides a Pubber that implements only the Federating API +// in ActivityPub. +func NewFederatingPubber(clock Clock, app Application, fApp FederateApp, cb Callbacker, d Deliverer, client HttpClient, userAgent string, maxDeliveryDepth, maxForwardingDepth int) Pubber { return &federator{ - Clock: clock, - App: app, - FederateApp: fApp, - ServerCallbacker: cb, - Client: client, - Agent: userAgent, - MaxDeliveryDepth: maxDeliveryDepth, - EnableServer: true, - deliverer: d, + Clock: clock, + App: app, + FederateApp: fApp, + ServerCallbacker: cb, + Client: client, + Agent: userAgent, + MaxDeliveryDepth: maxDeliveryDepth, + MaxInboxForwardingDepth: maxForwardingDepth, + EnableServer: true, + deliverer: d, } } // NewPubber provides a Pubber that implements both the Social API and the // Federating API in ActivityPub. -func NewPubber(clock Clock, app Application, sApp SocialApp, fApp FederateApp, client, server Callbacker, d Deliverer, httpClient HttpClient, userAgent string, maxDeliveryDepth int) Pubber { +func NewPubber(clock Clock, app Application, sApp SocialApp, fApp FederateApp, client, server Callbacker, d Deliverer, httpClient HttpClient, userAgent string, maxDeliveryDepth, maxForwardingDepth int) Pubber { return &federator{ - Clock: clock, - App: app, - SocialApp: sApp, - FederateApp: fApp, - Client: httpClient, - Agent: userAgent, - MaxDeliveryDepth: maxDeliveryDepth, - ServerCallbacker: server, - ClientCallbacker: client, - EnableClient: true, - EnableServer: true, - deliverer: d, + Clock: clock, + App: app, + SocialApp: sApp, + FederateApp: fApp, + Client: httpClient, + Agent: userAgent, + MaxDeliveryDepth: maxDeliveryDepth, + MaxInboxForwardingDepth: maxForwardingDepth, + ServerCallbacker: server, + ClientCallbacker: client, + EnableClient: true, + EnableServer: true, + deliverer: d, } } @@ -141,6 +143,13 @@ type federator struct { // // It is only required if EnableServer is true. MaxDeliveryDepth int + // MaxInboxForwardingDepth is how deep the values are examined for + // determining ownership of whether to forward an Activity to + // collections or followers. Once this maximum is exceeded, the ghost + // replies issue may become a problem, but users may not mind. + // + // It is only required if EnableServer is true. + MaxInboxForwardingDepth int // deliverer handles deliveries to other federated servers. // // It is only required if EnableServer is true. @@ -192,7 +201,9 @@ func (f *federator) PostInbox(c context.Context, w http.ResponseWriter, r *http. if err := f.addToInbox(c, r, m); err != nil { return true, err } - // TODO: 7.1.2 Inbox forwarding + if err := f.inboxForwarding(c, m); err != nil { + return true, err + } w.WriteHeader(http.StatusOK) return true, nil } diff --git a/pub/fed_test.go b/pub/fed_test.go index 4243bbd..6f78cfd 100644 --- a/pub/fed_test.go +++ b/pub/fed_test.go @@ -442,6 +442,7 @@ type MockApplication struct { t *testing.T owns func(c context.Context, id url.URL) bool get func(c context.Context, id url.URL) (PubObject, error) + has func(c context.Context, id url.URL) (bool, error) set func(c context.Context, o PubObject) error getInbox func(c context.Context, r *http.Request) (vocab.OrderedCollectionType, error) getOutbox func(c context.Context, r *http.Request) (vocab.OrderedCollectionType, error) @@ -462,6 +463,13 @@ func (m *MockApplication) Get(c context.Context, id url.URL) (PubObject, error) return m.get(c, id) } +func (m *MockApplication) Has(c context.Context, id url.URL) (bool, error) { + if m.has == nil { + m.t.Fatal("unexpected call to MockApplication Has") + } + return m.has(c, id) +} + func (m *MockApplication) Set(c context.Context, o PubObject) error { if m.set == nil { m.t.Fatal("unexpected call to MockApplication Set") @@ -647,12 +655,13 @@ func (m *MockCallbacker) Reject(c context.Context, s *streams.Reject) error { var _ FederateApp = &MockFederateApp{} type MockFederateApp struct { - t *testing.T - canAdd func(c context.Context, obj vocab.ObjectType, target vocab.ObjectType) bool - canRemove func(c context.Context, obj vocab.ObjectType, target vocab.ObjectType) bool - onFollow func(c context.Context, s *streams.Follow) FollowResponse - unblocked func(c context.Context, actorIRIs []url.URL) error - getFollowing func(c context.Context, actor url.URL) (vocab.CollectionType, error) + t *testing.T + canAdd func(c context.Context, obj vocab.ObjectType, target vocab.ObjectType) bool + canRemove func(c context.Context, obj vocab.ObjectType, target vocab.ObjectType) bool + onFollow func(c context.Context, s *streams.Follow) FollowResponse + unblocked func(c context.Context, actorIRIs []url.URL) error + getFollowing func(c context.Context, actor url.URL) (vocab.CollectionType, error) + filterForwarding func(c context.Context, activity vocab.ActivityType, iris []url.URL) ([]url.URL, error) } func (m *MockFederateApp) CanAdd(c context.Context, obj vocab.ObjectType, target vocab.ObjectType) bool { @@ -690,6 +699,13 @@ func (m *MockFederateApp) GetFollowing(c context.Context, actor url.URL) (vocab. return m.getFollowing(c, actor) } +func (m *MockFederateApp) FilterForwarding(c context.Context, activity vocab.ActivityType, iris []url.URL) ([]url.URL, error) { + if m.filterForwarding == nil { + m.t.Fatal("unexpected call to MockFederateApp FilterForwarding") + } + return m.filterForwarding(c, activity, iris) +} + var _ Deliverer = &MockDeliverer{} type MockDeliverer struct { @@ -734,7 +750,7 @@ func NewFederatingPubberTest(t *testing.T) (app *MockApplication, fedApp *MockFe cb = &MockCallbacker{t: t} d = &MockDeliverer{t: t} h = &MockHttpClient{t: t} - p = NewFederatingPubber(clock, app, fedApp, cb, d, h, testAgent, 1) + p = NewFederatingPubber(clock, app, fedApp, cb, d, h, testAgent, 1, 1) return } @@ -747,7 +763,7 @@ func NewPubberTest(t *testing.T) (app *MockApplication, socialApp *MockSocialApp fedCb = &MockCallbacker{t: t} d = &MockDeliverer{t: t} h = &MockHttpClient{t: t} - p = NewPubber(clock, app, socialApp, fedApp, socialCb, fedCb, d, h, testAgent, 1) + p = NewPubber(clock, app, socialApp, fedApp, socialCb, fedCb, d, h, testAgent, 1, 1) return } @@ -763,6 +779,9 @@ func PreparePostInboxTest(t *testing.T, app *MockApplication, socialApp *MockSoc app.set = func(c context.Context, o PubObject) error { return nil } + app.has = func(c context.Context, id url.URL) (bool, error) { + return false, nil + } return } @@ -981,6 +1000,26 @@ func TestFederatingPubber_PostInbox(t *testing.T) { } return nil } + gotHas := 0 + var hasIriActivity url.URL + var hasIriTo url.URL + app.has = func(c context.Context, id url.URL) (bool, error) { + gotHas++ + if gotHas == 1 { + hasIriActivity = id + return false, nil + } else { + hasIriTo = id + return true, nil + } + } + gotGet := 0 + var gotIri url.URL + app.get = func(c context.Context, iri url.URL) (PubObject, error) { + gotGet++ + gotIri = iri + return samActor, nil + } gotCreate := 0 var gotCreateCallback *streams.Create cb.create = func(c context.Context, s *streams.Create) error { @@ -1005,6 +1044,16 @@ func TestFederatingPubber_PostInbox(t *testing.T) { t.Fatalf("expected %s, got %s", "OrderedCollection", l) } else if l := setObject.GetType(0).(string); l != "Note" { t.Fatalf("expected %s, got %s", "Note", l) + } else if gotHas != 2 { + t.Fatalf("expected %d, got %d", 2, gotHas) + } else if hasIriActivityString := (&hasIriActivity).String(); hasIriActivityString != noteActivityURIString { + t.Fatalf("expected %s, got %s", noteActivityURIString, hasIriActivityString) + } else if hasIriToString := (&hasIriTo).String(); hasIriToString != samIRIString { + t.Fatalf("expected %s, got %s", samIRIString, hasIriToString) + } else if gotGet != 1 { + t.Fatalf("expected %d, got %d", 1, gotGet) + } else if gotIriString := (&gotIri).String(); gotIriString != samIRIString { + t.Fatalf("expected %s, got %s", samIRIString, gotIriString) } else if gotCreate != 1 { t.Fatalf("expected %d, got %d", 1, gotCreate) } else if s := gotCreateCallback.Raw().GetActorObject(0).GetId(); s.String() != sallyIRIString { @@ -1112,6 +1161,26 @@ func TestPubber_PostInbox(t *testing.T) { } return nil } + gotHas := 0 + var hasIriActivity url.URL + var hasIriTo url.URL + app.has = func(c context.Context, id url.URL) (bool, error) { + gotHas++ + if gotHas == 1 { + hasIriActivity = id + return false, nil + } else { + hasIriTo = id + return true, nil + } + } + gotGet := 0 + var gotIri url.URL + app.get = func(c context.Context, iri url.URL) (PubObject, error) { + gotGet++ + gotIri = iri + return samActor, nil + } gotCreate := 0 var gotCreateCallback *streams.Create fedCb.create = func(c context.Context, s *streams.Create) error { @@ -1136,6 +1205,16 @@ func TestPubber_PostInbox(t *testing.T) { t.Fatalf("expected %s, got %s", "OrderedCollection", l) } else if l := setObject.GetType(0).(string); l != "Note" { t.Fatalf("expected %s, got %s", "Note", l) + } else if gotHas != 2 { + t.Fatalf("expected %d, got %d", 2, gotHas) + } else if hasIriActivityString := (&hasIriActivity).String(); hasIriActivityString != noteActivityURIString { + t.Fatalf("expected %s, got %s", noteActivityURIString, hasIriActivityString) + } else if hasIriToString := (&hasIriTo).String(); hasIriToString != samIRIString { + t.Fatalf("expected %s, got %s", samIRIString, hasIriToString) + } else if gotGet != 1 { + t.Fatalf("expected %d, got %d", 1, gotGet) + } else if gotIriString := (&gotIri).String(); gotIriString != samIRIString { + t.Fatalf("expected %s, got %s", samIRIString, gotIriString) } else if gotCreate != 1 { t.Fatalf("expected %d, got %d", 1, gotCreate) } else if s := gotCreateCallback.Raw().GetActorObject(0).GetId(); s.String() != sallyIRIString { diff --git a/pub/interfaces.go b/pub/interfaces.go index 5ea629f..3b25afa 100644 --- a/pub/interfaces.go +++ b/pub/interfaces.go @@ -41,6 +41,9 @@ type Application interface { Owns(c context.Context, id url.URL) bool // Get fetches the ActivityStream representation of the given id. Get(c context.Context, id url.URL) (PubObject, error) + // Has determines if the server already knows about the object or + // Activity specified by the given id. + Has(c context.Context, id url.URL) (bool, error) // Set should write or overwrite the value of the provided object for // its 'id'. Set(c context.Context, o PubObject) error @@ -96,6 +99,17 @@ type FederateApp interface { // If nil error is returned, then the received activity is processed as // a normal unblocked interaction. Unblocked(c context.Context, actorIRIs []url.URL) error + // FilterForwarding is invoked when a received activity needs to be + // forwarded to specific inboxes owned by this server in order to avoid + // the ghost reply problem. The IRIs provided are collections owned by + // this server that the federate peer requested inbox forwarding to. + // + // Implementors must apply some sort of filtering to the provided IRI + // collections. Without any filtering, the resulting application is + // vulnerable to becoming a spam bot for a malicious federate peer. + // Typical implementations will filter the iris down to be only the + // follower collections owned by the actors targeted in the activity. + FilterForwarding(c context.Context, activity vocab.ActivityType, iris []url.URL) ([]url.URL, error) } // FollowResponse instructs how to proceed upon immediately receiving a request @@ -113,6 +127,9 @@ const ( // interactions. These callbacks are called after their spec-compliant actions // are completed, but before inbox forwarding and before delivery. // +// Note that at minimum, for inbox forwarding to work correctly, these +// Activities must be stored in the client application as a system of record. +// // Note that modifying the ActivityStream objects in a callback may cause // unintentionally non-standard behavior if modifying core attributes, but // otherwise affords clients powerful flexibility. Use responsibly. diff --git a/pub/internal.go b/pub/internal.go index 55601bc..150309d 100644 --- a/pub/internal.go +++ b/pub/internal.go @@ -483,6 +483,12 @@ func (f *federator) deliver(obj vocab.ActivityType) error { if err != nil { return err } + return f.deliverToRecipients(obj, recipients) +} + +// deliverToRecipients will take a prepared Activity and send it to specific +// recipients without examining the activity. +func (f *federator) deliverToRecipients(obj vocab.ActivityType, recipients []url.URL) error { m, err := obj.Serialize() if err != nil { return err @@ -1335,6 +1341,212 @@ func (f *federator) addToInbox(c context.Context, r *http.Request, m map[string] return f.App.Set(c, inbox) } +// Note: This is a mechanism for causing other victim servers to DDOS +// or forward spam on a malicious user's behalf. The trick is a simple +// one: Reply to a user, and CC a ton of 'follower' collections owned +// by the victim server. Bonus points for listing more 'follower' +// collections from other popular instances as well. Leveraging the +// Inbox Forwarding mechanism, a storm of messages will ensue. +// +// I don't want users of this library to be vulnerable to this kind of +// spam/DDOS storm. So here we allow the client application to filter +// out recipient collections. +func (f *federator) inboxForwarding(c context.Context, m map[string]interface{}) error { + a, err := toAnyActivity(m) + if err != nil { + return err + } + // 1. Must be first time we have seen this Activity. + if ok, err := f.App.Has(c, a.GetId()); err != nil { + return err + } else if ok { + return nil + } + // 2. The values of 'to', 'cc', or 'audience' are Collections owned by + // this server. + var r []url.URL + r = append(r, getToIRIs(a)...) + r = append(r, getCcIRIs(a)...) + r = append(r, getAudienceIRIs(a)...) + var myIRIs []url.URL + col := make(map[string]vocab.CollectionType, 0) + oCol := make(map[string]vocab.OrderedCollectionType, 0) + for _, iri := range r { + if ok, err := f.App.Has(c, iri); err != nil { + return err + } else if !ok { + continue + } + obj, err := f.App.Get(c, iri) + if err != nil { + return err + } + if c, ok := obj.(vocab.CollectionType); ok { + col[(&iri).String()] = c + myIRIs = append(myIRIs, iri) + } else if oc, ok := obj.(vocab.OrderedCollectionType); ok { + oCol[(&iri).String()] = oc + myIRIs = append(myIRIs, iri) + } + } + if len(myIRIs) == 0 { + return nil + } + // 3. The values of 'inReplyTo', 'object', 'target', or 'tag' are owned + // by this server. + ownsValue := false + objs, l, iris := getInboxForwardingValues(a) + for _, obj := range objs { + if f.hasInboxForwardingValues(c, 0, f.MaxInboxForwardingDepth, obj) { + ownsValue = true + break + } + } + if !ownsValue && f.ownsAnyLinks(c, l) { + ownsValue = true + } + if !ownsValue && f.ownsAnyIRIs(c, iris) { + ownsValue = true + } + if !ownsValue { + return nil + } + // Do the inbox forwarding since the above conditions hold true. Support + // the behavior of letting the application filter out the resulting + // collections to be targeted. + toSend, err := f.FederateApp.FilterForwarding(c, a, myIRIs) + if err != nil { + return err + } + recipients := make([]url.URL, 0, len(toSend)) + for _, iri := range toSend { + if c, ok := col[(&iri).String()]; ok { + for i := 0; i < c.ItemsLen(); i++ { + if c.IsItemsObject(i) { + obj := c.GetItemsObject(i) + if obj.HasId() { + recipients = append(recipients, obj.GetId()) + } + } else if c.IsItemsLink(i) { + l := c.GetItemsLink(i) + if l.HasHref() { + recipients = append(recipients, l.GetHref()) + } + } else if c.IsItemsIRI(i) { + recipients = append(recipients, c.GetItemsIRI(i)) + } + } + } else if oc, ok := oCol[(&iri).String()]; ok { + for i := 0; i < oc.OrderedItemsLen(); i++ { + if oc.IsOrderedItemsObject(i) { + obj := oc.GetOrderedItemsObject(i) + if obj.HasId() { + recipients = append(recipients, obj.GetId()) + } + } else if oc.IsOrderedItemsLink(i) { + l := oc.GetItemsLink(i) + if l.HasHref() { + recipients = append(recipients, l.GetHref()) + } + } else if oc.IsOrderedItemsIRI(i) { + recipients = append(recipients, oc.GetOrderedItemsIRI(i)) + } + } + } + } + return f.deliverToRecipients(a, recipients) +} + +// Given an 'inReplyTo', 'object', 'target', or 'tag' object, recursively +// examines those same values to determine if the app owns any, up to a maximum +// depth. +func (f *federator) hasInboxForwardingValues(c context.Context, depth, maxDepth int, o vocab.ObjectType) bool { + if depth == maxDepth { + return false + } + if f.App.Owns(c, o.GetId()) { + return true + } + objs, l, iris := getInboxForwardingValues(o) + for _, obj := range objs { + if f.hasInboxForwardingValues(c, depth+1, maxDepth, obj) { + return true + } + } + if f.ownsAnyLinks(c, l) { + return true + } + return f.ownsAnyIRIs(c, iris) +} + +func (f *federator) ownsAnyIRIs(c context.Context, iris []url.URL) bool { + for _, iri := range iris { + if f.App.Owns(c, iri) { + return true + } + // TODO: Dereference the IRI + } + return false +} + +func (f *federator) ownsAnyLinks(c context.Context, links []vocab.LinkType) bool { + for _, link := range links { + if !link.HasHref() { + continue + } + href := link.GetHref() + if f.App.Owns(c, href) { + return true + } + // TODO: Dereference the IRI + } + return false +} + +func getInboxForwardingValues(o vocab.ObjectType) (objs []vocab.ObjectType, l []vocab.LinkType, iri []url.URL) { + // 'inReplyTo' + for i := 0; i < o.InReplyToLen(); i++ { + if o.IsInReplyToObject(i) { + objs = append(objs, o.GetInReplyToObject(i)) + } else if o.IsInReplyToLink(i) { + l = append(l, o.GetInReplyToLink(i)) + } else if o.IsInReplyToIRI(i) { + iri = append(iri, o.GetInReplyToIRI(i)) + } + } + // 'tag' + for i := 0; i < o.TagLen(); i++ { + if o.IsTagObject(i) { + objs = append(objs, o.GetTagObject(i)) + } else if o.IsTagLink(i) { + l = append(l, o.GetTagLink(i)) + } else if o.IsTagIRI(i) { + iri = append(iri, o.GetTagIRI(i)) + } + } + if a, ok := o.(vocab.ActivityType); ok { + // 'object' + for i := 0; i < a.ObjectLen(); i++ { + if a.IsObject(i) { + objs = append(objs, a.GetObject(i)) + } else if a.IsObjectIRI(i) { + iri = append(iri, a.GetObjectIRI(i)) + } + } + // 'target' + for i := 0; i < a.TargetLen(); i++ { + if a.IsTargetObject(i) { + objs = append(objs, a.GetTargetObject(i)) + } else if a.IsTargetLink(i) { + l = append(l, a.GetTargetLink(i)) + } else if a.IsTargetIRI(i) { + iri = append(iri, a.GetTargetIRI(i)) + } + } + } + return +} + // Fetches an "object" on a raw JSON map of an Activity with the matching 'id' // field. If there is no object matching the IRI, or the object just is an IRI, // or the object wth the matching id is not in the array of objects, then a nil