GHSA-p7mm-r948-4q3q: Paperclip Approval Decision Attribution Spoofing Vulnerability

MODERATE
CVSS 4.3
CWE-345
Published
Advisory Summary

GHSA-p7mm-r948-4q3q is a MODERATE (CVSS 4.3) approval decision attribution spoofing vulnerability (CWE-345: Insufficient Verification of Data Authenticity) affecting Paperclip libraries in the npm ecosystem. The issue involves approval decision attribution spoofing via client-controlled `decidedbyuserid` in paperclip server. Remediation is available by upgrading affected packages to version 2026.416.0.

GHSA-p7mm-r948-4q3q at a Glance

CVE Identifier N/A
GHSA Identifier GHSA-p7mm-r948-4q3q
Severity MODERATE
CVSS v3.1 Base Score 4.3
CWE Classification CWE-345: Insufficient Verification of Data Authenticity
Affected Product Paperclip
Package Ecosystem npm
Published Date
Last Updated

Security Impact

CVSS: 4.3

The security impact metrics below are parsed directly from the CVSS v3.1 vector string (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N).

Attack Vector Network (AV:N)
Attack Complexity Low (AC:L)
Privileges Required Low (PR:L)
User Interaction None (UI:N)
Scope Unchanged (S:U)
Confidentiality None (C:N)
Integrity Low (I:L)
Availability None (A:N)

How to Fix GHSA-p7mm-r948-4q3q

To remediate this vulnerability, upgrade all affected Paperclip dependencies to a patched release. Both direct and transitive dependencies should be checked, as frameworks or security libraries may pull in vulnerable versions transitively.

@paperclipai/server fixed: 2026.416.0
npm install @paperclipai/server@2026.416.0
Verify Direct and Transitive Dependencies

Run the following command in your project directory to verify that all resolved dependencies use the patched release:

npm ls @paperclipai/server

How to Check if You Are Affected

To determine whether your Node.js application is affected by GHSA-p7mm-r948-4q3q, inspect your project dependencies using npm:

npm ls @paperclipai/server

Analyze the terminal output to locate any version of Paperclip in your dependency tree. If any resolved version matches the vulnerable ranges listed in the table below (for example, versions earlier than 2026.416.0), your application is affected.

Note: The npm ls @paperclipai/server command inspects dependencies declared in your package.json and lockfile; it does not perform automated runtime dynamic scanning.

Technical Details

Vulnerability Mechanism & Technical Analysis

This vulnerability is classified under CWE-345: Insufficient Verification of Data Authenticity, where the product does not sufficiently verify the origin or authenticity of data, in a way that causes it to accept invalid data. In Paperclip, software state validation or input boundaries are circumvented during processing. Review the official source advisory statement below for specific affected components, classes, and reproduction details.

Source Advisory Statement

## 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).

GHSA-p7mm-r948-4q3q Affected Paperclip Versions

1 component(s)
Fixed Versions:
2026.416.0
Product / Package Ecosystem Affected Versions Fixed Version
@paperclipai/server
npm
< 2026.416.0
2026.416.0

References

Frequently Asked Questions

