Harden issue artifact metadata

This commit is contained in:
Dotta 2026-05-30 19:34:44 +00:00
parent 96d266109b
commit bbf77fcb69
6 changed files with 292 additions and 5 deletions

View file

@ -16,4 +16,26 @@ describe("attachmentArtifactWorkProductMetadataSchema", () => {
expect(parsed.contentType).toBe("video/mp4"); expect(parsed.contentType).toBe("video/mp4");
expect(parsed.downloadPath).toContain("download=1"); expect(parsed.downloadPath).toContain("download=1");
}); });
it("rejects off-route or scriptable paths", () => {
const parsed = attachmentArtifactWorkProductMetadataSchema.safeParse({
attachmentId: "11111111-1111-4111-8111-111111111111",
contentType: "video/mp4",
byteSize: 1234,
contentPath: "https://evil.example/video.mp4",
openPath: "javascript:alert(1)",
downloadPath: "/api/attachments/11111111-1111-4111-8111-111111111111/content",
originalFilename: "demo.mp4",
});
expect(parsed.success).toBe(false);
if (parsed.success) {
throw new Error("Expected invalid attachment artifact metadata");
}
expect(parsed.error.issues.map((issue) => issue.path.join("."))).toEqual([
"contentPath",
"openPath",
"downloadPath",
]);
});
}); });

View file

@ -1,5 +1,9 @@
import { z } from "zod"; import { z } from "zod";
function attachmentContentPath(attachmentId: string): string {
return `/api/attachments/${attachmentId}/content`;
}
export const issueWorkProductTypeSchema = z.enum([ export const issueWorkProductTypeSchema = z.enum([
"preview_url", "preview_url",
"runtime_service", "runtime_service",
@ -37,6 +41,29 @@ export const attachmentArtifactWorkProductMetadataSchema = z.object({
openPath: z.string().min(1), openPath: z.string().min(1),
downloadPath: z.string().min(1), downloadPath: z.string().min(1),
originalFilename: z.string().optional().nullable(), originalFilename: z.string().optional().nullable(),
}).superRefine((value, ctx) => {
const contentPath = attachmentContentPath(value.attachmentId);
if (value.contentPath !== contentPath) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["contentPath"],
message: "contentPath must point to the same-origin attachment content route",
});
}
if (value.openPath !== contentPath) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["openPath"],
message: "openPath must point to the same-origin attachment content route",
});
}
if (value.downloadPath !== `${contentPath}?download=1`) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ["downloadPath"],
message: "downloadPath must point to the same-origin attachment download route",
});
}
}); });
export type AttachmentArtifactWorkProductMetadata = z.infer<typeof attachmentArtifactWorkProductMetadataSchema>; export type AttachmentArtifactWorkProductMetadata = z.infer<typeof attachmentArtifactWorkProductMetadataSchema>;

View file

