ActionCable websocket connections can trigger arbitrary public methods on ApplicationCable::Channel

⚠️ Please read the process on how to fix security issues before starting to work on the issue. Vulnerabilities must be fixed in a security mirror.

Summary

ActionCable websocket connections can trigger arbitrary public methods on ApplicationCable::Channel (including its included modules, Gitlab::Auth::AuthFinders and ApplicationCable::Logging)

I wasn't able to find anything too nasty that someone could do by calling one of these methods, but I might not be creative enough, and an unsuspecting developer might change one in the future.

Steps to reproduce

Get a client websocket connection somehow. I applied this patch locally to make it easy, but of course anyone could inject arbitrary websocket messages:

<REDACTED>

Send a message on the websocket connection from your browser console: window.__subscriptions[0].perform("find_user_from_warden") for example, as it's the first public method in AuthFinder. Note that this method then gets called on the server 😱 (I verified with a breakpoint).

It appears it's possible to call 0-argument methods, or 1-argument methods, and pass arbitrary de-serialized json to the 1-argument methods.

Example Project

This works on any ActionCable websocket browsing any GitLab project.

What is the current bug behavior?

ActionCable will public_send any of the following 48 methods (passing up to 1 user-supplied argument):

Method list (name - arity - source file)
unsubscribed - 0 - app/channels/graphql_channel.rb:34
subscribed - 0 - app/channels/graphql_channel.rb:8
authorization_scopes - 0 - ee/app/channels/ee/graphql_channel.rb:8
validate_user_authorization - 0 - app/channels/application_cable/channel.rb:11
find_user_from_job_token - 0 - ee/lib/ee/gitlab/auth/auth_finders.rb:53
find_user_from_geo_token - 0 - ee/lib/ee/gitlab/auth/auth_finders.rb:14
geo_api_request? - 0 - ee/lib/ee/gitlab/auth/auth_finders.rb:48
parse_geo_token - 1 - ee/lib/ee/gitlab/auth/auth_finders.rb:29
scim_request? - 0 - ee/lib/ee/gitlab/auth/auth_finders.rb:44
find_oauth_access_token - 0 - ee/lib/ee/gitlab/auth/auth_finders.rb:38
runner_controller_token_from_authorization_token - 0 - ee/lib/ee/gitlab/auth/auth_finders.rb:62
find_user_from_static_object_token - 1 - lib/gitlab/auth/auth_finders.rb:69
find_user_from_feed_token - 1 - lib/gitlab/auth/auth_finders.rb:78
find_user_from_access_token - 0 - lib/gitlab/auth/auth_finders.rb:146
validate_and_save_access_token! - -1 - lib/gitlab/auth/auth_finders.rb:207
find_user_from_basic_auth_password - 0 - lib/gitlab/auth/auth_finders.rb:103
find_user_from_lfs_token - 0 - lib/gitlab/auth/auth_finders.rb:112
find_user_from_personal_access_token - 0 - lib/gitlab/auth/auth_finders.rb:121
find_user_from_web_access_token - -2 - lib/gitlab/auth/auth_finders.rb:135
current_token= - 1 - lib/gitlab/auth/auth_finders.rb:62
cluster_agent_token_from_authorization_token - 0 - lib/gitlab/auth/auth_finders.rb:177
find_runner_from_token - 0 - lib/gitlab/auth/auth_finders.rb:187
find_job_from_job_token - 0 - lib/gitlab/auth/auth_finders.rb:196
authentication_token_present? - 0 - lib/gitlab/auth/auth_finders.rb:243
workspace_token_from_authorization_token - 0 - ee/lib/gitlab/auth/remote_development/auth_finders.rb:11
deploy_token_from_request - 0 - lib/gitlab/auth/auth_finders.rb:161
find_user_from_bearer_token - 0 - lib/gitlab/auth/auth_finders.rb:90
find_user_from_warden - 0 - lib/gitlab/auth/auth_finders.rb:65
current_token - 0 - lib/gitlab/auth/auth_finders.rb:62
load_balancer_stick_request - -4 - lib/gitlab/rack_load_balancing_helpers.rb:5
token_and_options - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:494
token_params_from - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:502
raw_params - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:519
params_array_from - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:507
rewrite_param_values - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:512
authentication_request - -3 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:555
encode_credentials - -2 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:539
authenticate - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:472
auth_scheme - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:126
has_basic_credentials? - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:114
user_name_and_password - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:118
decode_credentials - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:122
auth_param - 1 - gem:actionpack-7.2.3/lib/action_controller/metal/http_authentication.rb:130
strong_memoize_with - -2 - gems/gitlab-utils/lib/gitlab/utils/strong_memoize.rb:59
strong_memoized? - 1 - gems/gitlab-utils/lib/gitlab/utils/strong_memoize.rb:69
strong_memoize - 1 - gems/gitlab-utils/lib/gitlab/utils/strong_memoize.rb:28
strong_memoize_with_expiration - 2 - gems/gitlab-utils/lib/gitlab/utils/strong_memoize.rb:41
clear_memoization - 1 - gems/gitlab-utils/lib/gitlab/utils/strong_memoize.rb:74
List generated with this script
FakeConnection = Struct.new(:identifiers)

