GitHub Security Advisory: GHSA-p7mm-r948-4q3q
Package: @paperclipai/server (NPM)
Severity: MODERATE (CVSS 4.3)
CWE: CWE-345: Insufficient Verification of Data Authenticity
Vulnerable versions: < 2026.416.0
Patched in: 2026.416.0

Summary: Paperclip: Approval decision attribution spoofing via client-controlled `decidedByUserId` in paperclip server

Details:
## 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_p