Skip to content

Commit b151d07

Browse files
authored
feat(nextjs): Send tunnel route requests with credentials: 'omit' (#25509)
The tunnel route is same-origin, so the browser transport includes credentials (cookies, HTTP authentication) with each request, and the tunnel route does not need them. With `tunnelRoute`, `@sentry/nextjs` now sets `credentials: 'omit'` on the transport. Apps with a layer in front that checks credentials (for example Vercel Deployment Protection) can opt back in: ```js // instrumentation-client.ts Sentry.init({ transportOptions: { fetchOptions: { credentials: 'same-origin' } }, }); ``` Fixes JS-3927
1 parent 782b0e5 commit b151d07

5 files changed

Lines changed: 103 additions & 2 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,18 @@
1919

2020
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.
2121

22+
- **feat(nextjs): Send tunnel route requests with `credentials: 'omit'` ([#25509](https://github.com/getsentry/sentry-javascript/pull/25509))**
23+
24+
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.
25+
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:
26+
27+
```js
28+
// instrumentation-client.ts
29+
Sentry.init({
30+
transportOptions: { fetchOptions: { credentials: 'same-origin' } },
31+
});
32+
```
33+
2234
Work in this release was contributed by @adityareddy-dev, @jpdefo, @Jxxunnn, @philipatkinson, @Shubham-Padkonde, @mikamikasuki, and @mketiku. Thank you for your contributions!
2335

2436
- **feat(deno): Add incoming request options to `denoServeIntegration`**

‎dev-packages/e2e-tests/test-applications/nextjs-16-tunnel/tests/tunnel-route.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,38 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag
122122
expect(sentryFetchSpans).toHaveLength(0);
123123
});
124124

125+
test('Tunnel requests are sent without the cookies of the browser', async ({ page, context, baseURL }) => {
126+
test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE === 'handler', 'The handler variants send the cookies');
127+
await context.addCookies([{ name: 'session', value: '8f2c1d', url: baseURL }]);
128+
const proxyRequestPromise = waitForRequest('nextjs-16-tunnel', ({ envelope }) =>
129+
envelope[1].some(
130+
([itemHeader, item]) =>
131+
itemHeader.type === 'event' && (item as Event).exception?.values?.[0]?.value === 'Tunnel cookie test',
132+
),
133+
);
134+
135+
await page.goto('/');
136+
await page.waitForFunction(() => typeof (window as { __SENTRY__?: unknown }).__SENTRY__ === 'object');
137+
const tunnelRequestPromise = page.waitForRequest(
138+
request =>
139+
request.method() === 'POST' &&
140+
new URL(request.url()).origin === baseURL &&
141+
(request.postData() ?? '').includes('Tunnel cookie test'),
142+
);
143+
await page.evaluate(() => {
144+
setTimeout(() => {
145+
throw new Error('Tunnel cookie test');
146+
});
147+
});
148+
149+
const tunnelRequest = await tunnelRequestPromise;
150+
const proxyRequest = await proxyRequestPromise;
151+
152+
expect(await page.evaluate(() => document.cookie)).toBe('session=8f2c1d');
153+
expect((await tunnelRequest.allHeaders()).cookie).toBeUndefined();
154+
expect(proxyRequest.rawProxyRequestHeaders.cookie).toBeUndefined();
155+
});
156+
125157
test.describe('Tunnel route handler', () => {
126158
test.skip(process.env.NEXT_PUBLIC_E2E_TEST_TUNNEL_MODE !== 'handler', 'Only the handler variants use the handler');
127159

‎packages/nextjs/src/client/tunnelRoute.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ export function applyTunnelRouteOption(options: BrowserOptions): void {
3636
tunnelPath += `&r=${regionCode}`;
3737
}
3838
options.tunnel = tunnelPath;
39+
// The tunnel route is same-origin, so fetch would include credentials (cookies, HTTP authentication) that the
40+
// tunnel does not need.
41+
options.transportOptions = {
42+
...options.transportOptions,
43+
fetchOptions: { credentials: 'omit', ...options.transportOptions?.fetchOptions },
44+
};
3945
DEBUG_BUILD && debug.log(`Tunneling events to "${tunnelPath}"`);
4046
} else {
4147
DEBUG_BUILD && debug.warn('Provided DSN is not a Sentry SaaS DSN. Will not tunnel events.');

‎packages/nextjs/src/config/types.ts‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -272,8 +272,8 @@ export type SentryBuildOptions = Omit<
272272
* - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build
273273
* - Pass a string path (e.g., '/monitoring') to use a custom route
274274
*
275-
* By default, the tunnel route proxies each request to Sentry with all its headers, cookies included. To forward
276-
* only the event payload, create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route
275+
* By default, the tunnel route proxies each request to Sentry with its headers. To forward only the event payload,
276+
* create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route
277277
* group) with:
278278
*
279279
* ```ts
@@ -287,6 +287,10 @@ export type SentryBuildOptions = Omit<
287287
* middleware redirects or blocks unauthenticated requests, exclude the tunnel route in its `matcher` so events
288288
* can reach Sentry. Matchers have to be static, so use a fixed string route in that case rather than `true`.
289289
*
290+
* Without the route handler, the browser sends tunnel requests with `credentials: 'omit'`, so without cookies or HTTP
291+
* authentication. If a layer in front of your app needs them (for example Vercel Deployment Protection or HTTP Basic authentication), set
292+
* `transportOptions: { fetchOptions: { credentials: 'same-origin' } }` in `Sentry.init` of `instrumentation-client.ts`.
293+
*
290294
* NOTE: This feature only works with Next.js 11+
291295
*/
292296
tunnelRoute?: string | boolean;

‎packages/nextjs/test/utils/tunnelRoute.test.ts‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,53 @@ describe('Random tunnel route generation', () => {
122122
});
123123
});
124124

125+
describe('applyTunnelRouteOption() credentials', () => {
126+
const SAAS_DSN = 'https://11111111111111111111111111111111@o2222222.ingest.sentry.io/3333333';
127+
128+
beforeEach(() => {
129+
globalWithInjectedValues._sentryRewritesTunnelPath = '/my-error-monitoring-route';
130+
});
131+
132+
it("sends tunnel requests with `credentials: 'omit'`", () => {
133+
const options: BrowserOptions = { dsn: SAAS_DSN };
134+
135+
applyTunnelRouteOption(options);
136+
137+
expect(options.transportOptions).toEqual({ fetchOptions: { credentials: 'omit' } });
138+
});
139+
140+
it('keeps the fetch options of the user', () => {
141+
const options: BrowserOptions = {
142+
dsn: SAAS_DSN,
143+
transportOptions: { bufferSize: 10, fetchOptions: { credentials: 'same-origin', priority: 'low' } },
144+
};
145+
146+
applyTunnelRouteOption(options);
147+
148+
expect(options.transportOptions).toEqual({
149+
bufferSize: 10,
150+
fetchOptions: { credentials: 'same-origin', priority: 'low' },
151+
});
152+
});
153+
154+
it('does not change the transport options when the events are not tunneled', () => {
155+
const options: BrowserOptions = { dsn: 'https://11111111111111111111111111111111@example.com/3333333' };
156+
157+
applyTunnelRouteOption(options);
158+
159+
expect(options.transportOptions).toBeUndefined();
160+
});
161+
162+
it('does not change the transport options in handler mode', () => {
163+
globalWithInjectedValues._sentryTunnelRouteMode = 'handler';
164+
const options: BrowserOptions = { dsn: SAAS_DSN };
165+
166+
applyTunnelRouteOption(options);
167+
168+
expect(options.transportOptions).toBeUndefined();
169+
});
170+
});
171+
125172
describe('applyTunnelRouteOption() in handler mode', () => {
126173
it.each([
127174
['a SaaS DSN', 'https://11111111111111111111111111111111@o2222222.ingest.us.sentry.io/3333333'],

0 commit comments

Comments
 (0)