VYPR
Moderate severityNVD Advisory· Published Mar 2, 2020· Updated Aug 4, 2024

HTTP Response Splitting (Early Hints) in Puma

CVE-2020-5249

Description

In Puma (RubyGem) before 4.3.3 and 3.12.4, if an application using Puma allows untrusted input in an early-hints header, an attacker can use a carriage return character to end the header and inject malicious content, such as additional headers or an entirely new response body. This vulnerability is known as HTTP Response Splitting. While not an attack in itself, response splitting is a vector for several other attacks, such as cross-site scripting (XSS). This is related to CVE-2020-5247, which fixed this vulnerability but only for regular responses. This has been fixed in 4.3.3 and 3.12.4.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Puma HTTP response splitting via early-hints header allows injection of CR characters, enabling header/body manipulation when untrusted input is used.

CVE-2020-5249 is an HTTP response splitting vulnerability in Puma (RubyGem) affecting versions before 4.3.3 and 3.12.4. The flaw arises because Puma's handling of early-hints headers does not properly sanitize carriage return (CR) characters. An attacker who can supply untrusted input into an early-hints header value can inject a CR character to prematurely terminate the header and control subsequent headers or the response body [1][2].

For exploitation, the application must pass user-controllable data into an early-hints header. The attacker does not need authentication if the application exposes such input. The CR character allows the attacker to break out of the header context and inject arbitrary headers (e.g., Set-Cookie) or even an entire fake HTTP response body, enabling response splitting [3][4].

This vulnerability can be chained with other attacks, such as cross-site scripting (XSS). By injecting malicious content into the response, an attacker can modify the page content returned to the victim's browser, potentially stealing cookies or performing actions on behalf of the user [2][4]. Note that a related issue, CVE-2020-5247, fixed response splitting in regular (non-early-hint) responses, but the early-hints path remained vulnerable [1].

The issue is resolved in Puma versions 4.3.3 and 3.12.4, which properly validate and reject CR and LF characters in early-hints headers [1][3]. Users are strongly encouraged to upgrade immediately. No known workarounds exist apart from ensuring untrusted input is never placed in early-hints headers.

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
pumaRubyGems
< 3.12.43.12.4
pumaRubyGems
>= 4.0.0, < 4.3.34.3.3

Affected products

12

Patches

1
c22712fc9328

HTTP Injection - fix bug + 1 more vector (#2136)

https://github.com/puma/pumaNate BerkopecFeb 28, 2020via ghsa
4 files changed · +71 13
  • History.md+7 0 modified
    @@ -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
    
  • lib/puma/const.rb+1 1 modified
    @@ -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
    
  • lib/puma/server.rb+8 2 modified
    @@ -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
    @@ -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
     
    @@ -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
    @@ -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
    @@ -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
    
  • test/test_puma_server.rb+55 10 modified
    @@ -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
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

13

News mentions

0

No linked articles in our index yet.