Merge pull request #102 from copenhagentruckwash/fix-unsandboxed-blob-attachment-previews

Sanitize and sandbox order attachment previews to prevent XSS
This commit is contained in:
Jeppe B
2026-06-02 00:38:15 +02:00
committed by GitHub
2 changed files with 165 additions and 3 deletions
@@ -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
></iframe>
<a
v-else-if="activePreviewKind === 'link'"
@@ -0,0 +1,132 @@
// @vitest-environment jsdom
import { mount } from "@vue/test-utils";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
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:
'<button type="button" class="action-settings-wheel-item-stub" :disabled="disabled" @click="clickAction">{{ label }}</button>',
};
const ActionSettingsWheelItemLabelStub = {
props: ["label"],
template: '<div class="action-settings-wheel-item-label-stub">{{ label }}</div>',
};
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(["<script>localStorage.token</script>"], { 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");
});
});