Skip to content

Commit 82a8590

Browse files
committed
impl: strict URL validation
This commit rejects any URL that is opaque, not hierarchical, not using http or https protocol, or it misses the hostname. The rejection is handled in the connection/auth screen and also in the URI protocol handling logic.
1 parent c5f8e12 commit 82a8590

File tree

5 files changed

+134
-6
lines changed

5 files changed

+134
-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+
### Changed
6+
7+
- URL validation is stricter in the connection screen and URI protocol handler
8+
59
## 0.6.0 - 2025-07-25
610

711
### Changed

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import com.coder.toolbox.sdk.CoderRestClient
99
import com.coder.toolbox.sdk.v2.models.Workspace
1010
import com.coder.toolbox.sdk.v2.models.WorkspaceAgent
1111
import com.coder.toolbox.sdk.v2.models.WorkspaceStatus
12+
import com.coder.toolbox.util.WebUrlValidationResult.Invalid
1213
import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper
1314
import kotlinx.coroutines.Job
1415
import kotlinx.coroutines.TimeoutCancellationException
@@ -107,6 +108,11 @@ open class CoderProtocolHandler(
107108
context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI")
108109
return null
109110
}
111+
val validationResult = deploymentURL.validateStrictWebUrl()
112+
if (validationResult is Invalid) {
113+
context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}")
114+
return null
115+
}
110116
return deploymentURL
111117
}
112118

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,31 @@
11
package com.coder.toolbox.util
22

3+
import com.coder.toolbox.util.WebUrlValidationResult.Invalid
4+
import com.coder.toolbox.util.WebUrlValidationResult.Valid
35
import java.net.IDN
46
import java.net.URI
57
import java.net.URL
68

79
fun String.toURL(): URL = URI.create(this).toURL()
810

11+
fun String.validateStrictWebUrl(): WebUrlValidationResult = try {
12+
val uri = URI(this)
13+
14+
when {
15+
uri.isOpaque -> Invalid("$this is opaque, instead of hierarchical")
16+
!uri.isAbsolute -> Invalid("$this is relative, it must be absolute")
17+
uri.scheme?.lowercase() !in setOf("http", "https") ->
18+
Invalid("Scheme for $this must be either http or https")
19+
20+
uri.authority.isNullOrBlank() ->
21+
Invalid("$this does not have a hostname")
22+
23+
else -> Valid
24+
}
25+
} catch (e: Exception) {
26+
Invalid(e.message ?: "$this could not be parsed as a URI reference")
27+
}
28+
929
fun URL.withPath(path: String): URL = URL(
1030
this.protocol,
1131
this.host,
@@ -30,3 +50,8 @@ fun URI.toQueryParameters(): Map<String, String> = (this.query ?: "")
3050
parts[0] to ""
3151
}
3252
}
53+
54+
sealed class WebUrlValidationResult {
55+
object Valid : WebUrlValidationResult()
56+
data class Invalid(val reason: String) : WebUrlValidationResult()
57+
}

src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package com.coder.toolbox.views
22

