Skip to content

Commit c00704d

Browse files
authored
fix: relaxed SNI hostname resolution (#197)
When establishing TLS connections, SNI resolution may fail if the configured altHostname contains `_` or any other characters not allowed by domain name standards (i.e. letters, digits and hyphens). This change introduces a relaxed SNI resolution strategy which ignores the LDH rules completely. Because this change goes hand in hand with auth. via certificates, I was able to reproduce the issue only via UTs. At this point the official Coder releases supports only auth. via API keys.
1 parent 7005d1e commit c00704d

File tree

5 files changed

+497
-6
lines changed

5 files changed

+497
-6
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## Unreleased
44

5+
### Fixed
6+
7+
- relaxed SNI hostname resolution
8+
59
## 0.6.5 - 2025-09-16
610

711
### Fixed

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
version=0.6.5
1+
version=0.6.6
22
group=com.coder.toolbox
33
name=coder-toolbox

src/main/kotlin/com/coder/toolbox/util/TLS.kt

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import com.coder.toolbox.settings.ReadOnlyTLSSettings
44
import okhttp3.internal.tls.OkHostnameVerifier
55
import java.io.File
66
import java.io.FileInputStream
7+
import java.net.IDN
78
import java.net.InetAddress
89
import java.net.Socket
10+
import java.nio.charset.StandardCharsets
911
import java.security.KeyFactory
1012
import java.security.KeyStore
1113
import java.security.cert.CertificateException
@@ -18,11 +20,12 @@ import java.util.Locale
1820
import javax.net.ssl.HostnameVerifier
1921
import javax.net.ssl.KeyManager
2022
import javax.net.ssl.KeyManagerFactory
21-
import javax.net.ssl.SNIHostName
23+
import javax.net.ssl.SNIServerName
2224
import javax.net.ssl.SSLContext
2325
import javax.net.ssl.SSLSession
2426
import javax.net.ssl.SSLSocket
2527
import javax.net.ssl.SSLSocketFactory
28+
import javax.net.ssl.StandardConstants
2629
import javax.net.ssl.TrustManager
2730
import javax.net.ssl.TrustManagerFactory
2831
import javax.net.ssl.X509TrustManager
@@ -83,11 +86,13 @@ fun sslContextFromPEMs(
8386

8487
fun coderSocketFactory(settings: ReadOnlyTLSSettings): SSLSocketFactory {
8588
val sslContext = sslContextFromPEMs(settings.certPath, settings.keyPath, settings.caPath)
86-
if (settings.altHostname.isNullOrBlank()) {
89+
90+
val altHostname = settings.altHostname
91+
if (altHostname.isNullOrBlank()) {
8792
return sslContext.socketFactory
8893
}
8994

90-
return AlternateNameSSLSocketFactory(sslContext.socketFactory, settings.altHostname)
95+
return AlternateNameSSLSocketFactory(sslContext.socketFactory, altHostname)
9196
}
9297

9398
fun coderTrustManagers(tlsCAPath: String?): Array<TrustManager> {
@@ -111,7 +116,7 @@ fun coderTrustManagers(tlsCAPath: String?): Array<TrustManager> {
111116
return trustManagerFactory.trustManagers.map { MergedSystemTrustManger(it as X509TrustManager) }.toTypedArray()
112117
}
113118

114-
class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String?) :
119+
class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, private val alternateName: String) :
115120
SSLSocketFactory() {
116121
override fun getDefaultCipherSuites(): Array<String> = delegate.defaultCipherSuites
117122

@@ -176,12 +181,19 @@ class AlternateNameSSLSocketFactory(private val delegate: SSLSocketFactory, priv
176181

177182
private fun customizeSocket(socket: SSLSocket) {
178183
val params = socket.sslParameters
179-
params.serverNames = listOf(SNIHostName(alternateName))
184+
185+
params.serverNames = listOf(RelaxedSNIHostname(alternateName))
180186
socket.sslParameters = params
181187
}
182188
}
183189

190+
private class RelaxedSNIHostname(hostname: String) : SNIServerName(
191+
StandardConstants.SNI_HOST_NAME,
192+
IDN.toASCII(hostname, 0).toByteArray(StandardCharsets.UTF_8)
193+
)
194+
184195
class CoderHostnameVerifier(private val alternateName: String?) : HostnameVerifier {
196+
185197
override fun verify(
186198
host: String,
187199
session: SSLSession,
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
package com.coder.toolbox.util
2+
3+
import io.mockk.Runs
4+
import io.mockk.every
5+
import io.mockk.just
6+
import io.mockk.mockk
7+
import io.mockk.verify
8+
import java.net.InetAddress
9+
import java.net.Socket
10+
import javax.net.ssl.SSLParameters
11+
import javax.net.ssl.SSLSocket
12+
import javax.net.ssl.SSLSocketFactory
13+
import kotlin.test.Test
14+
import kotlin.test.assertEquals
15+
import kotlin.test.assertNotNull
16+
import kotlin.test.assertSame
17+
18+
19+
class AlternateNameSSLSocketFactoryTest {
20+
21+
@Test
22+
fun `createSocket with no parameters should customize socket with alternate name`() {
23+
// Given
24+
val mockFactory = mockk<SSLSocketFactory>()
25+
val mockSocket = mockk<SSLSocket>(relaxed = true)
26+
val mockParams = mockk<SSLParameters>(relaxed = true)
27+
28+
every { mockFactory.createSocket() } returns mockSocket
29+
every { mockSocket.sslParameters } returns mockParams
30+
every { mockSocket.sslParameters = any() } just Runs
31+
32+
val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com")
33+
34+
// When
35+
val result = alternateFactory.createSocket()
36+
37+
// Then
38+
verify { mockSocket.sslParameters = any() }
39+
assertSame(mockSocket, result)
40+
}
41+
42+
@Test
43+
fun `createSocket with host and port should customize socket with alternate name`() {
44+
// Given
45+
val mockFactory = mockk<SSLSocketFactory>()
46+
val mockSocket = mockk<SSLSocket>(relaxed = true)
47+
val mockParams = mockk<SSLParameters>(relaxed = true)
48+
49+
every { mockFactory.createSocket("original.com", 443) } returns mockSocket
50+
every { mockSocket.sslParameters } returns mockParams
51+
every { mockSocket.sslParameters = any() } just Runs
52+
53+
val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com")
54+
55+
// When
56+
val result = alternateFactory.createSocket("original.com", 443)
57+
58+
// Then
59+
verify { mockSocket.sslParameters = any() }
60+
assertSame(mockSocket, result)
61+
}
62+
63+
@Test
64+
fun `createSocket with host port and local address should customize socket`() {
65+
// Given
66+
val mockFactory = mockk<SSLSocketFactory>()
67+
val mockSocket = mockk<SSLSocket>(relaxed = true)
68+
val mockParams = mockk<SSLParameters>(relaxed = true)
69+
val localHost = mockk<InetAddress>()
70+
71+
every { mockFactory.createSocket("original.com", 443, localHost, 8080) } returns mockSocket
72+
every { mockSocket.sslParameters } returns mockParams
73+
every { mockSocket.sslParameters = any() } just Runs
74+
75+
val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com")
76+
77+
// When
78+
val result = alternateFactory.createSocket("original.com", 443, localHost, 8080)
79+
80+
// Then
81+
verify { mockSocket.sslParameters = any() }
82+
assertSame(mockSocket, result)
83+
}
84+
85+
@Test
86+
fun `createSocket with InetAddress should customize socket with alternate name`() {
87+
// Given
88+
val mockFactory = mockk<SSLSocketFactory>()
89+
val mockSocket = mockk<SSLSocket>(relaxed = true)
90+
val mockParams = mockk<SSLParameters>(relaxed = true)
91+
val address = mockk<InetAddress>()
92+
93+
every { mockFactory.createSocket(address, 443) } returns mockSocket
94+
every { mockSocket.sslParameters } returns mockParams
95+
every { mockSocket.sslParameters = any() } just Runs
96+
97+
val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com")
98+
99+
// When
100+
val result = alternateFactory.createSocket(address, 443)
101+
102+
// Then
103+
verify { mockSocket.sslParameters = any() }
104+
assertSame(mockSocket, result)
105+
}
106+
107+
@Test
108+
fun `createSocket with InetAddress and local address should customize socket`() {
109+
// Given
110+
val mockFactory = mockk<SSLSocketFactory>()
111+
val mockSocket = mockk<SSLSocket>(relaxed = true)
112+
val mockParams = mockk<SSLParameters>(relaxed = true)
113+
val address = mockk<InetAddress>()
114+
val localAddress = mockk<InetAddress>()
115+
116+
every { mockFactory.createSocket(address, 443, localAddress, 8080) } returns mockSocket
117+
every { mockSocket.sslParameters } returns mockParams
118+
every { mockSocket.sslParameters = any() } just Runs
119+
120+
val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com")
121+
122+
// When
123+
val result = alternateFactory.createSocket(address, 443, localAddress, 8080)
124+
125+
// Then
126+
verify { mockSocket.sslParameters = any() }
127+
assertSame(mockSocket, result)
128+
}
129+
130+
@Test
131+
fun `createSocket with existing socket should customize socket with alternate name`() {
132+
// Given
133+
val mockFactory = mockk<SSLSocketFactory>()
134+
val mockSSLSocket = mockk<SSLSocket>(relaxed = true)
135+
val mockParams = mockk<SSLParameters>(relaxed = true)
136+
val existingSocket = mockk<Socket>()
137+
138+
every { mockFactory.createSocket(existingSocket, "original.com", 443, true) } returns mockSSLSocket
139+
every { mockSSLSocket.sslParameters } returns mockParams
140+
every { mockSSLSocket.sslParameters = any() } just Runs
141+
142+
val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "alternate.example.com")
143+
144+
// When
145+
val result = alternateFactory.createSocket(existingSocket, "original.com", 443, true)
146+
147+
// Then
148+
verify { mockSSLSocket.sslParameters = any() }
149+
assertSame(mockSSLSocket, result)
150+
}
151+
152+
@Test
153+
fun `customizeSocket should set SNI hostname to alternate name for valid hostname`() {
154+
// Given
155+
val mockFactory = mockk<SSLSocketFactory>()
156+
val mockSocket = mockk<SSLSocket>(relaxed = true)
157+
val mockParams = mockk<SSLParameters>(relaxed = true)
158+
159+
every { mockFactory.createSocket() } returns mockSocket
160+
every { mockSocket.sslParameters } returns mockParams
161+
every { mockSocket.sslParameters = any() } just Runs
162+
163+
val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "valid-hostname.example.com")
164+
165+
// When & Then - This should work without throwing an exception
166+
assertNotNull(alternateFactory.createSocket())
167+
verify { mockSocket.sslParameters = any() }
168+
}
169+
170+
@Test
171+
fun `customizeSocket should NOT throw IllegalArgumentException for hostname with underscore`() {
172+
// Given
173+
val mockFactory = mockk<SSLSocketFactory>()
174+
val mockSocket = mockk<SSLSocket>(relaxed = true)
175+
val mockParams = mockk<SSLParameters>(relaxed = true)
176+
177+
every { mockFactory.createSocket() } returns mockSocket
178+
every { mockSocket.sslParameters } returns mockParams
179+
every { mockSocket.sslParameters = any() } just Runs
180+
181+
val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "non_compliant_hostname.example.com")
182+
183+
// When & Then - This should work without throwing an exception
184+
assertNotNull(alternateFactory.createSocket())
185+
verify { mockSocket.sslParameters = any() }
186+
assertEquals(0, mockSocket.sslParameters.serverNames.size)
187+
}
188+
189+
@Test
190+
fun `createSocket should work with valid international domain names`() {
191+
// Given
192+
val mockFactory = mockk<SSLSocketFactory>()
193+
val mockSocket = mockk<SSLSocket>(relaxed = true)
194+
val mockParams = mockk<SSLParameters>(relaxed = true)
195+
196+
every { mockFactory.createSocket() } returns mockSocket
197+
every { mockSocket.sslParameters } returns mockParams
198+
every { mockSocket.sslParameters = any() } just Runs
199+
200+
val alternateFactory = AlternateNameSSLSocketFactory(mockFactory, "test-server.example.com")
201+
202+
// When & Then - This should work as hyphens are valid
203+
assertNotNull(alternateFactory.createSocket())
204+
verify { mockSocket.sslParameters = any() }
205+
}
206+
207+
private fun createMockSSLSocketFactory(): SSLSocketFactory {
208+
val mockFactory = mockk<SSLSocketFactory>()
209+
val mockSocket = mockk<SSLSocket>(relaxed = true)
210+
val mockParams = mockk<SSLParameters>(relaxed = true)
211+
212+
// Setup default behavior
213+
every { mockFactory.defaultCipherSuites } returns arrayOf("TLS_AES_256_GCM_SHA384")
214+
every { mockFactory.supportedCipherSuites } returns arrayOf("TLS_AES_256_GCM_SHA384", "TLS_AES_128_GCM_SHA256")
215+
216+
// Make all createSocket methods return our mock socket
217+
every { mockFactory.createSocket() } returns mockSocket
218+
every { mockFactory.createSocket(any<String>(), any<Int>()) } returns mockSocket
219+
every { mockFactory.createSocket(any<String>(), any<Int>(), any<InetAddress>(), any<Int>()) } returns mockSocket
220+
every { mockFactory.createSocket(any<InetAddress>(), any<Int>()) } returns mockSocket
221+
every {
222+
mockFactory.createSocket(
223+
any<InetAddress>(),
224+
any<Int>(),
225+
any<InetAddress>(),
226+
any<Int>()
227+
)
228+
} returns mockSocket
229+
every { mockFactory.createSocket(any<Socket>(), any<String>(), any<Int>(), any<Boolean>()) } returns mockSocket
230+
231+
// Setup SSL parameters
232+
every { mockSocket.sslParameters } returns mockParams
233+
every { mockSocket.sslParameters = any() } just Runs
234+
235+
return mockFactory
236+
}
237+
}

0 commit comments

Comments
 (0)