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:
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user