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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/scripts/test-security-contracts.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,28 @@ grep -F 'sandboxSSHAgentDenied' dory-core-swift/Sources/DoryVMMKit/DoryVMM.swift
grep -F -- '--env-json-stdin' dory-core-swift/Sources/dorydctl/main.swift scripts/dory >/dev/null \
|| fail "ephemeral sandbox secrets can no longer avoid process argv"

# Automatic local HTTPS listens on the wildcard address so the privileged :443 bind keeps working;
# admitting loopback peers only is what keeps container backends off the LAN.
for tls_proxy in dory-core-swift/Sources/DorydKit/DoryTLSProxyServer.swift Dory/Net/DoryTLSProxy.swift; do
grep -F 'isLoopbackPeer(client.endpoint)' "$tls_proxy" >/dev/null \
|| fail "$tls_proxy accepts TLS peers without a loopback check"
done

# Remote daemon mode carries Exec with no in-band authentication and is only ever reachable
# through doryd's SSH tunnel.
grep -F 'is_loopback_listen_addr(&local)' dory-core/agent/src/daemon.rs >/dev/null \
|| fail "dory-agent daemon can serve unauthenticated control on a routable address"
grep -F 'is_loopback_peer(&peer)' dory-core/agent/src/daemon.rs >/dev/null \
|| fail "dory-agent daemon admits non-loopback control peers"

# The local CA signs certificates the user trusts in their login keychain.
grep -F 'validateCertificateName' Dory/Net/LocalCA.swift >/dev/null \
|| fail "app-side local CA no longer validates certificate names before building SAN arguments"
grep -F 'env:\(passphraseVariable)' \
Dory/Net/LocalCA.swift dory-core-swift/Sources/DorydKit/LocalCA.swift >/dev/null \
|| fail "PKCS#12 export passphrases are visible in process argv"
if grep -R -E --include='*.swift' 'password: "dory"' Dory dory-core-swift/Sources >/dev/null; then
fail "a production TLS identity uses a hardcoded passphrase"
fi

echo "security contracts: PASS"
14 changes: 12 additions & 2 deletions Dory/Models/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2574,6 +2574,13 @@ final class AppStore {
}
}

nonisolated static func ephemeralIdentityPassword() -> String {
var generator = SystemRandomNumberGenerator()
return (0..<24)
.map { _ in String(format: "%02x", UInt8.random(in: .min ... .max, using: &generator)) }
.joined()
}

