diff --git a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx index c74e545c..05952169 100644 --- a/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx +++ b/src/Web/autopilot-monitor-web/app/diagnosis/page.tsx @@ -30,7 +30,8 @@ export default function DiagnosisPage() { function DiagnosisContent() { const searchParams = useSearchParams(); const router = useRouter(); - const sessionId = searchParams?.get("id") as string; + // Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders. + const sessionId = (isGuid(searchParams?.get("id")) ? searchParams!.get("id")!.trim() : "") as string; const [session, setSession] = useState(null); const [sessionTenantId, setSessionTenantId] = useState(null); diff --git a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDetail.ts b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDetail.ts index 8ba2fd31..8663281b 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDetail.ts +++ b/src/Web/autopilot-monitor-web/app/sessions/hooks/useSessionDetail.ts @@ -108,7 +108,10 @@ export function useSessionDetail({ const response = await authenticatedFetch(endpoint, getAccessToken); if (response.ok) { const data = await response.json(); - const foundSession = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId); + // Accept only a session whose id matches the one we asked for — a misrouted or + // unexpected 200 must never hydrate the page (defense in depth behind the GUID gate). + const candidate: Session | undefined = data.session ?? data.sessions?.find((s: Session) => s.sessionId === sessionId); + const foundSession = candidate?.sessionId?.toLowerCase() === sessionId.toLowerCase() ? candidate : undefined; if (foundSession) { setSession(foundSession); setSessionTenantId(foundSession.tenantId); diff --git a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx index 5e0c3bb4..3d6eff58 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/network-timeline/page.tsx @@ -16,6 +16,7 @@ import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { ProtectedRoute } from '@/components/ProtectedRoute'; import { useAuth } from '@/contexts/AuthContext'; +import { isGuid } from '@/utils/inputValidation'; import { api } from '@/lib/api'; import { authenticatedFetch } from '@/lib/authenticatedFetch'; import { extractContinuation, MAX_EAGER_PAGES } from '@/lib/paginationLink'; @@ -91,7 +92,8 @@ export default function NetworkTimelinePage() { function Content() { const searchParams = useSearchParams(); - const sessionId = searchParams?.get('id') ?? null; + // Deep-link value is percent-decoded by useSearchParams; only a GUID may reach the API builders. + const sessionId = isGuid(searchParams?.get('id')) ? searchParams!.get('id')!.trim() : null; const tenantIdOverride = searchParams?.get('tenantId') ?? undefined; const { session, events, loading, error } = useSessionData(sessionId, tenantIdOverride); diff --git a/src/Web/autopilot-monitor-web/app/sessions/page.tsx b/src/Web/autopilot-monitor-web/app/sessions/page.tsx index e042956f..f80b2c8e 100644 --- a/src/Web/autopilot-monitor-web/app/sessions/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sessions/page.tsx @@ -3,6 +3,7 @@ import { diagnosisUrl, inspectorUrl } from "@/lib/routes"; import { Suspense, useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; +import Link from "next/link"; import { useSignalR } from "../../contexts/SignalRContext"; import { useTenant } from "../../contexts/TenantContext"; import { useAuth } from "../../contexts/AuthContext"; @@ -15,6 +16,7 @@ import ScriptExecutions from '../../components/ScriptExecutions'; import { useLatestVersions } from '@/lib/useLatestVersions'; import { useScriptDisplayNames } from '@/lib/scriptDisplayNames'; import { api } from "@/lib/api"; +import { isGuid } from "@/utils/inputValidation"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { useSessionAnalysis } from "./hooks/useSessionAnalysis"; @@ -59,7 +61,11 @@ function SessionDetailContent() { const searchParams = useSearchParams(); // Query-string identity (`?id=`) — path params are gone with the static export; // legacy /sessions/{id} URLs are rewritten here by LegacyPathRedirect. - const sessionId = searchParams?.get("id") as string; + // The raw value is attacker-deliverable (deep link) and useSearchParams returns it + // percent-DECODED, so anything but a GUID must never reach an API URL builder: an empty + // sessionId keeps every data hook inert and the page renders the invalid-id branch below. + const rawSessionId = searchParams?.get("id") ?? ""; + const sessionId = isGuid(rawSessionId) ? rawSessionId.trim() : ""; // Explicit target tenant from the fleet drill-in (`?tenantId=`). Drives the cross-tenant reads for a // delegated ("MSP") admin viewing a managed tenant's session, and flips the page into read-only mode. const tenantIdOverride = searchParams?.get("tenantId") || undefined; @@ -424,6 +430,19 @@ function SessionDetailContent() { // so `loading` never flips and we'd render this branch forever — bypassing the // auth gate and hanging on "Loading session details..." without ever triggering // the MSAL login redirect. Wrapping here lets ProtectedRoute drive re-auth. + if (!sessionId) { + return ( + +
+
+ {rawSessionId ? "Invalid session id." : "No session id given."}{" "} + Back to dashboard +
+
+
+ ); + } + if (loading && !session && events.length === 0) { return ( diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts new file mode 100644 index 00000000..45d53989 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/apiPathEncoding.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect, vi } from "vitest"; + +// API_BASE_URL is read at import time of lib/api — stub it before the import. +vi.mock("@/utils/config", () => ({ API_BASE_URL: "https://test.example" })); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const apiPromise = import("../api") as Promise<{ api: any }>; + +/** + * Path-position ids must be percent-encoded so an attacker-controlled id + * (deep link `/sessions?id=...`, decoded by useSearchParams) cannot rewrite the + * route: '/' and '..' would otherwise be resolved by the URL parser into a + * different API path, '#' would truncate the builder's suffix and query string. + */ +const HOSTILE = "x/../../global/y#"; +const ENCODED = "x%2F..%2F..%2Fglobal%2Fy%23"; + +describe("api path ids are percent-encoded", () => { + it("sessions.* keeps a hostile id inside the sessions/{id} segment", async () => { + const { api } = await apiPromise; + const builders: Array<(id: string) => string> = [ + (id) => api.sessions.get(id), + (id) => api.sessions.events(id), + (id) => api.sessions.delete(id, "t"), + (id) => api.sessions.analysis(id), + (id) => api.sessions.vulnerabilityReport(id), + (id) => api.sessions.timeAttribution(id), + (id) => api.sessions.markFailed(id), + (id) => api.sessions.markSucceeded(id), + (id) => api.sessions.queueAction(id), + (id) => api.sessions.report(id), + (id) => api.sessions.annotations(id), + (id) => api.sessions.annotation(id, "lane"), + (id) => api.inspector.signals(id), + (id) => api.inspector.decisionGraph(id), + (id) => api.inspector.reducerVerification(id), + ]; + for (const build of builders) { + const url = build(HOSTILE); + expect(url.startsWith(`https://test.example/api/sessions/${ENCODED}`)).toBe(true); + expect(new URL(url).pathname.startsWith(`/api/sessions/${ENCODED}`)).toBe(true); + } + }); + + it("markFailed keeps its suffix after a '#'-bearing id", async () => { + const { api } = await apiPromise; + expect(api.sessions.markFailed(HOSTILE, "t")).toBe( + `https://test.example/api/sessions/${ENCODED}/mark-failed?tenantId=t`, + ); + }); + + it("annotation lane and gather rule id are encoded too", async () => { + const { api } = await apiPromise; + expect(api.sessions.annotation("s", "a/b")).toBe("https://test.example/api/sessions/s/annotations/a%2Fb"); + expect(api.rules.gatherRule(HOSTILE)).toBe(`https://test.example/api/rules/gather/${ENCODED}`); + }); + + it("no path builder lets an id escape its segment", async () => { + const { api } = await apiPromise; + type Builder = (...args: string[]) => string; + const walk = (node: unknown): Builder[] => + typeof node === "function" + ? [node as Builder] + : Object.values(node as Record).flatMap(walk); + for (const fn of walk(api)) { + if (fn.length === 0) continue; + const url = fn(HOSTILE, HOSTILE); + // The WHATWG parser resolves a raw "x/../../global/y" into "/api/global/y"; + // an encoded id survives verbatim and the fragment marker never appears. + expect(new URL(url).pathname, url).not.toMatch(/\/global\/y/); + expect(url).not.toContain("#"); + } + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index c4613cd8..73b07e27 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -33,44 +33,44 @@ export const api = { continuation: opts?.continuation, })}`, get: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs({ tenantId })}`, events: ( sessionId: string, tenantId?: string, opts?: { pageSize?: number; continuation?: string }, ) => - `${API_BASE_URL}/api/sessions/${sessionId}/events${qs({ + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/events${qs({ tenantId, pageSize: opts?.pageSize?.toString(), continuation: opts?.continuation, })}`, delete: (sessionId: string, tenantId: string) => - `${API_BASE_URL}/api/sessions/${sessionId}${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}${qs({ tenantId })}`, analysis: (sessionId: string, tenantId?: string, reanalyze?: boolean) => - `${API_BASE_URL}/api/sessions/${sessionId}/analysis${qs({ tenantId, ...(reanalyze ? { reanalyze: "true" } : {}) })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/analysis${qs({ tenantId, ...(reanalyze ? { reanalyze: "true" } : {}) })}`, vulnerabilityReport: (sessionId: string, tenantId?: string, rescan?: boolean) => - `${API_BASE_URL}/api/sessions/${sessionId}/vulnerability-report${qs({ tenantId, ...(rescan ? { rescan: "true" } : {}) })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/vulnerability-report${qs({ tenantId, ...(rescan ? { rescan: "true" } : {}) })}`, // F1 time attribution: pre-computed breakdown row (null for pre-feature / non-terminal / // Incomplete sessions — the lane is simply omitted, nothing is re-derived client-side). timeAttribution: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/time-attribution${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/time-attribution${qs({ tenantId })}`, markFailed: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/mark-failed${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/mark-failed${qs({ tenantId })}`, markSucceeded: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/mark-succeeded${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/mark-succeeded${qs({ tenantId })}`, // POST { type, reason } — queues a ServerAction delivered with the agent's next ingest // response (e.g. request_diagnostics for the "Collect Logs" button). queueAction: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/actions${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/actions${qs({ tenantId })}`, report: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/report${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/report${qs({ tenantId })}`, // GET — all annotation lanes visible to the caller (backend hides the globaladmin // lane from callers without global scope). annotations: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/annotations${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/annotations${qs({ tenantId })}`, // PUT { verdict, note } — upserts one lane; both fields null/empty clears it. annotation: (sessionId: string, lane: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/annotations/${lane}${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/annotations/${encodeURIComponent(lane)}${qs({ tenantId })}`, quickSearch: (q: string) => `${API_BASE_URL}/api/search/quick${qs({ q })}`, // Server-side free-text search (dashboard search box) — substring across the same @@ -92,11 +92,11 @@ export const api = { // ── Inspector v1 (global admin only — Plan §M6) ─────────────────────────── inspector: { signals: (sessionId: string, opts?: { tenantId?: string; maxResults?: number }) => - `${API_BASE_URL}/api/sessions/${sessionId}/signals${qs({ tenantId: opts?.tenantId, maxResults: opts?.maxResults?.toString() })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/signals${qs({ tenantId: opts?.tenantId, maxResults: opts?.maxResults?.toString() })}`, decisionGraph: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/decision-graph${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/decision-graph${qs({ tenantId })}`, reducerVerification: (sessionId: string, tenantId?: string) => - `${API_BASE_URL}/api/sessions/${sessionId}/reducer-verification${qs({ tenantId })}`, + `${API_BASE_URL}/api/sessions/${encodeURIComponent(sessionId)}/reducer-verification${qs({ tenantId })}`, }, // ── Global Sessions (global admin) ──────────────────────────────────────── @@ -131,31 +131,31 @@ export const api = { // ── Config ──────────────────────────────────────────────────────────────── config: { all: () => `${API_BASE_URL}/api/config/all`, - tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}`, + tenant: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}`, // Same PUT, labelled: the session-detail Collect Logs quick-config dialog. The backend // maps the intent to write source "portal-collect-logs" and emits the dedicated // CollectLogsQuickConfigEnabled ops event instead of the generic DiagnosticsUploadEnabled. - tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}?intent=collect-logs`, + tenantCollectLogsQuickConfig: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}?intent=collect-logs`, /** * PATCH — transactional field-level config write (TenantAdminOrGA, own row): * { fields: { : value | null, ... }, reason?: string }. The backend * verifies exactly those fields changed and rolls back on drift; every write snapshots * to ConfigurationBackups first. The Settings sections save through this per section. */ - fields: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/fields`, - featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/feature-flags`, + fields: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/fields`, + featureFlags: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/feature-flags`, /** PATCH — GA-only plan/trial mutation: { planTier?, trialExpiresUtc?: ISO | null }. */ - plan: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/plan`, + plan: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/plan`, /** POST — tenant-admin self-service 30-day Pro trial (once per tenant, 409 after). */ - trial: (tenantId: string) => `${API_BASE_URL}/api/config/${tenantId}/trial`, + trial: (tenantId: string) => `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/trial`, autopilotConsentUrl: (tenantId: string, redirectUri: string) => - `${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-url${qs({ redirectUri })}`, + `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-url${qs({ redirectUri })}`, autopilotConsentStatus: (tenantId: string) => - `${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-status`, + `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-status`, autopilotConsentFailure: (tenantId: string) => - `${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-failure`, + `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-failure`, autopilotConsentSuccess: (tenantId: string) => - `${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/consent-success`, + `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/consent-success`, /** * Probe whether the app's core validation permission is already effectively granted in * the tenant (pre-approved by someone with consent rights), so a rights-less admin can be @@ -163,16 +163,16 @@ export const api = { * { accessPresent, isTransient, requiredPermission }. */ autopilotAccessCheck: (tenantId: string) => - `${API_BASE_URL}/api/config/${tenantId}/autopilot-device-validation/access-check`, + `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/autopilot-device-validation/access-check`, /** * POST — dual app-reg homing flip: { target: "primary" | "legacy", force?: boolean }. * Tenant admins: target "primary" only, no force, kill-switch flag on, consent probe must * pass. Global Admins: both directions, force skips the probe. */ appHoming: (tenantId: string) => - `${API_BASE_URL}/api/config/${tenantId}/app-homing`, + `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/app-homing`, testNotification: (tenantId: string) => - `${API_BASE_URL}/api/config/${tenantId}/test-notification`, + `${API_BASE_URL}/api/config/${encodeURIComponent(tenantId)}/test-notification`, latestVersions: (opts?: { refresh?: boolean }) => `${API_BASE_URL}/api/config/latest-versions${qs({ refresh: opts?.refresh ? "true" : undefined })}`, }, @@ -181,13 +181,13 @@ export const api = { graphPermissions: { /** GET status of optional Graph add-on permissions for the tenant (admin-only). */ status: (tenantId: string) => - `${API_BASE_URL}/api/tenants/${tenantId}/graph-permissions/status`, + `${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/graph-permissions/status`, /** POST to invalidate the backend's cached roles after a grant script run. */ refresh: (tenantId: string) => - `${API_BASE_URL}/api/tenants/${tenantId}/graph-permissions/refresh`, + `${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/graph-permissions/refresh`, /** POST endpoint to resolve Intune script display names; body carries the refs array. */ scriptDisplayNames: (tenantId: string) => - `${API_BASE_URL}/api/tenants/${tenantId}/scripts/display-names`, + `${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/scripts/display-names`, }, // ── Global Config (global admin) ────────────────────────────────────────── @@ -201,17 +201,17 @@ export const api = { // ── Tenants ─────────────────────────────────────────────────────────────── tenants: { admins: (tenantId: string) => - `${API_BASE_URL}/api/tenants/${tenantId}/admins`, + `${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins`, admin: (tenantId: string, adminUpn: string) => - `${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}`, + `${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}`, adminAction: (tenantId: string, adminUpn: string, action: string) => - `${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/${action}`, + `${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/${encodeURIComponent(action)}`, adminPermissions: (tenantId: string, adminUpn: string) => - `${API_BASE_URL}/api/tenants/${tenantId}/admins/${encodeURIComponent(adminUpn)}/permissions`, + `${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/admins/${encodeURIComponent(adminUpn)}/permissions`, offboard: (tenantId: string) => - `${API_BASE_URL}/api/tenants/${tenantId}/offboard`, + `${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard`, offboardFeedback: (tenantId: string) => - `${API_BASE_URL}/api/tenants/${tenantId}/offboard/feedback`, + `${API_BASE_URL}/api/tenants/${encodeURIComponent(tenantId)}/offboard/feedback`, }, // ── Devices ─────────────────────────────────────────────────────────────── @@ -248,7 +248,7 @@ export const api = { gather: (tenantId?: string) => `${API_BASE_URL}/api/rules/gather${qs({ tenantId })}`, gatherRule: (ruleId: string, tenantId?: string) => - `${API_BASE_URL}/api/rules/gather/${ruleId}${qs({ tenantId })}`, + `${API_BASE_URL}/api/rules/gather/${encodeURIComponent(ruleId)}${qs({ tenantId })}`, globalGather: (tenantId?: string) => `${API_BASE_URL}/api/global/rules/gather${qs({ tenantId })}`, // Cross-tenant edit/toggle/delete of an existing gather rule (Global Admin override) — see globalAnalyzeRule. @@ -406,7 +406,7 @@ export const api = { lookup: (tenantId: string, search: string) => `${API_BASE_URL}/api/progress/sessions/lookup${qs({ tenantId, search })}`, sessionEvents: (sessionId: string, tenantId: string, serial: string) => - `${API_BASE_URL}/api/progress/sessions/${sessionId}/events${qs({ tenantId, serial })}`, + `${API_BASE_URL}/api/progress/sessions/${encodeURIComponent(sessionId)}/events${qs({ tenantId, serial })}`, }, // ── Bootstrap ───────────────────────────────────────────────────────────── @@ -414,7 +414,7 @@ export const api = { sessions: (tenantId?: string) => `${API_BASE_URL}/api/bootstrap/sessions${qs({ tenantId })}`, session: (code: string, tenantId: string) => - `${API_BASE_URL}/api/bootstrap/sessions/${code}${qs({ tenantId })}`, + `${API_BASE_URL}/api/bootstrap/sessions/${encodeURIComponent(code)}${qs({ tenantId })}`, }, // ── Annotations (tenant-scoped overview list) ───────────────────────────── @@ -467,7 +467,7 @@ export const api = { downloadUrl: (blobName: string) => `${API_BASE_URL}/api/global/session-reports/download-url${qs({ blobName })}`, note: (reportId: string) => - `${API_BASE_URL}/api/global/session-reports/${reportId}/note`, + `${API_BASE_URL}/api/global/session-reports/${encodeURIComponent(reportId)}/note`, }, // ── Diag Files Reports (no session context) ─────────────────────────────── @@ -553,11 +553,11 @@ export const api = { // ── Notifications ───────────────────────────────────────────────────────── notifications: { list: () => `${API_BASE_URL}/api/global/notifications`, - dismiss: (id: string) => `${API_BASE_URL}/api/global/notifications/${id}/dismiss`, + dismiss: (id: string) => `${API_BASE_URL}/api/global/notifications/${encodeURIComponent(id)}/dismiss`, dismissAll: () => `${API_BASE_URL}/api/global/notifications/dismiss-all`, // Tenant-scoped persistent notifications (bell). TenantId resolved server-side from JWT. tenantList: () => `${API_BASE_URL}/api/notifications`, - tenantDismiss: (id: string) => `${API_BASE_URL}/api/notifications/${id}/dismiss`, + tenantDismiss: (id: string) => `${API_BASE_URL}/api/notifications/${encodeURIComponent(id)}/dismiss`, tenantDismissAll: () => `${API_BASE_URL}/api/notifications/dismiss-all`, }, @@ -608,12 +608,12 @@ export const api = { preview: { whitelist: () => `${API_BASE_URL}/api/preview/whitelist`, whitelistTenant: (tenantId: string) => - `${API_BASE_URL}/api/preview/whitelist/${tenantId}`, + `${API_BASE_URL}/api/preview/whitelist/${encodeURIComponent(tenantId)}`, sendWelcomeEmail: (tenantId: string) => - `${API_BASE_URL}/api/preview/send-welcome-email/${tenantId}`, + `${API_BASE_URL}/api/preview/send-welcome-email/${encodeURIComponent(tenantId)}`, notificationEmail: () => `${API_BASE_URL}/api/preview/notification-email`, notificationEmailTenant: (tenantId: string) => - `${API_BASE_URL}/api/preview/notification-email/${tenantId}`, + `${API_BASE_URL}/api/preview/notification-email/${encodeURIComponent(tenantId)}`, /** GA/Reader: every stored notification address, keyed by lowercased tenant id. */ notificationEmails: () => `${API_BASE_URL}/api/preview/notification-emails`, }, @@ -721,13 +721,13 @@ export const api = { // ── Email Templates (GA) ────────────────────────────────────────────────── emailTemplates: { get: (kind: "welcome" | "farewell") => - `${API_BASE_URL}/api/global/email-templates/${kind}`, + `${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`, save: (kind: "welcome" | "farewell") => - `${API_BASE_URL}/api/global/email-templates/${kind}`, + `${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`, reset: (kind: "welcome" | "farewell") => - `${API_BASE_URL}/api/global/email-templates/${kind}`, + `${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}`, sendTest: (kind: "welcome" | "farewell") => - `${API_BASE_URL}/api/global/email-templates/${kind}/test`, + `${API_BASE_URL}/api/global/email-templates/${encodeURIComponent(kind)}/test`, }, // ── Maintenance ───────────────────────────────────────────────────────────