VYPR
Moderate severityNVD Advisory· Published Sep 19, 2024· Updated Nov 3, 2025

Header normalization allows for client to clobber proxy set headers in Puma

CVE-2024-45614

Description

Puma is a Ruby/Rack web server built for parallelism. In affected versions clients could clobber values set by intermediate proxies (such as X-Forwarded-For) by providing a underscore version of the same header (X-Forwarded_For). Any users relying on proxy set variables is affected. v6.4.3/v5.6.9 now discards any headers using underscores if the non-underscore version also exists. Effectively, allowing the proxy defined headers to always win. Users are advised to upgrade. Nginx has a underscores_in_headers configuration variable to discard these headers at the proxy level as a mitigation. Any users that are implicitly trusting the proxy defined headers for security should immediately cease doing so until upgraded to the fixed versions.

AI Insight

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

Puma servers allow client-provided underscore headers (e.g., X-Forwarded_For) to override proxy-set values, bypassing IP-based security controls.

Vulnerability

Overview

CVE-2024-45614 is a header injection vulnerability in the Puma Ruby web server (versions prior to 6.4.3 and 5.6.9). The root cause lies in how Puma normalizes HTTP header names: it converts hyphens to underscores but does not check for existing underscore-form headers when a hyphen-form header (like X-Forwarded-For) is also present. This allows a malicious client to send a header with an underscore in place of a hyphen (e.g., X-Forwarded_For) and clobber the value set by an intermediate proxy, such as a load balancer or reverse proxy adding the real client IP via X-Forwarded-For. The normalization process treats both variants as distinct, but the application may trust the proxy-set value; however, the client-supplied underscore version can overwrite it due to the way headers are parsed and stored in the Rack environment [3].

Attack

Vector and Exploitation

An attacker with the ability to craft HTTP requests can exploit this flaw by sending a request containing the underscore variant of a header that a proxy is expected to set (e.g., X-Forwarded_For). No special authentication is required—any client able to connect to the Puma server directly or through a proxy can attempt this. The attack surface is particularly impactful for applications that rely on proxy-set headers for security decisions, such as IP-based access controls, logging, or rate limiting. For example, an attacker could spoof their IP address by sending X-Forwarded_For: 127.0.0.1 alongside a proxy-set X-Forwarded-For, potentially bypassing IP allowlists or masking their true origin [1]. The Nginx underscores_in_headers directive is noted as a mitigation at the proxy level, but it is not enabled by default, leaving many deployments exposed [1].

Impact

If exploited, an attacker can inject arbitrary values into headers that the application trusts for security or functionality, leading to potential authentication bypass, privilege escalation, or information disclosure. For instance, using a spoofed X-Forwarded-For, an attacker could mimic a trusted internal IP to access restricted resources, or manipulate other proxy-dependent logic such as X-Forwarded-Proto or X-Forwarded-Host to alter application behavior. The impact is rated with a CVSS v4.0 score pending, but the description underscores that all users who implicitly trust proxy-set headers for security should immediately reassess that trust [3].

Mitigation and

Remediation

Puma has released fixed versions 6.4.3 and 5.6.9 that discard underscore headers when a corresponding hyphen-form header already exists, ensuring proxy-set values always take precedence [2]. The fix is implemented in the HTTP parsing layer and the req_env_post_parse method, comparing header names after normalization and prioritizing the proxy-set variant [4]. Users are strongly advised to upgrade to the patched versions. As a temporary workaround, administrators can configure Nginx to set underscores_in_headers off; to reject underscore-containing headers entirely at the proxy level [1]. Additionally, any application logic that relies on proxy headers for security should be hardened to not trust client-controllable header values.

AI Insight generated on May 20, 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
< 5.6.95.6.9
pumaRubyGems
>= 6.0.0, < 6.4.36.4.3

Affected products

127

Patches

2
cac3fd18cf29

Merge commit from fork

