This document describes the security precautions taken in activitypub-go and its consumer booster-bot. It is intended as a reference for auditors and maintainers.


Code: activitypub.goreceiveInbox, verifyHTTPSignature, obtainPublicKey

Every POST to /users/{actor}/inbox must carry a valid HTTP Signature (draft-cavage-http-signatures-12). The verification pipeline is:

(request-target)  host  date          (GET)
(request-target)  host  date  digest  (POST)

SWICG ActivityPub HTTP Signatures profile: "Most fediverse software will reject GET requests without signed (request-target), and POST requests without signed Digest, in order to prevent replay attacks."

Any request whose headers parameter omits one of these is rejected with HTTP 401.

The optional algorithm parameter, if present, must be hs2019 or rsa-sha256. All keys in this implementation are RSA-2048, so any other declared algorithm (e.g. hmac-sha256, ecdsa-sha256, rsa-sha1) is rejected.

draft-cavage-http-signatures-12 §2.1.3: "If algorithm is provided and differs from the key metadata identified by the keyId … then an implementation MUST produce an error."

The Date header must be within ±70 minutes of the server's clock. This prevents replayed requests from being accepted indefinitely.

For POST requests, the Digest: SHA-256=<base64> header is required and verified against SHA-256(body).

SWICG ActivityPub HTTP Signatures profile: "If the request has a body, compare it to the Digest header. If they don't match, the signature is invalid."

The keyId from the Signature header is used to fetch the remote actor document. The publicKey.id field in that document must match keyId exactly. This means the signing key can only be established from a document served at the same origin as the keyId — an attacker cannot point keyId at a victim's actor URL and supply their own PEM, because the PEM is fetched from the victim's server, not from the request.

FEP-fe34 §Signatures: "The ID of the public key (or the verification method) MUST have the same origin as the object's ID."

FEP-fe34 §Reciprocal claims: "The ownership of a public key can be asserted by embedding it within the actor document."

If signature verification fails with the cached actor document, the cache entry is evicted and the actor document is re-fetched once. This handles key rotation without requiring a restart.

The inbox body is read with io.LimitReader(r.Body, 1<<20) (1 MiB) before any processing, preventing memory exhaustion from oversized payloads.