@ -13,6 +13,11 @@ const mockIssueService = vi.hoisted(() => ({
const mockCompanyService = vi.hoisted(() => ({ const mockCompanyService = vi.hoisted(() => ({
getById: vi.fn(), getById: vi.fn(),
})); }));
const mockWorkProductService = vi.hoisted(() => ({
createForIssue: vi.fn(),
getById: vi.fn(),
update: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined)); const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
@ -97,7 +102,7 @@ function registerRouteMocks() {
routineService: () => ({ routineService: () => ({
syncRunStatusForIssue: vi.fn(async () => undefined), syncRunStatusForIssue: vi.fn(async () => undefined),
}), }),
workProductService: () => ({}), workProductService: () => mockWorkProductService,
})); }));
} }
@ -144,6 +149,7 @@ async function createApp(storage: StorageService, options?: { companyIds?: strin
vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"), vi.importActual<typeof import("../routes/issues.js")>("../routes/issues.js"),
]); ]);
const app = express(); const app = express();
app.use(express.json());
app.use((req, _res, next) => { app.use((req, _res, next) => {
(req as any).actor = { (req as any).actor = {
type: "board", type: "board",
@ -219,6 +225,9 @@ describe("issue attachment routes", () => {
id: "company-1", id: "company-1",
attachmentMaxBytes: 1024 * 1024 * 1024, attachmentMaxBytes: 1024 * 1024 * 1024,
}); });
mockWorkProductService.createForIssue.mockReset();
mockWorkProductService.getById.mockReset();
mockWorkProductService.update.mockReset();
}); });
it("accepts zip uploads for issue attachments", async () => { it("accepts zip uploads for issue attachments", async () => {
@ -429,4 +438,160 @@ describe("issue attachment routes", () => {
expect(res.status).toBe(403); expect(res.status).toBe(403);
expect(storage.getObject).not.toHaveBeenCalled(); expect(storage.getObject).not.toHaveBeenCalled();
}); });
it("canonicalizes paperclip artifact metadata before creating a work product", async () => {
const storage = createStorageService();
const issue = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
projectId: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("video/mp4", "clip.mp4"),
id: "22222222-2222-4222-8222-222222222222",
byteSize: 6,
issueId: issue.id,
});
mockWorkProductService.createForIssue.mockResolvedValue({
id: "work-product-1",
issueId: issue.id,
companyId: issue.companyId,
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: null,
});
const app = await createApp(storage);
const res = await request(app)
.post(`/api/issues/${issue.id}/work-products`)
.send({
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
contentType: "video/mp4",
byteSize: 6,
contentPath: "https://evil.example/clip.mp4",
openPath: "javascript:alert(1)",
downloadPath: "javascript:alert(2)",
originalFilename: "clip.mp4",
},
});
expect(res.status).toBe(201);
expect(mockWorkProductService.createForIssue).toHaveBeenCalledWith(
issue.id,
issue.companyId,
expect.objectContaining({
type: "artifact",
provider: "paperclip",
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
contentType: "video/mp4",
byteSize: 6,
contentPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content",
openPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content",
downloadPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content?download=1",
originalFilename: "clip.mp4",
},
}),
);
});
it("rejects paperclip artifact metadata that references another issue's attachment", async () => {
const storage = createStorageService();
const issue = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
projectId: null,
};
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("video/mp4", "clip.mp4"),
id: "22222222-2222-4222-8222-222222222222",
issueId: "different-issue",
});
const app = await createApp(storage);
const res = await request(app)
.post(`/api/issues/${issue.id}/work-products`)
.send({
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
},
});
expect(res.status).toBe(422);
expect(res.body.error).toBe("Attachment artifact must reference an attachment on the same issue");
expect(mockWorkProductService.createForIssue).not.toHaveBeenCalled();
});
it("canonicalizes paperclip artifact metadata on work product updates", async () => {
const storage = createStorageService();
const issue = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
identifier: "PAP-1",
projectId: null,
};
mockWorkProductService.getById.mockResolvedValue({
id: "work-product-1",
issueId: issue.id,
companyId: issue.companyId,
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: null,
});
mockIssueService.getById.mockResolvedValue(issue);
mockIssueService.getAttachmentById.mockResolvedValue({
...makeAttachment("video/webm", "clip.webm"),
id: "22222222-2222-4222-8222-222222222222",
issueId: issue.id,
byteSize: 8,
});
mockWorkProductService.update.mockResolvedValue({
id: "work-product-1",
issueId: issue.id,
companyId: issue.companyId,
type: "artifact",
provider: "paperclip",
title: "Clip",
metadata: null,
});
const app = await createApp(storage);
const res = await request(app)
.patch("/api/work-products/work-product-1")
.send({
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
openPath: "javascript:alert(1)",
},
});
expect(res.status).toBe(200);
expect(mockWorkProductService.update).toHaveBeenCalledWith(
"work-product-1",
expect.objectContaining({
metadata: {
attachmentId: "22222222-2222-4222-8222-222222222222",
contentType: "video/webm",
byteSize: 8,
contentPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content",
openPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content",
downloadPath: "/api/attachments/22222222-2222-4222-8222-222222222222/content?download=1",
originalFilename: "clip.webm",
},
}),
);
});
}); });

View file

