Fedibook Security Review 1

Date: 2026-03-25
Reviewer: Claude Code (claude-sonnet-4-6)
Scope: dev1 (primary codebase), dev2 (configuration variant), publish (release template)
Code read: All Go backend files, all Svelte/TypeScript frontend files, all Docker/compose files, all configuration files.


Summary Statement

Fedibook is a small but structurally sound ActivityPub social network. The overall security posture is medium. The code is written with clear awareness of common web application threats: it uses Argon2id for password hashing, parameterized queries throughout (no SQL injection risk found), MIME-sniffed file type validation on uploads, JWT revocation via Redis, and HTTP Signatures on all ActivityPub traffic.

The two most critical findings are:

  1. Group posts (GET /api/v1/groups/{id}/posts) are returned to any authenticated user, including non-members. The handler checks hidden-group visibility for the GET endpoint but does NOT verify that the requesting user is an active group member before returning posts. For closed groups, the check is g.Visibility == "hidden" && !g.IsMember — meaning a closed (non-hidden) group's posts are readable by any logged-in user without membership.

  2. No rate limiting anywhere. There is no rate limiting on login, registration, password reset, the ActivityPub inbox, or any other endpoint. The login endpoint is fully open to brute-force credential stuffing. This is a fundamental gap that affects all endpoints.

Overall recommendation: The codebase is a legitimate early-stage project with good security instincts, but it is not ready for a general public deployment. The missing rate limiting and the group post access control flaw should be fixed before opening the service to untrusted users.


1. Dependencies and Known Vulnerabilities

Backend Go dependencies (go.mod, version as used)

Package Version Notes
github.com/go-chi/chi/v5 v5.2.1 Current stable. No known CVEs.
github.com/golang-jwt/jwt/v5 v5.2.1 Current stable. No known CVEs. Correctly rejects non-HMAC algorithms (alg:none attack is mitigated).
github.com/golang-migrate/migrate/v4 v4.18.1 Recent. No known CVEs.
github.com/google/uuid v1.6.0 Current. No known CVEs.
github.com/hibiken/asynq v0.24.1 Recent. No known CVEs.
github.com/jackc/pgx/v5 v5.7.2 Current stable. No known CVEs.
github.com/redis/go-redis/v9 v9.7.3 Current stable. No known CVEs.
golang.org/x/crypto v0.36.0 Current stable. Contains the Argon2id implementation. No known CVEs.
golang.org/x/time v0.5.0 Provides rate package. Imported as indirect but not used — rate limiting is absent despite the package being available.

Assessment: Go dependencies are all recent and have no known published CVEs. The dependency surface is small and well-chosen.

Frontend dependencies (package.json)

Package Version constraint Notes
svelte ^4.2.0 Svelte 4.x is current. No known CVEs in recent versions.
@sveltejs/kit ^2.5.0 SvelteKit 2.x is current. No known CVEs.
vite ^5.0.0 Vite 5.x. No known CVEs in recent releases.
typescript ^5.3.0 Current. No known CVEs.
tailwindcss ^3.4.0 Current. No known CVEs.
node (runtime) 20-alpine (Dockerfile) Node 20 LTS is currently supported. No critical unpatched CVEs.

Assessment: Frontend dependencies are current. No known CVE exposure.

Docker base images

Image Version Notes
golang:1.23-alpine Builder only (not in final image) Go 1.23 is supported. Current toolchain is go1.23.12 per go.mod.
alpine:3.21 Backend runtime Current stable. Minimal attack surface.
postgres:16-alpine Current stable No known exploitable CVEs.
redis:7-alpine Current stable No known exploitable CVEs.
node:20-alpine Frontend runtime Node 20 LTS. Current.

Assessment: All base images are current. No pinned digests — image tags are mutable and could silently pull newer (potentially broken or compromised) versions. This is a low risk for a private deployment but worth noting.

Prioritized update list

No urgent dependency updates are required at this time. The only recommendation is to pin Docker image digests for production deployments and re-evaluate when LTS support windows close.


2. Passwords and User Data

Password hashing

Implemented correctly. Passwords are hashed with Argon2id (the memory-hard variant recommended by OWASP):

argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)

