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
5 changes: 5 additions & 0 deletions docs/contributing/api-patterns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<organization>.sentry.io` for public SaaS. Single-tenant and
self-hosted links keep the configured host and `/organizations/<organization>`
path prefix. Use `isPublicSentryHost` for these routing decisions;
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/auth/login.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 2 additions & 4 deletions packages/cli/src/commands/cli/import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/init/init-service-auth.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down
6 changes: 2 additions & 4 deletions packages/cli/src/lib/login-host-guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/sentry-url-parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
35 changes: 3 additions & 32 deletions packages/cli/src/lib/sentry-urls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/lib/sentryclirc-import.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/sentryclirc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down
23 changes: 18 additions & 5 deletions packages/cli/src/lib/token-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)
Expand Down
15 changes: 15 additions & 0 deletions packages/cli/test/lib/token-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
7 changes: 4 additions & 3 deletions packages/cli/test/script/cli-startup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
20 changes: 15 additions & 5 deletions packages/mcp-core/src/internal/tool-helpers/validate-region-url.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin";
import { UserInputError } from "../../errors";
import { SENTRY_ALLOWED_REGION_DOMAINS } from "../../constants";

Expand All @@ -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.",
);
}

Expand All @@ -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(", ")}`,
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
8 changes: 4 additions & 4 deletions packages/toolkit-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ builds bundle this private workspace package into their artifacts.

The shared code validates and formats upstream bearer tokens, assembles Sentry
API URLs, 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, host
trust checks, regional routing, polling deadline, HTTP transport, response
validation, and user-facing errors.
responses, advances retry intervals, 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.
4 changes: 4 additions & 0 deletions packages/toolkit-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
"./sentry-host": {
"types": "./src/sentry-host.ts",
"default": "./src/sentry-host.ts"
},
"./sentry-origin": {
"types": "./src/sentry-origin.ts",
"default": "./src/sentry-origin.ts"
}
},
"scripts": {
Expand Down
Loading
Loading