PROXY Protocol v1 パーサーの複数バグ修正
Puma 8.0.2 バックポートとして、PROXY Protocol v1 のパース処理に存在した複数のバグが修正されました。不正な長さのPROXYヘッダーへの未定義動作、Keep-Alive 接続での誤パース、HTTP ボディ内の偽PROXYラインの誤認識という3つの問題が同時に解消されています。
背景
PROXY Protocol は HAProxy 等のロードバランサーがクライアントのIPアドレスをバックエンドサーバーへ伝達するためのプロトコルです。Puma はこのプロトコルの v1 をサポートしており、remote_address: :proxy_protocol 設定で有効化できます。
今回の修正が対象とする問題は、主に3点です。まず、CRLF(\r\n)が現れる前にバッファが最大長に達した場合の処理が定義されていませんでした。次に、reset メソッドが Keep-Alive 接続の2リクエスト目以降でも @read_proxy を true にリセットしてしまうため、HTTPリクエストが PROXY ヘッダーとして誤解析される可能性がありました。最後に、HTTP ボディ中に PROXY で始まる行が含まれると、正規表現がそれに一致してしまう問題がありました。
技術的な変更
PROXY_PROTOCOL_V1_MAX_LENGTH の導入と長さ制限
lib/puma/const.rb に PROXY_PROTOCOL_V1_MAX_LENGTH = 107 が追加されました。この定数が PROXY Protocol v1 ヘッダー行の上限バイト数を表し、長さチェックの基準値として使われます。
PROXY_PROTOCOL_V1_REGEX = /\APROXY (?:TCP4|TCP6|UNKNOWN) ([^\r]+)\r\n/.freeze
PROXY_PROTOCOL_V1_MAX_LENGTH = 107
正規表現も ^(行頭)から \A(文字列先頭)へ変更されました。^ は Ruby では改行後の行頭にもマッチするため、HTTP ボディ中に埋め込まれた PROXY 行に誤ってマッチする可能性がありました。\A への変更でバッファの先頭にのみマッチするようになり、ボディ内の偽PROXYラインを無視できます。
try_to_parse_proxy_protocol の再実装
try_to_parse_proxy_protocol メソッドは大幅に書き直されました。変更の核心は、CRLF の有無を先に確認し、その結果に応じて処理を分岐する構造への移行です。
変更前:
if @buffer.include? "\r\n"
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
else
return false
end
変更後:
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
return false
end
@read_proxy = 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
CRLF が未到着の場合、バッファの内容が "PROXY " の プレフィックスかどうか を "PROXY ".start_with?(@buffer) で確認します。これにより、"PRO" のような不完全な受信データを「まだ PROXY ヘッダーが来ている途中」と正しく判断できます。バッファが "PROXY " で始まっているが CRLF がなく最大長に達している場合は ConnectionError を発生させます。
@read_proxy = false に到達した後 return true を返す点(変更前は return @buffer.size > 0)も注目に値します。CRLF がないが PROXY でもない場合、バッファに内容があるかどうかによらず処理続行を示す true を返すことで、通常の HTTP リクエストを適切に後続のパース処理へ引き渡します。
Keep-Alive 接続での @read_proxy リセット修正
reset メソッドにおける @read_proxy の初期化ロジックが変更されました。
変更前:
@read_proxy = !!@expect_proxy_proto
変更後:
@read_proxy = !!@expect_proxy_proto && @requests_served.zero?
@requests_served.zero? の条件追加により、reset が呼ばれるのが同一接続の2リクエスト目以降であれば @read_proxy は false のままになります。PROXY Protocol ヘッダーは接続の先頭1回だけ送信されるため、Keep-Alive 接続の継続リクエストで PROXY パースを試みると、HTTPリクエストが誤って PROXY ヘッダーとして解析されるバグがありました。
テストケースの追加
test/test_puma_server.rb に4つのテストが追加されました:
-
test_proxy_protocol_rejects_line_without_crlf_at_max_length: 最大長に達しても CRLF のない PROXY 行を拒否する -
test_proxy_protocol_allows_non_proxy_requests_over_max_length: 最大長を超える通常 HTTP リクエストを許容する -
test_proxy_protocol_ignores_embedded_proxy_line_in_http_body: HTTP ボディ内の PROXY 行を無視する -
test_proxy_protocol_only_parses_first_request_on_connection: Keep-Alive 接続の最初のリクエストのみ PROXY パースを行う
設計判断
バッファの部分一致チェック に "PROXY ".start_with?(@buffer) という逆方向の start_with? を使う手法が採用されています。これは「受信途中のバッファが完全な "PROXY " プレフィックスになりうるか」を判定するエレガントな方法で、ストリーミング受信における状態遷移を明示的に扱っています。
PROXY_PROTOCOL_V1_MAX_LENGTH を定数として Const モジュールに切り出したことで、テストコードから Puma::Const::PROXY_PROTOCOL_V1_MAX_LENGTH を参照でき、仕様値をコードとテストで一元管理できています。実際、追加されたテストはこの定数を使って境界値テストを構成しており、仕様変更時に定数を変更するだけでテストも追従する設計になっています。
まとめ
今回の修正は、PROXY Protocol v1 パーサーの長さ制限・正規表現アンカー・Keep-Alive 対応という3つの独立したバグを一括修正したものです。特に @requests_served.zero? による1回限りのパース制御と \A アンカーへの変更は、プロトコルの仕様をコードに正確に反映するという観点で本質的な修正といえます。