From 1f2489d0c202d4b5c8c22d396ba00c3c525fc40e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicklas=20Ramho=CC=88j=20Holtryd?= Date: Tue, 22 Oct 2024 10:30:55 +0200 Subject: [PATCH] =?UTF-8?q?Allow=20the=20application=20to=20configure=20Tu?= =?UTF-8?q?rbo::StreamChannel=E2=80=99s=20inheritance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ApplicationCable::Connection` provides a simple and intuitive way to authenticate both custom ActionCable Channels and the `Turbo::Broadcastable` broadcasts made on `Turbo::StreamsChannel`. In multi-tenancy applications simply authenticating the user is often not enough as an evicted user could subscribe while being subscribed to another account. This PR allows an application to configure `Turbo::StreamsChannel` super class with the intention of allowing application specific authorization logic to be implemented in e.g `ApplicationCable::Channel`. `ApplicationCable::Channel` is generated by rails (new and action cable) but could have been removed. It’s also possible that `ApplicationCable::Channel` implements `authorized?` in a way that’s not meant to be used by `Turbo::StreamsChannel`. Do avoid this from being a breaking change `Turbo::StreamsChannel`’s super class defaults to `ActionCable::Channel::Base` (the current behavior). User can opt-in to inheriting from `ApplicationCable::Channel` (or any other Channel) with: ```rb config.turbo.base_stream_channel_class = "ApplicationCable::Channel" ``` Example of attacks possible in a multi-tenancy application when a Channel is streaming from a `Turbo::Broadcastable` compatible stream name: 1. A user uses the browser’s “Save as…” and sends the html file to a colleague or is tricked to send it directly to a bad actor. Since `turbo_stream_from` makes the signed stream name appear in the HTML the HTML needs to be treated as a secret similar to if a password or API token was present. 2. Someone with access to a shared stream can save the signed stream name. If the user is later removed from the account with the expectation to loose access to the shared stream they can sign up for another account and stream from there even with an authentication check in place. Some reasons why attacks are more likely / more severe. 1. Stealing a signed token name is could be more practical, easier and/or faster than e.g stealing cookies. 2. Web Socket connection and subscription logs might not be as well tracked as HTTP logs leading to breaches being harder to discover. 3. Less is published on Web Socket security than HTTP and awareness of the need to authenticate and authorize Web Sockets may be lower than that of HTTP. 4. Signed Stream Names never expire and could go unnoticed for a long time. Leaking an old stream name doesn’t have the same protection as e.g leaking an old password reset token has. I believe we should move towards making this the default as a way to encourage securing `Turbo::Broadcastable` broadcasts in the following steps: 1. This PR. No encouragements but a cleaner way and documented way to implement authorization. 2. Generate new rails applications with: `config.turbo.base_stream_channel_class = "ApplicationCable::Channel"` and include a commented-out authentication example in the `ApplicationCable::Connection` template and a commented-out authorization example in `ApplicationCable::Channel` template. 4. Change the default value of `base_stream_channel_class` to `"ApplicationCable::Channel"` and update rails new to generate the opposite for those wishing to opt-out. `config.turbo.base_stream_channel_class = "ActionCable::Channel::Base"` 5. Remove the configuration and hard code `ApplicationCable::Channel` as `Turbo::StreamsChannel`’s super class. --- README.md | 61 +++++++++++++++- app/channels/turbo/streams/locatable_name.rb | 17 +++++ app/channels/turbo/streams/stream_name.rb | 16 ++++- app/channels/turbo/streams_channel.rb | 45 +++++------- lib/turbo-rails.rb | 1 + lib/turbo/engine.rb | 6 ++ test/streams/streams_channel_test.rb | 74 ++++++++++++++++++++ 7 files changed, 191 insertions(+), 29 deletions(-) create mode 100644 app/channels/turbo/streams/locatable_name.rb diff --git a/README.md b/README.md index beeae82e..113f6264 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,65 @@ This gem provides a `turbo_stream_from` helper to create a turbo stream. <%# Rest of show here %> ``` +### Security + +#### Signed Stream Names + +Turbo stream names are cryptographically signed, which ensures that they cannot be guessed or altered. + +Stream names do not expire and are rendered into the HTML. If you're broadcasting private data, you should also authorize and/or authenticate subscriptions. + +#### Authentication + +It is recommended to authenticate connections in `ApplicationCable::Connection`. Without authentication, a leaked stream name could be used to subscribe without a valid application session. + +```rb +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :current_user + + def connect + self.current_user = find_verified_user + end + + private + def find_verified_user + if verified_session = Session.find_by(id: cookies.signed[:session_id]) + verified_session.user + else + reject_unauthorized_connection + end + end + end +end +``` + +#### Authorization + +In multi-tenant applications, it’s often crucial to authorize subscriptions. Without authorization, someone with prior access could continue to subscribe as another tenant. + +```rb +# config/application.rb + +config.turbo.base_stream_channel_class = "ApplicationCable::Channel" +``` + +This allows you to define domain-specific authorization logic that `Turbo::StreamsChannel` and any other channels inheriting from `ApplicationCable::Channel` will use." + +```rb +# app/channels/application_cable/channel.rb + +module ApplicationCable + class Channel < ActionCable::Channel::Base + private + + def authorized? + current_user.can_access? streamable + end + end +end +``` + ### Testing Turbo Stream Broadcasts Receiving server-generated Turbo Broadcasts requires a connected Web Socket. @@ -182,7 +241,7 @@ import "@hotwired/turbo-rails" You can watch [the video introduction to Hotwire](https://hotwired.dev/#screencast), which focuses extensively on demonstrating Turbo in a Rails demo. Then you should familiarize yourself with [Turbo handbook](https://turbo.hotwired.dev/handbook/introduction) to understand Drive, Frames, and Streams in-depth. Finally, dive into the code documentation by starting with [`Turbo::FramesHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/frames_helper.rb), [`Turbo::StreamsHelper`](https://github.com/hotwired/turbo-rails/blob/main/app/helpers/turbo/streams_helper.rb), [`Turbo::Streams::TagBuilder`](https://github.com/hotwired/turbo-rails/blob/main/app/models/turbo/streams/tag_builder.rb), and [`Turbo::Broadcastable`](https://github.com/hotwired/turbo-rails/blob/main/app/models/concerns/turbo/broadcastable.rb). -Note that in development, the default Action Cable adapter is the single-process `async` adapter. This means that turbo updates are only broadcast within that same process. So you can't start `bin/rails console` and trigger Turbo broadcasts and expect them to show up in a browser connected to a server running in a separate `bin/dev` or `bin/rails server` process. Instead, you should use the web-console when needing to manaually trigger Turbo broadcasts inside the same process. Add "console" to any action or "<%= console %>" in any view to make the web console appear. +Note that in development, the default Action Cable adapter is the single-process `async` adapter. This means that turbo updates are only broadcast within that same process. So you can't start `bin/rails console` and trigger Turbo broadcasts and expect them to show up in a browser connected to a server running in a separate `bin/dev` or `bin/rails server` process. Instead, you should use the web-console when needing to manaually trigger Turbo broadcasts inside the same process. Add "console" to any action or "<%= console %>" in any view to make the web console appear. ### RubyDoc Documentation diff --git a/app/channels/turbo/streams/locatable_name.rb b/app/channels/turbo/streams/locatable_name.rb new file mode 100644 index 00000000..1c438aef --- /dev/null +++ b/app/channels/turbo/streams/locatable_name.rb @@ -0,0 +1,17 @@ +# When streaming from a model instance using turbo_stream_from @post, it can be useful to locate the instance +# in config.turbo.base_stream_channel_class. These helper methods are available as a convenience for applications +# to implement custom logic such as authorization. +module Turbo::Streams::LocatableName + # Locate a single streamable. Useful when subscribing with turbo_stream_from @post. It can be used e.g to + # implement application-specific authorization, ex: current_user.can_access? locate_streamable + def locate_streamable + @locate_streamable ||= GlobalID::Locator.locate(verified_stream_name_from_params) + end + + # Locate multiple streamables. Useful when subscribing with turbo_stream_from @post1, @post2. It can be + # used e.g to implement application-specific authorization, ex: + # locate_streamables.present? && locate_streamables.all? { |streamable| current_user.can_access?(streamable) } + def locate_streamables + @locate_streamables ||= GlobalID::Locator.locate_many(verified_stream_name_parts_from_params) + end +end diff --git a/app/channels/turbo/streams/stream_name.rb b/app/channels/turbo/streams/stream_name.rb index 37d80b18..3b3be957 100644 --- a/app/channels/turbo/streams/stream_name.rb +++ b/app/channels/turbo/streams/stream_name.rb @@ -2,7 +2,12 @@ # Turbo::StreamsChannel, but each with their own subscription. Since stream names are exposed directly to the user # via the HTML stream subscription tags, we need to ensure that the name isn't tampered with, so the names are signed # upon generation and verified upon receipt. All verification happens through the Turbo.signed_stream_verifier. +# +# Signed stream names do not expire. To prevent unauthorized access through leaked stream names it is recommended to +# authorize subscriptions and/or authenticate connections based on your needs. module Turbo::Streams::StreamName + STREAMABLE_SEPARATOR = ":" + # Used by Turbo::StreamsChannel to verify a signed stream name. def verified_stream_name(signed_stream_name) Turbo.signed_stream_verifier.verified signed_stream_name @@ -14,16 +19,23 @@ def signed_stream_name(streamables) end module ClassMethods - # Can be used by custom turbo stream channels to obtain signed stream name from params + # Can be used by config.turbo.base_stream_channel_class or a custom channel to obtain signed stream name + # from params. def verified_stream_name_from_params self.class.verified_stream_name(params[:signed_stream_name]) end + + # Can be used by config.turbo.base_stream_channel_class or a custom channel to obtain signed stream name + # parts from params. + def verified_stream_name_parts_from_params + verified_stream_name_from_params.split STREAMABLE_SEPARATOR + end end private def stream_name_from(streamables) if streamables.is_a?(Array) - streamables.map { |streamable| stream_name_from(streamable) }.join(":") + streamables.map { |streamable| stream_name_from(streamable) }.join(STREAMABLE_SEPARATOR) else streamables.then { |streamable| streamable.try(:to_gid_param) || streamable.to_param } end diff --git a/app/channels/turbo/streams_channel.rb b/app/channels/turbo/streams_channel.rb index adb614b4..f174f021 100644 --- a/app/channels/turbo/streams_channel.rb +++ b/app/channels/turbo/streams_channel.rb @@ -5,41 +5,34 @@ # using the view helper Turbo::StreamsHelper#turbo_stream_from(*streamables). # If the signed stream name cannot be verified, the subscription is rejected. # -# In case if custom behavior is desired, one can create their own channel and re-use some of the primitives from -# helper modules like Turbo::Streams::StreamName: +# Stream names may leak, which is why it's highly recommended to authenticate your connections and authorize your subscriptions. +# See the README for more details. # -# class CustomChannel < 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).present? && -# subscription_allowed? -# stream_from stream_name -# else -# reject -# end -# end +# Subscribe to custom channels by passing the :channel option to turbo_stream_from: +# <%= turbo_stream_from "room", channel: CustomChannel %> # -# def subscription_allowed? -# # ... -# end -# end -# -# This channel can be connected to a web page using :channel option in -# turbo_stream_from helper: -# -# <%= turbo_stream_from 'room', channel: CustomChannel %> -# -class Turbo::StreamsChannel < ActionCable::Channel::Base +# Any channel that listens to a Turbo::Broadcastable-compatible stream name (e.g., verified_stream_name_from_params) +# can also be subscribed to via Turbo::StreamsChannel. Never use the turbo_stream_from :channel option +# to implement authorization. +class Turbo::StreamsChannel < Turbo.base_stream_channel_class.constantize extend Turbo::Streams::Broadcasts, Turbo::Streams::StreamName - include Turbo::Streams::StreamName::ClassMethods + include Turbo::Streams::LocatableName, Turbo::Streams::StreamName::ClassMethods def subscribed - if stream_name = verified_stream_name_from_params + if (stream_name = verified_stream_name_from_params) && authorized? stream_from stream_name else reject end end + + private + # Override this method to define custom authorization rules in config.turbo.base_stream_channel_class. + # Refer to Turbo::Streams::LocatableName for details on locating streamables. + # + # By default, no authorization is performed. + def authorized? + defined?(super) ? super : true + end end diff --git a/lib/turbo-rails.rb b/lib/turbo-rails.rb index ee81e814..50beca2e 100644 --- a/lib/turbo-rails.rb +++ b/lib/turbo-rails.rb @@ -5,6 +5,7 @@ module Turbo extend ActiveSupport::Autoload mattr_accessor :draw_routes, default: true + mattr_accessor :base_stream_channel_class, default: "ActionCable::Channel::Base" thread_mattr_accessor :current_request_id diff --git a/lib/turbo/engine.rb b/lib/turbo/engine.rb index ecfd82e0..19250fe0 100644 --- a/lib/turbo/engine.rb +++ b/lib/turbo/engine.rb @@ -78,6 +78,12 @@ class Engine < Rails::Engine end end + initializer "turbo.configure" do |app| + if base_class = app.config.turbo&.base_stream_channel_class + Turbo.base_stream_channel_class = base_class + end + end + initializer "turbo.helpers", before: :load_config_initializers do ActiveSupport.on_load(:action_controller_base) do include Turbo::Streams::TurboStreamsTagBuilder, Turbo::Frames::FrameRequest, Turbo::Native::Navigation diff --git a/test/streams/streams_channel_test.rb b/test/streams/streams_channel_test.rb index 63ae7af2..103c2d24 100644 --- a/test/streams/streams_channel_test.rb +++ b/test/streams/streams_channel_test.rb @@ -337,4 +337,78 @@ class Turbo::StreamsChannelTest < ActionCable::Channel::TestCase end end end + + test "confirms subscription when unauthenticated by default" do + subscribe signed_stream_name: Turbo.signed_stream_verifier.generate("stream") + + assert subscription.confirmed? + assert_has_stream "stream" + end + + test "confirms subscription when succeeding authorization" do + authorizing do |record| + Turbo::StreamsChannel.define_method(:authorized?) { locate_streamable == record } + subscribe signed_stream_name: signed_stream_name(record) + + assert subscription.confirmed? + assert_has_stream record.to_gid_param + end + end + + test "rejects subscription when failing authorization" do + authorizing do |record| + Turbo::StreamsChannel.define_method(:authorized?) { locate_streamable != record } + subscribe signed_stream_name: signed_stream_name(record) + + assert subscription.rejected? + assert_no_streams + end + end + + test "locates single streamable" do + with_record do |record| + subscribe signed_stream_name: signed_stream_name(record) + assert_equal record, subscription.locate_streamable + end + end + + test "raises if streamable can't be found" do + with_record do |record| + subscribe signed_stream_name: signed_stream_name(record) + record.destroy + + assert_raises(ActiveRecord::RecordNotFound) { subscription.locate_streamable } + end + end + + test "locates multiple streamables" do + with_record do |record1, record2| + subscribe signed_stream_name: signed_stream_name([ record1, record2 ]) + assert_equal [ record1, record2 ], subscription.locate_streamables + end + end + + test "raises unless all streamables can be found" do + with_record do |record1, record2| + subscribe signed_stream_name: signed_stream_name([ record1, record2 ]) + record1.destroy + + assert_raises(ActiveRecord::RecordNotFound) { subscription.locate_streamables } + end + end + + private + def authorizing + original_authorized = Turbo::StreamsChannel.instance_method(:authorized?) + with_record { |record| yield record } + ensure + Turbo::StreamsChannel.define_method :authorized?, original_authorized + end + + def with_record(&block) + recordings = block.arity.times.map { Message.create! } + yield *recordings + ensure + recordings.each(&:destroy) + end end