<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:
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);
...
});
<img width="5429" height="1448" alt="02-signup" src="https://github.com/user-attachments/assets/4c2b2939-326b-4e0d-aa01-05e22851486b" />
> 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.
<img width="2891" height="1697" alt="03-exploit" src="https://github.com/user-attachments/assets/c097e861-6bc9-4f6a-841c-b45501e27849" />
> 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.
<img width="2983" height="2009" alt="04-exfil" src="https://github.com/user-attachments/assets/ede5d469-4119-432c-b0ae-5a4fabc9a56b" />
> 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.