Is your Action Cable connection secure when using Turbo?
posted by Ayush Newatia
10 February, 2025
When Turbo 7 and its Rails integration turbo-rails
dropped in late 2020, it became easier than ever to use Action Cable. With just a few lines of code, you could broadcast updates to the client over WebSockets and build all kinds of real-time interactivity with ease.
This ease does come with a trade-off. There’s a security pitfall that’s all too easy to fall into. By default, WebSocket connections aren’t protected by any authentication or authorisation checks. There’s also no mention in the Turbo documentation that I’m aware of explaining how the developer can add their own checks.
Before discussing the issue itself, let’s back up a bit and look at how the WebSocket connection is made.
Creating the WebSocket connection for streaming Turbo Streams
In the view, we’d establish a connection and subscribe to a channel using an Active Model/Active Record object, or a string:
<%= turbo_stream_from @conversation %>
<%= turbo_stream_from "my_channel" %>
This will render as:
<turbo-cable-stream-source
channel="Turbo::StreamsChannel"
signed-stream-name="...">
</turbo-cable-stream-source>
We could then broadcast to this channel from the server:
Turbo::StreamsChannel.broadcast_action_to(
@conversation,
action: :append,
target: dom_id(@conversation, :messages),
partial: "messages/message"
)
The signed stream name is generated from the @conversation
object or the string.
A closer look at stream names
Any type of object can used to generate the stream name as long as it responds to to_gid_param
or to_param
. Hence, it’s usually an Active Record object, string, or symbol.
to_gid_param
generates the Global ID for a model and then Base64 encodes it. For strings, to_param
returns the object itself. For symbols, it returns the object’s string representation.
The stream name for a Conversation
object would look something like:
Z2lkOi8vcGlhenphL0NvbnZlcnNhdGlvbi8x
Base64 decoding the above value will reveal the object’s Global ID:
>> stream = "Z2lkOi8vcGlhenphL0NvbnZlcnNhdGlvbi8x"
>> Base64.urlsafe_decode64(stream)
# => "gid://piazza/Conversation/1"
The stream name is run through a MessageVerifier
to obtain the signed stream name which is rendered in the HTML attribute. This ensures the stream name can’t be tampered with on the client.
Using the signed stream name and channel name, the custom <turbo-cable-stream-source>
element establishes a WebSocket connection using Action Cable and subscribes to the desired channel.
The security vulnerability
The signed stream names themselves cannot be spoofed easily as they are securely signed with your app’s secret_key_base
.
The security issue is when a malicious user gets their hands on a signed stream name for a resource they shouldn’t have access to. This could happen in a variety of ways, but the most likely scenario is that they had access to a resource, but that access was revoked. Then, in the absence of any additional checks, they use the signed stream name to continue receiving messages broadcast to that channel.
Let’s look at some code to understand this further. This is the default ApplicationCable::Connection
class which is the entry point for all WebSocket connections.
module ApplicationCable
class Connection < ActionCable::Connection::Base
end
end
There’s no logic in there to do any checks on the incoming connections. It will automatically accept all WebSocket connections. The next step is that the connection will try to subscribe to a channel. The default Turbo::StreamsChannel
in the turbo-rails
gem looks like:
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
This does a check to verify the stream name hasn’t been tampered with, but then accepts the subscription without any additional authorisation checks.
Depending on the messages your app broadcasts, this might not be a big problem. I’ll also say that the scenario of a malicious user getting the markup they need is pretty unlikely. But in the event that they do, there’s absolutely no mitigation in place to stop them. It’s a vulnerability that makes me nervous.
I don’t think we should accept any connections without the appropriate authentication and authorisation checks. This applies to WebSockets just as much as it applies to HTTP.
Fixing the vulnerability
We can address this issue by doing an authentication check before the connection is ever established, and then doing an authorisation check when subscribing to a channel.
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]
# Authentication logic here
end
end
Depending on how your app’s authentication is setup, the above code will look slightly different. The general idea is to use a cookie to authenticate the user just as you would an HTTP request. The Rails session
variable isn’t available in this context so we need to read the session cookie manually.
The parameters passed to identified_by
are analogous to ActiveSupport::CurrentAttributes
, but in the context of an Action Cable connection. We can set global attributes for the connection in those params.
Next, we create a custom channel and add our authorization logic:
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?
# authorize self.user
end
end
And finally, we need to specify this channel in our markup:
<%= turbo_stream_from @conversation, channel: ConversationsChannel %>
That’s it! Now, we’re doing an authentication and authorisation check before accepting all WebSocket connections.
Conclusion
Rails and Turbo both needed to implement the WebSocket mechanism in a generic way. There’s just no way to anticipate the intricacies of an app’s authentication and authorisation logic to provide any default setup.
However, I do wish this gotcha was made more obvious. It can be a rather sizeable security hole in the right context. Every app I’ve encountered in my freelance work has been missing these checks.
As unlikely as such an attack might be, the fix is easy enough that I think it’s absolutely worth doing. I believe that application security is something everyone should take very seriously.
For more useful tips like this, and to level up your Rails skills, buy The Rails and Hotwire Codex.