Merge remote-tracking branch 'origin/master' into propose-fix-for-self-hosted-ci-vulnerability
# Conflicts: # .github/workflows/tests.yml
This commit is contained in:
@@ -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 += `<option value="${tmp.id}" ${(value == tmp.id || (value === null && tmp.id === 0)) ? 'selected' : ''}>${tmp.name ? tmp.name : tmp.id}</option>`;
|
||||
html += `<option value="${escapeHtml(optionValue)}" ${(value == optionValue || (value === null && optionValue === 0)) ? 'selected' : ''}>${escapeHtml(optionLabel)}</option>`;
|
||||
}
|
||||
});
|
||||
html += `</select>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 : "";
|
||||
};
|
||||
+15
-6
@@ -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,
|
||||
@@ -482,6 +483,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(() =>
|
||||
@@ -3158,14 +3162,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;
|
||||
@@ -4686,6 +4690,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
|
||||
);
|
||||
@@ -7054,7 +7063,7 @@ onBeforeUnmount(() => {
|
||||
class="studio-debug-attachment"
|
||||
:disabled="!attachment.download_link"
|
||||
:title="taskAttachmentLabel(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment, task.id)"
|
||||
>
|
||||
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
||||
<span>{{ taskAttachmentLabel(attachment) }}</span>
|
||||
@@ -7512,7 +7521,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
|
||||
>
|
||||
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
||||
@@ -8053,7 +8062,7 @@ onBeforeUnmount(() => {
|
||||
/>
|
||||
<span>Wash product</span>
|
||||
</label>
|
||||
<button class="button is-primary" type="submit" :disabled="!permissions.can_edit">
|
||||
<button class="button is-primary" type="submit" :disabled="!canEditVehicleTypeProduct">
|
||||
<span class="icon"><i class="fas fa-floppy-disk"></i></span>
|
||||
<span>Save vehicle type</span>
|
||||
</button>
|
||||
@@ -9838,7 +9847,7 @@ onBeforeUnmount(() => {
|
||||
class="studio-debug-attachment"
|
||||
:disabled="!attachment.download_link"
|
||||
:title="taskAttachmentLabel(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment)"
|
||||
@click.stop="openTaskAttachment(attachment, task.id)"
|
||||
>
|
||||
<span class="icon is-small"><i :class="taskAttachmentIcon(attachment)"></i></span>
|
||||
<span>{{ taskAttachmentLabel(attachment) }}</span>
|
||||
|
||||
+9
-6
@@ -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)"
|
||||
>
|
||||
<div class="status-toggle-top">
|
||||
@@ -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)"
|
||||
>
|
||||
<div class="status-toggle-top">
|
||||
|
||||
@@ -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,<script>alert(document.domain)</script>")).toBe("");
|
||||
expect(safeAttachmentDownloadLink("//evil.example/attachment.pdf")).toBe("");
|
||||
expect(safeAttachmentDownloadLink("https://evil.example/attachment.pdf")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: '</option></select><iframe src="javascript:parent.localStorage.token"></iframe>',
|
||||
},
|
||||
]),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const html = await ObjectsGlobal.generateEditObjectFieldForm(object, "relay_in_id", null);
|
||||
|
||||
expect(html).not.toContain("</option></select><iframe");
|
||||
expect(html).not.toContain("<iframe");
|
||||
expect(html).not.toContain('value="relay-1" autofocus');
|
||||
expect(html).toContain("relay-1" autofocus onfocus="alert(1)");
|
||||
expect(html).toContain(
|
||||
"</option></select><iframe src="javascript:parent.localStorage.token"></iframe>"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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,<script>alert(document.domain)</script>",
|
||||
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 () => {
|
||||
|
||||
Reference in New Issue
Block a user