Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: TurboのAction Cableコネクションに潜む脆弱性を事前に修正しよう(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails: TurboのAction Cableコネクションに潜む脆弱性を事前に修正しよう(翻訳)

2020 年後半にTurbo 7とそのRails統合がturbo-railsとしてリリースされて以来、Action Cableはこれまで以上に利用しやすくなりました。わずか数行のコードを書くだけで、更新をWebSocket経由でクライアントにブロードキャストし、あらゆる種類のリアルタイム対話操作を手軽に構築できるようになりました。

しかしこの手軽さにはトレードオフが伴います。実に簡単に陥りやすいセキュリティ上の落とし穴が存在しており、WebSocketコネクションはデフォルトでは認証(authentication)や承認(authorization)チェックで保護されていません。また私の知る限り、開発者が独自のチェックを追加する方法についてはTurboのドキュメントで言及されていません。

問題自体について説明する前に、話を少し戻して、WebSocketコネクションがどのように行われるかを見てみましょう。

🔗 Turbo Streamsストリーミング用のWebSocketコネクションを作成する

Railsのビューでは、以下のようにActive ModelやActive Recordオブジェクトまたは文字列を渡してコネクションを確立し、チャネルをサブスクライブします。

<%= turbo_stream_from @conversation %>
<%= turbo_stream_from "my_channel" %>

上は以下のようにレンダリングされます。

<turbo-cable-stream-source
  channel="Turbo::StreamsChannel"
  signed-stream-name="...">
</turbo-cable-stream-source>

これで、このチャネルを以下のようにサーバーからブロードキャストできます。

Turbo::StreamsChannel.broadcast_action_to(
  @conversation,
  action: :append,
  target: dom_id(@conversation, :messages),
  partial: "messages/message"
)

署名済みストリーム名は、@conversationオブジェクトまたは文字列から生成されます。

🔗 ストリーム名を詳しく見る

ストリーム名は、to_gid_param または to_param に応答する限り、任意の型のオブジェクトで生成できます。したがって、通常はActive Recordオブジェクト、文字列、シンボルのどれかになります。

  • to_gid_paramはモデルのglobalidを生成し、それをBase64でエンコードします。
  • 文字列の場合、to_paramはオブジェクト自身を返します。
  • シンボルの場合、オブジェクトの文字列表現を返します。

rails/globalid - GitHub

Conversationオブジェクトのストリーム名は以下のようになります。

Z2lkOi8vcGlhenphL0NvbnZlcnNhdGlvbi8x

上記の値をBase64デコードすると、オブジェクトのグローバルIDが明らかになります。

>> stream = "Z2lkOi8vcGlhenphL0NvbnZlcnNhdGlvbi8x"
>> Base64.urlsafe_decode64(stream)

# => "gid://piazza/Conversation/1"

ストリーム名は MessageVerifierを通じて実行され、HTML属性でレンダリングされる署名済みストリーム名が取得されます。これにより、ストリーム名がクライアント上で改ざんされないことが保証されます。

署名済みストリーム名とチャネル名を使うことで、カスタム<turbo-cable-stream-source>要素はAction CableでWebSocketコネクションを確立し、目的のチャネルをサブスクライブします。

🔗 セキュリティ上の脆弱性

署名済みストリーム名自身は、アプリのsecret_key_baseで安全に署名されているため、簡単には偽装できません。

セキュリティ上の問題が発生するのは、悪意のあるユーザーが、アクセスすべきではないリソースの署名済みストリーム名を入手したときです。
これはさまざまな方法で発生する可能性がありますが、最も可能性の高いシナリオは、「リソースにいったんアクセスできたものの、そのアクセスが取り消された場合」です。
その後、追加のチェックが行われないまま、その署名済みストリーム名が使われる形で、そのチャネルにブロードキャストされたメッセージを引き続き受信します。

これをより深く理解するために、いくつかのコードを見てみましょう。以下は、すべてのWebSocketコネクションのエントリポイントであるデフォルトのApplicationCable::Connectionクラスです。

module ApplicationCable
  class Connection < ActionCable::Connection::Base
  end
end

ここには着信コネクションをチェックするロジックは存在していないので、あらゆるWebSocketコネクションを自動的に受け取ります。

次のステップでは、コネクションがチャネルへのサブスクライブを試みます。turbo-railsgem のデフォルトのTurbo::StreamsChannelは以下のようになります。

class Turbo::StreamsChannel < ActionCable::Channel::Base
  extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName
  include Turbo::Streams::StreamName::ClassMethods

  def subscribed
    if stream_name = verified_stream_name_from_params
      stream_from stream_name
    else
      reject
    end
  end
end

ここでは、ストリーム名が改ざんされていないことを確認するためのチェックは行われますが、その後サブスクリプションを受け入れる前に追加の承認チェックが行われていません。

アプリがブロードキャストするメッセージによっては、これは大きな問題ではないかもしれません。また、悪意のあるユーザーが必要なマークアップを取得するシナリオはめったにありません。しかし、万が一取得に成功してしまった場合、それを防ぐための緩和策はまったくありません。これは私を不安にさせる脆弱性です。

適切な認証と承認のチェックが行われていないコネクションは受け入れるべきではないと思います。これはHTTPだけでなくWebSocketにも当てはまります。

🔗 脆弱性を修正する

この問題は、コネクションの確立前に認証チェックを実行し、チャネルにサブスクライブするときに承認チェックを実行することで解決できます。

module ApplicationCable
  class Connection < ActionCable::Connection::Base
    identified_by :user

    def connect
      user_session = authenticate_session
      reject_unauthorized_connection unless user_session.present?

      self.user = user_session.user
    end

    private
      def authenticate_session
        session = cookies.encrypted[:_piazza_session]
        # ここに認証ロジックを書く
      end
end

上記のコードは、アプリの認証の設定方法によって若干異なります。
一般的な考え方は、HTTPリクエストと同様に、Cookieでユーザーを認証することです。このコンテキストではRails のsession変数は利用できないため、セッションCookieを手動で読み取る必要があります。

identified_byに渡されるパラメータはActiveSupport::CurrentAttributesに似ていますが、Action Cableコネクションのコンテキストで使われます。これらのパラメータでコネクションのグローバル属性を設定できます。

次に、以下のようにカスタムチャネルを作成して認証ロジックを追加します。

class ConversationsChannel < Turbo::StreamsChannel

  def subscribed
    if authorized?
      stream_from stream_name
    else
      reject
    end
  end

  private

    def stream_name
      @stream_name ||= verified_stream_name_from_params
    end

    def authorized?
      # self.userを認証する
    end
end

最後に、このチャネルをマークアップ内で以下のように指定する必要があります。

<%= turbo_stream_from @conversation, channel: ConversationsChannel %>

これで完了です。これで、すべてのWebSocketコネクションを受け入れる前に、認証と承認のチェックが実行されるようになります。

🔗 まとめ

RailsとTurboは、どちらもWebSocketメカニズムを汎用的な形でフレームワークに実装する必要があります。アプリの認証ロジックや承認ロジックの複雑さをフレームワーク側で事前に予測して、デフォルトの設定を提供する方法はありません。

ただし、この落とし穴がもっとわかりやすくなればと思います。
適切な状況では、かなり大きなセキュリティホールになる可能性があります。私がフリーランスの仕事で出会ったどのアプリにも、これらのチェックは行われていませんでした。

このような攻撃が発生する可能性は低いかもしれませんが、簡単に修正できるので、絶対に行う価値があると思います。アプリケーションのセキュリティは、誰もが真剣に取り組むべきだと私は信じています。

関連記事

secret_key_baseが漏れると何が起きるのか実際に試してみた


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。