private func startTLS() {
let table = domainTable
let suffix = domainSuffix
Expand All @@ -2584,8 +2591,11 @@ final class AppStore {
let extraSANs = ["*.k8s.\(suffix)", "*.default.k8s.\(suffix)", "*.kube-system.k8s.\(suffix)"]
Task { [weak self] in
let proxy = await Task.detached { () -> DoryTLSProxy? in
guard let p12 = try? LocalCA().issuePKCS12(domain: suffix, password: "dory", extraSANs: extraSANs) else { return nil }
return DoryTLSProxy(p12Path: p12.path, password: "dory", resolve: { table.backend(for: $0) })
// The identity lives only for this proxy instance, so its passphrase is generated
// per start rather than shipped in the binary.
let password = Self.ephemeralIdentityPassword()
guard let p12 = try? LocalCA().issuePKCS12(domain: suffix, password: password, extraSANs: extraSANs) else { return nil }
return DoryTLSProxy(p12Path: p12.path, password: password, resolve: { table.backend(for: $0) })
}.value
guard let self, let proxy else { return }
do {
Expand Down
23 changes: 23 additions & 0 deletions Dory/Net/DoryTLSProxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,30 @@ nonisolated final class DoryTLSProxy: @unchecked Sendable {
listener = nil
}

/// Whether an inbound peer is on loopback. `NWListener` cannot pin a bind address without
/// losing the privileged `:443` bind (macOS grants an unprivileged low-port bind only on the
/// wildcard address), so automatic HTTPS admits loopback peers only — matching the plaintext
/// reverse proxy's explicit loopback bind — and keeps container backends off the LAN. Anything
/// not recognisably loopback fails closed.
static func isLoopbackPeer(_ endpoint: NWEndpoint) -> Bool {
guard case let .hostPort(host, _) = endpoint else { return false }
switch host {
case let .ipv4(address):
return address.isLoopback
case let .ipv6(address):
return address.isLoopback || address.asIPv4?.isLoopback == true
case let .name(name, _):
return name.lowercased() == "localhost"
@unknown default:
return false
}
}

private func accept(_ client: NWConnection) {
guard Self.isLoopbackPeer(client.endpoint) else {
client.cancel()
return
}
client.start(queue: queue)
readHead(client, buffer: Data())
}
Expand Down
66 changes: 56 additions & 10 deletions Dory/Net/LocalCA.swift
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import Darwin
import Foundation

nonisolated enum ShellError: Error, Sendable {
case launchFailed(String)
case nonZeroExit(Int32, String)
case toolNotFound(String)
case invalidArgument(String)
}

nonisolated enum Shell {
Expand Down Expand Up @@ -51,11 +53,17 @@ nonisolated enum Shell {
}

@discardableResult
static func run(_ launchPath: String, _ arguments: [String], cwd: URL? = nil) throws -> String {
static func run(
_ launchPath: String,
_ arguments: [String],
cwd: URL? = nil,
environment: [String: String]? = nil
) throws -> String {
let process = Process()
process.executableURL = URL(fileURLWithPath: launchPath)
process.arguments = arguments
if let cwd { process.currentDirectoryURL = cwd }
if let environment { process.environment = environment }
let output = Pipe()
process.standardOutput = output
process.standardError = output
Expand Down Expand Up @@ -100,18 +108,48 @@ nonisolated struct LocalCA: Sendable {
guard let openssl = opensslPath else { throw ShellError.toolNotFound("openssl") }
if caExists { return }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
try Shell.run(openssl, [
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", caKey.path, "-out", caCertificate.path, "-days", "3650",
"-subj", "/CN=Dory Local CA/O=Dory",
"-addext", "basicConstraints=critical,CA:TRUE",
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
])
try FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
// This key signs certificates the user is asked to trust in their login keychain, so it is
// created 0600 via umask rather than chmod-after: it is never briefly world-readable.
let previousMask = umask(0o177)
do {
try Shell.run(openssl, [
"req", "-x509", "-newkey", "ec", "-pkeyopt", "ec_paramgen_curve:prime256v1", "-nodes",
"-keyout", caKey.path, "-out", caCertificate.path, "-days", "3650",
"-subj", "/CN=Dory Local CA/O=Dory",
"-addext", "basicConstraints=critical,CA:TRUE",
"-addext", "keyUsage=critical,keyCertSign,cRLSign",
])
umask(previousMask)
} catch {
umask(previousMask)
throw error
}
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: caKey.path)
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: caCertificate.path)
}

/// Certificate names reach openssl inside a comma-separated SAN string and a file path, so a
/// name carrying a comma or a path separator could inject extra SAN entries or redirect the
/// written key. Domains come from user settings and container labels; validate them all.
static func validateCertificateName(_ name: String) throws {
var value = name
if value.hasPrefix("*.") { value = String(value.dropFirst(2)) }
let labels = value.split(separator: ".", omittingEmptySubsequences: false)
guard !labels.isEmpty else { throw ShellError.invalidArgument("certificate name: \(name)") }
for label in labels {
guard !label.isEmpty,
label.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber || $0 == "-") }) else {
throw ShellError.invalidArgument("certificate name: \(name)")
}
}
}

@discardableResult
func issue(domain: String, extraSANs: [String] = []) throws -> CertificatePair {
guard let openssl = opensslPath else { throw ShellError.toolNotFound("openssl") }
try Self.validateCertificateName(domain)
for name in extraSANs where !name.isEmpty { try Self.validateCertificateName(name) }
try ensureCA()
let certificate = directory.appendingPathComponent("\(domain).crt")
let key = directory.appendingPathComponent("\(domain).key")
Expand All @@ -131,6 +169,8 @@ nonisolated struct LocalCA: Sendable {
"x509", "-req", "-in", csr.path, "-CA", caCertificate.path, "-CAkey", caKey.path,
"-CAcreateserial", "-out", certificate.path, "-days", "825", "-copy_extensions", "copyall",
])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: key.path)
try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: certificate.path)
return CertificatePair(certificate: certificate, privateKey: key)
}

Expand All @@ -141,11 +181,17 @@ nonisolated struct LocalCA: Sendable {
guard let openssl = opensslPath else { throw ShellError.toolNotFound("openssl") }
let pair = try issue(domain: domain, extraSANs: extraSANs)
let p12 = directory.appendingPathComponent("\(domain).p12")
// Pass the export passphrase through the environment rather than argv, so it is not
// visible in `ps` output while openssl runs.
let passphraseVariable = "DORY_LOCALCA_P12_PASS"
var childEnvironment = ProcessInfo.processInfo.environment
childEnvironment[passphraseVariable] = password
try Shell.run(openssl, [
"pkcs12", "-export", "-inkey", pair.privateKey.path, "-in", pair.certificate.path,
"-certfile", caCertificate.path, "-out", p12.path,
"-passout", "pass:\(password)", "-legacy",
])
"-passout", "env:\(passphraseVariable)", "-legacy",
], environment: childEnvironment)
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: p12.path)
return p12
}

Expand Down
35 changes: 35 additions & 0 deletions DoryTests/LocalCATests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,41 @@ struct LocalCATests {
#expect(text.contains("Dory Local CA"))
}

