diff --git a/src/components/session/authenticatedRequest.vue b/src/components/session/authenticatedRequest.vue index a394c8ae..81cff26d 100644 --- a/src/components/session/authenticatedRequest.vue +++ b/src/components/session/authenticatedRequest.vue @@ -2,6 +2,7 @@ import axios from 'axios' import { enqueueRequest } from "@/services/requestQueue.js"; import { buildCurrentReleaseHeaders, resolveReleaseApiUrl } from "@/services/releaseTimeline.js"; +import { isTrustedReleaseUrl } from "@/services/releaseTrust.js"; /** * Get the selected customer number for X-Customer-Number header (used by subusers) @@ -18,22 +19,24 @@ export const authenticatedRequest = (url, method, data, catchCallable = null, th //throw new Error('No token was found, unable to make authenticated request'); } + const requestUrl = resolveReleaseApiUrl(url); + const canSendCredentials = isTrustedReleaseUrl(requestUrl); + // Build headers const headers = { ...buildCurrentReleaseHeaders(), }; - if (token && token.length > 0) { + if (canSendCredentials && token && token.length > 0) { headers.Authorization = `Bearer ${token}`; } // Add X-Customer-Number header if subuser has selected a grant const isSubuser = localStorage.getItem('is_subuser') === 'true'; const selectedCustomerNumber = getSelectedCustomerNumber(); - if (isSubuser && selectedCustomerNumber) { + if (canSendCredentials && isSubuser && selectedCustomerNumber) { headers['X-Customer-Number'] = selectedCustomerNumber; } - const requestUrl = resolveReleaseApiUrl(url); return enqueueRequest( () => axios({ method, diff --git a/src/config.js b/src/config.js index c08cac00..2f4b2910 100644 --- a/src/config.js +++ b/src/config.js @@ -29,6 +29,7 @@ const normalizeReleaseSource = (value) => { }; export const RELEASE_SOURCE_ENV = String(import.meta.env.VITE_RELEASE_SOURCE || "").trim().toLowerCase(); export const RELEASE_SOURCE = normalizeReleaseSource(RELEASE_SOURCE_ENV); +export const DEFAULT_LEGACY_API_URL = "https://api.truckwash.io"; export const DEFAULT_PUBLIC_GATEWAY_API_URL = "https://api-v2.truckwash.io"; export const DEFAULT_STABLE_API_URL = `${DEFAULT_PUBLIC_GATEWAY_API_URL}/master/api`; @@ -50,6 +51,10 @@ export const RELEASE_MANAGER_CONTROL_API_FALLBACK_URLS = String( .split(",") .map((url) => normalizeApiUrl(url.trim())) .filter(Boolean); +export const RELEASE_TRUSTED_ORIGINS = String(import.meta.env.VITE_RELEASE_TRUSTED_ORIGINS || "") + .split(",") + .map((origin) => origin.trim().replace(/\/+$/, "")) + .filter(Boolean); // Allowed origins export const ALLOWED_ORIGINS = [ diff --git a/src/services/releaseBootstrap.js b/src/services/releaseBootstrap.js index bac40976..1aa95fe1 100644 --- a/src/services/releaseBootstrap.js +++ b/src/services/releaseBootstrap.js @@ -1,5 +1,6 @@ import { API_URL, IS_DEV, RELEASE_MANAGER_CONTROL_API_URL, RELEASE_SOURCE, RELEASE_SOURCE_ENV } from "@/config.js"; import { buildReleaseHeaders } from "@/services/releaseHeaders.js"; +import { isTrustedReleaseUrl, sameOriginReleaseUrl } from "@/services/releaseTrust.js"; export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__"; export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug"; @@ -172,7 +173,8 @@ export const fetchReleaseRuntime = async ({ const runtimeFrontendBaseUrl = (runtime = {}) => { const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {}; - return normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || ""); + const frontendBaseUrl = normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || ""); + return isTrustedReleaseUrl(frontendBaseUrl, { allowRelative: false }) ? frontendBaseUrl : ""; }; const runtimeChannel = (runtime = {}) => runtime?.channel || {}; @@ -190,8 +192,14 @@ export const shouldLoadRemoteRelease = (runtime = {}) => { export const releaseEntryUrl = (frontendBaseUrl) => new URL(RELEASE_ENTRY_FILENAME, `${normalizeBaseUrl(frontendBaseUrl)}/`).href; -const resolveReleaseAssetUrl = (frontendBaseUrl, value) => - new URL(String(value || "").replace(/^\/+/, ""), `${normalizeBaseUrl(frontendBaseUrl)}/`).href; +const resolveReleaseAssetUrl = (frontendBaseUrl, value) => { + const baseUrl = normalizeBaseUrl(frontendBaseUrl); + const resolvedUrl = new URL(String(value || "").replace(/^\/+/, ""), `${baseUrl}/`).href; + if (!sameOriginReleaseUrl(resolvedUrl, baseUrl)) { + throw new Error("Release entry asset URL is not on the trusted release frontend origin."); + } + return resolvedUrl; +}; export const loadRemoteReleaseEntry = async ({ runtime, @@ -200,6 +208,9 @@ export const loadRemoteReleaseEntry = async ({ importModule = (url) => import(/* @vite-ignore */ url), } = {}) => { const frontendBaseUrl = runtimeFrontendBaseUrl(runtime); + if (!frontendBaseUrl) { + throw new Error("Release frontend URL is not trusted."); + } const response = await fetchFn(releaseEntryUrl(frontendBaseUrl), { method: "GET", cache: "no-store", diff --git a/src/services/releaseTimeline.js b/src/services/releaseTimeline.js index edf4b5dd..cb5247f0 100644 --- a/src/services/releaseTimeline.js +++ b/src/services/releaseTimeline.js @@ -1,5 +1,6 @@ import { reactive, readonly } from "vue"; import { API_URL } from "@/config.js"; +import { isTrustedReleaseUrl } from "@/services/releaseTrust.js"; import { buildReleaseHeaders, RELEASE_TRACE_STORAGE_KEY } from "@/services/releaseHeaders.js"; const TRACE_STORAGE_KEY = RELEASE_TRACE_STORAGE_KEY; @@ -97,9 +98,9 @@ const normalizeReadinessMissingValues = (missing = []) => ) ); -const normalizeRuntimeBaseUrl = (value) => { +const normalizeRuntimeBaseUrl = (value, { allowRelative = true } = {}) => { const raw = String(value || "").trim().replace(/\/+$/, ""); - if (!raw) { + if (!raw || !isTrustedReleaseUrl(raw, { allowRelative })) { return null; } if (raw.startsWith("/") && !raw.startsWith("//")) { @@ -114,7 +115,9 @@ const normalizeRuntimeBaseUrl = (value) => { const runtimeUrls = (runtime = {}) => { const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {}; return { - frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url), + frontendBaseUrl: normalizeRuntimeBaseUrl(runtime.frontend_base_url ?? urls.frontend_base_url, { + allowRelative: false, + }), apiBaseUrl: normalizeRuntimeBaseUrl(runtime.api_base_url ?? urls.api_base_url), }; }; @@ -421,7 +424,9 @@ const firstFilledString = (...values) => { const releaseRuntimeUrlsForDisplay = (runtime = {}) => { const urls = isPlainRecord(runtime?.urls) ? runtime.urls : {}; return { - frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url), + frontend: normalizeRuntimeBaseUrl(runtime.frontendBaseUrl ?? runtime.frontend_base_url ?? urls.frontend_base_url, { + allowRelative: false, + }), api: normalizeRuntimeBaseUrl(runtime.apiBaseUrl ?? runtime.api_base_url ?? urls.api_base_url), }; }; diff --git a/src/services/releaseTrust.js b/src/services/releaseTrust.js new file mode 100644 index 00000000..f10fca8f --- /dev/null +++ b/src/services/releaseTrust.js @@ -0,0 +1,69 @@ +import { + ALLOWED_ORIGINS, + API_URL, + DEFAULT_LEGACY_API_URL, + DEFAULT_PUBLIC_GATEWAY_API_URL, + DEFAULT_STABLE_API_URL, + RELEASE_MANAGER_CONTROL_API_URL, + RELEASE_PUBLIC_GATEWAY_API_URL, + RELEASE_TRUSTED_ORIGINS, +} from "@/config.js"; + +const normalizeOrigin = (value) => { + const raw = String(value || "").trim(); + if (!raw) { + return ""; + } + try { + return new URL(raw).origin; + } catch { + return ""; + } +}; + +const browserOrigin = () => { + if (typeof window !== "undefined" && window.location?.origin) { + return window.location.origin; + } + return ""; +}; + +const configuredTrustedOrigins = () => + [ + browserOrigin(), + ...ALLOWED_ORIGINS, + ...RELEASE_TRUSTED_ORIGINS, + API_URL, + RELEASE_MANAGER_CONTROL_API_URL, + RELEASE_PUBLIC_GATEWAY_API_URL, + DEFAULT_LEGACY_API_URL, + DEFAULT_PUBLIC_GATEWAY_API_URL, + DEFAULT_STABLE_API_URL, + ] + .map(normalizeOrigin) + .filter(Boolean); + +export const trustedReleaseOrigins = () => Array.from(new Set(configuredTrustedOrigins())); + +export const isTrustedReleaseOrigin = (value) => { + const origin = normalizeOrigin(value); + return Boolean(origin) && trustedReleaseOrigins().includes(origin); +}; + +export const isRelativeReleaseUrl = (value) => { + const raw = String(value || "").trim(); + return Boolean(raw) && raw.startsWith("/") && !raw.startsWith("//"); +}; + +export const isTrustedReleaseUrl = (value, { allowRelative = true } = {}) => { + if (allowRelative && isRelativeReleaseUrl(value)) { + return true; + } + return isTrustedReleaseOrigin(value); +}; + +export const sameOriginReleaseUrl = (value, expectedBaseUrl) => { + const resolvedOrigin = normalizeOrigin(value); + const expectedOrigin = normalizeOrigin(expectedBaseUrl); + return Boolean(resolvedOrigin && expectedOrigin && resolvedOrigin === expectedOrigin); +}; diff --git a/tests/unit/authenticated-request.spec.js b/tests/unit/authenticated-request.spec.js index 48687819..7db2a6db 100644 --- a/tests/unit/authenticated-request.spec.js +++ b/tests/unit/authenticated-request.spec.js @@ -11,6 +11,7 @@ vi.mock("axios", () => ({ })); import { authenticatedRequest } from "@/components/session/authenticatedRequest.vue"; +import { __resetReleaseTimelineForTests, configureReleaseRuntime } from "@/services/releaseTimeline.js"; import { __configureRequestQueueForTests, __resetRequestQueueForTests, @@ -43,6 +44,7 @@ describe("authenticatedRequest", () => { beforeEach(() => { axiosMock.mockReset(); __resetRequestQueueForTests(); + __resetReleaseTimelineForTests(); __configureRequestQueueForTests({ maxConcurrentGet: 1, maxConcurrentOther: 1, @@ -102,6 +104,26 @@ describe("authenticatedRequest", () => { expect(catchCallable).toHaveBeenCalledWith(error); }); + it("does not send bearer credentials to untrusted absolute request URLs", async () => { + const response = { status: 200, data: { ok: true } }; + axiosMock.mockResolvedValueOnce(response); + localStorage.setItem("is_subuser", "true"); + localStorage.setItem("selected_customer_number", "1234"); + configureReleaseRuntime({ api_base_url: "https://attacker.example/api" }); + + await authenticatedRequest("https://attacker.example/api/orders", "GET"); + + expect(axiosMock).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://attacker.example/api/orders", + headers: expect.not.objectContaining({ + Authorization: "Bearer token", + "X-Customer-Number": 1234, + }), + }) + ); + }); + it("queues requests and executes them sequentially", async () => { const first = createDeferred(); const second = createDeferred(); diff --git a/tests/unit/release-bootstrap.spec.js b/tests/unit/release-bootstrap.spec.js index 2c6a3cd0..96421ee0 100644 --- a/tests/unit/release-bootstrap.spec.js +++ b/tests/unit/release-bootstrap.spec.js @@ -163,6 +163,18 @@ describe("release bootstrap", () => { expect(shouldLoadRemoteRelease(null)).toBe(false); }); + it("does not load a remote release from an untrusted frontend origin", () => { + const runtime = { + channel: { slug: "canary", default_channel: false }, + availability: { configured: true }, + urls: { + frontend_base_url: "https://attacker.example/canary/frontend", + }, + }; + + expect(shouldLoadRemoteRelease(runtime)).toBe(false); + }); + it("loads a non-default release entry without changing the browser URL", async () => { const runtime = { channel: { slug: "canary", default_channel: false }, @@ -241,6 +253,25 @@ describe("release bootstrap", () => { }); }); + it("rejects release entry assets outside the frontend origin", async () => { + const fetchFn = vi.fn(async () => ({ + ok: true, + json: async () => ({ + entry: "https://attacker.example/assets/index-canary.js", + css: [], + }), + })); + const importModule = vi.fn(async () => ({})); + const runtime = { + frontend_base_url: "https://api-v2.truckwash.io/canary/frontend", + }; + + await expect(loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef: document })).rejects.toThrow( + "Release entry asset URL is not on the trusted release frontend origin." + ); + expect(importModule).not.toHaveBeenCalled(); + }); + it("injects release entry CSS only once", async () => { const fetchFn = vi.fn(async () => ({ ok: true, diff --git a/tests/unit/release-timeline.spec.js b/tests/unit/release-timeline.spec.js index 6ec05d38..987e464c 100644 --- a/tests/unit/release-timeline.spec.js +++ b/tests/unit/release-timeline.spec.js @@ -212,6 +212,26 @@ describe("release timeline runtime", () => { expect(resolveReleaseApiUrl("/orders")).toBe("https://api-v2.truckwash.io/canary/api/orders"); }); + it("ignores untrusted runtime frontend and API URLs", () => { + configureReleaseRuntime({ + trace_id: "trace-untrusted", + channel: { slug: "canary", name: "Canary" }, + versions: { + frontend: { version_label: "frontend-canary" }, + api: { version_label: "api-canary" }, + bundle_id: 31, + }, + urls: { + frontend_base_url: "https://attacker.example/frontend", + api_base_url: "https://attacker.example/api", + }, + }); + + expect(releaseRuntimeState.frontendBaseUrl).toBeNull(); + expect(releaseRuntimeState.apiBaseUrl).toBeNull(); + expect(resolveReleaseApiUrl("/orders")).not.toBe("https://attacker.example/api/orders"); + }); + it("keeps same-origin release API URLs visible in the session summary", () => { configureReleaseRuntime({ trace_id: "trace-local-api",