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 acceptedOrderAttachmentFileTypes = "image/*,application/pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx";
|
||||||
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
const imageExtensions = [".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp", ".svg"];
|
||||||
const officeExtensions = [".doc", ".docx", ".xls", ".xlsx", ".ppt", ".pptx"];
|
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({
|
const props = defineProps({
|
||||||
order: {
|
order: {
|
||||||
@@ -324,7 +334,21 @@ const hasCachedPreviewSource = (attachmentId) => {
|
|||||||
return Object.prototype.hasOwnProperty.call(previewSourcesById.value, 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) {
|
if (!downloadLink) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -340,7 +364,12 @@ const createEmbeddablePreviewUrl = async (downloadLink) => {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const objectUrl = URL.createObjectURL(fileBlob);
|
const safePreviewBlob = createSafePreviewBlob(fileBlob, previewKind);
|
||||||
|
if (!safePreviewBlob) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const objectUrl = URL.createObjectURL(safePreviewBlob);
|
||||||
generatedObjectUrls.add(objectUrl);
|
generatedObjectUrls.add(objectUrl);
|
||||||
return objectUrl;
|
return objectUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -372,7 +401,7 @@ const ensurePreviewSource = async (attachment) => {
|
|||||||
attachment.id,
|
attachment.id,
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
const previewSource = await createEmbeddablePreviewUrl(downloadLink);
|
const previewSource = await createEmbeddablePreviewUrl(downloadLink, previewKind);
|
||||||
previewSourcesById.value = {
|
previewSourcesById.value = {
|
||||||
...previewSourcesById.value,
|
...previewSourcesById.value,
|
||||||
[attachment.id]: previewSource,
|
[attachment.id]: previewSource,
|
||||||
@@ -517,6 +546,7 @@ const toggleDropdown = async () => {
|
|||||||
:src="activePreviewSource"
|
:src="activePreviewSource"
|
||||||
class="order-attachments-preview-panel__document"
|
class="order-attachments-preview-panel__document"
|
||||||
title="Attachment preview"
|
title="Attachment preview"
|
||||||
|
sandbox
|
||||||
></iframe>
|
></iframe>
|
||||||
<a
|
<a
|
||||||
v-else-if="activePreviewKind === 'link'"
|
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