2020 September 8th, Tuesday

ELBを経由したリクエストでCSRF対策エラーが起こったのでデバッグと解決まで

RailsrackAWSCSRF

背景

AWS で Proxy ELB -> Nginx -> ELB -> Taget Group -> ECS でリクエストを飛ばして Rails のサービスを動かしたところ、 CSRF トークン対策でエラーになったのでそのデバッグと解決策までの道のり。

CSRF トークン対策でエラーになる

エラー概要

起こっていたエラーはActionController::InvalidAuthenticityToken

CSRF トークン対策とは

https://railsguides.jp/security.html#クロスサイトリクエストフォージェリ-csrf

Rails が標準搭載しているセキュリティ対策です。 セッションに保存されてる token と POST 時の authencity_token が一致しているかを検証し、一致していない場合にエラーを吐く。

解決策

nginx.conf にproxy_set_header X-Forwarded-SSL on;を追加する。

# nginx.conf
# もっと本当は書いてあるけど省略
server {
  listen       80;
  server_name  hoge.jp;

  proxy_set_header Host $host;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
  proxy_set_header X-Forwarded-Proto https;
  # これを追加する
  proxy_set_header X-Forwarded-SSL on;
}

エラー検証

token が異なっている?

セッションに保存されてる token と POST 時の authencity_token が一致しているかを検証し、一致していない場合にエラーを吐く

ならセッションに保存されている token とauthencity_tokenが異なっているのか、通常の操作でそうなることがあるのだろうか、ということで実際に検証している Rails のコードをみてみた。

# rails/actionpack/lib/action_controller/metal/request_forgery_protection.rb
def verified_request? # :doc:
!protect_against_forgery? || request.get? || request.head? ||
  (valid_request_origin? && any_authenticity_token_valid?)
end

https://github.com/rails/rails/blob/98a4c0c76938e46009cca668da9c3b584a9e9e74/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L289-L292

この verified_request? が false の時に InvalidAuthenticityToken のエラーが投げられる。 token が異なるということは any_authenticity_token_valid? が false ということになるので、その予想でデバッグをしてみた。が、any_authenticity_token_valid? は true だった。

valid_request_origin? が false になっている?

上のコードを見ると、valid_request_origin? が false の時にもverified_request?が false になる可能性があるので、確認してみた。 すると確かに、valid_request_origin? が false だった。

valid_request_origin?の中身を見てみる。

# rails/actionpack/lib/action_controller/metal/request_forgery_protection.rb
def valid_request_origin? # :doc:
    if forgery_protection_origin_check
      # We accept blank origin headers because some user agents don't send it.
      raise InvalidAuthenticityToken, NULL_ORIGIN_MESSAGE if request.origin == "null"
      request.origin.nil? || request.origin == request.base_url
    else
      true
    end
end

https://github.com/rails/rails/blob/98a4c0c76938e46009cca668da9c3b584a9e9e74/actionpack/lib/action_controller/metal/request_forgery_protection.rb#L455-L463

valid_request_origin? が false になるには request.originrequest.base_url の中身がわかれば理由が分りそうなので出力してみた。 するとrequest.originhttps://〜 なのに対し request.base_urlhttp://〜 となっていた。

つまり上のコードの request.origin == request.base_url の検証部分で false になっていることがわかった。

Nginx の conf で X-Forwarded-Proto を設定する?

この時点でいろいろ調べると、「Nginx から Rails にリクエストが渡される時に HTTPS で Nginx にアクセスしても HTTP として Rails に渡されてしまうらしく、これを防ぐために Nginx の conf で X-Forwarded-Protoを使って Rails に HTTPS であることを知らせる」、という方法がすぐ出てくる。やってみた。

# nginx.conf
# もっと本当は書いてあるけど省略
server {
    listen       80;
    server_name  hoge.jp;

    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    # これを追加
    proxy_set_header X-Forwarded-Proto https;
}

けどダメだった。 試しに Rails でリクエストヘッダを出力してみると "X-Forwarded-Proto": "http"となっていた。

どこかで https から http に上書きされている?

その通りで、これは ELB の性質上でした。

https://docs.aws.amazon.com/ja_jp/elasticloadbalancing/latest/userguide/how-elastic-load-balancing-works.html

image.png

今回、Proxy となる ELB から動かしている Rails のサービスに紐づく ELB に対してリクエストが送られてくるが、ここは HTTP で送られてくる。

Application Load Balancer および クラシックロードバランサー は、クライアントに返信する応答のプロキシの後のクライアントの入力リクエストからの接続ヘッダーを優先します

とのことで、Nginx から ELB 間の HTTP 通信が優先されてリクエストヘッダの X-Forwarded-ForX-Forwarded-ProtoX-Forwarded-Portが書き換えられてしまっていた。

じゃあどうする

request オブジェクトは Rack で作られているらしいのでそこのコードを見てみた。

# rack/lib/rack/request.rb
def scheme
if get_header(HTTPS) == 'on'
  'https'
elsif get_header(HTTP_X_FORWARDED_SSL) == 'on'
  'https'
elsif forwarded_scheme
  forwarded_scheme
    else
      get_header(RACK_URL_SCHEME)
    end
end

# 省略

def base_url
    "#{scheme}://#{host_with_port}"
end

https://github.com/rack/rack/blob/649c72bab9e7b50d657b5b432d0c205c95c2be07/lib/rack/request.rb

base_url の作られ方から、 schemehttps になれば良い。 schemahttps になるにはいくつか条件があるけれど 、今回はget_header(HTTP_X_FORWARDED_SSL) == 'on' になるようにすればいけそう! (HTTP_X_FORWARDED_SSL は ELB に書き換えられる心配もない) ということで、Nginx のリクエストヘッダに X_Forwarded_SSL を追加してみた。

# nginx.conf
# もっと本当は書いてあるけど省略
server {
    listen       80;
    server_name  hoge.jp;

    proxy_set_header Host $host;
    # この下2つはELBに書き換えられちゃう
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto https;
    # これを追加する
    proxy_set_header X-Forwarded-SSL on;
}

結果

エラーが出なくなった! デバッグしてみたらちゃんとリクエストヘッダにX_Forwarded_SSLが追加されて schemehttpsになっていた。 image.png