Skip to content

Commit

Permalink
Allow the application to configure Turbo::StreamChannel’s inheritance
Browse files Browse the repository at this point in the history
`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.
  • Loading branch information
ramhoj committed Oct 23, 2024
1 parent 52727cb commit 1f2489d
Show file tree
Hide file tree
Showing 7 changed files with 191 additions and 29 deletions.
61 changes: 60 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down
17 changes: 17 additions & 0 deletions app/channels/turbo/streams/locatable_name.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# When streaming from a model instance using <tt>turbo_stream_from @post</tt>, it can be useful to locate the instance
# in <tt>config.turbo.base_stream_channel_class</tt>. 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 <tt>turbo_stream_from @post</tt>. It can be used e.g to
# implement application-specific authorization, ex: <tt>current_user.can_access? locate_streamable</tt>
def locate_streamable
@locate_streamable ||= GlobalID::Locator.locate(verified_stream_name_from_params)
end

# Locate multiple streamables. Useful when subscribing with <tt>turbo_stream_from @post1, @post2</tt>. It can be
# used e.g to implement application-specific authorization, ex:
# <tt>locate_streamables.present? && locate_streamables.all? { |streamable| current_user.can_access?(streamable) }</tt>
def locate_streamables
@locate_streamables ||= GlobalID::Locator.locate_many(verified_stream_name_parts_from_params)
end
end
16 changes: 14 additions & 2 deletions app/channels/turbo/streams/stream_name.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
# <tt>Turbo::StreamsChannel</tt>, 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 <tt>Turbo.signed_stream_verifier</tt>.
#
# 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 <tt>Turbo::StreamsChannel</tt> to verify a signed stream name.
def verified_stream_name(signed_stream_name)
Turbo.signed_stream_verifier.verified signed_stream_name
Expand All @@ -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 <tt>params</tt>
# Can be used by <tt>config.turbo.base_stream_channel_class</tt> or a custom channel to obtain signed stream name
# from <tt>params</tt>.
def verified_stream_name_from_params
self.class.verified_stream_name(params[:signed_stream_name])
end

# Can be used by <tt>config.turbo.base_stream_channel_class</tt> or a custom channel to obtain signed stream name
# parts from <tt>params</tt>.
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
Expand Down
45 changes: 19 additions & 26 deletions app/channels/turbo/streams_channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,34 @@
# using the view helper <tt>Turbo::StreamsHelper#turbo_stream_from(*streamables)</tt>.
# 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 <tt>Turbo::Streams::StreamName</tt>:
# 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 <tt>:channel</tt> option to <tt>turbo_stream_from</tt>:
# <%= turbo_stream_from "room", channel: CustomChannel %>
#
# def subscription_allowed?
# # ...
# end
# end
#
# This channel can be connected to a web page using <tt>:channel</tt> option in
# <tt>turbo_stream_from</tt> helper:
#
# <%= turbo_stream_from 'room', channel: CustomChannel %>
#
class Turbo::StreamsChannel < ActionCable::Channel::Base
# Any channel that listens to a <tt>Turbo::Broadcastable</tt>-compatible stream name (e.g., <tt>verified_stream_name_from_params</tt>)
# can also be subscribed to via <tt>Turbo::StreamsChannel</tt>. Never use the <tt>turbo_stream_from</tt> <tt>:channel</tt> 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 <tt>config.turbo.base_stream_channel_class</tt>.
# Refer to <tt>Turbo::Streams::LocatableName</tt> for details on locating streamables.
#
# By default, no authorization is performed.
def authorized?
defined?(super) ? super : true
end
end
1 change: 1 addition & 0 deletions lib/turbo-rails.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions lib/turbo/engine.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
74 changes: 74 additions & 0 deletions test/streams/streams_channel_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 1f2489d

Please sign in to comment.