diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index d76917bf0cdd..2fd6a4a9c8d5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -71,6 +71,8 @@ export { hasSpansEnabled } from './utils/hasSpansEnabled'; export { isSentryRequestUrl } from './utils/isSentryRequestUrl'; export { handleCallbackErrors } from './utils/handleCallbackErrors'; export { parameterize, fmt } from './utils/parameterize'; +export type { HandleTunnelRequestOptions } from './utils/tunnel'; +export { handleTunnelRequest } from './utils/tunnel'; export { addAutoIpAddressToSession } from './utils/ipAddress'; // eslint-disable-next-line deprecation/deprecation diff --git a/packages/core/src/utils/tunnel.ts b/packages/core/src/utils/tunnel.ts new file mode 100644 index 000000000000..5645633ff45e --- /dev/null +++ b/packages/core/src/utils/tunnel.ts @@ -0,0 +1,76 @@ +import { getEnvelopeEndpointWithUrlEncodedAuth } from '../api'; +import { debug } from './debug-logger'; +import { makeDsn } from './dsn'; +import { parseEnvelope } from './envelope'; + +export interface HandleTunnelRequestOptions { + /** Incoming request containing the Sentry envelope as its body */ + request: Request; + /** Pre-parsed array of allowed DSN strings */ + allowedDsns: Array; +} + +/** + * Core Sentry tunnel handler - framework agnostic. + * + * Validates the envelope DSN against allowed DSNs, then forwards the + * envelope to the Sentry ingest endpoint. + * + * @returns A `Response` — either the upstream Sentry response on success, or an error response. + */ +export async function handleTunnelRequest(options: HandleTunnelRequestOptions): Promise { + const { request, allowedDsns } = options; + + if (allowedDsns.length === 0) { + return new Response('Tunnel not configured', { status: 500 }); + } + + const body = new Uint8Array(await request.arrayBuffer()); + + let envelopeHeader; + try { + [envelopeHeader] = parseEnvelope(body); + } catch { + return new Response('Invalid envelope', { status: 400 }); + } + + if (!envelopeHeader) { + return new Response('Invalid envelope: missing header', { status: 400 }); + } + + const dsn = envelopeHeader.dsn; + if (!dsn) { + return new Response('Invalid envelope: missing DSN', { status: 400 }); + } + + // SECURITY: Validate that the envelope DSN matches one of the allowed DSNs + // This prevents SSRF attacks where attackers send crafted envelopes + // with malicious DSNs pointing to arbitrary hosts + const isAllowed = allowedDsns.some(allowed => allowed === dsn); + + if (!isAllowed) { + debug.warn(`Sentry tunnel: rejected request with unauthorized DSN (${dsn})`); + return new Response('DSN not allowed', { status: 403 }); + } + + const dsnComponents = makeDsn(dsn); + if (!dsnComponents) { + debug.warn(`Could not extract DSN Components from: ${dsn}`); + return new Response('Invalid DSN', { status: 403 }); + } + + const sentryIngestUrl = getEnvelopeEndpointWithUrlEncodedAuth(dsnComponents); + + try { + return await fetch(sentryIngestUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-sentry-envelope', + }, + body, + }); + } catch (error) { + debug.error('Sentry tunnel: failed to forward envelope', error); + return new Response('Failed to forward envelope to Sentry', { status: 500 }); + } +} diff --git a/packages/core/test/lib/utils/tunnel.test.ts b/packages/core/test/lib/utils/tunnel.test.ts new file mode 100644 index 000000000000..2b499ce754ae --- /dev/null +++ b/packages/core/test/lib/utils/tunnel.test.ts @@ -0,0 +1,136 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getEnvelopeEndpointWithUrlEncodedAuth } from '../../../src/api'; +import { makeDsn } from '../../../src/utils/dsn'; +import { createEnvelope, serializeEnvelope } from '../../../src/utils/envelope'; +import { handleTunnelRequest } from '../../../src/utils/tunnel'; + +const TEST_DSN = 'https://public@dsn.ingest.sentry.io/1337'; + +function makeEnvelopeRequest(envelopeHeader: Record): Request { + const envelope = createEnvelope(envelopeHeader, []); + const body = serializeEnvelope(envelope); + return new Request('http://localhost/tunnel', { method: 'POST', body }); +} + +describe('handleTunnelRequest', () => { + let fetchMock: ReturnType; + + beforeEach(() => { + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('forwards the envelope to Sentry and returns the upstream response', async () => { + const upstreamResponse = new Response('ok', { status: 200 }); + fetchMock.mockResolvedValueOnce(upstreamResponse); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url, init] = fetchMock.mock.calls[0]!; + expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); + expect(init.method).toBe('POST'); + expect(init.headers).toEqual({ 'Content-Type': 'application/x-sentry-envelope' }); + expect(init.body).toBeInstanceOf(Uint8Array); + + expect(result).toBe(upstreamResponse); + }); + + it('returns 500 when allowedDsns is empty', async () => { + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [], + }); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(500); + expect(await result.text()).toBe('Tunnel not configured'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 400 when the envelope has no DSN in the header', async () => { + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({}), + allowedDsns: [TEST_DSN], + }); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(400); + expect(await result.text()).toBe('Invalid envelope: missing DSN'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 400 when the envelope body contains malformed JSON', async () => { + const result = await handleTunnelRequest({ + request: new Request('http://localhost/tunnel', { method: 'POST', body: 'not valid envelope data{{{' }), + allowedDsns: [TEST_DSN], + }); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(400); + expect(await result.text()).toBe('Invalid envelope'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 403 when the envelope DSN is not in allowedDsns', async () => { + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: 'https://other@example.com/9999' }), + allowedDsns: [TEST_DSN], + }); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(403); + expect(await result.text()).toBe('DSN not allowed'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('returns 403 when the DSN string cannot be parsed into components', async () => { + const malformedDsn = 'not-a-valid-dsn'; + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: malformedDsn }), + allowedDsns: [malformedDsn], + }); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(403); + expect(await result.text()).toBe('Invalid DSN'); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('forwards the envelope when multiple DSNs are configured', async () => { + const otherDsn = 'https://other@example.com/9999'; + const upstreamResponse = new Response('ok', { status: 200 }); + fetchMock.mockResolvedValueOnce(upstreamResponse); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [otherDsn, TEST_DSN], + }); + + expect(fetchMock).toHaveBeenCalledOnce(); + const [url] = fetchMock.mock.calls[0]!; + expect(url).toBe(getEnvelopeEndpointWithUrlEncodedAuth(makeDsn(TEST_DSN)!)); + expect(result).toBe(upstreamResponse); + }); + + it('returns 500 when fetch throws a network error', async () => { + fetchMock.mockRejectedValueOnce(new Error('Network failure')); + + const result = await handleTunnelRequest({ + request: makeEnvelopeRequest({ dsn: TEST_DSN }), + allowedDsns: [TEST_DSN], + }); + + expect(result).toBeInstanceOf(Response); + expect(result.status).toBe(500); + expect(await result.text()).toBe('Failed to forward envelope to Sentry'); + }); +});