Skip to content

Commit 2e5a214

Browse files
Reject transfer-encoding in HTTP/1.0 messages and non-chunked request bodies.
1 parent 342e0de commit 2e5a214

3 files changed

Lines changed: 147 additions & 14 deletions

File tree

‎lib/protocol/http1/connection.rb‎

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ def read_request
451451
@persistent = persistent?(version, method, headers)
452452
end
453453

454-
body = read_request_body(method, headers)
454+
body = read_request_body(method, headers, version)
455455

456456
unless body
457457
self.receive_end_stream!
@@ -507,7 +507,7 @@ def read_response(method)
507507
end
508508

509509
unless interim_status?(status)
510-
body = read_response_body(method, status, headers)
510+
body = read_response_body(method, status, headers, version)
511511

512512
unless body
513513
self.receive_end_stream!
@@ -928,7 +928,8 @@ def extract_content_length(headers)
928928
# @parameter method [String] the HTTP method.
929929
# @parameter status [Integer] the HTTP status code.
930930
# @parameter headers [Hash] the headers of the response.
931-
def read_response_body(method, status, headers)
931+
# @parameter version [String | Nil] the HTTP version of the response, if known.
932+
def read_response_body(method, status, headers, version = nil)
932933
# RFC 7230 3.3.3
933934
# 1. Any response to a HEAD request and any response with a 1xx
934935
# (Informational), 204 (No Content), or 304 (Not Modified) status
@@ -965,7 +966,7 @@ def read_response_body(method, status, headers)
965966
return read_tunnel_body
966967
end
967968

968-
return read_body(headers, true)
969+
return read_body(headers, true, version)
969970
end
970971

971972
# Read the body of the request.
@@ -976,7 +977,8 @@ def read_response_body(method, status, headers)
976977
#
977978
# @parameter method [String] the HTTP method.
978979
# @parameter headers [Hash] the headers of the request.
979-
def read_request_body(method, headers)
980+
# @parameter version [String | Nil] the HTTP version of the request, if known.
981+
def read_request_body(method, headers, version = nil)
980982
# 2. Any 2xx (Successful) response to a CONNECT request implies that
981983
# the connection will become a tunnel immediately after the empty
982984
# line that concludes the header fields. A client MUST ignore any
@@ -993,7 +995,7 @@ def read_request_body(method, headers)
993995

994996
# 6. If this is a request message and none of the above are true, then
995997
# the message body length is zero (no message body is present).
996-
return read_body(headers)
998+
return read_body(headers, false, version)
997999
end
9981000

9991001
# Read the body of the message.
@@ -1002,15 +1004,28 @@ def read_request_body(method, headers)
10021004
# - Otherwise, if the `content-length` is present, the body is read until the content length is reached.
10031005
# - Otherwise, if `remainder` is true, the body is read until the connection is closed.
10041006
#
1007+
# Framing errors (e.g. `transfer-encoding` in an HTTP/1.0 message, or a request whose final transfer coding is not `chunked`) are rejected and mark the connection as non-persistent, since the remainder of the stream can no longer be delimited reliably.
1008+
#
10051009
# @parameter headers [Hash] the headers of the message.
1006-
# @parameter remainder [Boolean] whether to read the remainder of the body.
1010+
# @parameter remainder [Boolean] whether to read the remainder of the body, i.e. the message is a response which may be delimited by closing the connection.
1011+
# @parameter version [String | Nil] the HTTP version of the message, if known.
10071012
# @returns [Object] the body.
1008-
def read_body(headers, remainder = false)
1013+
# @raises [BadRequest] if the message framing is invalid.
1014+
def read_body(headers, remainder = false, version = nil)
10091015
# 3. If a Transfer-Encoding header field is present and the chunked
10101016
# transfer coding (Section 4.1) is the final encoding, the message
10111017
# body length is determined by reading and decoding the chunked
10121018
# data until the transfer coding indicates the data is complete.
10131019
if transfer_encoding = headers.delete(TRANSFER_ENCODING)
1020+
# A server or client that receives an HTTP/1.0 message containing a
1021+
# Transfer-Encoding header field MUST treat the message as if the
1022+
# framing is faulty, even if a Content-Length is present, and close
1023+
# the connection after processing the message.
1024+
if version == HTTP10
1025+
@persistent = false
1026+
raise BadRequest, "HTTP/1.0 message contains transfer encoding!"
1027+
end
1028+
10141029
# If a message is received with both a Transfer-Encoding and a
10151030
# Content-Length header field, the Transfer-Encoding overrides the
10161031
# Content-Length. Such a message might indicate an attempt to
@@ -1019,21 +1034,26 @@ def read_body(headers, remainder = false)
10191034
# remove the received Content-Length field prior to forwarding such
10201035
# a message downstream.
10211036
if headers[CONTENT_LENGTH]
1037+
@persistent = false
10221038
raise BadRequest, "Message contains both transfer encoding and content length!"
10231039
end
10241040

10251041
if transfer_encoding.last == CHUNKED
10261042
return read_chunked_body(headers)
1027-
else
1043+
elsif remainder
10281044
# If a Transfer-Encoding header field is present in a response and
10291045
# the chunked transfer coding is not the final encoding, the
10301046
# message body length is determined by reading the connection until
1031-
# it is closed by the server. If a Transfer-Encoding header field
1032-
# is present in a request and the chunked transfer coding is not
1033-
# the final encoding, the message body length cannot be determined
1034-
# reliably; the server MUST respond with the 400 (Bad Request)
1035-
# status code and then close the connection.
1047+
# it is closed by the server.
10361048
return read_remainder_body
1049+
else
1050+
# If a Transfer-Encoding header field is present in a request and
1051+
# the chunked transfer coding is not the final encoding, the
1052+
# message body length cannot be determined reliably; the server
1053+
# MUST respond with the 400 (Bad Request) status code and then
1054+
# close the connection.
1055+
@persistent = false
1056+
raise BadRequest, "Request transfer encoding does not end with chunked: #{transfer_encoding.inspect}"
10371057
end
10381058
end
10391059

