デュアルスタックソケット接続時の `REMOTE_ADDR` にIPv4アドレスを正しく表示する
デュアルスタックIPv6ソケット経由でIPv4クライアントが接続した際に REMOTE_ADDR や CommonLogger のログに表示されていた ::ffff:127.0.0.1 形式のアドレスを、元のIPv4形式(127.0.0.1)に変換して返すよう修正されました。
背景
この問題は #3847 によるデフォルトバインドアドレスの変更に起因しています。#3847 では、Pumaのデフォルトバインドアドレスを 0.0.0.0(IPv4)から ::(IPv6)へ変更しました。:: はデュアルスタックソケットを開き、IPv4とIPv6の両方のクライアントを受け付けます。
デュアルスタックソケットにIPv4クライアントが接続した場合、OSはその接続を IPv4マップドIPv6アドレス(IPv4-mapped IPv6 address)として表現します。これは ::ffff: というプレフィックスにIPv4アドレスを付加した形式(例: ::ffff:127.0.0.1)です。Pumaは peeraddr からこのアドレスをそのまま取得していたため、REMOTE_ADDR や CommonLogger のログに ::ffff:127.0.0.1 が表示されていました。
IPアドレスが変わったことで、IPベースのアクセス制御やログ分析ツールが想定外の形式のアドレスを受け取る可能性があり、既存の設定との互換性が損なわれる問題でした。
技術的な変更
lib/puma/client.rb の peerip メソッドで @io.peeraddr.last を直接参照していた箇所を、新たに追加した socket_peerip メソッドの呼び出しに置き換えました。
変更前:
def peerip
return @peerip if @peerip
if @remote_addr_header
hdr = (@env[@remote_addr_header] || @io.peeraddr.last).split(/[\s,]/).first
@peerip = hdr
return hdr
end
@peerip ||= @io.peeraddr.last
end
変更後:
def peerip
return @peerip if @peerip
if @remote_addr_header
hdr = (@env[@remote_addr_header] || socket_peerip).split(/[\s,]/).first
@peerip = hdr
return hdr
end
@peerip ||= socket_peerip
end
socket_peerip と unmap_ipv6 の2つのプライベートメソッドが追加されています。
IPV4_MAPPED_IPV6_PREFIX = "::ffff:"
private_constant :IPV4_MAPPED_IPV6_PREFIX
def socket_peerip
unmap_ipv6(@io.peeraddr.last)
end
# Converts IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) back to
# their IPv4 form. These addresses appear when IPv4 clients connect to
# a dual-stack IPv6 socket.
def unmap_ipv6(addr)
addr.delete_prefix(IPV4_MAPPED_IPV6_PREFIX)
end
変換ロジックは String#delete_prefix を使って ::ffff: プレフィックスを取り除くだけのシンプルな実装です。プレフィックスが存在しない場合(純粋なIPv4アドレスやネイティブIPv6アドレス)はそのまま返されるため、既存の動作には影響がありません。
テストは test/test_request_single.rb に4つのケースが追加されています。
-
test_peerip_unmaps_ipv4_mapped_ipv6:::ffff:127.0.0.1→127.0.0.1に変換されること -
test_remote_addr_header_fallback_unmaps_ipv4_mapped_ipv6:@remote_addr_headerフォールバック時にも変換が適用されること -
test_peerip_preserves_plain_ipv4: 通常のIPv4アドレスが変換されないこと -
test_peerip_preserves_native_ipv6: ネイティブIPv6アドレス(::1など)が変換されないこと
設計判断
変換ロジックを peerip メソッド内に直接書くのではなく、socket_peerip → unmap_ipv6 の2段階のプライベートメソッドに分離しました。これにより、peerip メソッドの呼び出し元が @remote_addr_header を使うフォールバックパスと通常パスの両方で同じ変換が適用されます。重複なく一貫した挙動を保証する設計です。
また、プレフィックス定数 IPV4_MAPPED_IPV6_PREFIX に private_constant を付与して外部からのアクセスを禁止しています。実装の詳細を公開インターフェースから隠蔽し、将来の変更の自由度を保つ判断です。
変換の判定に正規表現やアドレスパースを使わず、delete_prefix による文字列マッチングのみとしているのも注目点です。::ffff: は標準化されたプレフィックスであり、追加の検証なしに前方一致で除去するだけで正確に動作します。
まとめ
デフォルトバインドアドレスの :: への変更(#3847)で生じた副作用を、ソケット層でのIPv4マップドIPv6アドレスの変換によって修正した変更です。変換ロジックを peerip の参照箇所に集約することで、ヘッダーフォールバックを含むすべてのパスで一貫した動作を保証しつつ、既存のIPv4・IPv6クライアントへの影響を排除しています。