Proposal: Action Cable should accept a WebSocket handshake with no Origin header, as Action Controller does

What happens today

Action Cable rejects any WebSocket handshake whose Origin header is missing. On 8.1.3 and on main the check is:

# actioncable/lib/action_cable/server/base.rb (main)
def allow_request_origin?(env)
  return true if config.disable_request_forgery_protection

  request = ActionDispatch::Request.new(env)
  proto = request.ssl? ? "https" : "http"
  if config.allow_same_origin_as_host && env["HTTP_ORIGIN"] == "#{proto}://#{request.host_with_port}"
    true
  elsif Array(config.allowed_request_origins).any? { |allowed_origin| allowed_origin === env["HTTP_ORIGIN"] }
    true
  else
    logger.error("Request origin not allowed: #{env['HTTP_ORIGIN']}")
    false
  end
end

With no Origin, env["HTTP_ORIGIN"] is nil, neither branch matches, and the client gets a 404 with Request origin not allowed: in the log.

Action Controller makes the opposite choice for the same header:

# actionpack/lib/action_controller/metal/request_forgery_protection.rb
def valid_request_origin?
  if forgery_protection_origin_check
    # We accept blank origin headers because some user agents don't send it.
    raise InvalidAuthenticityToken, NULL_ORIGIN_MESSAGE if request.origin == "null"
    request.origin.nil? || request.origin == request.base_url
  else
    true
  end
end

Why I think a missing Origin should be accepted

1. It is what the check is for. The origin check protects against cross-site WebSocket hijacking: a page on another site opening a socket with the user’s cookies. That attack needs a browser, and a browser always sends Origin on a WebSocket handshake. RFC 6455 §4.1 requires it for browser clients and makes it optional for others, and §10.2 says of the check:

The intent is not to prevent non-browsers from establishing connections but rather to ensure that trusted browsers under the control of potentially malicious JavaScript cannot fake a WebSocket handshake.

A request without Origin cannot be the attack, so rejecting it protects nothing. A non-browser client that wants in can send any Origin it likes.

2. The current behaviour pushes people to switch the protection off. Non-browser clients are common: IoT devices, native mobile apps, server-to-server consumers, Postman. In #31524 (2017–2021) users of Flutter, Postman and other external clients all hit this, some found that adding nil to allowed_request_origins works, and the issue was closed with the suggestion to set config.action_cable.disable_request_forgery_protection = true. That removes the protection for browsers too, which is a worse outcome than accepting the requests that were never the threat.

I ran into it the same way, connecting Raspberry Pis to a Rails app over Action Cable: my app ran with disable_request_forgery_protection = true for three months before I made the devices send an Origin.

3. Consistency. Action Controller and Action Cable answer the same question in opposite ways, and the comment in Action Controller already gives the reason.

The last time origins came up here was Allow matching HTTP_ORIGIN with HTTP_HOST in ActionCable (2016), which gave us allow_same_origin_as_host. That was about browsers reaching an app under several hosts; this is about clients that are not browsers and send no Origin at all.

Options

Change Compatibility
A Treat a missing Origin as allowed, like Action Controller. Origin: null stays rejected. Behaviour change: clients that are rejected today would be accepted. Could go behind a load_defaults flag.
B Add an opt-in, e.g. config.action_cable.allow_missing_origin = true. None.
C Documentation only. None.

For C, the Action Cable guide would say that non-browser clients don’t send Origin, and show the two ways that work today without turning the protection off:

# accept clients that send no Origin, keep checking the ones that do
config.action_cable.allowed_request_origins = [nil, "https://example.com"]

or have the client send an Origin equal to the host it connects to, which allow_same_origin_as_host accepts. Both work on 8.1.3 (I verified them with a Ruby client on a Raspberry Pi).

I would prefer A behind a new-framework-default flag, and I’m happy to write the patch for whichever option the core team prefers, including C on its own.

1 Like