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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions Coder-Desktop/Coder-Desktop/SessionTokenLink.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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"

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 Handler(base: "\(scheme)://coder.coder-remote", path: path) }
} + [
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: "#", firstParam: "type=coder&"),
]

static func isKnownHandler(_ appURL: String) -> Bool {
handler(for: appURL) != nil
}

/// 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 }
guard appURL.components(separatedBy: placeholder).count == 2,
let handler = handler(for: appURL)
else { return .untrustedURL }

// 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: "&")
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 url = urlParam(params),
urlParam(decoded) == url
else { return .untrustedURL }

let trusted = [signedInURL, dashboardURL].compactMap(\.self)
return trusted.contains { trustedURL in isSameOrigin(url, trustedURL) }
? nil
: .wrongDeployment(link: url, signedIn: signedInURL)
}

/// 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
appURL.prefix(handler.base.count).lowercased() == handler.base
&& appURL.dropFirst(handler.base.count).hasPrefix(handler.path + handler.firstParam)
}
}

/// 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
}
}
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 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)
}
}
21 changes: 21 additions & 0 deletions Coder-Desktop/Coder-Desktop/State.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,16 @@ class AppState: ObservableObject {
}
}

/// Lowercase schemes of app links the user chose to open without confirmation.
@Published var trustedAppSchemes: Set<String> = 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()
Expand Down Expand Up @@ -171,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<String?, Never>?
public func refreshDeploymentConfig() async {
// Client is non-nil if there's a sesssion
Expand All @@ -191,12 +205,18 @@ class AppState: ObservableObject {
}

hostnameSuffix = await refreshTask?.value ?? Self.defaultHostnameSuffix
do {
dashboardURL = try await client.buildInfo().dashboard_url
} catch {
logger.error("failed to get build info: \(error)")
}
}
}

public func clearSession() {
hasSession = false
sessionToken = nil
dashboardURL = nil
refreshTask?.cancel()
client = nil
reconfigure()
Expand Down Expand Up @@ -228,6 +248,7 @@ class AppState: ObservableObject {

static let dangerousDisableCoderSignatureValidation = "DangerousDisableCoderSignatureValidation"
static let skipHiddenIconAlert = "SkipHiddenIconAlert"
static let trustedAppSchemes = "TrustedAppSchemes"
}
}

Expand Down
7 changes: 6 additions & 1 deletion Coder-Desktop/Coder-Desktop/Views/VPN/VPNMenuItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,12 @@ struct MenuItemView: View {
.flatMap(\.self)
.first(where: { $0.id == agent.id })
{
apps = agentToApps(logger, wsAgent, agent.primaryHost, baseAccessURL, 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")
}
Expand Down
Loading
Loading