From 4634cbad898c390f6d8274e94e19eb1a7e5ad363 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 1 Jun 2026 23:33:10 +0200 Subject: [PATCH 1/6] Fix select option HTML escaping --- .../SessionUser/Objects/ObjectsGlobal.vue | 13 ++++- tests/unit/objects-global-select-xss.spec.js | 55 +++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) create mode 100644 tests/unit/objects-global-select-xss.spec.js diff --git a/src/components/session/token/SessionUser/Objects/ObjectsGlobal.vue b/src/components/session/token/SessionUser/Objects/ObjectsGlobal.vue index 57611fd6..cb893a92 100644 --- a/src/components/session/token/SessionUser/Objects/ObjectsGlobal.vue +++ b/src/components/session/token/SessionUser/Objects/ObjectsGlobal.vue @@ -7,6 +7,13 @@ import { getSystemUserIds } from "@/components/session/token/SessionUser/Objects // Helper function to get i18n translation const t = (key) => i18n.global.t(key); +const escapeHtml = (value = "") => String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\"", """) + .replaceAll("'", "'"); + const normalizeDateTimeLocalValue = (value) => { if (value === null || value === undefined || value === '') { return ''; @@ -765,9 +772,11 @@ export const ObjectsGlobal = { console.log(response); // How long are the list of options for (let i = 0; i < response.length; i++) { - var tmp = response[i]; + const tmp = response[i]; + const optionValue = tmp?.id ?? ""; + const optionLabel = tmp?.name ? tmp.name : optionValue; console.log(tmp); - html += ``; + html += ``; } }); html += ` diff --git a/tests/unit/objects-global-select-xss.spec.js b/tests/unit/objects-global-select-xss.spec.js new file mode 100644 index 00000000..eeaff839 --- /dev/null +++ b/tests/unit/objects-global-select-xss.spec.js @@ -0,0 +1,55 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("sweetalert2", () => ({ + default: { + fire: vi.fn(), + }, +})); + +vi.mock("@/i18n", () => ({ + default: { + global: { + t: (key) => key, + }, + }, +})); + +vi.mock("@/components/session/authenticatedRequest.vue", () => ({ + authenticatedRequest: vi.fn(), + unauthenticatedRequest: vi.fn(), +})); + +vi.mock("@/components/session/token/SessionUser/Objects/systemUserIds.js", () => ({ + getSystemUserIds: () => [], +})); + +import { ObjectsGlobal } from "@/components/session/token/SessionUser/Objects/ObjectsGlobal.vue"; + +describe("ObjectsGlobal select editor escaping", () => { + it("escapes option ids and names before rendering SweetAlert HTML", async () => { + const object = { + columns: { + relay_in_id: { + label: "Relay", + type: "select", + options: vi.fn().mockResolvedValue([ + { + id: 'relay-1" autofocus onfocus="alert(1)', + name: '', + }, + ]), + }, + }, + }; + + const html = await ObjectsGlobal.generateEditObjectFieldForm(object, "relay_in_id", null); + + expect(html).not.toContain(" Date: Mon, 1 Jun 2026 23:33:34 +0200 Subject: [PATCH 2/6] Secure edge gateway websocket sessions --- .../edgeGateways/edgeGatewayLiveSessions.js | 101 ++++++++++++- ...dge-gateway-live-sessions-security.spec.js | 137 ++++++++++++++++++ 2 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 tests/unit/edge-gateway-live-sessions-security.spec.js diff --git a/src/features/edgeGateways/edgeGatewayLiveSessions.js b/src/features/edgeGateways/edgeGatewayLiveSessions.js index 08aae3ed..df94763d 100644 --- a/src/features/edgeGateways/edgeGatewayLiveSessions.js +++ b/src/features/edgeGateways/edgeGatewayLiveSessions.js @@ -10,6 +10,66 @@ const MOCK_SHELL_PROMPT = "edge@truckwash:/opt/truckwash-edge-agent$ "; const isMockSocketUrl = (value) => String(value || "").startsWith(MOCK_SOCKET_PREFIX); +const SENSITIVE_SOCKET_QUERY_KEYS = new Set([ + "access_token", + "auth", + "authorization", + "bearer", + "jwt", + "session", + "session_token", + "token", +]); + +const getBrowserLocation = () => (typeof window !== "undefined" && window.location ? window.location : null); + +const getConfiguredTrustedSocketOrigins = () => + String(import.meta.env?.VITE_EDGE_GATEWAY_WS_ALLOWED_ORIGINS || "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); + +const toSocketOrigin = (value) => { + const location = getBrowserLocation(); + const base = location?.origin || "http://localhost"; + const url = new URL(value, base); + if (url.protocol === "https:") { + url.protocol = "wss:"; + } else if (url.protocol === "http:") { + url.protocol = "ws:"; + } + return url.origin; +}; + +const isLocalSocketHost = (hostname) => ["localhost", "127.0.0.1", "::1", "[::1]"].includes(String(hostname || "")); + +const getTrustedSocketOrigins = () => { + const origins = new Set(); + const location = getBrowserLocation(); + + if (location?.origin) { + origins.add(toSocketOrigin(location.origin)); + } + + getConfiguredTrustedSocketOrigins().forEach((origin) => origins.add(toSocketOrigin(origin))); + return origins; +}; + +const assertTrustedSocketUrl = (url) => { + if (!["ws:", "wss:"].includes(url.protocol)) { + throw new Error("Unsupported websocket URL protocol"); + } + + if (url.protocol !== "wss:" && !isLocalSocketHost(url.hostname)) { + throw new Error("Gateway websocket URL must use wss"); + } + + const trustedOrigins = getTrustedSocketOrigins(); + if (trustedOrigins.size > 0 && !trustedOrigins.has(url.origin)) { + throw new Error("Gateway websocket URL origin is not trusted"); + } +}; + const toNativeSocketUrl = (socketUrl, query = {}) => { if (!socketUrl) { throw new Error("Missing websocket URL"); @@ -19,16 +79,48 @@ const toNativeSocketUrl = (socketUrl, query = {}) => { return socketUrl; } - const url = new URL(socketUrl, typeof window !== "undefined" ? window.location.origin : "http://localhost"); + const url = new URL(socketUrl, getBrowserLocation()?.origin || "http://localhost"); + assertTrustedSocketUrl(url); + + Array.from(url.searchParams.keys()).forEach((key) => { + if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) { + url.searchParams.delete(key); + } + }); + Object.entries(query || {}).forEach(([key, value]) => { if (value === null || value === undefined || value === "") { return; } + if (SENSITIVE_SOCKET_QUERY_KEYS.has(String(key).toLowerCase())) { + return; + } url.searchParams.set(key, String(value)); }); return url.toString(); }; +const createSessionAuthenticationPayload = (session, gatewayId, kind) => { + if (!session?.token) { + return null; + } + + return { + type: "AUTH", + gatewayId: String(gatewayId || session.gateway_id || ""), + token: String(session.token), + sessionId: session.id || session.session_id || null, + kind, + }; +}; + +const sendSessionAuthentication = (send, session, gatewayId, kind) => { + const authPayload = createSessionAuthenticationPayload(session, gatewayId, kind); + if (authPayload) { + send(authPayload); + } +}; + const safeJsonParse = (payload) => { if (payload === null || payload === undefined || payload === "") { return null; @@ -292,10 +384,10 @@ export async function createGatewayStreamClient(gatewayId, scopes = [], handlers const session = unwrapEdgeGatewayResponse(response, {}); const socketUrl = toNativeSocketUrl(session.ws_url, { gatewayId, - token: session.token, }); const connection = createConnection(socketUrl, session, "stream", handlers, ({ send }) => { + sendSessionAuthentication(send, session, gatewayId, "stream"); send({ type: "SUBSCRIBE", gatewayId: String(gatewayId), @@ -318,9 +410,10 @@ export async function createGatewayShellClient(gatewayId, options = {}, handlers const session = unwrapEdgeGatewayResponse(response, {}); const socketUrl = toNativeSocketUrl(session.ws_url, { gatewayId, - token: session.token, }); - const connection = createConnection(socketUrl, session, "shell", handlers); + const connection = createConnection(socketUrl, session, "shell", handlers, ({ send }) => { + sendSessionAuthentication(send, session, gatewayId, "shell"); + }); return { session, diff --git a/tests/unit/edge-gateway-live-sessions-security.spec.js b/tests/unit/edge-gateway-live-sessions-security.spec.js new file mode 100644 index 00000000..0478b98a --- /dev/null +++ b/tests/unit/edge-gateway-live-sessions-security.spec.js @@ -0,0 +1,137 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const streamSessionMock = vi.fn(); +const shellSessionMock = vi.fn(); + +vi.mock("@/services/edgeGateways.js", () => ({ + createEdgeGatewayStreamSession: (...args) => streamSessionMock(...args), + createEdgeGatewayShellSession: (...args) => shellSessionMock(...args), + unwrapEdgeGatewayResponse: (response, fallback = {}) => response?.data?.data || response?.data || fallback, +})); + +class MockWebSocket { + static instances = []; + + constructor(url) { + this.url = url; + this.readyState = 0; + this.sent = []; + this.listeners = new Map(); + MockWebSocket.instances.push(this); + } + + addEventListener(type, handler) { + this.listeners.set(type, handler); + } + + send(payload) { + this.sent.push(JSON.parse(payload)); + } + + close() { + this.readyState = 3; + } + + open() { + this.readyState = 1; + this.listeners.get("open")?.({}); + } +} + +const setBrowserLocation = (origin) => { + globalThis.window = { + location: new URL(origin), + setTimeout, + clearTimeout, + }; +}; + +describe("edge gateway live session websocket security", () => { + beforeEach(() => { + vi.clearAllMocks(); + MockWebSocket.instances = []; + globalThis.WebSocket = MockWebSocket; + setBrowserLocation("https://app.example/"); + }); + + it("keeps stream session tokens out of websocket URLs and authenticates after open", async () => { + streamSessionMock.mockResolvedValue({ + data: { + data: { + ws_url: "wss://app.example/gateway-stream?keep=1&token=URL-TOKEN&jwt=URL-JWT", + token: "STREAM-TOKEN-secret-456", + id: "stream-session-1", + }, + }, + }); + + const { createGatewayStreamClient } = await import("@/features/edgeGateways/edgeGatewayLiveSessions.js"); + await createGatewayStreamClient("gateway-8", ["logs"]); + + expect(MockWebSocket.instances).toHaveLength(1); + const socket = MockWebSocket.instances[0]; + expect(socket.url).toBe("wss://app.example/gateway-stream?keep=1&gatewayId=gateway-8"); + expect(socket.url).not.toContain("STREAM-TOKEN-secret-456"); + expect(socket.url).not.toContain("URL-TOKEN"); + expect(socket.url).not.toContain("URL-JWT"); + + socket.open(); + + expect(socket.sent[0]).toEqual({ + type: "AUTH", + gatewayId: "gateway-8", + token: "STREAM-TOKEN-secret-456", + sessionId: "stream-session-1", + kind: "stream", + }); + expect(socket.sent[1]).toEqual({ type: "SUBSCRIBE", gatewayId: "gateway-8", scopes: ["logs"] }); + }); + + it("keeps shell session tokens out of websocket URLs and authenticates before input", async () => { + shellSessionMock.mockResolvedValue({ + data: { + data: { + ws_url: "wss://app.example/live-shell?logme=1&token=URL-SHELL-TOKEN", + token: "SHELL-TOKEN-secret-123", + session_id: "shell-session-7", + }, + }, + }); + + const { createGatewayShellClient } = await import("@/features/edgeGateways/edgeGatewayLiveSessions.js"); + const client = await createGatewayShellClient("gateway-7"); + const socket = MockWebSocket.instances[0]; + + expect(socket.url).toBe("wss://app.example/live-shell?logme=1&gatewayId=gateway-7"); + expect(socket.url).not.toContain("SHELL-TOKEN-secret-123"); + expect(socket.url).not.toContain("URL-SHELL-TOKEN"); + + socket.open(); + client.sendInput("pwd"); + + expect(socket.sent[0]).toEqual({ + type: "AUTH", + gatewayId: "gateway-7", + token: "SHELL-TOKEN-secret-123", + sessionId: "shell-session-7", + kind: "shell", + }); + expect(socket.sent[1]).toEqual({ type: "input", data: "pwd" }); + }); + + it("rejects untrusted or insecure websocket endpoints before connecting", async () => { + shellSessionMock.mockResolvedValue({ + data: { + data: { + ws_url: "ws://broker.invalid/live-shell", + token: "SHELL-TOKEN-secret-123", + }, + }, + }); + + const { createGatewayShellClient } = await import("@/features/edgeGateways/edgeGatewayLiveSessions.js"); + + await expect(createGatewayShellClient("gateway-7")).rejects.toThrow("Gateway websocket URL must use wss"); + expect(MockWebSocket.instances).toHaveLength(0); + }); +}); From 53e193d8d1bf289d3e61acb36963168fbf5aa17e Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 1 Jun 2026 23:34:03 +0200 Subject: [PATCH 3/6] Fix lane toggle permission guards --- .../displays/DepartmentDailyReportSmall.vue | 15 +++-- ...ment-overview-period-sync.behavior.spec.js | 63 ++++++++++++++++++- 2 files changed, 70 insertions(+), 8 deletions(-) diff --git a/src/views/dashboards/departmentDashboard/other/displays/DepartmentDailyReportSmall.vue b/src/views/dashboards/departmentDashboard/other/displays/DepartmentDailyReportSmall.vue index c9859aeb..0e03c9cd 100644 --- a/src/views/dashboards/departmentDashboard/other/displays/DepartmentDailyReportSmall.vue +++ b/src/views/dashboards/departmentDashboard/other/displays/DepartmentDailyReportSmall.vue @@ -26,8 +26,11 @@ const washes = ref(0); const outsideHours = ref(createEmptyOutsideHours()); const laneToggles = ref([]); const laneToggleError = ref(""); -const canManageLaneToggles = computed(() => - SessionUser.hasPermission("admin") || SessionUser.hasPermission("list_department_wash_lanes") +const canManageMachineStatusToggles = computed(() => + SessionUser.hasPermission("admin") || SessionUser.hasPermission("modules_selfserve_lane_status_set") +); +const canManageDognvaskToggles = computed(() => + SessionUser.hasPermission("admin") || SessionUser.hasPermission("edit_department_lane") ); const normalizeBoolean = (value, defaultValue = false) => { @@ -252,7 +255,7 @@ const applyLaneUpdate = (laneId, currentLane, update, savingPatch = {}) => { }; const toggleMachineStatus = async (lane, event) => { - if (!canManageLaneToggles.value) { + if (!canManageMachineStatusToggles.value) { event.target.checked = isMachineToggleChecked(lane); return; } @@ -286,7 +289,7 @@ const toggleMachineStatus = async (lane, event) => { }; const toggleDognvask = async (lane, event) => { - if (!canManageLaneToggles.value) { + if (!canManageDognvaskToggles.value) { event.target.checked = isDognvaskToggleChecked(lane); return; } @@ -348,7 +351,7 @@ watch([selected_date, selected_date_to], () => { type="checkbox" :id="machineToggleId(lane)" :checked="isMachineToggleChecked(lane)" - :disabled="!canManageLaneToggles || lane.isSavingMachineStatus" + :disabled="!canManageMachineStatusToggles || lane.isSavingMachineStatus" @change="toggleMachineStatus(lane, $event)" >
@@ -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 - diff --git a/tests/unit/self-serve-studio-managed-inspector.spec.js b/tests/unit/self-serve-studio-managed-inspector.spec.js index 28d07644..fd196256 100644 --- a/tests/unit/self-serve-studio-managed-inspector.spec.js +++ b/tests/unit/self-serve-studio-managed-inspector.spec.js @@ -35,14 +35,27 @@ describe("self-serve studio managed inspector", () => { expect(source).toContain("const normalizeVehicleTypeForm = (raw = {}) => {"); expect(source).toContain("const saveVehicleTypeProduct = async () => {"); + expect(source).toContain("if (!canEditVehicleTypeProduct.value) {"); expect(source).toContain('await requestPut("/products", {'); expect(source).toContain('data-testid="studio-vehicle-type-form"'); expect(source).toContain('data-testid="studio-vehicle-type-name"'); + expect(source).toContain(':disabled="!canEditVehicleTypeProduct"'); expect(source).toContain("const normalizeMachineTypeForm = (raw = {}) => {"); expect(source).toContain("const saveMachineType = async () => {"); expect(source).toContain('await requestPut("/department/selfserve/machine-types", {'); }); + it("requires superuser access before saving vehicle type product catalog fields", () => { + const source = studioSource(); + + expect(source).toContain("const canEditVehicleTypeProduct = computed(() =>"); + expect(source).toContain("Boolean(permissions.value.can_edit) && SessionUser.canAccessSuperUser()"); + expect(source.indexOf("if (!canEditVehicleTypeProduct.value) {")).toBeLessThan( + source.indexOf('await requestPut("/products", {') + ); + expect(source).toContain("Only superusers can update vehicle type product catalog fields."); + }); + it("manages virtual hardware from selected gateway and relay binding nodes", () => { const source = studioSource(); From 36b399cb2f2ecde705c2e91dcc296dfa232d1c71 Mon Sep 17 00:00:00 2001 From: Jeppe B <2jepp9350@gmail.com> Date: Mon, 1 Jun 2026 23:40:00 +0200 Subject: [PATCH 6/6] Fix CI runner exposure for pull requests --- .github/workflows/tests.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 9af2a43f..e75979d7 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -16,8 +16,7 @@ concurrency: jobs: format-tests: - # Match the labels exposed by the Coolify-managed GitHub runner. - runs-on: [self-hosted, Linux, X64, default] + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v5 @@ -39,7 +38,7 @@ jobs: build-and-unit: needs: format-tests - runs-on: [self-hosted, Linux, X64, default] + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v5 @@ -64,7 +63,7 @@ jobs: e2e-pr: if: github.event_name != 'schedule' needs: build-and-unit - runs-on: [self-hosted, Linux, X64, default] + runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v5 @@ -122,7 +121,7 @@ jobs: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || github.ref_name == github.event.repository.default_branch needs: build-and-unit name: E2E-full-${{ matrix.role }}-${{ matrix.browser_label }}-${{ matrix.device }} - runs-on: [self-hosted, Linux, X64, default] + runs-on: ubuntu-latest strategy: fail-fast: false matrix: