Skip to content
Draft
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
4 changes: 2 additions & 2 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,8 +280,8 @@ export type SentryBuildOptions = Omit<
* export { POST } from '@sentry/nextjs/tunnel';
* ```
*
* This handler accepts the DSN of the server SDK, also a self-hosted one. For other DSNs, use
* `createSentryTunnelRoute` from the same import.
* This handler accepts the DSN of the server SDK, also a self-hosted one, and the DSNs `serveTunnelIntegration`
* allows. For other DSNs, use `createSentryTunnelRoute` from the same import.
*
* Tunnel requests go through your middleware (`proxy.ts` / `middleware.ts`) like any other request. If your
* middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events
Expand Down
40 changes: 7 additions & 33 deletions packages/nextjs/src/tunnel/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { dsnToString, getClient, handleTunnelRequest, makeDsn, suppressTracing } from '@sentry/core';
import { handleTunnelRequest } from '@sentry/core';

export interface CreateSentryTunnelRouteOptions {
/**
* The DSNs that the route accepts. It rejects envelopes for other DSNs with a 403.
* Defaults to the DSN of the server SDK.
* DSNs whose envelopes the route forwards, in addition to the DSN of the server SDK. It rejects envelopes for other
* DSNs with a 403. Defaults to the DSNs `serveTunnelIntegration` allows.
*/
allowedDsns?: string[];
}
Expand All @@ -27,32 +27,19 @@ type TunnelRouteHandler = (request: Request) => Promise<Response>;
* ```
*/
export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions = {}): { POST: TunnelRouteHandler } {
const allowedDsnsFromOptions = options.allowedDsns?.length ? normalizeDsns(options.allowedDsns) : undefined;
const allowedDsns = options.allowedDsns?.length ? options.allowedDsns.map(dsn => dsn.trim()) : undefined;

return {
POST: async request => {
// Read on each request, because the client can be set up after this module loads.
const allowedDsns = allowedDsnsFromOptions ?? getServerDsns();
if (!allowedDsns.length) {
return new Response(
allowedDsnsFromOptions
? 'Tunnel route has no valid DSN in allowedDsns.'
: 'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.',
{ status: 500 },
);
}

// Like the SDK transports, so that the forward creates no spans, breadcrumbs or trace headers.
return suppressTracing(() => handleTunnelRequest(request, { allowedDsns }));
},
POST: request => handleTunnelRequest(request, { allowedDsns }),
};
}

/**
* Route handler for the `tunnelRoute` of `withSentryConfig`. It forwards Sentry envelopes to Sentry, but not the
* request headers (such as cookies).
*
* It accepts envelopes only for the DSN of the server SDK. For other DSNs, use `createSentryTunnelRoute`.
* It accepts envelopes for the DSN of the server SDK, and the DSNs `serveTunnelIntegration` allows. For other DSNs,
* use `createSentryTunnelRoute`.
*
* @example
* ```ts
Expand All @@ -61,16 +48,3 @@ export function createSentryTunnelRoute(options: CreateSentryTunnelRouteOptions
* ```
*/
export const POST: TunnelRouteHandler = createSentryTunnelRoute().POST;

function getServerDsns(): string[] {
const dsn = getClient()?.getDsn();
return dsn ? [dsnToString(dsn)] : [];
}

// The same form as the DSN in the envelope header, because `handleTunnelRequest` compares exact strings.
function normalizeDsns(dsns: string[]): string[] {
return dsns.flatMap(dsn => {
const dsnComponents = makeDsn(dsn.trim());
return dsnComponents ? [dsnToString(dsnComponents)] : [];
});
}
52 changes: 30 additions & 22 deletions packages/nextjs/test/tunnel/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,9 @@
import type * as SentryCore from '@sentry/core';
import { createEnvelope, makeDsn, serializeEnvelope } from '@sentry/core';
import type { Client } from '@sentry/core';
import { createEnvelope, getCurrentScope, makeDsn, serializeEnvelope } from '@sentry/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { serveTunnelIntegration } from '@sentry/core/server';
import { createSentryTunnelRoute, POST } from '../../src/tunnel';

