Module: Hyperion::WebSocket::Handshake

Defined in:
lib/hyperion/websocket/handshake.rb

Constant Summary collapse

UPGRADE_KEY =

Common-case header keys looked up in env. All UPPER_SNAKE; values come from the Rack adapter’s HTTP_KEY_CACHE (frozen) so straight ‘env` is cheaper than building the key per call.

'HTTP_UPGRADE'
CONNECTION_KEY =
'HTTP_CONNECTION'
WS_KEY_KEY =
'HTTP_SEC_WEBSOCKET_KEY'
WS_VERSION_KEY =
'HTTP_SEC_WEBSOCKET_VERSION'
WS_PROTO_KEY =
'HTTP_SEC_WEBSOCKET_PROTOCOL'
WS_EXT_KEY =
'HTTP_SEC_WEBSOCKET_EXTENSIONS'
ORIGIN_KEY =
'HTTP_ORIGIN'
HOST_KEY =
'HTTP_HOST'
METHOD_KEY =
'REQUEST_METHOD'
PROTO_KEY =
'SERVER_PROTOCOL'
NOT_WEBSOCKET_RESULT =

Phase 11 — frozen sentinel returned by ‘validate` for plain HTTP requests (the overwhelmingly common branch). Pre-Phase-11 the function allocated a fresh `[:not_websocket, nil, nil]` Array on every non-WS request — one Array per HTTP request. The caller only reads `.first` and `case` on the tag, never mutates the tuple, so a frozen shared instance is safe.