‎releases.md‎

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

3+
## Unreleased
4+
5+
- Reject `transfer-encoding` in HTTP/1.0 messages and requests whose final transfer coding is not `chunked` with `BadRequest`, and mark the connection as non-persistent, as required by RFC 9112 for reliable message framing.
6+
37
## v0.41.0
48

59
- Map remote socket failures to `Protocol::HTTP::RemoteError` and prevent connection reuse.

‎test/protocol/http1/connection.rb‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,75 @@
156156
expect(server).to be(:persistent)
157157
end
158158

159+
it "reads request with chunked body and trailer" do
160+
client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\nTrailer: etag\r\n\r\nb\r\nHello World\r\n0\r\nETag: abcd\r\n\r\n"
161+
client.stream.close
162+
163+
authority, method, target, version, headers, body = server.read_request
164+
165+
expect(body.join).to be == "Hello World"
166+
expect(headers).to be == {"trailer" => ["etag"], "etag" => "abcd"}
167+
168+
expect(server).to be(:persistent)
169+
end
170+
171+
it "fails with HTTP/1.0 request with transfer-encoding" do
172+
# RFC 9112 Section 6.1: a recipient MUST treat an HTTP/1.0 message containing Transfer-Encoding as faulty framing.
173+
client.stream.write "GET / HTTP/1.0\r\nHost: localhost\r\nConnection: keep-alive\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nHello World\r\n0\r\n\r\n"
174+
client.stream.close
175+
176+
expect do
177+
server.read_request
178+
end.to raise_exception(Protocol::HTTP1::BadRequest, message: be =~ /HTTP\/1.0/)
179+
180+
expect(server).not.to be(:persistent)
181+
end
182+
183+
it "fails with HTTP/1.0 request with transfer-encoding and content-length" do
184+
client.stream.write "GET / HTTP/1.0\r\nHost: localhost\r\nContent-Length: 11\r\nTransfer-Encoding: chunked\r\n\r\nHello World"
185+
client.stream.close
186+
187+
expect do
188+
server.read_request
189+
end.to raise_exception(Protocol::HTTP1::BadRequest)
190+
191+
expect(server).not.to be(:persistent)
192+
end
193+
194+
it "fails with request whose final transfer-encoding is not chunked" do
195+
# RFC 9112 Section 6.3: the server MUST respond with 400 and close the connection, since the body length cannot be determined reliably.
196+
client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: gzip\r\n\r\nHello World"
197+
client.stream.close
198+
199+
expect do
200+
server.read_request
201+
end.to raise_exception(Protocol::HTTP1::BadRequest, message: be =~ /chunked/)
202+
203+
expect(server).not.to be(:persistent)
204+
end
205+
206+
it "fails with request where chunked is not the final transfer-encoding" do
207+
client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked, gzip\r\n\r\nb\r\nHello World\r\n0\r\n\r\n"
208+
client.stream.close
209+
210+
expect do
211+
server.read_request
212+
end.to raise_exception(Protocol::HTTP1::BadRequest)
213+
214+
expect(server).not.to be(:persistent)
215+
end
216+
217+
it "fails with request containing both transfer-encoding and content-length" do
218+
client.stream.write "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 11\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nHello World\r\n0\r\n\r\n"
219+
client.stream.close
220+
221+
expect do
222+
server.read_request
223+
end.to raise_exception(Protocol::HTTP1::BadRequest)
224+
225+
expect(server).not.to be(:persistent)
226+
end
227+
159228
it "reads request with CONNECT method" do
160229
client.stream.write "CONNECT localhost:443 HTTP/1.1\r\nHost: localhost\r\n\r\n"
161230
client.stream.close
@@ -333,6 +402,46 @@
333402

334403
expect(result).to be == :yielded
335404
end
405+
406+
it "should read chunked response" do
407+
client.open!
408+
409+
server.stream.write("HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nHello World\r\n0\r\n\r\n")
410+
server.stream.close
411+
412+
version, status, reason, headers, body = client.read_response("GET")
413+
414+
expect(headers).to be == {}
415+
expect(body.join).to be == "Hello World"
416+
expect(client).to be(:persistent)
417+
end
418+
419+
it "should read response with non-chunked transfer-encoding until closed" do
420+
client.open!
421+
422+
# RFC 9112 Section 6.3: for a response, the body is delimited by closing the connection.
423+
server.stream.write("HTTP/1.1 200 OK\r\nTransfer-Encoding: gzip\r\n\r\nHello World")
424+
server.stream.close
425+
426+
version, status, reason, headers, body = client.read_response("GET")
427+
428+
expect(body).to be_a(Protocol::HTTP1::Body::Remainder)
429+
expect(body.join).to be == "Hello World"
430+
expect(client).not.to be(:persistent)
431+
end
432+
433+
it "should fail with HTTP/1.0 response with transfer-encoding" do
434+
client.open!
435+
436+
server.stream.write("HTTP/1.0 200 OK\r\nConnection: keep-alive\r\nTransfer-Encoding: chunked\r\n\r\nb\r\nHello World\r\n0\r\n\r\n")
437+
server.stream.close
438+
439+
expect do
440+
client.read_response("GET")
441+
end.to raise_exception(Protocol::HTTP1::BadRequest, message: be =~ /HTTP\/1.0/)
442+
443+
expect(client).not.to be(:persistent)
444+
end
336445
end
337446

338447
with "#read_response_body" do

0 commit comments

Comments
 (0)