From 46cabc76585d2df96acfa0d1d903d87342a66459 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 28 Oct 2026 20:38:47 +0000 Subject: [PATCH 1/6] fix: only fill in session tokens for known Coder IDE links --- .../Coder-Desktop/SessionTokenLink.swift | 127 +++++++++++++++ .../Views/VPN/WorkspaceAppIcon.swift | 152 ++++++++++++------ .../SessionTokenLinkTests.swift | 126 +++++++++++++++ .../WorkspaceAppTests.swift | 102 ++++++++++-- Coder-Desktop/CoderSDK/Workspace.swift | 11 +- 5 files changed, 458 insertions(+), 60 deletions(-) create mode 100644 Coder-Desktop/Coder-Desktop/SessionTokenLink.swift create mode 100644 Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift diff --git a/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift new file mode 100644 index 00000000..4de7b983 --- /dev/null +++ b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift @@ -0,0 +1,127 @@ +import Foundation + +/// Decides whether an external app link may receive the user's session token. +/// Applies the same rules as the Coder dashboard. +enum SessionTokenLink { + /// Replaced with the user's session token in trusted app links. + static let placeholder = "$SESSION_TOKEN" + + /// Shown when an app needs a session token but its link isn't trusted with one. + static let blockedMessage = "This app's URL isn't trusted to sign you in to Coder." + + private struct Handler { + /// Scheme and authority, matched case-insensitively. + let base: String + /// Path through the first required param, matched exactly. + let path: String + } + + /// The only deep-link handlers that may receive the session token. + private static let handlers: [Handler] = [ + "vscode", "vscode-insiders", "windsurf", "devin", "cursor", "kiro", "positron", "antigravity", + "antigravity-ide", + ].flatMap { scheme in + ["/open?", "/openDevContainer?"].map { Handler(base: "\(scheme)://coder.coder-remote", path: $0) } + } + [ + Handler(base: "jetbrains://gateway", path: "/coder?"), + Handler(base: "jetbrains://gateway", path: "/com.coder.toolbox?"), + // Gateway also handles non-Coder providers, such as `type=ssh`. + Handler(base: "jetbrains-gateway://connect", path: "#type=coder&"), + ] + + /// Returns the link with the placeholder replaced by `token`, or by an empty + /// string for sub-agent apps. Links without the placeholder are returned + /// unchanged. Returns nil if the link isn't trusted with a token. + static func substitute( + _ appURL: String, + deploymentURL: URL, + token: String, + isSubAgentApp: Bool + ) -> String? { + guard appURL.contains(placeholder) else { return appURL } + guard isDestinationTrusted(appURL, deploymentURL: deploymentURL) else { return nil } + // Sub-agent apps never get a token; `coder open app` refuses them too. + return appURL.replacingOccurrences(of: placeholder, with: isSubAgentApp ? "" : token) + } + + /// True when the link opens a known Coder handler, its only `url` param is + /// `deploymentURL`'s origin, and the placeholder is its only `token`. + static func isDestinationTrusted(_ appURL: String, deploymentURL: URL) -> Bool { + // `URL(string:)` percent-encodes non-ASCII, so a valid link is printable + // ASCII. That also keeps Swift's grapheme-based string comparisons + // byte-exact. Some URL parsers drop tab, LF and CR, so `ur\tl` could + // become a second `url` in the IDE. + guard appURL.unicodeScalars.allSatisfy({ (0x20 ..< 0x7F).contains($0.value) }), + appURL.components(separatedBy: placeholder).count == 2, + let handler = handler(for: appURL) + else { return false } + + let params = String(appURL.dropFirst( + handler.base.count + handler.path.prefix { $0 != "?" && $0 != "#" }.count + 1 + )) + let entries = params.components(separatedBy: "&") + // Substitution replaces the raw placeholder, so it must be the raw + // `token` entry. JetBrains Gateway doesn't decode names, so they must be + // literal. IDEs decode values twice, so check that view too and reject + // leftovers. A later `#` would hide params from IDEs that read only the + // query. + guard !params.contains("#"), + entries.contains("token=\(placeholder)"), + entries.allSatisfy({ !$0.prefix { $0 != "=" }.contains("%") }), + let decoded = params.removingPercentEncoding, + !decoded.unicodeScalars.contains("%") + else { return false } + return hasTrustedParams(params, deploymentURL: deploymentURL) + && hasTrustedParams(decoded, deploymentURL: deploymentURL) + } + + /// True when `url` is only an origin, optionally with a trailing slash. A + /// path could reach a workspace app on the deployment, and userinfo can hide + /// a different host from IDEs that decode it again. Matched on the raw + /// string, since parsing normalizes `/.` and drops an empty `?`. + static func isBareOrigin(_ url: String) -> Bool { + url.range(of: #"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\\?#@\s]+/?\z"#, options: .regularExpression) != nil + && URLComponents(string: url)?.host?.isEmpty == false + } + + /// True when the link opens a known Coder IDE handler. + static func isKnownHandler(_ appURL: String) -> Bool { + handler(for: appURL) != nil + } + + /// The known handler the link starts with, if any. Compared as UTF-8 with + /// ASCII-only case folding, so a Kelvin sign doesn't match `kiro`. + private static func handler(for appURL: String) -> Handler? { + let bytes = appURL.utf8 + return handlers.first { handler in + let base = handler.base.utf8 + return bytes.prefix(base.count).map { (65 ... 90).contains($0) ? $0 + 32 : $0 }.elementsEqual(base) + && bytes.dropFirst(base.count).starts(with: handler.path.utf8) + } + } + + /// Every name appears once, `token` is the placeholder, `url` is the deployment. + private static func hasTrustedParams(_ query: String, deploymentURL: URL) -> Bool { + // Parsed like `URLSearchParams`. Split on scalars, since `&` or `=` + // followed by a combining mark is one `Character`. + let params = query.unicodeScalars.split(separator: "&").map { entry in + entry.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map { part in + let plain = String(part).replacingOccurrences(of: "+", with: " ", options: .literal) + return plain.removingPercentEncoding ?? plain + } + } + let value = { (name: String) in params.first { $0[0] == name }.map { $0.count > 1 ? $0[1] : "" } } + guard Set(params.map { $0[0] }).count == params.count, + value("token") == placeholder, + let url = value("url"), + isBareOrigin(url), + let url = URL(string: url), + let scheme = url.scheme?.lowercased(), + scheme == "http" || scheme == "https" + else { return false } + let defaultPort = scheme == "https" ? 443 : 80 + return scheme == deploymentURL.scheme?.lowercased() + && url.host?.lowercased() == deploymentURL.host?.lowercased() + && (url.port ?? defaultPort) == (deploymentURL.port ?? defaultPort) + } +} diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 94104d27..3f24dabf 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -6,49 +6,68 @@ import SwiftUI struct WorkspaceAppIcon: View { let app: WorkspaceApp @Environment(\.openURL) private var openURL + @Environment(\.dismiss) private var dismiss @State var isHovering: Bool = false @State var isPressed = false var body: some View { - Group { - Group { - WebImage( - url: app.icon, - context: [.imageThumbnailPixelSize: Theme.Size.appIconSize] - ) { $0 } - placeholder: { - if app.icon != nil { - CircularProgressView(value: nil, strokeWidth: 2, diameter: 10) - } else { - Image(systemName: "questionmark").frame( - width: Theme.Size.appIconWidth, - height: Theme.Size.appIconHeight - ) - } - }.frame( + WebImage( + url: app.icon, + context: [.imageThumbnailPixelSize: Theme.Size.appIconSize] + ) { $0 } + placeholder: { + if app.icon != nil { + CircularProgressView(value: nil, strokeWidth: 2, diameter: 10) + } else { + Image(systemName: "questionmark").frame( width: Theme.Size.appIconWidth, height: Theme.Size.appIconHeight ) - }.padding(6) - } - .background(isHovering ? Color.accentColor.opacity(0.8) : .clear) - .clipShape(RoundedRectangle(cornerRadius: 8)) - .onHover { hovering in isHovering = hovering } - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - withAnimation(.easeInOut(duration: 0.1)) { - isPressed = true - } } - .onEnded { _ in - withAnimation(.easeInOut(duration: 0.1)) { - isPressed = false + }.frame( + width: Theme.Size.appIconWidth, + height: Theme.Size.appIconHeight + ).padding(6) + .background(isHovering ? Color.accentColor.opacity(0.8) : .clear) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .onHover { hovering in isHovering = hovering && app.blockedReason == nil } + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + withAnimation(.easeInOut(duration: 0.1)) { + isPressed = true + } } - openURL(app.url) - } - ).help(app.displayName) + .onEnded { _ in + withAnimation(.easeInOut(duration: 0.1)) { + isPressed = false + } + guard app.blockedReason == nil else { return } + if app.confirmBeforeOpening { + confirmOpen() + } else { + openURL(app.url) + } + } + ) + .opacity(app.blockedReason == nil ? 1 : 0.4) + .help(app.blockedReason.map { "\(app.displayName): \($0)" } ?? app.displayName) + } + + /// `.alert` from SwiftUI doesn't play nice when the calling view is in the + /// menu bar. + private func confirmOpen() { + let alert = NSAlert() + alert.messageText = "Open \(app.displayName)?" + alert.informativeText = "This opens a \(app.url.scheme ?? ""): link in another app on your computer." + alert.alertStyle = .informational + alert.addButton(withTitle: "Open") + alert.addButton(withTitle: "Cancel") + dismiss() + if alert.runModal() == .alertFirstButtonReturn { + openURL(app.url) + } } } @@ -57,22 +76,30 @@ struct WorkspaceApp { let displayName: String let url: URL let icon: URL? + /// Set when the app's link needs a session token but isn't trusted with + /// one. Such apps are shown disabled with this message. + let blockedReason: String? + /// True when the user must confirm before the app's link is opened. + let confirmBeforeOpening: Bool var id: String { slug } - private static let magicTokenString = "$SESSION_TOKEN" - init(slug: String, displayName: String, url: URL, icon: URL?) { self.slug = slug self.displayName = displayName self.url = url self.icon = icon + blockedReason = nil + confirmBeforeOpening = false } + /// `deploymentURL` is the Coder deployment the user is signed in to. It is + /// the only `url` a link may name to receive `sessionToken`. init( _ original: CoderSDK.WorkspaceApp, - iconBaseURL: URL, - sessionToken: String + deploymentURL: URL, + sessionToken: String, + isSubAgentApp: Bool = false ) throws(WorkspaceAppError) { slug = original.slug // Same behaviour as the web UI @@ -96,23 +123,33 @@ struct WorkspaceApp { throw .isWebApp } - let newUrlString = originalUrl.absoluteString.replacingOccurrences( - of: Self.magicTokenString, - with: sessionToken - ) - guard let newUrl = URL(string: newUrlString) else { - throw .invalidURL + confirmBeforeOpening = !SessionTokenLink.isKnownHandler(originalUrl.absoluteString) + if let newUrlString = SessionTokenLink.substitute( + originalUrl.absoluteString, + deploymentURL: deploymentURL, + token: sessionToken, + isSubAgentApp: isSubAgentApp + ) { + guard let newUrl = URL(string: newUrlString) else { + throw .invalidURL + } + url = newUrl + blockedReason = nil + } else { + url = originalUrl + blockedReason = SessionTokenLink.blockedMessage + " " + (isSubAgentApp + ? "Fix the app's url in the dev container's devcontainer.json." + : "Ask your template administrator to fix the app's url in the template's coder_app configuration.") } - url = newUrl var icon = original.icon if let originalIcon = original.icon, var components = URLComponents(url: originalIcon, resolvingAgainstBaseURL: false) { if components.host == nil { - components.port = iconBaseURL.port - components.scheme = iconBaseURL.scheme - components.host = iconBaseURL.host(percentEncoded: false) + components.port = deploymentURL.port + components.scheme = deploymentURL.scheme + components.host = deploymentURL.host(percentEncoded: false) } if let newIconURL = components.url { @@ -154,7 +191,12 @@ func agentToApps( ) -> [WorkspaceApp] { let workspaceApps = agent.apps.compactMap { app in do throws(WorkspaceAppError) { - return try WorkspaceApp(app, iconBaseURL: baseAccessURL, sessionToken: sessionToken) + return try WorkspaceApp( + app, + deploymentURL: baseAccessURL, + sessionToken: sessionToken, + isSubAgentApp: agent.parent_id != nil + ) } catch { logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)") return nil @@ -191,7 +233,7 @@ func vscodeDisplayApp(hostname: String, baseIconURL: URL, path: String? = nil) - // slugs as SwiftUI IDs slug: "-vscode", displayName: "VS Code Desktop", - url: URL(string: "vscode://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!, + url: vscodeRemoteURL(scheme: "vscode", hostname: hostname, path: path), icon: icon ) } @@ -201,7 +243,17 @@ func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? return WorkspaceApp( slug: "-vscode-insiders", displayName: "VS Code Insiders Desktop", - url: URL(string: "vscode-insiders://vscode-remote/ssh-remote+\(hostname)/\(path ?? "")")!, + url: vscodeRemoteURL(scheme: "vscode-insiders", hostname: hostname, path: path), icon: icon ) } + +/// Builds a VS Code Remote SSH link. The path setter percent-encodes the host +/// and directory, so they can't add a query or fragment to the link. +private func vscodeRemoteURL(scheme: String, hostname: String, path: String?) -> URL { + var components = URLComponents() + components.scheme = scheme + components.host = "vscode-remote" + components.path = "/ssh-remote+\(hostname)/\(path ?? "")" + return components.url! +} diff --git a/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift b/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift new file mode 100644 index 00000000..15ab6c7f --- /dev/null +++ b/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift @@ -0,0 +1,126 @@ +@testable import Coder_Desktop +import Foundation +import Testing + +struct SessionTokenLinkTests { + static let origin = "https://coder.example.com" + static let token = SessionTokenLink.placeholder + let deployment = URL(string: "https://coder.example.com/")! + + @Test(arguments: [ + "vscode://coder.coder-remote/open?owner=a&workspace=b&url=\(origin)&token=\(token)", + "cursor://coder.coder-remote/open?url=\(origin)/&token=\(token)", + "jetbrains://gateway/coder?&workspace=b&url=\(origin)&token=\(token)&ide_product_code=GO", + "jetbrains-gateway://connect#type=coder&workspace=b&url=\(origin)&token=\(token)", + "cursor://coder.coder-remote/openDevContainer?url=\(origin)&token=\(token)", + "jetbrains://gateway/com.coder.toolbox?url=\(origin)&token=\(token)", + "VSCode://Coder.Coder-Remote/open?url=\(origin)&token=\(token)", + "vscode://coder.coder-remote/open?url=https://CODER.example.com&token=\(token)", + "vscode://coder.coder-remote/open?url=https://coder.example.com:443&token=\(token)", + "vscode://coder.coder-remote/open?url=https%3A%2F%2Fcoder.example.com&token=\(token)", + "vscode://coder.coder-remote/open?folder=/home/a%20b&url=\(origin)&token=\(token)", + ]) + func trusted(url: String) { + #expect(SessionTokenLink.isDestinationTrusted(url, deploymentURL: deployment)) + } + + @Test(arguments: [ + "vscode://coder.coder-remote/open?url=https://other.example&token=\(token)", + "vscode://coder.coder-remote/open?url=https://coder.example.com:1&token=\(token)", + "vscode://coder.coder-remote/open?url=http://coder.example.com&token=\(token)", + "vscode://coder.coder-remote/open?url=https%3A%2F%2Fother.example&token=\(token)", + "vscode://coder.coder-remote/open?token=\(token)", + "vscode://coder.coder-remote/open?url=not-a-url&token=\(token)", + "vscode://coder.coder-remote/open?url=\(origin)&url=https://other.example&token=\(token)", + "jetbrains-gateway://connect?url=https://other.example#url=\(origin)&token=\(token)", + "jetbrains://gateway/coder?url=\(origin)&token=x&ide_download_link=https://other.example/\(token)", + "vscode://coder.coder-remote/open?url=\(origin)&token=\(token)&t=\(token)", + "vscode://coder.coder-remote/open?url=\(origin)#token=\(token)", + "vscode://coder.coder-remote/open?url=\(origin)&token=\(token)#url=https://other.example", + "vscode://other.ext/open?url=\(origin)&token=\(token)", + "coder://open?url=\(origin)&token=\(token)", + "vscode://coder.coder-remote/open?url=\(origin)/@victim/ws.main/apps/x/&token=\(token)", + "vscode://coder.coder-remote/open?url=https://other.example%252Fx@coder.example.com/&token=\(token)", + "vscode://coder.coder-remote/open?owner=x%26url%3Dhttps%253A%252F%252Fother.example&url=\(origin)&token=\(token)", + "vscode://coder.coder-remote/open?x=%252526url%25253Dhttps%25253A%25252F%25252Fother.example&url=\(origin)" + + "&token=\(token)", + "vscode:coder.coder-remote/open?url=\(origin)&token=\(token)", + "vscode://x@coder.coder-remote/open?url=\(origin)&token=\(token)", + "vscode://coder.coder-remote/open/../open?url=\(origin)&token=\(token)", + "vscode://coder.coder-remote/open#url=\(origin)&token=\(token)", + "jetbrains-gateway://connect?url=\(origin)&token=\(token)", + "jetbrains-gateway://connect#type=coder&url=\(origin)&token=%24SESSION_TOKEN" + + "&ide_download_link=https://other.example/\(token)", + "jetbrains-gateway://connect#type=coder&%75rl=\(origin)&token=\(token)", + "jetbrains-gateway://connect#type=ssh&host=other.example&url=\(origin)&token=\(token)", + "jetbrains-gateway://connect#type=coder&url=\(origin)&token=\(token)&type=ssh", + "vscode://coder.coder-remote/open?x=1#&url=\(origin)&token=\(token)", + "\u{212A}iro://coder.coder-remote/open?url=\(origin)&token=\(token)", + "jetbrains-gateway://connect#type=coder&url=\(origin)&ur\tl=https://other.example&token=\(token)", + "jetbrains-gateway://connect#type=coder&url=\(origin)&ur\nl=https://other.example&token=\(token)", + "jetbrains-gateway://connect#type=coder&url=\(origin)&ur\rl=https://other.example&token=\(token)", + "vscode://coder.coder-remote/open?url=\(origin)/.&token=\(token)", + "vscode://coder.coder-remote/open?url=\(origin)?&token=\(token)", + "vscode://coder.coder-remote/open?url=\(origin)%0A&token=\(token)", + "vscode://coder.coder-remote/open?url=\(origin)&token=\(token)%FF", + "vscode://coder.coder-remote/open?x=%25%CC%81&url=\(origin)&token=\(token)", + "vscode://coder.coder-remote/open?url=\(origin)&token=\(token)&x=%26url=%CC%81https://other.example", + ]) + func untrusted(url: String) { + #expect(!SessionTokenLink.isDestinationTrusted(url, deploymentURL: deployment)) + #expect( + SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: false) == nil + ) + } + + @Test + func substitute() { + let url = "vscode://coder.coder-remote/open?url=\(Self.origin)&token=\(Self.token)" + #expect( + SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: false) + == "vscode://coder.coder-remote/open?url=\(Self.origin)&token=secret" + ) + #expect( + SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: true) + == "vscode://coder.coder-remote/open?url=\(Self.origin)&token=" + ) + } + + @Test + func substituteNoPlaceholder() { + let url = "someapp://anything?x=1" + #expect( + SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: false) == url + ) + } + + @Test(arguments: [ + ("vscode://coder.coder-remote/open?owner=a&workspace=b", true), + ("JetBrains://Gateway/coder?workspace=b", true), + ("jetbrains-gateway://connect#type=coder&workspace=b", true), + ("jetbrains-gateway://connect#type=ssh&host=other.example", false), + ("vscode://other.ext/open?x=1", false), + ("\u{212A}iro://coder.coder-remote/open?x=1", false), + ("someapp://anything?x=1", false), + ]) + func knownHandler(url: String, expected: Bool) { + #expect(SessionTokenLink.isKnownHandler(url) == expected) + } + + @Test(arguments: [ + ("https://coder.example.com", true), + ("https://coder.example.com/", true), + ("http://localhost:3000", true), + ("https://coder.example.com/x", false), + ("https://user@coder.example.com", false), + ("https://coder.example.com?", false), + ("https://coder.example.com#", false), + ("https://coder.example.com/.", false), + ("https://coder.example.com\n", false), + ("https://coder example.com", false), + ("coder.example.com", false), + ]) + func bareOrigin(url: String, expected: Bool) { + #expect(SessionTokenLink.isBareOrigin(url) == expected) + } +} diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift index d0aead16..803e2c32 100644 --- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -27,10 +27,11 @@ struct WorkspaceAppTests { let workspaceApp = try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, + deploymentURL: baseAccessURL, sessionToken: sessionToken ) + #expect(workspaceApp.confirmBeforeOpening) #expect(workspaceApp.slug == "test-app") #expect(workspaceApp.displayName == "Test App") #expect(workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo") @@ -39,27 +40,30 @@ struct WorkspaceAppTests { @Test func testCreateWorkspaceApp_SessionTokenReplacement() throws { - let sdkApp = CoderSDK.WorkspaceApp( + let sdkApp = try CoderSDK.WorkspaceApp( id: UUID(), - url: URL(string: "vscode://myworkspace.coder/foo?token=$SESSION_TOKEN")!, + url: #require(URL(string: "vscode://coder.coder-remote/open?url=https://coder.example.com&token=$SESSION_TOKEN")), external: true, slug: "token-app", display_name: "Token App", command: nil, - icon: URL(string: "/icon/test-app.svg")!, + icon: #require(URL(string: "/icon/test-app.svg")), subdomain: false, subdomain_name: nil ) let workspaceApp = try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, + deploymentURL: baseAccessURL, sessionToken: sessionToken ) #expect( - workspaceApp.url.absoluteString == "vscode://myworkspace.coder/foo?token=test-session-token" + workspaceApp.url.absoluteString == + "vscode://coder.coder-remote/open?url=https://coder.example.com&token=test-session-token" ) + #expect(workspaceApp.blockedReason == nil) + #expect(!workspaceApp.confirmBeforeOpening) } @Test @@ -79,7 +83,7 @@ struct WorkspaceAppTests { #expect(throws: WorkspaceAppError.missingURL) { try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, + deploymentURL: baseAccessURL, sessionToken: sessionToken ) } @@ -102,7 +106,7 @@ struct WorkspaceAppTests { #expect(throws: WorkspaceAppError.isCommandApp) { try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, + deploymentURL: baseAccessURL, sessionToken: sessionToken ) } @@ -115,6 +119,7 @@ struct WorkspaceAppTests { let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) #expect(apps.count == 1) + #expect(!apps[0].confirmBeforeOpening) #expect(apps[0].slug == "-vscode") #expect(apps[0].displayName == "VS Code Desktop") #expect(apps[0].url.absoluteString == "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/user") @@ -162,7 +167,7 @@ struct WorkspaceAppTests { #expect(throws: WorkspaceAppError.isWebApp) { try WorkspaceApp( sdkApp, - iconBaseURL: baseAccessURL, + deploymentURL: baseAccessURL, sessionToken: sessionToken ) } @@ -241,3 +246,82 @@ struct WorkspaceAppTests { ) } } + +extension WorkspaceAppTests { + @Test + func createWorkspaceApp_SessionTokenBlocked() throws { + let sdkApp = try CoderSDK.WorkspaceApp( + id: UUID(), + url: #require(URL(string: "vscode://coder.coder-remote/open?url=https://other.example&token=$SESSION_TOKEN")), + external: true, + slug: "token-app", + display_name: "Token App", + command: nil, + icon: nil, + subdomain: false, + subdomain_name: nil + ) + + let workspaceApp = try WorkspaceApp( + sdkApp, + deploymentURL: baseAccessURL, + sessionToken: sessionToken + ) + + #expect(!workspaceApp.url.absoluteString.contains(sessionToken)) + #expect(workspaceApp.blockedReason?.hasPrefix(SessionTokenLink.blockedMessage) == true) + } + + @Test + func createWorkspaceApp_SubAgentGetsNoToken() throws { + let sdkApp = try CoderSDK.WorkspaceApp( + id: UUID(), + url: #require(URL( + string: "vscode://coder.coder-remote/openDevContainer?url=https://coder.example.com&token=$SESSION_TOKEN" + )), + external: true, + slug: "token-app", + display_name: "Token App", + command: nil, + icon: nil, + subdomain: false, + subdomain_name: nil + ) + + let agent = CoderSDK.WorkspaceAgent( + id: UUID(), + expanded_directory: nil, + parent_id: UUID(), + apps: [sdkApp], + display_apps: [] + ) + let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + + #expect(apps.count == 1) + #expect( + apps[0].url.absoluteString == + "vscode://coder.coder-remote/openDevContainer?url=https://coder.example.com&token=" + ) + #expect(apps[0].blockedReason == nil) + } + + @Test + func displayApps_VSCodeEscapesPath() { + let agent = CoderSDK.WorkspaceAgent( + id: UUID(), + expanded_directory: "/home/a b?x=1#y", + apps: [], + display_apps: [.vscode] + ) + + let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + + #expect(apps.count == 1) + #expect( + apps[0].url.absoluteString == + "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/a%20b%3Fx=1%23y" + ) + #expect(apps[0].url.query == nil) + #expect(apps[0].url.fragment == nil) + } +} diff --git a/Coder-Desktop/CoderSDK/Workspace.swift b/Coder-Desktop/CoderSDK/Workspace.swift index e70820da..ab77c4ca 100644 --- a/Coder-Desktop/CoderSDK/Workspace.swift +++ b/Coder-Desktop/CoderSDK/Workspace.swift @@ -43,12 +43,21 @@ public struct WorkspaceResource: Codable, Identifiable, Sendable { public struct WorkspaceAgent: Codable, Identifiable, Sendable { public let id: UUID public let expanded_directory: String? // `omitempty` + /// Set for sub-agents, such as dev containers. + public let parent_id: UUID? public let apps: [WorkspaceApp] public let display_apps: [DisplayApp] - public init(id: UUID, expanded_directory: String?, apps: [WorkspaceApp], display_apps: [DisplayApp]) { + public init( + id: UUID, + expanded_directory: String?, + parent_id: UUID? = nil, + apps: [WorkspaceApp], + display_apps: [DisplayApp] + ) { self.id = id self.expanded_directory = expanded_directory + self.parent_id = parent_id self.apps = apps self.display_apps = display_apps } From 159669455b401f221c9c53fabde20d67e88d777e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 28 Oct 2026 21:28:43 +0000 Subject: [PATCH 2/6] feat: let users skip the app link confirmation per scheme --- Coder-Desktop/Coder-Desktop/State.swift | 11 +++++++++++ .../Views/VPN/WorkspaceAppIcon.swift | 15 +++++++++++---- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift index 3807273d..af7fa765 100644 --- a/Coder-Desktop/Coder-Desktop/State.swift +++ b/Coder-Desktop/Coder-Desktop/State.swift @@ -79,6 +79,16 @@ class AppState: ObservableObject { } } + /// Lowercase URL schemes the user chose to open without confirmation. + @Published var trustedAppSchemes: Set = Set( + UserDefaults.standard.stringArray(forKey: Keys.trustedAppSchemes) ?? [] + ) { + didSet { + guard persistent else { return } + UserDefaults.standard.set(Array(trustedAppSchemes), forKey: Keys.trustedAppSchemes) + } + } + func tunnelProviderProtocol() -> NETunnelProviderProtocol? { if !hasSession { return nil } let proto = NETunnelProviderProtocol() @@ -228,6 +238,7 @@ class AppState: ObservableObject { static let dangerousDisableCoderSignatureValidation = "DangerousDisableCoderSignatureValidation" static let skipHiddenIconAlert = "SkipHiddenIconAlert" + static let trustedAppSchemes = "TrustedAppSchemes" } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 3f24dabf..e69025d3 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -7,6 +7,7 @@ struct WorkspaceAppIcon: View { let app: WorkspaceApp @Environment(\.openURL) private var openURL @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var state: AppState @State var isHovering: Bool = false @State var isPressed = false @@ -44,8 +45,9 @@ struct WorkspaceAppIcon: View { isPressed = false } guard app.blockedReason == nil else { return } - if app.confirmBeforeOpening { - confirmOpen() + let scheme = app.url.scheme?.lowercased() ?? "" + if app.confirmBeforeOpening, !state.trustedAppSchemes.contains(scheme) { + confirmOpen(scheme: scheme) } else { openURL(app.url) } @@ -57,15 +59,20 @@ struct WorkspaceAppIcon: View { /// `.alert` from SwiftUI doesn't play nice when the calling view is in the /// menu bar. - private func confirmOpen() { + private func confirmOpen(scheme: String) { let alert = NSAlert() alert.messageText = "Open \(app.displayName)?" - alert.informativeText = "This opens a \(app.url.scheme ?? ""): link in another app on your computer." + alert.informativeText = "This opens a \(scheme): link in another app on your computer." alert.alertStyle = .informational + let dontAskAgain = NSButton(checkboxWithTitle: "Don't ask again for \(scheme): links", target: nil, action: nil) + alert.accessoryView = dontAskAgain alert.addButton(withTitle: "Open") alert.addButton(withTitle: "Cancel") dismiss() if alert.runModal() == .alertFirstButtonReturn { + if dontAskAgain.state == .on { + state.trustedAppSchemes.insert(scheme) + } openURL(app.url) } } From 442bb5b52c808e0061d10438f92565b2e03b3ee4 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 28 Oct 2026 22:17:04 +0000 Subject: [PATCH 3/6] refactor: trim app link checks and tests --- .../Coder-Desktop/SessionTokenLink.swift | 107 ++++------ Coder-Desktop/Coder-Desktop/State.swift | 2 +- .../Views/VPN/WorkspaceAppIcon.swift | 10 +- .../SessionTokenLinkTests.swift | 189 +++++++++--------- .../WorkspaceAppTests.swift | 74 ++----- 5 files changed, 153 insertions(+), 229 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift index 4de7b983..a37f72b2 100644 --- a/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift +++ b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift @@ -1,70 +1,53 @@ import Foundation -/// Decides whether an external app link may receive the user's session token. -/// Applies the same rules as the Coder dashboard. +/// Decides whether an external app link may receive the user's session token, +/// using the same rules as the Coder dashboard. enum SessionTokenLink { - /// Replaced with the user's session token in trusted app links. static let placeholder = "$SESSION_TOKEN" - - /// Shown when an app needs a session token but its link isn't trusted with one. static let blockedMessage = "This app's URL isn't trusted to sign you in to Coder." - private struct Handler { - /// Scheme and authority, matched case-insensitively. - let base: String - /// Path through the first required param, matched exactly. - let path: String - } - - /// The only deep-link handlers that may receive the session token. - private static let handlers: [Handler] = [ + /// `base` (scheme and authority) is case-insensitive; `path` runs through + /// the first param and is exact. + private static let handlers: [(base: String, path: String)] = [ "vscode", "vscode-insiders", "windsurf", "devin", "cursor", "kiro", "positron", "antigravity", "antigravity-ide", ].flatMap { scheme in - ["/open?", "/openDevContainer?"].map { Handler(base: "\(scheme)://coder.coder-remote", path: $0) } + ["/open?", "/openDevContainer?"].map { ("\(scheme)://coder.coder-remote", $0) } } + [ - Handler(base: "jetbrains://gateway", path: "/coder?"), - Handler(base: "jetbrains://gateway", path: "/com.coder.toolbox?"), + ("jetbrains://gateway", "/coder?"), + ("jetbrains://gateway", "/com.coder.toolbox?"), // Gateway also handles non-Coder providers, such as `type=ssh`. - Handler(base: "jetbrains-gateway://connect", path: "#type=coder&"), + ("jetbrains-gateway://connect", "#type=coder&"), ] - /// Returns the link with the placeholder replaced by `token`, or by an empty - /// string for sub-agent apps. Links without the placeholder are returned - /// unchanged. Returns nil if the link isn't trusted with a token. - static func substitute( - _ appURL: String, - deploymentURL: URL, - token: String, - isSubAgentApp: Bool - ) -> String? { + static func isKnownHandler(_ appURL: String) -> Bool { + handler(for: appURL) != nil + } + + /// Replaces the placeholder with `token`, or with nothing for sub-agent apps. + /// Links without the placeholder are unchanged. Returns nil if the link + /// isn't trusted. + static func substitute(_ appURL: String, deploymentURL: URL, token: String, isSubAgentApp: Bool) -> String? { guard appURL.contains(placeholder) else { return appURL } guard isDestinationTrusted(appURL, deploymentURL: deploymentURL) else { return nil } - // Sub-agent apps never get a token; `coder open app` refuses them too. return appURL.replacingOccurrences(of: placeholder, with: isSubAgentApp ? "" : token) } - /// True when the link opens a known Coder handler, its only `url` param is - /// `deploymentURL`'s origin, and the placeholder is its only `token`. + /// True when the link opens a known handler, its only `url` is the + /// deployment's bare origin, and the placeholder is its only `token`. static func isDestinationTrusted(_ appURL: String, deploymentURL: URL) -> Bool { - // `URL(string:)` percent-encodes non-ASCII, so a valid link is printable - // ASCII. That also keeps Swift's grapheme-based string comparisons - // byte-exact. Some URL parsers drop tab, LF and CR, so `ur\tl` could - // become a second `url` in the IDE. + // Printable ASCII only: some parsers drop tab, LF and CR, so `ur\tl` + // could become `url`, and it keeps grapheme comparisons byte-exact. guard appURL.unicodeScalars.allSatisfy({ (0x20 ..< 0x7F).contains($0.value) }), appURL.components(separatedBy: placeholder).count == 2, let handler = handler(for: appURL) else { return false } - let params = String(appURL.dropFirst( - handler.base.count + handler.path.prefix { $0 != "?" && $0 != "#" }.count + 1 - )) + let paramsStart = handler.base.count + handler.path.prefix { $0 != "?" && $0 != "#" }.count + 1 + let params = String(appURL.dropFirst(paramsStart)) let entries = params.components(separatedBy: "&") - // Substitution replaces the raw placeholder, so it must be the raw - // `token` entry. JetBrains Gateway doesn't decode names, so they must be - // literal. IDEs decode values twice, so check that view too and reject - // leftovers. A later `#` would hide params from IDEs that read only the - // query. + // The placeholder is replaced raw, so it must be the raw `token` entry. + // Gateway doesn't decode names, and IDEs decode values twice. guard !params.contains("#"), entries.contains("token=\(placeholder)"), entries.allSatisfy({ !$0.prefix { $0 != "=" }.contains("%") }), @@ -75,23 +58,8 @@ enum SessionTokenLink { && hasTrustedParams(decoded, deploymentURL: deploymentURL) } - /// True when `url` is only an origin, optionally with a trailing slash. A - /// path could reach a workspace app on the deployment, and userinfo can hide - /// a different host from IDEs that decode it again. Matched on the raw - /// string, since parsing normalizes `/.` and drops an empty `?`. - static func isBareOrigin(_ url: String) -> Bool { - url.range(of: #"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/\\?#@\s]+/?\z"#, options: .regularExpression) != nil - && URLComponents(string: url)?.host?.isEmpty == false - } - - /// True when the link opens a known Coder IDE handler. - static func isKnownHandler(_ appURL: String) -> Bool { - handler(for: appURL) != nil - } - - /// The known handler the link starts with, if any. Compared as UTF-8 with - /// ASCII-only case folding, so a Kelvin sign doesn't match `kiro`. - private static func handler(for appURL: String) -> Handler? { + private static func handler(for appURL: String) -> (base: String, path: String)? { + // Compare UTF-8 with ASCII-only case folding, so a Kelvin sign isn't `k`. let bytes = appURL.utf8 return handlers.first { handler in let base = handler.base.utf8 @@ -100,10 +68,9 @@ enum SessionTokenLink { } } - /// Every name appears once, `token` is the placeholder, `url` is the deployment. private static func hasTrustedParams(_ query: String, deploymentURL: URL) -> Bool { - // Parsed like `URLSearchParams`. Split on scalars, since `&` or `=` - // followed by a combining mark is one `Character`. + // Parsed like `URLSearchParams`, split on scalars since `&` followed by a + // combining mark is one `Character`. let params = query.unicodeScalars.split(separator: "&").map { entry in entry.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map { part in let plain = String(part).replacingOccurrences(of: "+", with: " ", options: .literal) @@ -111,17 +78,17 @@ enum SessionTokenLink { } } let value = { (name: String) in params.first { $0[0] == name }.map { $0.count > 1 ? $0[1] : "" } } + // `url` must be a bare origin, matched raw since parsing normalizes `/.` and `?`. guard Set(params.map { $0[0] }).count == params.count, value("token") == placeholder, let url = value("url"), - isBareOrigin(url), - let url = URL(string: url), - let scheme = url.scheme?.lowercased(), - scheme == "http" || scheme == "https" + url.range(of: #"^https?://[^/\\?#@\s]+/?\z"#, options: [.regularExpression, .caseInsensitive]) != nil, + let url = URLComponents(string: url), + let deployment = URLComponents(url: deploymentURL, resolvingAgainstBaseURL: false) else { return false } - let defaultPort = scheme == "https" ? 443 : 80 - return scheme == deploymentURL.scheme?.lowercased() - && url.host?.lowercased() == deploymentURL.host?.lowercased() - && (url.port ?? defaultPort) == (deploymentURL.port ?? defaultPort) + let port = { (c: URLComponents) in c.port ?? (c.scheme?.lowercased() == "https" ? 443 : 80) } + return url.scheme?.lowercased() == deployment.scheme?.lowercased() + && url.host?.lowercased() == deployment.host?.lowercased() + && port(url) == port(deployment) } } diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift index af7fa765..99608517 100644 --- a/Coder-Desktop/Coder-Desktop/State.swift +++ b/Coder-Desktop/Coder-Desktop/State.swift @@ -79,7 +79,7 @@ class AppState: ObservableObject { } } - /// Lowercase URL schemes the user chose to open without confirmation. + /// Lowercase schemes of app links the user chose to open without confirmation. @Published var trustedAppSchemes: Set = Set( UserDefaults.standard.stringArray(forKey: Keys.trustedAppSchemes) ?? [] ) { diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index e69025d3..6cc9e9d1 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -83,10 +83,8 @@ struct WorkspaceApp { let displayName: String let url: URL let icon: URL? - /// Set when the app's link needs a session token but isn't trusted with - /// one. Such apps are shown disabled with this message. + /// Set when the link needs a session token but isn't trusted with one. let blockedReason: String? - /// True when the user must confirm before the app's link is opened. let confirmBeforeOpening: Bool var id: String { slug } @@ -100,8 +98,6 @@ struct WorkspaceApp { confirmBeforeOpening = false } - /// `deploymentURL` is the Coder deployment the user is signed in to. It is - /// the only `url` a link may name to receive `sessionToken`. init( _ original: CoderSDK.WorkspaceApp, deploymentURL: URL, @@ -255,8 +251,8 @@ func vscodeInsidersDisplayApp(hostname: String, baseIconURL: URL, path: String? ) } -/// Builds a VS Code Remote SSH link. The path setter percent-encodes the host -/// and directory, so they can't add a query or fragment to the link. +/// The path setter percent-encodes the host and directory, so they can't add a +/// query or fragment. private func vscodeRemoteURL(scheme: String, hostname: String, path: String?) -> URL { var components = URLComponents() components.scheme = scheme diff --git a/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift b/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift index 15ab6c7f..50f0a98f 100644 --- a/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift @@ -3,124 +3,117 @@ import Foundation import Testing struct SessionTokenLinkTests { - static let origin = "https://coder.example.com" - static let token = SessionTokenLink.placeholder let deployment = URL(string: "https://coder.example.com/")! + /// A link with `url` set to `origin` and the placeholder as `token`. + static func link(_ prefix: String, origin: String = "https://coder.example.com", extra: String = "") -> String { + "\(prefix)url=\(origin)&token=\(SessionTokenLink.placeholder)\(extra)" + } + + func trusted(_ url: String) -> Bool { + SessionTokenLink.isDestinationTrusted(url, deploymentURL: deployment) + } + @Test(arguments: [ - "vscode://coder.coder-remote/open?owner=a&workspace=b&url=\(origin)&token=\(token)", - "cursor://coder.coder-remote/open?url=\(origin)/&token=\(token)", - "jetbrains://gateway/coder?&workspace=b&url=\(origin)&token=\(token)&ide_product_code=GO", - "jetbrains-gateway://connect#type=coder&workspace=b&url=\(origin)&token=\(token)", - "cursor://coder.coder-remote/openDevContainer?url=\(origin)&token=\(token)", - "jetbrains://gateway/com.coder.toolbox?url=\(origin)&token=\(token)", - "VSCode://Coder.Coder-Remote/open?url=\(origin)&token=\(token)", - "vscode://coder.coder-remote/open?url=https://CODER.example.com&token=\(token)", - "vscode://coder.coder-remote/open?url=https://coder.example.com:443&token=\(token)", - "vscode://coder.coder-remote/open?url=https%3A%2F%2Fcoder.example.com&token=\(token)", - "vscode://coder.coder-remote/open?folder=/home/a%20b&url=\(origin)&token=\(token)", + "vscode://coder.coder-remote/open?owner=a&", + "cursor://coder.coder-remote/openDevContainer?", + "VSCode://Coder.Coder-Remote/open?", + "jetbrains://gateway/coder?", + "jetbrains://gateway/com.coder.toolbox?", + "jetbrains-gateway://connect#type=coder&", ]) - func trusted(url: String) { - #expect(SessionTokenLink.isDestinationTrusted(url, deploymentURL: deployment)) + func trustedHandler(prefix: String) { + #expect(trusted(Self.link(prefix))) } @Test(arguments: [ - "vscode://coder.coder-remote/open?url=https://other.example&token=\(token)", - "vscode://coder.coder-remote/open?url=https://coder.example.com:1&token=\(token)", - "vscode://coder.coder-remote/open?url=http://coder.example.com&token=\(token)", - "vscode://coder.coder-remote/open?url=https%3A%2F%2Fother.example&token=\(token)", - "vscode://coder.coder-remote/open?token=\(token)", - "vscode://coder.coder-remote/open?url=not-a-url&token=\(token)", - "vscode://coder.coder-remote/open?url=\(origin)&url=https://other.example&token=\(token)", - "jetbrains-gateway://connect?url=https://other.example#url=\(origin)&token=\(token)", - "jetbrains://gateway/coder?url=\(origin)&token=x&ide_download_link=https://other.example/\(token)", - "vscode://coder.coder-remote/open?url=\(origin)&token=\(token)&t=\(token)", - "vscode://coder.coder-remote/open?url=\(origin)#token=\(token)", - "vscode://coder.coder-remote/open?url=\(origin)&token=\(token)#url=https://other.example", - "vscode://other.ext/open?url=\(origin)&token=\(token)", - "coder://open?url=\(origin)&token=\(token)", - "vscode://coder.coder-remote/open?url=\(origin)/@victim/ws.main/apps/x/&token=\(token)", - "vscode://coder.coder-remote/open?url=https://other.example%252Fx@coder.example.com/&token=\(token)", - "vscode://coder.coder-remote/open?owner=x%26url%3Dhttps%253A%252F%252Fother.example&url=\(origin)&token=\(token)", - "vscode://coder.coder-remote/open?x=%252526url%25253Dhttps%25253A%25252F%25252Fother.example&url=\(origin)" - + "&token=\(token)", - "vscode:coder.coder-remote/open?url=\(origin)&token=\(token)", - "vscode://x@coder.coder-remote/open?url=\(origin)&token=\(token)", - "vscode://coder.coder-remote/open/../open?url=\(origin)&token=\(token)", - "vscode://coder.coder-remote/open#url=\(origin)&token=\(token)", - "jetbrains-gateway://connect?url=\(origin)&token=\(token)", - "jetbrains-gateway://connect#type=coder&url=\(origin)&token=%24SESSION_TOKEN" - + "&ide_download_link=https://other.example/\(token)", - "jetbrains-gateway://connect#type=coder&%75rl=\(origin)&token=\(token)", - "jetbrains-gateway://connect#type=ssh&host=other.example&url=\(origin)&token=\(token)", - "jetbrains-gateway://connect#type=coder&url=\(origin)&token=\(token)&type=ssh", - "vscode://coder.coder-remote/open?x=1#&url=\(origin)&token=\(token)", - "\u{212A}iro://coder.coder-remote/open?url=\(origin)&token=\(token)", - "jetbrains-gateway://connect#type=coder&url=\(origin)&ur\tl=https://other.example&token=\(token)", - "jetbrains-gateway://connect#type=coder&url=\(origin)&ur\nl=https://other.example&token=\(token)", - "jetbrains-gateway://connect#type=coder&url=\(origin)&ur\rl=https://other.example&token=\(token)", - "vscode://coder.coder-remote/open?url=\(origin)/.&token=\(token)", - "vscode://coder.coder-remote/open?url=\(origin)?&token=\(token)", - "vscode://coder.coder-remote/open?url=\(origin)%0A&token=\(token)", - "vscode://coder.coder-remote/open?url=\(origin)&token=\(token)%FF", - "vscode://coder.coder-remote/open?x=%25%CC%81&url=\(origin)&token=\(token)", - "vscode://coder.coder-remote/open?url=\(origin)&token=\(token)&x=%26url=%CC%81https://other.example", + "https://coder.example.com/", + "https://CODER.example.com", + "https://coder.example.com:443", + "https%3A%2F%2Fcoder.example.com", ]) - func untrusted(url: String) { - #expect(!SessionTokenLink.isDestinationTrusted(url, deploymentURL: deployment)) - #expect( - SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: false) == nil - ) + func trustedOrigin(origin: String) { + #expect(trusted(Self.link("vscode://coder.coder-remote/open?", origin: origin))) } - @Test - func substitute() { - let url = "vscode://coder.coder-remote/open?url=\(Self.origin)&token=\(Self.token)" - #expect( - SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: false) - == "vscode://coder.coder-remote/open?url=\(Self.origin)&token=secret" - ) - #expect( - SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: true) - == "vscode://coder.coder-remote/open?url=\(Self.origin)&token=" - ) + @Test(arguments: [ + "vscode://other.ext/open?", + "coder://open?", + "vscode:coder.coder-remote/open?", + "vscode://x@coder.coder-remote/open?", + "vscode://coder.coder-remote/open/../open?", + "vscode://coder.coder-remote/open#", + "jetbrains-gateway://connect?", + "jetbrains-gateway://connect#type=ssh&", + "\u{212A}iro://coder.coder-remote/open?", + ]) + func untrustedHandler(prefix: String) { + #expect(!trusted(Self.link(prefix))) + } + + @Test(arguments: [ + "https://other.example", + "https://coder.example.com:1", + "http://coder.example.com", + "https%3A%2F%2Fother.example", + "not-a-url", + "https://coder.example.com/@victim/ws.main/apps/x/", + "https://user@coder.example.com", + "https://other.example%252Fx@coder.example.com/", + "https://coder.example.com/.", + "https://coder.example.com?", + "https://coder.example.com%0A", + ]) + func untrustedOrigin(origin: String) { + #expect(!trusted(Self.link("vscode://coder.coder-remote/open?", origin: origin))) + } + + @Test(arguments: [ + "&url=https://other.example", + "&t=$SESSION_TOKEN", + "#url=https://other.example", + "&type=ssh", + "%FF", + "&x=%26url%3Dhttps%253A%252F%252Fother.example", + "&x=%252526url%25253Dhttps%25253A%25252F%25252Fother.example", + "&x=%26url=%CC%81https://other.example", + "&ur\tl=https://other.example", + "&%75rl=https://other.example", + ]) + func untrustedExtraParams(extra: String) { + #expect(!trusted(Self.link("jetbrains-gateway://connect#type=coder&", extra: extra))) + } + + @Test(arguments: [ + "vscode://coder.coder-remote/open?token=$SESSION_TOKEN", + "vscode://coder.coder-remote/open?url=https://coder.example.com#token=$SESSION_TOKEN", + "vscode://coder.coder-remote/open?x=1#&url=https://coder.example.com&token=$SESSION_TOKEN", + "jetbrains://gateway/coder?url=https://coder.example.com&token=x&ide_download_link=https://other.example/$SESSION_TOKEN", + "jetbrains-gateway://connect#type=coder&url=https://coder.example.com&token=%24SESSION_TOKEN&x=$SESSION_TOKEN", + ]) + func untrustedTokenPlacement(url: String) { + #expect(!trusted(url)) } @Test - func substituteNoPlaceholder() { - let url = "someapp://anything?x=1" - #expect( - SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: false) == url - ) + func substitute() { + let substitute = { (url: String, isSubAgentApp: Bool) in + SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: isSubAgentApp) + } + let url = Self.link("vscode://coder.coder-remote/open?") + #expect(substitute(url, false)?.hasSuffix("&token=secret") == true) + #expect(substitute(url, true)?.hasSuffix("&token=") == true) + #expect(substitute(Self.link("vscode://other.ext/open?"), false) == nil) + #expect(substitute("someapp://anything?x=1", false) == "someapp://anything?x=1") } @Test(arguments: [ - ("vscode://coder.coder-remote/open?owner=a&workspace=b", true), - ("JetBrains://Gateway/coder?workspace=b", true), + ("vscode://coder.coder-remote/open?owner=a", true), ("jetbrains-gateway://connect#type=coder&workspace=b", true), ("jetbrains-gateway://connect#type=ssh&host=other.example", false), - ("vscode://other.ext/open?x=1", false), - ("\u{212A}iro://coder.coder-remote/open?x=1", false), ("someapp://anything?x=1", false), ]) - func knownHandler(url: String, expected: Bool) { + func isKnownHandler(url: String, expected: Bool) { #expect(SessionTokenLink.isKnownHandler(url) == expected) } - - @Test(arguments: [ - ("https://coder.example.com", true), - ("https://coder.example.com/", true), - ("http://localhost:3000", true), - ("https://coder.example.com/x", false), - ("https://user@coder.example.com", false), - ("https://coder.example.com?", false), - ("https://coder.example.com#", false), - ("https://coder.example.com/.", false), - ("https://coder.example.com\n", false), - ("https://coder example.com", false), - ("coder.example.com", false), - ]) - func bareOrigin(url: String, expected: Bool) { - #expect(SessionTokenLink.isBareOrigin(url) == expected) - } } diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift index 803e2c32..98914a18 100644 --- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -248,80 +248,48 @@ struct WorkspaceAppTests { } extension WorkspaceAppTests { - @Test - func createWorkspaceApp_SessionTokenBlocked() throws { - let sdkApp = try CoderSDK.WorkspaceApp( - id: UUID(), - url: #require(URL(string: "vscode://coder.coder-remote/open?url=https://other.example&token=$SESSION_TOKEN")), - external: true, - slug: "token-app", - display_name: "Token App", - command: nil, - icon: nil, - subdomain: false, - subdomain_name: nil + private func externalApp(_ url: String) -> CoderSDK.WorkspaceApp { + CoderSDK.WorkspaceApp( + id: UUID(), url: URL(string: url)!, external: true, slug: "app", display_name: "App", + command: nil, icon: nil, subdomain: false, subdomain_name: nil ) + } - let workspaceApp = try WorkspaceApp( - sdkApp, + @Test + func createWorkspaceApp_SessionTokenBlocked() throws { + let app = try WorkspaceApp( + externalApp("vscode://coder.coder-remote/open?url=https://other.example&token=$SESSION_TOKEN"), deploymentURL: baseAccessURL, sessionToken: sessionToken ) - #expect(!workspaceApp.url.absoluteString.contains(sessionToken)) - #expect(workspaceApp.blockedReason?.hasPrefix(SessionTokenLink.blockedMessage) == true) + #expect(!app.url.absoluteString.contains(sessionToken)) + #expect(app.blockedReason?.hasPrefix(SessionTokenLink.blockedMessage) == true) } @Test - func createWorkspaceApp_SubAgentGetsNoToken() throws { - let sdkApp = try CoderSDK.WorkspaceApp( - id: UUID(), - url: #require(URL( - string: "vscode://coder.coder-remote/openDevContainer?url=https://coder.example.com&token=$SESSION_TOKEN" - )), - external: true, - slug: "token-app", - display_name: "Token App", - command: nil, - icon: nil, - subdomain: false, - subdomain_name: nil - ) - + func agentToApps_SubAgentGetsNoToken() { + let url = "vscode://coder.coder-remote/openDevContainer?url=https://coder.example.com&token=" let agent = CoderSDK.WorkspaceAgent( - id: UUID(), - expanded_directory: nil, - parent_id: UUID(), - apps: [sdkApp], - display_apps: [] + id: UUID(), expanded_directory: nil, parent_id: UUID(), + apps: [externalApp(url + "$SESSION_TOKEN")], display_apps: [] ) + let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) - #expect(apps.count == 1) - #expect( - apps[0].url.absoluteString == - "vscode://coder.coder-remote/openDevContainer?url=https://coder.example.com&token=" - ) - #expect(apps[0].blockedReason == nil) + #expect(apps.map(\.url.absoluteString) == [url]) } @Test func displayApps_VSCodeEscapesPath() { let agent = CoderSDK.WorkspaceAgent( - id: UUID(), - expanded_directory: "/home/a b?x=1#y", - apps: [], - display_apps: [.vscode] + id: UUID(), expanded_directory: "/home/a b?x=1#y", apps: [], display_apps: [.vscode] ) let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) - #expect(apps.count == 1) - #expect( - apps[0].url.absoluteString == - "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/a%20b%3Fx=1%23y" - ) - #expect(apps[0].url.query == nil) - #expect(apps[0].url.fragment == nil) + #expect(apps.map(\.url.absoluteString) == [ + "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/a%20b%3Fx=1%23y", + ]) } } From f4b2fbf737767be518b879ba00324da4fe28d670 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 29 Oct 2026 09:58:02 +0000 Subject: [PATCH 4/6] fix: trust the dashboard URL and explain blocked app links --- .../Coder-Desktop/SessionTokenLink.swift | 111 ++++++++----- Coder-Desktop/Coder-Desktop/State.swift | 6 + .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 4 +- .../Views/VPN/WorkspaceAppIcon.swift | 38 +++-- .../SessionTokenLinkTests.swift | 156 ++++++++---------- .../WorkspaceAppTests.swift | 38 ++++- Coder-Desktop/CoderSDK/Deployment.swift | 6 + 7 files changed, 206 insertions(+), 153 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift index a37f72b2..33bdbd5e 100644 --- a/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift +++ b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift @@ -1,10 +1,25 @@ import Foundation +/// Why an app link that needs a session token isn't given one. +enum SessionTokenBlockReason: Equatable { + case untrustedURL + case wrongDeployment(link: String, signedIn: URL) + + var description: String { + switch self { + case .untrustedURL: + "This app's URL isn't trusted to sign you in to Coder. Ask your template administrator to fix the " + + "app's url in the template's coder_app configuration." + case let .wrongDeployment(link, signedIn): + "This app signs in to \(link), but Coder Desktop is signed in to \(signedIn.absoluteString)." + } + } +} + /// Decides whether an external app link may receive the user's session token, /// using the same rules as the Coder dashboard. enum SessionTokenLink { static let placeholder = "$SESSION_TOKEN" - static let blockedMessage = "This app's URL isn't trusted to sign you in to Coder." /// `base` (scheme and authority) is case-insensitive; `path` runs through /// the first param and is exact. @@ -12,7 +27,7 @@ enum SessionTokenLink { "vscode", "vscode-insiders", "windsurf", "devin", "cursor", "kiro", "positron", "antigravity", "antigravity-ide", ].flatMap { scheme in - ["/open?", "/openDevContainer?"].map { ("\(scheme)://coder.coder-remote", $0) } + ["/open?", "/openDevContainer?"].map { path in ("\(scheme)://coder.coder-remote", path) } } + [ ("jetbrains://gateway", "/coder?"), ("jetbrains://gateway", "/com.coder.toolbox?"), @@ -24,38 +39,36 @@ enum SessionTokenLink { handler(for: appURL) != nil } - /// Replaces the placeholder with `token`, or with nothing for sub-agent apps. - /// Links without the placeholder are unchanged. Returns nil if the link - /// isn't trusted. - static func substitute(_ appURL: String, deploymentURL: URL, token: String, isSubAgentApp: Bool) -> String? { - guard appURL.contains(placeholder) else { return appURL } - guard isDestinationTrusted(appURL, deploymentURL: deploymentURL) else { return nil } - return appURL.replacingOccurrences(of: placeholder, with: isSubAgentApp ? "" : token) - } - - /// True when the link opens a known handler, its only `url` is the - /// deployment's bare origin, and the placeholder is its only `token`. - static func isDestinationTrusted(_ appURL: String, deploymentURL: URL) -> Bool { + /// Returns why the link can't get a session token, or nil if it has no + /// placeholder or its only `url` is `signedInURL` or `dashboardURL`. + static func blockReason(_ appURL: String, signedInURL: URL, dashboardURL: URL?) -> SessionTokenBlockReason? { + guard appURL.contains(placeholder) else { return nil } // Printable ASCII only: some parsers drop tab, LF and CR, so `ur\tl` // could become `url`, and it keeps grapheme comparisons byte-exact. - guard appURL.unicodeScalars.allSatisfy({ (0x20 ..< 0x7F).contains($0.value) }), + guard appURL.unicodeScalars.allSatisfy({ scalar in (0x20 ..< 0x7F).contains(scalar.value) }), appURL.components(separatedBy: placeholder).count == 2, let handler = handler(for: appURL) - else { return false } + else { return .untrustedURL } - let paramsStart = handler.base.count + handler.path.prefix { $0 != "?" && $0 != "#" }.count + 1 + let paramsStart = handler.base.count + handler.path.prefix { char in char != "?" && char != "#" }.count + 1 let params = String(appURL.dropFirst(paramsStart)) let entries = params.components(separatedBy: "&") - // The placeholder is replaced raw, so it must be the raw `token` entry. - // Gateway doesn't decode names, and IDEs decode values twice. + // The placeholder is replaced raw, so it must be the raw `token` entry, + // and unique names make it the only `token`. Gateway doesn't decode + // names, and IDEs decode values twice. guard !params.contains("#"), entries.contains("token=\(placeholder)"), - entries.allSatisfy({ !$0.prefix { $0 != "=" }.contains("%") }), + entries.allSatisfy({ entry in !entry.prefix { char in char != "=" }.contains("%") }), let decoded = params.removingPercentEncoding, - !decoded.unicodeScalars.contains("%") - else { return false } - return hasTrustedParams(params, deploymentURL: deploymentURL) - && hasTrustedParams(decoded, deploymentURL: deploymentURL) + !decoded.unicodeScalars.contains("%"), + let origin = linkOrigin(params), + linkOrigin(decoded) == origin + else { return .untrustedURL } + + let trusted = [signedInURL, dashboardURL].compactMap(\.self) + return trusted.contains { url in sameOrigin(origin, url) } + ? nil + : .wrongDeployment(link: origin, signedIn: signedInURL) } private static func handler(for appURL: String) -> (base: String, path: String)? { @@ -63,32 +76,44 @@ enum SessionTokenLink { let bytes = appURL.utf8 return handlers.first { handler in let base = handler.base.utf8 - return bytes.prefix(base.count).map { (65 ... 90).contains($0) ? $0 + 32 : $0 }.elementsEqual(base) - && bytes.dropFirst(base.count).starts(with: handler.path.utf8) + let folded = bytes.prefix(base.count).map { byte in (65 ... 90).contains(byte) ? byte + 32 : byte } + return folded.elementsEqual(base) && bytes.dropFirst(base.count).starts(with: handler.path.utf8) } } - private static func hasTrustedParams(_ query: String, deploymentURL: URL) -> Bool { - // Parsed like `URLSearchParams`, split on scalars since `&` followed by a - // combining mark is one `Character`. - let params = query.unicodeScalars.split(separator: "&").map { entry in - entry.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map { part in + /// Returns the `url` param if every name is unique and `url` is a bare + /// http(s) origin. Matched raw, since parsing normalizes `/.` and drops an + /// empty `?`. + private static func linkOrigin(_ query: String) -> String? { + let params = parseQuery(query) + guard Set(params.map(\.name)).count == params.count, + let url = params.first(where: { param in param.name == "url" })?.value, + url.range(of: #"^https?://[^/\\?#@\s]+/?\z"#, options: [.regularExpression, .caseInsensitive]) != nil + else { return nil } + return url + } + + /// Parsed like `URLSearchParams`, split on scalars since `&` followed by a + /// combining mark is one `Character`. + private static func parseQuery(_ query: String) -> [(name: String, value: String)] { + query.unicodeScalars.split(separator: "&").map { entry in + let parts = entry.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map { part in let plain = String(part).replacingOccurrences(of: "+", with: " ", options: .literal) return plain.removingPercentEncoding ?? plain } + return (parts[0], parts.count > 1 ? parts[1] : "") } - let value = { (name: String) in params.first { $0[0] == name }.map { $0.count > 1 ? $0[1] : "" } } - // `url` must be a bare origin, matched raw since parsing normalizes `/.` and `?`. - guard Set(params.map { $0[0] }).count == params.count, - value("token") == placeholder, - let url = value("url"), - url.range(of: #"^https?://[^/\\?#@\s]+/?\z"#, options: [.regularExpression, .caseInsensitive]) != nil, - let url = URLComponents(string: url), - let deployment = URLComponents(url: deploymentURL, resolvingAgainstBaseURL: false) + } + + private static func sameOrigin(_ origin: String, _ url: URL) -> Bool { + guard let lhs = URLComponents(string: origin), + let rhs = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false } - let port = { (c: URLComponents) in c.port ?? (c.scheme?.lowercased() == "https" ? 443 : 80) } - return url.scheme?.lowercased() == deployment.scheme?.lowercased() - && url.host?.lowercased() == deployment.host?.lowercased() - && port(url) == port(deployment) + let port = { (components: URLComponents) in + components.port ?? (components.scheme?.lowercased() == "https" ? 443 : 80) + } + return lhs.scheme?.lowercased() == rhs.scheme?.lowercased() + && lhs.host?.lowercased() == rhs.host?.lowercased() + && port(lhs) == port(rhs) } } diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift index 99608517..c714e6b0 100644 --- a/Coder-Desktop/Coder-Desktop/State.swift +++ b/Coder-Desktop/Coder-Desktop/State.swift @@ -181,6 +181,10 @@ class AppState: ObservableObject { } } + /// The deployment's access URL from build info. Not saved, since an admin + /// can change it. + @Published var dashboardURL: URL? + private var refreshTask: Task? public func refreshDeploymentConfig() async { // Client is non-nil if there's a sesssion @@ -201,12 +205,14 @@ class AppState: ObservableObject { } hostnameSuffix = await refreshTask?.value ?? Self.defaultHostnameSuffix + dashboardURL = try? await client.buildInfo().dashboard_url } } public func clearSession() { hasSession = false sessionToken = nil + dashboardURL = nil refreshTask?.cancel() client = nil reconfigure() diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index c598889d..1bcf0123 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -189,7 +189,9 @@ struct MenuItemView: View { .flatMap(\.self) .first(where: { $0.id == agent.id }) { - apps = agentToApps(logger, wsAgent, agent.primaryHost, baseAccessURL, sessionToken) + apps = agentToApps( + logger, wsAgent, agent.primaryHost, baseAccessURL, state.dashboardURL, sessionToken + ) } else { logger.error("Could not find agent '\(agent.id)' in workspace '\(item.wsName)' resources") } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index 6cc9e9d1..dd644f96 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -54,7 +54,7 @@ struct WorkspaceAppIcon: View { } ) .opacity(app.blockedReason == nil ? 1 : 0.4) - .help(app.blockedReason.map { "\(app.displayName): \($0)" } ?? app.displayName) + .help(app.blockedReason.map { reason in "\(app.displayName): \(reason.description)" } ?? app.displayName) } /// `.alert` from SwiftUI doesn't play nice when the calling view is in the @@ -84,7 +84,7 @@ struct WorkspaceApp { let url: URL let icon: URL? /// Set when the link needs a session token but isn't trusted with one. - let blockedReason: String? + let blockedReason: SessionTokenBlockReason? let confirmBeforeOpening: Bool var id: String { slug } @@ -101,8 +101,8 @@ struct WorkspaceApp { init( _ original: CoderSDK.WorkspaceApp, deploymentURL: URL, - sessionToken: String, - isSubAgentApp: Bool = false + dashboardURL: URL?, + sessionToken: String ) throws(WorkspaceAppError) { slug = original.slug // Same behaviour as the web UI @@ -127,22 +127,22 @@ struct WorkspaceApp { } confirmBeforeOpening = !SessionTokenLink.isKnownHandler(originalUrl.absoluteString) - if let newUrlString = SessionTokenLink.substitute( + blockedReason = SessionTokenLink.blockReason( originalUrl.absoluteString, - deploymentURL: deploymentURL, - token: sessionToken, - isSubAgentApp: isSubAgentApp - ) { + signedInURL: deploymentURL, + dashboardURL: dashboardURL + ) + if blockedReason == nil { + let newUrlString = originalUrl.absoluteString.replacingOccurrences( + of: SessionTokenLink.placeholder, + with: sessionToken + ) guard let newUrl = URL(string: newUrlString) else { throw .invalidURL } url = newUrl - blockedReason = nil } else { url = originalUrl - blockedReason = SessionTokenLink.blockedMessage + " " + (isSubAgentApp - ? "Fix the app's url in the dev container's devcontainer.json." - : "Ask your template administrator to fix the app's url in the template's coder_app configuration.") } var icon = original.icon @@ -185,20 +185,28 @@ enum WorkspaceAppError: Error { var localizedDescription: String { description } } +// swiftlint:disable:next function_parameter_count func agentToApps( _ logger: Logger, _ agent: CoderSDK.WorkspaceAgent, _ host: String, _ baseAccessURL: URL, + _ dashboardURL: URL?, _ sessionToken: String ) -> [WorkspaceApp] { let workspaceApps = agent.apps.compactMap { app in + var app = app + // Dev container apps never get a token. coderd removes the placeholder + // since coder/coder#31262, but older servers still send it. + if agent.parent_id != nil, let url = app.url { + app.url = URL(string: url.absoluteString.replacingOccurrences(of: SessionTokenLink.placeholder, with: "")) + } do throws(WorkspaceAppError) { return try WorkspaceApp( app, deploymentURL: baseAccessURL, - sessionToken: sessionToken, - isSubAgentApp: agent.parent_id != nil + dashboardURL: dashboardURL, + sessionToken: sessionToken ) } catch { logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)") diff --git a/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift b/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift index 50f0a98f..f1fd2557 100644 --- a/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/SessionTokenLinkTests.swift @@ -3,114 +3,98 @@ import Foundation import Testing struct SessionTokenLinkTests { - let deployment = URL(string: "https://coder.example.com/")! + static let signedIn = URL(string: "https://coder.example.com/")! + static let dashboard = URL(string: "https://dashboard.example/")! + + struct LinkCase: CustomStringConvertible { + let description: String + let url: String + let expected: SessionTokenBlockReason? + } /// A link with `url` set to `origin` and the placeholder as `token`. static func link(_ prefix: String, origin: String = "https://coder.example.com", extra: String = "") -> String { "\(prefix)url=\(origin)&token=\(SessionTokenLink.placeholder)\(extra)" } - func trusted(_ url: String) -> Bool { - SessionTokenLink.isDestinationTrusted(url, deploymentURL: deployment) + static func trusted(_ description: String, _ url: String) -> LinkCase { + LinkCase(description: description, url: url, expected: nil) } - @Test(arguments: [ - "vscode://coder.coder-remote/open?owner=a&", - "cursor://coder.coder-remote/openDevContainer?", - "VSCode://Coder.Coder-Remote/open?", - "jetbrains://gateway/coder?", - "jetbrains://gateway/com.coder.toolbox?", - "jetbrains-gateway://connect#type=coder&", - ]) - func trustedHandler(prefix: String) { - #expect(trusted(Self.link(prefix))) + static func untrusted(_ description: String, _ url: String) -> LinkCase { + LinkCase(description: description, url: url, expected: .untrustedURL) } - @Test(arguments: [ - "https://coder.example.com/", - "https://CODER.example.com", - "https://coder.example.com:443", - "https%3A%2F%2Fcoder.example.com", - ]) - func trustedOrigin(origin: String) { - #expect(trusted(Self.link("vscode://coder.coder-remote/open?", origin: origin))) + static func wrongDeployment(_ description: String, origin: String) -> LinkCase { + LinkCase( + description: description, + url: link("vscode://coder.coder-remote/open?", origin: origin), + expected: .wrongDeployment(link: origin, signedIn: signedIn) + ) } - @Test(arguments: [ - "vscode://other.ext/open?", - "coder://open?", - "vscode:coder.coder-remote/open?", - "vscode://x@coder.coder-remote/open?", - "vscode://coder.coder-remote/open/../open?", - "vscode://coder.coder-remote/open#", - "jetbrains-gateway://connect?", - "jetbrains-gateway://connect#type=ssh&", - "\u{212A}iro://coder.coder-remote/open?", - ]) - func untrustedHandler(prefix: String) { - #expect(!trusted(Self.link(prefix))) - } + static let vscode = "vscode://coder.coder-remote/open?" + static let gateway = "jetbrains-gateway://connect#type=coder&" - @Test(arguments: [ - "https://other.example", - "https://coder.example.com:1", - "http://coder.example.com", - "https%3A%2F%2Fother.example", - "not-a-url", - "https://coder.example.com/@victim/ws.main/apps/x/", - "https://user@coder.example.com", - "https://other.example%252Fx@coder.example.com/", - "https://coder.example.com/.", - "https://coder.example.com?", - "https://coder.example.com%0A", - ]) - func untrustedOrigin(origin: String) { - #expect(!trusted(Self.link("vscode://coder.coder-remote/open?", origin: origin))) - } + @Test("Block reasons", arguments: [ + trusted("no placeholder", "someapp://anything?x=1"), + trusted("VS Code", link("vscode://coder.coder-remote/open?owner=a&")), + trusted("dev container handler", link("cursor://coder.coder-remote/openDevContainer?")), + trusted("uppercase scheme and authority", link("VSCode://Coder.Coder-Remote/open?")), + trusted("Gateway plugin", link("jetbrains://gateway/coder?")), + trusted("Toolbox", link("jetbrains://gateway/com.coder.toolbox?")), + trusted("Gateway fragment", link(gateway)), + trusted("trailing slash", link(vscode, origin: "https://coder.example.com/")), + trusted("uppercase host", link(vscode, origin: "https://CODER.example.com")), + trusted("default port", link(vscode, origin: "https://coder.example.com:443")), + trusted("encoded url value", link(vscode, origin: "https%3A%2F%2Fcoder.example.com")), + trusted("dashboard URL", link(vscode, origin: "https://dashboard.example")), - @Test(arguments: [ - "&url=https://other.example", - "&t=$SESSION_TOKEN", - "#url=https://other.example", - "&type=ssh", - "%FF", - "&x=%26url%3Dhttps%253A%252F%252Fother.example", - "&x=%252526url%25253Dhttps%25253A%25252F%25252Fother.example", - "&x=%26url=%CC%81https://other.example", - "&ur\tl=https://other.example", - "&%75rl=https://other.example", - ]) - func untrustedExtraParams(extra: String) { - #expect(!trusted(Self.link("jetbrains-gateway://connect#type=coder&", extra: extra))) - } + untrusted("unknown extension", link("vscode://other.ext/open?")), + untrusted("unknown scheme", link("coder://open?")), + untrusted("opaque handler", link("vscode:coder.coder-remote/open?")), + untrusted("userinfo in handler", link("vscode://x@coder.coder-remote/open?")), + untrusted("dot segments in handler", link("vscode://coder.coder-remote/open/../open?")), + untrusted("VS Code params in fragment", link("vscode://coder.coder-remote/open#")), + untrusted("Gateway params in query", link("jetbrains-gateway://connect?")), + untrusted("non-Coder Gateway provider", link("jetbrains-gateway://connect#type=ssh&")), + untrusted("tab in a param name", link(gateway, extra: "&ur\tl=https://other.example")), + untrusted("second placeholder", link(gateway, extra: "&x=$SESSION_TOKEN")), + untrusted("params after a fragment", link("\(vscode)x=1#&")), + untrusted( + "placeholder outside the raw token entry", + "\(vscode)url=https://coder.example.com&token=%24SESSION_TOKEN&y=$SESSION_TOKEN" + ), + untrusted("encoded url name", "\(vscode)%75rl=https://coder.example.com&token=$SESSION_TOKEN"), + untrusted("invalid percent encoding", link(gateway, extra: "&x=%FF")), + untrusted("double-encoded url param", link(gateway, extra: "&x=%2526url%253Dhttps://other.example")), + untrusted("duplicate url", link(gateway, extra: "&url=https://other.example")), + untrusted("duplicate Gateway type", link(gateway, extra: "&type=ssh")), + untrusted("url hidden by a combining mark", link(gateway, extra: "&x=%26url=%CC%81https://other.example")), + untrusted("url smuggled in an encoded value", link(gateway, extra: "&x=a%26url%3Dhttps://other.example")), + untrusted("no url", "\(vscode)token=$SESSION_TOKEN"), + untrusted("url isn't a URL", link(vscode, origin: "not-a-url")), + untrusted("url has a path", link(vscode, origin: "https://coder.example.com/@victim/ws.main/apps/x/")), + untrusted("url has userinfo", link(vscode, origin: "https://user@coder.example.com")), + untrusted("url has a dot segment", link(vscode, origin: "https://coder.example.com/.")), + untrusted("url has an empty query", link(vscode, origin: "https://coder.example.com?")), - @Test(arguments: [ - "vscode://coder.coder-remote/open?token=$SESSION_TOKEN", - "vscode://coder.coder-remote/open?url=https://coder.example.com#token=$SESSION_TOKEN", - "vscode://coder.coder-remote/open?x=1#&url=https://coder.example.com&token=$SESSION_TOKEN", - "jetbrains://gateway/coder?url=https://coder.example.com&token=x&ide_download_link=https://other.example/$SESSION_TOKEN", - "jetbrains-gateway://connect#type=coder&url=https://coder.example.com&token=%24SESSION_TOKEN&x=$SESSION_TOKEN", + wrongDeployment("another host", origin: "https://other.example"), + wrongDeployment("another port", origin: "https://coder.example.com:1"), + wrongDeployment("http instead of https", origin: "http://coder.example.com"), ]) - func untrustedTokenPlacement(url: String) { - #expect(!trusted(url)) - } - - @Test - func substitute() { - let substitute = { (url: String, isSubAgentApp: Bool) in - SessionTokenLink.substitute(url, deploymentURL: deployment, token: "secret", isSubAgentApp: isSubAgentApp) - } - let url = Self.link("vscode://coder.coder-remote/open?") - #expect(substitute(url, false)?.hasSuffix("&token=secret") == true) - #expect(substitute(url, true)?.hasSuffix("&token=") == true) - #expect(substitute(Self.link("vscode://other.ext/open?"), false) == nil) - #expect(substitute("someapp://anything?x=1", false) == "someapp://anything?x=1") + func blockReason(_ testCase: LinkCase) { + #expect( + SessionTokenLink.blockReason(testCase.url, signedInURL: Self.signedIn, dashboardURL: Self.dashboard) + == testCase.expected + ) } @Test(arguments: [ ("vscode://coder.coder-remote/open?owner=a", true), ("jetbrains-gateway://connect#type=coder&workspace=b", true), ("jetbrains-gateway://connect#type=ssh&host=other.example", false), + ("\u{212A}iro://coder.coder-remote/open?", false), ("someapp://anything?x=1", false), ]) func isKnownHandler(url: String, expected: Bool) { diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift index 98914a18..f19cc57e 100644 --- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -28,6 +28,7 @@ struct WorkspaceAppTests { let workspaceApp = try WorkspaceApp( sdkApp, deploymentURL: baseAccessURL, + dashboardURL: nil, sessionToken: sessionToken ) @@ -55,6 +56,7 @@ struct WorkspaceAppTests { let workspaceApp = try WorkspaceApp( sdkApp, deploymentURL: baseAccessURL, + dashboardURL: nil, sessionToken: sessionToken ) @@ -84,6 +86,7 @@ struct WorkspaceAppTests { try WorkspaceApp( sdkApp, deploymentURL: baseAccessURL, + dashboardURL: nil, sessionToken: sessionToken ) } @@ -107,6 +110,7 @@ struct WorkspaceAppTests { try WorkspaceApp( sdkApp, deploymentURL: baseAccessURL, + dashboardURL: nil, sessionToken: sessionToken ) } @@ -116,7 +120,7 @@ struct WorkspaceAppTests { func testDisplayApps_VSCode() throws { let agent = createMockAgent(displayApps: [.vscode, .web_terminal, .ssh_helper, .port_forwarding_helper]) - let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) #expect(apps.count == 1) #expect(!apps[0].confirmBeforeOpening) @@ -137,7 +141,7 @@ struct WorkspaceAppTests { ] ) - let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) #expect(apps.count == 1) #expect(apps[0].slug == "-vscode-insiders") @@ -168,6 +172,7 @@ struct WorkspaceAppTests { try WorkspaceApp( sdkApp, deploymentURL: baseAccessURL, + dashboardURL: nil, sessionToken: sessionToken ) } @@ -225,7 +230,7 @@ struct WorkspaceAppTests { ) let agent = createMockAgent(apps: [sdkApp1, sdkApp2, sdkApp3, sdkApp4], displayApps: [.vscode]) - let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) #expect(apps.count == 3) let appSlugs = apps.map(\.slug) @@ -260,24 +265,41 @@ extension WorkspaceAppTests { let app = try WorkspaceApp( externalApp("vscode://coder.coder-remote/open?url=https://other.example&token=$SESSION_TOKEN"), deploymentURL: baseAccessURL, + dashboardURL: nil, sessionToken: sessionToken ) - #expect(!app.url.absoluteString.contains(sessionToken)) - #expect(app.blockedReason?.hasPrefix(SessionTokenLink.blockedMessage) == true) + #expect(app.url.absoluteString.contains(SessionTokenLink.placeholder)) + #expect(app.blockedReason == .wrongDeployment(link: "https://other.example", signedIn: baseAccessURL)) + } + + @Test + func createWorkspaceApp_TrustsDashboardURL() throws { + let dashboardURL = try #require(URL(string: "https://dogfood.example/")) + let app = try WorkspaceApp( + externalApp("vscode://coder.coder-remote/open?url=https://dogfood.example&token=$SESSION_TOKEN"), + deploymentURL: baseAccessURL, + dashboardURL: dashboardURL, + sessionToken: sessionToken + ) + + #expect(app.url.absoluteString.hasSuffix("&token=\(sessionToken)")) + #expect(app.blockedReason == nil) } @Test func agentToApps_SubAgentGetsNoToken() { - let url = "vscode://coder.coder-remote/openDevContainer?url=https://coder.example.com&token=" + // The trust check doesn't apply, since there's no token to protect. + let url = "vscode://coder.coder-remote/openDevContainer?url=https://other.example&token=" let agent = CoderSDK.WorkspaceAgent( id: UUID(), expanded_directory: nil, parent_id: UUID(), apps: [externalApp(url + "$SESSION_TOKEN")], display_apps: [] ) - let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) #expect(apps.map(\.url.absoluteString) == [url]) + #expect(apps.map(\.blockedReason) == [nil]) } @Test @@ -286,7 +308,7 @@ extension WorkspaceAppTests { id: UUID(), expanded_directory: "/home/a b?x=1#y", apps: [], display_apps: [.vscode] ) - let apps = agentToApps(logger, agent, host, baseAccessURL, sessionToken) + let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) #expect(apps.map(\.url.absoluteString) == [ "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/a%20b%3Fx=1%23y", diff --git a/Coder-Desktop/CoderSDK/Deployment.swift b/Coder-Desktop/CoderSDK/Deployment.swift index b88029f1..e5178842 100644 --- a/Coder-Desktop/CoderSDK/Deployment.swift +++ b/Coder-Desktop/CoderSDK/Deployment.swift @@ -12,6 +12,12 @@ public extension Client { public struct BuildInfoResponse: Codable, Equatable, Sendable { public let version: String + public let dashboard_url: URL? + + public init(version: String, dashboard_url: URL? = nil) { + self.version = version + self.dashboard_url = dashboard_url + } // `version` in the form `[0-9]+.[0-9]+.[0-9]+` public var semver: String? { From 628d89157c06a32b15b01bcf6b7c7b8eb381e0da Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 29 Oct 2026 10:11:39 +0000 Subject: [PATCH 5/6] refactor: simplify app link checks --- .../Coder-Desktop/SessionTokenLink.swift | 110 +++++++++--------- 1 file changed, 52 insertions(+), 58 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift index 33bdbd5e..7c35b995 100644 --- a/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift +++ b/Coder-Desktop/Coder-Desktop/SessionTokenLink.swift @@ -21,99 +21,93 @@ enum SessionTokenBlockReason: Equatable { enum SessionTokenLink { static let placeholder = "$SESSION_TOKEN" - /// `base` (scheme and authority) is case-insensitive; `path` runs through - /// the first param and is exact. - private static let handlers: [(base: String, path: String)] = [ + private struct Handler { + /// Scheme and authority, matched case-insensitively. + let base: String + /// Matched exactly, up to where the params start. + let path: String + var firstParam = "" + } + + private static let handlers: [Handler] = [ "vscode", "vscode-insiders", "windsurf", "devin", "cursor", "kiro", "positron", "antigravity", "antigravity-ide", ].flatMap { scheme in - ["/open?", "/openDevContainer?"].map { path in ("\(scheme)://coder.coder-remote", path) } + ["/open?", "/openDevContainer?"].map { path in Handler(base: "\(scheme)://coder.coder-remote", path: path) } } + [ - ("jetbrains://gateway", "/coder?"), - ("jetbrains://gateway", "/com.coder.toolbox?"), + Handler(base: "jetbrains://gateway", path: "/coder?"), + Handler(base: "jetbrains://gateway", path: "/com.coder.toolbox?"), // Gateway also handles non-Coder providers, such as `type=ssh`. - ("jetbrains-gateway://connect", "#type=coder&"), + Handler(base: "jetbrains-gateway://connect", path: "#", firstParam: "type=coder&"), ] static func isKnownHandler(_ appURL: String) -> Bool { handler(for: appURL) != nil } - /// Returns why the link can't get a session token, or nil if it has no - /// placeholder or its only `url` is `signedInURL` or `dashboardURL`. + /// Returns nil if the link has no placeholder, or its only `url` is + /// `signedInURL` or `dashboardURL`. static func blockReason(_ appURL: String, signedInURL: URL, dashboardURL: URL?) -> SessionTokenBlockReason? { guard appURL.contains(placeholder) else { return nil } - // Printable ASCII only: some parsers drop tab, LF and CR, so `ur\tl` - // could become `url`, and it keeps grapheme comparisons byte-exact. - guard appURL.unicodeScalars.allSatisfy({ scalar in (0x20 ..< 0x7F).contains(scalar.value) }), - appURL.components(separatedBy: placeholder).count == 2, + guard appURL.components(separatedBy: placeholder).count == 2, let handler = handler(for: appURL) else { return .untrustedURL } - let paramsStart = handler.base.count + handler.path.prefix { char in char != "?" && char != "#" }.count + 1 - let params = String(appURL.dropFirst(paramsStart)) + // The placeholder is replaced raw, so it must be the raw `token` value. + // Gateway doesn't decode names, IDEs decode values twice, and VS Code + // ignores params after a `#`. + let params = String(appURL.dropFirst(handler.base.count + handler.path.count)) let entries = params.components(separatedBy: "&") - // The placeholder is replaced raw, so it must be the raw `token` entry, - // and unique names make it the only `token`. Gateway doesn't decode - // names, and IDEs decode values twice. - guard !params.contains("#"), - entries.contains("token=\(placeholder)"), + guard entries.contains("token=\(placeholder)"), entries.allSatisfy({ entry in !entry.prefix { char in char != "=" }.contains("%") }), + !params.contains("#"), let decoded = params.removingPercentEncoding, !decoded.unicodeScalars.contains("%"), - let origin = linkOrigin(params), - linkOrigin(decoded) == origin + let url = urlParam(params), + urlParam(decoded) == url else { return .untrustedURL } let trusted = [signedInURL, dashboardURL].compactMap(\.self) - return trusted.contains { url in sameOrigin(origin, url) } + return trusted.contains { trustedURL in isSameOrigin(url, trustedURL) } ? nil - : .wrongDeployment(link: origin, signedIn: signedInURL) + : .wrongDeployment(link: url, signedIn: signedInURL) } - private static func handler(for appURL: String) -> (base: String, path: String)? { - // Compare UTF-8 with ASCII-only case folding, so a Kelvin sign isn't `k`. - let bytes = appURL.utf8 + /// Only printable ASCII links match, so parsers can't drop a tab, LF or CR + /// and `lowercased()` can't fold a Kelvin sign into `k`. + private static func handler(for appURL: String) -> Handler? { + guard appURL.unicodeScalars.allSatisfy({ scalar in (0x20 ..< 0x7F).contains(scalar.value) }) else { + return nil + } return handlers.first { handler in - let base = handler.base.utf8 - let folded = bytes.prefix(base.count).map { byte in (65 ... 90).contains(byte) ? byte + 32 : byte } - return folded.elementsEqual(base) && bytes.dropFirst(base.count).starts(with: handler.path.utf8) + appURL.prefix(handler.base.count).lowercased() == handler.base + && appURL.dropFirst(handler.base.count).hasPrefix(handler.path + handler.firstParam) } } - /// Returns the `url` param if every name is unique and `url` is a bare - /// http(s) origin. Matched raw, since parsing normalizes `/.` and drops an - /// empty `?`. - private static func linkOrigin(_ query: String) -> String? { - let params = parseQuery(query) - guard Set(params.map(\.name)).count == params.count, - let url = params.first(where: { param in param.name == "url" })?.value, - url.range(of: #"^https?://[^/\\?#@\s]+/?\z"#, options: [.regularExpression, .caseInsensitive]) != nil - else { return nil } - return url - } - - /// Parsed like `URLSearchParams`, split on scalars since `&` followed by a - /// combining mark is one `Character`. - private static func parseQuery(_ query: String) -> [(name: String, value: String)] { - query.unicodeScalars.split(separator: "&").map { entry in - let parts = entry.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map { part in + /// Returns `url` if the names are unique and it's a bare http(s) origin, + /// matched raw since parsing normalizes `/.` and drops an empty `?`. + private static func urlParam(_ query: String) -> String? { + // Split on scalars like `URLSearchParams`, since `&` followed by a + // combining mark is one `Character`. + let params = query.unicodeScalars.split(separator: "&").map { entry in + entry.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).map { part in let plain = String(part).replacingOccurrences(of: "+", with: " ", options: .literal) return plain.removingPercentEncoding ?? plain } - return (parts[0], parts.count > 1 ? parts[1] : "") } + guard Set(params.map { param in param[0] }).count == params.count, + let url = params.first(where: { param in param[0] == "url" && param.count == 2 })?[1], + url.range(of: #"^https?://[^/\\?#@\s]+/?\z"#, options: [.regularExpression, .caseInsensitive]) != nil + else { return nil } + return url } - private static func sameOrigin(_ origin: String, _ url: URL) -> Bool { - guard let lhs = URLComponents(string: origin), - let rhs = URLComponents(url: url, resolvingAgainstBaseURL: false) - else { return false } - let port = { (components: URLComponents) in - components.port ?? (components.scheme?.lowercased() == "https" ? 443 : 80) - } - return lhs.scheme?.lowercased() == rhs.scheme?.lowercased() - && lhs.host?.lowercased() == rhs.host?.lowercased() - && port(lhs) == port(rhs) + private static func isSameOrigin(_ link: String, _ url: URL) -> Bool { + guard let origin = URL(string: link) else { return false } + let port = { (target: URL) in target.port ?? (target.scheme?.lowercased() == "https" ? 443 : 80) } + return origin.scheme?.lowercased() == url.scheme?.lowercased() + && origin.host?.lowercased() == url.host?.lowercased() + && port(origin) == port(url) } } From 3ff66f1a23a33602099a6f04637bd1b3151fd433 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 29 Oct 2026 10:30:25 +0000 Subject: [PATCH 6/6] refactor: pass the app link session as one value --- Coder-Desktop/Coder-Desktop/State.swift | 6 +- .../Coder-Desktop/Views/VPN/VPNMenuItem.swift | 7 +- .../Views/VPN/WorkspaceAppIcon.swift | 40 ++++++----- .../WorkspaceAppTests.swift | 66 ++++++++----------- Coder-Desktop/CoderSDK/Workspace.swift | 2 +- 5 files changed, 59 insertions(+), 62 deletions(-) diff --git a/Coder-Desktop/Coder-Desktop/State.swift b/Coder-Desktop/Coder-Desktop/State.swift index c714e6b0..bb7c0094 100644 --- a/Coder-Desktop/Coder-Desktop/State.swift +++ b/Coder-Desktop/Coder-Desktop/State.swift @@ -205,7 +205,11 @@ class AppState: ObservableObject { } hostnameSuffix = await refreshTask?.value ?? Self.defaultHostnameSuffix - dashboardURL = try? await client.buildInfo().dashboard_url + do { + dashboardURL = try await client.buildInfo().dashboard_url + } catch { + logger.error("failed to get build info: \(error)") + } } } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift index 1bcf0123..f3b5522f 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift @@ -189,9 +189,12 @@ struct MenuItemView: View { .flatMap(\.self) .first(where: { $0.id == agent.id }) { - apps = agentToApps( - logger, wsAgent, agent.primaryHost, baseAccessURL, state.dashboardURL, sessionToken + let session = CoderSession( + baseAccessURL: baseAccessURL, + dashboardURL: state.dashboardURL, + sessionToken: sessionToken ) + apps = agentToApps(logger, wsAgent, agent.primaryHost, session) } else { logger.error("Could not find agent '\(agent.id)' in workspace '\(item.wsName)' resources") } diff --git a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift index dd644f96..eb20889d 100644 --- a/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift +++ b/Coder-Desktop/Coder-Desktop/Views/VPN/WorkspaceAppIcon.swift @@ -78,6 +78,14 @@ struct WorkspaceAppIcon: View { } } +/// The signed-in deployment that app links are built for. +struct CoderSession { + let baseAccessURL: URL + /// The deployment's access URL from build info, if it has loaded. + let dashboardURL: URL? + let sessionToken: String +} + struct WorkspaceApp { let slug: String let displayName: String @@ -100,9 +108,7 @@ struct WorkspaceApp { init( _ original: CoderSDK.WorkspaceApp, - deploymentURL: URL, - dashboardURL: URL?, - sessionToken: String + session: CoderSession ) throws(WorkspaceAppError) { slug = original.slug // Same behaviour as the web UI @@ -129,13 +135,13 @@ struct WorkspaceApp { confirmBeforeOpening = !SessionTokenLink.isKnownHandler(originalUrl.absoluteString) blockedReason = SessionTokenLink.blockReason( originalUrl.absoluteString, - signedInURL: deploymentURL, - dashboardURL: dashboardURL + signedInURL: session.baseAccessURL, + dashboardURL: session.dashboardURL ) if blockedReason == nil { let newUrlString = originalUrl.absoluteString.replacingOccurrences( of: SessionTokenLink.placeholder, - with: sessionToken + with: session.sessionToken ) guard let newUrl = URL(string: newUrlString) else { throw .invalidURL @@ -150,9 +156,9 @@ struct WorkspaceApp { var components = URLComponents(url: originalIcon, resolvingAgainstBaseURL: false) { if components.host == nil { - components.port = deploymentURL.port - components.scheme = deploymentURL.scheme - components.host = deploymentURL.host(percentEncoded: false) + components.port = session.baseAccessURL.port + components.scheme = session.baseAccessURL.scheme + components.host = session.baseAccessURL.host(percentEncoded: false) } if let newIconURL = components.url { @@ -185,14 +191,11 @@ enum WorkspaceAppError: Error { var localizedDescription: String { description } } -// swiftlint:disable:next function_parameter_count func agentToApps( _ logger: Logger, _ agent: CoderSDK.WorkspaceAgent, _ host: String, - _ baseAccessURL: URL, - _ dashboardURL: URL?, - _ sessionToken: String + _ session: CoderSession ) -> [WorkspaceApp] { let workspaceApps = agent.apps.compactMap { app in var app = app @@ -202,12 +205,7 @@ func agentToApps( app.url = URL(string: url.absoluteString.replacingOccurrences(of: SessionTokenLink.placeholder, with: "")) } do throws(WorkspaceAppError) { - return try WorkspaceApp( - app, - deploymentURL: baseAccessURL, - dashboardURL: dashboardURL, - sessionToken: sessionToken - ) + return try WorkspaceApp(app, session: session) } catch { logger.warning("Skipping WorkspaceApp '\(app.slug)' for \(host): \(error.localizedDescription)") return nil @@ -219,13 +217,13 @@ func agentToApps( case .vscode: return vscodeDisplayApp( hostname: host, - baseIconURL: baseAccessURL, + baseIconURL: session.baseAccessURL, path: agent.expanded_directory ) case .vscode_insiders: return vscodeInsidersDisplayApp( hostname: host, - baseIconURL: baseAccessURL, + baseIconURL: session.baseAccessURL, path: agent.expanded_directory ) default: diff --git a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift index f19cc57e..599d4a4e 100644 --- a/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift +++ b/Coder-Desktop/Coder-DesktopTests/WorkspaceAppTests.swift @@ -10,6 +10,9 @@ struct WorkspaceAppTests { let baseAccessURL = URL(string: "https://coder.example.com")! let sessionToken = "test-session-token" let host = "test-workspace.coder.test" + var session: CoderSession { + CoderSession(baseAccessURL: baseAccessURL, dashboardURL: nil, sessionToken: sessionToken) + } @Test func testCreateWorkspaceApp_Success() throws { @@ -27,9 +30,7 @@ struct WorkspaceAppTests { let workspaceApp = try WorkspaceApp( sdkApp, - deploymentURL: baseAccessURL, - dashboardURL: nil, - sessionToken: sessionToken + session: session ) #expect(workspaceApp.confirmBeforeOpening) @@ -55,9 +56,7 @@ struct WorkspaceAppTests { let workspaceApp = try WorkspaceApp( sdkApp, - deploymentURL: baseAccessURL, - dashboardURL: nil, - sessionToken: sessionToken + session: session ) #expect( @@ -85,9 +84,7 @@ struct WorkspaceAppTests { #expect(throws: WorkspaceAppError.missingURL) { try WorkspaceApp( sdkApp, - deploymentURL: baseAccessURL, - dashboardURL: nil, - sessionToken: sessionToken + session: session ) } } @@ -109,9 +106,7 @@ struct WorkspaceAppTests { #expect(throws: WorkspaceAppError.isCommandApp) { try WorkspaceApp( sdkApp, - deploymentURL: baseAccessURL, - dashboardURL: nil, - sessionToken: sessionToken + session: session ) } } @@ -120,7 +115,7 @@ struct WorkspaceAppTests { func testDisplayApps_VSCode() throws { let agent = createMockAgent(displayApps: [.vscode, .web_terminal, .ssh_helper, .port_forwarding_helper]) - let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) + let apps = agentToApps(logger, agent, host, session) #expect(apps.count == 1) #expect(!apps[0].confirmBeforeOpening) @@ -141,7 +136,7 @@ struct WorkspaceAppTests { ] ) - let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) + let apps = agentToApps(logger, agent, host, session) #expect(apps.count == 1) #expect(apps[0].slug == "-vscode-insiders") @@ -171,9 +166,7 @@ struct WorkspaceAppTests { #expect(throws: WorkspaceAppError.isWebApp) { try WorkspaceApp( sdkApp, - deploymentURL: baseAccessURL, - dashboardURL: nil, - sessionToken: sessionToken + session: session ) } } @@ -230,7 +223,7 @@ struct WorkspaceAppTests { ) let agent = createMockAgent(apps: [sdkApp1, sdkApp2, sdkApp3, sdkApp4], displayApps: [.vscode]) - let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) + let apps = agentToApps(logger, agent, host, session) #expect(apps.count == 3) let appSlugs = apps.map(\.slug) @@ -246,6 +239,7 @@ struct WorkspaceAppTests { CoderSDK.WorkspaceAgent( id: UUID(), expanded_directory: "/home/user", + parent_id: nil, apps: apps, display_apps: displayApps ) @@ -253,21 +247,18 @@ struct WorkspaceAppTests { } extension WorkspaceAppTests { - private func externalApp(_ url: String) -> CoderSDK.WorkspaceApp { - CoderSDK.WorkspaceApp( - id: UUID(), url: URL(string: url)!, external: true, slug: "app", display_name: "App", + private func externalApp(_ url: String) throws -> CoderSDK.WorkspaceApp { + try CoderSDK.WorkspaceApp( + id: UUID(), url: #require(URL(string: url)), external: true, slug: "app", display_name: "App", command: nil, icon: nil, subdomain: false, subdomain_name: nil ) } @Test func createWorkspaceApp_SessionTokenBlocked() throws { - let app = try WorkspaceApp( - externalApp("vscode://coder.coder-remote/open?url=https://other.example&token=$SESSION_TOKEN"), - deploymentURL: baseAccessURL, - dashboardURL: nil, - sessionToken: sessionToken - ) + let sdkApp = try externalApp("vscode://coder.coder-remote/open?url=https://other.example&token=$SESSION_TOKEN") + + let app = try WorkspaceApp(sdkApp, session: session) #expect(app.url.absoluteString.contains(SessionTokenLink.placeholder)) #expect(app.blockedReason == .wrongDeployment(link: "https://other.example", signedIn: baseAccessURL)) @@ -275,28 +266,29 @@ extension WorkspaceAppTests { @Test func createWorkspaceApp_TrustsDashboardURL() throws { - let dashboardURL = try #require(URL(string: "https://dogfood.example/")) - let app = try WorkspaceApp( - externalApp("vscode://coder.coder-remote/open?url=https://dogfood.example&token=$SESSION_TOKEN"), - deploymentURL: baseAccessURL, - dashboardURL: dashboardURL, + let sdkApp = try externalApp("vscode://coder.coder-remote/open?url=https://dogfood.example&token=$SESSION_TOKEN") + let session = try CoderSession( + baseAccessURL: baseAccessURL, + dashboardURL: #require(URL(string: "https://dogfood.example/")), sessionToken: sessionToken ) + let app = try WorkspaceApp(sdkApp, session: session) + #expect(app.url.absoluteString.hasSuffix("&token=\(sessionToken)")) #expect(app.blockedReason == nil) } @Test - func agentToApps_SubAgentGetsNoToken() { + func agentToApps_SubAgentGetsNoToken() throws { // The trust check doesn't apply, since there's no token to protect. let url = "vscode://coder.coder-remote/openDevContainer?url=https://other.example&token=" - let agent = CoderSDK.WorkspaceAgent( + let agent = try CoderSDK.WorkspaceAgent( id: UUID(), expanded_directory: nil, parent_id: UUID(), apps: [externalApp(url + "$SESSION_TOKEN")], display_apps: [] ) - let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) + let apps = agentToApps(logger, agent, host, session) #expect(apps.map(\.url.absoluteString) == [url]) #expect(apps.map(\.blockedReason) == [nil]) @@ -305,10 +297,10 @@ extension WorkspaceAppTests { @Test func displayApps_VSCodeEscapesPath() { let agent = CoderSDK.WorkspaceAgent( - id: UUID(), expanded_directory: "/home/a b?x=1#y", apps: [], display_apps: [.vscode] + id: UUID(), expanded_directory: "/home/a b?x=1#y", parent_id: nil, apps: [], display_apps: [.vscode] ) - let apps = agentToApps(logger, agent, host, baseAccessURL, nil, sessionToken) + let apps = agentToApps(logger, agent, host, session) #expect(apps.map(\.url.absoluteString) == [ "vscode://vscode-remote/ssh-remote+test-workspace.coder.test//home/a%20b%3Fx=1%23y", diff --git a/Coder-Desktop/CoderSDK/Workspace.swift b/Coder-Desktop/CoderSDK/Workspace.swift index ab77c4ca..e6655f1f 100644 --- a/Coder-Desktop/CoderSDK/Workspace.swift +++ b/Coder-Desktop/CoderSDK/Workspace.swift @@ -51,7 +51,7 @@ public struct WorkspaceAgent: Codable, Identifiable, Sendable { public init( id: UUID, expanded_directory: String?, - parent_id: UUID? = nil, + parent_id: UUID?, apps: [WorkspaceApp], display_apps: [DisplayApp] ) {