From 82a85906c40c699ca7a01648044bbb26dd27f919 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 29 Jul 2025 00:19:44 +0300 Subject: [PATCH 1/2] 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. --- CHANGELOG.md | 4 + .../toolbox/util/CoderProtocolHandler.kt | 6 ++ .../com/coder/toolbox/util/URLExtensions.kt | 25 +++++ .../coder/toolbox/views/DeploymentUrlStep.kt | 13 +-- .../coder/toolbox/util/URLExtensionsTest.kt | 92 +++++++++++++++++++ 5 files changed, 134 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1cab6bf..b79d7d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Changed + +- URL validation is stricter in the connection screen and URI protocol handler + ## 0.6.0 - 2025-07-25 ### Changed diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index f299528..f0e84b9 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -9,6 +9,7 @@ import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.jetbrains.toolbox.api.remoteDev.connection.RemoteToolsHelper import kotlinx.coroutines.Job import kotlinx.coroutines.TimeoutCancellationException @@ -107,6 +108,11 @@ open class CoderProtocolHandler( context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"$URL\" is missing from URI") return null } + val validationResult = deploymentURL.validateStrictWebUrl() + if (validationResult is Invalid) { + context.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}") + return null + } return deploymentURL } diff --git a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt index c1aaa81..10d4c9d 100644 --- a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt @@ -1,11 +1,31 @@ package com.coder.toolbox.util +import com.coder.toolbox.util.WebUrlValidationResult.Invalid +import com.coder.toolbox.util.WebUrlValidationResult.Valid import java.net.IDN import java.net.URI import java.net.URL fun String.toURL(): URL = URI.create(this).toURL() +fun String.validateStrictWebUrl(): WebUrlValidationResult = try { + val uri = URI(this) + + when { + uri.isOpaque -> Invalid("$this is opaque, instead of hierarchical") + !uri.isAbsolute -> Invalid("$this is relative, it must be absolute") + uri.scheme?.lowercase() !in setOf("http", "https") -> + Invalid("Scheme for $this must be either http or https") + + uri.authority.isNullOrBlank() -> + Invalid("$this does not have a hostname") + + else -> Valid + } +} catch (e: Exception) { + Invalid(e.message ?: "$this could not be parsed as a URI reference") +} + fun URL.withPath(path: String): URL = URL( this.protocol, this.host, @@ -30,3 +50,8 @@ fun URI.toQueryParameters(): Map = (this.query ?: "") parts[0] to "" } } + +sealed class WebUrlValidationResult { + object Valid : WebUrlValidationResult() + data class Invalid(val reason: String) : WebUrlValidationResult() +} \ No newline at end of file diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 128bba4..0608347 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -2,7 +2,9 @@ package com.coder.toolbox.views import com.coder.toolbox.CoderToolboxContext import com.coder.toolbox.settings.SignatureFallbackStrategy +import com.coder.toolbox.util.WebUrlValidationResult.Invalid import com.coder.toolbox.util.toURL +import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.views.state.CoderCliSetupContext import com.coder.toolbox.views.state.CoderCliSetupWizardState import com.jetbrains.toolbox.api.ui.components.CheckboxField @@ -69,16 +71,11 @@ class DeploymentUrlStep( override fun onNext(): Boolean { context.settingsStore.updateSignatureFallbackStrategy(signatureFallbackStrategyField.checkedState.value) - var url = urlField.textState.value + val url = urlField.textState.value if (url.isBlank()) { errorField.textState.update { context.i18n.ptrl("URL is required") } return false } - url = if (!url.startsWith("http://") && !url.startsWith("https://")) { - "https://$url" - } else { - url - } try { CoderCliSetupContext.url = validateRawUrl(url) } catch (e: MalformedURLException) { @@ -98,6 +95,10 @@ class DeploymentUrlStep( */ private fun validateRawUrl(url: String): URL { try { + val result = url.validateStrictWebUrl() + if (result is Invalid) { + throw MalformedURLException(result.reason) + } return url.toURL() } catch (e: Exception) { throw MalformedURLException(e.message) diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index 1db26c7..1814ccd 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -60,4 +60,96 @@ internal class URLExtensionsTest { ) } } + + @Test + fun `valid http URL should return Valid`() { + val result = "http://coder.com".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `valid https URL with path and query should return Valid`() { + val result = "https://coder.com/bin/coder-linux-amd64?query=1".validateStrictWebUrl() + assertEquals(WebUrlValidationResult.Valid, result) + } + + @Test + fun `relative URL should return Invalid with appropriate message`() { + val url = "/bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("$url is relative, it must be absolute"), + result + ) + } + + @Test + fun `opaque URI like mailto should return Invalid`() { + val url = "mailto:user@coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("$url is opaque, instead of hierarchical"), + result + ) + } + + @Test + fun `unsupported scheme like ftp should return Invalid`() { + val url = "ftp://coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("Scheme for $url must be either http or https"), + result + ) + } + + @Test + fun `http URL with missing authority should return Invalid`() { + val url = "http:///bin/coder-linux-amd64" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("$url does not have a hostname"), + result + ) + } + + @Test + fun `malformed URI should return Invalid with parsing error message`() { + val url = "http://[invalid-uri]" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("Malformed IPv6 address at index 8: $url"), + result + ) + } + + @Test + fun `URI without colon should return Invalid as URI is not absolute`() { + val url = "http//coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("http//coder.com is relative, it must be absolute"), + result + ) + } + + @Test + fun `URI without double forward slashes should return Invalid because the URI is not hierarchical`() { + val url = "http:coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("http:coder.com is opaque, instead of hierarchical"), + result + ) + } + + @Test + fun `URI without a single forward slash should return Invalid because the URI does not have a hostname`() { + val url = "https:/coder.com" + val result = url.validateStrictWebUrl() + assertEquals( + WebUrlValidationResult.Invalid("https:/coder.com does not have a hostname"), + result + ) + } } From 720a36fe24d8ff61305e392de9bad809cb641739 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 29 Jul 2025 22:48:52 +0300 Subject: [PATCH 2/2] fix: use accessible language rather than technical RFC terminology --- .../com/coder/toolbox/util/URLExtensions.kt | 29 ++++++++++++++----- .../coder/toolbox/util/URLExtensionsTest.kt | 16 +++++----- 2 files changed, 29 insertions(+), 16 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt index 10d4c9d..7e2a8e3 100644 --- a/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt +++ b/src/main/kotlin/com/coder/toolbox/util/URLExtensions.kt @@ -12,18 +12,31 @@ fun String.validateStrictWebUrl(): WebUrlValidationResult = try { val uri = URI(this) when { - uri.isOpaque -> Invalid("$this is opaque, instead of hierarchical") - !uri.isAbsolute -> Invalid("$this is relative, it must be absolute") - uri.scheme?.lowercase() !in setOf("http", "https") -> - Invalid("Scheme for $this must be either http or https") + uri.isOpaque -> Invalid( + "The URL \"$this\" is invalid because it is not in the standard format. " + + "Please enter a full web address like \"https://example.com\"" + ) + !uri.isAbsolute -> Invalid( + "The URL \"$this\" is missing a scheme (like https://). " + + "Please enter a full web address like \"https://example.com\"" + ) + uri.scheme?.lowercase() !in setOf("http", "https") -> + Invalid( + "The URL \"$this\" must start with http:// or https://, not \"${uri.scheme}\"" + ) uri.authority.isNullOrBlank() -> - Invalid("$this does not have a hostname") - + Invalid( + "The URL \"$this\" does not include a valid website name. " + + "Please enter a full web address like \"https://example.com\"" + ) else -> Valid } -} catch (e: Exception) { - Invalid(e.message ?: "$this could not be parsed as a URI reference") +} catch (_: Exception) { + Invalid( + "The input \"$this\" is not a valid web address. " + + "Please enter a full web address like \"https://example.com\"" + ) } fun URL.withPath(path: String): URL = URL( diff --git a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt index 1814ccd..af1b4ef 100644 --- a/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/URLExtensionsTest.kt @@ -78,7 +78,7 @@ internal class URLExtensionsTest { val url = "/bin/coder-linux-amd64" val result = url.validateStrictWebUrl() assertEquals( - WebUrlValidationResult.Invalid("$url is relative, it must be absolute"), + WebUrlValidationResult.Invalid("The URL \"/bin/coder-linux-amd64\" is missing a scheme (like https://). Please enter a full web address like \"https://example.com\""), result ) } @@ -88,7 +88,7 @@ internal class URLExtensionsTest { val url = "mailto:user@coder.com" val result = url.validateStrictWebUrl() assertEquals( - WebUrlValidationResult.Invalid("$url is opaque, instead of hierarchical"), + WebUrlValidationResult.Invalid("The URL \"mailto:user@coder.com\" is invalid because it is not in the standard format. Please enter a full web address like \"https://example.com\""), result ) } @@ -98,7 +98,7 @@ internal class URLExtensionsTest { val url = "ftp://coder.com" val result = url.validateStrictWebUrl() assertEquals( - WebUrlValidationResult.Invalid("Scheme for $url must be either http or https"), + WebUrlValidationResult.Invalid("The URL \"ftp://coder.com\" must start with http:// or https://, not \"ftp\""), result ) } @@ -108,7 +108,7 @@ internal class URLExtensionsTest { val url = "http:///bin/coder-linux-amd64" val result = url.validateStrictWebUrl() assertEquals( - WebUrlValidationResult.Invalid("$url does not have a hostname"), + WebUrlValidationResult.Invalid("The URL \"http:///bin/coder-linux-amd64\" does not include a valid website name. Please enter a full web address like \"https://example.com\""), result ) } @@ -118,7 +118,7 @@ internal class URLExtensionsTest { val url = "http://[invalid-uri]" val result = url.validateStrictWebUrl() assertEquals( - WebUrlValidationResult.Invalid("Malformed IPv6 address at index 8: $url"), + WebUrlValidationResult.Invalid("The input \"http://[invalid-uri]\" is not a valid web address. Please enter a full web address like \"https://example.com\""), result ) } @@ -128,7 +128,7 @@ internal class URLExtensionsTest { val url = "http//coder.com" val result = url.validateStrictWebUrl() assertEquals( - WebUrlValidationResult.Invalid("http//coder.com is relative, it must be absolute"), + WebUrlValidationResult.Invalid("The URL \"http//coder.com\" is missing a scheme (like https://). Please enter a full web address like \"https://example.com\""), result ) } @@ -138,7 +138,7 @@ internal class URLExtensionsTest { val url = "http:coder.com" val result = url.validateStrictWebUrl() assertEquals( - WebUrlValidationResult.Invalid("http:coder.com is opaque, instead of hierarchical"), + WebUrlValidationResult.Invalid("The URL \"http:coder.com\" is invalid because it is not in the standard format. Please enter a full web address like \"https://example.com\""), result ) } @@ -148,7 +148,7 @@ internal class URLExtensionsTest { val url = "https:/coder.com" val result = url.validateStrictWebUrl() assertEquals( - WebUrlValidationResult.Invalid("https:/coder.com does not have a hostname"), + WebUrlValidationResult.Invalid("The URL \"https:/coder.com\" does not include a valid website name. Please enter a full web address like \"https://example.com\""), result ) }