Harden release runtime URL trust boundaries
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user