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
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ final class HTTP1ProxyConnectHandler: ChannelDuplexHandler, RemovableChannelHand

private var state: State = .initialized

private static let reservedConnectHeaders: Set<String> = ["host", "proxy-authorization"]

private let targetHost: String
private let targetPort: Int
private let proxyAuthorization: HTTPClient.Authorization?
private let connectHeaders: HTTPHeaders
private let deadline: NIODeadline

private var proxyEstablishedPromise: EventLoopPromise<Void>?
Expand All @@ -48,6 +51,7 @@ final class HTTP1ProxyConnectHandler: ChannelDuplexHandler, RemovableChannelHand
convenience init(
target: ConnectionTarget,
proxyAuthorization: HTTPClient.Authorization?,
connectHeaders: HTTPHeaders,
deadline: NIODeadline
) {
let targetHost: String
Expand All @@ -66,6 +70,7 @@ final class HTTP1ProxyConnectHandler: ChannelDuplexHandler, RemovableChannelHand
targetHost: targetHost,
targetPort: targetPort,
proxyAuthorization: proxyAuthorization,
connectHeaders: connectHeaders,
deadline: deadline
)
}
Expand All @@ -74,11 +79,13 @@ final class HTTP1ProxyConnectHandler: ChannelDuplexHandler, RemovableChannelHand
targetHost: String,
targetPort: Int,
proxyAuthorization: HTTPClient.Authorization?,
connectHeaders: HTTPHeaders,
deadline: NIODeadline
) {
self.targetHost = targetHost
self.targetPort = targetPort
self.proxyAuthorization = proxyAuthorization
self.connectHeaders = connectHeaders
self.deadline = deadline
}

