Bind HTTP-signature signer to activity.actor on all AP inbox activity types #82

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/routes/activitypub.ts:402-446 (Follow), 502-519 (Like/Announce), 522-621 (Create-comment; stores ap_actor_url and resolves author_name from activity.actor at 604-618).
  • api/src/routes/activitypub.ts:312-326 - the entry-write path DOES bind signer?actor via parseSignatureKeyId (the correct pattern, applied only there).

Problem
verifyInboxSignature proves the request was signed by the owner of keyId, but for every activity type except the entry-write path it does not assert keyId owner == activity.actor. An attacker controlling any domain (valid actor doc + key) can sign a request while setting activity.actor to an arbitrary third party.

Impact
Impersonation of arbitrary fediverse identities in public motif comments (stored with a spoofed ap_actor_url and the victim's real display name), plus forced Follow rows and an unsolicited signed Accept. Content is sanitized, so no XSS - identity spoofing, not code execution.

Fix
After verifyInboxSignature, reject when parseSignatureKeyId(signature) !== activity.actor for all activity types (hoist the check the entry-write path already does).

Acceptance criteria

  • A signed activity whose actor → signer keyId owner is rejected (401) for Follow, Like, Announce, and Create.
  • Unit test covers the mismatch case.

Related: SSRF hardening (F-02) on the same inbox.


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/routes/activitypub.ts:402-446` (Follow), `502-519` (Like/Announce), `522-621` (Create-comment; stores `ap_actor_url` and resolves `author_name` from `activity.actor` at 604-618). - `api/src/routes/activitypub.ts:312-326` - the entry-write path DOES bind signer?actor via `parseSignatureKeyId` (the correct pattern, applied only there). **Problem** `verifyInboxSignature` proves the request was signed by the owner of `keyId`, but for every activity type except the entry-write path it does not assert `keyId` owner == `activity.actor`. An attacker controlling any domain (valid actor doc + key) can sign a request while setting `activity.actor` to an arbitrary third party. **Impact** Impersonation of arbitrary fediverse identities in public motif comments (stored with a spoofed `ap_actor_url` and the victim's real display name), plus forced Follow rows and an unsolicited signed `Accept`. Content is sanitized, so no XSS - identity spoofing, not code execution. **Fix** After `verifyInboxSignature`, reject when `parseSignatureKeyId(signature) !== activity.actor` for all activity types (hoist the check the entry-write path already does). **Acceptance criteria** - [ ] A signed activity whose actor → signer keyId owner is rejected (401) for Follow, Like, Announce, and Create. - [ ] Unit test covers the mismatch case. Related: SSRF hardening (F-02) on the same inbox. --- _Filed from the 2026-07-15 codebase audit. Full report: `docs/.internal/report-2026-07-15.md` (gitignored)._
Author
Contributor

Fixed in ab0e89e (+ test fix f4ea594) — v7.1.0 wave 2.

Hoisted the signer↔actor binding out of tryRemoteEntryWrite so it runs for every activity type, immediately after verifyInboxSignature and before any handler reads activity.actor:

const verifiedSigner = parseSignatureKeyId(sigHeader);
const claimedActor = typeof activity.actor === 'string' ? activity.actor : null;
if (!verifiedSigner || !claimedActor || verifiedSigner !== claimedActor) {
  res.status(401).json({ error: 'Signature does not match activity actor' });
  return;
}

Covers Follow / Invite / Undo / Like / Announce / Create / Delete. The entry-write path keeps its own check as defence in depth.

Acceptance criteria:

  • A signed activity whose actor ≠ signer keyId owner is rejected (401).
  • Unit test covers the mismatch case — api/src/test/integration/apSignature.test.ts asserts a Like signed by alice but claiming actor: mallory → 401, with a matching-actor control returning 202 (so the 401 is provably the binding, not a bad signature). Verified on the dev server:
    [AP inbox] signature actor mismatch: { verifiedSigner: '…/alice', claimedActor: '…/mallory' }

CI green (typecheck + 239 tests).

Fixed in `ab0e89e` (+ test fix `f4ea594`) — v7.1.0 wave 2. Hoisted the signer↔actor binding out of `tryRemoteEntryWrite` so it runs for **every** activity type, immediately after `verifyInboxSignature` and before any handler reads `activity.actor`: ```ts const verifiedSigner = parseSignatureKeyId(sigHeader); const claimedActor = typeof activity.actor === 'string' ? activity.actor : null; if (!verifiedSigner || !claimedActor || verifiedSigner !== claimedActor) { res.status(401).json({ error: 'Signature does not match activity actor' }); return; } ``` Covers Follow / Invite / Undo / Like / Announce / Create / Delete. The entry-write path keeps its own check as defence in depth. **Acceptance criteria:** - [x] A signed activity whose actor ≠ signer keyId owner is rejected (401). - [x] Unit test covers the mismatch case — `api/src/test/integration/apSignature.test.ts` asserts a Like signed by `alice` but claiming `actor: mallory` → 401, with a matching-actor control returning 202 (so the 401 is provably the binding, not a bad signature). Verified on the dev server: `[AP inbox] signature actor mismatch: { verifiedSigner: '…/alice', claimedActor: '…/mallory' }` CI green (typecheck + 239 tests).
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#82
No description provided.