From a206057a34dcf78d9b9cd6f549e42700c2820923 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Wed, 7 Oct 2026 21:29:07 +0000 Subject: [PATCH] feat(core): Share strict Sentry origin checks Co-Authored-By: GPT-6 Sol --- docs/contributing/api-patterns.md | 5 +++ packages/cli/src/commands/auth/login.ts | 3 +- packages/cli/src/commands/cli/import.ts | 6 ++-- .../cli/src/lib/init/init-service-auth.ts | 3 +- packages/cli/src/lib/login-host-guard.ts | 6 ++-- packages/cli/src/lib/sentry-url-parser.ts | 2 +- packages/cli/src/lib/sentry-urls.ts | 35 ++----------------- packages/cli/src/lib/sentryclirc-import.ts | 3 +- packages/cli/src/lib/sentryclirc.ts | 2 +- packages/cli/src/lib/token-host.ts | 23 +++++++++--- packages/cli/test/lib/token-host.test.ts | 15 ++++++++ packages/cli/test/script/cli-startup.test.ts | 7 ++-- .../tool-helpers/validate-region-url.test.ts | 15 ++++++++ .../tool-helpers/validate-region-url.ts | 20 ++++++++--- .../catalog/get-event-attachment.test.ts | 4 +-- .../tools/catalog/get-issue-details.test.ts | 4 +-- packages/toolkit-core/README.md | 7 ++-- packages/toolkit-core/package.json | 4 +++ .../toolkit-core/src/sentry-origin.test.ts | 26 ++++++++++++++ packages/toolkit-core/src/sentry-origin.ts | 17 +++++++++ 20 files changed, 140 insertions(+), 67 deletions(-) create mode 100644 packages/toolkit-core/src/sentry-origin.test.ts create mode 100644 packages/toolkit-core/src/sentry-origin.ts diff --git a/docs/contributing/api-patterns.md b/docs/contributing/api-patterns.md index d4930e8f5..9611d5da2 100644 --- a/docs/contributing/api-patterns.md +++ b/docs/contributing/api-patterns.md @@ -79,6 +79,11 @@ User identity (`/api/0/auth/`, used by `whoami`) follows the same control-host routing. Organization-scoped requests continue to use the configured host or a validated `regionUrl`. +Region URL checks reject embedded URL credentials. SaaS region hosts also need +HTTPS, the default port, and an explicit entry in the MCP region allowlist; +self-hosted region hosts must match the configured host and port exactly. The +shared `isSaaSTrustOrigin` check never replaces those product-specific rules. + Web links use `.sentry.io` for public SaaS. Single-tenant and self-hosted links keep the configured host and `/organizations/` path prefix. Use `isPublicSentryHost` for these routing decisions; diff --git a/packages/cli/src/commands/auth/login.ts b/packages/cli/src/commands/auth/login.ts index eb358d1d8..8b911319c 100644 --- a/packages/cli/src/commands/auth/login.ts +++ b/packages/cli/src/commands/auth/login.ts @@ -1,4 +1,5 @@ import { isatty } from "node:tty"; +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import type { SentryContext } from "../../context.js"; import { getCurrentUser, @@ -45,7 +46,7 @@ import { } from "../../lib/login-host-guard.js"; import { resolveOAuthScopeString } from "../../lib/oauth.js"; import { clearResponseCache } from "../../lib/response-cache.js"; -import { isSaaSTrustOrigin, normalizeOrigin } from "../../lib/sentry-urls.js"; +import { normalizeOrigin } from "../../lib/sentry-urls.js"; import { loadSentryCliRc, type SentryCliRcConfig, diff --git a/packages/cli/src/commands/cli/import.ts b/packages/cli/src/commands/cli/import.ts index 83baf3e12..6518e9b67 100644 --- a/packages/cli/src/commands/cli/import.ts +++ b/packages/cli/src/commands/cli/import.ts @@ -11,6 +11,7 @@ */ import { isatty } from "node:tty"; +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { getDefaultUrl } from "../../lib/db/defaults.js"; @@ -20,10 +21,7 @@ import { renderMarkdown } from "../../lib/formatters/markdown.js"; import { CommandOutput } from "../../lib/formatters/output.js"; import { logger } from "../../lib/logger.js"; import { DRY_RUN_FLAG } from "../../lib/mutate-command.js"; -import { - isSaaSTrustOrigin, - normalizeUserInputToOrigin, -} from "../../lib/sentry-urls.js"; +import { normalizeUserInputToOrigin } from "../../lib/sentry-urls.js"; import type { DiscoveredRcFile, ImportPlan, diff --git a/packages/cli/src/lib/init/init-service-auth.ts b/packages/cli/src/lib/init/init-service-auth.ts index a1ed99732..c08008642 100644 --- a/packages/cli/src/lib/init/init-service-auth.ts +++ b/packages/cli/src/lib/init/init-service-auth.ts @@ -1,7 +1,8 @@ import { MastraClientError } from "@mastra/client-js"; +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { enrich401Detail } from "../api/infrastructure.js"; import { ApiError, HostScopeError } from "../errors.js"; -import { isSaaSTrustOrigin, normalizeOrigin } from "../sentry-urls.js"; +import { normalizeOrigin } from "../sentry-urls.js"; import { getActiveTokenHost } from "../token-host.js"; import { DEFAULT_MASTRA_API_URL, diff --git a/packages/cli/src/lib/login-host-guard.ts b/packages/cli/src/lib/login-host-guard.ts index 913238575..587807e21 100644 --- a/packages/cli/src/lib/login-host-guard.ts +++ b/packages/cli/src/lib/login-host-guard.ts @@ -14,14 +14,12 @@ * unconfirmed self-hosted login that `sentry auth login` would have refused. */ +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { DEFAULT_SENTRY_URL } from "./constants.js"; import { getStoredAuthHost } from "./db/auth.js"; import { getDefaultUrl } from "./db/defaults.js"; import { getEnv } from "./env.js"; -import { - isSaaSTrustOrigin, - normalizeUserInputToOrigin, -} from "./sentry-urls.js"; +import { normalizeUserInputToOrigin } from "./sentry-urls.js"; import { isHostTrusted, isLoginTrustAnchorFor } from "./token-host.js"; /** diff --git a/packages/cli/src/lib/sentry-url-parser.ts b/packages/cli/src/lib/sentry-url-parser.ts index 1d3e32e2a..8081e9582 100644 --- a/packages/cli/src/lib/sentry-url-parser.ts +++ b/packages/cli/src/lib/sentry-url-parser.ts @@ -8,12 +8,12 @@ * so that subsequent API calls reach the correct instance. */ +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { DEFAULT_SENTRY_HOST } from "./constants.js"; import { getEnv } from "./env.js"; import { HostScopeError } from "./errors.js"; import { tryNormalizeHexId } from "./hex-id.js"; import { logger } from "./logger.js"; -import { isSaaSTrustOrigin } from "./sentry-urls.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; const log = logger.withTag("url-parser"); diff --git a/packages/cli/src/lib/sentry-urls.ts b/packages/cli/src/lib/sentry-urls.ts index 242bfbc72..de5b94e1a 100644 --- a/packages/cli/src/lib/sentry-urls.ts +++ b/packages/cli/src/lib/sentry-urls.ts @@ -56,7 +56,8 @@ export function getOrgBaseUrl(orgSlug: string): string { * Resolves the configured base URL (env `SENTRY_HOST`/`SENTRY_URL`, else the * default SaaS URL) and applies the hostname-only {@link isSentrySaasUrl} * check. Intended for routing/UX decisions (e.g. choosing a SaaS-only default), - * NOT for credential-trust decisions — use {@link isSaaSTrustOrigin} for those. + * NOT for credential-trust decisions — use the shared + * `@sentry/toolkit-core/sentry-origin` helper for those. * * @returns true when the active base URL is sentry.io or a subdomain of it */ @@ -75,7 +76,7 @@ export function isSaaS(): boolean { * routing (test harnesses occasionally use these). * * For TRUST decisions (deciding whether a SaaS-scoped token is valid for - * a given origin), use {@link isSaaSTrustOrigin} which additionally + * a given origin), use `@sentry/toolkit-core/sentry-origin`, which additionally * requires https scheme and default port. * * @param url - URL string to validate @@ -91,36 +92,6 @@ export function isSentrySaasUrl(url: string): boolean { } } -/** - * Check if a URL is a Sentry SaaS origin for TRUST purposes. - * - * Stricter than {@link isSentrySaasUrl}: additionally requires - * - scheme = `https:` (production SaaS is HTTPS-only; `http://sentry.io` - * is never legitimate and a crafted plaintext URL must NOT inherit - * SaaS trust) - * - port = default (empty `port` in WHATWG URL means the scheme's - * default port; any explicit non-default port indicates either a - * crafted URL or DNS redirect we don't trust) - * - * Used by the host-scoping trust check (`token-host.ts::isHostTrusted`) - * to decide SaaS equivalence. Keep in sync with {@link isSentrySaasUrl} - * when adding new trust classes. - * - * @param url - URL string to validate - * @returns true only if the URL is a strictly-SaaS origin - */ -export function isSaaSTrustOrigin(url: string): boolean { - // oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing. - try { - const parsed = new URL(url); - return ( - parsed.protocol === "https:" && parsed.port === "" && isSentrySaasUrl(url) - ); - } catch { - return false; - } -} - /** * Normalize a URL (or fetch input) to its canonical origin * (`scheme://host[:port]`). Returns `undefined` for inputs that don't parse diff --git a/packages/cli/src/lib/sentryclirc-import.ts b/packages/cli/src/lib/sentryclirc-import.ts index b1791e793..95efa022e 100644 --- a/packages/cli/src/lib/sentryclirc-import.ts +++ b/packages/cli/src/lib/sentryclirc-import.ts @@ -30,6 +30,7 @@ import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js"; import { clearAuth, @@ -49,7 +50,7 @@ import { setUserInfo } from "./db/user.js"; import { clearMetadata, getMetadata, setMetadata } from "./db/utils.js"; import { parseIni } from "./ini.js"; import { logger } from "./logger.js"; -import { isSaaSTrustOrigin, normalizeOrigin } from "./sentry-urls.js"; +import { normalizeOrigin } from "./sentry-urls.js"; import { CONFIG_FILENAME, getGlobalPaths, diff --git a/packages/cli/src/lib/sentryclirc.ts b/packages/cli/src/lib/sentryclirc.ts index 6aeaf0eb5..2a8567bac 100644 --- a/packages/cli/src/lib/sentryclirc.ts +++ b/packages/cli/src/lib/sentryclirc.ts @@ -19,13 +19,13 @@ import { readFile, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { join } from "node:path"; +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { normalizeUrl } from "./constants.js"; import { getConfigDir } from "./db/index.js"; import { getEnv } from "./env.js"; import { HostScopeError } from "./errors.js"; import { parseIni } from "./ini.js"; import { logger } from "./logger.js"; -import { isSaaSTrustOrigin } from "./sentry-urls.js"; import { getActiveTokenHost, isHostTrusted } from "./token-host.js"; import { walkUpFrom } from "./walk-up.js"; diff --git a/packages/cli/src/lib/token-host.ts b/packages/cli/src/lib/token-host.ts index b1158bc7b..ad8063a13 100644 --- a/packages/cli/src/lib/token-host.ts +++ b/packages/cli/src/lib/token-host.ts @@ -15,13 +15,26 @@ * `sentry.acme.evil.com`). */ +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { getActiveAuthHost, getCredentialContext, getIdentityFingerprint, } from "./db/auth.js"; import { isTrustedRegionOrigin } from "./db/regions.js"; -import { isSaaSTrustOrigin, normalizeOrigin } from "./sentry-urls.js"; +import { normalizeHttpOrigin } from "./sentry-urls.js"; + +function normalizeTrustOrigin( + input: string | URL | Request | undefined | null, +): string | undefined { + const url = + input instanceof URL + ? input.href + : input instanceof Request + ? input.url + : input; + return normalizeHttpOrigin(url); +} /** * Check whether `candidate` matches `trusted` under the host-scoping trust @@ -37,8 +50,8 @@ export function isHostTrusted( if (!trusted) { return false; } - const candidateOrigin = normalizeOrigin(candidate); - const trustedOrigin = normalizeOrigin(trusted); + const candidateOrigin = normalizeTrustOrigin(candidate); + const trustedOrigin = normalizeTrustOrigin(trusted); if (!(candidateOrigin && trustedOrigin)) { return false; } @@ -75,7 +88,7 @@ let loginTrustAnchor: string | undefined; /** Register an explicit login-time trust anchor. URLs are normalized. */ export function registerLoginTrustAnchor(url: string): void { - const origin = normalizeOrigin(url); + const origin = normalizeHttpOrigin(url); if (origin) { loginTrustAnchor = origin; } @@ -109,7 +122,7 @@ function isOriginTrustedFor( if (isHostTrusted(requestInput, anchorHost)) { return true; } - const requestOrigin = normalizeOrigin(requestInput); + const requestOrigin = normalizeTrustOrigin(requestInput); return ( requestOrigin !== undefined && isTrustedRegionOrigin(requestOrigin, anchorHost, identity) diff --git a/packages/cli/test/lib/token-host.test.ts b/packages/cli/test/lib/token-host.test.ts index ad618066b..44c25e60a 100644 --- a/packages/cli/test/lib/token-host.test.ts +++ b/packages/cli/test/lib/token-host.test.ts @@ -73,6 +73,21 @@ describe("isHostTrusted", () => { ).toBe(true); }); + test.each([ + ["https://user:secret@us.sentry.io/", "https://sentry.io"], + ["https://us.sentry.io/", "https://user:secret@sentry.io"], + ["https://user:secret@sentry.example.com/", "https://sentry.example.com"], + ["https://sentry.example.com/", "https://user:secret@sentry.example.com"], + ])("refuses embedded URL credentials in %s or its anchor", (url, anchor) => { + expect(isHostTrusted(url, anchor)).toBe(false); + }); + + test("does not trust non-HTTP(S) exact origins", () => { + expect( + isHostTrusted("ftp://sentry.example.com", "ftp://sentry.example.com"), + ).toBe(false); + }); + test("scheme mismatch fails", () => { expect( isHostTrusted("http://sentry.example.com/", "https://sentry.example.com"), diff --git a/packages/cli/test/script/cli-startup.test.ts b/packages/cli/test/script/cli-startup.test.ts index c089040af..1b998f8fe 100644 --- a/packages/cli/test/script/cli-startup.test.ts +++ b/packages/cli/test/script/cli-startup.test.ts @@ -26,11 +26,12 @@ test.each(["cli.ts", "index.ts"])( if (args.kind === "dynamic-import") { return { path: args.path, external: true }; } - // The hostname predicate is pure and has no startup side effects. - // Keep traversing its source to reject any future SDK imports. + // Hostname and origin predicates are pure and have no startup + // side effects. Traverse their source to catch future SDK imports. if ( args.path.startsWith("@sentry/") && - args.path !== "@sentry/toolkit-core/sentry-host" + args.path !== "@sentry/toolkit-core/sentry-host" && + args.path !== "@sentry/toolkit-core/sentry-origin" ) { return { errors: [ diff --git a/packages/mcp-core/src/internal/tool-helpers/validate-region-url.test.ts b/packages/mcp-core/src/internal/tool-helpers/validate-region-url.test.ts index f7b19aee7..8cfdf7d10 100644 --- a/packages/mcp-core/src/internal/tool-helpers/validate-region-url.test.ts +++ b/packages/mcp-core/src/internal/tool-helpers/validate-region-url.test.ts @@ -78,6 +78,21 @@ describe("validateRegionUrl", () => { }); describe("protocol validation", () => { + it.each([ + ["https://user:password@us.sentry.io", "sentry.io"], + ["https://user:password@sentry.company.com", "sentry.company.com"], + ])("rejects embedded credentials in %s", (regionUrl, baseHost) => { + expect(() => validateRegionUrl(regionUrl, baseHost)).toThrow( + "URL credentials are not allowed", + ); + try { + validateRegionUrl(regionUrl, baseHost); + } catch (error) { + expect(error).toBeInstanceOf(UserInputError); + expect(String(error)).not.toContain("password"); + } + }); + it("rejects URLs without protocol", () => { expect(() => validateRegionUrl("sentry.io", "sentry.io")).toThrow( UserInputError, diff --git a/packages/mcp-core/src/internal/tool-helpers/validate-region-url.ts b/packages/mcp-core/src/internal/tool-helpers/validate-region-url.ts index 0160e9442..ddf4292a0 100644 --- a/packages/mcp-core/src/internal/tool-helpers/validate-region-url.ts +++ b/packages/mcp-core/src/internal/tool-helpers/validate-region-url.ts @@ -1,3 +1,4 @@ +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { UserInputError } from "../../errors"; import { SENTRY_ALLOWED_REGION_DOMAINS } from "../../constants"; @@ -21,21 +22,27 @@ export function validateRegionUrl(regionUrl: string, baseHost: string): string { parsedUrl = new URL(regionUrl); } catch { throw new UserInputError( - `Invalid regionUrl provided: ${regionUrl}. Must be a valid URL.`, + "Invalid regionUrl provided. Must be a valid URL.", ); } // Validate protocol - MUST be HTTPS for security if (parsedUrl.protocol !== "https:") { throw new UserInputError( - `Invalid regionUrl provided: ${regionUrl}. Must use HTTPS protocol for security.`, + "Invalid regionUrl provided. Must use HTTPS protocol for security.", ); } // Validate that the host is not just the protocol name if (parsedUrl.host === "https" || parsedUrl.host === "http") { throw new UserInputError( - `Invalid regionUrl provided: ${regionUrl}. The host cannot be just a protocol name.`, + "Invalid regionUrl provided. The host cannot be just a protocol name.", + ); + } + + if (parsedUrl.username || parsedUrl.password) { + throw new UserInputError( + "Invalid regionUrl provided. URL credentials are not allowed.", ); } @@ -48,9 +55,12 @@ export function validateRegionUrl(regionUrl: string, baseHost: string): string { } // Otherwise, check against the allowlist - if (!SENTRY_ALLOWED_REGION_DOMAINS.has(regionHost)) { + if ( + !SENTRY_ALLOWED_REGION_DOMAINS.has(regionHost) || + !isSaaSTrustOrigin(regionUrl) + ) { throw new UserInputError( - `Invalid regionUrl: ${regionUrl}. The domain '${regionHost}' is not allowed. Allowed domains are: ${Array.from(SENTRY_ALLOWED_REGION_DOMAINS).join(", ")}`, + `Invalid regionUrl. The domain '${regionHost}' is not allowed. Allowed domains are: ${Array.from(SENTRY_ALLOWED_REGION_DOMAINS).join(", ")}`, ); } diff --git a/packages/mcp-core/src/tools/catalog/get-event-attachment.test.ts b/packages/mcp-core/src/tools/catalog/get-event-attachment.test.ts index bc6310725..f34b44faf 100644 --- a/packages/mcp-core/src/tools/catalog/get-event-attachment.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-event-attachment.test.ts @@ -270,8 +270,6 @@ describe("get_event_attachment", () => { userId: "1", }, ), - ).rejects.toThrow( - "Invalid regionUrl provided: https. Must be a valid URL.", - ); + ).rejects.toThrow("Invalid regionUrl provided. Must be a valid URL."); }); }); diff --git a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts index 7f282caba..d0a603940 100644 --- a/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts +++ b/packages/mcp-core/src/tools/catalog/get-issue-details.test.ts @@ -1475,9 +1475,7 @@ describe("get_issue_details", () => { userId: "1", }, ), - ).rejects.toThrow( - "Invalid regionUrl provided: https. Must be a valid URL.", - ); + ).rejects.toThrow("Invalid regionUrl provided. Must be a valid URL."); }); it("enhances 404 error with parameter context for non-existent issue", async () => { diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 0244c8fb4..2872371aa 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -5,6 +5,7 @@ builds bundle this private workspace package into their artifacts. The shared code validates opaque bearer tokens, constructs OAuth device-flow form bodies, classifies RFC 8628 polling responses, advances retry intervals, -recognizes Sentry hostnames, and encodes API path identifiers. Each product -retains its own credential storage, URL and host trust checks, regional routing, -polling deadline, HTTP transport, response validation, and user-facing errors. +recognizes Sentry hostnames and credential-safe SaaS HTTPS origins, and encodes +API path identifiers. Each product retains its own credential storage, regional +host allowlists, routing, polling deadline, HTTP transport, response validation, +and user-facing errors. diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 7b8a8df37..4db8242dc 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -7,6 +7,10 @@ "node": ">=22.13" }, "exports": { + "./sentry-origin": { + "types": "./src/sentry-origin.ts", + "default": "./src/sentry-origin.ts" + }, "./api-path-segment": { "types": "./src/api-path-segment.ts", "default": "./src/api-path-segment.ts" diff --git a/packages/toolkit-core/src/sentry-origin.test.ts b/packages/toolkit-core/src/sentry-origin.test.ts new file mode 100644 index 000000000..320c20318 --- /dev/null +++ b/packages/toolkit-core/src/sentry-origin.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { isSaaSTrustOrigin } from "./sentry-origin.js"; + +describe("isSaaSTrustOrigin", () => { + it.each([ + "https://sentry.io", + "https://us.sentry.io/api/0/", + "https://de.sentry.io:443", + "https://tenant.my.sentry.io/", + ])("accepts a credential-free SaaS HTTPS origin: %s", (url) => { + expect(isSaaSTrustOrigin(url)).toBe(true); + }); + + it.each([ + "http://sentry.io", + "http://us.sentry.io", + "https://sentry.io:8443", + "https://evil@us.sentry.io", + "https://user:password@sentry.io", + "https://sentry.io.example.com", + "https://notsentry.io", + "not-a-url", + ])("rejects an untrusted origin: %s", (url) => { + expect(isSaaSTrustOrigin(url)).toBe(false); + }); +}); diff --git a/packages/toolkit-core/src/sentry-origin.ts b/packages/toolkit-core/src/sentry-origin.ts new file mode 100644 index 000000000..2f389f92e --- /dev/null +++ b/packages/toolkit-core/src/sentry-origin.ts @@ -0,0 +1,17 @@ +import { isSentryHost } from "./sentry-host.js"; + +/** Strict SaaS origin check for credential and regional-host trust decisions. */ +export function isSaaSTrustOrigin(rawUrl: string): boolean { + try { + const url = new URL(rawUrl); + return ( + url.protocol === "https:" && + url.port === "" && + !url.username && + !url.password && + isSentryHost(url.hostname) + ); + } catch { + return false; + } +}