Skip to content
New issue

Have a question about this project? # for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “#”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? # to your account

request.rb - fix partial hijack precedence, refactor, add tests #3072

Merged
merged 8 commits into from
Feb 17, 2023
83 changes: 43 additions & 40 deletions lib/puma/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ def handle_request(client, requests)
# is called
res_body = app_body

# full hijack, app called env['rack.hijack']
return :async if client.hijacked

status = status.to_i
Expand Down Expand Up @@ -169,54 +170,55 @@ def prepare_response(status, headers, res_body, requests, client)
resp_info = str_headers(env, status, headers, res_body, io_buffer, force_keep_alive)

close_body = false
response_hijack = nil
content_length = resp_info[:content_length]
keep_alive = resp_info[:keep_alive]

# below converts app_body into body, dependent on app_body's characteristics, and
# resp_info[:content_length] will be set if it can be determined
if !resp_info[:content_length] && !resp_info[:transfer_encoding] && status != 204
if res_body.respond_to?(:to_ary) && (array_body = res_body.to_ary) && array_body.is_a?(Array)
body = array_body
resp_info[:content_length] = body.sum(&:bytesize)
elsif res_body.is_a?(File) && res_body.respond_to?(:size)
body = res_body
resp_info[:content_length] = body.size
elsif res_body.respond_to?(:to_path) && res_body.respond_to?(:each) &&
if res_body.respond_to?(:each) && !resp_info[:response_hijack]
# below converts app_body into body, dependent on app_body's characteristics, and
# content_length will be set if it can be determined
if !content_length && !resp_info[:transfer_encoding] && status != 204
if res_body.respond_to?(:to_ary) && (array_body = res_body.to_ary) &&
array_body.is_a?(Array)
body = array_body
content_length = body.sum(&:bytesize)
elsif res_body.is_a?(File) && res_body.respond_to?(:size)
body = res_body
content_length = body.size
elsif res_body.respond_to?(:to_path) && File.readable?(fn = res_body.to_path)
body = File.open fn, 'rb'
content_length = body.size
close_body = true
else
body = res_body
end
elsif !res_body.is_a?(::File) && res_body.respond_to?(:to_path) &&
File.readable?(fn = res_body.to_path)
body = File.open fn, 'rb'
resp_info[:content_length] = body.size
content_length = body.size
close_body = true
elsif !res_body.is_a?(::File) && res_body.respond_to?(:filename) &&
res_body.respond_to?(:bytesize) && File.readable?(fn = res_body.filename)
# Sprockets::Asset
content_length = res_body.bytesize unless content_length
if (body_str = res_body.to_hash[:source])
body = [body_str]
else # avoid each and use a File object
body = File.open fn, 'rb'
close_body = true
end
else
body = res_body
end
elsif !res_body.is_a?(::File) && res_body.respond_to?(:to_path) && res_body.respond_to?(:each) &&
File.readable?(fn = res_body.to_path)
body = File.open fn, 'rb'
resp_info[:content_length] = body.size
close_body = true
elsif !res_body.is_a?(::File) && res_body.respond_to?(:filename) && res_body.respond_to?(:each) &&
res_body.respond_to?(:bytesize) && File.readable?(fn = res_body.filename)
# Sprockets::Asset
resp_info[:content_length] = res_body.bytesize unless resp_info[:content_length]
if res_body.to_hash[:source] # use each to return @source
body = res_body
else # avoid each and use a File object
body = File.open fn, 'rb'
close_body = true
end
else
body = res_body
# partial hijack, from Rack spec:
# Servers must ignore the body part of the response tuple when the
# rack.hijack response header is present.
response_hijack = resp_info[:response_hijack] || res_body
end

line_ending = LINE_END

content_length = resp_info[:content_length]
keep_alive = resp_info[:keep_alive]

if res_body && !res_body.respond_to?(:each)
response_hijack = res_body
else
response_hijack = resp_info[:response_hijack]
end

cork_socket socket

if resp_info[:no_body]
Expand Down Expand Up @@ -244,6 +246,8 @@ def prepare_response(status, headers, res_body, requests, client)

io_buffer << line_ending

# partial hijack, we write headers, then hand the socket to the app via
# response_hijack.call
if response_hijack
fast_write_str socket, io_buffer.read_and_reset
uncork_socket socket
Expand Down Expand Up @@ -358,16 +362,15 @@ def fast_write_response(socket, body, io_buffer, chunked, content_length)
fast_write_str(socket, io_buffer.read_and_reset) unless io_buffer.length.zero?
else
# for enum bodies
fast_write_str socket, io_buffer.read_and_reset
if chunked
body.each do |part|
next if (byte_size = part.bytesize).zero?
fast_write_str socket, (byte_size.to_s(16) << LINE_END)
fast_write_str socket, part
fast_write_str socket, LINE_END
io_buffer.append byte_size.to_s(16), LINE_END, part, LINE_END
fast_write_str socket, io_buffer.read_and_reset
end
fast_write_str socket, CLOSE_CHUNKED
else
fast_write_str socket, io_buffer.read_and_reset
body.each do |part|
next if part.bytesize.zero?
fast_write_str socket, part
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,18 @@
require "puma/server"
require "net/http"
require "nio"
require "ipaddr"

class TestPumaServerPartialHijack < Minitest::Test
require "rack"
require "rack/body_proxy"

# Tests check both the proper passing of the socket to the app, and also calling
# of `body.close` on the response body. Rack spec is unclear as to whether
# calling close is expected.
#
# The sleep statements may not be needed for local CI, but are needed
# for use with GitHub Actions...

