Skip to content

Commit 121eb92

Browse files
xgedevLms24
andauthored
feat(core): Support IPv6 hosts in the DSN (#2996) (#17708)
Setting an IPv6 URL as a DSN previously didn't work, see #2996. This patch updates the `DSN_REGEX` to correctly match IPv6 and fixes the then occuring issue that the brackets "[" and "]" are in the request's hostname and prevent the request from being made. --------- Co-authored-by: Lukas Stracke <lukas.stracke@sentry.io>
1 parent 1657815 commit 121eb92

File tree

11 files changed

+156
-11
lines changed

11 files changed

+156
-11
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import * as Sentry from '@sentry/browser';
2+
3+
window.Sentry = Sentry;
4+
5+
Sentry.init({
6+
dsn: 'https://public@[2001:db8::1]/1337',
7+
sendClientReports: false,
8+
defaultIntegrations: false,
9+
});
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Sentry.captureException(new Error('Test error'));
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<!DOCTYPE html>
2+
<html>
3+
<head>
4+
<meta charset="utf-8" />
5+
<title></title>
6+
</head>
7+
<body>
8+
</body>
9+
</html>
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { expect } from '@playwright/test';
2+
import { sentryTest } from '../../utils/fixtures';
3+
import { envelopeRequestParser } from '../../utils/helpers';
4+
5+
sentryTest('sends event to an IPv6 DSN', async ({ getLocalTestUrl, page }) => {
6+
const url = await getLocalTestUrl({ testDir: __dirname });
7+
8+
// Technically, we could also use `waitForErrorRequest` but it listens to every POST request, regardless
9+
// of URL. Therefore, waiting on the ipv6 URL request, makes the test a bit more robust.
10+
// We simplify things further by setting up the SDK for errors-only, so that no other request is made.
11+
const requestPromise = page.waitForRequest(req => req.method() === 'POST' && req.url().includes('[2001:db8::1]'));
12+
13+
await page.goto(url);
14+
15+
const errorRequest = envelopeRequestParser(await requestPromise);
16+
17+
expect(errorRequest.exception?.values?.[0]?.value).toBe('Test error');
18+
19+
await page.waitForTimeout(1000);
20+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@[2001:db8::1]/1337',
6+
defaultIntegrations: false,
7+
sendClientReports: false,
8+
release: '1.0',
9+
transport: loggingTransport,
10+
});
11+
12+
Sentry.captureException(new Error(Sentry.getClient()?.getDsn()?.host));
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('should capture a simple error with message', async () => {
9+
await createRunner(__dirname, 'scenario.ts')
10+
.expect({
11+
event: event => {
12+
expect(event.exception?.values?.[0]?.value).toBe('[2001:db8::1]');
13+
},
14+
})
15+
.start()
16+
.completed();
17+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import * as Sentry from '@sentry/node';
2+
import { loggingTransport } from '@sentry-internal/node-integration-tests';
3+
4+
Sentry.init({
5+
dsn: 'https://public@[2001:db8::1]/1337',
6+
defaultIntegrations: false,
7+
sendClientReports: false,
8+
release: '1.0',
9+
transport: loggingTransport,
10+
});
11+
12+
Sentry.captureException(new Error(Sentry.getClient()?.getDsn()?.host));
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { afterAll, expect, test } from 'vitest';
2+
import { cleanupChildProcesses, createRunner } from '../../utils/runner';
3+
4+
afterAll(() => {
5+
cleanupChildProcesses();
6+
});
7+
8+
test('should capture a simple error with message', async () => {
9+
await createRunner(__dirname, 'scenario.ts')
10+
.expect({
11+
event: event => {
12+
expect(event.exception?.values?.[0]?.value).toBe('[2001:db8::1]');
13+
},
14+
})
15+
.start()
16+
.completed();
17+
});

packages/core/src/utils/dsn.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { consoleSandbox, debug } from './debug-logger';
77
const ORG_ID_REGEX = /^o(\d+)\./;
88

99
/** Regular expression used to parse a Dsn. */
10-
const DSN_REGEX = /^(?:(\w+):)\/\/(?:(\w+)(?::(\w+)?)?@)([\w.-]+)(?::(\d+))?\/(.+)/;
10+
const DSN_REGEX = /^(?:(\w+):)\/\/(?:(\w+)(?::(\w+)?)?@)((?:\[[:.%\w]+\]|[\w.-]+))(?::(\d+))?\/(.+)/;
1111

1212
function isValidProtocol(protocol?: string): protocol is DsnProtocol {
1313
return protocol === 'http' || protocol === 'https';

packages/core/test/lib/utils/dsn.test.ts

Lines changed: 54 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,23 @@
11
import { beforeEach, describe, expect, it, test, vi } from 'vitest';
2-
import { DEBUG_BUILD } from '../../../src/debug-build';
32
import { debug } from '../../../src/utils/debug-logger';
43
import { dsnToString, extractOrgIdFromClient, extractOrgIdFromDsnHost, makeDsn } from '../../../src/utils/dsn';
54
import { getDefaultTestClientOptions, TestClient } from '../../mocks/client';
65

7-
function testIf(condition: boolean) {
8-
return condition ? test : test.skip;
9-
}
6+
let mockDebugBuild = true;
7+
8+
vi.mock('../../../src/debug-build', () => ({
9+
get DEBUG_BUILD() {
10+
return mockDebugBuild;
11+
},
12+
}));
1013

1114
const loggerErrorSpy = vi.spyOn(debug, 'error').mockImplementation(() => {});
1215
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
1316

1417
describe('Dsn', () => {
1518
beforeEach(() => {
1619
vi.clearAllMocks();
20+
mockDebugBuild = true;
1721
});
1822

1923
describe('fromComponents', () => {
@@ -51,7 +55,7 @@ describe('Dsn', () => {
5155
expect(dsn?.projectId).toBe('123');
5256
});
5357

54-
testIf(DEBUG_BUILD)('returns `undefined` for missing components', () => {
58+
it('returns `undefined` for missing components', () => {
5559
expect(
5660
makeDsn({
5761
host: '',
@@ -88,7 +92,7 @@ describe('Dsn', () => {
8892
expect(loggerErrorSpy).toHaveBeenCalledTimes(4);
8993
});
9094

91-
testIf(DEBUG_BUILD)('returns `undefined` if components are invalid', () => {
95+
it('returns `undefined` if components are invalid', () => {
9296
expect(
9397
makeDsn({
9498
host: 'sentry.io',
@@ -167,20 +171,61 @@ describe('Dsn', () => {
167171
expect(dsn?.projectId).toBe('321');
168172
});
169173

170-
testIf(DEBUG_BUILD)('returns undefined when provided invalid Dsn', () => {
174+
test('with IPv4 hostname', () => {
175+
const dsn = makeDsn('https://abc@192.168.1.1/123');
176+
expect(dsn?.protocol).toBe('https');
177+
expect(dsn?.publicKey).toBe('abc');
178+
expect(dsn?.pass).toBe('');
179+
expect(dsn?.host).toBe('192.168.1.1');
180+
expect(dsn?.port).toBe('');
181+
expect(dsn?.path).toBe('');
182+
expect(dsn?.projectId).toBe('123');
183+
});
184+
185+
test.each([
186+
'[2001:db8::1]',
187+
'[::1]', // loopback
188+
'[::ffff:192.0.2.1]', // IPv4-mapped IPv6 (contains dots)
189+
'[fe80::1]', // link-local
190+
'[2001:db8:85a3::8a2e:370:7334]', // compressed in middle
191+
'[2001:db8::]', // trailing zeros compressed
192+
'[2001:0db8:0000:0000:0000:0000:0000:0001]', // full form with leading zeros
193+
'[fe80::1%eth0]', // zone identifier with interface name (contains percent sign)
194+
'[fe80::1%25eth0]', // zone identifier URL-encoded (percent as %25)
195+
'[fe80::a:b:c:d%en0]', // zone identifier with different interface
196+
])('with IPv6 hostname %s', hostname => {
197+
const dsn = makeDsn(`https://abc@${hostname}/123`);
198+
expect(dsn?.protocol).toBe('https');
199+
expect(dsn?.publicKey).toBe('abc');
200+
expect(dsn?.pass).toBe('');
201+
expect(dsn?.host).toBe(hostname);
202+
expect(dsn?.port).toBe('');
203+
expect(dsn?.path).toBe('');
204+
expect(dsn?.projectId).toBe('123');
205+
});
206+
207+
test('skips validation for non-debug builds', () => {
208+
mockDebugBuild = false;
209+
const dsn = makeDsn('httx://abc@192.168.1.1/123');
210+
expect(dsn?.protocol).toBe('httx');
211+
expect(dsn?.publicKey).toBe('abc');
212+
expect(dsn?.pass).toBe('');
213+
});
214+
215+
it('returns undefined when provided invalid Dsn', () => {
171216
expect(makeDsn('some@random.dsn')).toBeUndefined();
172217
expect(consoleErrorSpy).toHaveBeenCalledTimes(1);
173218
});
174219

175-
testIf(DEBUG_BUILD)('returns undefined if mandatory fields are missing', () => {
220+
it('returns undefined if mandatory fields are missing', () => {
176221
expect(makeDsn('://abc@sentry.io/123')).toBeUndefined();
177222
expect(makeDsn('https://@sentry.io/123')).toBeUndefined();
178223
expect(makeDsn('https://abc@123')).toBeUndefined();
179224
expect(makeDsn('https://abc@sentry.io/')).toBeUndefined();
180225
expect(consoleErrorSpy).toHaveBeenCalledTimes(4);
181226
});
182227

183-
testIf(DEBUG_BUILD)('returns undefined if fields are invalid', () => {
228+
it('returns undefined if fields are invalid', () => {
184229
expect(makeDsn('httpx://abc@sentry.io/123')).toBeUndefined();
185230
expect(makeDsn('httpx://abc@sentry.io:xxx/123')).toBeUndefined();
186231
expect(makeDsn('http://abc@sentry.io/abc')).toBeUndefined();

0 commit comments

Comments
 (0)