Puma PROXY Protocol v1 Parser Allows Remote Memory Exhaustion
Description
Puma PROXY protocol v1 parsing allows remote memory exhaustion via unbounded buffer growth on unauthenticated TCP connections.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Puma PROXY protocol v1 parsing allows remote memory exhaustion via unbounded buffer growth on unauthenticated TCP connections.
Vulnerability
When PROXY protocol v1 support is enabled in Puma servers configured with set_remote_address proxy_protocol: :v1, the server reads incoming bytes into an internal buffer and waits for \r\n to identify a PROXY v1 line. If an attacker sends continuous bytes without CRLF, Puma appends these to a pre-parse buffer, causing unbounded in-process memory growth and increased CPU usage from buffer scanning. This vulnerability affects Puma versions 5.5.0 up to, but not including, 7.2.1 and versions 8.0.0 up to, but not including, 8.0.2 [2].
Exploitation
An unauthenticated attacker can exploit this vulnerability by opening a single TCP connection to a Puma server configured with PROXY protocol v1 support and continuously sending data without the expected \r\n sequence. This requires network access to the Puma listener, but no other privileges or user interaction are needed [3].
Impact
Successful exploitation can lead to significant memory growth within the Puma process, potentially causing the process or its container to be Out Of Memory (OOM) killed. This results in degraded availability of the application [2, 3].
Mitigation
Puma versions 7.2.1 and 8.0.2 contain fixes for this vulnerability [1, 2]. Users should upgrade to these versions or later. If upgrading is not immediately possible, PROXY protocol v1 parsing can be disabled by removing or commenting out the set_remote_address proxy_protocol: :v1 configuration. Additionally, restricting direct network access to Puma listeners and only allowing trusted load balancers or reverse proxies to connect can mitigate the risk [1].
AI Insight generated on Jun 9, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
2ebe9db3929ab7.2.1 backport (#3947)
3 files changed · +100 −12
lib/puma/client.rb+27 −11 modified@@ -157,7 +157,7 @@ def reset @parser.reset @io_buffer.reset @read_header = true - @read_proxy = !!@expect_proxy_proto + @read_proxy = !!@expect_proxy_proto && @requests_served.zero? @env = @proto_env.dup @parsed_bytes = 0 @ready = false @@ -211,20 +211,36 @@ def tempfile_close def try_to_parse_proxy_protocol if @read_proxy if @expect_proxy_proto == :v1 - if @buffer.include? "\r\n" - if md = PROXY_PROTOCOL_V1_REGEX.match(@buffer) - if md[1] - @peerip = md[1].split(" ")[0] + crlf_index = @buffer.index "\r\n" + + unless crlf_index + if "PROXY ".start_with? @buffer + return false + elsif @buffer.start_with? "PROXY " + if @buffer.bytesize >= PROXY_PROTOCOL_V1_MAX_LENGTH + raise ConnectionError, "PROXY protocol v1 line is too long" end - @buffer = md.post_match + return false end - # if the buffer has a \r\n but doesn't have a PROXY protocol - # request, this is just HTTP from a non-PROXY client; move on + @read_proxy = false - return @buffer.size > 0 - else - return false + return true + end + + if @buffer.start_with?("PROXY ") && crlf_index + 2 > PROXY_PROTOCOL_V1_MAX_LENGTH + raise ConnectionError, "PROXY protocol v1 line is too long" + end + + if md = PROXY_PROTOCOL_V1_REGEX.match(@buffer) + if md[1] + @peerip = md[1].split(" ")[0] + end + @buffer = md.post_match end + # if the buffer has a \r\n but doesn't have a PROXY protocol + # request, this is just HTTP from a non-PROXY client; move on + @read_proxy = false + return @buffer.size > 0 end end true
lib/puma/const.rb+2 −1 modified@@ -291,7 +291,8 @@ module Const # Banned keys of response header BANNED_HEADER_KEY = /\A(rack\.|status\z)/.freeze - PROXY_PROTOCOL_V1_REGEX = /^PROXY (?:TCP4|TCP6|UNKNOWN) ([^\r]+)\r\n/.freeze + PROXY_PROTOCOL_V1_REGEX = /\APROXY (?:TCP4|TCP6|UNKNOWN) ([^\r]+)\r\n/.freeze + PROXY_PROTOCOL_V1_MAX_LENGTH = 107 # All constants are prefixed with `PIPE_` to avoid name collisions. module PipeRequest
test/test_puma_server.rb+71 −0 modified@@ -1623,6 +1623,77 @@ def test_proxy_protocol assert_equal 'fd00::1', remote_addr end + def test_proxy_protocol_rejects_line_without_crlf_at_max_length + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1) do + [200, {}, ["Hello"]] + end + + socket = new_socket + socket << "PROXY #{'A' * (Puma::Const::PROXY_PROTOCOL_V1_MAX_LENGTH - "PROXY ".bytesize)}" + + assert_raises EOFError, Errno::ECONNABORTED, Errno::ECONNRESET do + socket.read_response(timeout: 1) + end + end + + def test_proxy_protocol_allows_non_proxy_requests_over_max_length + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1) do + [200, {}, ["Hello"]] + end + + response = send_http_read_response "GET /#{'a' * Puma::Const::PROXY_PROTOCOL_V1_MAX_LENGTH} HTTP/1.0\r\n\r\n" + + assert_equal "HTTP/1.0 200 OK", response.status + assert_equal "Hello", response.body + end + + def test_proxy_protocol_ignores_embedded_proxy_line_in_http_body + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1) do |env| + body = env['rack.input'].read + [200, {'Content-Length' => body.bytesize.to_s}, [body]] + end + + body = "prefix\nPROXY TCP4 1.1.1.1 2.2.2.2 3 4\r\nsuffix" + response = send_http_read_response "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}" + + assert_equal "HTTP/1.1 200 OK", response.status + assert_equal body, response.body + end + + def test_proxy_protocol_only_parses_first_request_on_connection + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1) do |env| + [200, {}, [env["REMOTE_ADDR"]]] + end + + socket = send_proxy_v1_http("GET /one HTTP/1.1\r\nHost: test.com\r\n\r\n", "1.2.3.4") + + response = socket.read_response + assert_equal "HTTP/1.1 200 OK", response.status + assert_equal "1.2.3.4", response.body + + socket << "PROXY TCP4 127.0.0.1 127.0.0.1 10000 80\r\nGET /two HTTP/1.1\r\nHost: test.com\r\nConnection: close\r\n\r\n" + + assert_match %r{\AHTTP/1\.[01] 400 Bad Request\z}, socket.read_response.status + end + + def test_proxy_protocol_allows_proxy_http_method_on_keep_alive + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1, supported_http_methods: :any) do |env| + [200, {}, [env["REMOTE_ADDR"]]] + end + + socket = send_proxy_v1_http("GET /one HTTP/1.1\r\nHost: test.com\r\n\r\n", "1.2.3.4") + + response = socket.read_response + assert_equal "HTTP/1.1 200 OK", response.status + assert_equal "1.2.3.4", response.body + + socket << "PROXY /two HTTP/1.1\r\nHost: test.com\r\nConnection: close\r\n\r\n" + + response = socket.read_response + assert_equal "HTTP/1.1 200 OK", response.status + assert_equal "1.2.3.4", response.body + end + # 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 = {})
439c6136d9c28.0.2 backport (#3944)
3 files changed · +100 −12
lib/puma/client.rb+27 −11 modified@@ -163,7 +163,7 @@ def reset @parser.reset @io_buffer.reset @read_header = true - @read_proxy = !!@expect_proxy_proto + @read_proxy = !!@expect_proxy_proto && @requests_served.zero? @env = @proto_env.dup @parsed_bytes = 0 @ready = false @@ -213,20 +213,36 @@ def tempfile_close def try_to_parse_proxy_protocol if @read_proxy if @expect_proxy_proto == :v1 - if @buffer.include? "\r\n" - if md = PROXY_PROTOCOL_V1_REGEX.match(@buffer) - if md[1] - @peerip = md[1].split(" ")[0] + crlf_index = @buffer.index "\r\n" + + unless crlf_index + if "PROXY ".start_with? @buffer + return false + elsif @buffer.start_with? "PROXY " + if @buffer.bytesize >= PROXY_PROTOCOL_V1_MAX_LENGTH + raise ConnectionError, "PROXY protocol v1 line is too long" end - @buffer = md.post_match + return false end - # if the buffer has a \r\n but doesn't have a PROXY protocol - # request, this is just HTTP from a non-PROXY client; move on + @read_proxy = false - return @buffer.size > 0 - else - return false + return true + end + + if @buffer.start_with?("PROXY ") && crlf_index + 2 > PROXY_PROTOCOL_V1_MAX_LENGTH + raise ConnectionError, "PROXY protocol v1 line is too long" + end + + if md = PROXY_PROTOCOL_V1_REGEX.match(@buffer) + if md[1] + @peerip = md[1].split(" ")[0] + end + @buffer = md.post_match end + # if the buffer has a \r\n but doesn't have a PROXY protocol + # request, this is just HTTP from a non-PROXY client; move on + @read_proxy = false + return @buffer.size > 0 end end true
lib/puma/const.rb+2 −1 modified@@ -291,7 +291,8 @@ module Const # Banned keys of response header BANNED_HEADER_KEY = /\A(rack\.|status\z)/.freeze - PROXY_PROTOCOL_V1_REGEX = /^PROXY (?:TCP4|TCP6|UNKNOWN) ([^\r]+)\r\n/.freeze + PROXY_PROTOCOL_V1_REGEX = /\APROXY (?:TCP4|TCP6|UNKNOWN) ([^\r]+)\r\n/.freeze + PROXY_PROTOCOL_V1_MAX_LENGTH = 107 # All constants are prefixed with `PIPE_` to avoid name collisions. module PipeRequest
test/test_puma_server.rb+71 −0 modified@@ -1528,6 +1528,77 @@ def test_proxy_protocol assert_equal 'fd00::1', remote_addr end + def test_proxy_protocol_rejects_line_without_crlf_at_max_length + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1) do + [200, {}, ["Hello"]] + end + + socket = new_socket + socket << "PROXY #{'A' * (Puma::Const::PROXY_PROTOCOL_V1_MAX_LENGTH - "PROXY ".bytesize)}" + + assert_raises EOFError, Errno::ECONNABORTED, Errno::ECONNRESET do + socket.read_response(timeout: 1) + end + end + + def test_proxy_protocol_allows_non_proxy_requests_over_max_length + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1) do + [200, {}, ["Hello"]] + end + + response = send_http_read_response "GET /#{'a' * Puma::Const::PROXY_PROTOCOL_V1_MAX_LENGTH} HTTP/1.0\r\n\r\n" + + assert_equal "HTTP/1.0 200 OK", response.status + assert_equal "Hello", response.body + end + + def test_proxy_protocol_ignores_embedded_proxy_line_in_http_body + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1) do |env| + body = env['rack.input'].read + [200, {'Content-Length' => body.bytesize.to_s}, [body]] + end + + body = "prefix\nPROXY TCP4 1.1.1.1 2.2.2.2 3 4\r\nsuffix" + response = send_http_read_response "POST / HTTP/1.1\r\nHost: test.com\r\nContent-Length: #{body.bytesize}\r\n\r\n#{body}" + + assert_equal "HTTP/1.1 200 OK", response.status + assert_equal body, response.body + end + + def test_proxy_protocol_only_parses_first_request_on_connection + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1) do |env| + [200, {}, [env["REMOTE_ADDR"]]] + end + + socket = send_proxy_v1_http("GET /one HTTP/1.1\r\nHost: test.com\r\n\r\n", "1.2.3.4") + + response = socket.read_response + assert_equal "HTTP/1.1 200 OK", response.status + assert_equal "1.2.3.4", response.body + + socket << "PROXY TCP4 127.0.0.1 127.0.0.1 10000 80\r\nGET /two HTTP/1.1\r\nHost: test.com\r\nConnection: close\r\n\r\n" + + assert_match %r{\AHTTP/1\.[01] 400 Bad Request\z}, socket.read_response.status + end + + def test_proxy_protocol_allows_proxy_http_method_on_keep_alive + server_run(remote_address: :proxy_protocol, remote_address_proxy_protocol: :v1, supported_http_methods: :any) do |env| + [200, {}, [env["REMOTE_ADDR"]]] + end + + socket = send_proxy_v1_http("GET /one HTTP/1.1\r\nHost: test.com\r\n\r\n", "1.2.3.4") + + response = socket.read_response + assert_equal "HTTP/1.1 200 OK", response.status + assert_equal "1.2.3.4", response.body + + socket << "PROXY /two HTTP/1.1\r\nHost: test.com\r\nConnection: close\r\n\r\n" + + response = socket.read_response + assert_equal "HTTP/1.1 200 OK", response.status + assert_equal "1.2.3.4", response.body + end + # 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 = {})
Vulnerability mechanics
Synthesis attempt was rejected by the grounding validator. Re-run pending.
References
4News mentions
0No linked articles in our index yet.