PROXY Protocol v1 パーサーの複数バグ修正

puma/puma

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_proxytrue にリセットしてしまうため、HTTPリクエストが PROXY ヘッダーとして誤解析される可能性がありました。最後に、HTTP ボディ中に PROXY で始まる行が含まれると、正規表現がそれに一致してしまう問題がありました。

技術的な変更

PROXY_PROTOCOL_V1_MAX_LENGTH の導入と長さ制限

lib/puma/const.rbPROXY_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_proxyfalse のままになります。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 アンカーへの変更は、プロトコルの仕様をコードに正確に反映するという観点で本質的な修正といえます。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
73c00edc

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
リトライ後承認
Review Count:
2回 (改善を経て承認)
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

「総論→各論→結論」の構成が明確です。リード文での要旨提示、背景、技術詳細、そして単なる繰り返しではないまとめと、ガイドラインに完全に準拠しています。任意項目である「設計判断」も含まれており、構成は理想的です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライト(```ruby:ファイルパス)とGitHubのPRリンク記法([#3944](URL))が正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

Pumaの内部実装やPROXY Protocolに関する知識を持つエンジニアを対象としており、専門用語のレベルや説明の粒度が適切です。冗長な初心者向け解説はありません。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクションが総論→各論で構成され、各パラグラフはトピックセンテンスで始まっています。1段落1トピックが徹底されており、段落の長さも適切で非常に読みやすいです。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

`client.rb`と`const.rb`のコード変更、および追加されたテストケースについて、Diffの内容を正確に引用し解説しています。省略や改変はなく、完全に一致しています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

PROXY Protocol, Keep-Alive, CRLF, 正規表現アンカー(`\A`)など、関連する技術用語が正確かつ適切な文脈で使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

正規表現アンカーの変更理由、Keep-Alive接続でのリセットロジックの修正意図、バッファの部分一致チェックの仕組みなど、すべての技術的説明がDiffの内容と整合しており、論理的かつ正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

PRのDescriptionがない中で、Diffの内容のみを根拠として記事が構成されています。記事で言及されている3つの問題点や設計判断はすべてDiffから論理的に導出されており、ハルシネーションは見られません。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#3944)や定数値(`PROXY_PROTOCOL_V1_MAX_LENGTH = 107`)がソース情報と一致しており、正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトル「PROXY Protocol v1 パーサーの複数バグ修正」は、PRのDiff内容(PROXYプロトコルに関する複数の修正)を的確に要約しており、主題が完全に一致しています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

PR情報(タイトル「8.0.2 backport」)に基づかない外部知識の持ち込みはなく、記事の信頼性が高いです。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

PRが完了した変更であることを示す「〜が修正されました」といった過去形の表現が使われており、時間表現に歪曲はありません。