Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion packages/cli/src/lib/region.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -58,6 +59,31 @@ export function resolveOrgRegion(orgSlug: string): Promise<string> {
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.
Expand Down Expand Up @@ -85,7 +111,14 @@ async function resolveOrgRegionUncached(orgSlug: string): Promise<string> {
throw response.error;
}

const regionUrl = response.data?.links?.regionUrl || baseUrl;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cached region URLs skip validation

Medium Severity

resolveOrgRegionUncached returns a cached regionUrl without running toAbsoluteRegionUrl. A relative value already stored in the region cache, or one written by listOrganizationsUncached, is still used as a fetch base and reproduces the original URL parse failure.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 791579b. Configure here.

// 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
Expand Down
72 changes: 72 additions & 0 deletions packages/cli/test/lib/region.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading