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
はオブジェクト自身を返します。 - シンボルの場合、オブジェクトの文字列表現を返します。
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-rails
gem のデフォルトの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メカニズムを汎用的な形でフレームワークに実装する必要があります。アプリの認証ロジックや承認ロジックの複雑さをフレームワーク側で事前に予測して、デフォルトの設定を提供する方法はありません。
ただし、この落とし穴がもっとわかりやすくなればと思います。
適切な状況では、かなり大きなセキュリティホールになる可能性があります。私がフリーランスの仕事で出会ったどのアプリにも、これらのチェックは行われていませんでした。
このような攻撃が発生する可能性は低いかもしれませんが、簡単に修正できるので、絶対に行う価値があると思います。アプリケーションのセキュリティは、誰もが真剣に取り組むべきだと私は信じています。
概要
原著者の許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。