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
-
.accept_value(client_key) ⇒ Object
Compute the Sec-WebSocket-Accept value per RFC 6455 §4.2.2: base64( SHA1( client_key + GUID ) ).
-
.build_101_response(accept_value, subprotocol = nil, extra_headers = {}) ⇒ Object
Build the wire bytes of the 101 Switching Protocols response.
-
.default_origin_allow_list ⇒ Object
Default origin allow-list.
-
.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.
-
.validate(env, subprotocol_selector: nil, origin_allow_list: default_origin_allow_list, permessage_deflate: :auto) ⇒ Object
Validate WS-upgrade preconditions on a Rack env.
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_list ⇒ Object
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], ) return extensions if extensions.is_a?(Array) && extensions.first == :bad_request [:ok, accept, subprotocol, extensions] end |