SSRF: unauthenticated AP inbox fetches attacker-controlled actor URLs with no private-IP guard #81

Closed
opened 2026-07-15 19:49:55 +00:00 by claude-bot · 1 comment
Contributor

Severity: Medium · Confidence: High · Effort: S · Category: security

Evidence

  • api/src/lib/apCrypto.ts:51-106 - fetchAndCacheActorfetch(actorUrl) with no host/IP restriction, follows redirects.
  • api/src/lib/apCrypto.ts:182-183, 246-247 - called from verifyInboxSignature / verifyGetSignature.
  • api/src/routes/activitypub.ts:347-390 - public unauthenticated POST /ap/actors/:apActorId/inbox; 244-251 - GET /ap/projects/:id.

Problem
The AP inbox is unauthenticated by design (gated only by activitypub.enabled + block list). To verify a signature the server fetches the actor URL taken from the attacker-supplied keyId/actor, with no allowlist, no private-IP/loopback/link-local filtering, and following redirects.

Impact
Unauthenticated blind SSRF into the homelab / Tailscale network (port probing, hitting internal GET endpoints). Blind (bodies not reflected). Redirect-following can bounce even a federated host to an internal address.

Fix
Add a shared guard that resolves the target host and rejects RFC1918/loopback/link-local/ULA (and disables or re-validates redirects); apply it to every server-side fetch of a remote-controlled URL (inbound actor fetch + outbound delivery/read).

Acceptance criteria

  • Actor fetch to a private/loopback/link-local address is rejected before the request.
  • Redirects to private addresses are rejected.
  • Unit test covers a keyId resolving to 127.0.0.1 / 169.254.169.254 / 10.x.

Related: the actor?signer binding fix (F-03) on the same inbox trust boundary.


Filed from the 2026-07-15 codebase audit. Full report: docs/.internal/report-2026-07-15.md (gitignored).

**Severity:** Medium · **Confidence:** High · **Effort:** S · Category: security **Evidence** - `api/src/lib/apCrypto.ts:51-106` - `fetchAndCacheActor` → `fetch(actorUrl)` with no host/IP restriction, follows redirects. - `api/src/lib/apCrypto.ts:182-183, 246-247` - called from `verifyInboxSignature` / `verifyGetSignature`. - `api/src/routes/activitypub.ts:347-390` - public unauthenticated `POST /ap/actors/:apActorId/inbox`; `244-251` - `GET /ap/projects/:id`. **Problem** The AP inbox is unauthenticated by design (gated only by `activitypub.enabled` + block list). To verify a signature the server fetches the actor URL taken from the attacker-supplied `keyId`/`actor`, with no allowlist, no private-IP/loopback/link-local filtering, and following redirects. **Impact** Unauthenticated blind SSRF into the homelab / Tailscale network (port probing, hitting internal GET endpoints). Blind (bodies not reflected). Redirect-following can bounce even a federated host to an internal address. **Fix** Add a shared guard that resolves the target host and rejects RFC1918/loopback/link-local/ULA (and disables or re-validates redirects); apply it to every server-side fetch of a remote-controlled URL (inbound actor fetch + outbound delivery/read). **Acceptance criteria** - [ ] Actor fetch to a private/loopback/link-local address is rejected before the request. - [ ] Redirects to private addresses are rejected. - [ ] Unit test covers a keyId resolving to 127.0.0.1 / 169.254.169.254 / 10.x. Related: the actor?signer binding fix (F-03) on the same inbox trust boundary. --- _Filed from the 2026-07-15 codebase audit. Full report: `docs/.internal/report-2026-07-15.md` (gitignored)._
Author
Contributor

Fixed in ab0e89e — v7.1.0 wave 2.

New api/src/lib/ssrfGuard.ts:

  • isBlockedIp() — rejects loopback, 10/8, 172.16/12, 192.168/16, 169.254/16 (incl. cloud metadata), 100.64/10 (CGNAT/Tailscale), 0/8, multicast/reserved, and IPv6 ::1 / :: / fe80::/10 / fc00::/7 / IPv4-mapped.
  • assertUrlAllowed() — enforces http(s), resolves the host (all A/AAAA records) and rejects if any resolves to a blocked range. Throws SsrfError before the request is made.
  • safeApFetch() — sets redirect: 'manual' and re-validates every hop (max 4), so a 3xx Location can't bounce us to an internal address.

Applied to both remote-controlled fetches in apCrypto.ts: fetchAndCacheActor (inbound actor fetch) and deliverActivity (outbound delivery).

One deliberate design note: an origin the admin has explicitly federated with (enabled row in federated_instances) bypasses the private-range denylist. Without this, two homelab instances federating over a private/Tailscale address would break — and it mirrors the allowlist approach already used for the #62 outbound path. Unauthenticated attackers hitting the public inbox are not in that table, so keyId=http://169.254.169.254/… is still rejected.

Acceptance criteria:

  • Actor fetch to a private/loopback/link-local address is rejected before the request.
  • Redirects to private addresses are rejected (manual redirect + per-hop validation).
  • Unit test covers 127.0.0.1 / 169.254.169.254 / 10.x — api/src/test/unit/ssrfGuard.test.ts (plus 172.16/12, 192.168/16, CGNAT, IPv6 ULA/link-local, and public-address negative cases).

CI green. Related binding fix: #82.

Fixed in `ab0e89e` — v7.1.0 wave 2. New `api/src/lib/ssrfGuard.ts`: - `isBlockedIp()` — rejects loopback, `10/8`, `172.16/12`, `192.168/16`, `169.254/16` (incl. cloud metadata), `100.64/10` (CGNAT/Tailscale), `0/8`, multicast/reserved, and IPv6 `::1` / `::` / `fe80::/10` / `fc00::/7` / IPv4-mapped. - `assertUrlAllowed()` — enforces http(s), resolves the host (all A/AAAA records) and rejects if **any** resolves to a blocked range. Throws `SsrfError` **before** the request is made. - `safeApFetch()` — sets `redirect: 'manual'` and re-validates every hop (max 4), so a 3xx `Location` can't bounce us to an internal address. Applied to both remote-controlled fetches in `apCrypto.ts`: `fetchAndCacheActor` (inbound actor fetch) and `deliverActivity` (outbound delivery). **One deliberate design note:** an origin the admin has explicitly federated with (enabled row in `federated_instances`) bypasses the private-range denylist. Without this, two homelab instances federating over a private/Tailscale address would break — and it mirrors the allowlist approach already used for the #62 outbound path. Unauthenticated attackers hitting the public inbox are not in that table, so `keyId=http://169.254.169.254/…` is still rejected. **Acceptance criteria:** - [x] Actor fetch to a private/loopback/link-local address is rejected before the request. - [x] Redirects to private addresses are rejected (manual redirect + per-hop validation). - [x] Unit test covers 127.0.0.1 / 169.254.169.254 / 10.x — `api/src/test/unit/ssrfGuard.test.ts` (plus 172.16/12, 192.168/16, CGNAT, IPv6 ULA/link-local, and public-address negative cases). CI green. Related binding fix: #82.
Sign in to join this conversation.
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/TeaLeaves#81
No description provided.