const { mockGetClient } = vi.hoisted(() => ({ mockGetClient: vi.fn() }));

vi.mock('@sentry/core', async importOriginal => ({
...(await importOriginal<typeof SentryCore>()),
getClient: mockGetClient,
}));

const SERVER_DSN = 'https://public@o1.ingest.sentry.io/1';
const BROWSER_DSN = 'https://browser@o1.ingest.sentry.io/2';

Expand All @@ -33,7 +27,8 @@ function createEnvelopeRequest(dsn: string): { request: Request; body: Uint8Arra
}

function useClientDsn(dsn: string | undefined): void {
mockGetClient.mockReturnValue(dsn ? { getDsn: () => makeDsn(dsn) } : undefined);
const client = dsn ? ({ getDsn: () => makeDsn(dsn), getOptions: () => ({}) } as unknown as Client) : undefined;
getCurrentScope().setClient(client);
}

let fetchSpy: ReturnType<typeof vi.spyOn>;
Expand Down Expand Up @@ -75,9 +70,6 @@ describe('POST', () => {
const response = await POST(request);

expect(response.status).toBe(500);
await expect(response.text()).resolves.toBe(
'Tunnel route requires Sentry server SDK initialized with a DSN, or pass allowedDsns explicitly.',
);
expect(fetchSpy).not.toHaveBeenCalled();
});

Expand All @@ -91,6 +83,22 @@ describe('POST', () => {
expect(fetchSpy).not.toHaveBeenCalled();
});

it('accepts the DSNs `serveTunnelIntegration` allows', async () => {
const integration = serveTunnelIntegration({ allowedDsns: [BROWSER_DSN] });
const options = {};
const client = {
getDsn: () => makeDsn(SERVER_DSN),
getOptions: () => options,
getIntegrationByName: () => integration,
} as unknown as Client;
integration.setup?.(client);
getCurrentScope().setClient(client);

const response = await POST(createEnvelopeRequest(BROWSER_DSN).request);

expect(response.status).toBe(200);
});

it('does not accept DSNs from environment variables', async () => {
useClientDsn(SERVER_DSN);
vi.stubEnv('SENTRY_DSN', BROWSER_DSN);
Expand All @@ -104,16 +112,18 @@ describe('POST', () => {
});

describe('createSentryTunnelRoute', () => {
it('accepts only the DSNs in `allowedDsns`', async () => {
it('accepts the DSNs in `allowedDsns` and the server DSN', async () => {
useClientDsn(SERVER_DSN);
const { POST: handler } = createSentryTunnelRoute({ allowedDsns: [BROWSER_DSN] });

const allowedResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request);
const rejectedResponse = await handler(createEnvelopeRequest(SERVER_DSN).request);
const browserResponse = await handler(createEnvelopeRequest(BROWSER_DSN).request);
const serverResponse = await handler(createEnvelopeRequest(SERVER_DSN).request);
const otherResponse = await handler(createEnvelopeRequest('https://other@o2.ingest.sentry.io/3').request);

expect(allowedResponse.status).toBe(200);
expect(rejectedResponse.status).toBe(403);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(browserResponse.status).toBe(200);
expect(serverResponse.status).toBe(200);
expect(otherResponse.status).toBe(403);
expect(fetchSpy).toHaveBeenCalledTimes(2);
});

it.each([
Expand All @@ -138,15 +148,13 @@ describe('createSentryTunnelRoute', () => {
expect(response.status).toBe(200);
});

it('responds with 500 when no allowed DSN is valid', async () => {
it('responds with 500 when no allowed DSN is valid and the server SDK has no DSN', async () => {
vi.spyOn(console, 'error').mockImplementation(() => undefined);
useClientDsn(SERVER_DSN);
const { POST: handler } = createSentryTunnelRoute({ allowedDsns: ['not-a-dsn'] });

const response = await handler(createEnvelopeRequest(SERVER_DSN).request);

expect(response.status).toBe(500);
await expect(response.text()).resolves.toBe('Tunnel route has no valid DSN in allowedDsns.');
expect(fetchSpy).not.toHaveBeenCalled();
});
});
Loading