Paperclip: Approval decision attribution spoofing via client-controlled `decidedByUserId` in paperclip server
Date: 2026-04
Affected: npm/@paperclipai/server
Attack vector: other
Category: vulnerability-disclosure
Severity: Medium
CWE: CWE-345
CVSS: 4.3 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N)

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


Mitigations:

Architectural layers (MAESTRO):