Security Model
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.
1. Inbox HTTP Signature Verification
Code: activitypub.go — receiveInbox, verifyHTTPSignature,
obtainPublicKey
Every POST to /users/{actor}/inbox must carry a valid HTTP Signature
(draft-cavage-http-signatures-12). The verification pipeline is:
1.1 Required signed headers
(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 signedDigest, in order to prevent replay attacks."
Any request whose headers parameter omits one of these is rejected
with HTTP 401.
1.2 Algorithm constraint
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."
1.3 Date skew check
The Date header must be within ±70 minutes of the server's clock.
This prevents replayed requests from being accepted indefinitely.
1.4 Body digest verification
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."
1.5 Public key retrieval — reciprocal claim
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."
1.6 Key rotation handling
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.
1.7 Request body size limit
The inbox body is read with io.LimitReader(r.Body, 1<<20) (1 MiB)
before any processing, preventing memory exhaustion from oversized
payloads.
1.8 Outbound redirect handling
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."
1.9 User-Agent header
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."
1.10 Outbound response body size limits
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."
2. Activity-Level Owner Check
Code: activitypub.go — receiveInbox, 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.
3. Embedded Object Owner Checks
Code: activitypub.go — verifyEmbeddedObject
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:
- ID origin: if the embedded object has an
id, it must be same-origin as the signing actor (origin check is sufficient here — theididentifies the object, not its owner). - Owner equality: the object's owner (
actorfor Activity subtypes,attributedTofor Notes/Objects) must be the same owner as the signing actor, usingsameOwner(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.
4. SSRF Protection — Safe HTTP Client
Code: activitypub.go — safeSocketControl, 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:
- Any non-TCP network type
- Any port other than 443 (HTTPS only)
- Any private, loopback, or link-local IP address
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."
5. Traversal-Resistant Filesystem Access
Code: activitypub.go — Server.root; booster-bot/main.go —
dataRoot
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."
-
activitypub-go:Server.rootis opened oncfg.DataDir. All reads/writes offollowers.json,outbox.json,inbox.json,ap-actor-doc.json,key.pem, etc. go throughroot.Open/root.OpenFile. -
booster-bot:dataRootis opened oncfg.DataDir. Reads/writes ofactors/<name>/actor.jsonandactors/<name>/avatar-<id>.jpggo throughroot.Open/root.OpenFile.
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).
6. Session Authentication (booster-bot)
Code: booster-bot/main.go — newToken, newPIN,
checkLoginPin, makeLoginHandler, sessionFromRequest
6.1 Out-of-band PIN login
Login requires the user to prove they control a fediverse account:
- User submits their fediverse handle at
GET /login?user=@alice@example.com. - Handle is resolved via WebFinger (over the SSRF-safe client) to an actor URL.
- Actor URL is checked against the admin list and per-actor curator allowlists; unknown actors are rejected immediately.
- A 128-bit cryptographically random session token (
newToken) and a 4-digit PIN (newPIN, fromcrypto/rand) are generated. - The PIN is sent to the user as a DM via the auth actor.
- 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.
- 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.
6.2 Session cookie properties
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.
6.3 Token entropy
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.
6.4 DM boost authorisation
Code: booster-bot/main.go — mayBoost, 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.
7. Actor Name Validation (booster-bot)
Code: booster-bot/main.go — validActorName
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.
8. Avatar Filename Validation (booster-bot)
Code: booster-bot/main.go — makeAvatarHandler
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.
9. Upload Size Limits (booster-bot)
- Avatar uploads:
r.ParseMultipartForm(8 << 20)limits the multipart body to 8 MiB. - Inbox body:
io.LimitReader(r.Body, 1<<20)limits to 1 MiB. - All outbound HTTP responses:
io.LimitReader(resp.Body, 1<<20)infetchActor,FetchObject,ResolveHandle.
10. Content-Type Validation on Fetched Objects
Code: activitypub.go — FetchObject
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-Typeheader with theapplication/ld+json; profile="https://www.w3.org/ns/activitystreams"orapplication/activity+jsonmedia 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.
11. Known Limitations
-
Session expiry: verified sessions persist for up to 1 year with no server-side expiry check beyond that. There is no logout endpoint.
-
PIN brute-force: 4-digit PINs (10,000 possibilities) with no rate limiting. An attacker who can send many DMs within the 10-minute window could theoretically enumerate all possibilities. In practice, fediverse rate limits and the requirement for the attacker to already be logged in to send DMs mitigate this.
-
os.Root.Rename: not yet available in Go 1.24. The atomic rename step insaveJSONandsaveActorMetausesos.Renamewith full paths. Both source and destination are withincfg.DataDir, and all filenames are hardcoded string literals, so this is not a traversal risk in practice. -
Relay forwarding: activities signed by a relay on behalf of an actor at a different origin are rejected by the owner equality check in §2. This is a deliberate choice; relay support would require additional trust configuration.