33
import com.coder.toolbox.CoderToolboxContext
44
import com.coder.toolbox.settings.SignatureFallbackStrategy
5+
import com.coder.toolbox.util.WebUrlValidationResult.Invalid
56
import com.coder.toolbox.util.toURL
7+
import com.coder.toolbox.util.validateStrictWebUrl
68
import com.coder.toolbox.views.state.CoderCliSetupContext
79
import com.coder.toolbox.views.state.CoderCliSetupWizardState
810
import com.jetbrains.toolbox.api.ui.components.CheckboxField
@@ -69,16 +71,11 @@ class DeploymentUrlStep(
6971

7072
override fun onNext(): Boolean {
7173
context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value)
72-
var url = urlField.textState.value
74+
val url = urlField.textState.value
7375
if (url.isBlank()) {
7476
errorField.textState.update { context.i18n.ptrl("URL is required") }
7577
return false
7678
}
77-
url = if (!url.startsWith("http://") && !url.startsWith("https://")) {
78-
"https://$url"
79-
} else {
80-
url
81-
}
8279
try {
8380
CoderCliSetupContext.url = validateRawUrl(url)
8481
} catch (e: MalformedURLException) {
@@ -98,6 +95,10 @@ class DeploymentUrlStep(
9895
*/
9996
private fun validateRawUrl(url: String): URL {
10097
try {
98+
val result = url.validateStrictWebUrl()
99+
if (result is Invalid) {
100+
throw MalformedURLException(result.reason)
101+
}
101102
return url.toURL()
102103
} catch (e: Exception) {
103104
throw MalformedURLException(e.message)

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

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,96 @@ internal class URLExtensionsTest {
6060
)
6161
}
6262
}
63+
64+
@Test
65+
fun `valid http URL should return Valid`() {
66+
val result = "http://coder.com".validateStrictWebUrl()
67+
assertEquals(WebUrlValidationResult.Valid, result)
68+
}
69+
70+
@Test
71+
fun `valid https URL with path and query should return Valid`() {
72+
val result = "https://coder.com/bin/coder-linux-amd64?query=1".validateStrictWebUrl()
73+
assertEquals(WebUrlValidationResult.Valid, result)
74+
}
75+
76+
@Test
77+
fun `relative URL should return Invalid with appropriate message`() {
78+
val url = "/bin/coder-linux-amd64"
79+
val result = url.validateStrictWebUrl()
80+
assertEquals(
81+
WebUrlValidationResult.Invalid("$url is relative, it must be absolute"),
82+
result
83+
)
84+
}
85+
86+
@Test
87+
fun `opaque URI like mailto should return Invalid`() {
88+
val url = "mailto:[email protected]"
89+
val result = url.validateStrictWebUrl()
90+
assertEquals(
91+
WebUrlValidationResult.Invalid("$url is opaque, instead of hierarchical"),
92+
result
93+
)
94+
}
95+
96+
@Test
97+
fun `unsupported scheme like ftp should return Invalid`() {
98+
val url = "ftp://coder.com"
99+
val result = url.validateStrictWebUrl()
100+
assertEquals(
101+
WebUrlValidationResult.Invalid("Scheme for $url must be either http or https"),
102+
result
103+
)
104+
}
105+
106+
@Test
107+
fun `http URL with missing authority should return Invalid`() {
108+
val url = "http:///bin/coder-linux-amd64"
109+
val result = url.validateStrictWebUrl()
110+
assertEquals(
111+
WebUrlValidationResult.Invalid("$url does not have a hostname"),
112+
result
113+
)
114+
}
115+
116+
@Test
117+
fun `malformed URI should return Invalid with parsing error message`() {
118+
val url = "http://[invalid-uri]"
119+
val result = url.validateStrictWebUrl()
120+
assertEquals(
121+
WebUrlValidationResult.Invalid("Malformed IPv6 address at index 8: $url"),
122+
result
123+
)
124+
}
125+
126+
@Test
127+
fun `URI without colon should return Invalid as URI is not absolute`() {
128+
val url = "http//coder.com"
129+
val result = url.validateStrictWebUrl()
130+
assertEquals(
131+
WebUrlValidationResult.Invalid("http//coder.com is relative, it must be absolute"),
132+
result
133+
)
134+
}
135+
136+
@Test
137+
fun `URI without double forward slashes should return Invalid because the URI is not hierarchical`() {
138+
val url = "http:coder.com"
139+
val result = url.validateStrictWebUrl()
140+
assertEquals(
141+
WebUrlValidationResult.Invalid("http:coder.com is opaque, instead of hierarchical"),
142+
result
143+
)
144+
}
145+
146+
@Test
147+
fun `URI without a single forward slash should return Invalid because the URI does not have a hostname`() {
148+
val url = "https:/coder.com"
149+
val result = url.validateStrictWebUrl()
150+
assertEquals(
151+
WebUrlValidationResult.Invalid("https:/coder.com does not have a hostname"),
152+
result
153+
)
154+
}
63155
}

0 commit comments

Comments
 (0)