Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import Foundation

/// Picks which remote machine serves a browser URL addressed to a machine's
/// private address.
///
/// SSH machines all use this Mac's loopback as their private address, so a
/// loopback URL routes only to the machine that owns the browser.
public struct PrivateAddressRouteSelector<Machine: Hashable & Sendable>: Sendable {
/// Creates the selector.
public init() {}

/// The machine that serves `host`, or `nil` when the URL must not be
/// routed to any remote machine.
///
/// - Parameters:
/// - host: The URL host.
/// - owner: The machine the browser belongs to, if any.
/// - addresses: Each connected machine's private address.
public func machine(forHost host: String?, owner: Machine?, addresses: [Machine: String]) -> Machine? {
guard let host else { return nil }
let target = normalized(host)
guard !target.isEmpty else { return nil }
let candidates = addresses.compactMap { normalized($0.value) == target ? $0.key : nil }
if let owner, candidates.contains(owner) { return owner }
if PrivateNetworkHostPolicy().isLoopback(host: target) { return nil }
return candidates.count == 1 ? candidates[0] : nil
}

private func normalized(_ address: String) -> String {
address.trimmingCharacters(in: .whitespaces).trimmingCharacters(in: ["[", "]"]).lowercased()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import Darwin
import Foundation

/// Classifies URL hosts that a remote machine could use to reach this Mac or
/// its local network.
///
/// The checks are static: a public DNS name can still resolve to a private
/// address, which ``requiresAddressLookup(host:)`` reports.
public struct PrivateNetworkHostPolicy: Sendable {
private static let localNameSuffixes = [".localhost", ".local", ".internal", ".lan", ".home.arpa", ".localdomain"]
private static let numericIPv4Characters = Set("0123456789abcdefx.")

/// Creates the policy.
public init() {}

/// Whether `host` names a loopback, link-local, private or otherwise
/// non-public destination.
///
/// Hosts that cannot be classified, such as empty, malformed or
/// non-ASCII hosts, count as non-public.
public func isNonPublic(host: String) -> Bool {
switch classify(host) {
case .invalid:
return true
case .ipv4(let address):
return !isPublic(ipv4: address)
case .ipv6(let bytes):
return !isPublic(ipv6: bytes)
case .name(let name):
return isLocalName(name)
}
}

/// Whether `host` names this machine's loopback interface.
public func isLoopback(host: String) -> Bool {
switch classify(host) {
case .invalid:
return false
case .ipv4(let address):
return address >> 24 == 127 || address == 0
case .ipv6(let bytes):
if let embedded = mappedIPv4(bytes) {
return embedded >> 24 == 127 || embedded == 0
}
return bytes.dropLast().allSatisfy { $0 == 0 } && bytes[15] <= 1
case .name(let name):
return name == "localhost" || name.hasSuffix(".localhost") || RemoteLoopbackProxyAlias.isLoopbackHost(name)
}
}

/// Whether `host` is a DNS name whose addresses must be checked before a
/// remote-initiated open.
public func requiresAddressLookup(host: String) -> Bool {
guard case .name(let name) = classify(host) else { return false }
return !isLocalName(name)
}

private enum HostKind {
case invalid
case ipv4(UInt32)
case ipv6([UInt8])
case name(String)
}

private func classify(_ raw: String) -> HostKind {
var host = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if host.hasPrefix("["), host.hasSuffix("]") {
host = String(host.dropFirst().dropLast())
}
if host.contains(":") {
if let zone = host.firstIndex(of: "%") {
host = String(host[..<zone])
}
var address = in6_addr()
guard inet_pton(AF_INET6, host, &address) == 1 else { return .invalid }
return .ipv6(withUnsafeBytes(of: &address) { Array($0) })
}
while host.hasSuffix(".") {
host.removeLast()
}
host = host.lowercased()
guard !host.isEmpty, host.allSatisfy(\.isASCII), !host.contains("%") else { return .invalid }
let lastLabel = host.split(separator: ".").last.map(String.init) ?? host
let isNumeric = lastLabel.allSatisfy(\.isNumber)
|| (lastLabel.hasPrefix("0x") && lastLabel.dropFirst(2).allSatisfy(\.isHexDigit))
guard isNumeric else { return .name(host) }
// A numeric last label makes the host an IPv4 address in URL parsing,
// including the legacy decimal, octal, hex and short forms.
guard host.allSatisfy({ Self.numericIPv4Characters.contains($0) }) else { return .invalid }
var address = in_addr()
guard inet_aton(host, &address) == 1 else { return .invalid }
return .ipv4(UInt32(bigEndian: address.s_addr))
}

private func isLocalName(_ name: String) -> Bool {
if name == "localhost" || !name.contains(".") { return true }
if Self.localNameSuffixes.contains { name.hasSuffix($0) } { return true }
let alias = RemoteLoopbackProxyAlias.aliasHost
return name == alias || name.hasSuffix("." + alias)
}

private func isPublic(ipv4 address: UInt32) -> Bool {
let nonPublic: [(network: UInt32, prefix: UInt32)] = [
(0x0000_0000, 8), (0x0A00_0000, 8), (0x6440_0000, 10), (0x7F00_0000, 8),
(0xA9FE_0000, 16), (0xAC10_0000, 12), (0xC000_0000, 24), (0xC000_0200, 24),
(0xC0A8_0000, 16), (0xC612_0000, 15), (0xC633_6400, 24), (0xCB00_7100, 24),
(0xE000_0000, 4), (0xF000_0000, 4),
]
return !nonPublic.contains { range in
let mask = UInt32.max << (32 - range.prefix)
return address & mask == range.network
}
}

private func isPublic(ipv6 bytes: [UInt8]) -> Bool {
if let embedded = mappedIPv4(bytes) {
return isPublic(ipv4: embedded)
}
if bytes[0] == 0x20, bytes[1] == 0x02 {
return isPublic(ipv4: ipv4(bytes[2..<6]))
}
// Teredo and documentation prefixes.
if bytes[0] == 0x20, bytes[1] == 0x01, bytes[2] == 0x00, bytes[3] == 0x00 { return false }
if bytes[0] == 0x20, bytes[1] == 0x01, bytes[2] == 0x0D, bytes[3] == 0xB8 { return false }
return bytes[0] & 0xE0 == 0x20
}

/// The IPv4 address inside an IPv4-mapped (`::ffff:0:0/96`) or NAT64
/// (`64:ff9b::/96`) IPv6 address.
private func mappedIPv4(_ bytes: [UInt8]) -> UInt32? {
let isMapped = bytes[0..<10].allSatisfy { $0 == 0 } && bytes[10] == 0xFF && bytes[11] == 0xFF
let isNAT64 = bytes[0..<4] == [0x00, 0x64, 0xFF, 0x9B] && bytes[4..<12].allSatisfy { $0 == 0 }
return isMapped || isNAT64 ? ipv4(bytes[12..<16]) : nil
}

private func ipv4(_ bytes: ArraySlice<UInt8>) -> UInt32 {
bytes.reduce(0) { $0 << 8 | UInt32($1) }
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
public import Foundation

/// Where a link from a remote terminal may open.
public struct RemoteLinkDestinations: Equatable, Sendable {
/// The URL for the cmux browser, or `nil` when the link must not open there.
public var browserURL: URL?
/// The URL for the default browser, or `nil` when the link must not open there.
public var externalURL: URL?

/// Creates a destination pair.
public init(browserURL: URL?, externalURL: URL?) {
self.browserURL = browserURL
self.externalURL = externalURL
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
public import Foundation

/// Chooses where a link from a remote terminal opens.
///
/// A remote machine can ask this Mac to open a URL without a click. Such an
/// open must not reach this Mac's loopback or local network, so it never hands
/// a non-public URL to the default browser.
public struct RemoteLinkOpenPolicy: Sendable {
private let hosts: PrivateNetworkHostPolicy

/// Creates the policy.
public init(hosts: PrivateNetworkHostPolicy = PrivateNetworkHostPolicy()) {
self.hosts = hosts
}

/// The destinations for `url`.
///
/// - Parameters:
/// - url: The link as the remote terminal wrote it.
/// - machineRoute: The same link rewritten to the remote machine's own
/// address, when the link names the remote machine's loopback.
/// - remoteInitiated: Whether the remote machine asked for the open
/// without a click on this Mac.
public func destinations(for url: URL, machineRoute: URL?, remoteInitiated: Bool) -> RemoteLinkDestinations {
guard let machineRoute else {
let opens = !remoteInitiated || !hosts.isNonPublic(host: url.host ?? "")
return RemoteLinkDestinations(browserURL: opens ? url : nil, externalURL: opens ? url : nil)
}
// An SSH machine's route is this Mac's loopback proxy, which only the
// cmux browser resolves to the remote machine. In the default browser
// it would reach this Mac's own services.
guard hosts.isLoopback(host: machineRoute.host ?? "") else {
return RemoteLinkDestinations(browserURL: machineRoute, externalURL: machineRoute)
}
return RemoteLinkDestinations(browserURL: machineRoute, externalURL: remoteInitiated ? nil : url)
}

/// Whether a link that resolved to `url` may open a file on this Mac.
///
/// Only a click on content that came from this Mac opens a local file. A
/// path in a remote or unplaced terminal names a file on another machine,
/// so it never falls back to this Mac's filesystem. A remote machine can't
/// ask this Mac to open a local file either, so apps and scripts on this
/// Mac never launch without a click here.
///
/// - Parameters:
/// - url: The link after path resolution.
/// - localContent: Whether the link came from a terminal known to run on
/// this Mac, or from a file this Mac's terminal wrote.
/// - remoteInitiated: Whether the remote machine asked for the open
/// without a click on this Mac.
public func allowsLocalFile(_ url: URL, localContent: Bool, remoteInitiated: Bool) -> Bool {
guard url.scheme?.lowercased() == "file" else { return true }
return localContent && !remoteInitiated
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import CmuxCore
import Testing

@Suite("PrivateAddressRouteSelector")
struct PrivateAddressRouteSelectorTests {
private let selector = PrivateAddressRouteSelector<String>()

@Test func ownerWinsAmongMachinesSharingLoopback() {
let addresses = ["ssh:a": "127.0.0.1", "ssh:b": "127.0.0.1"]
#expect(selector.machine(forHost: "127.0.0.1", owner: "ssh:b", addresses: addresses) == "ssh:b")
#expect(selector.machine(forHost: "127.0.0.1", owner: "ssh:a", addresses: addresses) == "ssh:a")
}

@Test(arguments: [nil, "cloud-x"] as [String?])
func loopbackIsNotRoutedForABrowserTheMachineDoesNotOwn(owner: String?) {
#expect(selector.machine(forHost: "127.0.0.1", owner: owner, addresses: ["ssh:a": "127.0.0.1"]) == nil)
}

@Test func uniqueMachineAddressRoutesWithoutAnOwner() {
let addresses = ["vm1": "10.8.0.5", "vm2": "10.8.0.6"]
#expect(selector.machine(forHost: "10.8.0.6", owner: nil, addresses: addresses) == "vm2")
}

@Test func sharedMachineAddressNeedsAnOwner() {
let addresses = ["vm1": "10.8.0.5", "vm2": "10.8.0.5"]
#expect(selector.machine(forHost: "10.8.0.5", owner: nil, addresses: addresses) == nil)
#expect(selector.machine(forHost: "10.8.0.5", owner: "vm2", addresses: addresses) == "vm2")
}

@Test func addressComparisonIgnoresBracketsAndCase() {
#expect(selector.machine(forHost: "fd00::5", owner: nil, addresses: ["vm": "[FD00::5]"]) == "vm")
}

@Test func unmatchedHostIsNotRouted() {
#expect(selector.machine(forHost: "example.com", owner: "vm", addresses: ["vm": "10.8.0.5"]) == nil)
#expect(selector.machine(forHost: nil, owner: "vm", addresses: ["vm": "10.8.0.5"]) == nil)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import CmuxCore
import Foundation
import Testing

@Suite("PrivateNetworkHostPolicy")
struct PrivateNetworkHostPolicyTests {
private let policy = PrivateNetworkHostPolicy()

@Test(arguments: [
"127.0.0.1", "127.5.6.7", "10.1.2.3", "172.16.0.1", "172.31.255.255", "192.168.1.1",
"169.254.169.254", "100.64.0.1", "0.0.0.0", "224.0.0.1", "255.255.255.255", "198.18.0.1",
])
func privateIPv4IsNonPublic(host: String) {
#expect(policy.isNonPublic(host: host))
}

@Test(arguments: ["2130706433", "0x7f000001", "0177.0.0.1", "127.1", "0x7f.1", "127.0.0.1.", "127.0x"])
func legacyIPv4SpellingsAreNonPublic(host: String) {
#expect(policy.isNonPublic(host: host))
}

@Test(arguments: [
"::1", "[::1]", "::", "fe80::1", "fe80::1%en0", "[fe80::1%25en0]", "fc00::1", "fd12:3456::1",
"::ffff:127.0.0.1", "::ffff:7f00:1", "64:ff9b::a00:1", "2002:c0a8:101::1", "ff02::1",
])
func privateIPv6IsNonPublic(host: String) {
#expect(policy.isNonPublic(host: host))
}

@Test(arguments: [
"localhost", "LOCALHOST", "app.localhost", "printer.local", "router.lan", "db.internal",
"nas.home.arpa", "intranet", "cmux-loopback.localtest.me", "", "\u{FF11}\u{FF12}\u{FF17}.0.0.1",
])
func localNamesAreNonPublic(host: String) {
#expect(policy.isNonPublic(host: host))
}

@Test(arguments: [
"example.com", "github.com", "8.8.8.8", "1.1.1.1", "2606:4700:4700::1111", "[2001:4860:4860::8888]",
"172.32.0.1", "100.128.0.1", "192.169.0.1", "0x7f.example.com",
])
func publicHostsArePublic(host: String) {
#expect(!policy.isNonPublic(host: host))
}

@Test(arguments: ["localhost", "api.localhost", "127.0.0.2", "::1", "[::1]", "::ffff:127.0.0.1", "0x7f.1", "0.0.0.0"])
func loopbackHostsAreLoopback(host: String) {
#expect(policy.isLoopback(host: host))
}

@Test(arguments: ["10.0.0.1", "example.com", "192.168.1.1"])
func otherHostsAreNotLoopback(host: String) {
#expect(!policy.isLoopback(host: host))
}

@Test func onlyPublicNamesNeedAnAddressLookup() {
#expect(policy.requiresAddressLookup(host: "example.com"))
#expect(!policy.requiresAddressLookup(host: "8.8.8.8"))
#expect(!policy.requiresAddressLookup(host: "[2606:4700::1]"))
#expect(!policy.requiresAddressLookup(host: "localhost"))
#expect(!policy.requiresAddressLookup(host: "printer.local"))
#expect(!policy.requiresAddressLookup(host: "intranet"))
}
}
Loading
Loading