fix(server): accept Office issue attachments (#8562)

## 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 <sr@samirusani>
This commit is contained in:
Sam 2026-07-21 19:47:33 +02:00 committed by GitHub
parent 3e1dc90bf2
commit b565603a86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 214 additions and 7 deletions

View File

@ -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"]) {

View File

@ -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({

View File

@ -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]);
}

View File

@ -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;