class TestPumaServerHijack < Minitest::Test
parallelize_me!

def setup
Expand Down Expand Up @@ -54,13 +63,39 @@ def send_http_and_read(req)
end

def send_http(req)
new_connection << req
t = new_connection
t.syswrite req
t
end

def new_connection
TCPSocket.new(@host, @port).tap {|sock| @ios << sock}
end

# Full hijack does not return headers
def test_full_hijack_body_close
@body_closed = false
server_run do |env|
io = env['rack.hijack'].call
io.syswrite 'Server listening'
io.wait_readable 2
io.syswrite io.sysread(256)
body = ::Rack::BodyProxy.new([]) { @body_closed = true }
[200, {}, body]
end

sock = send_http "GET / HTTP/1.1\r\n\r\n"

sock.wait_readable 2
assert_equal "Server listening", sock.sysread(256)

sock.syswrite "this should echo"
assert_equal "this should echo", sock.sysread(256)
Thread.pass
sleep 0.001 # intermittent failure, may need to increase in CI
assert @body_closed, "Reponse body must be closed"
end

def test_101_body
headers = {
'Upgrade' => 'websocket',
Expand All @@ -81,8 +116,7 @@ def test_101_body
[101, headers, body]
end

sock = new_connection
sock.syswrite "GET / HTTP/1.1\r\n\r\n"
sock = send_http "GET / HTTP/1.1\r\n\r\n"
resp = sock.sysread 1_024
echo_msg = "This should echo..."
sock.syswrite echo_msg
Expand Down Expand Up @@ -110,8 +144,7 @@ def test_101_header
[101, headers, []]
end

sock = new_connection
sock.syswrite "GET / HTTP/1.1\r\n\r\n"
sock = send_http "GET / HTTP/1.1\r\n\r\n"
resp = sock.sysread 1_024
echo_msg = "This should echo..."
sock.syswrite echo_msg
Expand All @@ -132,8 +165,56 @@ def test_http_10_header_with_content_length
[200, {"Content-Length" => "5", 'rack.hijack' => hijack_lambda}, nil]
end

# using sysread may only receive part of the response
data = send_http_and_read "GET / HTTP/1.0\r\nConnection: close\r\n\r\n"

assert_equal "HTTP/1.0 200 OK\r\nContent-Length: 5\r\n\r\nabcde", data
end

def test_partial_hijack_body_closes_body
@available = true
hdrs = { 'Content-Type' => 'text/plain' }
body = ::Rack::BodyProxy.new(HIJACK_LAMBDA) { @available = true }
partial_hijack_closes_body(hdrs, body)
end

def test_partial_hijack_header_closes_body_correct_precedence
@available = true
incorrect_lambda = ->(io) {
io.syswrite 'incorrect body.call'
io.close
}
hdrs = { 'Content-Type' => 'text/plain', 'rack.hijack' => HIJACK_LAMBDA}
body = ::Rack::BodyProxy.new(incorrect_lambda) { @available = true }
partial_hijack_closes_body(hdrs, body)
end

HIJACK_LAMBDA = ->(io) {
io.syswrite 'hijacked'
io.close
}

def partial_hijack_closes_body(hdrs, body)
server_run do
if @available
@available = false
[200, hdrs, body]
else
[500, { 'Content-Type' => 'text/plain' }, ['incorrect']]
end
end

sock1 = send_http "GET / HTTP/1.1\r\n\r\n"
sleep (Puma::IS_WINDOWS || !Puma::IS_MRI ? 0.3 : 0.1)
resp1 = sock1.sysread 1_024

sleep 0.01 # time for close block to be called ?

sock2 = send_http "GET / HTTP/1.1\r\n\r\n"
sleep (Puma::IS_WINDOWS || !Puma::IS_MRI ? 0.3 : 0.1)
resp2 = sock2.sysread 1_024

assert_operator resp1, :end_with?, 'hijacked'
assert_operator resp2, :end_with?, 'hijacked'
end
end
37 changes: 0 additions & 37 deletions test/test_rack_server.rb
Original file line number Diff line number Diff line change
Expand Up @@ -215,43 +215,6 @@ def test_rack_body_proxy_content_length
assert_equal str_ary_bytes, content_length
end

def test_hijack_body_close
available = true
@server.app = ->(env) {
if available
available = false
hijack_lambda = ->(io) {
io.syswrite 'hijacked'
io.close
}
[200, { 'Content-Type' => 'text/plain', 'rack.hijack' => hijack_lambda},
::Rack::BodyProxy.new([]) { available = true }]
else
[500, { 'Content-Type' => 'text/plain' }, ['incorrect']]
end
}

@server.run

socket1 = TCPSocket.new HOST, @port
socket1.syswrite "GET / HTTP/1.1\r\n\r\n"
sleep (Puma::IS_WINDOWS || !Puma::IS_MRI ? 0.25 : 0.1)
resp1 = socket1.sysread 1_024

sleep 0.01 # time for close block to be called ?

socket2 = TCPSocket.new HOST, @port
socket2.syswrite "GET / HTTP/1.1\r\n\r\n"
sleep (Puma::IS_WINDOWS || !Puma::IS_MRI ? 0.25 : 0.1)
resp2 = socket2.sysread 1_024

assert_operator resp1, :end_with?, 'hijacked'
assert_operator resp2, :end_with?, 'hijacked'

socket1.close
socket2.close
end

def test_common_logger
log = StringIO.new

Expand Down