Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

HTTP Injection - fix bug + 1 more vectors #2136

Merged
merged 1 commit into from Feb 28, 2020
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 7 additions & 0 deletions History.md
Expand Up @@ -23,6 +23,13 @@
* Simplify `Runner#start_control` URL parsing (#2111)
* Removed the IOBuffer extension and replaced with Ruby (#1980)


## 4.3.3 and 3.12.4 / 2020-02-28
* Bugfixes
* Fix: Fixes a problem where we weren't splitting headers correctly on newlines (#2132)
* Security
* Fix: Prevent HTTP Response splitting via CR in early hints.

## 4.3.2 and 3.12.3 / 2020-02-27

* Security
Expand Down
2 changes: 1 addition & 1 deletion lib/puma/const.rb
Expand Up @@ -228,7 +228,7 @@ module Const
COLON = ": ".freeze

NEWLINE = "\n".freeze
CRLF_REGEX = /[\r\n]/.freeze
HTTP_INJECTION_REGEX = /[\r\n]/.freeze

HIJACK_P = "rack.hijack?".freeze
HIJACK = "rack.hijack".freeze
Expand Down
10 changes: 8 additions & 2 deletions lib/puma/server.rb
Expand Up @@ -663,6 +663,7 @@ def handle_request(req, lines)
headers.each_pair do |k, vs|
if vs.respond_to?(:to_s) && !vs.to_s.empty?
vs.to_s.split(NEWLINE).each do |v|
next if possible_header_injection?(v)
fast_write client, "#{k}: #{v}\r\n"
end
else
Expand All @@ -687,8 +688,6 @@ def handle_request(req, lines)
status, headers, res_body = @app.call(env)

return :async if req.hijacked
# Checking to see if an attacker is trying to inject headers into the response
headers.reject! { |_k, v| CRLF_REGEX =~ v.to_s }

status = status.to_i

Expand Down Expand Up @@ -766,6 +765,7 @@ def handle_request(req, lines)
headers.each do |k, vs|
case k.downcase
when CONTENT_LENGTH2
next if possible_header_injection?(vs)
content_length = vs
next
when TRANSFER_ENCODING
Expand All @@ -778,6 +778,7 @@ def handle_request(req, lines)

if vs.respond_to?(:to_s) && !vs.to_s.empty?
vs.to_s.split(NEWLINE).each do |v|
next if possible_header_injection?(v)
lines.append k, colon, v, line_ending
end
else
Expand Down Expand Up @@ -1048,5 +1049,10 @@ def self.current
def shutting_down?
@status == :stop || @status == :restart
end

def possible_header_injection?(header_value)
HTTP_INJECTION_REGEX =~ header_value.to_s
end
private :possible_header_injection?
end
end
65 changes: 55 additions & 10 deletions test/test_puma_server.rb
Expand Up @@ -772,22 +772,67 @@ def test_open_connection_wait_no_queue
test_open_connection_wait
end

# https://github.com/ruby/ruby/commit/d9d4a28f1cdd05a0e8dabb36d747d40bbcc30f16
def test_prevent_response_splitting_headers
server_run app: ->(_) { [200, {'X-header' => "malicious\r\nCookie: hack"}, ["Hello"]] }
# Rack may pass a newline in a header expecting us to split it.
def test_newline_splits
server_run app: ->(_) { [200, {'X-header' => "first line\nsecond line"}, ["Hello"]] }

data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n"
refute_match 'hack', data

assert_match "X-header: first line\r\nX-header: second line\r\n", data
end

def test_prevent_response_splitting_headers_cr
server_run app: ->(_) { [200, {'X-header' => "malicious\rCookie: hack"}, ["Hello"]] }
def test_newline_splits_in_early_hint
server_run early_hints: true, app: ->(env) do
env['rack.early_hints'].call({'X-header' => "first line\nsecond line"})
[200, {}, ["Hello world!"]]
end

data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n"
refute_match 'hack', data

assert_match "X-header: first line\r\nX-header: second line\r\n", data
end

def test_prevent_response_splitting_headers_lf
server_run app: ->(_) { [200, {'X-header' => "malicious\nCookie: hack"}, ["Hello"]] }
# To comply with the Rack spec, we have to split header field values
# containing newlines into multiple headers.
def assert_does_not_allow_http_injection(app, opts = {})
server_run(early_hints: opts[:early_hints], app: app)

data = send_http_and_read "HEAD / HTTP/1.0\r\n\r\n"
refute_match 'hack', data

refute_match(/[\r\n]Cookie: hack[\r\n]/, data)
end

# HTTP Injection Tests
#
# Puma should prevent injection of CR and LF characters into headers, either as
# CRLF or CR or LF, because browsers may interpret it at as a line end and
# allow untrusted input in the header to split the header or start the
# response body. While it's not documented anywhere and they shouldn't be doing
# it, Chrome and curl recognize a lone CR as a line end. According to RFC,
# clients SHOULD interpret LF as a line end for robustness, and CRLF is the
# specced line end.
#
# There are three different tests because there are three ways to set header
# content in Puma. Regular (rack env), early hints, and a special case for
# overriding content-length.
{"cr" => "\r", "lf" => "\n", "crlf" => "\r\n"}.each do |suffix, line_ending|
# The cr-only case for the following test was CVE-2020-5247
define_method("test_prevent_response_splitting_headers_#{suffix}") do
app = ->(_) { [200, {'X-header' => "untrusted input#{line_ending}Cookie: hack"}, ["Hello"]] }
assert_does_not_allow_http_injection(app)
end

define_method("test_prevent_response_splitting_headers_early_hint_#{suffix}") do
app = ->(env) do
env['rack.early_hints'].call("X-header" => "untrusted input#{line_ending}Cookie: hack")
[200, {}, ["Hello"]]
end
assert_does_not_allow_http_injection(app, early_hints: true)
end

define_method("test_prevent_content_length_injection_#{suffix}") do
app = ->(_) { [200, {'content-length' => "untrusted input#{line_ending}Cookie: hack"}, ["Hello"]] }
assert_does_not_allow_http_injection(app)
end
end
end