Parameters: 1 iteration, 64 MB memory, 4 threads, 32-byte output. A 16-byte random salt is generated with crypto/rand per password. The comparison is constant-time (manual XOR loop). This is solid.

Finding (Low): The time cost parameter (t=1) is at the very low end of OWASP recommendations (which suggest t≥1 and m≥47104). At m=65536, t=1 is acceptable but consider t=2 or t=3 for stronger protection on faster hardware.

Finding (Medium): Password reset tokens are hashed with SHA-256 (hashResetToken). The raw token is 32 bytes of crypto/rand output (256 bits), so brute force is infeasible. This is acceptable practice. Email verification tokens use the same mechanism. No issues here.

Personal data stored

The database stores: username, email address, full name, bio, location, avatar/cover URLs, password hash, RSA private key (for ActivityPub signing), IP-derived data (indirectly, via the chi RealIP middleware). Email addresses appear in API responses to admin users only (the admin panel lists user emails). They do not appear in public-facing API responses.

Finding (Low): RSA private keys are stored in the user_keys table in plaintext PEM format. If the database is compromised, all users' ActivityPub private keys are exposed. For a social network at this scale this is a common trade-off, but worth noting.

Finding (Info): The location and bio fields are stored but there is no length validation visible in the service layer (only username length is validated). A very long bio or location string will be stored and returned in API responses, which could cause unexpected client behavior.

Sessions and JWT tokens

  • Tokens are HMAC-SHA256 JWTs signed with APP_SECRET_KEY.
  • Token lifetime is 30 days (tokenTTL = 30 * 24 * time.Hour).
  • Each token has a unique jti (UUID v4).
  • On logout, the jti is stored in Redis with a TTL matching the token expiry. All subsequent requests check Redis for revocation.
  • The middleware correctly rejects tokens with unknown signing algorithms (jwt.ErrSignatureInvalid is returned for non-HMAC methods), preventing algorithm confusion attacks.
  • Tokens are sent as HttpOnly cookies (SameSite=Lax) from the frontend. The secure flag is not set explicitly on the cookie.

Finding (Medium — Cookie secure flag missing): The login handler sets cookies with httpOnly: true, sameSite: 'lax' but does not set secure: true. Without the secure flag, the browser will transmit the cookie over HTTP as well as HTTPS. In practice, Traefik handles HTTPS termination and the SvelteKit server only receives HTTPS traffic through it, so this is unlikely to cause token leakage in the current deployment model. However, if the frontend were ever accessible directly over HTTP, tokens could leak. This should be set to secure: true unconditionally.

Finding (Low — 30-day token lifetime): A 30-day JWT lifetime with logout-time revocation is reasonable for a social network. The revocation mechanism (Redis blacklist) works correctly. However, if Redis is unavailable, revoked tokens will be accepted (the middleware revokes on err != nil || revoked; an error on IsTokenRevoked causes rejection, which is correct defensive behavior — tokens are denied on Redis error).

Finding (Info): The is_admin claim is baked into the JWT at issuance. If a user's admin status changes (promoted or demoted), existing tokens retain the old admin status until they expire or are manually revoked. There is no mechanism to force-revoke all tokens for a specific user.

Sensitive data in logs and error messages

Finding (Medium — Debug logging of usernames in production): The hooks.server.ts logs authentication activity to stdout on every request:

console.log(`[hooks] ${method} ${path} token=yes`)
console.log('[hooks] user set:', user.username)

These are console.log calls that will appear in production container logs. Usernames, paths, and auth status are logged on every request. This is not catastrophic but increases the information available if logs are exfiltrated.

Finding (Low): slog.Debug("login attempt", "username", req.Username) logs usernames at DEBUG level. In production, LOG_LEVEL defaults to info, so this will not appear in production logs. Acceptable.

Finding (Info): Error messages in API responses use generic text ("Uventet fejl." = "Unexpected error.") for internal server errors. This is correct — stack traces and internal details are not leaked to clients.


3. ActivityPub Security

HTTP Signature verification

Implemented correctly on all inbox endpoints. Both the user inbox (POST /ap/users/{username}/inbox) and the group inbox (POST /ap/groups/{id}/inbox) verify the incoming HTTP Signature before processing the activity:

if err := activitypub.VerifyRequest(r, body, func(keyID string) (string, error) {
    return activitypub.FetchPublicKeyPEM(r.Context(), keyID)
}); err != nil {
    writeProblem(w, http.StatusUnauthorized, ...)
    return
}

The verification reconstructs the signing string from the request headers, fetches the sender's public key from their actor URL, and verifies the RSA-PKCS1v15 signature. Digest verification is also performed when a Digest header is present.

Finding (Medium — Digest header is optional): The VerifyRequest function only validates the Digest header if it is present: if digestHeader := r.Header.Get("Digest"); digestHeader != "" {. A sender could omit the Digest header entirely, and the body would be accepted without integrity verification. If only (request-target) host date are signed (without digest), the body could be modified in transit without detection. The server should require the Digest header to be present and signed.

Finding (Medium — No clock skew check on the Date header): The VerifyRequest function does not verify that the Date header is within an acceptable window (e.g., ±5 minutes) of the current time. Without this, signed requests can be replayed indefinitely as long as the key is not revoked. Most ActivityPub implementations enforce a clock window. This should be added.

Finding (Low — No key caching): For every incoming ActivityPub activity, FetchPublicKeyPEM makes an HTTP GET to the remote actor's URL to retrieve their public key. There is no caching. A burst of incoming activities from the same remote instance will cause a burst of outbound HTTP requests to that instance's actor endpoints. This is a minor performance and availability concern (could be used to amplify load on the local server if the remote is slow).

Outgoing signatures

All outgoing deliveries are signed via activitypub.SignRequest, which signs (request-target) host date digest. This is correct.

Spoofing from malicious instances

Finding (Medium — Actor URL is not cross-checked against the keyId domain): In VerifyRequest, the keyId parameter from the Signature header is used directly to fetch the public key from the remote server. There is no validation that the keyId domain matches the actor field domain in the activity body. A malicious instance at evil.com could claim to act on behalf of mastodon.social by presenting a Signature with keyId="https://mastodon.social/users/admin#main-key" but then serving a spoofed key at that URL (if it can intercept DNS or the TLS certificate is not validated). TLS certificate validation is handled by the Go standard library's default HTTP client, which does validate certificates, so this specific attack requires a compromised CA or MITM capability. However, there is no explicit check that actor and keyId share the same origin domain. This is a known issue in many AP implementations; adding an explicit origin check would be more robust.

Rate limiting on the ActivityPub inbox

Finding (High — No rate limiting on the AP inbox.) Covered in section 6.


4. Access Control for Posts and Groups

Visibility levels: public / friends / instance

Implemented correctly in most places. The GetByID (single post) and GetUserPosts SQL queries both include a visibility gate:

WHERE p.id = $1
  AND (
    p.visibility = 'public'
    OR p.author_id = $2
    OR (p.visibility IN ('friends','instance') AND EXISTS(
      SELECT 1 FROM friendships ...
      WHERE status = 'accepted'
    ))
  )

The feed query similarly gates on friendship, AP-follow status, or group membership. This is implemented at the database layer, which is the correct place. No bypass via direct API calls was found for individual post or user-post list endpoints.

Finding (High — Group posts accessible to non-members of closed groups):

The GET /api/v1/groups/{id}/posts handler (GroupHandler.Posts) fetches the group and then:

if g.Visibility == "hidden" && !g.IsMember {
    writeProblem(w, http.StatusNotFound, ...)
    return
}
posts, ... := h.postSvc.GetGroupPosts(...)

For a closed group (Visibility == "closed"), there is no membership check — any authenticated user can read all posts. The GetGroupPosts repository query is:

WHERE p.group_id = $1
AND ($3::timestamptz IS NULL OR p.created_at < $3::timestamptz)

There is no AND EXISTS(SELECT 1 FROM group_memberships WHERE group_id = $1 AND user_id = $2 AND status = 'active') filter. The requestingUserID parameter is passed but used only for computing liked_by_me and has_new_comments, not for access control.

Impact: Any logged-in user can enumerate group UUIDs (visible in the group list endpoint) and then read all posts of any closed group without being a member.

Recommendation: Add a membership check in the handler: if !g.IsMember, return 403 for closed groups (or 404 to be consistent with hidden groups). Also add an analogous check in the GetGroupPosts SQL query as a defense in depth measure.

Hidden groups

Correctly implemented for most operations. GetByID in the service layer returns ErrNotFound if the group is hidden and the user has MembershipStatus == "none". The List query excludes hidden groups unless the user has an active membership. The join and invite flows also enforce this. The group posts endpoint correctly hides hidden-group posts from non-members (though, as noted above, closed groups are not hidden from non-members).


5. Invitations and Join Requests

Joining without an invitation

Users cannot bypass the invitation/join-request flow. GroupRepo.Join always creates a membership with status = 'pending', never directly active. The admin must call ApproveRequest to activate it. This is correctly enforced.

Finding (Low — Remote Fedibook users join via AP Follow, which also creates a pending membership): The HandleGroupFollow handler in APGroupService correctly creates a pending membership for remote users via groupStore.Join. The Accept is only sent after an admin explicitly approves via the API (SendGroupAccept). This is correct behavior.

Invitation abuse

Finding (Low — Any active member can invite anyone): The Invite service function checks only that the inviter is an active member, not that they are an admin:

// Invite invites a user. Any active member can invite.
func (s *GroupService) Invite(...) error {
    g, err := s.groups.GetByID(ctx, groupID, inviterID)
    ...
    if err := s.groups.Invite(ctx, groupID, inviterID, inviteeID); ...

And in the repository:

// Check inviter is an active member
SELECT EXISTS(SELECT 1 FROM group_memberships WHERE group_id = $1 AND user_id = $2 AND status = 'active')

This means any member (not just admins) can invite others. Depending on the intended group policy, this may be a design choice or a flaw. For hidden groups especially, this is a risk: a single compromised or rogue member can invite external users to a hidden group without admin knowledge. The admin is not notified of member-initiated invites (only a notification is sent to the invitee).

Finding (Low — Invitations can be accepted only by the invitee's authenticated session): AcceptInvite uses the JWT-authenticated userID from context, which is verified by the middleware. An invitation cannot be accepted by a different user than the one to whom it was sent, because the AcceptInvite DB call is:

UPDATE group_memberships
SET status = 'active', joined_at = NOW()
WHERE group_id = $1 AND user_id = $2 AND status = 'invited'

where $2 is the requesting user's own ID. Correct.

Finding (Info — Invite tokens are not time-limited): Invitations (stored as status = 'invited' rows) do not expire. An invitation to a group that has since changed visibility or become restricted remains valid indefinitely until accepted, declined, or the member is explicitly removed.


6. General API Security

Authentication enforcement

All routes under /api/v1 (except the explicit public auth routes at /api/v1/auth/...) are protected by the Authenticate middleware. The router structure confirms there is no gap:

r.Route("/api/v1", func(r chi.Router) {
    r.Use(appmiddleware.Authenticate(deps.JWTSecret, deps.TokenRepo))
    // all user-facing routes here
})

The ActivityPub, WebFinger, NodeInfo, and health endpoints are intentionally public, which is correct.

Admin routes use a double-gate: the JWT Authenticate middleware plus an explicit RequireAdmin middleware that reads the user from the database (not just from the JWT claim). This is a good defense-in-depth pattern.

Rate limiting

Finding (Critical — No rate limiting anywhere in the application.)

There is no rate limiting on:

  • POST /api/v1/auth/login — susceptible to unlimited brute-force password attempts
  • POST /api/v1/auth/register — susceptible to mass account creation
  • POST /api/v1/auth/password-reset — susceptible to email bombing / enumeration timing
  • POST /ap/users/{username}/inbox and POST /ap/groups/{id}/inbox — susceptible to DoS via crafted AP activities
  • Media upload — already has a 10 MB per-file limit but no request-rate limit

The package golang.org/x/time/rate is already in the dependency graph (pulled in as an indirect dependency). Implementing a token-bucket rate limiter per IP for the auth endpoints is straightforward using chi middleware.

Recommendation: At minimum, implement per-IP rate limiting on /api/v1/auth/login (e.g., 5 attempts per minute) and /api/v1/auth/register (e.g., 10 per hour). For the AP inbox, a per-domain or per-key-ID limit would be appropriate.

CSRF

Finding (Low — SvelteKit's built-in CSRF protection provides partial coverage.)

SvelteKit enforces ORIGIN checking for form actions (the ORIGIN environment variable is set in the compose file). The enhance directive is used for form submissions, which means state-changing operations go through SvelteKit's server actions with built-in CSRF token handling. However, the REST API endpoints on the backend (/api/v1/...) accept both Bearer token (from Authorization header) and a cookie named token. If a user's browser has the token cookie set and visits a malicious cross-origin page, a CSRF attack could be attempted against the backend API directly, bypassing SvelteKit. This is partially mitigated by SameSite=Lax on the cookie (which prevents cross-site POST with cookies in most browsers) but not fully, as Lax permits cross-site GET and there may be edge cases. A SameSite=Strict cookie would be more robust.

XSS

No {@html} directives or innerHTML assignments were found in any Svelte component. All user-generated content is rendered through Svelte's default text interpolation ({variable}), which HTML-escapes content automatically. The htmlToText function in the AP service strips all HTML tags before storing remote content. This is correctly implemented.

SQL injection

No SQL injection risk found. All database queries use pgx parameterized queries ($1, $2, etc.) throughout every repository file. No raw string concatenation into SQL was found.

File upload handling

Implemented correctly. The MediaService.Upload function:

  1. Reads the file content and sniffs the MIME type using http.DetectContentType plus a manual WebP check — it does not trust the client-supplied Content-Type header.
  2. Rejects any MIME type not in the whitelist (image/jpeg, image/png, image/gif, image/webp).
  3. Generates a new UUID filename with the correct extension — the original client-supplied filename is not used.
  4. Stores files in the upload directory (/app/uploads), served as static files via http.FileServer.
  5. Image dimensions are decoded using the stdlib image decoder.

Finding (Low — Decompression bomb potential in GIF/animated WebP): The image.DecodeConfig call reads only the image header to get dimensions, not the full image, so it is safe from decompression bombs. However, the io.ReadAll(in.File) at the beginning of the Upload function reads the entire 10 MB into memory before MIME detection. With concurrent uploads this could be a memory pressure concern, but it is not a security vulnerability per se.

Finding (Low — Uploaded files are served without Content-Type sniffing protection): The upload directory is served via http.FileServer. Go's http.FileServer sets the Content-Type header based on file extension. Since filenames are UUIDs with the correct extension, and only image extensions are allowed, there is no risk of serving an uploaded file as text/html and causing XSS.

Finding (Medium — BODY_SIZE_LIMIT: Infinity in docker-compose.yml):

BODY_SIZE_LIMIT: Infinity

This is a SvelteKit configuration that disables the built-in request body size limit. It is set to enable large video file uploads (handled by the post creation endpoint with a 2 GB limit enforced in the handler). However, this means that any multipart request to the frontend server has no size limit at the SvelteKit layer, relying entirely on the backend to enforce limits. If Traefik does not impose a body size limit, very large requests could be used to exhaust server memory. Traefik itself does have configurable body limits (not shown in the provided compose files). This should be confirmed.


7. Docker and Infrastructure

Port exposure

No services expose ports directly to the host in either the dev or publish compose files. All traffic routes through the Traefik reverse proxy on the proxy external network. The app, db, redis, and worker services communicate only on the internal app bridge network. This is a good configuration.

Container user

Implemented correctly. The backend Dockerfile creates a non-root user and runs as that user:

RUN addgroup -S app && adduser -S app -G app && mkdir -p /app/uploads && chown app:app /app/uploads
USER app

The frontend Dockerfile uses node:20-alpine which runs as root by default. This is a minor finding.

Finding (Low — Frontend container runs as root): The frontend/Dockerfile does not add a non-root user. The Node.js process runs as root inside the container. While the container is isolated, running as root increases the blast radius of a container escape. A non-root user should be added.

Secrets in environment

Finding (Low — dev1/.env is tracked by git):

The file /docker/fedibook/dev1/.env contains live credentials (SMTP API key, admin password, JWT secret m.fl.) og er nødvendig for at køre dev1-instansen. .env-filen distribueres ikke med releases og udgør derfor ikke en risiko i produktionsbrug.

Risikoen er alene at filen er tracked i git: hvis repositoriet nogensinde pushes til et offentligt remote (GitHub, GitLab o.l.), vil credentials være eksponeret i historikken.

Anbefaling:

  1. Tilføj dev1/.env og dev2/.env til .gitignore og fjern dem fra git-historikken (git filter-repo eller BFG Repo Cleaner), så de ikke ved en fejl havner i et offentligt repo.
  2. Brug .env.example-filer med dummy-værdier som dokumentation i stedet.

Finding (Low — DB and Redis passwords are weak in dev1): DB_PASSWORD and REDIS_PASSWORD use obviously weak dev credentials. These are not surprising for a development instance, but if this instance is network-accessible, the internal app network should be the only network that can reach them.

TLS configuration

Finding (Low — Database connection uses sslmode=disable):

fmt.Sprintf("host=%s port=%d dbname=%s user=%s password=%s sslmode=disable", ...)

The DB connection disables TLS. Since PostgreSQL runs on the same internal Docker network as the application, this is acceptable in the current deployment model (Docker network traffic does not traverse untrusted networks). For a deployment where the database is on a separate host or cloud-managed, this should be changed to sslmode=require or verify-full.

NodeInfo honesty

Finding (Info): The NodeInfo endpoint at /nodeinfo/2.1 always reports openRegistrations: true regardless of the REGISTRATION_MODE configuration. An operator running an invite-only instance will be misrepresenting their open registration status to the fediverse. This is cosmetic but misleading.


Prioritized Action List

Priority Severity Finding Action
1 Critical No rate limiting on any endpoint, including login and AP inbox Implement per-IP rate limiting on auth endpoints using golang.org/x/time/rate. Consider Traefik middleware as a first layer.
3 High Group posts (GET /api/v1/groups/{id}/posts) return content to non-members of closed groups Add !g.IsMember check for closed groups in the handler. Add membership filter to GetGroupPosts SQL query.
4 Medium No clock-skew validation on HTTP Signature Date header Reject activities where Date is more than 5 minutes from time.Now() in VerifyRequest.
5 Medium HTTP Signature Digest header is optional, allowing body substitution Require the Digest header to be present and signed on all inbox POST requests.
6 Medium Actor domain is not cross-checked against keyId domain in signature verification Validate that the origin of the keyId URL matches the origin of the actor field in the activity.
7 Medium BODY_SIZE_LIMIT: Infinity in SvelteKit — confirm Traefik enforces a body size limit Set an explicit client_max_body_size equivalent in Traefik or use BODY_SIZE_LIMIT=2gb.
8 Medium Debug console.log calls in hooks.server.ts log usernames and paths on every request Wrap console calls in a DEBUG condition or remove production-irrelevant logging.
9 Medium Cookie secure flag not set Add secure: true to all cookies.set() calls in the frontend server files.
10 Low Any active group member can invite any user, including to hidden groups, without admin notification Consider restricting invitations to group admins, or at minimum send an admin notification on member-initiated invites.
11 Low Frontend container runs as root Add a non-root user to the frontend Dockerfile.
12 Low is_admin claim baked into JWT — no per-user token revocation Accept as a known limitation or add a per-user token version counter in Redis.
13 Low No public-key caching for AP signature verification Add a short-lived in-memory or Redis cache for remote public keys (e.g., 5-minute TTL).
14 Low DB connection uses sslmode=disable Acceptable within Docker network; change to sslmode=require if DB is ever external.
15 Low Argon2id time cost t=1 is at the minimum Consider increasing to t=2 or t=3 for slightly stronger protection.
16 Info NodeInfo always reports openRegistrations: true Read REGISTRATION_MODE config and set the field accordingly.
17 Info Group invitations do not expire Add an expires_at column to group_memberships for invited status.
18 Info No Docker image digest pinning Pin images to SHA256 digests in production compose files.
19 Low dev1/.env og dev2/.env er tracked af git Tilføj til .gitignore og rens git-historikken for at undgå utilsigtet eksponering ved et offentligt push.