https://github.com/puma/pumaEvan PhoenixSep 19, 2024via ghsa
5 files changed · +111 3
  • ext/puma_http11/org/jruby/puma/Http11.java+2 0 modified
    @@ -99,6 +99,8 @@ public static void http_field(Ruby runtime, RubyHash req, ByteList buffer, int f
                 int bite = b.get(i) & 0xFF;
                 if(bite == '-') {
                     b.set(i, (byte)'_');
    +            } else if(bite == '_') {
    +                b.set(i, (byte)',');
                 } else {
                     b.set(i, (byte)Character.toUpperCase(bite));
                 }
    
  • lib/puma/const.rb+8 0 modified
    @@ -281,6 +281,14 @@ module Const
         # header values can contain HTAB?
         ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/.freeze
     
    +    # The keys of headers that should not be convert to underscore
    +    # normalized versions. These headers are ignored at the request reading layer,
    +    # but if we normalize them after reading, it's just confusing for the application.
    +    UNMASKABLE_HEADERS = {
    +      "HTTP_TRANSFER,ENCODING" => true,
    +      "HTTP_CONTENT,LENGTH" => true,
    +    }
    +
         # Banned keys of response header
         BANNED_HEADER_KEY = /\A(rack\.|status\z)/.freeze
     
    
  • lib/puma/request.rb+16 3 modified
    @@ -495,6 +495,11 @@ def illegal_header_value?(header_value)
         # compatibility, we'll convert them back. This code is written to
         # avoid allocation in the common case (ie there are no headers
         # with `,` in their names), that's why it has the extra conditionals.
    +    #
    +    # @note If a normalized version of a `,` header already exists, we ignore
    +    #       the `,` version. This prevents clobbering headers managed by proxies
    +    #       but not by clients (Like X-Forwarded-For).
    +    #
         # @param env [Hash] see Puma::Client#env, from request, modifies in place
         # @version 5.0.3
         #
    @@ -503,23 +508,31 @@ def req_env_post_parse(env)
           to_add = nil
     
           env.each do |k,v|
    -        if k.start_with?("HTTP_") && k.include?(",") && k != "HTTP_TRANSFER,ENCODING"
    +        if k.start_with?("HTTP_") && k.include?(",") && !UNMASKABLE_HEADERS.key?(k)
               if to_delete
                 to_delete << k
               else
                 to_delete = [k]
               end
     
    +          new_k = k.tr(",", "_")
    +          if env.key?(new_k)
    +            next
    +          end
    +
               unless to_add
                 to_add = {}
               end
     
    -          to_add[k.tr(",", "_")] = v
    +          to_add[new_k] = v
             end
           end
     
    -      if to_delete
    +      if to_delete # rubocop:disable Style/SafeNavigation
             to_delete.each { |k| env.delete(k) }
    +      end
    +
    +      if to_add
             env.merge! to_add
           end
         end
    
  • test/test_normalize.rb+57 0 added
    @@ -0,0 +1,57 @@
    +# frozen_string_literal: true
    +
    +require_relative "helper"
    +
    +require "puma/request"
    +
    +class TestNormalize < Minitest::Test
    +  parallelize_me!
    +
    +  include Puma::Request
    +
    +  def test_comma_headers
    +    env = {
    +      "HTTP_X_FORWARDED_FOR" => "1.1.1.1",
    +      "HTTP_X_FORWARDED,FOR" => "2.2.2.2",
    +    }
    +
    +    req_env_post_parse env
    +
    +    expected = {
    +      "HTTP_X_FORWARDED_FOR" => "1.1.1.1",
    +    }
    +
    +    assert_equal expected, env
    +
    +    # Test that the iteration order doesn't matter
    +
    +    env = {
    +      "HTTP_X_FORWARDED,FOR" => "2.2.2.2",
    +      "HTTP_X_FORWARDED_FOR" => "1.1.1.1",
    +    }
    +
    +    req_env_post_parse env
    +
    +    expected = {
    +      "HTTP_X_FORWARDED_FOR" => "1.1.1.1",
    +    }
    +
    +    assert_equal expected, env
    +  end
    +
    +  def test_unmaskable_headers
    +    env = {
    +      "HTTP_CONTENT,LENGTH" => "100000",
    +      "HTTP_TRANSFER,ENCODING" => "chunky"
    +    }
    +
    +    req_env_post_parse env
    +
    +    expected = {
    +      "HTTP_CONTENT,LENGTH" => "100000",
    +      "HTTP_TRANSFER,ENCODING" => "chunky"
    +    }
    +
    +    assert_equal expected, env
    +  end
    +end
    
  • test/test_request_invalid.rb+28 0 modified
    @@ -215,4 +215,32 @@ def test_chunked_size_mismatch_2
     
         assert_status data
       end
    +
    +  def test_underscore_header_1
    +    hdrs = [
    +      "X-FORWARDED-FOR: 1.1.1.1",  # proper
    +      "X-FORWARDED-FOR: 2.2.2.2",  # proper
    +      "X_FORWARDED-FOR: 3.3.3.3",  # invalid, contains underscore
    +      "Content-Length: 5",
    +    ].join "\r\n"
    +
    +    response = send_http_and_read "#{GET_PREFIX}#{hdrs}\r\n\r\nHello\r\n\r\n"
    +
    +    assert_includes response, "HTTP_X_FORWARDED_FOR = 1.1.1.1, 2.2.2.2"
    +    refute_includes response, "3.3.3.3"
    +  end
    +
    +  def test_underscore_header_2
    +    hdrs = [
    +      "X_FORWARDED-FOR: 3.3.3.3",  # invalid, contains underscore
    +      "X-FORWARDED-FOR: 2.2.2.2",  # proper
    +      "X-FORWARDED-FOR: 1.1.1.1",  # proper
    +      "Content-Length: 5",
    +    ].join "\r\n"
    +
    +    response = send_http_and_read "#{GET_PREFIX}#{hdrs}\r\n\r\nHello\r\n\r\n"
    +
    +    assert_includes response, "HTTP_X_FORWARDED_FOR = 2.2.2.2, 1.1.1.1"
    +    refute_includes response, "3.3.3.3"
    +  end
     end
    
f196b23be247

Merge commit from fork

https://github.com/puma/pumaEvan PhoenixSep 19, 2024via ghsa
5 files changed · +111 3
  • ext/puma_http11/org/jruby/puma/Http11.java+2 0 modified
    @@ -99,6 +99,8 @@ public static void http_field(Ruby runtime, RubyHash req, ByteList buffer, int f
                 int bite = b.get(i) & 0xFF;
                 if(bite == '-') {
                     b.set(i, (byte)'_');
    +            } else if(bite == '_') {
    +                b.set(i, (byte)',');
                 } else {
                     b.set(i, (byte)Character.toUpperCase(bite));
                 }
    
  • lib/puma/const.rb+8 0 modified
    @@ -244,6 +244,14 @@ module Const
         # header values can contain HTAB?
         ILLEGAL_HEADER_VALUE_REGEX = /[\x00-\x08\x0A-\x1F]/.freeze
     
    +    # The keys of headers that should not be convert to underscore
    +    # normalized versions. These headers are ignored at the request reading layer,
    +    # but if we normalize them after reading, it's just confusing for the application.
    +    UNMASKABLE_HEADERS = {
    +      "HTTP_TRANSFER,ENCODING" => true,
    +      "HTTP_CONTENT,LENGTH" => true,
    +    }
    +
         # Banned keys of response header
         BANNED_HEADER_KEY = /\A(rack\.|status\z)/.freeze
     
    
  • lib/puma/request.rb+16 3 modified
    @@ -318,6 +318,11 @@ def illegal_header_value?(header_value)
         # compatibility, we'll convert them back. This code is written to
         # avoid allocation in the common case (ie there are no headers
         # with `,` in their names), that's why it has the extra conditionals.
    +    #
    +    # @note If a normalized version of a `,` header already exists, we ignore
    +    #       the `,` version. This prevents clobbering headers managed by proxies
    +    #       but not by clients (Like X-Forwarded-For).
    +    #
         # @param env [Hash] see Puma::Client#env, from request, modifies in place
         # @version 5.0.3
         #
    @@ -326,23 +331,31 @@ def req_env_post_parse(env)
           to_add = nil
     
           env.each do |k,v|
    -        if k.start_with?("HTTP_") and k.include?(",") and k != "HTTP_TRANSFER,ENCODING"
    +        if k.start_with?("HTTP_") && k.include?(",") && !UNMASKABLE_HEADERS.key?(k)
               if to_delete
                 to_delete << k
               else
                 to_delete = [k]
               end
     
    +          new_k = k.tr(",", "_")
    +          if env.key?(new_k)
    +            next
    +          end
    +
               unless to_add
                 to_add = {}
               end
     
    -          to_add[k.tr(",", "_")] = v
    +          to_add[new_k] = v
             end
           end
     
    -      if to_delete
    +      if to_delete # rubocop:disable Style/SafeNavigation
             to_delete.each { |k| env.delete(k) }
    +      end
    +
    +      if to_add
             env.merge! to_add
           end
         end
    
  • test/test_normalize.rb+57 0 added
    @@ -0,0 +1,57 @@
    +# frozen_string_literal: true
    +
    +require_relative "helper"
    +
    +require "puma/request"
    +
    +class TestNormalize < Minitest::Test
    +  parallelize_me!
    +
    +  include Puma::Request
    +
    +  def test_comma_headers
    +    env = {
    +      "HTTP_X_FORWARDED_FOR" => "1.1.1.1",
    +      "HTTP_X_FORWARDED,FOR" => "2.2.2.2",
    +    }
    +
    +    req_env_post_parse env
    +
    +    expected = {
    +      "HTTP_X_FORWARDED_FOR" => "1.1.1.1",
    +    }
    +
    +    assert_equal expected, env
    +
    +    # Test that the iteration order doesn't matter
    +
    +    env = {
    +      "HTTP_X_FORWARDED,FOR" => "2.2.2.2",
    +      "HTTP_X_FORWARDED_FOR" => "1.1.1.1",
    +    }
    +
    +    req_env_post_parse env
    +
    +    expected = {
    +      "HTTP_X_FORWARDED_FOR" => "1.1.1.1",
    +    }
    +
    +    assert_equal expected, env
    +  end
    +
    +  def test_unmaskable_headers
    +    env = {
    +      "HTTP_CONTENT,LENGTH" => "100000",
    +      "HTTP_TRANSFER,ENCODING" => "chunky"
    +    }
    +
    +    req_env_post_parse env
    +
    +    expected = {
    +      "HTTP_CONTENT,LENGTH" => "100000",
    +      "HTTP_TRANSFER,ENCODING" => "chunky"
    +    }
    +
    +    assert_equal expected, env
    +  end
    +end
    
  • test/test_request_invalid.rb+28 0 modified
    @@ -216,4 +216,32 @@ def test_chunked_size_mismatch_2
     
         assert_status data
       end
    +
    +  def test_underscore_header_1
    +    hdrs = [
    +      "X-FORWARDED-FOR: 1.1.1.1",  # proper
    +      "X-FORWARDED-FOR: 2.2.2.2",  # proper
    +      "X_FORWARDED-FOR: 3.3.3.3",  # invalid, contains underscore
    +      "Content-Length: 5",
    +    ].join "\r\n"
    +
    +    response = send_http_and_read "#{GET_PREFIX}#{hdrs}\r\n\r\nHello\r\n\r\n"
    +
    +    assert_includes response, "HTTP_X_FORWARDED_FOR = 1.1.1.1, 2.2.2.2"
    +    refute_includes response, "3.3.3.3"
    +  end
    +
    +  def test_underscore_header_2
    +    hdrs = [
    +      "X_FORWARDED-FOR: 3.3.3.3",  # invalid, contains underscore
    +      "X-FORWARDED-FOR: 2.2.2.2",  # proper
    +      "X-FORWARDED-FOR: 1.1.1.1",  # proper
    +      "Content-Length: 5",
    +    ].join "\r\n"
    +
    +    response = send_http_and_read "#{GET_PREFIX}#{hdrs}\r\n\r\nHello\r\n\r\n"
    +
    +    assert_includes response, "HTTP_X_FORWARDED_FOR = 2.2.2.2, 1.1.1.1"
    +    refute_includes response, "3.3.3.3"
    +  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

8

News mentions

0

No linked articles in our index yet.