From a8bfea797076e5a85899ea6c45893ad48552f346 Mon Sep 17 00:00:00 2001 From: Sigrid <32902192+s1gr1d@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:59:38 +0200 Subject: [PATCH] feat(v10/nextjs): Send tunnel route requests with `credentials: 'omit'` Backport of: #25509 --- CHANGELOG.md | 12 +++++ .../tests/tunnel-route.test.ts | 32 +++++++++++++ packages/nextjs/src/client/tunnelRoute.ts | 6 +++ packages/nextjs/src/config/types.ts | 8 +++- .../nextjs/test/utils/tunnelRoute.test.ts | 47 +++++++++++++++++++ 5 files changed, 103 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 540215190a07..77ab101fb4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,18 @@ Also, the server no longer sends spans of tunnel requests when span streaming is on, because these spans only show the SDK's own traffic. +- **feat(v10/nextjs): Send tunnel route requests with `credentials: 'omit'` ([#25509](https://github.com/getsentry/sentry-javascript/pull/25509))** + + With `tunnelRoute` and no tunnel route handler, `@sentry/nextjs` now sends events from the browser with `credentials: 'omit'`, because the tunnel route does not need cookies. + If a layer in front of your app checks cookies before it lets requests through (for example Vercel Deployment Protection), you can send them again in your browser `Sentry.init` call: + + ```js + // instrumentation-client.ts + Sentry.init({ + transportOptions: { fetchOptions: { credentials: 'same-origin' } }, + }); + ``` + ## 10.76.2 - feat(v10/deps): Bump bundler plugins to 5.4.1 ([#25147](https://github.com/getsentry/sentry-javascript/pull/25147)) diff --git a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts index f3f2bd9b7363..81dae5b31494 100644 --- a/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts +++ b/dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts @@ -133,6 +133,38 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag expect(sentryFetchTransactions).toHaveLength(0); }); +test('Tunnel requests are sent without the cookies of the browser', async ({ page, context, baseURL }) => { + test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler', 'The handler variants send the cookies'); + await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]); + const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) => + envelope[1].some( + ([itemHeader, item]) => + itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel cookie test', + ), + ); + + await page.goto('/'); + await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object'); + const tunnelRequestPromise = page.waitForRequest( + request => + request.method() === 'POST' && + new URL(request.url()).origin === baseURL && + (request.postData() ?? '').includes('Tunnel cookie test'), + ); + await page.evaluate(() => { + setTimeout(() => { + throw new Error('Tunnel cookie test'); + }); + }); + + const tunnelRequest = await tunnelRequestPromise; + const proxyRequest = await proxyRequestPromise; + + expect(await page.evaluate(() => document.cookie)).toBe('session=8f2c1d'); + expect((await tunnelRequest.allHeaders()).cookie).toBeUndefined(); + expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined(); +}); + test.describe('Tunnel route handler', () => { test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler'); diff --git a/packages/nextjs/src/client/tunnelRoute.ts b/packages/nextjs/src/client/tunnelRoute.ts index ba82260e17df..72f2855e32ae 100644 --- a/packages/nextjs/src/client/tunnelRoute.ts +++ b/packages/nextjs/src/client/tunnelRoute.ts @@ -36,6 +36,12 @@ export function applyTunnelRouteOption(options: BrowserOptions): void { tunnelPath += `&r=${regionCode}`; } options.tunnel = tunnelPath; + // The tunnel route is same-origin, so fetch would include credentials (cookies, HTTP authentication) that the + // tunnel does not need. + options.transportOptions = { + ...options.transportOptions, + fetchOptions: { credentials: 'omit', ...options.transportOptions?.fetchOptions }, + }; DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelPath}"`); } else { DEBUG_BUILD && debug.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.'); diff --git a/packages/nextjs/src/config/types.ts b/packages/nextjs/src/config/types.ts index 43f99bb00f77..3c9d4bc7d3ba 100644 --- a/packages/nextjs/src/config/types.ts +++ b/packages/nextjs/src/config/types.ts @@ -612,8 +612,8 @@ export type SentryBuildOptions = { * - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build * - Pass a string path (e.g., '/monitoring') to use a custom route * - * By default, the tunnel route proxies each request to Sentry with all its headers, cookies included. To forward - * only the event payload, create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route + * By default, the tunnel route proxies each request to Sentry with its headers. To forward only the event payload, + * create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route * group) with: * * ```ts @@ -628,6 +628,10 @@ export type SentryBuildOptions = { * `matcher` so events can reach Sentry. Matchers have to be static, so use a fixed string route in that case * rather than `true`. * + * Without the route handler, the browser sends tunnel requests with `credentials: 'omit'`, so without cookies or HTTP + * authentication. If a layer in front of your app needs them (for example Vercel Deployment Protection or HTTP Basic authentication), set + * `transportOptions: { fetchOptions: { credentials: 'same-origin' } }` in `Sentry.init` of `instrumentation-client.ts`. + * * NOTE: This feature only works with Next.js 11+ */ tunnelRoute?: string | boolean; diff --git a/packages/nextjs/test/utils/tunnelRoute.test.ts b/packages/nextjs/test/utils/tunnelRoute.test.ts index b901826d3f13..30dd3f840ba3 100644 --- a/packages/nextjs/test/utils/tunnelRoute.test.ts +++ b/packages/nextjs/test/utils/tunnelRoute.test.ts @@ -122,6 +122,53 @@ describe('Random tunnel route generation', () => { }); }); +describe('applyTunnelRouteOption() credentials', () => { + const SAAS_DSN = 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333'; + + beforeEach(() => { + globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route'; + }); + + it("sends tunnel requests with `credentials: 'omit'`", () => { + const options: BrowserOptions = { dsn: SAAS_DSN }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toEqual({ fetchOptions: { credentials: 'omit' } }); + }); + + it('keeps the fetch options of the user', () => { + const options: BrowserOptions = { + dsn: SAAS_DSN, + transportOptions: { bufferSize: 10, fetchOptions: { credentials: 'same-origin', priority: 'low' } }, + }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toEqual({ + bufferSize: 10, + fetchOptions: { credentials: 'same-origin', priority: 'low' }, + }); + }); + + it('does not change the transport options when the events are not tunneled', () => { + const options: BrowserOptions = { dsn: 'https://11111111111111111111111111111111@example.com/3333333' }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toBeUndefined(); + }); + + it('does not change the transport options in handler mode', () => { + globalWithInjectedValues._sentryTunnelRouteMode = 'handler'; + const options: BrowserOptions = { dsn: SAAS_DSN }; + + applyTunnelRouteOption(options); + + expect(options.transportOptions).toBeUndefined(); + }); +}); + describe('applyTunnelRouteOption() in handler mode', () => { it.each([ ['a SaaS DSN', 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333'],