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_channel.rb b/app/channels/turbo/streams_channel.rb index adb614b4..b70a9556 100644 --- a/app/channels/turbo/streams_channel.rb +++ b/app/channels/turbo/streams_channel.rb @@ -5,41 +5,46 @@ # 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 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 config.turbo.base_stream_channel_class e.g: + # current_user.can_access? streamable. current_user should match your + # ApplicationCable::Connection identified_by accessor. + def authorized? + defined?(super) ? super : true + end + + # Helpful for implementing domain specific authorization rules when overriding authorized?. + def streamable + @streamable ||= GlobalID::Locator.locate(stream_name) + 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