Skip to content
Open
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
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
6 changes: 6 additions & 0 deletions packages/nextjs/src/client/tunnelRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Expand Down
8 changes: 6 additions & 2 deletions packages/nextjs/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand Down
47 changes: 47 additions & 0 deletions packages/nextjs/test/utils/tunnelRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
Loading