Skip to content

Commit 714ed4a

Browse files
committed
Unify CONNECT semantics.
1 parent 36b86c6 commit 714ed4a

File tree

8 files changed

+74
-12
lines changed

8 files changed

+74
-12
lines changed

async-http.gemspec

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,8 @@ Gem::Specification.new do |spec|
2828
spec.add_dependency "async-pool", "~> 0.9"
2929
spec.add_dependency "io-endpoint", "~> 0.14"
3030
spec.add_dependency "io-stream", "~> 0.6"
31-
spec.add_dependency "protocol-http", "~> 0.43"
32-
spec.add_dependency "protocol-http1", "~> 0.29"
31+
spec.add_dependency "protocol-http", "~> 0.49"
32+
spec.add_dependency "protocol-http1", "~> 0.30"
3333
spec.add_dependency "protocol-http2", "~> 0.22"
3434
spec.add_dependency "traces", "~> 0.10"
3535
spec.add_dependency "metrics", "~> 0.12"

lib/async/http/protocol/http1/client.rb

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,16 @@ def call(request, task: Task.current)
3939

4040
# We carefully interpret https://tools.ietf.org/html/rfc7230#section-6.3.1 to implement this correctly.
4141
begin
42-
write_request(request.authority, request.method, request.path, @version, request.headers)
42+
target = request.path
43+
authority = request.authority
44+
45+
# If we are using a CONNECT request, we need to use the authority as the target:
46+
if request.connect?
47+
target = authority
48+
authority = nil
49+
end
50+
51+
write_request(authority, request.method, target, @version, request.headers)
4352
rescue
4453
# If we fail to fully write the request and body, we can retry this request.
4554
raise RequestFailed

