From b6591c93a075406fdc15779d795db232f501bdb0 Mon Sep 17 00:00:00 2001 From: "sentry[bot]" <39604003+sentry[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 14:31:24 -0700 Subject: [PATCH] fix(issues): validate UUID query parameters on list and count endpoints (#17389) Reject malformed UUID filters (participantAgentId, projectId, workspaceId, executionWorkspaceId, parentId, parentIssueId, descendantOf, createdFromIssueId, labelId) on GET /api/companies/:companyId/issues and /issues/count with a 422 instead of letting Postgres fail the uuid cast with a 500. Fixes PAPERCLIP-BACKEND-4M. Co-Authored-By: Devin Foley Co-Authored-By: Claude Opus 5.5 --- .../issue-list-assignee-filter-routes.test.ts | 56 ++++++++++++ server/src/routes/issues.ts | 85 ++++++++++++++----- 2 files changed, 121 insertions(+), 20 deletions(-) diff --git a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts index 269f5d20010..3c685dcfa3d 100644 --- a/server/src/__tests__/issue-list-assignee-filter-routes.test.ts +++ b/server/src/__tests__/issue-list-assignee-filter-routes.test.ts @@ -791,6 +791,62 @@ describeEmbeddedPostgres("issue list routes assigneeAgentId filter", () => { }); }); + it.each([ + "projectId", + "parentId", + "parentIssueId", + "workspaceId", + "executionWorkspaceId", + "descendantOf", + "labelId", + "participantAgentId", + ])("returns 422 instead of a DB error for malformed %s filters", async (param) => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues`) + .query({ status: "todo", [param]: "d", limit: "20" }); + + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body).toMatchObject({ error: `${param} must be a UUID` }); + }); + + it.each([ + "projectId", + "parentId", + "parentIssueId", + "workspaceId", + "executionWorkspaceId", + "descendantOf", + "labelId", + "participantAgentId", + ])("returns 422 from issues/count for malformed %s filters", async (param) => { + const companyId = randomUUID(); + await db.insert(companies).values({ + id: companyId, + name: "Paperclip", + issuePrefix: uniqueIssuePrefix(), + requireBoardApprovalForNewAgents: false, + }); + await seedCloudTenantMember(companyId); + + const app = createApp(companyId); + const res = await request(app) + .get(`/api/companies/${companyId}/issues/count`) + .query({ attention: "blocked", [param]: "d" }); + + expect(res.status, JSON.stringify(res.body)).toBe(422); + expect(res.body).toMatchObject({ error: `${param} must be a UUID` }); + }); + it("returns opt-in live descendant counts for offscreen live descendants only", async () => { const companyId = randomUUID(); const otherCompanyId = randomUUID(); diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 69398e41a4c..9a29f1c9d91 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -3190,6 +3190,42 @@ function decrementIssueListActorClientInflight(actorClientKey: string) { else issueListActorClientInflight.set(actorClientKey, next); } +const ISSUE_LIST_UUID_QUERY_PARAMS = [ + "participantAgentId", + "projectId", + "workspaceId", + "executionWorkspaceId", + "parentId", + "parentIssueId", + "descendantOf", + "createdFromIssueId", + "labelId", +] as const; + +type IssueListUuidQueryParam = (typeof ISSUE_LIST_UUID_QUERY_PARAMS)[number]; + +function parseIssueListUuidQueryParams( + query: Record, +): + | { ok: true; values: Partial> } + | { ok: false; error: string } { + const values: Partial> = {}; + for (const name of ISSUE_LIST_UUID_QUERY_PARAMS) { + const raw = query[name]; + if (raw === undefined) continue; + if (typeof raw !== "string") { + return { ok: false, error: `${name} must be a UUID` }; + } + const trimmed = raw.trim(); + if (trimmed.length === 0) continue; + if (!isUuidLike(trimmed)) { + return { ok: false, error: `${name} must be a UUID` }; + } + values[name] = trimmed; + } + return { ok: true, values }; +} + async function coordinateIssueListGet(input: { req: Request; companyId: string; @@ -8445,6 +8481,12 @@ export function issueRoutes( }); return; } + const uuidQuery = parseIssueListUuidQueryParams(req.query); + if (!uuidQuery.ok) { + res.status(422).json({ error: uuidQuery.error }); + return; + } + const uuidFilters = uuidQuery.values; const offset = parsedOffset ?? 0; const listFilters: IssueFilters = { @@ -8452,20 +8494,18 @@ export function issueRoutes( attention: attention === "blocked" ? "blocked" : undefined, status: req.query.status as string | string[] | undefined, assigneeAgentId, - participantAgentId: req.query.participantAgentId as string | undefined, + participantAgentId: uuidFilters.participantAgentId, assigneeUserId, touchedByUserId, inboxArchivedByUserId, unreadForUserId, - projectId: req.query.projectId as string | undefined, - workspaceId: req.query.workspaceId as string | undefined, - executionWorkspaceId: req.query.executionWorkspaceId as - string | undefined, - parentId: (req.query.parentId ?? req.query.parentIssueId) as - string | undefined, - descendantOf: req.query.descendantOf as string | undefined, - createdFromIssueId: req.query.createdFromIssueId as string | undefined, - labelId: req.query.labelId as string | undefined, + projectId: uuidFilters.projectId, + workspaceId: uuidFilters.workspaceId, + executionWorkspaceId: uuidFilters.executionWorkspaceId, + parentId: uuidFilters.parentId ?? uuidFilters.parentIssueId, + descendantOf: uuidFilters.descendantOf, + createdFromIssueId: uuidFilters.createdFromIssueId, + labelId: uuidFilters.labelId, originKind: req.query.originKind as string | undefined, originKindPrefix: req.query.originKindPrefix as string | undefined, originId: req.query.originId as string | undefined, @@ -8686,22 +8726,27 @@ export function issueRoutes( return; } + const countUuidQuery = parseIssueListUuidQueryParams(req.query); + if (!countUuidQuery.ok) { + res.status(422).json({ error: countUuidQuery.error }); + return; + } + const countUuidFilters = countUuidQuery.values; + const blockedCountFilters = { readCondition: await issueReadSqlCondition(db, req.actor), attention: "blocked", status: req.query.status as string | string[] | undefined, assigneeAgentId: req.query.assigneeAgentId as string | undefined, - participantAgentId: req.query.participantAgentId as string | undefined, + participantAgentId: countUuidFilters.participantAgentId, assigneeUserId: req.query.assigneeUserId as string | undefined, - projectId: req.query.projectId as string | undefined, - workspaceId: req.query.workspaceId as string | undefined, - executionWorkspaceId: req.query.executionWorkspaceId as - string | undefined, - parentId: (req.query.parentId ?? req.query.parentIssueId) as - string | undefined, - descendantOf: req.query.descendantOf as string | undefined, - createdFromIssueId: req.query.createdFromIssueId as string | undefined, - labelId: req.query.labelId as string | undefined, + projectId: countUuidFilters.projectId, + workspaceId: countUuidFilters.workspaceId, + executionWorkspaceId: countUuidFilters.executionWorkspaceId, + parentId: countUuidFilters.parentId ?? countUuidFilters.parentIssueId, + descendantOf: countUuidFilters.descendantOf, + createdFromIssueId: countUuidFilters.createdFromIssueId, + labelId: countUuidFilters.labelId, originKind: req.query.originKind as string | undefined, originKindPrefix: req.query.originKindPrefix as string | undefined, originId: req.query.originId as string | undefined,