mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-25 02:26:12 +02:00
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 <devinfoley@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2 files changed
+121
-20
No files matched your search
@@ -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();
|
||||
|
||||
+65
-20
@@ -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<string, unknown>,
|
||||
):
|
||||
| { ok: true; values: Partial<Record<IssueListUuidQueryParam, string>> }
|
||||
| { ok: false; error: string } {
|
||||
const values: Partial<Record<IssueListUuidQueryParam, string>> = {};
|
||||
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,
|
||||
|
||||
Reference in new issue
Block a user