From b565603a862cc9f8b6cee2eee7b179bc6c1687ea Mon Sep 17 00:00:00 2001 From: Sam Date: Tue, 21 Jul 2026 19:47:33 +0200 Subject: [PATCH] fix(server): accept Office issue attachments (#8562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents and board users can attach files to issues so context and deliverables stay with the task > - Some clients upload Microsoft Office files with generic binary MIME types such as `application/octet-stream` > - Current `master` now accepts arbitrary issue attachment MIME types, so the upload should keep working for unknown binary files > - Office files still benefit from being stored with a specific Office MIME type when the filename makes that inference safe > - Shared attachment allow-list defaults should also include common Office MIME types for routes that still use that allow-list > - This pull request keeps the current arbitrary-MIME issue upload behavior and only narrows generic binary uploads to Office MIME types for known Office filename extensions ## Linked Issues or Issue Description Fixes #8243 Duplicate search performed before implementation: - No matching open or closed PR found for `8243`, `Office document`, `attachment MIME`, or `openxmlformats`. ## What Changed - Added common Office MIME types to the default shared attachment allow-list. - Added upload content-type normalization that maps generic binary uploads to a specific Office MIME type only for known Office filename extensions. - Added an optional helper-level allow-list gate so callers that still validate against an effective allow-list can keep generic binary uploads generic when the inferred Office MIME type is not allowed. - Reused the shared generic attachment content-type list for response handling. - Preserved current `master` behavior for issue uploads that use unknown or arbitrary MIME types. - Added regression coverage for default Office allow-list matching, filename inference, optional allow-list fallback, official Office MIME uploads, inferred generic Office uploads, and preservation of unknown generic binary uploads. ## Verification - `env CI=true corepack pnpm install --frozen-lockfile --force` - `env CI=true corepack pnpm --filter @paperclipai/server exec vitest run src/__tests__/attachment-types.test.ts src/__tests__/issue-attachment-routes.test.ts` - `env CI=true corepack pnpm --filter @paperclipai/plugin-sdk ensure-build-deps` - `env CI=true corepack pnpm --filter @paperclipai/server exec tsc --noEmit` - `git diff --check origin/master...HEAD` GitHub CI, security checks, and Greptile pass on rebased head `acc364cfbe3440a59db6570bb907818046649eb4`. ## Risks Low risk. The issue attachment route continues to accept arbitrary MIME types as current `master` does; this change only stores a more specific Office MIME type for generic binary uploads when the filename has a known Office extension. Unknown generic binary uploads remain generic. For callers that use an allow-list before storing uploads, `normalizeUploadAttachmentContentType` supports an optional gate so inference can be limited to MIME types that are already allowed. No docs change included because this is a default upload compatibility fix covered by server tests. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. This is a narrow bug fix, not roadmap-level core feature work. `ROADMAP.md` was checked. ## Model Used OpenAI Codex using GPT-5, tool-enabled coding agent. Context window details are not exposed in this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Sami Rusani --- server/src/__tests__/attachment-types.test.ts | 76 ++++++++++++++++ .../__tests__/issue-attachment-routes.test.ts | 87 +++++++++++++++++++ server/src/attachment-types.ts | 43 +++++++++ server/src/routes/issues.ts | 15 ++-- 4 files changed, 214 insertions(+), 7 deletions(-) diff --git a/server/src/__tests__/attachment-types.test.ts b/server/src/__tests__/attachment-types.test.ts index a1508cf017..dcf202a539 100644 --- a/server/src/__tests__/attachment-types.test.ts +++ b/server/src/__tests__/attachment-types.test.ts @@ -2,9 +2,11 @@ import { describe, it, expect } from "vitest"; import { DEFAULT_ALLOWED_TYPES, INLINE_ATTACHMENT_TYPES, + inferOfficeAttachmentContentTypeFromFilename, isInlineAttachmentContentType, matchesContentType, normalizeContentType, + normalizeUploadAttachmentContentType, parseAllowedTypes, } from "../attachment-types.js"; @@ -97,6 +99,19 @@ describe("matchesContentType", () => { expect(matchesContentType("text/plain", patterns)).toBe(true); expect(matchesContentType("application/zip", patterns)).toBe(true); }); + + it("allows common Office document types by default", () => { + for (const contentType of [ + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ]) { + expect(matchesContentType(contentType, [...DEFAULT_ALLOWED_TYPES])).toBe(true); + } + }); }); describe("normalizeContentType", () => { @@ -110,6 +125,67 @@ describe("normalizeContentType", () => { }); }); +describe("inferOfficeAttachmentContentTypeFromFilename", () => { + it("infers common Office content types from filenames", () => { + expect(inferOfficeAttachmentContentTypeFromFilename("notes.docx")).toBe( + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ); + expect(inferOfficeAttachmentContentTypeFromFilename("raw-data.xlsx")).toBe( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ); + expect(inferOfficeAttachmentContentTypeFromFilename("deck.pptx")).toBe( + "application/vnd.openxmlformats-officedocument.presentationml.presentation", + ); + expect(inferOfficeAttachmentContentTypeFromFilename("legacy.doc")).toBe("application/msword"); + expect(inferOfficeAttachmentContentTypeFromFilename("legacy.xls")).toBe("application/vnd.ms-excel"); + expect(inferOfficeAttachmentContentTypeFromFilename("legacy.ppt")).toBe("application/vnd.ms-powerpoint"); + }); + + it("does not infer unknown extensions", () => { + expect(inferOfficeAttachmentContentTypeFromFilename("payload.bin")).toBeNull(); + expect(inferOfficeAttachmentContentTypeFromFilename(undefined)).toBeNull(); + }); +}); + +describe("normalizeUploadAttachmentContentType", () => { + it("keeps explicit content types unchanged", () => { + expect( + normalizeUploadAttachmentContentType({ + contentType: "application/pdf", + originalFilename: "raw-data.xlsx", + }), + ).toBe("application/pdf"); + }); + + it("infers Office content type for generic binary uploads", () => { + expect( + normalizeUploadAttachmentContentType({ + contentType: "application/octet-stream", + originalFilename: "raw-data.xlsx", + }), + ).toBe("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); + }); + + it("keeps generic binary uploads generic when the inferred Office type is not allowed", () => { + expect( + normalizeUploadAttachmentContentType({ + contentType: "application/octet-stream", + originalFilename: "raw-data.xlsx", + isAllowedContentType: (contentType) => contentType === "application/octet-stream", + }), + ).toBe("application/octet-stream"); + }); + + it("keeps generic binary uploads generic for unknown filenames", () => { + expect( + normalizeUploadAttachmentContentType({ + contentType: "application/octet-stream", + originalFilename: "payload.bin", + }), + ).toBe("application/octet-stream"); + }); +}); + describe("isInlineAttachmentContentType", () => { it("allows the configured inline-safe types", () => { for (const contentType of ["image/png", "image/svg+xml", "application/pdf", "text/plain", "video/mp4"]) { diff --git a/server/src/__tests__/issue-attachment-routes.test.ts b/server/src/__tests__/issue-attachment-routes.test.ts index 648f2b4fc9..2103aa5880 100644 --- a/server/src/__tests__/issue-attachment-routes.test.ts +++ b/server/src/__tests__/issue-attachment-routes.test.ts @@ -350,6 +350,93 @@ describe("issue attachment routes", () => { expect(res.body.contentType).toBe("application/x-msdownload"); }); + it("accepts Office uploads with official MIME types for issue attachments", async () => { + const contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + const storage = createStorageService(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.createAttachment.mockResolvedValue(makeAttachment(contentType, "raw-data.xlsx")); + + const app = await createApp(storage); + const res = await request(app) + .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") + .attach("file", Buffer.from("xlsx"), { filename: "raw-data.xlsx", contentType }); + + expect(res.status).toBe(201); + expect(storage.__calls.putFile).toMatchObject({ + contentType, + originalFilename: "raw-data.xlsx", + }); + expect(mockIssueService.createAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + contentType, + originalFilename: "raw-data.xlsx", + }), + ); + }); + + it("infers Office MIME types for generic binary issue attachment uploads", async () => { + const contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + const storage = createStorageService(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.createAttachment.mockResolvedValue(makeAttachment(contentType, "raw-data.xlsx")); + + const app = await createApp(storage); + const res = await request(app) + .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") + .attach("file", Buffer.from("xlsx"), { + filename: "raw-data.xlsx", + contentType: "application/octet-stream", + }); + + expect(res.status).toBe(201); + expect(storage.__calls.putFile).toMatchObject({ + contentType, + originalFilename: "raw-data.xlsx", + }); + expect(mockIssueService.createAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + contentType, + originalFilename: "raw-data.xlsx", + }), + ); + }); + + it("preserves generic binary uploads when the filename is not a known Office document", async () => { + const storage = createStorageService(); + mockIssueService.getById.mockResolvedValue({ + id: "11111111-1111-4111-8111-111111111111", + companyId: "company-1", + identifier: "PAP-1", + }); + mockIssueService.createAttachment.mockResolvedValue(makeAttachment("application/octet-stream", "payload.bin")); + + const app = await createApp(storage); + const res = await request(app) + .post("/api/companies/company-1/issues/11111111-1111-4111-8111-111111111111/attachments") + .attach("file", Buffer.from("bin"), { filename: "payload.bin", contentType: "application/octet-stream" }); + + expect(res.status).toBe(201); + expect(storage.__calls.putFile).toMatchObject({ + contentType: "application/octet-stream", + originalFilename: "payload.bin", + }); + expect(mockIssueService.createAttachment).toHaveBeenCalledWith( + expect.objectContaining({ + contentType: "application/octet-stream", + originalFilename: "payload.bin", + }), + ); + expect(res.body.contentType).toBe("application/octet-stream"); + }); + it("enforces the process-level issue attachment limit even when the company limit allows more", async () => { const storage = createStorageService(); mockIssueService.getById.mockResolvedValue({ diff --git a/server/src/attachment-types.ts b/server/src/attachment-types.ts index 304caf45a3..b5b949b492 100644 --- a/server/src/attachment-types.ts +++ b/server/src/attachment-types.ts @@ -32,6 +32,12 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ "application/json", "text/csv", "text/html", + "application/msword", + "application/vnd.ms-excel", + "application/vnd.ms-powerpoint", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + "application/vnd.openxmlformats-officedocument.presentationml.presentation", "video/mp4", "video/webm", "video/quicktime", @@ -40,6 +46,11 @@ export const DEFAULT_ALLOWED_TYPES: readonly string[] = [ export const DEFAULT_ATTACHMENT_CONTENT_TYPE = "application/octet-stream"; export const SVG_CONTENT_TYPE = "image/svg+xml"; +export const GENERIC_ATTACHMENT_CONTENT_TYPES: readonly string[] = [ + "application/octet-stream", + "binary/octet-stream", + "application/x-binary", +]; export const INLINE_ATTACHMENT_TYPES: readonly string[] = [ "image/*", "application/pdf", @@ -88,6 +99,38 @@ export function normalizeContentType(contentType: string | null | undefined): st return normalized || DEFAULT_ATTACHMENT_CONTENT_TYPE; } +export function inferOfficeAttachmentContentTypeFromFilename( + filename: string | null | undefined, +): string | null { + const lower = (filename ?? "").trim().toLowerCase(); + if (lower.endsWith(".docx")) { + return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + } + if (lower.endsWith(".xlsx")) { + return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; + } + if (lower.endsWith(".pptx")) { + return "application/vnd.openxmlformats-officedocument.presentationml.presentation"; + } + if (lower.endsWith(".doc")) return "application/msword"; + if (lower.endsWith(".xls")) return "application/vnd.ms-excel"; + if (lower.endsWith(".ppt")) return "application/vnd.ms-powerpoint"; + return null; +} + +export function normalizeUploadAttachmentContentType(input: { + contentType: string | null | undefined; + originalFilename?: string | null; + isAllowedContentType?: (contentType: string) => boolean; +}): string { + const normalized = normalizeContentType(input.contentType); + if (!GENERIC_ATTACHMENT_CONTENT_TYPES.includes(normalized)) return normalized; + const inferred = inferOfficeAttachmentContentTypeFromFilename(input.originalFilename); + if (!inferred) return normalized; + if (input.isAllowedContentType && !input.isAllowedContentType(inferred)) return normalized; + return inferred; +} + export function isInlineAttachmentContentType(contentType: string): boolean { return matchesContentType(contentType, [...INLINE_ATTACHMENT_TYPES]); } diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index a426207567..3138619e87 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -137,9 +137,11 @@ import { } from "./workspace-command-authz.js"; import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js"; import { + GENERIC_ATTACHMENT_CONTENT_TYPES, isInlineAttachmentContentType, normalizeIssueAttachmentMaxBytes, normalizeContentType, + normalizeUploadAttachmentContentType, SVG_CONTENT_TYPE, } from "../attachment-types.js"; import { queueIssueAssignmentWakeup } from "../services/issue-assignment-wakeup.js"; @@ -351,11 +353,7 @@ function buildAttachmentContentPath(attachmentId: string): string { return `/api/attachments/${attachmentId}/content`; } -const GENERIC_ATTACHMENT_CONTENT_TYPES = new Set([ - "application/octet-stream", - "binary/octet-stream", - "application/x-binary", -]); +const GENERIC_RESPONSE_ATTACHMENT_CONTENT_TYPES = new Set(GENERIC_ATTACHMENT_CONTENT_TYPES); function inferVideoContentTypeFromFilename(filename: string | null | undefined): string | null { const lower = (filename ?? "").toLowerCase(); @@ -371,7 +369,7 @@ function resolveAttachmentResponseContentType(input: { originalFilename?: string | null; }) { const storedContentType = normalizeContentType(input.storedContentType || input.objectContentType); - if (!GENERIC_ATTACHMENT_CONTENT_TYPES.has(storedContentType)) return storedContentType; + if (!GENERIC_RESPONSE_ATTACHMENT_CONTENT_TYPES.has(storedContentType)) return storedContentType; return inferVideoContentTypeFromFilename(input.originalFilename) ?? storedContentType; } @@ -10443,7 +10441,10 @@ export function issueRoutes( res.status(400).json({ error: "Missing file field 'file'" }); return; } - const contentType = normalizeContentType(file.mimetype); + const contentType = normalizeUploadAttachmentContentType({ + contentType: file.mimetype, + originalFilename: file.originalname, + }); if (file.buffer.length <= 0) { res.status(422).json({ error: "Attachment is empty" }); return;