Retrieval (actor fetches, object fetches, WebFinger): redirects are capped at 3 (Go's default of 10 is intentionally overridden). Each redirect target is independently validated by safeSocketControl. FetchObject handles redirects explicitly with a separate no-follow client and re-signs the request to the final URL, ensuring the (request-target) and Host signed headers remain valid.

Delivery (postActivity): redirects are disabled entirely (http.ErrUseLastResponse). Following a redirect on a signed POST would invalidate the signature since the signed (request-target) and Host no longer match the redirected URL.

ap-next guide §Network — Retrieving objects: "Follow redirects, but set a limit. Request must be re-signed after every redirect."

ap-next guide §Network — Delivering activities: "Do not follow redirects."

All outbound requests set User-Agent: activitypub-go/1.0 (https://<domain>) via signRequest, identifying the implementation and providing a contact point for remote server operators.

ap-next guide §Network: "Add User-Agent header."

All outbound HTTP response bodies are wrapped with io.LimitReader(resp.Body, 1<<20) (1 MiB) before JSON decoding in fetchActor, FetchObject, and ResolveHandle. This prevents memory exhaustion from malicious remote servers.

ap-next guide §Network: "Set limit on response size."


Code: activitypub.goreceiveInbox, sameOwner

After the HTTP signature is verified, the resolved actorURL (derived from keyId) is compared against the activity's actor field using exact owner equality:

if !sameOwner(actorURL, activityActor) {
    http.Error(w, "activity actor does not match signing key owner", 401)
}

sameOwner implements FEP-fe34 §Comparing owners: scheme and host are case-folded to lowercase; path and query are compared exactly; fragment is excluded (it is client-side only per RFC-3986 §3.5 and never sent to the server).

FEP-fe34 §Signatures: "In order to minimize damage in the event of a key compromise or insufficient validation, consumers MUST verify that the signing key has the same owner as the signed object."

FEP-fe34 §Comparing owners: "Owners are the same if their identifiers are identical after conversion of their schemes and hosts to lowercase."

This prevents a compromised server from injecting activities on behalf of a different actor on the same server — same-origin would pass this through, but exact owner equality rejects it. Relay forwarding (a relay signing on behalf of an actor at a different origin) is intentionally not supported and is rejected by this check.


Code: activitypub.goverifyEmbeddedObject

ActivityStreams defines specific activity types where an object is embedded inside an activity and is semantically owned by the activity's actor. The verifyEmbeddedObject function enforces FEP-fe34 §Embedding for these cases.

FEP-fe34 §Embedding: "An embedded object can be trusted when its wrapping object is trusted if: it has the same origin and the same owner as the wrapping object."

Two checks are applied to the embedded object:

  1. ID origin: if the embedded object has an id, it must be same-origin as the signing actor (origin check is sufficient here — the id identifies the object, not its owner).
  2. Owner equality: the object's owner (actor for Activity subtypes, attributedTo for Notes/Objects) must be the same owner as the signing actor, using sameOwner (exact URL with case-folded scheme+host). Anonymous objects (no owner field) are accepted.

Using exact owner equality for the owner field (rather than same-origin) follows silverpill's recommendation: if an origin server does poor C2S validation, an attacker could craft an embedded object with attributedTo pointing to a different actor on the same server. Same-origin would pass this; exact owner equality rejects it.

This is applied in:

Handler Activity type Embedded object Why
handleCreate Create{Note} the Note Prevents cross-origin note.id spoofing — an attacker setting object.id to a victim's post URL to make our reply DM thread under it
handleUndo Undo{Follow} the Follow Prevents cross-origin unfollow injection
handleDelete Delete{*} the deleted object (map or bare string ID) Prevents a server triggering UndoAnnounce for objects it doesn't own

Accept and Reject are intentionally excluded: they embed our own activities returned to us, where the object having a different origin is expected and correct by protocol design.


Code: activitypub.gosafeSocketControl, newSafeClient

All outbound HTTP requests (actor fetches, object fetches, inbox POSTs, WebFinger lookups) go through newSafeClient, which installs a net.Dialer.Control hook. The hook runs after DNS resolution but before the TCP connection is made, blocking:

Blocked IPv4 ranges include:

0.0.0.0/8         "This" network
10.0.0.0/8        Private (RFC 1918)
100.64.0.0/10     Shared address space (RFC 6598)
127.0.0.0/8       Loopback
169.254.0.0/16    Link-local (includes AWS metadata 169.254.169.254)
172.16.0.0/12     Private (RFC 1918)
192.168.0.0/16    Private (RFC 1918)
224.0.0.0/4       Multicast
240.0.0.0/4       Reserved
… (full list in activitypub.go)

IPv6 is restricted to 2000::/3 (global unicast) only.

Checking at the TCP dialer level — after DNS resolution — defeats DNS rebinding attacks where a hostname resolves to a safe IP on the first query and a private IP on the second.

Andrew Ayer, "Preventing Server Side Request Forgery in Golang" (2019, CC0): "It's also insufficient to do the DNS lookup yourself and block a URL if the hostname resolves to an unsafe address; an attacker could set up a special DNS server that returns a safe address the first time it's queried, and the target address the second time when your application actually connects to the URL."


Code: activitypub.goServer.root; booster-bot/main.godataRoot

All file I/O for persistent state uses Go 1.24's os.Root, opened once on the data directory at construction time. os.Root resolves .. components and symlinks structurally at the syscall level using the openat family, preventing path traversal even if a maliciously constructed filename somehow reached a file operation.

Go Blog, "Traversal-resistant file APIs" (March 2025): "Root defends against symlink traversal … A Root contains a file descriptor referencing its root directory and will track that directory across renames or deletion."

Note that cfg.DataDir is chosen by the embedding application and may be a directory in which that application keeps its own files — booster-bot passes <data_dir>/actors/<name>, where it also stores actor.json. Filenames written by the library therefore carry an ap- prefix to stay clear of the application's namespace. This is not a traversal concern but a collision one: the library's actor-document cache was previously called actor.json and silently overwrote booster-bot's per-actor settings, discarding the curator allowlist (see §6.4).

All actor names that reach filesystem operations are additionally constrained by the actorRegistry (which only contains names matching ^[a-z0-9-]+$) as a defence-in-depth layer.

The atomic write pattern (write to <file>.tmp then os.Rename) is preserved for all JSON state files. os.Root.Rename is not yet available in Go 1.24, so the rename step uses os.Rename with full paths constructed from cfg.DataDir — safe because both paths are derived from the already-validated root, and the filenames used are hardcoded string literals (never user-controlled).


Code: booster-bot/main.gonewToken, newPIN, checkLoginPin, makeLoginHandler, sessionFromRequest

Login requires the user to prove they control a fediverse account:

  1. User submits their fediverse handle at GET /login?user=@alice@example.com.
  2. Handle is resolved via WebFinger (over the SSRF-safe client) to an actor URL.
  3. Actor URL is checked against the admin list and per-actor curator allowlists; unknown actors are rejected immediately.
  4. A 128-bit cryptographically random session token (newToken) and a 4-digit PIN (newPIN, from crypto/rand) are generated.
  5. The PIN is sent to the user as a DM via the auth actor.
  6. The user POSTs the PIN back via the browser form. Only DMs from the correct actor URL that contain the matching PIN verify the session.
  7. Unverified sessions are garbage-collected after 10 minutes.

This scheme means that obtaining a session requires both access to the browser form (to get the token) and control of the fediverse account (to receive and send back the PIN). Neither alone is sufficient.

http.Cookie{
    HttpOnly: true,   // not accessible to JavaScript
    Secure:   true,   // HTTPS only
    SameSite: http.SameSiteLaxMode,  // CSRF mitigation
}

SameSite: Lax means the session cookie is not sent on cross-site POST requests, preventing CSRF attacks on state-mutating endpoints (/backend, /backend/avatar, /backend/new-actor) from third-party pages.

Session tokens are 128-bit random values encoded as base64url (22 characters). PINs are generated with crypto/rand via rand.Int on big.NewInt(10000), ensuring uniform distribution across 0–9999.

Code: booster-bot/main.gomayBoost, OnDM

Boosting is triggered by sending the bot a DM, so the sender has to be authorised. mayBoost permits an actor URL if it is in the server-wide admin list (config.json) or in that actor's curator allowlist (actors/<name>/actor.json, re-read from disk on every DM so edits take effect without a restart).

An empty allowlist means nobody — only admins. It must never mean "everybody": because the allowlist is re-read per DM, any failure to load it (missing file, parse error, file overwritten by another component) would otherwise silently turn the bot into an open boost relay for the entire fediverse. Failing closed degrades to "only admins can boost" instead.

This is not hypothetical. The library's actor-document cache was once also named actor.json and overwrote booster-bot's settings file, leaving an allowlist that parsed as empty. The guard at the time read len(allowlist) > 0 && !inAllowlist(...), so the check was skipped entirely and any fediverse account could make the bot boost arbitrary posts. Both halves are fixed: the files no longer share a name (§5), and an empty allowlist now denies. loadActorMeta additionally recognises an actor document in its settings file and logs a warning rather than proceeding with silently empty settings.


Code: booster-bot/main.govalidActorName

var validActorName = regexp.MustCompile(`^[a-z0-9-]+$`)

Actor names are validated at creation time (makeNewActorHandler). Only names matching this regex are accepted. This means all names in the actorRegistry — and thus all names used in actors/<name>/ filesystem paths — are guaranteed to contain no path separator characters, no .. components, and no special characters. This is a defence-in-depth layer on top of the os.Root path traversal protection.


Code: booster-bot/main.gomakeAvatarHandler

Avatar filenames in GET /avatars/<filename> are validated before use:

if filename == "" ||
   strings.ContainsAny(filename, "/\\") ||
   !strings.HasSuffix(filename, ".jpg") {
    http.Error(w, "not found", http.StatusNotFound)
    return
}

Any filename containing / or \ is rejected. Combined with the os.Root on cfg.DataDir, this provides two independent layers of traversal prevention for avatar serving.

Avatar IDs are server-generated 128-bit random tokens (newToken), so the <name>-<id>.jpg filename is not guessable.



Code: activitypub.goFetchObject

After fetching a remote AP object, the Content-Type header is checked to contain activity+json or ld+json:

if !strings.Contains(ct, "activity+json") && !strings.Contains(ct, "ld+json") {
    return nil, fmt.Errorf("object fetch returned non-AP content-type %q", ct)
}

FEP-fe34 §Fetching from an origin: "consumers MUST verify that the response to a GET request contains the Content-Type header with the application/ld+json; profile="https://www.w3.org/ns/activitystreams" or application/activity+json media type (see [GHSA-jhrq-qvrm-qr36] for more information)."

This prevents a server from serving arbitrary content (HTML, images, JavaScript) as an AP object and having it parsed as JSON.