@@ -384,7 +387,7 @@ watch([selected_date, selected_date_to], () => {
type="checkbox"
:id="dognvaskToggleId(lane)"
:checked="isDognvaskToggleChecked(lane)"
- :disabled="!canManageLaneToggles || lane.isSavingDognvask"
+ :disabled="!canManageDognvaskToggles || lane.isSavingDognvask"
@change="toggleDognvask(lane, $event)"
>
diff --git a/tests/unit/department-overview-period-sync.behavior.spec.js b/tests/unit/department-overview-period-sync.behavior.spec.js
index faf1bcdd..65ca667e 100644
--- a/tests/unit/department-overview-period-sync.behavior.spec.js
+++ b/tests/unit/department-overview-period-sync.behavior.spec.js
@@ -144,7 +144,9 @@ describe("Department overview period sync behavior", () => {
setMachineStatusEnabledMock.mockReset();
setLaneSelfServeEnabledMock.mockReset();
hasPermissionMock.mockReset();
- hasPermissionMock.mockImplementation((permission) => permission === "list_department_wash_lanes");
+ hasPermissionMock.mockImplementation((permission) =>
+ ["modules_selfserve_lane_status_set", "edit_department_lane"].includes(permission)
+ );
getDepartmentMock.mockResolvedValue({ id: 20, name: "Dept 20" });
getLaneStatusTogglesMock.mockResolvedValue({ data: { data: [] } });
@@ -443,7 +445,7 @@ describe("Department overview period sync behavior", () => {
expect(wrapper.find("#dognvask-20-22").element.checked).toBe(false);
});
- it("disables lane mutation toggles for daily-report users without wash-lane permission", async () => {
+ it("disables lane mutation toggles for users without lane mutation permissions", async () => {
hasPermissionMock.mockImplementation((permission) => permission === "list_department_daily_reports");
getLaneStatusTogglesMock.mockResolvedValueOnce({
data: {
@@ -499,4 +501,61 @@ describe("Department overview period sync behavior", () => {
expect(setMachineStatusEnabledMock).not.toHaveBeenCalled();
expect(setLaneSelfServeEnabledMock).not.toHaveBeenCalled();
});
+
+ it("does not allow wash-lane list permission to mutate lane toggles", async () => {
+ hasPermissionMock.mockImplementation((permission) => permission === "list_department_wash_lanes");
+ getLaneStatusTogglesMock.mockResolvedValueOnce({
+ data: {
+ data: [
+ {
+ id: 21,
+ department: 20,
+ name: "T1",
+ status: "AVAILABLE",
+ machine_status_enabled: true,
+ selfserve_enabled: true,
+ relay_in_id: "in-1",
+ relay_out_id: "out-1",
+ relay_machine_id: "machine-1",
+ relay_machine_program_picker_id: "picker-1",
+ relay_machine_cleaner_id: "cleaner-1",
+ dynamic_image_id: 1,
+ machine_type_id: 1,
+ dognvask_configured: true,
+ dognvask_configuration_warnings: [],
+ },
+ ],
+ },
+ });
+
+ const wrapper = mount(DepartmentDailyReportSmall, {
+ props: {
+ department_id: 20,
+ },
+ global: {
+ mocks: {
+ $t: (value) => value,
+ },
+ stubs: {
+ BLoading: true,
+ },
+ },
+ });
+
+ await flushAll();
+ await flushAll();
+
+ const machineToggle = wrapper.find("#machine-status-20-21");
+ const dognvaskToggle = wrapper.find("#dognvask-20-21");
+
+ expect(machineToggle.element.disabled).toBe(true);
+ expect(dognvaskToggle.element.disabled).toBe(true);
+
+ await machineToggle.trigger("change");
+ await dognvaskToggle.trigger("change");
+ await flushAll();
+
+ expect(setMachineStatusEnabledMock).not.toHaveBeenCalled();
+ expect(setLaneSelfServeEnabledMock).not.toHaveBeenCalled();
+ });
});
From b2a9b593995575e38b529efc419a3503488bcf6d Mon Sep 17 00:00:00 2001
From: Jeppe B <2jepp9350@gmail.com>
Date: Mon, 1 Jun 2026 23:34:34 +0200
Subject: [PATCH 4/6] Validate self-serve attachment download links
---
src/composables/useSelfServeLogic.js | 10 ++--
src/config.js | 6 ++
src/services/attachmentDownloadLinks.js | 55 +++++++++++++++++++
.../self-serve/DepartmentSelfServeStudio.vue | 11 ++--
tests/unit/attachment-download-links.spec.js | 16 ++++++
.../unit/self-serve-studio-task-scope.spec.js | 3 +-
tests/unit/use-self-serve-logic.spec.js | 24 +++++++-
7 files changed, 112 insertions(+), 13 deletions(-)
create mode 100644 src/services/attachmentDownloadLinks.js
create mode 100644 tests/unit/attachment-download-links.spec.js
diff --git a/src/composables/useSelfServeLogic.js b/src/composables/useSelfServeLogic.js
index 1ea76c51..2d859909 100644
--- a/src/composables/useSelfServeLogic.js
+++ b/src/composables/useSelfServeLogic.js
@@ -1,5 +1,6 @@
import { computed, ref } from "vue";
import { SessionUser } from "@/components/session/token/SessionUser.vue";
+import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
const normalizeBooleanAnswer = (value) => {
if (value === true || value === false) {
@@ -526,7 +527,7 @@ export function useSelfServeLogic() {
};
const downloadAttachment = async (taskId, attachmentId, attachment = null) => {
- const existingDownloadLink = attachment?.download_link || null;
+ const existingDownloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (existingDownloadLink) {
window.open(existingDownloadLink, "_blank", "noopener");
return;
@@ -534,10 +535,9 @@ export function useSelfServeLogic() {
try {
const response = await SessionUser.objects.self_serve_tasks.attachments.download(taskId, attachmentId);
- if (response?.data?.download_link) {
- window.open(response.data.download_link, "_blank", "noopener");
- } else if (response?.download_link) {
- window.open(response.download_link, "_blank", "noopener");
+ const downloadLink = safeAttachmentDownloadLink(response?.data?.download_link || response?.download_link);
+ if (downloadLink) {
+ window.open(downloadLink, "_blank", "noopener");
}
} catch (error) {
console.error("Error downloading attachment:", error);
diff --git a/src/config.js b/src/config.js
index 2f4b2910..edc47d02 100644
--- a/src/config.js
+++ b/src/config.js
@@ -55,6 +55,12 @@ export const RELEASE_TRUSTED_ORIGINS = String(import.meta.env.VITE_RELEASE_TRUST
.split(",")
.map((origin) => origin.trim().replace(/\/+$/, ""))
.filter(Boolean);
+export const SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS = String(
+ import.meta.env.VITE_SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS || ""
+)
+ .split(",")
+ .map((origin) => origin.trim().replace(/\/+$/, ""))
+ .filter(Boolean);
// Allowed origins
export const ALLOWED_ORIGINS = [
diff --git a/src/services/attachmentDownloadLinks.js b/src/services/attachmentDownloadLinks.js
new file mode 100644
index 00000000..fafd474e
--- /dev/null
+++ b/src/services/attachmentDownloadLinks.js
@@ -0,0 +1,55 @@
+import { SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS } from "@/config.js";
+import { trustedReleaseOrigins } from "@/services/releaseTrust.js";
+
+const normalizeOrigin = (value) => {
+ const raw = String(value || "").trim();
+ if (!raw) {
+ return "";
+ }
+ try {
+ return new URL(raw).origin;
+ } catch {
+ return "";
+ }
+};
+
+const isLocalHttpOrigin = (url) =>
+ url.protocol === "http:" && ["localhost", "127.0.0.1", "::1"].includes(url.hostname);
+
+const attachmentTrustedOrigins = () =>
+ Array.from(
+ new Set(
+ [...trustedReleaseOrigins(), ...SELF_SERVE_ATTACHMENT_TRUSTED_ORIGINS]
+ .map(normalizeOrigin)
+ .filter(Boolean)
+ )
+ );
+
+export const isSafeAttachmentDownloadLink = (value) => {
+ const raw = String(value || "").trim();
+ if (!raw || raw.startsWith("//")) {
+ return false;
+ }
+
+ if (raw.startsWith("/")) {
+ return true;
+ }
+
+ let url;
+ try {
+ url = new URL(raw);
+ } catch {
+ return false;
+ }
+
+ if (url.protocol !== "https:" && !isLocalHttpOrigin(url)) {
+ return false;
+ }
+
+ return attachmentTrustedOrigins().includes(url.origin);
+};
+
+export const safeAttachmentDownloadLink = (value) => {
+ const raw = String(value || "").trim();
+ return isSafeAttachmentDownloadLink(raw) ? raw : "";
+};
diff --git a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue
index 38906dba..1a14b660 100644
--- a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue
+++ b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue
@@ -18,6 +18,7 @@ import {
isSearching as isSearchingCustomers,
} from "@/components/search/economic/customerSearch.vue";
import { resolveReleaseApiUrl } from "@/services/releaseTimeline.js";
+import { safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
import {
buildSelfServeDynamicImageUrl,
getSelfServeCompletedDynamicImageStep,
@@ -3158,14 +3159,14 @@ const inspectorTaskDynamicImageUnavailableReason = computed(() => {
});
const openTaskAttachment = async (attachment, taskId = null) => {
- let downloadLink = String(attachment?.download_link || "").trim();
+ let downloadLink = safeAttachmentDownloadLink(attachment?.download_link);
if (!downloadLink && taskId && attachment?.id) {
try {
const payload = await requestGet("/department/selfserve/tasks/attachments/download", {
task_id: taskId,
attachment_id: attachment.id,
});
- downloadLink = String(payload?.download_link || "").trim();
+ downloadLink = safeAttachmentDownloadLink(payload?.download_link);
} catch (error) {
withErrorToast(error, "Attachment download could not be prepared.");
return;
@@ -7054,7 +7055,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
- @click.stop="openTaskAttachment(attachment)"
+ @click.stop="openTaskAttachment(attachment, task.id)"
>
{{ taskAttachmentLabel(attachment) }}
@@ -7512,7 +7513,7 @@ onBeforeUnmount(() => {
class="studio-node-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
- @click.stop="openTaskAttachment(attachment)"
+ @click.stop="openTaskAttachment(attachment, data.object_id)"
@mousedown.stop
>
@@ -9838,7 +9839,7 @@ onBeforeUnmount(() => {
class="studio-debug-attachment"
:disabled="!attachment.download_link"
:title="taskAttachmentLabel(attachment)"
- @click.stop="openTaskAttachment(attachment)"
+ @click.stop="openTaskAttachment(attachment, task.id)"
>
{{ taskAttachmentLabel(attachment) }}
diff --git a/tests/unit/attachment-download-links.spec.js b/tests/unit/attachment-download-links.spec.js
new file mode 100644
index 00000000..50424cc8
--- /dev/null
+++ b/tests/unit/attachment-download-links.spec.js
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+import { isSafeAttachmentDownloadLink, safeAttachmentDownloadLink } from "@/services/attachmentDownloadLinks.js";
+
+describe("attachmentDownloadLinks", () => {
+ it("allows relative and trusted HTTPS attachment URLs", () => {
+ expect(isSafeAttachmentDownloadLink("/department/selfserve/tasks/attachments/download?token=abc")).toBe(true);
+ expect(isSafeAttachmentDownloadLink("https://api-v2.truckwash.io/master/api/attachments/123")).toBe(true);
+ });
+
+ it("rejects scriptable, protocol-relative, and untrusted attachment URLs", () => {
+ expect(safeAttachmentDownloadLink("javascript:alert(document.domain)")).toBe("");
+ expect(safeAttachmentDownloadLink("data:text/html,")).toBe("");
+ expect(safeAttachmentDownloadLink("//evil.example/attachment.pdf")).toBe("");
+ expect(safeAttachmentDownloadLink("https://evil.example/attachment.pdf")).toBe("");
+ });
+});
diff --git a/tests/unit/self-serve-studio-task-scope.spec.js b/tests/unit/self-serve-studio-task-scope.spec.js
index 2b341916..1cef3487 100644
--- a/tests/unit/self-serve-studio-task-scope.spec.js
+++ b/tests/unit/self-serve-studio-task-scope.spec.js
@@ -99,7 +99,8 @@ describe("self-serve studio task editing", () => {
expect(source).toContain('data-testid="studio-task-attachment-manager"');
expect(source).toContain('data-testid="studio-task-attachment-upload"');
expect(source).toContain('class="studio-debug-attachment-list"');
- expect(source).toContain('@click.stop="openTaskAttachment(attachment)"');
+ expect(source).toContain('@click.stop="openTaskAttachment(attachment, data.object_id)"');
+ expect(source).toContain('@click.stop="openTaskAttachment(attachment, task.id)"');
expect(source).toContain("delete data.attachments");
});
diff --git a/tests/unit/use-self-serve-logic.spec.js b/tests/unit/use-self-serve-logic.spec.js
index 05e5d873..dc51cb37 100644
--- a/tests/unit/use-self-serve-logic.spec.js
+++ b/tests/unit/use-self-serve-logic.spec.js
@@ -137,12 +137,32 @@ describe("useSelfServeLogic", () => {
await logic.downloadAttachment(5, 202, {
id: 202,
- download_link: "https://cdn.example.test/photo.jpg",
+ download_link: "https://api-v2.truckwash.io/master/api/attachments/photo.jpg",
content: { other: "photo.jpg" },
});
expect(mocks.attachmentsDownload).not.toHaveBeenCalled();
- expect(openSpy).toHaveBeenCalledWith("https://cdn.example.test/photo.jpg", "_blank", "noopener");
+ expect(openSpy).toHaveBeenCalledWith(
+ "https://api-v2.truckwash.io/master/api/attachments/photo.jpg",
+ "_blank",
+ "noopener"
+ );
+ });
+
+ it("does not open untrusted embedded task attachment links", async () => {
+ const openSpy = vi.fn();
+ vi.stubGlobal("window", { open: openSpy });
+ mocks.attachmentsDownload.mockResolvedValue({ download_link: "javascript:alert(document.domain)" });
+ const logic = useSelfServeLogic();
+
+ await logic.downloadAttachment(5, 202, {
+ id: 202,
+ download_link: "data:text/html,",
+ content: { other: "photo.jpg" },
+ });
+
+ expect(mocks.attachmentsDownload).toHaveBeenCalledWith(5, 202);
+ expect(openSpy).not.toHaveBeenCalled();
});
it("falls back to lane/reg summary when session summary does not include questions", async () => {
From feccfada77f580257c4d4a964b49ceda23ed457e Mon Sep 17 00:00:00 2001
From: Jeppe B <2jepp9350@gmail.com>
Date: Mon, 1 Jun 2026 23:35:04 +0200
Subject: [PATCH 5/6] Restrict vehicle type product edits to superusers
---
.../self-serve/DepartmentSelfServeStudio.vue | 10 +++++++++-
.../self-serve-studio-managed-inspector.spec.js | 13 +++++++++++++
2 files changed, 22 insertions(+), 1 deletion(-)
diff --git a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue
index 38906dba..f29abf6d 100644
--- a/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue
+++ b/src/views/dashboards/departmentDashboard/modules/self-serve/DepartmentSelfServeStudio.vue
@@ -482,6 +482,9 @@ const graphStats = computed(() => {
});
const permissions = computed(() => graphPayload.value?.permissions || {});
+const canEditVehicleTypeProduct = computed(() =>
+ Boolean(permissions.value.can_edit) && SessionUser.canAccessSuperUser()
+);
const destructiveGatewayActions = new Set(["rotate_credentials", "uninstall"]);
const canManageGateways = computed(() => Boolean(permissions.value.can_manage_gateways));
const canRunGatewayDestructiveActions = computed(() =>
@@ -4686,6 +4689,11 @@ const deleteSelected = async () => {
};
const saveVehicleTypeProduct = async () => {
+ if (!canEditVehicleTypeProduct.value) {
+ toast.error("Only superusers can update vehicle type product catalog fields.");
+ return;
+ }
+
const id = parseNullableInt(
inspectorForm.value.id || selectedRaw.value.product || selectedRaw.value.product_id || selectedRaw.value.id
);
@@ -8053,7 +8061,7 @@ onBeforeUnmount(() => {
/>
Wash product
-