From 8153cfe14e9eb443f51acd3ab8efc805af96a8bc Mon Sep 17 00:00:00 2001 From: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:52:15 +0200 Subject: [PATCH] fix self-federation (#1044) --- db/routes/remote_list.go | 30 +++++++++++++++++++--- db/routes/remote_trail.go | 34 ++++++++++++++++++++++--- db/util/activitypub.go | 53 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+), 8 deletions(-) diff --git a/db/routes/remote_list.go b/db/routes/remote_list.go index 2e08aba7..3069947b 100644 --- a/db/routes/remote_list.go +++ b/db/routes/remote_list.go @@ -49,6 +49,12 @@ func RemoteListGet(e *core.RequestEvent) error { } return e.InternalServerError("Sync failed", err) } + if record.Id == "" { + // Local content that does not exist: performFullListSync + // short-circuits local IRIs and returns the unsaved shell — + // surface a real 404 instead of access/expand on a missing record. + return e.NotFoundError("List not found", nil) + } } else { updatedAt := record.GetDateTime("updated").Time() @@ -111,11 +117,24 @@ func findLocalListByRemoteInfo(e *core.RequestEvent, ctx context.Context, handle } func performFullListSync(app core.App, ctx context.Context, reqURL *url.URL, localList *core.Record) (*core.Record, error) { - client := util.SafeHTTPClient() - iri := localList.GetString("iri") + + // Never federate with ourselves (see performFullSync in remote_trail.go). + if iri == "" || util.IsLocalIRI(iri) { + if localList.GetBool("needs_full_sync") { + localList.Set("needs_full_sync", false) + if err := app.Save(localList); err != nil { + return localList, err + } + } + return localList, nil + } + + client := util.SafeHTTPClient() remoteUrl, _ := url.Parse(iri) - remoteUrl.RawQuery = reqURL.RawQuery + query := reqURL.Query() + query.Del("handle") + remoteUrl.RawQuery = query.Encode() origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host) req, err := http.NewRequestWithContext(ctx, http.MethodGet, remoteUrl.String(), nil) @@ -124,10 +143,13 @@ func performFullListSync(app core.App, ctx context.Context, reqURL *url.URL, loc } res, err := client.Do(req) - if err != nil || res.StatusCode != 200 { + if err != nil { return localList, err } defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return localList, fmt.Errorf("remote list fetch %s returned: %d", remoteUrl.String(), res.StatusCode) + } var remoteMap map[string]any if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil { diff --git a/db/routes/remote_trail.go b/db/routes/remote_trail.go index 01bd8f90..c9a0fed6 100644 --- a/db/routes/remote_trail.go +++ b/db/routes/remote_trail.go @@ -79,6 +79,13 @@ func RemoteTrailGet(e *core.RequestEvent) error { } return e.InternalServerError("Sync failed", err) } + if record.Id == "" { + // Local content that does not exist (e.g. a stale URL to a + // missing local trail): performFullSync short-circuits local + // IRIs and returns the unsaved shell — surface a real 404 + // instead of running access/expand on a non-existent record. + return e.NotFoundError("Trail not found", nil) + } } else { // We already have it locally. Show and update background. updatedAt := record.GetDateTime("updated").Time() @@ -146,19 +153,38 @@ func findLocalTrailByRemoteInfo(e *core.RequestEvent, ctx context.Context, handl // --- Core Sync Logic --- func performFullSync(app core.App, ctx context.Context, reqURL *url.URL, localTrail *core.Record) (*core.Record, error) { - client := util.SafeHTTPClient() - iri := localTrail.GetString("iri") + + // Never federate with ourselves: a trail whose IRI is empty or points back + // to this instance is local content (we are the source of truth). Syncing it + // would fetch our own origin (or fail on an empty URL); just clear the stale + // flag so the record is no longer stuck in a permanent re-sync loop. + if iri == "" || util.IsLocalIRI(iri) { + if localTrail.GetBool("needs_full_sync") { + localTrail.Set("needs_full_sync", false) + if err := app.Save(localTrail); err != nil { + return localTrail, err + } + } + return localTrail, nil + } + + client := util.SafeHTTPClient() remoteUrl, _ := url.Parse(iri) - remoteUrl.RawQuery = reqURL.RawQuery // Forward params + query := reqURL.Query() + query.Del("handle") + remoteUrl.RawQuery = query.Encode() origin := fmt.Sprintf("%s://%s", remoteUrl.Scheme, remoteUrl.Host) req, _ := http.NewRequestWithContext(ctx, "GET", remoteUrl.String(), nil) res, err := client.Do(req) - if err != nil || res.StatusCode != 200 { + if err != nil { return localTrail, err } defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return localTrail, fmt.Errorf("remote trail fetch %s returned: %d", remoteUrl.String(), res.StatusCode) + } var remoteMap map[string]any if err := json.NewDecoder(res.Body).Decode(&remoteMap); err != nil { diff --git a/db/util/activitypub.go b/db/util/activitypub.go index a52cd1c1..7a44466f 100644 --- a/db/util/activitypub.go +++ b/db/util/activitypub.go @@ -111,6 +111,28 @@ func generateKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) { return priv, pub, nil } +// IsLocalIRI reports whether iri belongs to this instance's own ORIGIN. +// It is used to prevent the instance from federating with itself, i.e. treating +// its own content as if it were remote. +func IsLocalIRI(iri string) bool { + if iri == "" { + return false + } + origin := os.Getenv("ORIGIN") + if origin == "" { + return false + } + o, err := url.Parse(origin) + if err != nil { + return false + } + u, err := url.Parse(iri) + if err != nil { + return false + } + return strings.EqualFold(u.Host, o.Host) +} + func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) (*core.Record, error) { t, err := pub.ToObject(activity.Object) if err != nil { @@ -118,6 +140,24 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) } iri := t.ID.String() + + // Own content must never be ingested as if it were remote. An inbound + // activity referencing one of our own trails (e.g. an announce echoed back) + // would otherwise flag the local trail for a full sync and later make the + // instance fetch itself. Only a local actor may resolve a local object id to + // the local record; a remote actor must not be able to reference or attach + // side effects (feeds/shares/notifications) to local content by id. + if IsLocalIRI(iri) { + if !actor.GetBool("isLocal") { + return nil, fmt.Errorf("refusing remote activity referencing local trail %q", iri) + } + trailUrl, parseErr := url.Parse(iri) + if parseErr != nil { + return nil, parseErr + } + return app.FindRecordById("trails", path.Base(trailUrl.Path)) + } + var record *core.Record if actor.GetBool(("isLocal")) { trailUrl, parseErr := url.Parse(iri) @@ -409,6 +449,19 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) ( } iri := l.ID.String() + + // Own content must never be ingested as if it were remote (see TrailFromActivity). + if IsLocalIRI(iri) { + if !actor.GetBool("isLocal") { + return nil, fmt.Errorf("refusing remote activity referencing local list %q", iri) + } + listURL, parseErr := url.Parse(iri) + if parseErr != nil { + return nil, parseErr + } + return app.FindRecordById("lists", path.Base(listURL.Path)) + } + var record *core.Record if actor.GetBool(("isLocal")) { listURL, parseErr := url.Parse(iri)