diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 3885a319e..9a7d3cfc5 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -9,6 +9,7 @@ import { promisify } from "node:util"; import { zstdCompress as zstdCompressCb } from "node:zlib"; import { parseSentryLinkHeader } from "@sentry/api"; +import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request"; // oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import import * as Sentry from "@sentry/node-core/light"; import { type GenericSchema, safeParse } from "valibot"; @@ -592,12 +593,8 @@ export async function apiRequestToRegion( } = options; const config = getSdkConfig(regionUrl, { credential, validatedRedirects }); - const normalizedEndpoint = endpoint.startsWith("/") - ? endpoint.slice(1) - : endpoint; - const endpointWithParams = appendSearchParams(normalizedEndpoint, params); - // getSdkConfig.baseUrl is the plain region URL; add /api/0/ for raw requests - const url = `${config.baseUrl}/api/0/${endpointWithParams}`; + const endpointWithParams = appendSearchParams(endpoint, params); + const url = buildSentryApiUrl(config.baseUrl, endpointWithParams); const fetchFn = config.fetch; const headers: Record = { @@ -749,11 +746,8 @@ export async function apiRequestToRegionNoContent( const config = getSdkConfig(regionUrl); const searchParams = buildSearchParams(params); - const normalizedEndpoint = endpoint.startsWith("/") - ? endpoint.slice(1) - : endpoint; const queryString = searchParams ? `?${searchParams.toString()}` : ""; - const url = `${config.baseUrl}/api/0/${normalizedEndpoint}${queryString}`; + const url = buildSentryApiUrl(config.baseUrl, `${endpoint}${queryString}`); const fetchFn = config.fetch; const headers: Record = { @@ -878,12 +872,8 @@ export async function rawApiRequest( // enforces isRequestOriginTrusted() before attaching Authorization. const config = baseUrl ? getSdkConfig(baseUrl) : getDefaultSdkConfig(); - const normalizedEndpoint = endpoint.startsWith("/") - ? endpoint.slice(1) - : endpoint; - const endpointWithParams = appendSearchParams(normalizedEndpoint, params); - // getSdkConfig.baseUrl is the plain region URL; add /api/0/ for raw requests - const url = `${config.baseUrl}/api/0/${endpointWithParams}`; + const endpointWithParams = appendSearchParams(endpoint, params); + const url = buildSentryApiUrl(config.baseUrl, endpointWithParams); // Build request headers and body. // String bodies: no Content-Type unless the caller explicitly provides one. diff --git a/packages/cli/src/lib/auth-header.ts b/packages/cli/src/lib/auth-header.ts index ddfaaba81..7edc82517 100644 --- a/packages/cli/src/lib/auth-header.ts +++ b/packages/cli/src/lib/auth-header.ts @@ -2,6 +2,7 @@ import { normalizeAuthToken as parseAuthToken, + sentryBearerHeader, trimAuthToken as trimSharedAuthToken, } from "@sentry/toolkit-core/auth-token"; import { MalformedAuthTokenError } from "./errors.js"; @@ -22,5 +23,9 @@ export function normalizeAuthToken(token: string): string { /** Normalize and validate a credential before constructing its Authorization value. */ export function formatAuthHeader(token: string): string { - return `Bearer ${normalizeAuthToken(token)}`; + const header = sentryBearerHeader(token); + if (header === null) { + throw new MalformedAuthTokenError(); + } + return header; } diff --git a/packages/cli/test/lib/api/infrastructure.test.ts b/packages/cli/test/lib/api/infrastructure.test.ts index 9dac96c12..a1fb52534 100644 --- a/packages/cli/test/lib/api/infrastructure.test.ts +++ b/packages/cli/test/lib/api/infrastructure.test.ts @@ -606,6 +606,28 @@ describe("rawApiRequest binary handling", () => { expect(fetchSpy).not.toHaveBeenCalled(); }); + test("keeps a trusted self-hosted path and bearer credential on raw requests", async () => { + setAuthToken(" \tvalid-token\x7f ", undefined, undefined, { + host: "https://sentry.example.com", + }); + globalThis.fetch = mockFetch(async (input, init) => { + expect(input).toBe( + "https://sentry.example.com/sentry/api/0/organizations/acme/?cursor=a%3Ab", + ); + expect(new Headers(init?.headers).get("Authorization")).toBe( + "Bearer valid-token", + ); + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }); + + await rawApiRequest("/organizations/acme/", { + baseUrl: "https://sentry.example.com/sentry", + params: { cursor: "a:b" }, + }); + }); + test("returns Uint8Array for image/png without UTF-8 corruption", async () => { // Real PNG signature: 89 50 4e 47 0d 0a 1a 0a — the leading 0x89 is not // valid UTF-8 and would become EF BF BD if response.text() were used. diff --git a/packages/mcp-core/src/api-client/client.ts b/packages/mcp-core/src/api-client/client.ts index 3842b2a49..ce20217c5 100644 --- a/packages/mcp-core/src/api-client/client.ts +++ b/packages/mcp-core/src/api-client/client.ts @@ -1,5 +1,6 @@ import { parseSentryLinkHeader } from "@sentry/api"; -import { normalizeAuthToken } from "@sentry/toolkit-core/auth-token"; +import { buildSentryApiUrl } from "@sentry/toolkit-core/api-request"; +import { sentryBearerHeader } from "@sentry/toolkit-core/auth-token"; import { z } from "zod"; import { DEFAULT_SEARCH_ISSUES_PERIOD } from "../constants"; import { ConfigurationError } from "../errors"; @@ -768,20 +769,21 @@ export class SentryApiService { options: RequestInit = {}, { host, allowStatuses }: { host?: string; allowStatuses?: number[] } = {}, ): Promise { - const url = host - ? `${this.protocol}://${host}/api/0${path}` - : `${this.apiPrefix}${path}`; + const url = buildSentryApiUrl( + `${this.protocol}://${host ?? this.host}`, + path, + ); const headers: Record = { "Content-Type": "application/json", "User-Agent": USER_AGENT, }; if (this.accessToken !== null) { - const token = normalizeAuthToken(this.accessToken); - if (token === null) { + const authorization = sentryBearerHeader(this.accessToken); + if (authorization === null) { throw new ConfigurationError("Malformed authentication token"); } - headers.Authorization = `Bearer ${token}`; + headers.Authorization = authorization; } if (this.clientId) { headers["X-Sentry-MCP-Client-Id"] = this.clientId; diff --git a/packages/mcp-server-mocks/src/index.ts b/packages/mcp-server-mocks/src/index.ts index 0f8bcea9d..610a6bfab 100644 --- a/packages/mcp-server-mocks/src/index.ts +++ b/packages/mcp-server-mocks/src/index.ts @@ -24,142 +24,58 @@ import { HttpResponse, http } from "msw"; */ import { setupServer } from "msw/node"; -import autofixStateFixture from "./fixtures/autofix-state.json" with { - type: "json", -}; -import autofixStateExplorerFixture from "./fixtures/autofix-state-explorer.json" with { - type: "json", -}; +import autofixStateFixture from "./fixtures/autofix-state.json" with { type: "json" }; +import autofixStateExplorerFixture from "./fixtures/autofix-state-explorer.json" with { type: "json" }; import clientKeyFixture from "./fixtures/client-key.json" with { type: "json" }; -import dashboardDetailsFixture from "./fixtures/dashboard-details.json" with { - type: "json", -}; -import dashboardListFixture from "./fixtures/dashboard-list.json" with { - type: "json", -}; +import dashboardDetailsFixture from "./fixtures/dashboard-details.json" with { type: "json" }; +import dashboardListFixture from "./fixtures/dashboard-list.json" with { type: "json" }; import eventsFixture from "./fixtures/event.json" with { type: "json" }; -import eventAttachmentsFixture from "./fixtures/event-attachments.json" with { - type: "json", -}; -import eventsErrorsFixture from "./fixtures/events-errors.json" with { - type: "json", -}; -import eventsErrorsEmptyFixture from "./fixtures/events-errors-empty.json" with { - type: "json", -}; -import eventsSpansFixture from "./fixtures/events-spans.json" with { - type: "json", -}; -import eventsSpansEmptyFixture from "./fixtures/events-spans-empty.json" with { - type: "json", -}; -import eventsTraceMetricsFixture from "./fixtures/events-tracemetrics.json" with { - type: "json", -}; -import eventsTraceMetricsAggregateFixture from "./fixtures/events-tracemetrics-aggregate.json" with { - type: "json", -}; -import eventsTraceMetricsEmptyFixture from "./fixtures/events-tracemetrics-empty.json" with { - type: "json", -}; -import flamegraphFixture from "./fixtures/flamegraph.json" with { - type: "json", -}; +import eventAttachmentsFixture from "./fixtures/event-attachments.json" with { type: "json" }; +import eventsErrorsFixture from "./fixtures/events-errors.json" with { type: "json" }; +import eventsErrorsEmptyFixture from "./fixtures/events-errors-empty.json" with { type: "json" }; +import eventsSpansFixture from "./fixtures/events-spans.json" with { type: "json" }; +import eventsSpansEmptyFixture from "./fixtures/events-spans-empty.json" with { type: "json" }; +import eventsTraceMetricsFixture from "./fixtures/events-tracemetrics.json" with { type: "json" }; +import eventsTraceMetricsAggregateFixture from "./fixtures/events-tracemetrics-aggregate.json" with { type: "json" }; +import eventsTraceMetricsEmptyFixture from "./fixtures/events-tracemetrics-empty.json" with { type: "json" }; +import flamegraphFixture from "./fixtures/flamegraph.json" with { type: "json" }; import issueFixture from "./fixtures/issue.json" with { type: "json" }; -import issueActivityFixture from "./fixtures/issue-activity.json" with { - type: "json", -}; -import issueCommentsFixture from "./fixtures/issue-comments.json" with { - type: "json", -}; -import issueNullCulpritFixture from "./fixtures/issue-null-culprit.json" with { - type: "json", -}; -import issueTagValuesFixture from "./fixtures/issue-tag-values.json" with { - type: "json", -}; -import issueUserReportsFixture from "./fixtures/issue-user-reports.json" with { - type: "json", -}; +import issueActivityFixture from "./fixtures/issue-activity.json" with { type: "json" }; +import issueCommentsFixture from "./fixtures/issue-comments.json" with { type: "json" }; +import issueNullCulpritFixture from "./fixtures/issue-null-culprit.json" with { type: "json" }; +import issueTagValuesFixture from "./fixtures/issue-tag-values.json" with { type: "json" }; +import issueUserReportsFixture from "./fixtures/issue-user-reports.json" with { type: "json" }; import monitorFixture from "./fixtures/monitor.json" with { type: "json" }; -import monitorCheckInsFixture from "./fixtures/monitor-checkins.json" with { - type: "json", -}; -import monitorStatsFixture from "./fixtures/monitor-stats.json" with { - type: "json", -}; -import organizationFixture from "./fixtures/organization.json" with { - type: "json", -}; -import performanceEventFixture from "./fixtures/performance-event.json" with { - type: "json", -}; -import profileChunkFixture from "./fixtures/profile-chunk.json" with { - type: "json", -}; +import monitorCheckInsFixture from "./fixtures/monitor-checkins.json" with { type: "json" }; +import monitorStatsFixture from "./fixtures/monitor-stats.json" with { type: "json" }; +import organizationFixture from "./fixtures/organization.json" with { type: "json" }; +import performanceEventFixture from "./fixtures/performance-event.json" with { type: "json" }; +import profileChunkFixture from "./fixtures/profile-chunk.json" with { type: "json" }; import projectFixture from "./fixtures/project.json" with { type: "json" }; import releaseFixture from "./fixtures/release.json" with { type: "json" }; -import releaseCommitsFixture from "./fixtures/release-commits.json" with { - type: "json", -}; -import releaseDeploysFixture from "./fixtures/release-deploys.json" with { - type: "json", -}; -import replayDetailsFixture from "./fixtures/replay-details.json" with { - type: "json", -}; -import replayRecordingSegmentsFixture from "./fixtures/replay-recording-segments.json" with { - type: "json", -}; +import releaseCommitsFixture from "./fixtures/release-commits.json" with { type: "json" }; +import releaseDeploysFixture from "./fixtures/release-deploys.json" with { type: "json" }; +import replayDetailsFixture from "./fixtures/replay-details.json" with { type: "json" }; +import replayRecordingSegmentsFixture from "./fixtures/replay-recording-segments.json" with { type: "json" }; import tagsFixture from "./fixtures/tags.json" with { type: "json" }; import teamFixture from "./fixtures/team.json" with { type: "json" }; import traceFixture from "./fixtures/trace.json" with { type: "json" }; -import traceEventFixture from "./fixtures/trace-event.json" with { - type: "json", -}; -import traceItemsAttributesLogsNumberFixture from "./fixtures/trace-items-attributes-logs-number.json" with { - type: "json", -}; -import traceItemsAttributesLogsStringFixture from "./fixtures/trace-items-attributes-logs-string.json" with { - type: "json", -}; -import traceItemsAttributesSpansNumberFixture from "./fixtures/trace-items-attributes-spans-number.json" with { - type: "json", -}; -import traceItemsAttributesSpansStringFixture from "./fixtures/trace-items-attributes-spans-string.json" with { - type: "json", -}; -import traceItemsAttributesSpansNumberWithContextFixture from "./fixtures/trace-items-attributes-spans-number-with-context.json" with { - type: "json", -}; -import traceItemsAttributesSpansStringWithContextFixture from "./fixtures/trace-items-attributes-spans-string-with-context.json" with { - type: "json", -}; -import traceItemsAttributesTraceMetricsNumberFixture from "./fixtures/trace-items-attributes-tracemetrics-number.json" with { - type: "json", -}; -import traceItemsAttributesTraceMetricsStringFixture from "./fixtures/trace-items-attributes-tracemetrics-string.json" with { - type: "json", -}; +import traceEventFixture from "./fixtures/trace-event.json" with { type: "json" }; +import traceItemsAttributesLogsNumberFixture from "./fixtures/trace-items-attributes-logs-number.json" with { type: "json" }; +import traceItemsAttributesLogsStringFixture from "./fixtures/trace-items-attributes-logs-string.json" with { type: "json" }; +import traceItemsAttributesSpansNumberFixture from "./fixtures/trace-items-attributes-spans-number.json" with { type: "json" }; +import traceItemsAttributesSpansStringFixture from "./fixtures/trace-items-attributes-spans-string.json" with { type: "json" }; +import traceItemsAttributesSpansNumberWithContextFixture from "./fixtures/trace-items-attributes-spans-number-with-context.json" with { type: "json" }; +import traceItemsAttributesSpansStringWithContextFixture from "./fixtures/trace-items-attributes-spans-string-with-context.json" with { type: "json" }; +import traceItemsAttributesTraceMetricsNumberFixture from "./fixtures/trace-items-attributes-tracemetrics-number.json" with { type: "json" }; +import traceItemsAttributesTraceMetricsStringFixture from "./fixtures/trace-items-attributes-tracemetrics-string.json" with { type: "json" }; import traceMetaFixture from "./fixtures/trace-meta.json" with { type: "json" }; -import traceMetaWithNullsFixture from "./fixtures/trace-meta-with-nulls.json" with { - type: "json", -}; -import traceMixedFixture from "./fixtures/trace-mixed.json" with { - type: "json", -}; -import transactionProfileV1Fixture from "./fixtures/transaction-profile-v1.json" with { - type: "json", -}; -import transactionProfileV1MissingFunctionFixture from "./fixtures/transaction-profile-v1-missing-function.json" with { - type: "json", -}; -import uptimeChecksFixture from "./fixtures/uptime-checks.json" with { - type: "json", -}; -import uptimeMonitorFixture from "./fixtures/uptime-monitor.json" with { - type: "json", -}; +import traceMetaWithNullsFixture from "./fixtures/trace-meta-with-nulls.json" with { type: "json" }; +import traceMixedFixture from "./fixtures/trace-mixed.json" with { type: "json" }; +import transactionProfileV1Fixture from "./fixtures/transaction-profile-v1.json" with { type: "json" }; +import transactionProfileV1MissingFunctionFixture from "./fixtures/transaction-profile-v1-missing-function.json" with { type: "json" }; +import uptimeChecksFixture from "./fixtures/uptime-checks.json" with { type: "json" }; +import uptimeMonitorFixture from "./fixtures/uptime-monitor.json" with { type: "json" }; import userFixture from "./fixtures/user.json" with { type: "json" }; import { issueFixture2 } from "./payloads"; diff --git a/packages/toolkit-core/README.md b/packages/toolkit-core/README.md index 0244c8fb4..4ba95eeba 100644 --- a/packages/toolkit-core/README.md +++ b/packages/toolkit-core/README.md @@ -3,8 +3,9 @@ Pure protocol and hostname helpers shared by the CLI and MCP. Both product 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. +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. diff --git a/packages/toolkit-core/package.json b/packages/toolkit-core/package.json index 7b8a8df37..2ea47a939 100644 --- a/packages/toolkit-core/package.json +++ b/packages/toolkit-core/package.json @@ -7,6 +7,10 @@ "node": ">=22.13" }, "exports": { + "./api-request": { + "types": "./src/api-request.ts", + "default": "./src/api-request.ts" + }, "./api-path-segment": { "types": "./src/api-path-segment.ts", "default": "./src/api-path-segment.ts" diff --git a/packages/toolkit-core/src/api-request.test.ts b/packages/toolkit-core/src/api-request.test.ts new file mode 100644 index 000000000..8d56d5132 --- /dev/null +++ b/packages/toolkit-core/src/api-request.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { buildSentryApiUrl } from "./api-request.js"; + +describe("buildSentryApiUrl", () => { + it.each([ + [ + "https://sentry.io", + "/organizations/org/", + "https://sentry.io/api/0/organizations/org/", + ], + [ + "https://us.sentry.io/", + "organizations/org/?cursor=a%3Ab", + "https://us.sentry.io/api/0/organizations/org/?cursor=a%3Ab", + ], + [ + "https://self-hosted.example/sentry/", + "/organizations/org/", + "https://self-hosted.example/sentry/api/0/organizations/org/", + ], + [ + "https://self-hosted.example/sentry////", + "organizations/org/", + "https://self-hosted.example/sentry/api/0/organizations/org/", + ], + ])("assembles %s with %s", (baseUrl, endpoint, expected) => { + expect(buildSentryApiUrl(baseUrl, endpoint)).toBe(expected); + }); + + it("preserves encoded identifiers without decoding them", () => { + expect(buildSentryApiUrl("https://sentry.io", "issues/a%2Fb/")).toBe( + "https://sentry.io/api/0/issues/a%2Fb/", + ); + }); +}); diff --git a/packages/toolkit-core/src/api-request.ts b/packages/toolkit-core/src/api-request.ts new file mode 100644 index 000000000..82b7a9da1 --- /dev/null +++ b/packages/toolkit-core/src/api-request.ts @@ -0,0 +1,12 @@ +/** + * Assemble an API URL from a product-validated base and a relative endpoint. + * This does not decide which host is trusted or which region an org uses. + */ +export function buildSentryApiUrl(baseUrl: string, endpoint: string): string { + const path = endpoint.startsWith("/") ? endpoint.slice(1) : endpoint; + let end = baseUrl.length; + while (end > 0 && baseUrl.charAt(end - 1) === "/") { + end -= 1; + } + return `${baseUrl.slice(0, end)}/api/0/${path}`; +} diff --git a/packages/toolkit-core/src/auth-token.test.ts b/packages/toolkit-core/src/auth-token.test.ts index 05d905e41..69d4e57ae 100644 --- a/packages/toolkit-core/src/auth-token.test.ts +++ b/packages/toolkit-core/src/auth-token.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { normalizeAuthToken, trimAuthToken } from "./auth-token"; +import { + normalizeAuthToken, + sentryBearerHeader, + trimAuthToken, +} from "./auth-token"; describe("Sentry bearer credentials", () => { it("preserves every visible ASCII byte through edge normalization", () => { @@ -19,4 +23,12 @@ describe("Sentry bearer credentials", () => { expect(normalizeAuthToken(" \t\x00 ")).toBeNull(); expect(normalizeAuthToken("é")).toBeNull(); }); + + it("formats a bearer header only for a valid upstream token", () => { + expect(sentryBearerHeader(" \tvalid-token\x7f ")).toBe( + "Bearer valid-token", + ); + expect(sentryBearerHeader("valid\nsecret")).toBeNull(); + expect(sentryBearerHeader(" \t ")).toBeNull(); + }); }); diff --git a/packages/toolkit-core/src/auth-token.ts b/packages/toolkit-core/src/auth-token.ts index c4118cba2..eafd66ca2 100644 --- a/packages/toolkit-core/src/auth-token.ts +++ b/packages/toolkit-core/src/auth-token.ts @@ -23,3 +23,9 @@ export function normalizeAuthToken(token: string): string | null { ? normalized : null; } + +/** Format a validated upstream credential; callers own their auth errors. */ +export function sentryBearerHeader(token: string): string | null { + const normalized = normalizeAuthToken(token); + return normalized === null ? null : `Bearer ${normalized}`; +}