connection = FakeConnection.new(identifiers: [])

gql_channel = GraphqlChannel.new(connection, "fake-identifier")

action_methods = GraphqlChannel.action_methods

current_dir = Dir.pwd + '/'

action_methods.each do |method_name|
    method = gql_channel.method(method_name)
    (source_file, source_line) = method.source_location
    source_file = source_file.gsub("/Users/simon/.local/share/mise/installs/ruby/3.3.10/lib/ruby/gems/3.3.0/gems/", "gem:")
    source_file = source_file.gsub(current_dir, "")
    puts "#{method_name} - #{method.arity} - #{source_file}:#{source_line}"
end

This happens because ActionCable thinks the application is defining these methods as callable RPCs on the channel.

For example https://guides.rubyonrails.org/action_cable_overview.html#example-1-user-appearances defines two RPC-callable methods - AppearancesChannel#appear and AppearancesChannel#away. The actual ActionCable implementation verifies that the requested method is in the allowlist Channel#action_methods, but action_methods includes all public methods on any superclass between the channel and ActionCable::Channel::Base - see https://github.com/rails/rails/blob/v7.2.3/actioncable/lib/action_cable/channel/base.rb#L129. In GitLab's case, that includes ApplicationChannel, which has all these methods defined on it. The method then gets called via public_send in https://github.com/rails/rails/blob/v7.2.3/actioncable/lib/action_cable/channel/base.rb#L287.

What is the expected correct behavior?

We don't expose these methods as user-callable.

Relevant logs and/or screenshots

Screenshot of debugger stacktrace showing arbitrary method call

image

Output of checks

I haven't tested it, but I assume this happens on Gitlab.com (and anywhere else).

Results of GitLab environment info

Expand for output related to GitLab environment info
  (For installations with omnibus-gitlab package run and paste the output of: \\\`sudo gitlab-rake gitlab:env:info\\\`)  (For installations from source run and paste the output of: \\\`sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production\\\`)   

Results of GitLab application Check

This is a logic error, I reproduced it with an up-to-date gdk.

Expand for output related to the GitLab application check
 (For installations with omnibus-gitlab package run and paste the output of: \`sudo gitlab-rake gitlab:check SANITIZE=true\`)  (For installations from source run and paste the output of: \`sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production SANITIZE=true\`)  (we will only investigate if the tests are passing)  

Possible fixes

NOTE: See proposal from Heinrich below #588959 (comment 3076548097)

We could patch action_methods (~~~~https://github.com/rails/rails/blob/v7.2.3/actioncable/lib/action_cable/channel/base.rb#L129~~~~) to not include methods from superclasses, but that's a pretty brittle patch. Alternatively we could remove the includes from ApplicationCable.


cc @gitlab-com/gl-security/appsec cc @gitlab-com/gl-security/product-security/psirt-group

Edited by Katherine Wu