lib/async/http/protocol/http1/request.rb

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,21 +10,45 @@ module HTTP
1010
module Protocol
1111
module HTTP1
1212
class Request < Protocol::Request
13+
def self.valid_path?(target)
14+
if target.start_with?("/")
15+
return true
16+
elsif target == '*'
17+
return true
18+
else
19+
return false
20+
end
21+
end
22+
23+
URI_PATTERN = %r{\A(?<scheme>[^:/]+)://(?<authority>[^/]+)(?<path>.*)\z}
24+
1325
def self.read(connection)
1426
connection.read_request do |authority, method, target, version, headers, body|
15-
self.new(connection, authority, method, target, version, headers, body)
27+
if method == ::Protocol::HTTP::Methods::CONNECT
28+
# We put the target into the authority field for CONNECT requests, as per HTTP/2 semantics.
29+
self.new(connection, nil, target, method, nil, version, headers, body)
30+
elsif valid_path?(target)
31+
# This is a valid request.
32+
self.new(connection, nil, authority, method, target, version, headers, body)
33+
elsif match = target.match(URI_PATTERN)
34+
# We map the incoming absolute URI target to the scheme, authority, and path fields of the request.
35+
self.new(connection, match[:scheme], match[:authority], method, match[:path], version, headers, body)
36+
else
37+
# This is an invalid request.
38+
raise ::Protocol::HTTP1::BadRequest.new("Invalid request target: #{target}")
39+
end
1640
end
1741
end
1842

1943
UPGRADE = "upgrade"
2044

21-
def initialize(connection, authority, method, path, version, headers, body)
45+
def initialize(connection, scheme, authority, method, path, version, headers, body)
2246
@connection = connection
2347

2448
# HTTP/1 requests with an upgrade header (which can contain zero or more values) are extracted into the protocol field of the request, and we expect a response to select one of those protocols with a status code of 101 Switching Protocols.
2549
protocol = headers.delete("upgrade")
2650

27-
super(nil, authority, method, path, version, headers, body, protocol, self.public_method(:write_interim_response))
51+
super(scheme, authority, method, path, version, headers, body, protocol, self.public_method(:write_interim_response))
2852
end
2953

3054
def connection

lib/async/http/protocol/http2/request.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ def connection
9999
end
100100

101101
def valid?
102-
@scheme and @method and @path
102+
@scheme and @method and (@path or @method == ::Protocol::HTTP::Methods::CONNECT)
103103
end
104104

105105
def hijack?

lib/async/http/protocol/http2/response.rb

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,9 +204,12 @@ def send_request(request)
204204
pseudo_headers = [
205205
[SCHEME, request.scheme],
206206
[METHOD, request.method],
207-
[PATH, request.path],
208207
]
209208

209+
if path = request.path
210+
pseudo_headers << [PATH, path]
211+
end
212+
210213
# To ensure that the HTTP/1.1 request line can be reproduced accurately, this pseudo-header field MUST be omitted when translating from an HTTP/1.1 request that has a request target in origin or asterisk form (see [RFC7230], Section 5.3). Clients that generate HTTP/2 requests directly SHOULD use the :authority pseudo-header field instead of the Host header field.
211214
if authority = request.authority
212215
pseudo_headers << [AUTHORITY, authority]

lib/async/http/proxy.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ def close
8282
def connect(&block)
8383
input = Body::Writable.new
8484

85-
response = @client.connect(@address.to_s, @headers, input)
85+
response = @client.connect(authority: @address, headers: @headers, body: input)
8686

8787
if response.success?
8888
pipe = Body::Pipe.new(response.body, input)

releases.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,31 @@
11
# Releases
22

3+
## Unreleased
4+
5+
### Unify HTTP/1 and HTTP/2 `CONNECT` semantics
6+
7+
HTTP/1 has a request line "target" which takes different forms depending on the kind of request. For `CONNECT` requests, the target is the authority (host and port) of the target server, e.g.
8+
9+
```
10+
CONNECT example.com:443 HTTP/1.1
11+
```
12+
13+
In HTTP/2, the `CONNECT` method uses the `:authority` pseudo-header to specify the target, e.g.
14+
15+
```http
16+
[HEADERS FRAME]
17+
:method: connect
18+
:authority: example.com:443
19+
```
20+
21+
In HTTP/1, the `Request#path` attribute was previously used to store the target, and this was incorrectly mapped to the `:path` pseudo-header in HTTP/2. This has been corrected, and the `Request#authority` attribute is now used to store the target for both HTTP/1 and HTTP/2, and mapped accordingly. Thus, to make a `CONNECT` request, you should set the `Request#authority` attribute, e.g.
22+
23+
```ruby
24+
response = client.connect(authority: "example.com:443")
25+
```
26+
27+
For HTTP/1, the authority is mapped back to the request line target, and for HTTP/2, it is mapped to the `:authority` pseudo-header.
28+
329
## v0.86.0
430

531
- Add support for HTTP/2 `NO_RFC7540_PRIORITIES`. See <https://www.rfc-editor.org/rfc/rfc9218.html> for more details.

test/async/http/proxy.rb

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
it "can connect and hijack connection" do
5555
input = Async::HTTP::Body::Writable.new
5656

57-
response = client.connect("127.0.0.1:1234", [], input)
57+
response = client.connect(body: input, authority: "127.0.0.1:1234")
5858

5959
expect(response).to be(:success?)
6060

@@ -68,7 +68,7 @@
6868
with "echo server" do
6969
let(:app) do
7070
Protocol::HTTP::Middleware.for do |request|
71-
expect(request.path).to be == "localhost:1"
71+
expect(request.authority).to be == "localhost:1"
7272

7373
Async::HTTP::Body::Hijack.response(request, 200, {}) do |stream|
7474
while chunk = stream.read_partial(1024)
@@ -125,7 +125,7 @@
125125
next Protocol::HTTP::Response[407, [], nil]
126126
end
127127

128-
host, port = request.path.split(":", 2)
128+
host, port = request.authority.split(":", 2)
129129
endpoint = IO::Endpoint.tcp(host, port)
130130

131131
Console.logger.debug(self) {"Making connection to #{endpoint}..."}

0 commit comments

Comments
 (0)