2.3-C: the handshake result tuple now has a 4th slot (‘extensions`) for permessage-deflate parameters. Existing destructuring of `[:ok, accept, sub]` is unchanged; the 4th slot is appended and ignored by 3-arg callers. The `:not_websocket` sentinel keeps the 4-slot shape with a frozen empty hash so `.frozen?` invariants on the slot stay stable.

[:not_websocket, nil, nil, {}].freeze
EMPTY_EXTENSIONS =
{}.freeze
PERMESSAGE_DEFLATE =

RFC 7692 §7.1 — permessage-deflate extension token + parameter names. We accept these spellings only (case-sensitive per RFC).

'permessage-deflate'
PARAM_SERVER_NO_TAKEOVER =
'server_no_context_takeover'
PARAM_CLIENT_NO_TAKEOVER =
'client_no_context_takeover'
PARAM_SERVER_MAX_WINDOW =
'server_max_window_bits'
PARAM_CLIENT_MAX_WINDOW =
'client_max_window_bits'
MIN_WINDOW_BITS =

RFC 7692 §7.1.2.2 — window_bits range. RFC says 8..15, but zlib’s raw deflate rejects window_bits=8 in some versions; we clamp to 9..15 in practice, the lower bound matches what browsers actually use.

9
MAX_WINDOW_BITS =
15
DEFAULT_WINDOW_BITS =
15

Class Method Summary collapse

Class Method Details

.accept_value(client_key) ⇒ Object

Compute the Sec-WebSocket-Accept value per RFC 6455 §4.2.2: base64( SHA1( client_key + GUID ) ).

Test vector: key=“dGhlIHNhbXBsZSBub25jZQ==” → “s3pPLMBiTxaQ9kYGzzhZRbK+xOo=”



215
216
217
# File 'lib/hyperion/websocket/handshake.rb', line 215

def self.accept_value(client_key)
  Base64.strict_encode64(Digest::SHA1.digest("#{client_key}#{GUID}"))
end

.build_101_response(accept_value, subprotocol = nil, extra_headers = {}) ⇒ Object

Build the wire bytes of the 101 Switching Protocols response. Apps that don’t want to hand-roll headers can call this and write the result to ‘env.call` (the raw socket) before sending any frames. Header keys are lowercased (RFC 7230 says field names are case-insensitive; lowercasing matches what every other Hyperion writer does).

accept_value    — String, the value of Sec-WebSocket-Accept
subprotocol     — String or nil, echoed back in
                  Sec-WebSocket-Protocol when non-nil
extra_headers   — Hash<String,String>, any additional 101
                  headers (e.g. Sec-WebSocket-Extensions for
                  permessage-deflate, when negotiated by the app)


232
233
234
235
236
237
238
239
240
241
242
243
244
# File 'lib/hyperion/websocket/handshake.rb', line 232

def self.build_101_response(accept_value, subprotocol = nil, extra_headers = {})
  lines = String.new(encoding: Encoding::ASCII_8BIT)
  lines << "HTTP/1.1 101 Switching Protocols\r\n"
  lines << "upgrade: websocket\r\n"
  lines << "connection: Upgrade\r\n"
  lines << "sec-websocket-accept: #{accept_value}\r\n"
  lines << "sec-websocket-protocol: #{subprotocol}\r\n" if subprotocol
  extra_headers.each do |k, v|
    lines << "#{k.to_s.downcase}: #{v}\r\n"
  end
  lines << "\r\n"
  lines
end

.default_origin_allow_listObject

Default origin allow-list. nil = accept any origin (the safe default for backend services where browsers enforce CORS-style restrictions independently). Operators can override via the env var fallback ‘HYPERION_WS_ORIGIN_ALLOW_LIST` (comma-separated) without needing to thread a Hyperion::Config DSL change in.



283
284
285
286
287
288
# File 'lib/hyperion/websocket/handshake.rb', line 283

def self.default_origin_allow_list
  raw = ENV.fetch('HYPERION_WS_ORIGIN_ALLOW_LIST', nil)
  return nil if raw.nil? || raw.empty?

  raw.split(',').map(&:strip).reject(&:empty?)
end

.format_extensions_header(extensions) ⇒ Object

Render the negotiated ‘extensions` hash from `validate` as the `sec-websocket-extensions` header value the server should echo back in the 101 response. Returns nil when nothing was negotiated (caller should omit the header). Operators can pass the result straight into the `extra_headers` slot of `build_101_response`:

ext_value = Handshake.format_extensions_header(extensions)
extras = ext_value ? { 'sec-websocket-extensions' => ext_value } : {}
socket.write(Handshake.build_101_response(accept, sub, extras))


256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
# File 'lib/hyperion/websocket/handshake.rb', line 256

def self.format_extensions_header(extensions)
  return nil if extensions.nil? || extensions.empty?

  params = extensions[:permessage_deflate]
  return nil if params.nil?

  parts = [PERMESSAGE_DEFLATE]
  parts << PARAM_SERVER_NO_TAKEOVER if params[:server_no_context_takeover]
  parts << PARAM_CLIENT_NO_TAKEOVER if params[:client_no_context_takeover]
  # Only echo window-bits parameters if the negotiated value
  # differs from the RFC default of 15. RFC 7692 §7.1.2.1 says
  # the absence of the parameter means 15 bits; including it
  # redundantly is allowed but adds wire bytes for no win.
  if (server_max = params[:server_max_window_bits]) && server_max != DEFAULT_WINDOW_BITS
    parts << "#{PARAM_SERVER_MAX_WINDOW}=#{server_max}"
  end
  if (client_max = params[:client_max_window_bits]) && client_max != DEFAULT_WINDOW_BITS
    parts << "#{PARAM_CLIENT_MAX_WINDOW}=#{client_max}"
  end
  parts.join('; ')
end

.validate(env, subprotocol_selector: nil, origin_allow_list: default_origin_allow_list, permessage_deflate: :auto) ⇒ Object

Validate WS-upgrade preconditions on a Rack env.

Returns a 4-tuple. The first slot is a Symbol tag the caller branches on:

[:ok, accept_header_value, selected_subprotocol_or_nil,
 negotiated_extensions]
  — request is a valid RFC 6455 §4.2.1 handshake. Caller should
  stash the tuple in env and let the app handle the 101.
  `negotiated_extensions` is a Hash keyed by extension symbol;
  `{}` when no extension was negotiated. For permessage-deflate
  (RFC 7692) the value carries the resolved parameter set:
    {
      permessage_deflate: {
        server_no_context_takeover: false,
        client_no_context_takeover: false,
        server_max_window_bits: 15,
        client_max_window_bits: 15
      }
    }

[:bad_request, body, extra_headers]
  — request is a WS upgrade attempt with a protocol error
  (missing/invalid Sec-WebSocket-Key, wrong method, etc.).
  Caller short-circuits a 400.

[:upgrade_required, body, extra_headers]
  — Sec-WebSocket-Version is missing or not 13.
  `extra_headers` always includes `'sec-websocket-version' => '13'`
  so the client sees the version Hyperion supports.
  Caller short-circuits a 426 (RFC 6455 §4.4).

[:not_websocket, nil, nil]
  — request is not a WS upgrade (no Upgrade header, or Upgrade:
  value other than `websocket`). Caller proceeds with the
  normal HTTP flow. We don't trip on h2c / other Upgrade
  variants — only `websocket` is intercepted.

Optional kwargs:

subprotocol_selector — a Proc that receives the array of
client-offered subprotocols (parsed from
Sec-WebSocket-Protocol). Returns:
  * a String matching one of the offers → echoed back in the
    Sec-WebSocket-Protocol response header
  * nil → no Sec-WebSocket-Protocol header (server silently
    declines, RFC 6455 §4.2.2)
  * a String NOT matching any offer → treated as nil (server
    MUST NOT pick a protocol the client didn't offer)

origin_allow_list — an Array of allowed Origin header values.
When nil (default), any Origin (including missing) is accepted
— browsers enforce CORS-style restrictions on the WS upgrade
independently. Pass [] to reject all browser-originated WS,
pass ['https://example.com'] to allow only that origin.


157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
# File 'lib/hyperion/websocket/handshake.rb', line 157

def self.validate(env, subprotocol_selector: nil, origin_allow_list: default_origin_allow_list,
                  permessage_deflate: :auto)
  return NOT_WEBSOCKET_RESULT unless websocket_upgrade?(env)

  # Once we've decided this IS a WS attempt, every subsequent
  # validation failure is a 4xx, NOT a passthrough. The order
  # below mirrors RFC 6455 §4.2.1's MUST list.

  return bad_request('WebSocket upgrade requires GET') unless env[METHOD_KEY] == 'GET'

  proto = env[PROTO_KEY].to_s
  unless proto.start_with?('HTTP/') &&
         http_version_at_least_1_1?(proto)
    return bad_request('WebSocket upgrade requires HTTP/1.1+')
  end

  host = env[HOST_KEY]
  return bad_request('Host header required') if host.nil? || host.empty?

  # Sec-WebSocket-Version check before Sec-WebSocket-Key so a
  # client speaking the old hixie-76 / draft-08 dialect gets the
  # 426 hint to upgrade rather than a generic 400 on the missing
  # key (the old dialect uses different key headers).
  version = env[WS_VERSION_KEY]
  unless version == SUPPORTED_VERSION
    return [
      :upgrade_required,
      "Unsupported Sec-WebSocket-Version (need #{SUPPORTED_VERSION})",
      { 'sec-websocket-version' => SUPPORTED_VERSION }
    ]
  end

  client_key = env[WS_KEY_KEY]
  return bad_request('Sec-WebSocket-Key required') if client_key.nil? || client_key.empty?

  return bad_request('Sec-WebSocket-Key must decode to 16 bytes') unless valid_client_key?(client_key)

  if origin_allow_list && !origin_allowed?(env[ORIGIN_KEY], origin_allow_list)
    return bad_request('Origin not in allow-list')
  end

  accept = accept_value(client_key)
  subprotocol = pick_subprotocol(env[WS_PROTO_KEY], subprotocol_selector)

  # RFC 7692 negotiation. Returns either a {permessage_deflate: {...}}
  # hash or `EMPTY_EXTENSIONS`. With `permessage_deflate: :on` and
  # no client offer, returns the bad_request tuple itself — the
  # operator opted into "compression-required" semantics.
  extensions = negotiate_extensions(env[WS_EXT_KEY], permessage_deflate)
  return extensions if extensions.is_a?(Array) && extensions.first == :bad_request

  [:ok, accept, subprotocol, extensions]
end