Skip to content

Commit ede4973

Browse files
authored
fix: handle brackets in ssl settings (#1174)
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 4c652fb commit ede4973

5 files changed

Lines changed: 139 additions & 15 deletions

File tree

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@
9191
"aws-sigv4-sign": "^1.2.1",
9292
"conventional-changelog-conventionalcommits": "^5.0.0",
9393
"dotenv": "^16.0.0",
94+
"fast-decode-uri-component": "^1.0.1",
9495
"fast-json-stringify": "^6.3.0",
9596
"fast-querystring": "^1.1.2",
9697
"fastify": "^5.8.5",

src/internal/database/ssl.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ describe('database utils', () => {
1010
['121.212.187.123', true],
1111
['121.212.187.5', true],
1212
['2001:db8:3333:4444:5555:6666:7777:8888', true],
13+
['[2001:db8:3333:4444:5555:6666:7777:8888]', true],
14+
['%5B2001%3Adb8%3A3333%3A4444%3A5555%3A6666%3A7777%3A8888%5D', true],
1315
['2001:db8:3333:4444:CCCC:DDDD:EEEE:FFFF', true],
1416
['2406%3Ada18%3A4fd%3A9b09%3A2c76%3A5d38%3Ade30%3A7904', true],
1517
])('is %s ip address, expected %s', (text: string, expected: boolean) => {
@@ -32,6 +34,55 @@ describe('database utils', () => {
3234
).toStrictEqual({ ca: '<cert>', rejectUnauthorized: false })
3335
})
3436

37+
test('should detect IP host without constructing a URL', () => {
38+
const originalUrl = global.URL
39+
40+
try {
41+
global.URL = class {
42+
constructor() {
43+
throw new Error('URL parsing should not be used')
44+
}
45+
} as unknown as typeof URL
46+
47+
expect(
48+
getSslSettings({
49+
connectionString: 'postgres://foo:bar@1.2.3.4:5432/postgres',
50+
databaseSSLRootCert: '<cert>',
51+
})
52+
).toStrictEqual({ ca: '<cert>', rejectUnauthorized: false })
53+
} finally {
54+
global.URL = originalUrl
55+
}
56+
})
57+
58+
test('should return SSL settings if hostname is a bracketed IPv6 address', () => {
59+
expect(
60+
getSslSettings({
61+
connectionString:
62+
'postgres://foo:bar@[2001:db8:3333:4444:5555:6666:7777:8888]:5432/postgres',
63+
databaseSSLRootCert: '<cert>',
64+
})
65+
).toStrictEqual({ ca: '<cert>', rejectUnauthorized: false })
66+
})
67+
68+
test('should detect an IP host even when the password contains an @', () => {
69+
expect(
70+
getSslSettings({
71+
connectionString: 'postgres://user:p@ss@1.2.3.4:5432/postgres',
72+
databaseSSLRootCert: '<cert>',
73+
})
74+
).toStrictEqual({ ca: '<cert>', rejectUnauthorized: false })
75+
})
76+
77+
test('should not treat an IP in the userinfo as the host', () => {
78+
expect(
79+
getSslSettings({
80+
connectionString: 'postgres://1.2.3.4@db.ref.supabase.red:5432/postgres',
81+
databaseSSLRootCert: '<cert>',
82+
})
83+
).toStrictEqual({ ca: '<cert>' })
84+
})
85+
3586
test('should return SSL settings if hostname is not an IP address', () => {
3687
expect(
3788
getSslSettings({

src/internal/database/ssl.ts

Lines changed: 77 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { isIP } from 'node:net'
2-
import { logger } from '@internal/monitoring'
2+
import fastDecodeURIComponent from 'fast-decode-uri-component'
33
import { ConnectionOptions } from 'tls'
44

55
export function getSslSettings({
@@ -11,24 +11,86 @@ export function getSslSettings({
1111
}): ConnectionOptions | undefined {
1212
if (!databaseSSLRootCert) return undefined
1313

14-
try {
15-
// When connecting through PGBouncer, we connect through an IPv6 address rather than a hostname
16-
// When passing in the root CA for SSL, this will always fail, so we need to skip passing in the SSL root cert
17-
// in case the hostname is an IP address
18-
const url = new URL(connectionString)
19-
if (url.hostname && isIpAddress(url.hostname)) {
20-
return { ca: databaseSSLRootCert, rejectUnauthorized: false }
21-
}
22-
} catch (err) {
23-
// ignore to ensure this never breaks the connection in case of an invalid URL
24-
logger.warn(err, 'Failed to parse connection string')
14+
// When connecting through PGBouncer, we connect through an IPv6 address rather than a hostname.
15+
// When passing in the root CA for SSL, this will always fail,
16+
// so we need to skip passing the SSL root cert if host name is an IP address.
17+
const hostname = getConnectionStringHostname(connectionString)
18+
if (hostname && isIpAddress(hostname)) {
19+
return { ca: databaseSSLRootCert, rejectUnauthorized: false }
2520
}
2621

2722
return { ca: databaseSSLRootCert }
2823
}
2924

3025
export function isIpAddress(ip: string) {
31-
// IP might be URL-encoded
32-
const decodedIp = decodeURIComponent(ip)
33-
return isIP(decodedIp) !== 0
26+
const decoded = fastDecodeURIComponent(ip) ?? ip
27+
28+
if (decoded.startsWith('[') && decoded.endsWith(']')) {
29+
return isIP(decoded.slice(1, -1)) !== 0
30+
}
31+
32+
return isIP(decoded) !== 0
33+
}
34+
35+
function getConnectionStringHostname(connectionString: string): string | undefined {
36+
const protocolSeparatorIndex = connectionString.indexOf('://')
37+
if (protocolSeparatorIndex === -1) {
38+
return undefined
39+
}
40+
41+
const authorityStartIndex = protocolSeparatorIndex + 3
42+
let authorityEndIndex = connectionString.length
43+
44+
for (let index = authorityStartIndex; index < connectionString.length; index++) {
45+
const charCode = connectionString.charCodeAt(index)
46+
if (charCode === 47 /* / */ || charCode === 63 /* ? */ || charCode === 35 /* # */) {
47+
authorityEndIndex = index
48+
break
49+
}
50+
}
51+
52+
if (authorityStartIndex >= authorityEndIndex) {
53+
return undefined
54+
}
55+
56+
let hostStartIndex = authorityStartIndex
57+
for (let index = authorityEndIndex - 1; index >= authorityStartIndex; index--) {
58+
if (connectionString.charCodeAt(index) === 64 /* @ */) {
59+
hostStartIndex = index + 1
60+
break
61+
}
62+
}
63+
64+
if (hostStartIndex >= authorityEndIndex) {
65+
return undefined
66+
}
67+
68+
if (connectionString.charCodeAt(hostStartIndex) === 91 /* [ */) {
69+
const bracketEndIndex = connectionString.indexOf(']', hostStartIndex + 1)
70+
71+
if (
72+
bracketEndIndex === -1 ||
73+
bracketEndIndex >= authorityEndIndex ||
74+
(bracketEndIndex + 1 < authorityEndIndex &&
75+
connectionString.charCodeAt(bracketEndIndex + 1) !== 58) /* : */
76+
) {
77+
return undefined
78+
}
79+
80+
return connectionString.slice(hostStartIndex + 1, bracketEndIndex)
81+
}
82+
83+
let hostEndIndex = authorityEndIndex
84+
for (let index = hostStartIndex; index < authorityEndIndex; index++) {
85+
if (connectionString.charCodeAt(index) === 58 /* : */) {
86+
hostEndIndex = index
87+
break
88+
}
89+
}
90+
91+
if (hostStartIndex >= hostEndIndex) {
92+
return undefined
93+
}
94+
95+
return connectionString.slice(hostStartIndex, hostEndIndex)
3496
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
declare module 'fast-decode-uri-component' {
2+
/**
3+
* Decodes a URI component without allocating when there is nothing to decode
4+
* (returns the input unchanged when it contains no `%`). Returns `null`
5+
* instead of throwing when the input is malformed.
6+
*/
7+
function fastDecodeURIComponent(uri: string): string | null
8+
export default fastDecodeURIComponent
9+
}

0 commit comments

Comments
 (0)