What is GHSA-p7mm-r948-4q3q?
GHSA-p7mm-r948-4q3q is a security vulnerability in Paperclip involving Approval decision attribution spoofing via client-controlled `decidedByUserId` in paperclip server under CWE-345: Insufficient Verification of Data Authenticity.
Which Paperclip versions are affected?
Affected versions include releases prior to 2026.416.0 across affected packages: @paperclipai/server.
What version fixes GHSA-p7mm-r948-4q3q?
Upgrading to 2026.416.0 (or the latest patched release for your respective branch) resolves the vulnerability.
Is @paperclipai/server affected?
Yes, @paperclipai/server is affected in releases prior to 2026.416.0. Upgrade your dependency to 2026.416.0 or later.
What is the CVSS score of GHSA-p7mm-r948-4q3q?
GHSA-p7mm-r948-4q3q has a CVSS v3.1 base score of 4.3 (MODERATE) with vector string CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N.
What CWE is associated with GHSA-p7mm-r948-4q3q?
GHSA-p7mm-r948-4q3q is classified under CWE-345: Insufficient Verification of Data Authenticity (The product does not sufficiently verify the origin or authenticity of data, in a way that causes it to accept invalid data.).
How can I check npm dependencies for GHSA-p7mm-r948-4q3q?
Run the dependency tree command: 'npm ls @paperclipai/server'. This command lists direct and transitive dependencies in your build configuration so you can verify if a vulnerable version is resolved.
Machine-readable OSV JSON OSV v1.5 Schema
Normalized schema output
{
  "schema_version" : "1.4.0",
  "id" : "GHSA-p7mm-r948-4q3q",
  "modified" : "2026-04-16T22:48:46Z",
  "published" : "2026-04-16T22:48:46Z",
  "aliases" : [ ],
  "summary" : "Paperclip: Approval decision attribution spoofing via client-controlled `decidedByUserId` in paperclip server",
  "details" : "## Summary\n\nThe 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`.\n\n## Details\n\n**Entry point** — `server/src/routes/approvals.ts:130`:\n\n```ts\nrouter.post(\"/approvals/:id/approve\", validate(resolveApprovalSchema), async (req, res) => {\n  assertBoard(req);\n  const id = req.params.id as string;\n  if (!(await requireApprovalAccess(req, id))) {\n    res.status(404).json({ error: \"Approval not found\" });\n    return;\n  }\n  const { approval, applied } = await svc.approve(\n    id,\n    req.body.decidedByUserId ?? \"board\",   // ← client-controlled\n    req.body.decisionNote,\n  );\n```\n\n**Authorization check** — `server/src/routes/authz.ts:4`:\n\n```ts\nexport function assertBoard(req: Request) {\n  if (req.actor.type !== \"board\") {\n    throw forbidden(\"Board access required\");\n  }\n}\n```\n\n`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.\n\n**Validator** — `packages/shared/src/validators/approval.ts:13`:\n\n```ts\nexport const resolveApprovalSchema = z.object({\n  decisionNote: z.string().optional().nullable(),\n  decidedByUserId: z.string().optional().default(\"board\"),\n});\n```\n\nThe Zod schema accepts any string for `decidedByUserId` — no UUID check, no membership check, no binding to the session.\n\n**Sink** — `server/src/services/approvals.ts:54`:\n\n```ts\nconst updated = await db\n  .update(approvals)\n  .set({\n    status: targetStatus,\n    decidedByUserId,               // ← attacker-chosen value written verbatim\n    decisionNote: decisionNote ?? null,\n    decidedAt: now,\n    updatedAt: now,\n  })\n  .where(and(eq(approvals.id, id), inArray(approvals.status, resolvableStatuses)))\n  .returning()\n```\n\n**Secondary sink (budget policies)** — `server/src/services/approvals.ts:147-156`, reached when a `hire_agent` approval with `budgetMonthlyCents > 0` is approved:\n\n```ts\nif (budgetMonthlyCents > 0) {\n  await budgets.upsertPolicy(\n    updated.companyId,\n    { scopeType: \"agent\", scopeId: hireApprovedAgentId, amount: budgetMonthlyCents, windowKind: \"calendar_month_utc\" },\n    decidedByUserId,              // ← forwarded as actorUserId\n  );\n}\n```\n\n`budgets.upsertPolicy` uses that `actorUserId` to populate `createdByUserId`/`updatedByUserId` on the `budget_policies` row, extending the forgery to budget-policy audit columns.\n\n**Same pattern in `reject` and `request-revision`** — `server/src/routes/approvals.ts:229` and `:257`:\n\n```ts\nrouter.post(\"/approvals/:id/reject\", validate(resolveApprovalSchema), async (req, res) => {\n  assertBoard(req);\n  ...\n  const { approval, applied } = await svc.reject(id, req.body.decidedByUserId ?? \"board\", req.body.decisionNote);\n```\n\n`approvalService.reject()` and `requestRevision()` (`approvals.ts:175` and `:201`) both write `decidedByUserId` directly into the approvals row.\n\n**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.\n\n**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.\n\n## PoC\n\nPrerequisite: 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.\n\n1. Attacker approves as the CEO:\n\n```bash\ncurl -X POST http://localhost:3000/approvals/$APPROVAL_ID/approve \\\n  -H 'Content-Type: application/json' \\\n  -H \"Cookie: $ATTACKER_SESSION\" \\\n  -d '{\"decidedByUserId\":\"ceo@corp\",\"decisionNote\":\"LGTM\"}'\n```\n\n2. Verify the forged attribution is stored on the authoritative row:\n\n```bash\ncurl http://localhost:3000/approvals/$APPROVAL_ID \\\n  -H \"Cookie: $ATTACKER_SESSION\" | jq '.decidedByUserId'\n# => \"ceo@corp\"\n```\n\n3. 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`):\n\n```sql\nSELECT scope_id, amount, created_by_user_id, updated_by_user_id\nFROM budget_policies\nWHERE scope_type = 'agent'\nORDER BY created_at DESC LIMIT 1;\n-- created_by_user_id = 'ceo@corp'\n-- updated_by_user_id = 'ceo@corp'\n```\n\n4. The same body works against `/approvals/$APPROVAL_ID/reject` and `/approvals/$APPROVAL_ID/request-revision`.\n\nNote: 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.\n\n## Impact\n\n- **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.\n- **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.\n- **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.\n- **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.\n\n## Recommended Fix\n\nDrop `decidedByUserId` from the request schema entirely and derive it server-side from the authenticated actor. Treat `req.body.decidedByUserId` as untrusted and ignore it.\n\n**`packages/shared/src/validators/approval.ts`:**\n\n```ts\nexport const resolveApprovalSchema = z.object({\n  decisionNote: z.string().optional().nullable(),\n  // decidedByUserId removed — server derives from req.actor\n});\n\nexport const requestApprovalRevisionSchema = z.object({\n  decisionNote: z.string().optional().nullable(),\n});\n```\n\n**`server/src/routes/approvals.ts`** (apply to `/approve`, `/reject`, `/request-revision`):\n\n```ts\nrouter.post(\"/approvals/:id/approve\", validate(resolveApprovalSchema), async (req, res) => {\n  assertBoard(req);\n  const id = req.params.id as string;\n  if (!(await requireApprovalAccess(req, id))) {\n    res.status(404).json({ error: \"Approval not found\" });\n    return;\n  }\n  const decidedBy = req.actor.userId ?? \"board\";   // trust the session, not the body\n  const { approval, applied } = await svc.approve(id, decidedBy, req.body.decisionNote);\n  ...\n});\n```\n\nRepeat 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).",
  "severity" : [ {
    "type" : "CVSS_V3",
    "score" : "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N"
  } ],
  "affected" : [ {
    "package" : {
      "ecosystem" : "npm",
      "name" : "@paperclipai/server"
    },
    "ranges" : [ {
      "type" : "ECOSYSTEM",
      "events" : [ {
        "introduced" : "0"
      }, {
        "fixed" : "2026.416.0"
      } ]
    } ]
  } ],
  "references" : [ {
    "type" : "WEB",
    "url" : "https://github.com/paperclipai/paperclip/security/advisories/GHSA-p7mm-r948-4q3q"
  }, {
    "type" : "PACKAGE",
    "url" : "https://github.com/paperclipai/paperclip"
  } ],
  "database_specific" : {
    "cwe_ids" : [ "CWE-345" ],
    "severity" : "MODERATE",
    "github_reviewed" : true,
    "github_reviewed_at" : "2026-04-16T22:48:46Z",
    "nvd_published_at" : null
  }
}