From 81016fd89801ca0cea405207037b6e292fb91c0a Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Tue, 2 Jun 2026 00:38:03 +0200 Subject: [PATCH] Fix unsafe order attachment previews --- .../orders/OrderAttachmentsActionButton.vue | 36 ++++- .../order-attachments-action-button.spec.js | 132 ++++++++++++++++++ 2 files changed, 165 insertions(+), 3 deletions(-) create mode 100644 tests/unit/order-attachments-action-button.spec.js diff --git a/src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue b/src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue index 2d42276a..8fddd8b2 100644 --- a/src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue +++ b/src/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue @@ -10,6 +10,16 @@ import { SessionUser } from "@/components/session/token/SessionUser.vue"; const acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx"; const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"]; const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"]; +const safePreviewBlobTypesByKind = { + image: { + fallback: "image/png", + allowed: new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp", "image/svg+xml"]), + }, + document: { + fallback: "application/pdf", + allowed: new Set(["application/pdf"]), + }, +}; const props = defineProps({ order: { @@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => { return Object.prototype.hasOwnProperty.call(previewSourcesById.value, attachmentId); }; -const createEmbeddablePreviewUrl = async (downloadLink) => { +const createSafePreviewBlob = (fileBlob, previewKind) => { + const previewBlobTypes = safePreviewBlobTypesByKind[previewKind]; + if (!previewBlobTypes) { + return null; + } + + const normalizedBlobType = String(fileBlob.type || "").toLowerCase(); + const safeBlobType = previewBlobTypes.allowed.has(normalizedBlobType) + ? normalizedBlobType + : previewBlobTypes.fallback; + + return new Blob([fileBlob], { type: safeBlobType }); +}; + +const createEmbeddablePreviewUrl = async (downloadLink, previewKind) => { if (!downloadLink) { return null; } @@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => { return null; } - const objectUrl = URL.createObjectURL(fileBlob); + const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind); + if (!safePreviewBlob) { + return null; + } + + const objectUrl = URL.createObjectURL(safePreviewBlob); generatedObjectUrls.add(objectUrl); return objectUrl; } catch (error) { @@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => { attachment.id, false ); - const previewSource = await createEmbeddablePreviewUrl(downloadLink); + const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind); previewSourcesById.value = { ...previewSourcesById.value, [attachment.id]: previewSource, @@ -517,6 +546,7 @@ const toggleDropdown = async () => { :src="activePreviewSource" class="order-attachments-preview-panel__document" title="Attachment preview" + sandbox > ({ + downloadAttachment: vi.fn(), + fetchAttachments: vi.fn(), + uploadAttachment: vi.fn(), + showAttachWashCertificateForm: vi.fn(), + canAccessAdmin: vi.fn(() => true), + canAccessSuperUser: vi.fn(() => false), + hasPermission: vi.fn(() => false), +})); + +vi.mock("vue-i18n", () => ({ + useI18n: () => ({ + t: (key) => key, + }), +})); + +vi.mock("sweetalert2", () => ({ + default: { + fire: vi.fn(), + }, +})); + +vi.mock("@/components/session/token/SessionUser.vue", () => ({ + SessionUser: { + canAccessAdmin: mocks.canAccessAdmin, + canAccessSuperUser: mocks.canAccessSuperUser, + hasPermission: mocks.hasPermission, + objects: { + orders: { + functions: { + downloadAttachment: mocks.downloadAttachment, + fetchAttachments: mocks.fetchAttachments, + uploadAttachment: mocks.uploadAttachment, + showAttachWashCertificateForm: mocks.showAttachWashCertificateForm, + }, + }, + }, + }, +})); + +import OrderAttachmentsActionButton from "@/components/displays/department/pos/orders/OrderAttachmentsActionButton.vue"; + +const flushPromises = async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +}; + +const ActionSettingsWheelItemStub = { + props: ["label", "clickAction", "disabled"], + template: + '', +}; + +const ActionSettingsWheelItemLabelStub = { + props: ["label"], + template: '
{{ label }}
', +}; + +const mountButton = () => + mount(OrderAttachmentsActionButton, { + props: { + order: { + id: 123, + attachments: [ + { + id: 456, + content: { + document: "invoice.pdf", + }, + }, + ], + }, + refreshFunction: vi.fn(), + }, + global: { + stubs: { + ActionSettingsWheelItem: ActionSettingsWheelItemStub, + ActionSettingsWheelItemLabel: ActionSettingsWheelItemLabelStub, + }, + }, + }); + +describe("OrderAttachmentsActionButton", () => { + beforeEach(() => { + vi.restoreAllMocks(); + mocks.downloadAttachment.mockResolvedValue("https://attachments.example/download/456"); + mocks.fetchAttachments.mockResolvedValue([]); + mocks.canAccessAdmin.mockReturnValue(true); + mocks.canAccessSuperUser.mockReturnValue(false); + mocks.hasPermission.mockReturnValue(false); + }); + + it("sandboxes document previews and coerces downloaded content to a safe PDF blob type", async () => { + const createObjectUrl = vi.fn(() => "blob:safe-preview"); + Object.defineProperty(URL, "createObjectURL", { + configurable: true, + writable: true, + value: createObjectUrl, + }); + + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + blob: async () => new Blob([""], { type: "text/html" }), + })) + ); + + const wrapper = mountButton(); + + await wrapper.find(".dropdown-trigger button").trigger("click"); + await wrapper.find(".order-attachments-previewable-item").trigger("mouseenter"); + await flushPromises(); + + expect(mocks.downloadAttachment).toHaveBeenCalledWith(123, 456, false); + expect(fetch).toHaveBeenCalledWith("https://attachments.example/download/456", { method: "GET" }); + expect(createObjectUrl).toHaveBeenCalledTimes(1); + expect(createObjectUrl.mock.calls[0][0]).toBeInstanceOf(Blob); + expect(createObjectUrl.mock.calls[0][0].type).toBe("application/pdf"); + + const iframe = wrapper.find("iframe.order-attachments-preview-panel__document"); + expect(iframe.exists()).toBe(true); + expect(iframe.attributes("src")).toBe("blob:safe-preview"); + expect(iframe.attributes()).toHaveProperty("sandbox"); + }); +});