GitHub Security Advisory: GHSA-47wq-cj9q-wpmp
Package: @paperclipai/server (NPM)
Severity: CRITICAL (CVSS 9.9)
CWE: CWE-285: Improper Authorization, CWE-639: Authorization Bypass Through User-Controlled Key, CWE-862: Missing Authorization, CWE-1220: Insufficient Granularity of Access Control
Vulnerable versions: < 2026.416.0
Patched in: 2026.416.0

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

Details:
<img width="7007" height="950" alt="01-setup" src="https://github.com/user-attachments/assets/1596b8d1-8de5-4c21-b1d2-2db41b568d7e" />

> 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);                             // <-- no assertCompanyAccess
  const id = req.params.id as string;
  const keys = await svc.listKeys(id);
  res.json(keys);
});

router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
  assertBoard(req);                             // <-- no assertCompanyAccess
  const id = req.params.id as string;
  const key = await svc.createApiKey(id, req.body.name);
  ...
  res.status(201).json(key);                    // returns plaintext `token`
});

router.delete("/agents/:id/keys/:keyId", async (req, res) => {
  assertBoard(req);                             // <-- no assertCompanyAccess
  const keyId = req.params.keyId as string;
  const revoked = await svc.revokeKey(keyId);
  ...
});
```

Compare the handler 12 lines below, `router.post("/agents/:id/wakeup")`,
which shows the correct pattern: it fetches the agent, then calls
`assertCompanyAccess(req, agent.companyId)`. The three `/keys` handlers above
do not even fetch the agent.

The token returned by `POST /agents/:id/keys` is bound to the **victim**
company in `server/src/services/agents.ts`, lines 580-609:

```ts
createApiKey: async (id: string, name: string) => {
  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