Expand Down Expand Up @@ -157,9 +164,12 @@ final class HTTP1ProxyConnectHandler: ChannelDuplexHandler, RemovableChannelHand
method: .CONNECT,
uri: "\(self.targetHost):\(self.targetPort)"
)
head.headers.replaceOrAdd(name: "host", value: "\(self.targetHost)")
for (name, value) in self.connectHeaders where !Self.reservedConnectHeaders.contains(name.lowercased()) {
head.headers.add(name: name, value: value)
}
head.headers.add(name: "host", value: "\(self.targetHost)")
if let authorization = self.proxyAuthorization {
head.headers.replaceOrAdd(name: "proxy-authorization", value: authorization.headerValue)
head.headers.add(name: "proxy-authorization", value: authorization.headerValue)
}
context.write(self.wrapOutboundOut(.head(head)), promise: nil)
context.write(self.wrapOutboundOut(.end(nil)), promise: nil)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,7 @@ extension HTTPConnectionPool.ConnectionFactory {
let proxyHandler = HTTP1ProxyConnectHandler(
target: self.key.connectionTarget,
proxyAuthorization: proxy.authorization,
connectHeaders: proxy.connectHeaders,
deadline: deadline
)

Expand Down
40 changes: 37 additions & 3 deletions Sources/AsyncHTTPClient/HTTPClient+Proxy.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//===----------------------------------------------------------------------===//

import NIOCore
import NIOHTTP1

extension HTTPClient.Configuration {
/// Proxy server configuration
Expand Down Expand Up @@ -57,7 +58,15 @@ extension HTTPClient.Configuration {

var type: ProxyType

/// Create a HTTP proxy.
/// Extra headers sent only on the HTTP `CONNECT` request to the proxy.
///
/// These headers are not sent to the
/// destination server, and are ignored for SOCKS proxies.
/// The `host` and `proxy-authorization` headers cannot be overridden through this property
/// Note: Excluded from hash, because HTTPHeaders are not hashable.
public var connectHeaders: HTTPHeaders = [:]

/// Create an HTTP proxy configuration.
///
/// - parameters:
/// - host: proxy server host.
Expand All @@ -66,7 +75,7 @@ extension HTTPClient.Configuration {
.init(host: host, port: port, type: .http(nil))
}

/// Create a HTTP proxy.
/// Create an HTTP proxy configuration.
///
/// - parameters:
/// - host: proxy server host.
Expand All @@ -76,12 +85,37 @@ extension HTTPClient.Configuration {
.init(host: host, port: port, type: .http(authorization))
}

/// Create a SOCKSv5 proxy.
/// Create an HTTP proxy configuration.
///
/// - parameters:
/// - host: proxy server host.
/// - port: proxy server port.
/// - authorization: proxy server authorization.
/// - connectHeaders: extra headers sent only on the `CONNECT` request to the proxy.
public static func server(
host: String,
port: Int,
authorization: HTTPClient.Authorization? = nil,
connectHeaders: HTTPHeaders
) -> Self {
var proxy = Self(host: host, port: port, type: .http(authorization))
proxy.connectHeaders = connectHeaders
return proxy
}

/// Create a SOCKSv5 proxy configuration.
/// - parameter host: The SOCKSv5 proxy address.
/// - parameter port: The SOCKSv5 proxy port, defaults to 1080.
/// - returns: A new instance of `Proxy` configured to connect to a `SOCKSv5` server.
public static func socksServer(host: String, port: Int = 1080) -> Proxy {
.init(host: host, port: port, type: .socks)
}

// `connectHeaders` is omitted (HTTPHeaders is not hashable)
Comment thread
benrobby marked this conversation as resolved.
public func hash(into hasher: inout Hasher) {
hasher.combine(self.host)
hasher.combine(self.port)
hasher.combine(self.type)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
#if compiler(>=6.2)
import Configuration
import NIOCore
import NIOHTTP1

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
extension HTTPClient.Configuration {
Expand All @@ -38,11 +39,9 @@ extension HTTPClient.Configuration {
// Each entry in the list should be a colon separated pair e.g. localhost:127.0.0.1 or localhost:::1
if let dnsOverridesList = configReader.stringArray(forKey: "dnsOverrides") {
for entry in dnsOverridesList {
guard let separatorIndex = entry.firstIndex(of: ":") else {
guard let (key, value) = entry.splitOnFirstColon() else {
throw HTTPClientError.invalidDNSOverridesConfiguration
}
let key = entry.prefix(upTo: separatorIndex)
let value = entry.suffix(from: entry.index(after: separatorIndex))
if key.isEmpty || value.isEmpty {
throw HTTPClientError.invalidDNSOverridesConfiguration
}
Expand Down Expand Up @@ -165,22 +164,26 @@ extension HTTPClient.Configuration.Proxy {
/// - `type` (string, optional, default: "http"): Proxy type ("http" or "socks").
/// - `authorization` (scoped, optional): Authorization configuration read by ``HTTPClient/Authorization/init(configReader:)``.
/// Only supported for `http` proxies.
/// - `connectHeaders` (string array, optional): Extra headers sent only on the `CONNECT` request to the proxy.
/// Each entry is a colon-separated `Name: value` pair (e.g., "X-Foo: bar"). Only supported for `http` proxies.
///
/// - Throws: `HTTPClientError.invalidProxyConfiguration` if `enabled` is `true` but `host` is missing, `type` is unknown,
/// `port` is missing for an HTTP proxy, or `authorization` is specified for a SOCKS proxy, or `authorization` is invalid (see ``HTTPClient/Authorization/init(configReader:)``)
/// `port` is missing for an HTTP proxy, `authorization` or `connectHeaders` is specified for a SOCKS proxy,
/// a `connectHeaders` entry is malformed, or `authorization` is invalid (see ``HTTPClient/Authorization/init(configReader:)``)
public init?(configReader: ConfigReader) throws {
guard configReader.bool(forKey: "enabled", default: false) else {
return nil
}
let host = try configReader.requiredString(forKey: "host")
let type = configReader.string(forKey: "type", default: "http")
let authorization = try HTTPClient.Authorization(configReader: configReader.scoped(to: "authorization"))
let connectHeaders = try Self.parseConnectHeaders(configReader: configReader)
switch type {
case "http":
let port = try configReader.requiredInt(forKey: "port")
self = .server(host: host, port: port, authorization: authorization)
self = .server(host: host, port: port, authorization: authorization, connectHeaders: connectHeaders)
case "socks":
if authorization != nil {
if authorization != nil || !connectHeaders.isEmpty {
throw HTTPClientError.invalidProxyConfiguration
}
let port = configReader.int(forKey: "port", default: 1080)
Expand All @@ -189,6 +192,47 @@ extension HTTPClient.Configuration.Proxy {
throw HTTPClientError.invalidProxyConfiguration
}
}

/// Each entry is a colon separated `key: value` pair. Example: "X-Foo: bar".
/// RFC 9110
/// §5.1: header field names are tokens, §5.6.2: tokens can not contain delimiters (e.g., colon)
/// §5.5: field values can colons, but leading/trailing whitespace must be stripped
private static func parseConnectHeaders(configReader: ConfigReader) throws -> HTTPHeaders {
guard let entries = configReader.stringArray(forKey: "connectHeaders") else {
return [:]
}
var headers = HTTPHeaders()
headers.reserveCapacity(entries.count)
for entry in entries {
guard let (name, value) = entry.splitOnFirstColon() else {
throw HTTPClientError.invalidProxyConfiguration
}
let trimmedName = name.trimmingASCIIWhitespace()
if trimmedName.isEmpty {
throw HTTPClientError.invalidProxyConfiguration
}
headers.add(name: String(trimmedName), value: String(value.trimmingASCIIWhitespace()))
}
return headers
}
}

extension StringProtocol {
fileprivate func splitOnFirstColon() -> (SubSequence, SubSequence)? {
guard let index = self.firstIndex(of: ":") else {
return nil
}
return (self[..<index], self[self.index(after: index)...])
}

fileprivate func trimmingASCIIWhitespace() -> SubSequence {
guard let start = self.firstIndex(where: { $0 != " " && $0 != "\t" }),
let end = self.lastIndex(where: { $0 != " " && $0 != "\t" })
else {
return self[self.endIndex..<self.endIndex]
}
return self[start...end]
}
}

@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *)
Expand Down
82 changes: 82 additions & 0 deletions Tests/AsyncHTTPClientTests/HTTP1ProxyConnectHandlerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ class HTTP1ProxyConnectHandlerTests: XCTestCase {
targetHost: "swift.org",
targetPort: 443,
proxyAuthorization: .none,
connectHeaders: [:],
deadline: .now() + .seconds(10)
)

Expand Down Expand Up @@ -65,6 +66,7 @@ class HTTP1ProxyConnectHandlerTests: XCTestCase {
targetHost: "swift.org",
targetPort: 443,
proxyAuthorization: .basic(credentials: "abc123"),
connectHeaders: [:],
deadline: .now() + .seconds(10)
)

Expand Down Expand Up @@ -99,6 +101,7 @@ class HTTP1ProxyConnectHandlerTests: XCTestCase {
targetHost: "swift.org",
targetPort: 443,
proxyAuthorization: .none,
connectHeaders: [:],
deadline: .now() + .seconds(10)
)

Expand Down Expand Up @@ -139,6 +142,7 @@ class HTTP1ProxyConnectHandlerTests: XCTestCase {
targetHost: "swift.org",
targetPort: 443,
proxyAuthorization: .none,
connectHeaders: [:],
deadline: .now() + .seconds(10)
)

Expand Down Expand Up @@ -179,6 +183,7 @@ class HTTP1ProxyConnectHandlerTests: XCTestCase {
targetHost: "swift.org",
targetPort: 443,
proxyAuthorization: .none,
connectHeaders: [:],
deadline: .now() + .seconds(10)
)

Expand Down Expand Up @@ -208,4 +213,81 @@ class HTTP1ProxyConnectHandlerTests: XCTestCase {
XCTAssertEqual($0 as? HTTPClientError, .invalidProxyResponse)
}
}

func testProxyConnectSendsConnectHeaders() throws {
let embedded = EmbeddedChannel()
defer { XCTAssertNoThrow(try embedded.finish(acceptAlreadyClosed: false)) }

let socketAddress = try SocketAddress.makeAddressResolvingHost("localhost", port: 0)
XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait())

var connectHeaders = HTTPHeaders()
connectHeaders.add(name: "X-Proxy-Token", value: "first")
connectHeaders.add(name: "X-Proxy-Token", value: "second")
connectHeaders.add(name: "Host", value: "should get overriden")
connectHeaders.add(name: "Proxy-Authorization", value: "should get overriden")

let proxyConnectHandler = HTTP1ProxyConnectHandler(
targetHost: "swift.org",
targetPort: 443,
proxyAuthorization: .basic(credentials: "abc123"),
connectHeaders: connectHeaders,
deadline: .now() + .seconds(10)
)

XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler))

var maybeHead: HTTPClientRequestPart?
XCTAssertNoThrow(maybeHead = try embedded.readOutbound(as: HTTPClientRequestPart.self))
guard case .some(.head(let head)) = maybeHead else {
return XCTFail("Expected the proxy connect handler to first send a http head part")
}

XCTAssertEqual(head.headers["X-Proxy-Token"], ["first", "second"])
XCTAssertEqual(head.headers["host"], ["swift.org"])
XCTAssertEqual(head.headers["proxy-authorization"], ["Basic abc123"])
XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil))

let responseHead = HTTPResponseHead(version: .http1_1, status: .ok)
XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead)))
XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil)))

