🗄️

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_urlRack::Request が提供しているメソッド。どうやら Rack に原因がありそう。

https://github.com/DataDog/dd-trace-rb/blob/v2.9.0/lib/datadog/tracing/contrib/rack/middlewares.rb#L243-L252

不具合の再現

本例外の再現を行う。

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

app.rb
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 を使う場合はこんな感じ

Gemfile
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_ipv6nil に対して 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

原因の調査

検証で得られたバックトレースをたどってコードを読み、原因となったコードを特定する。

バックトレースをたどる

wrap_ipv6nil を受け取ったのが直接の例外の原因。このメソッドは受け取った引数にいきなり start_with? してるので、文字列しか受け取らない想定っぽい。

https://github.com/rack/rack/blob/v3.1.10/lib/rack/request.rb#L630-L642

wrap_ipv6nil を渡しているのが forwarded_authority

https://github.com/rack/rack/blob/v3.1.10/lib/rack/request.rb#L393-L408

401行目のチェックが問題で、 valuenil の時は素通りするが value が空文字だと if 節の中にはいる。 split_header に空文字を渡すと空配列を返すので、402行目の split_header(value).lastnil となり、wrap_ipv6 にそのまま nil が渡る。

空文字を falsy だと思ってコードを書くのって普段も結構やるミス…な気がする。 ifnil チェックしてるけど空文字の考慮ができてないということで、コード上の原因はここと見てよさそう。

同じ条件で例外を起こしうるメソッド

forwarded_authorityRack::Request の公開メソッドでどこからも使われる可能性がある。またこのクラス内から呼ばれてもいるため、以下のメソッドを使ったときにこの例外の発生がする可能性がある。

全部ではない。多分たどっていくともっとある。

Rails ではちょっと事情が違う

Rails 上ではちょっと事情が違い、X-Forwarded-Host が空の状態で問題のメソッドたちを呼んでも例外にならないことがある。

理由は、ActionController の中で request を見ようとするとだいたい Rack::Request ではなく ActionDispatch::Request を返すようになってて、 ActionDispatch::RequestRack::Request#forwarded_authority を使用したメソッドのいくつかをオーバーライドしているため。

なので ActionDispatch::Request がレシーバーなら request.base_url を呼んでも例外は発生しない。内部で使われる host_with_port がオーバーライドされているため。反対に hostname はオーバーライドされていないので例外になる。また、当たり前だけど Rack::Request のインスタンスを使っていると Rails 上でもどっちも例外になる。

Rack::Request だと例外になるが ActionDispatch::Request を使っていると例外にならない例

app_with_action_dispatch.rb
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 のメソッドをオーバーライドしているコードはこの辺り

ActionControllerrequest の実体がなんなのかは多分この辺り。

対処方法

Rack::Request::Helpers をモンキーパッチする。
split_headernil を渡されても [] を返すようになっているので、元のコードで実施していた get_header に対する nil チェックはなくてよさそう。

monkey_patch_to_rack.rb
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 を送った。

https://github.com/rack/rack/pull/2270

いくつかレビューコメントをいただいた。

  • 本当に Rack の挙動なのかを示すためのバックトレースを提供せよ(comment)
    • PR の概要の書き方が甘かったと反省
  • 余計な配列の確保を防ぐよう修正せよ(comment)
    • 基盤として動くライブラリなのでちょっとのメモリも節約してるんだなあと勉強になった
  • テストで返すべきでない値を返している(comment)
    • Rack の @env に入る値たちの仕様を理解していなかった

上記の修正を行ってマージされた。 3.2.0 以降のバージョンを使用することでこのエラーは防げる(はず。まだリリースされてない)

Rails アプリを開発していて常に依存してきた Rack にコントリビュートできて嬉しい

関連ドキュメントのリンク

Discussion