@ -17,6 +17,7 @@ import {
import { import {
addIssueCommentSchema, addIssueCommentSchema,
acceptIssueThreadInteractionSchema, acceptIssueThreadInteractionSchema,
attachmentArtifactWorkProductMetadataSchema,
cancelIssueThreadInteractionSchema, cancelIssueThreadInteractionSchema,
companySearchQuerySchema, companySearchQuerySchema,
createIssueAttachmentMetadataSchema, createIssueAttachmentMetadataSchema,
@ -181,6 +182,26 @@ function applyCreateIssueStatusDefault(req: Request, res: Response, next: () =>
next(); next();
} }
function buildAttachmentContentPath(attachmentId: string): string {
return `/api/attachments/${attachmentId}/content`;
}
function requiresPaperclipAttachmentMetadata(input: {
type?: unknown;
provider?: unknown;
}, fallback?: {
type?: string | null;
provider?: string | null;
}) {
const type = typeof input.type === "string" ? input.type : fallback?.type ?? null;
const provider = typeof input.provider === "string" ? input.provider : fallback?.provider ?? null;
return type === "artifact" && provider === "paperclip";
}
const attachmentArtifactMetadataInputSchema = z.object({
attachmentId: z.string().uuid(),
}).passthrough();
function buildCreateIssueActivityStatusDetails( function buildCreateIssueActivityStatusDetails(
issue: { assigneeAgentId: string | null; status: string }, issue: { assigneeAgentId: string | null; status: string },
res: Response, res: Response,
@ -1233,6 +1254,38 @@ export function issueRoutes(
}, "failed to wake assignee on document annotation comment")); }, "failed to wake assignee on document annotation comment"));
} }
async function canonicalizePaperclipArtifactMetadata(input: {
issue: { id: string; companyId: string };
metadata: Record<string, unknown> | null | undefined;
}) {
const parsed = attachmentArtifactMetadataInputSchema.safeParse(input.metadata);
if (!parsed.success) {
throw unprocessable("Invalid attachment artifact metadata", {
code: "invalid_attachment_artifact_metadata",
details: parsed.error.issues,
});
}
const attachment = await svc.getAttachmentById(parsed.data.attachmentId);
if (!attachment || attachment.companyId !== input.issue.companyId || attachment.issueId !== input.issue.id) {
throw unprocessable("Attachment artifact must reference an attachment on the same issue", {
code: "invalid_attachment_artifact_metadata",
attachmentId: parsed.data.attachmentId,
});
}
const contentPath = buildAttachmentContentPath(attachment.id);
return attachmentArtifactWorkProductMetadataSchema.parse({
attachmentId: attachment.id,
contentType: normalizeContentType(attachment.contentType),
byteSize: attachment.byteSize,
contentPath,
openPath: contentPath,
downloadPath: `${contentPath}?download=1`,
originalFilename: attachment.originalFilename ?? null,
});
}
async function assertIssueEnvironmentSelection( async function assertIssueEnvironmentSelection(
companyId: string, companyId: string,
environmentId: string | null | undefined, environmentId: string | null | undefined,
@ -3245,10 +3298,17 @@ export function issueRoutes(
assertCompanyAccess(req, issue.companyId); assertCompanyAccess(req, issue.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, { const createInput = {
...req.body, ...req.body,
projectId: req.body.projectId ?? issue.projectId ?? null, projectId: req.body.projectId ?? issue.projectId ?? null,
}); };
if (requiresPaperclipAttachmentMetadata(createInput)) {
createInput.metadata = await canonicalizePaperclipArtifactMetadata({
issue,
metadata: req.body.metadata ?? null,
});
}
const product = await workProductsSvc.createForIssue(issue.id, issue.companyId, createInput);
if (!product) { if (!product) {
res.status(422).json({ error: "Invalid work product payload" }); res.status(422).json({ error: "Invalid work product payload" });
return; return;
@ -3289,7 +3349,19 @@ export function issueRoutes(
} }
if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return; if (!(await assertAgentIssueMutationAllowed(req, res, issue))) return;
if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return; if (!(await assertDeliverableMutationAllowedByRunContext(req, res, issue))) return;
const product = await workProductsSvc.update(id, req.body); const patch = { ...req.body };
if (requiresPaperclipAttachmentMetadata(patch, existing)) {
if (patch.metadata !== undefined) {
patch.metadata = await canonicalizePaperclipArtifactMetadata({
issue,
metadata: patch.metadata ?? null,
});
} else if (!requiresPaperclipAttachmentMetadata(existing)) {
res.status(422).json({ error: "Attachment-backed artifact metadata is required" });
return;
}
}
const product = await workProductsSvc.update(id, patch);
if (!product) { if (!product) {
res.status(404).json({ error: "Work product not found" }); res.status(404).json({ error: "Work product not found" });
return; return;

View file

@ -100,6 +100,7 @@ describe("getIssueOutputs", () => {
const result = getIssueOutputs([ const result = getIssueOutputs([
makeWorkProduct({ id: "pr-1", type: "pull_request" }), makeWorkProduct({ id: "pr-1", type: "pull_request" }),
makeWorkProduct({ id: "doc-1", type: "document" }), makeWorkProduct({ id: "doc-1", type: "document" }),
makeWorkProduct({ id: "artifact-1", type: "artifact", provider: "custom", metadata: videoMetadata() }),
]); ]);
expect(result.count).toBe(0); expect(result.count).toBe(0);
expect(result.primary).toBeNull(); expect(result.primary).toBeNull();

View file

@ -123,7 +123,7 @@ function toTime(value: string | Date): number {
* marked primary) comes first, then remaining artifacts by most-recent. * marked primary) comes first, then remaining artifacts by most-recent.
*/ */
export function getIssueOutputs(workProducts: IssueWorkProduct[] | null | undefined): IssueOutputs { export function getIssueOutputs(workProducts: IssueWorkProduct[] | null | undefined): IssueOutputs {
const artifacts = (workProducts ?? []).filter((wp) => wp.type === "artifact"); const artifacts = (workProducts ?? []).filter((wp) => wp.type === "artifact" && wp.provider === "paperclip");
const items: IssueOutputItem[] = artifacts.map((wp) => { const items: IssueOutputItem[] = artifacts.map((wp) => {
const parsed = attachmentArtifactWorkProductMetadataSchema.safeParse(wp.metadata); const parsed = attachmentArtifactWorkProductMetadataSchema.safeParse(wp.metadata);