Skip to content

Commit 06f0feb

Browse files
committed
impl: strict URL validation for the connection screen
This commit rejects any URL that is opaque, not hierarchical, not using http or https protocol, or it misses the hostname.
1 parent 5945b6c commit 06f0feb

File tree

4 files changed

+121
-10
lines changed

4 files changed

+121
-10
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
- support for checking if CLI is signed
1010
- improved progress reporting while downloading the CLI
11+
- URL validation is stricter in the connection screen
1112

1213
## 2.21.1 - 2025-06-26
1314

src/main/kotlin/com/coder/gateway/util/URLExtensions.kt

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package com.coder.gateway.util
22

3+
import com.coder.gateway.util.WebUrlValidationResult.Invalid
34
import java.net.IDN
45
import java.net.URI
56
import java.net.URL
67

7-
fun String.toURL(): URL = URL(this)
8+
9+
fun String.toURL(): URL = URI.create(this).toURL()
810

911
fun URL.withPath(path: String): URL = URL(
1012
this.protocol,
@@ -13,6 +15,23 @@ fun URL.withPath(path: String): URL = URL(
1315
if (path.startsWith("/")) path else "/$path",
1416
)
1517

18+
fun URI.validateStrictWebUrl(): WebUrlValidationResult = try {
19+
when {
20+
isOpaque -> Invalid("$this is opaque, instead of hierarchical")
21+
!isAbsolute -> Invalid("$this is relative, it must be absolute")
22+
scheme?.lowercase() !in setOf("http", "https") -> Invalid("Scheme for $this must be either http or https")
23+
authority.isNullOrBlank() -> Invalid("$this does not have a hostname")
24+
else -> WebUrlValidationResult.Valid
25+
}
26+
} catch (e: Exception) {
27+
Invalid(e.message ?: "$this could not be parsed as a URI reference")
28+
}
29+
30+
sealed class WebUrlValidationResult {
31+
object Valid : WebUrlValidationResult()
32+
data class Invalid(val reason: String) : WebUrlValidationResult()
33+
}
34+
1635
/**
1736
* Return the host, converting IDN to ASCII in case the file system cannot
1837
* support the necessary character set.

src/main/kotlin/com/coder/gateway/views/steps/CoderWorkspacesStepView.kt

Lines changed: 46 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,11 @@ import com.coder.gateway.util.DialogUi
2020
import com.coder.gateway.util.InvalidVersionException
2121
import com.coder.gateway.util.OS
2222
import com.coder.gateway.util.SemVer
23+
import com.coder.gateway.util.WebUrlValidationResult
2324
import com.coder.gateway.util.humanizeConnectionError
2425
import com.coder.gateway.util.isCancellation
2526
import com.coder.gateway.util.toURL
27+
import com.coder.gateway.util.validateStrictWebUrl
2628
import com.coder.gateway.util.withoutNull
2729
import com.intellij.icons.AllIcons
2830
import com.intellij.ide.ActivityTracker
@@ -78,6 +80,8 @@ import javax.swing.JLabel
7880
import javax.swing.JTable
7981
import javax.swing.JTextField
8082
import javax.swing.ListSelectionModel
83+
import javax.swing.event.DocumentEvent
84+
import javax.swing.event.DocumentListener
8185
import javax.swing.table.DefaultTableCellRenderer
8286
import javax.swing.table.TableCellRenderer
8387

@@ -133,7 +137,6 @@ class CoderWorkspacesStepView :
133137
private var tfUrl: JTextField? = null
134138
private var tfUrlComment: JLabel? = null
135139
private var cbExistingToken: JCheckBox? = null
136-
private var cbFallbackOnSignature: JCheckBox? = null
137140

138141
private val notificationBanner = NotificationBanner()
139142
private var tableOfWorkspaces =
@@ -219,6 +222,31 @@ class CoderWorkspacesStepView :
219222
// Reconnect when the enter key is pressed.
220223
maybeAskTokenThenConnect()
221224
}
225+
// Add document listener to clear error when user types
226+
document.addDocumentListener(object : DocumentListener {
227+
override fun insertUpdate(e: DocumentEvent?) = clearErrorState()
228+
override fun removeUpdate(e: DocumentEvent?) = clearErrorState()
229+
override fun changedUpdate(e: DocumentEvent?) = clearErrorState()
230+
231+
private fun clearErrorState() {
232+
tfUrlComment?.apply {
233+
foreground = UIUtil.getContextHelpForeground()
234+
if (tfUrl?.text.equals(client?.url?.toString())) {
235+
text =
236+
CoderGatewayBundle.message(
237+
"gateway.connector.view.coder.workspaces.connect.text.connected",
238+
client!!.url.host,
239+
)
240+
} else {
241+
text = CoderGatewayBundle.message(
242+
"gateway.connector.view.coder.workspaces.connect.text.comment",
243+
CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text"),
244+
)
245+
}
246+
icon = null
247+
}
248+
}
249+
})
222250
}.component
223251
button(CoderGatewayBundle.message("gateway.connector.view.coder.workspaces.connect.text")) {
224252
// Reconnect when the connect button is pressed.
@@ -268,15 +296,15 @@ class CoderWorkspacesStepView :
268296
}
269297
row {
270298
cell() // For alignment.
271-
checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title"))
272-
.bindSelected(state::fallbackOnCoderForSignatures).applyToComponent {
273-
addActionListener { event ->
274-
state.fallbackOnCoderForSignatures = (event.source as JBCheckBox).isSelected
275-
}
299+
checkBox(CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.title"))
300+
.bindSelected(state::fallbackOnCoderForSignatures).applyToComponent {
301+
addActionListener { event ->
302+
state.fallbackOnCoderForSignatures = (event.source as JBCheckBox).isSelected
276303
}
277-
.comment(
278-
CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"),
279-
)
304+
}
305+
.comment(
306+
CoderGatewayBundle.message("gateway.connector.settings.fallback-on-coder-for-signatures.comment"),
307+
)
280308

281309
}.layout(RowLayout.PARENT_GRID)
282310
row {
@@ -539,6 +567,15 @@ class CoderWorkspacesStepView :
539567
component.apply() // Force bindings to be filled.
540568
val newURL = fields.coderURL.toURL()
541569
if (settings.requireTokenAuth) {
570+
val result = newURL.toURI().validateStrictWebUrl()
571+
if (result is WebUrlValidationResult.Invalid) {
572+
tfUrlComment.apply {
573+
this?.foreground = UIUtil.getErrorForeground()
574+
this?.text = result.reason
575+
this?.icon = UIUtil.getBalloonErrorIcon()
576+
}
577+
return
578+
}
542579
val pastedToken =
543580
dialogUi.askToken(
544581
newURL,

src/test/kotlin/com/coder/gateway/util/URLExtensionsTest.kt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,58 @@ internal class URLExtensionsTest {
6060
)
6161
}
6262
}
63+
64+
@Test
65+
fun `valid http URL should return Valid`() {
66+
val uri = URI("http://coder.com")
67+
val result = uri.validateStrictWebUrl()
68+
assertEquals(WebUrlValidationResult.Valid, result)
69+
}
70+
71+
@Test
72+
fun `valid https URL with path and query should return Valid`() {
73+
val uri = URI("https://coder.com/bin/coder-linux-amd64?query=1")
74+
val result = uri.validateStrictWebUrl()
75+
assertEquals(WebUrlValidationResult.Valid, result)
76+
}
77+
78+
@Test
79+
fun `relative URL should return Invalid with appropriate message`() {
80+
val uri = URI("/bin/coder-linux-amd64")
81+
val result = uri.validateStrictWebUrl()
82+
assertEquals(
83+
WebUrlValidationResult.Invalid("$uri is relative, it must be absolute"),
84+
result
85+
)
86+
}
87+
88+
@Test
89+
fun `opaque URI like mailto should return Invalid`() {
90+
val uri = URI("mailto:user@coder.com")
91+
val result = uri.validateStrictWebUrl()
92+
assertEquals(
93+
WebUrlValidationResult.Invalid("$uri is opaque, instead of hierarchical"),
94+
result
95+
)
96+
}
97+
98+
@Test
99+
fun `unsupported scheme like ftp should return Invalid`() {
100+
val uri = URI("ftp://coder.com")
101+
val result = uri.validateStrictWebUrl()
102+
assertEquals(
103+
WebUrlValidationResult.Invalid("Scheme for $uri must be either http or https"),
104+
result
105+
)
106+
}
107+
108+
@Test
109+
fun `http URL with missing authority should return Invalid`() {
110+
val uri = URI("http:///bin/coder-linux-amd64")
111+
val result = uri.validateStrictWebUrl()
112+
assertEquals(
113+
WebUrlValidationResult.Invalid("$uri does not have a hostname"),
114+
result
115+
)
116+
}
63117
}

0 commit comments

Comments
 (0)