Rack サーバに X-Forwaded-Host を空でリクエストすると例外になる(ので Rack に PR を送った)
こんにちは!ツクリンクエンジニアの oieioi です。
先日、Rack サーバに X-Forwaded-Host
を空でリクエストすると例外になる不具合を見つけました。Rack の該当箇所のコードを読んで不具合の内容を特定できたので、不具合を修正する PR を Rack に送ったらマージされました!嬉しかったのでぜひとも紹介させてください!
不具合の内容
Rack v3.1.10 以下を使っている Web サーバに、 X-Forwarded-Host
ヘッダが空となるリクエストを発行し、 その処理の中で Rack::Request#forwarded_authority
が呼ばれると、 Rack が以下のような例外になる。
#<NoMethodError: undefined method `start_with?' for nil:NilClass>
Rack は Web サーバ( Puma など)と Web アプリケーションフレームワーク( Rails など)の間にインタフェースを提供するライブラリ。 Rails が依存しているライブラリ の一つなので Rails 使ってる場合まず使うことになる。
Datadog などの監視系ツールは Rack に直接フックしたりするので、そういったツールがこの不具合を踏むと、 Rails アプリケーションとしては成功しているのにサーバとしては失敗を返すことになったりする。
発端
一部の機能でリバースプロキシ的な挙動をする Remix サーバ が、バックに存在する Rails サーバにリクエストを送った時だけで発生するエラーがあった。色々調べた結果、Datadog の dd-trace と、 X-Forwarded-Host
が空となるヘッダの組み合わせによって起きていることがわかった。
dd-trace の問題の箇所は以下。 request_obj.base_url
でエラーが発生する。 base_url
は Rack::Request
が提供しているメソッド。どうやら Rack に原因がありそう。
不具合の再現
本例外の再現を行う。
rack サーバを準備
Rack 3.1.10 + puma で動くアプリケーションサーバを用意する。dd-trace で例外になっていた Rack::Request#base_url
をコールする。
この辺の検証コードの書き方は Rack に PR を作ったときにレビューコメントで教えてもらった。Thanks to @dentarg 🙏
https://github.com/rack/rack/pull/2270#issuecomment-2562884804
https://github.com/rack/rack/pull/2270#issuecomment-2563564863
require 'rack'
app { |env|
req = ::Rack::Request.new(env)
pp req.env['HTTP_X_FORWARDED_HOST']
pp req.env['HTTP_X_FORWARDED_HOST'].class
begin
pp req.base_url
[200, {}, ['OK']]
rescue => e
pp e
pp e.backtrace
[500, {}, ['ERROR']]
end
}
起動。 puma をインストールしておく必要がある。
$ puma --config app.rb --log-requests --port 8888
bundler を使う場合はこんな感じ
source 'https://rubygems.org'
gem 'rack', '3.1.10'
gem 'puma'
X-Forwarded-Host
を空にしたリクエストを発行する
上で起動したサーバに X-Forwarded-Host
が空のリクエストを発行する。再現できた!
注意事項として curl
は通常空のヘッダを自動で除去するので、 <ヘッダ名>;
のようにセミコロンで終わらせる特別な指定方法をする必要がある。cf. man curl
$ curl --verbose --header 'x-forwarded-host;' localhost:8888
* Trying 127.0.0.1:8888...
* Connected to localhost (127.0.0.1) port 8888 (#0)
> GET / HTTP/1.1
> Host: localhost:8888
> User-Agent: curl/8.1.2
> Accept: */*
> x-forwarded-host:
>
< HTTP/1.1 500 Internal Server Error
< Content-Length: 5
<
* Connection #0 to host localhost left intact
ERROR%
下の例だと X-Forwarded-Host
ヘッダは自動で除去されてサーバに届かない。
# これだと再現しないので注意!
$ curl --verbose --header 'x-forwarded-host:' localhost:8888
* Trying 127.0.0.1:8888...
* Connected to localhost (127.0.0.1) port 8888 (#0)
> GET / HTTP/1.1
> Host: localhost:8888
> User-Agent: curl/8.1.2
> Accept: */*
>
< HTTP/1.1 200 OK
< Content-Length: 2
<
* Connection #0 to host localhost left intact
OK%
例外時のログ
例外発生時のサーバログ。 X-Forwarded-Host
の値が ""
空文字になり、 Rack::Request::Helpers#wrap_ipv6
が nil
に対して start_with?
を実行し例外になっていることが確認できた。
""
String
"undefined method 'start_with?' for nil"
["/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/rack-3.1.10/lib/rack/request.rb:637:in 'Rack::Request::Helpers#wrap_ipv6'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/rack-3.1.10/lib/rack/request.rb:402:in 'block in Rack::Request::Helpers#forwarded_authority'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/rack-3.1.10/lib/rack/request.rb:394:in 'Array#each'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/rack-3.1.10/lib/rack/request.rb:394:in 'Rack::Request::Helpers#forwarded_authority'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/rack-3.1.10/lib/rack/request.rb:267:in 'Rack::Request::Helpers#authority'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/rack-3.1.10/lib/rack/request.rb:322:in 'Rack::Request::Helpers#host_with_port'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/rack-3.1.10/lib/rack/request.rb:591:in 'Rack::Request::Helpers#base_url'",
"app.rb:17:in 'block in Puma::DSL#_load_from'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/puma-6.6.0/lib/puma/commonlogger.rb:47:in 'Puma::CommonLogger#call'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/puma-6.6.0/lib/puma/configuration.rb:279:in 'Puma::Configuration::ConfigMiddleware#call'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/puma-6.6.0/lib/puma/request.rb:99:in 'block in Puma::Request#handle_request'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/puma-6.6.0/lib/puma/thread_pool.rb:390:in 'Puma::ThreadPool#with_force_shutdown'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/puma-6.6.0/lib/puma/request.rb:98:in 'Puma::Request#handle_request'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/puma-6.6.0/lib/puma/server.rb:472:in 'Puma::Server#process_client'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/puma-6.6.0/lib/puma/server.rb:254:in 'block in Puma::Server#run'",
"/Users/oieioi/Desktop/rack-app/vendor/ruby/3.4.0/gems/puma-6.6.0/lib/puma/thread_pool.rb:167:in 'block in Puma::ThreadPool#spawn_thread'"]
127.0.0.1 - - [28/Feb/2025:14:12:44 +0900] "GET / HTTP/1.1" 500 - 0.0176
原因の調査
検証で得られたバックトレースをたどってコードを読み、原因となったコードを特定する。
バックトレースをたどる
- Rack::Request::Helpers#wrap_ipv6
- <- Rack::Request::Helpers#forwarded_authority
- <- Rack::Request::Helpers#authority
- <- Rack::Request::Helpers#host_with_port
- <- Rack::Request::Helpers#base_url
- <- app.rb
wrap_ipv6
が nil
を受け取ったのが直接の例外の原因。このメソッドは受け取った引数にいきなり start_with?
してるので、文字列しか受け取らない想定っぽい。
wrap_ipv6
に nil
を渡しているのが forwarded_authority
。
401行目のチェックが問題で、 value
が nil
の時は素通りするが value
が空文字だと if
節の中にはいる。 split_header
に空文字を渡すと空配列を返すので、402行目の split_header(value).last
が nil
となり、wrap_ipv6
にそのまま nil
が渡る。
空文字を falsy だと思ってコードを書くのって普段も結構やるミス…な気がする。 if
で nil
チェックしてるけど空文字の考慮ができてないということで、コード上の原因はここと見てよさそう。
同じ条件で例外を起こしうるメソッド
forwarded_authority
は Rack::Request
の公開メソッドでどこからも使われる可能性がある。またこのクラス内から呼ばれてもいるため、以下のメソッドを使ったときにこの例外の発生がする可能性がある。
全部ではない。多分たどっていくともっとある。
Rails ではちょっと事情が違う
Rails 上ではちょっと事情が違い、X-Forwarded-Host
が空の状態で問題のメソッドたちを呼んでも例外にならないことがある。
理由は、ActionController
の中で request
を見ようとするとだいたい Rack::Request
ではなく ActionDispatch::Request
を返すようになってて、 ActionDispatch::Request
は Rack::Request#forwarded_authority
を使用したメソッドのいくつかをオーバーライドしているため。
なので ActionDispatch::Request
がレシーバーなら request.base_url
を呼んでも例外は発生しない。内部で使われる host_with_port
がオーバーライドされているため。反対に hostname
はオーバーライドされていないので例外になる。また、当たり前だけど Rack::Request
のインスタンスを使っていると Rails 上でもどっちも例外になる。
Rack::Request
だと例外になるが ActionDispatch::Request
を使っていると例外にならない例
require 'rack'
require 'active_support'
require 'action_dispatch'
app { |env|
rack_req = ::Rack::Request.new(env)
ad_req = ::ActionDispatch::Request.new(env)
begin
# X-Forwarded-Host が空でも ActionDispatch::Request なら例外にならない
pp ad_req.host_with_port
pp ad_req.base_url
# Rack::Requestだと例外になる
pp rack_req.base_url
[200, {}, ['OK']]
rescue => e
pp e.full_message
[500, {}, ['ERROR']]
end
}
Rack::Request::Helpers
のメソッドをオーバーライドしているコードはこの辺り
ActionController
の request
の実体がなんなのかは多分この辺り。
対処方法
Rack::Request::Helpers
をモンキーパッチする。
split_header
は nil
を渡されても []
を返すようになっているので、元のコードで実施していた get_header
に対する nil
チェックはなくてよさそう。
module Rack
class Request
module Helpers
def forwarded_authority
forwarded_priority.each do |type|
case type
when :forwarded
if forwarded = get_http_forwarded(:host)
return forwarded.last
end
when :x_forwarded
# 変更はこの行だけ
if x_forwarded = split_header(get_header(HTTP_X_FORWARDED_HOST)).last
return wrap_ipv6(x_forwarded)
end
end
end
nil
end
end
end
end
rack/rack に PR 送った
X-Forwarded-Host
を空にしてリクエストするのはやろうと思わないとなかなかできないし、このヘッダはリバースプロキシが後ろにあるサーバにフォワードするときにつけるやつで一般のブラウザから使うことはまずないので、クライアントの問題ではあるのかなと思いつつ、アプリケーション側では防ぎようがないのでやっぱり Rack の不具合だろうと PR を送った。
いくつかレビューコメントをいただいた。
- 本当に Rack の挙動なのかを示すためのバックトレースを提供せよ(comment)
- PR の概要の書き方が甘かったと反省
- 余計な配列の確保を防ぐよう修正せよ(comment)
- 基盤として動くライブラリなのでちょっとのメモリも節約してるんだなあと勉強になった
- テストで返すべきでない値を返している(comment)
- Rack の
@env
に入る値たちの仕様を理解していなかった
- Rack の
上記の修正を行ってマージされた。 3.2.0
以降のバージョンを使用することでこのエラーは防げる(はず。まだリリースされてない)
Rails アプリを開発していて常に依存してきた Rack にコントリビュートできて嬉しい
Discussion