diff --git a/packages/cli/src/lib/region.ts b/packages/cli/src/lib/region.ts index aa98af4b4..a79341829 100644 --- a/packages/cli/src/lib/region.ts +++ b/packages/cli/src/lib/region.ts @@ -10,6 +10,7 @@ import { getConfiguredSentryUrl } from "./constants.js"; import { getOrgByNumericId, getOrgRegion, setOrgRegion } from "./db/regions.js"; import { stripDsnOrgPrefix } from "./dsn/index.js"; import { withAuthGuard } from "./errors.js"; +import { logger } from "./logger.js"; import { getSdkConfig } from "./sentry-client.js"; import { getSentryBaseUrl, isSentrySaasUrl } from "./sentry-urls.js"; @@ -58,6 +59,31 @@ export function resolveOrgRegion(orgSlug: string): Promise { return promise; } +/** + * Coerce a regionUrl from the API into an absolute URL. + * + * Self-hosted instances may return a relative regionUrl (e.g. "/") which is + * truthy but breaks fetch calls that depend on an absolute base URL. Resolve + * a relative value against baseUrl so it becomes absolute instead of being + * discarded; an already-absolute value is returned unchanged. + */ +function toAbsoluteRegionUrl(rawRegionUrl: string, baseUrl: string): string { + // Already absolute — use verbatim. + if (URL.canParse(rawRegionUrl)) { + return rawRegionUrl; + } + + // Relative (e.g. "/") — resolve against baseUrl to get an absolute origin. + if (URL.canParse(rawRegionUrl, baseUrl)) { + return new URL(rawRegionUrl, baseUrl).origin; + } + + logger.debug( + `regionUrl "${rawRegionUrl}" from API could not be resolved to an absolute URL; falling back to baseUrl` + ); + return baseUrl; +} + /** * Resolve org region from SQLite cache or API. * Called at most once per orgSlug per process lifetime. @@ -85,7 +111,14 @@ async function resolveOrgRegionUncached(orgSlug: string): Promise { throw response.error; } - const regionUrl = response.data?.links?.regionUrl || baseUrl; + // Self-hosted instances may return a relative regionUrl (e.g. "/") which + // is truthy but would break fetch calls that depend on an absolute base + // URL. Resolve it against baseUrl so a relative value becomes absolute + // instead of being discarded; keep an already-absolute value as-is. + const rawRegionUrl = response.data?.links?.regionUrl; + const regionUrl = rawRegionUrl + ? toAbsoluteRegionUrl(rawRegionUrl, baseUrl) + : baseUrl; // Cache for future use. setOrgRegion also extends the in-process // trust class so the subsequent request to this region passes the diff --git a/packages/cli/test/lib/region.test.ts b/packages/cli/test/lib/region.test.ts index 10d66ff55..dcbd1f67b 100644 --- a/packages/cli/test/lib/region.test.ts +++ b/packages/cli/test/lib/region.test.ts @@ -203,6 +203,78 @@ describe("resolveOrgRegion", () => { } }); + test("resolves a relative regionUrl against baseUrl", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/organizations/relative-region-org/")) { + return new Response( + JSON.stringify({ + id: "789", + slug: "relative-region-org", + name: "Self-hosted Org", + links: { + organizationUrl: "/organizations/relative-region-org/", + // Self-hosted instance returns a relative path instead of an absolute URL + regionUrl: "/", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + try { + const regionUrl = await resolveOrgRegion("relative-region-org"); + // Relative "/" resolves to the base origin, producing an absolute URL + // instead of a broken relative value. + expect(regionUrl).toBe("https://sentry.io"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("falls back to baseUrl when API returns a malformed regionUrl", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url.includes("/organizations/malformed-region-org/")) { + return new Response( + JSON.stringify({ + id: "790", + slug: "malformed-region-org", + name: "Self-hosted Org 2", + links: { + organizationUrl: "/organizations/malformed-region-org/", + regionUrl: "not-a-valid-url", + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + } + ); + } + return new Response(JSON.stringify({ detail: "Not found" }), { + status: 404, + }); + }; + + try { + // A path-like relative value resolves against the base origin. + const regionUrl = await resolveOrgRegion("malformed-region-org"); + expect(regionUrl).toBe("https://sentry.io"); + } finally { + globalThis.fetch = originalFetch; + } + }); + test("falls back to default URL when API call fails", async () => { // Mock fetch to fail const originalFetch = globalThis.fetch;