Harden release runtime URL trust boundaries

This commit is contained in:
Jeppe B
2026-06-01 21:14:39 +02:00
parent 13b0742deb
commit c47bf229f0
8 changed files with 176 additions and 10 deletions
@@ -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,
+5
View File
@@ -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 = [
+14 -3
View File
@@ -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",
+9 -4
View File
@@ -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),
};
};
+69
View File
@@ -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);
};
+22
View File
@@ -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();
+31
View File
@@ -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,
+20
View File
@@ -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",