GitHub Security Advisory: GHSA-3xx2-mqjm-hg9x
Package: @paperclipai/server (NPM)
Severity: CRITICAL (CVSS 9.9)
CWE: CWE-639: Authorization Bypass Through User-Controlled Key
Vulnerable versions: < 2026.416.0
Patched in: 2026.416.0

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

Details:
## Summary

The `GET`, `POST`, and `DELETE` handlers under `/agents/:id/keys` in the Paperclip control-plane API only call `assertBoard(req)`, which verifies that the caller has a board-type session but does not verify that the caller has access to the company owning the target agent. A board user whose membership is limited to Company A can therefore list, create, or revoke agent API keys for any agent in Company B by supplying the victim agent's UUID in the URL path. The `POST` handler returns the newly-minted token in cleartext, which authenticates subsequent requests as `{type:"agent", companyId:<CompanyB>}`, giving the attacker full agent-level access inside the victim tenant — a complete cross-tenant compromise.

## Details

The three vulnerable routes are defined in `server/src/routes/agents.ts:2050-2087`:

```ts
router.get("/agents/:id/keys", async (req, res) => {
  assertBoard(req);                       // <-- only checks actor.type === "board"
  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);                       // <-- same
  const id = req.params.id as string;
  const key = await svc.createApiKey(id, req.body.name);
  // ... activity log ...
  res.status(201).json(key);              // returns cleartext `token`
});

router.delete("/agents/:id/keys/:keyId", async (req, res) => {
  assertBoard(req);                       // <-- same
  const keyId = req.params.keyId as string;
  const revoked = await svc.revokeKey(keyId);
  if (!revoked) { res.status(404).json({ error: "Key not found" }); return; }
  res.json({ ok: true });
});
```

`assertBoard` in `server/src/routes/authz.ts:4-8` is intentionally narrow:

```ts
export function assertBoard(req: Request) {
  if (req.actor.type !== "board") {
    throw forbidden("Board access required");
  }
}
```

It does **not** consult `req.actor.companyIds` or `req.actor.isInstanceAdmin`. Company-scoping is handled by a separate helper, `assertCompanyAccess(req, companyId)` (same file, lines 18-31), which the key-management routes never call.

The service layer is also unauthenticated. In `server/src/services/agents.ts:580-629`:

```ts
createApiKey: async (id: string, name: string) => {
  const existing = await getById(id);
  if (!existing) throw notFound("Agent not found");
  // ... status checks only ...
  const token = createToken();
  const keyHash = hashToken(token);
  const created = await db
    .insert(agentApiKeys)
    .values({
      agentId: id,
      companyId: existing.companyId,       // <-- copied from the victim agent
      name,
      keyHash,
    })
    .returning()
    .then((rows) => rows[0]);
  return { id: created.id, name: created.name, token, createdAt: created.createdAt };
},

listKeys: (id: string) => db.select({ ... }).from(agentApiKeys).where(eq(agentApiKeys.agentId, id)),

revokeKey: async (keyId: string) => {
  const