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` allows the application to apply
authentication to all streams method, including those from
`Turbo::Broadcastable`.

By allowing `Turbo::StreamsChannel` to inherit from
`ApplicationCable::Channel` we open up a symmetrical path for
authorization.

In the spirit of being secure by default we should be moving towards
making `Turbo.base_stream_channel_class` default to
`"ApplicationCable::Channel"` but doing so without warning would break
applications relying on `ApplicationCable::Connection#authorized?` for
non-turbo broadcastable streams only.

Once we deem it safe we can remove the awkward initialiser and hard code
`ApplicationCable::Channel` as the super class. If people needs
different super classes for `Turbo::StreamsChannel` and custom channels
they can simply add another super class to inherit from in their
application.
  • Loading branch information
ramhoj committed Oct 22, 2024
1 parent 52727cb commit 5c8196d
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 26 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
55 changes: 30 additions & 25 deletions app/channels/turbo/streams_channel.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,41 +5,46 @@
# 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

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

private
def subscription_allowed?
stream_name && authorized?
end

def stream_name
@stream_name ||= verified_stream_name_from_params
end

# Override this method to match your authorization rules in <tt>config.turbo.base_stream_channel_class</tt> e.g:
# <tt>current_user.can_access? streamable</tt>. <tt>current_user<tt> should match your
# <tt>ApplicationCable::Connection</tt> <tt>identified_by</tt> accessor.
def authorized?
defined?(super) ? super : true
end

# Helpful for implementing domain specific authorization rules when overriding <tt>authorized?</tt>.
def streamable
@streamable ||= GlobalID::Locator.locate(stream_name)
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
33 changes: 33 additions & 0 deletions test/streams/streams_channel_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -337,4 +337,37 @@ 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
board = Board.create!(name: "A")
original_authorized = Turbo::StreamsChannel.instance_method(:authorized?)
Turbo::StreamsChannel.define_method(:authorized?) { streamable.name == "A" }

subscribe signed_stream_name: signed_stream_name(board)

assert subscription.confirmed?
assert_has_stream board.to_gid_param
ensure
Turbo::StreamsChannel.define_method :authorized?, original_authorized
end

test "rejects subscription when failing authorization" do
board = Board.create!(name: "A")
original_authorized = Turbo::StreamsChannel.instance_method(:authorized?)
Turbo::StreamsChannel.define_method(:authorized?) { streamable.name == "B" }

subscribe signed_stream_name: signed_stream_name(board)

assert subscription.rejected?
assert_no_streams
ensure
Turbo::StreamsChannel.define_method :authorized?, original_authorized
end
end

0 comments on commit 5c8196d

Please sign in to comment.