XCTAssertNoThrow(try XCTUnwrap(proxyConnectHandler.proxyEstablishedFuture).wait())
}

func testProxyConnectStripsProxyAuthorizationHeaderWithoutAuthorization() throws {
let embedded = EmbeddedChannel()
defer { XCTAssertNoThrow(try embedded.finish(acceptAlreadyClosed: false)) }

let socketAddress = try SocketAddress.makeAddressResolvingHost("localhost", port: 0)
XCTAssertNoThrow(try embedded.connect(to: socketAddress).wait())

var connectHeaders = HTTPHeaders()
connectHeaders.add(name: "proxy-authorization", value: "should get stripped")

let proxyConnectHandler = HTTP1ProxyConnectHandler(
targetHost: "swift.org",
targetPort: 443,
proxyAuthorization: .none,
connectHeaders: connectHeaders,
deadline: .now() + .seconds(10)
)

XCTAssertNoThrow(try embedded.pipeline.syncOperations.addHandler(proxyConnectHandler))

var maybeHead: HTTPClientRequestPart?
XCTAssertNoThrow(maybeHead = try embedded.readOutbound(as: HTTPClientRequestPart.self))
guard case .some(.head(let head)) = maybeHead else {
return XCTFail("Expected the proxy connect handler to first send a http head part")
}

XCTAssertFalse(head.headers.contains(name: "proxy-authorization"))
XCTAssertEqual(try embedded.readOutbound(as: HTTPClientRequestPart.self), .end(nil))

let responseHead = HTTPResponseHead(version: .http1_1, status: .ok)
XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.head(responseHead)))
XCTAssertNoThrow(try embedded.writeInbound(HTTPClientResponsePart.end(nil)))

XCTAssertNoThrow(try XCTUnwrap(proxyConnectHandler.proxyEstablishedFuture).wait())
}
}
Loading
Loading