Federation Refactoring & Architecture Improvements (#930)

* initial commit

* add lists

* update permissions

* fix waypoint create

* needs_full_sync for activitypub trails

* fixes build issues

* fix list get

* further CSRF protection

* update API docs

* adds rate limiter

* fix tiptap mentions

* a bit more cleanup of main.go

* Fix migration order

* fixes reviewed notes

* require context for activitpub server calls

* improve hashing for identifier

* fix copy paste error

* fix sync trail/list issues

* fix summit log/comment duplicates

* adaptions after trail merge

* fix dockerignore

---------

Co-authored-by: Christian Beutel <>
Co-authored-by: slothful-vassal <89943360+slothful-vassal@users.noreply.github.com>
This commit is contained in:
Flomp
2026-05-09 17:30:34 +02:00
committed by GitHub
parent 992ebfc0c4
commit d2ac49470a
73 changed files with 3773 additions and 2153 deletions

View File

@@ -19,6 +19,7 @@ import (
"time"
pub "github.com/go-ap/activitypub"
"github.com/go-fed/httpsig"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/filesystem"
"github.com/pocketbase/pocketbase/tools/security"
@@ -110,53 +111,6 @@ func generateKeyPair() (*rsa.PrivateKey, *rsa.PublicKey, error) {
return priv, pub, nil
}
func SyncOutbox(app core.App, actor *core.Record) error {
return fetchOutboxPage(app, actor, actor.GetString("outbox")+"?page=1")
}
func fetchOutboxPage(app core.App, actor *core.Record, pageURL string) error {
client := &http.Client{}
req, err := http.NewRequest(http.MethodGet, pageURL, nil)
if err != nil {
return err
}
req.Header.Add("Accept", `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
var page pub.OrderedCollectionPage
err = json.Unmarshal(body, &page)
if err != nil {
return err
}
for _, item := range page.OrderedItems {
activity, err := pub.ToActivity(item)
if err != nil {
return err
}
if activity.Type != pub.CreateType {
continue
}
}
if page.Next != nil {
return fetchOutboxPage(app, actor, page.Next.GetID().String())
}
return nil
}
func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record) (*core.Record, error) {
t, err := pub.ToObject(activity.Object)
if err != nil {
@@ -191,7 +145,13 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
}
} else {
// this trail exists already
// nothing more to do
// ensure that it is fully synced to catch waypoint/summit log updates
record.Set("needs_full_sync", true)
err = app.Save(record)
if err != nil {
return nil, err
}
return record, nil
}
@@ -264,6 +224,7 @@ func TrailFromActivity(activity pub.Activity, app core.App, actor *core.Record)
record.Set("public", true)
record.Set("iri", t.ID.String())
record.Set("author", actor.Id)
record.Set("needs_full_sync", true)
categoryRecord, err := app.FindFirstRecordByData("categories", "name", category)
if err == nil {
@@ -473,7 +434,9 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (
}
} else {
// this list exists already
// nothing more to do
// ensure that it is fully synced to catch trail updates
record.Set("needs_full_sync", true)
return record, nil
}
@@ -483,6 +446,7 @@ func ListFromActivity(activity pub.Activity, app core.App, actor *core.Record) (
record.Set("public", true)
record.Set("iri", iri)
record.Set("author", actor.Id)
record.Set("needs_full_sync", true)
if l.Attachment != nil {
@@ -601,7 +565,7 @@ func ObjectFromComment(app core.App, comment *core.Record, mentions *pub.ItemCol
func TrailObjectFromIRI(iri string) (*pub.Object, error) {
fetchURL := strings.Replace(iri, "api/v1/trail", "api/v1/activitypub/trail", 1)
client := &http.Client{}
client := SafeHTTPClient()
req, err := http.NewRequest(http.MethodGet, fetchURL, nil)
if err != nil {
@@ -627,3 +591,68 @@ func TrailObjectFromIRI(iri string) (*pub.Object, error) {
return &object, nil
}
func VerifySignature(app core.App, req *http.Request, publicKeyPem string) (bool, error) {
origin := os.Getenv("ORIGIN")
if origin == "" {
return false, fmt.Errorf("ORIGIN not set")
}
block, _ := pem.Decode([]byte(publicKeyPem))
if block == nil || block.Type != "PUBLIC KEY" {
return false, fmt.Errorf("could not decode publicKeyPem to PUBLIC KEY pem block type")
}
req.URL = &url.URL{
Path: req.Header.Get("X-Forwarded-Path"),
}
url, err := url.Parse(origin)
if err != nil {
return false, err
}
req.Header.Set("Host", url.Host)
req.Host = url.Host
app.Logger().Info(req.Header.Get("signature"))
publicKey, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return false, err
}
v, err := httpsig.NewVerifier(req)
if err != nil {
return false, err
}
err = v.Verify(publicKey, httpsig.RSA_SHA256)
if err != nil {
return false, err
}
return true, nil
}
func SplitHandle(handle string) (string, string) {
cleaned := strings.TrimPrefix(handle, "@")
cleaned = strings.TrimSpace(cleaned)
if !strings.Contains(cleaned, "@") {
return cleaned, ""
}
parts := strings.SplitN(cleaned, "@", 2)
user := parts[0]
domain := parts[1]
return user, domain
}
func ItemID(item pub.Item) string {
if item == nil || item.GetID() == "" {
return ""
}
return item.GetID().String()
}

190
db/util/network.go Normal file
View File

@@ -0,0 +1,190 @@
package util
import (
"context"
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/pocketbase/pocketbase/core"
)
var ErrRateLimited = fmt.Errorf("rate limit exceeded for origin")
type RateLimiter struct {
mu sync.RWMutex
requests map[string][]time.Time
maxReqs int
window time.Duration
key []byte
}
func NewRateLimiter(maxReqs int, window time.Duration) *RateLimiter {
rl := &RateLimiter{
requests: make(map[string][]time.Time),
maxReqs: maxReqs,
window: window,
key: make([]byte, 32),
}
rand.Read(rl.key)
// Background worker: Cleans up memory and rotates keys
go rl.maintenanceWorker()
return rl
}
func (rl *RateLimiter) maintenanceWorker() {
ticker := time.NewTicker(rl.window * 2)
for range ticker.C {
rl.mu.Lock()
newKey := make([]byte, 32)
rand.Read(newKey)
rl.key = newKey
rl.requests = make(map[string][]time.Time)
rl.mu.Unlock()
}
}
func (rl *RateLimiter) CheckRateLimit(identifier string, host string) error {
rl.mu.Lock()
defer rl.mu.Unlock()
h := hmac.New(sha256.New, rl.key)
h.Write([]byte(identifier + ":" + host))
key := hex.EncodeToString(h.Sum(nil))
now := time.Now()
threshold := now.Add(-rl.window)
timestamps := rl.requests[key]
w := 0
for _, t := range timestamps {
if t.After(threshold) {
timestamps[w] = t
w++
}
}
timestamps = timestamps[:w]
if len(timestamps) >= rl.maxReqs {
rl.requests[key] = timestamps
return ErrRateLimited
}
rl.requests[key] = append(timestamps, now)
return nil
}
var ActivityPubRateLimiter = NewRateLimiter(30, time.Minute)
type safeTransport struct {
transport http.RoundTripper
}
func (t *safeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
host := req.URL.Hostname()
if host == "" {
return nil, fmt.Errorf("invalid host in request")
}
ips, err := net.LookupIP(host)
if err != nil {
return nil, fmt.Errorf("failed to resolve host: %w", err)
}
for _, ip := range ips {
if isPrivateOrReservedIP(ip) {
return nil, fmt.Errorf("request to private/reserved IP address blocked: %s", ip)
}
}
return t.transport.RoundTrip(req)
}
func isPrivateOrReservedIP(ip net.IP) bool {
if ip.IsLoopback() {
return true
}
if ip.IsPrivate() {
return true
}
if ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() {
return true
}
if ip.IsMulticast() {
return true
}
if ip.IsUnspecified() {
return true
}
return false
}
func SafeHTTPClient() *http.Client {
dialer := &net.Dialer{Timeout: 30 * time.Second}
return &http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, _ := net.SplitHostPort(addr)
identifier, _ := ctx.Value("actor").(string)
if identifier == "" {
identifier = "system"
}
if err := ActivityPubRateLimiter.CheckRateLimit(identifier, host); err != nil {
return nil, err
}
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
if err != nil || len(ips) == 0 {
return nil, fmt.Errorf("failed to resolve: %w", err)
}
for _, ip := range ips {
if isPrivateOrReservedIP(ip) {
return nil, fmt.Errorf("SSRF blocked: %s", ip)
}
}
// Standard practice: Dial the first resolved IP to prevent TOCTOU/Rebinding
return dialer.DialContext(ctx, network, net.JoinHostPort(ips[0].String(), port))
},
},
}
}
func GetSafeActorContext(r *http.Request, userActor *core.Record) (context.Context, error) {
var identifier string
if userActor != nil {
identifier = "actor:" + userActor.Id
} else if r != nil {
ip, _, _ := net.SplitHostPort(r.RemoteAddr)
identifier = "anon:" + ip
} else {
return nil, errors.New("request or actor must be defined")
}
parentCtx := context.Background()
if r != nil {
parentCtx = r.Context()
}
return context.WithValue(parentCtx, "actor", identifier), nil
}

43
db/util/sanitize.go Normal file
View File

@@ -0,0 +1,43 @@
package util
import (
"github.com/microcosm-cc/bluemonday"
"github.com/pocketbase/pocketbase/core"
)
func SanitizeHTML() func(e *core.RecordRequestEvent) error {
return func(e *core.RecordRequestEvent) error {
fieldsToSanitize := map[string][]string{
"lists": {"description"},
"settings": {"bio"},
"summit_logs": {"text"},
"trails": {"description"},
"comments": {"text"},
"waypoints": {"description"},
}
collection := e.Collection.Name
fields, ok := fieldsToSanitize[collection]
if !ok {
return e.Next()
}
p := bluemonday.NewPolicy()
p.AllowStandardAttributes()
p.AllowStandardURLs()
p.AllowLists()
p.AllowElements("br", "div", "hr", "p", "span", "wbr")
p.AllowElements("b", "strong", "em", "u", "blockquote", "a")
p.AllowAttrs("href").OnElements("a")
p.AllowAttrs("target").OnElements("a")
p.AllowAttrs("class").OnElements("a")
for _, field := range fields {
if val, ok := e.Record.Get(field).(string); ok {
sanitizedValue := p.Sanitize(val)
e.Record.Set(field, sanitizedValue)
}
}
return e.Next()
}
}