Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,15 @@ It follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

- SSH connections being blocked while non-blocking startup scripts run or after startup scripts fail or time out.
- Automatic connection not starting when a workspace agent becomes connectable after the initial workspace load.
- Duplicate CLI sign-ins that could send the setup wizard back to the token screen after a successful connection.
- Opening a workspace link for another deployment could leave the previous setup page on screen until Toolbox regained focus.

### Security

- Reject ambiguous workspace links and OAuth callbacks. Workspace links must identify the deployment by its HTTP(S) origin.

- Restrict HTTP redirects and deployment credentials to their original destination. Disable redirects in CLI v2.38.0 and newer.

- Ask for confirmation before a workspace link opens an unknown Coder deployment. Links select the deployment by
scheme, host, and port. For a known deployment, Toolbox asks before using a link token if the accounts differ or
it cannot verify the saved account.
Expand Down
53 changes: 36 additions & 17 deletions src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import com.coder.toolbox.views.CoderSetupWizardPage
import com.coder.toolbox.views.NewEnvironmentPage
import com.coder.toolbox.views.SuspendBiConsumer
import com.coder.toolbox.views.state.CoderOAuthSessionContext
import com.coder.toolbox.views.state.ConnectionOrigin
import com.coder.toolbox.views.state.Credentials
import com.coder.toolbox.views.state.PageRouter
import com.coder.toolbox.views.state.PendingOAuthConnection
Expand All @@ -51,11 +52,13 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.job
import kotlinx.coroutines.launch
import kotlinx.coroutines.selects.onTimeout
import kotlinx.coroutines.selects.select
Expand Down Expand Up @@ -418,6 +421,11 @@ class CoderRemoteProvider(
* Also called as part of our own logout.
*/
override fun close() {
resetConnection()
router.clear()
}

private fun resetConnection() {
val sessionIds = lastEnvironments.currentSessionIds()
softClose()
client = null
Expand All @@ -428,7 +436,6 @@ class CoderRemoteProvider(
environments.value = LoadableState.Value(emptyList())
isInitialized.update { false }
accountDropdownField.visibility.update { false }
router.clear()
context.logger.info(sessionIds, "Coder plugin is now closed")
context.logger.flush("provider_closed")
}
Expand Down Expand Up @@ -501,9 +508,14 @@ class CoderRemoteProvider(
*/
override suspend fun handleUri(uri: URI) {
try {
require(uri.scheme.equals("jetbrains", ignoreCase = true) &&
uri.rawAuthority.equals("gateway", ignoreCase = true) && uri.rawFragment == null &&
uri.rawPath in setOf("/com.coder.toolbox", "/coder", "/com.coder.toolbox/auth")) {
"Unrecognized Toolbox link."
}
val params = uri.toQueryParameters()
when {
uri.toString().startsWith("jetbrains://gateway/com.coder.toolbox/auth") -> handleOAuthUri(params)
uri.rawPath == "/com.coder.toolbox/auth" -> handleOAuthUri(params)
// An empty query can indicate plugin installation.
params.isEmpty() ->
context.logger.logAndShowInfo("URI will not be handled", "No query parameters were provided")
Expand All @@ -515,8 +527,6 @@ class CoderRemoteProvider(
} catch (ex: Exception) {
val reason = (ex as? APIResponseException)?.reason?.takeIf { it.isNotBlank() }
context.logger.logAndShowError("Error encountered while handling Coder URI", reason ?: ex.message.orEmpty())
} finally {
firstRun = false
}
}

Expand Down Expand Up @@ -580,6 +590,7 @@ class CoderRemoteProvider(
onConnect = onConnect,
onTokenRefreshed = ::onTokenRefreshed,
)
firstRun = false
router.navigate(wizard)

context.envPageManager.showPluginEnvironmentsPage(false)
Expand All @@ -604,6 +615,7 @@ class CoderRemoteProvider(
val activeClient = client?.takeIf { it.url.hasSameOrigin(deployment.url) }
if (!confirmDeploymentSignIn(deployment, activeClient)) return

firstRun = false
if (activeClient != null) {
openLinkInActiveSession(params, activeClient, deployment.token)
} else {
Expand All @@ -623,7 +635,7 @@ class CoderRemoteProvider(
context.logger.logAndShowError(CAN_T_HANDLE_URI_TITLE, "Query parameter \"${URL}\" is missing from URI")
return null
}
val validationResult = deploymentURL.validateStrictWebUrl()
val validationResult = deploymentURL.validateStrictWebUrl(originOnly = true)
if (validationResult is Invalid) {
context.logger.logAndShowError(CAN_T_HANDLE_URI_TITLE, "\"$URL\" is invalid: ${validationResult.reason}")
return null
Expand All @@ -632,6 +644,10 @@ class CoderRemoteProvider(
val origin = requireNotNull(deploymentURL.toHttpUrlOrNull()?.origin()) {
"The deployment URL must have a valid HTTP(S) host and port."
}
val saved = context.secrets.savedDeploymentUrl(origin.toURL())
if (!saved.isNullOrEmpty() && saved.validateStrictWebUrl() !is Invalid && saved.toHttpUrlOrNull()?.origin() == origin) {
return saved.toURL()
}
return origin.toURL()
}

Expand Down Expand Up @@ -711,6 +727,7 @@ class CoderRemoteProvider(
}

private fun autoSetupCredentials(url: URL): Credentials? {
if (url.toString().validateStrictWebUrl() is Invalid) return null
if (context.settingsStore.requiresMTlsAuth) return Credentials.MTls

val tokenCredentials = context.secrets.apiTokenFor(url)
Expand Down Expand Up @@ -811,7 +828,7 @@ class CoderRemoteProvider(
linkHandler.handle(params, url, client, cli)
}

private fun connectFromLink(params: Map<String, String>, url: URL, token: String?) {
private suspend fun connectFromLink(params: Map<String, String>, url: URL, token: String?) {
val credentials = token?.let { Credentials.Token(it) } ?: Credentials.MTls
val wizard = CoderSetupWizardPage.connectStep(
context, settingsPage,
Expand All @@ -820,10 +837,12 @@ class CoderRemoteProvider(
onConnect = onConnect.andThen(deferredLinkHandler(params, url)),
onTokenRefreshed = ::onTokenRefreshed,
)
// The router tears down any in-flight wizard and installs the new one before
// showPluginEnvironmentsPage() asks getOverrideUiPage() for the page to display.
// Replace any in-flight wizard before showPluginEnvironmentsPage() requests a refresh.
// If Toolbox calls getOverrideUiPage(), it must get the wizard for this link.
router.navigate(wizard)
context.popupPluginMainPage()
// An already visible provider can keep showing the old wizard, so show the new one explicitly.
context.ui.showUiPageSuspending(wizard)
}

/** Returns a [SuspendBiConsumer] that handles the given link parameters in a background coroutine. */
Expand All @@ -849,21 +868,16 @@ class CoderRemoteProvider(
}
}

/**
* Return the sign-in page if we do not have a valid client.

* Otherwise, return null, which causes Toolbox to display the environment
* list.
*/
/** Show interactive setup when needed. Saved sessions restore behind the workspace list. */
override fun getOverrideUiPage(): UiPage? {
// Show the setup wizard if one is already scheduled.
router.activePage?.let { return it }
router.activePage?.let { return it.pageForDisplay() }

// Let the default workspace UI render if the HTTP client is initialized.
if (client != null) return null

// Otherwise, schedule our own setup wizard.
return router.getOrCreate { buildSetupWizard() }
return router.getOrCreate { buildSetupWizard() }.pageForDisplay()
}

/**
Expand All @@ -887,6 +901,7 @@ class CoderRemoteProvider(
credentials = credentials,
onConnect = onConnect,
onTokenRefreshed = ::onTokenRefreshed,
origin = ConnectionOrigin.SAVED_SESSION,
)
} catch (ex: Exception) {
context.logger.logAndShowError(
Expand Down Expand Up @@ -915,9 +930,13 @@ class CoderRemoteProvider(
fun canAutoLogin(): Boolean = autoSetupCredentials(context.deploymentUrl) != null

private val onConnect: SuspendBiConsumer<CoderRestClient, CoderCLIManager> = SuspendBiConsumer { client, cli ->
router.finishSignIn(currentCoroutineContext().job) { startSession(client, cli) }
}

private fun startSession(client: CoderRestClient, cli: CoderCLIManager) {
// Store the URL and token for use next time.
storeSession(client)
close()
resetConnection()
this.client = client
this.cli = cli
lastEnvironments.forEach { it.updateClientAndCli(client, cli) }
Expand Down
2 changes: 2 additions & 0 deletions src/main/kotlin/com/coder/toolbox/cli/CoderCLIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ class CoderCLIManager(

// Keep credential storage consistent with existing SSH commands until the next sign-in.
private val useKeyring = context.settingsStore.useKeyring && usesTokenAuth
private val supportsRedirectFlag by lazy { tryVersion()?.let { it >= SemVer(2, 38, 0) } == true }
private val downloader = createDownloadService()
private val gpgVerifier = GPGVerifier(context)

Expand Down Expand Up @@ -661,6 +662,7 @@ class CoderCLIManager(
private fun globalConfigArgs(): List<String> = listOf("--global-config", coderConfigPath.toString())

private fun workspaceAuthArgs(): List<String> = buildList {
if (supportsRedirectFlag) add("--allow-redirects=false")
val useKeyringAuth = shouldUseKeyringAuth()
if (!useKeyringAuth) addAll(globalConfigArgs())
// Override inherited CODER_URL and CODER_USE_KEYRING for every authenticated command.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.cli.ex.ResponseException
import com.coder.toolbox.util.OS
import com.coder.toolbox.util.SemVer
import com.coder.toolbox.util.hasSameOrigin
import com.coder.toolbox.util.getHeaders
import com.coder.toolbox.util.getOS
import com.coder.toolbox.util.sha1
Expand Down Expand Up @@ -130,7 +131,7 @@ class CoderDownloadService(
}

private fun getRequestHeaders(): Map<String, String> {
return if (context.settingsStore.headerCommand.isNullOrBlank()) {
return if (!remoteBinaryURL.hasSameOrigin(deploymentUrl) || context.settingsStore.headerCommand.isNullOrBlank()) {
emptyMap()
} else {
getHeaders(deploymentUrl, context.settingsStore.headerCommand)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ object CoderHttpClientBuilder {
.sslSocketFactory(tlsContext.sslSocketFactory, tlsContext.trustManager)
.hostnameVerifier(CoderHostnameVerifier(context.settingsStore.tls.altHostname))
.retryOnConnectionFailure(true)
.followRedirects(false)
.addInterceptor(Interceptors.sameOriginRedirects())
// Detect silent connection loss even when a workspace build produces no log messages.
.pingInterval(30, TimeUnit.SECONDS)

Expand Down
12 changes: 7 additions & 5 deletions src/main/kotlin/com/coder/toolbox/sdk/CoderRestClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -95,11 +95,7 @@ open class CoderRestClient(

val interceptors = buildList {
if (context.settingsStore.requiresTokenAuth) {
val oauthOrApiToken = oauthContext?.tokenResponse?.accessToken ?: token
if (oauthOrApiToken.isNullOrBlank()) {
throw IllegalStateException("OAuth or API token is required for $url deployment")
}
add(Interceptors.tokenAuth(oauthOrApiToken))
add(Interceptors.tokenAuth(requireSessionToken(), url))
}
add((Interceptors.userAgent(pluginVersion)))
add(Interceptors.externalHeaders(context, url))
Expand All @@ -123,6 +119,12 @@ open class CoderRestClient(
.build().create(CoderV2RestFacade::class.java)
}

internal fun requireSessionToken(): String {
val sessionToken = oauthContext?.tokenResponse?.accessToken ?: token
check(!sessionToken.isNullOrBlank()) { "OAuth or API token is required for $url deployment" }
return sessionToken
}

/**
* Load information about the current user and the build version.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import com.coder.toolbox.CoderToolboxContext
import com.coder.toolbox.util.getArch
import com.coder.toolbox.util.getHeaders
import com.coder.toolbox.util.getOS
import com.coder.toolbox.util.origin
import okhttp3.Interceptor
import java.net.ProtocolException
import java.net.URL

const val CODER_SESSION_TOKEN_HEADER_NAME = "Coder-Session-Token"
Expand All @@ -14,11 +16,34 @@ const val CODER_SESSION_TOKEN_HEADER_NAME = "Coder-Session-Token"
*/
object Interceptors {

/** Follow redirects for reads only. Credential-bearing POST bodies must never be replayed. */
fun sameOriginRedirects(): Interceptor = Interceptor { chain ->
var request = chain.request()
val origin = request.url.origin()
var redirects = 0
var response = chain.proceed(request)
while (response.code in setOf(300, 301, 302, 303, 307, 308)) {
val location = response.header("Location") ?: break
val target = request.url.resolve(location)
if (target == null || target.origin() != origin || target.username.isNotEmpty() || target.password.isNotEmpty()) {
response.close()
throw ProtocolException("Refused a redirect outside the request origin or to an invalid URL.")
}
if (request.method != "GET" && request.method != "HEAD") break
response.close()
if (++redirects > 20) throw ProtocolException("Too many redirects.")
request = request.newBuilder().url(target).build()
response = chain.proceed(request)
}
response
}

/**
* Creates a token authentication interceptor
*/
fun tokenAuth(token: String): Interceptor {
fun tokenAuth(token: String, deploymentUrl: URL): Interceptor {
return Interceptor { chain ->
if (chain.request().url.origin() != deploymentUrl.origin()) return@Interceptor chain.proceed(chain.request())
chain.proceed(
chain.request().newBuilder()
.addHeader(CODER_SESSION_TOKEN_HEADER_NAME, token)
Expand Down Expand Up @@ -47,6 +72,7 @@ object Interceptors {
val settings = context.settingsStore.readOnly()
return Interceptor { chain ->
var request = chain.request()
if (request.url.origin() != url.origin()) return@Interceptor chain.proceed(request)
val headers = getHeaders(url, settings.headerCommand)
if (headers.isNotEmpty()) {
val reqBuilder = request.newBuilder()
Expand Down
4 changes: 3 additions & 1 deletion src/main/kotlin/com/coder/toolbox/store/CoderSecretsStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ class CoderSecretsStore(private val store: PluginSecretStore) {
fun isUrlTrusted(url: URL): Boolean {
val requested = url.toString().toHttpUrlOrNull() ?: return false
val origin = requested.origin()
val saved = store["deployment-url:$origin"]
val saved = savedDeploymentUrl(url)
return if (saved != null) {
saved.toHttpUrlOrNull() == requested
} else {
Expand All @@ -58,6 +58,8 @@ class CoderSecretsStore(private val store: PluginSecretStore) {
}
}

fun savedDeploymentUrl(url: URL): String? = url.origin()?.let { store["deployment-url:$it"] }

fun trustDeployment(url: URL) {
val address = requireNotNull(url.toString().toHttpUrlOrNull()) { "Deployment trust requires an HTTP(S) URL" }
val key = "deployment-url:${address.origin()}"
Expand Down
Loading
Loading