@Test func caAndLeafPrivateKeysAreOwnerReadableOnly() throws {
let directory = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("dory-ca-perms-\(UUID().uuidString)")
defer { try? FileManager.default.removeItem(at: directory) }

let ca = LocalCA(directory: directory)
guard ca.opensslPath != nil else { return }

try ca.ensureCA()
let pair = try ca.issue(domain: "web.dory.local")
let p12 = try ca.issuePKCS12(domain: "dory.local", password: AppStore.ephemeralIdentityPassword())

for path in [ca.caKey.path, pair.privateKey.path, p12.path] {
let mode = try #require(
FileManager.default.attributesOfItem(atPath: path)[.posixPermissions] as? NSNumber
).intValue
#expect(mode & 0o077 == 0)
}
}

@Test func certificateNamesThatCouldInjectSANEntriesOrPathsAreRejected() throws {
for name in ["dory.local,DNS:evil.example.com", "../../etc/dory", "dory.local/../evil", "", "*.", "a..b"] {
#expect(throws: (any Error).self) { try LocalCA.validateCertificateName(name) }
}
for name in ["dory.local", "*.dory.local", "*.default.k8s.dory.local", "my-project.local"] {
try LocalCA.validateCertificateName(name)
}
}

@Test func ephemeralIdentityPasswordIsRandomAndNotAConstant() {
let first = AppStore.ephemeralIdentityPassword()
let second = AppStore.ephemeralIdentityPassword()
#expect(first.count == 48)
#expect(first != second)
}

@Test func localTrustParserAcceptsOnlyAValidDoryCA() throws {
let directory = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("dory-trust-parser-\(UUID().uuidString)")
Expand Down
26 changes: 26 additions & 0 deletions dory-core-swift/Sources/DorydKit/DoryTLSProxyServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,33 @@ public final class DoryTLSProxyServer: @unchecked Sendable {
}
}

/// Whether an inbound peer is on loopback.
///
/// `NWListener` has no bind-address knob equivalent to the plaintext proxy's explicit
/// `127.0.0.1` bind, and pinning it to loopback would break the privileged `:443` bind (macOS
/// lets an unprivileged process bind a low port only on the wildcard address). Automatic HTTPS
/// therefore listens on the wildcard and admits loopback peers only — the policy
/// `LoopbackTCPForwarder` already applies to the standard ports — so container backends are not
/// reachable from the LAN. Anything not recognisably loopback fails closed.
static func isLoopbackPeer(_ endpoint: NWEndpoint) -> Bool {
guard case let .hostPort(host, _) = endpoint else { return false }
switch host {
case let .ipv4(address):
return address.isLoopback
case let .ipv6(address):
return address.isLoopback || address.asIPv4?.isLoopback == true
case let .name(name, _):
return DoryHTTPProxyServer.isLoopbackHost(name)
@unknown default:
return false
}
}

private func accept(_ client: NWConnection) {
guard Self.isLoopbackPeer(client.endpoint) else {
client.cancel()
return
}
guard let lease = connectionBudget.tryAcquire() else {
client.cancel()
return
Expand Down
22 changes: 22 additions & 0 deletions dory-core-swift/Tests/DorydKitTests/DoryTLSProxyServerTests.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Darwin
@testable import DorydKit
import Network
import XCTest

final class DoryTLSProxyServerTests: XCTestCase {
Expand Down Expand Up @@ -118,6 +119,27 @@ final class DoryTLSProxyServerTests: XCTestCase {
XCTAssertEqual(response, "hello custom tls")
XCTAssertTrue(backend.lastRequest.contains("Host: admin.myproject.local"))
}

func testAdmitsOnlyLoopbackPeers() throws {
let port = NWEndpoint.Port(rawValue: 51234)!
XCTAssertTrue(DoryTLSProxyServer.isLoopbackPeer(.hostPort(host: .ipv4(.loopback), port: port)))
XCTAssertTrue(DoryTLSProxyServer.isLoopbackPeer(.hostPort(host: .ipv6(.loopback), port: port)))
XCTAssertTrue(DoryTLSProxyServer.isLoopbackPeer(
.hostPort(host: .ipv6(IPv6Address("::ffff:127.0.0.1")!), port: port)
))
XCTAssertTrue(DoryTLSProxyServer.isLoopbackPeer(.hostPort(host: .name("localhost", nil), port: port)))

XCTAssertFalse(DoryTLSProxyServer.isLoopbackPeer(
.hostPort(host: .ipv4(IPv4Address("192.168.1.20")!), port: port)
))
XCTAssertFalse(DoryTLSProxyServer.isLoopbackPeer(
.hostPort(host: .ipv6(IPv6Address("fe80::1")!), port: port)
))
XCTAssertFalse(DoryTLSProxyServer.isLoopbackPeer(
.hostPort(host: .name("attacker.example.com", nil), port: port)
))
XCTAssertFalse(DoryTLSProxyServer.isLoopbackPeer(.unix(path: "/tmp/dory.sock")))
}
}

private func availableTCPPort() throws -> UInt16 {
Expand Down
Loading
Loading