Skip to content

Commit 32fcecd

Browse files
committed
feat(v10/nextjs): Send tunnel route requests with credentials: 'omit'
Backport of: #25509
1 parent 014ba82 commit 32fcecd

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
@@ -21,6 +21,18 @@
2121

2222
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.
2323

24+
- **feat(v10/nextjs): Send tunnel route requests with `credentials: 'omit'` ([#25509](https://github.com/getsentry/sentry-javascript/pull/25509))**
25+
26+
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.
27+
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:
28+
29+
```js
30+
// instrumentation-client.ts
31+
Sentry.init({
32+
transportOptions: { fetchOptions: { credentials: 'same-origin' } },
33+
});
34+
```
35+
2436
## 10.76.2
2537

2638
- feat(v10/deps): Bump bundler plugins to 5.4.1 ([#25147](https://github.com/getsentry/sentry-javascript/pull/25147))

‎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
@@ -133,6 +133,38 @@ test('Tunnel requests should not create middleware or fetch spans', async ({ pag
133133
expect(sentryFetchTransactions).toHaveLength(0);
134134
});
135135

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

‎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
@@ -612,8 +612,8 @@ export type SentryBuildOptions = {
612612
* - Pass `true` to auto-generate a random, ad-blocker-resistant route for each build
613613
* - Pass a string path (e.g., '/monitoring') to use a custom route
614614
*
615-
* By default, the tunnel route proxies each request to Sentry with all its headers, cookies included. To forward
616-
* only the event payload, create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route
615+
* By default, the tunnel route proxies each request to Sentry with its headers. To forward only the event payload,
616+
* create `app/sentry-tunnel/route.ts` (or `src/app/sentry-tunnel/route.ts`, not in a route
617617
* group) with:
618618
*
619619
* ```ts
@@ -628,6 +628,10 @@ export type SentryBuildOptions = {
628628
* `matcher` so events can reach Sentry. Matchers have to be static, so use a fixed string route in that case
629629
* rather than `true`.
630630
*
631+
* Without the route handler, the browser sends tunnel requests with `credentials: 'omit'`, so without cookies or HTTP
632+
* authentication. If a layer in front of your app needs them (for example Vercel Deployment Protection or HTTP Basic authentication), set
633+
* `transportOptions: { fetchOptions: { credentials: 'same-origin' } }` in `Sentry.init` of `instrumentation-client.ts`.
634+
*
631635
* NOTE: This feature only works with Next.js 11+
632636
*/
633637
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)