em-websocket 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/Gemfile +3 -0
- data/README.md +76 -0
- data/Rakefile +6 -28
- data/lib/em-websocket.rb +6 -2
- data/lib/em-websocket/connection.rb +53 -112
- data/lib/em-websocket/framing03.rb +176 -0
- data/lib/em-websocket/framing76.rb +96 -0
- data/lib/em-websocket/handler.rb +28 -4
- data/lib/em-websocket/handler03.rb +14 -0
- data/lib/em-websocket/handler75.rb +2 -15
- data/lib/em-websocket/handler76.rb +3 -56
- data/lib/em-websocket/handler_factory.rb +37 -12
- data/lib/em-websocket/handshake75.rb +21 -0
- data/lib/em-websocket/handshake76.rb +61 -0
- data/lib/em-websocket/version.rb +5 -0
- data/spec/helper.rb +12 -7
- data/spec/integration/draft03_spec.rb +252 -0
- data/spec/integration/{integration_spec.rb → draft76_spec.rb} +26 -1
- data/spec/unit/framing_spec.rb +108 -0
- data/spec/unit/handler_spec.rb +12 -0
- data/spec/websocket_spec.rb +16 -1
- metadata +51 -17
- data/README.rdoc +0 -73
- data/VERSION +0 -1
- data/examples/srv.rb +0 -19
@@ -0,0 +1,96 @@
|
|
1
|
+
module EventMachine
|
2
|
+
module WebSocket
|
3
|
+
module Framing76
|
4
|
+
|
5
|
+
# Set the max frame lenth to very high value (10MB) until there is a
|
6
|
+
# limit specified in the spec to protect against malicious attacks
|
7
|
+
MAXIMUM_FRAME_LENGTH = 10 * 1024 * 1024
|
8
|
+
|
9
|
+
def initialize_framing
|
10
|
+
@data = ''
|
11
|
+
end
|
12
|
+
|
13
|
+
def process_data
|
14
|
+
debug [:message, @data]
|
15
|
+
|
16
|
+
# This algorithm comes straight from the spec
|
17
|
+
# http://tools.ietf.org/html/draft-hixie-thewebsocketprotocol-76#section-4.2
|
18
|
+
|
19
|
+
error = false
|
20
|
+
|
21
|
+
while !error
|
22
|
+
pointer = 0
|
23
|
+
frame_type = @data[pointer].to_i
|
24
|
+
pointer += 1
|
25
|
+
|
26
|
+
if (frame_type & 0x80) == 0x80
|
27
|
+
# If the high-order bit of the /frame type/ byte is set
|
28
|
+
length = 0
|
29
|
+
|
30
|
+
loop do
|
31
|
+
return false if !@data[pointer]
|
32
|
+
b = @data[pointer].to_i
|
33
|
+
pointer += 1
|
34
|
+
b_v = b & 0x7F
|
35
|
+
length = length * 128 + b_v
|
36
|
+
break unless (b & 0x80) == 0x80
|
37
|
+
end
|
38
|
+
|
39
|
+
# Addition to the spec to protect against malicious requests
|
40
|
+
if length > MAXIMUM_FRAME_LENGTH
|
41
|
+
@connection.close_with_error(DataError.new("Frame length too long (#{length} bytes)"))
|
42
|
+
return false
|
43
|
+
end
|
44
|
+
|
45
|
+
if @data[pointer+length-1] == nil
|
46
|
+
debug [:buffer_incomplete, @data.inspect]
|
47
|
+
# Incomplete data - leave @data to accumulate
|
48
|
+
error = true
|
49
|
+
else
|
50
|
+
# Straight from spec - I'm sure this isn't crazy...
|
51
|
+
# 6. Read /length/ bytes.
|
52
|
+
# 7. Discard the read bytes.
|
53
|
+
@data = @data[(pointer+length)..-1]
|
54
|
+
|
55
|
+
# If the /frame type/ is 0xFF and the /length/ was 0, then close
|
56
|
+
if length == 0
|
57
|
+
@connection.send_data("\xff\x00")
|
58
|
+
@state = :closing
|
59
|
+
@connection.close_connection_after_writing
|
60
|
+
else
|
61
|
+
error = true
|
62
|
+
end
|
63
|
+
end
|
64
|
+
else
|
65
|
+
# If the high-order bit of the /frame type/ byte is _not_ set
|
66
|
+
msg = @data.slice!(/\A\x00([^\xff]*)\xff/)
|
67
|
+
if msg
|
68
|
+
msg.gsub!(/\A\x00|\xff\z/, '')
|
69
|
+
if @state == :closing
|
70
|
+
debug [:ignored_message, msg]
|
71
|
+
else
|
72
|
+
msg.force_encoding('UTF-8') if msg.respond_to?(:force_encoding)
|
73
|
+
@connection.trigger_on_message(msg)
|
74
|
+
end
|
75
|
+
else
|
76
|
+
error = true
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
80
|
+
|
81
|
+
false
|
82
|
+
end
|
83
|
+
|
84
|
+
# frames need to start with 0x00-0x7f byte and end with
|
85
|
+
# an 0xFF byte. Per spec, we can also set the first
|
86
|
+
# byte to a value betweent 0x80 and 0xFF, followed by
|
87
|
+
# a leading length indicator
|
88
|
+
def send_text_frame(data)
|
89
|
+
ary = ["\x00", data, "\xff"]
|
90
|
+
ary.collect{ |s| s.force_encoding('UTF-8') if s.respond_to?(:force_encoding) }
|
91
|
+
@connection.send_data(ary.join)
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
data/lib/em-websocket/handler.rb
CHANGED
@@ -3,17 +3,41 @@ module EventMachine
|
|
3
3
|
class Handler
|
4
4
|
include Debugger
|
5
5
|
|
6
|
-
attr_reader :request
|
6
|
+
attr_reader :request, :state
|
7
7
|
|
8
|
-
def initialize(
|
9
|
-
@request = request
|
10
|
-
@response = response
|
8
|
+
def initialize(connection, request, debug = false)
|
9
|
+
@connection, @request = connection, request
|
11
10
|
@debug = debug
|
11
|
+
@state = :handshake
|
12
|
+
initialize_framing
|
12
13
|
end
|
13
14
|
|
15
|
+
def run
|
16
|
+
@connection.send_data handshake
|
17
|
+
@state = :connected
|
18
|
+
@connection.trigger_on_open
|
19
|
+
end
|
20
|
+
|
21
|
+
# Handshake response
|
14
22
|
def handshake
|
15
23
|
# Implemented in subclass
|
16
24
|
end
|
25
|
+
|
26
|
+
def receive_data(data)
|
27
|
+
@data << data
|
28
|
+
process_data
|
29
|
+
end
|
30
|
+
|
31
|
+
def close_websocket
|
32
|
+
# Unless redefined in a subclass, just close the connection
|
33
|
+
@state = :closed
|
34
|
+
@connection.close_connection_after_writing
|
35
|
+
end
|
36
|
+
|
37
|
+
def unbind
|
38
|
+
@state = :closed
|
39
|
+
@connection.trigger_on_close
|
40
|
+
end
|
17
41
|
end
|
18
42
|
end
|
19
43
|
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
module EventMachine
|
2
|
+
module WebSocket
|
3
|
+
class Handler03 < Handler
|
4
|
+
include Handshake76
|
5
|
+
include Framing03
|
6
|
+
|
7
|
+
def close_websocket
|
8
|
+
# TODO: Should we send data and check the response matches?
|
9
|
+
send_frame(:close, '')
|
10
|
+
@state = :closing
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
@@ -1,21 +1,8 @@
|
|
1
1
|
module EventMachine
|
2
2
|
module WebSocket
|
3
3
|
class Handler75 < Handler
|
4
|
-
|
5
|
-
|
6
|
-
location << ":#{@request['Host'].port}" if @request['Host'].port
|
7
|
-
location << @request['Path']
|
8
|
-
|
9
|
-
upgrade = "HTTP/1.1 101 Web Socket Protocol Handshake\r\n"
|
10
|
-
upgrade << "Upgrade: WebSocket\r\n"
|
11
|
-
upgrade << "Connection: Upgrade\r\n"
|
12
|
-
upgrade << "WebSocket-Origin: #{@request['Origin']}\r\n"
|
13
|
-
upgrade << "WebSocket-Location: #{location}\r\n\r\n"
|
14
|
-
|
15
|
-
debug [:upgrade_headers, upgrade]
|
16
|
-
|
17
|
-
return upgrade
|
18
|
-
end
|
4
|
+
include Handshake75
|
5
|
+
include Framing76
|
19
6
|
end
|
20
7
|
end
|
21
8
|
end
|
@@ -1,64 +1,11 @@
|
|
1
|
-
require 'digest/md5'
|
2
|
-
|
3
1
|
module EventMachine
|
4
2
|
module WebSocket
|
5
3
|
class Handler76 < Handler
|
4
|
+
include Handshake76
|
5
|
+
include Framing76
|
6
|
+
|
6
7
|
# "\377\000" is octet version and "\xff\x00" is hex version
|
7
8
|
TERMINATE_STRING = "\xff\x00"
|
8
|
-
|
9
|
-
def handshake
|
10
|
-
challenge_response = solve_challenge(
|
11
|
-
@request['Sec-WebSocket-Key1'],
|
12
|
-
@request['Sec-WebSocket-Key2'],
|
13
|
-
@request['Third-Key']
|
14
|
-
)
|
15
|
-
|
16
|
-
location = "#{@request['Host'].scheme}://#{@request['Host'].host}"
|
17
|
-
location << ":#{@request['Host'].port}" if @request['Host'].port
|
18
|
-
location << @request['Path']
|
19
|
-
|
20
|
-
upgrade = "HTTP/1.1 101 WebSocket Protocol Handshake\r\n"
|
21
|
-
upgrade << "Upgrade: WebSocket\r\n"
|
22
|
-
upgrade << "Connection: Upgrade\r\n"
|
23
|
-
upgrade << "Sec-WebSocket-Location: #{location}\r\n"
|
24
|
-
upgrade << "Sec-WebSocket-Origin: #{@request['Origin']}\r\n"
|
25
|
-
if protocol = @request['Sec-WebSocket-Protocol']
|
26
|
-
validate_protocol!(protocol)
|
27
|
-
upgrade << "Sec-WebSocket-Protocol: #{protocol}\r\n"
|
28
|
-
end
|
29
|
-
upgrade << "\r\n"
|
30
|
-
upgrade << challenge_response
|
31
|
-
|
32
|
-
debug [:upgrade_headers, upgrade]
|
33
|
-
|
34
|
-
return upgrade
|
35
|
-
end
|
36
|
-
|
37
|
-
private
|
38
|
-
|
39
|
-
def solve_challenge(first, second, third)
|
40
|
-
# Refer to 5.2 4-9 of the draft 76
|
41
|
-
sum = [(extract_nums(first) / count_spaces(first))].pack("N*") +
|
42
|
-
[(extract_nums(second) / count_spaces(second))].pack("N*") +
|
43
|
-
third
|
44
|
-
Digest::MD5.digest(sum)
|
45
|
-
end
|
46
|
-
|
47
|
-
def extract_nums(string)
|
48
|
-
string.scan(/[0-9]/).join.to_i
|
49
|
-
end
|
50
|
-
|
51
|
-
def count_spaces(string)
|
52
|
-
spaces = string.scan(/ /).size
|
53
|
-
# As per 5.2.5, abort the connection if spaces are zero.
|
54
|
-
raise HandshakeError, "Websocket Key1 or Key2 does not contain spaces - this is a symptom of a cross-protocol attack" if spaces == 0
|
55
|
-
return spaces
|
56
|
-
end
|
57
|
-
|
58
|
-
def validate_protocol!(protocol)
|
59
|
-
raise HandshakeError, "Invalid WebSocket-Protocol: empty" if protocol.empty?
|
60
|
-
# TODO: Validate characters
|
61
|
-
end
|
62
9
|
end
|
63
10
|
end
|
64
11
|
end
|
@@ -4,11 +4,16 @@ module EventMachine
|
|
4
4
|
PATH = /^(\w+) (\/[^\s]*) HTTP\/1\.1$/
|
5
5
|
HEADER = /^([^:]+):\s*(.+)$/
|
6
6
|
|
7
|
-
def self.build(data, secure = false, debug = false)
|
7
|
+
def self.build(connection, data, secure = false, debug = false)
|
8
|
+
(header, remains) = data.split("\r\n\r\n", 2)
|
9
|
+
unless remains
|
10
|
+
# The whole header has not been received yet.
|
11
|
+
return nil
|
12
|
+
end
|
13
|
+
|
8
14
|
request = {}
|
9
|
-
response = nil
|
10
15
|
|
11
|
-
lines =
|
16
|
+
lines = header.split("\r\n")
|
12
17
|
|
13
18
|
# extract request path
|
14
19
|
first_line = lines.shift.match(PATH)
|
@@ -27,7 +32,24 @@ module EventMachine
|
|
27
32
|
h = HEADER.match(line)
|
28
33
|
request[h[1].strip] = h[2].strip if h
|
29
34
|
end
|
30
|
-
|
35
|
+
|
36
|
+
version = request['Sec-WebSocket-Key1'] ? 76 : 75
|
37
|
+
case version
|
38
|
+
when 75
|
39
|
+
if !remains.empty?
|
40
|
+
raise HandshakeError, "Extra bytes after header"
|
41
|
+
end
|
42
|
+
when 76
|
43
|
+
if remains.length < 8
|
44
|
+
# The whole third-key has not been received yet.
|
45
|
+
return nil
|
46
|
+
elsif remains.length > 8
|
47
|
+
raise HandshakeError, "Extra bytes after third key"
|
48
|
+
end
|
49
|
+
request['Third-Key'] = remains
|
50
|
+
else
|
51
|
+
raise WebSocketError, "Must not happen"
|
52
|
+
end
|
31
53
|
|
32
54
|
unless request['Connection'] == 'Upgrade' and request['Upgrade'] == 'WebSocket'
|
33
55
|
raise HandshakeError, "Connection and Upgrade headers required"
|
@@ -37,15 +59,18 @@ module EventMachine
|
|
37
59
|
protocol = (secure ? "wss" : "ws")
|
38
60
|
request['Host'] = Addressable::URI.parse("#{protocol}://"+request['Host'])
|
39
61
|
|
40
|
-
version = request['Sec-WebSocket-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
62
|
+
if version = request['Sec-WebSocket-Draft']
|
63
|
+
if version == '1' || version == '2' || version == '3'
|
64
|
+
# We'll use handler03 - I believe they're all compatible
|
65
|
+
Handler03.new(connection, request, debug)
|
66
|
+
else
|
67
|
+
# According to spec should abort the connection
|
68
|
+
raise WebSocketError, "Unknown draft version: #{version}"
|
69
|
+
end
|
70
|
+
elsif request['Sec-WebSocket-Key1']
|
71
|
+
Handler76.new(connection, request, debug)
|
47
72
|
else
|
48
|
-
|
73
|
+
Handler75.new(connection, request, debug)
|
49
74
|
end
|
50
75
|
end
|
51
76
|
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module EventMachine
|
2
|
+
module WebSocket
|
3
|
+
module Handshake75
|
4
|
+
def handshake
|
5
|
+
location = "#{request['Host'].scheme}://#{request['Host'].host}"
|
6
|
+
location << ":#{request['Host'].port}" if request['Host'].port
|
7
|
+
location << request['Path']
|
8
|
+
|
9
|
+
upgrade = "HTTP/1.1 101 Web Socket Protocol Handshake\r\n"
|
10
|
+
upgrade << "Upgrade: WebSocket\r\n"
|
11
|
+
upgrade << "Connection: Upgrade\r\n"
|
12
|
+
upgrade << "WebSocket-Origin: #{request['Origin']}\r\n"
|
13
|
+
upgrade << "WebSocket-Location: #{location}\r\n\r\n"
|
14
|
+
|
15
|
+
debug [:upgrade_headers, upgrade]
|
16
|
+
|
17
|
+
return upgrade
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,61 @@
|
|
1
|
+
require 'digest/md5'
|
2
|
+
|
3
|
+
module EventMachine
|
4
|
+
module WebSocket
|
5
|
+
module Handshake76
|
6
|
+
def handshake
|
7
|
+
challenge_response = solve_challenge(
|
8
|
+
request['Sec-WebSocket-Key1'],
|
9
|
+
request['Sec-WebSocket-Key2'],
|
10
|
+
request['Third-Key']
|
11
|
+
)
|
12
|
+
|
13
|
+
location = "#{request['Host'].scheme}://#{request['Host'].host}"
|
14
|
+
location << ":#{request['Host'].port}" if request['Host'].port
|
15
|
+
location << request['Path']
|
16
|
+
|
17
|
+
upgrade = "HTTP/1.1 101 WebSocket Protocol Handshake\r\n"
|
18
|
+
upgrade << "Upgrade: WebSocket\r\n"
|
19
|
+
upgrade << "Connection: Upgrade\r\n"
|
20
|
+
upgrade << "Sec-WebSocket-Location: #{location}\r\n"
|
21
|
+
upgrade << "Sec-WebSocket-Origin: #{request['Origin']}\r\n"
|
22
|
+
if protocol = request['Sec-WebSocket-Protocol']
|
23
|
+
validate_protocol!(protocol)
|
24
|
+
upgrade << "Sec-WebSocket-Protocol: #{protocol}\r\n"
|
25
|
+
end
|
26
|
+
upgrade << "\r\n"
|
27
|
+
upgrade << challenge_response
|
28
|
+
|
29
|
+
debug [:upgrade_headers, upgrade]
|
30
|
+
|
31
|
+
return upgrade
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def solve_challenge(first, second, third)
|
37
|
+
# Refer to 5.2 4-9 of the draft 76
|
38
|
+
sum = [(extract_nums(first) / count_spaces(first))].pack("N*") +
|
39
|
+
[(extract_nums(second) / count_spaces(second))].pack("N*") +
|
40
|
+
third
|
41
|
+
Digest::MD5.digest(sum)
|
42
|
+
end
|
43
|
+
|
44
|
+
def extract_nums(string)
|
45
|
+
string.scan(/[0-9]/).join.to_i
|
46
|
+
end
|
47
|
+
|
48
|
+
def count_spaces(string)
|
49
|
+
spaces = string.scan(/ /).size
|
50
|
+
# As per 5.2.5, abort the connection if spaces are zero.
|
51
|
+
raise HandshakeError, "Websocket Key1 or Key2 does not contain spaces - this is a symptom of a cross-protocol attack" if spaces == 0
|
52
|
+
return spaces
|
53
|
+
end
|
54
|
+
|
55
|
+
def validate_protocol!(protocol)
|
56
|
+
raise HandshakeError, "Invalid WebSocket-Protocol: empty" if protocol.empty?
|
57
|
+
# TODO: Validate characters
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
data/spec/helper.rb
CHANGED
@@ -1,9 +1,13 @@
|
|
1
1
|
require 'rubygems'
|
2
|
-
require '
|
2
|
+
require 'rspec'
|
3
3
|
require 'pp'
|
4
4
|
require 'em-http'
|
5
5
|
|
6
|
-
require '
|
6
|
+
require 'em-websocket'
|
7
|
+
|
8
|
+
Rspec.configure do |c|
|
9
|
+
c.mock_with :rspec
|
10
|
+
end
|
7
11
|
|
8
12
|
class FakeWebSocketClient < EM::Connection
|
9
13
|
attr_writer :onopen, :onclose, :onmessage
|
@@ -21,7 +25,7 @@ class FakeWebSocketClient < EM::Connection
|
|
21
25
|
@onopen.call if @onopen
|
22
26
|
@state = :open
|
23
27
|
else
|
24
|
-
@onmessage.call if @onmessage
|
28
|
+
@onmessage.call(data) if @onmessage
|
25
29
|
@packets << data
|
26
30
|
end
|
27
31
|
end
|
@@ -55,11 +59,12 @@ def format_response(r)
|
|
55
59
|
end
|
56
60
|
|
57
61
|
def handler(request, secure = false)
|
58
|
-
|
62
|
+
connection = Object.new
|
63
|
+
EM::WebSocket::HandlerFactory.build(connection, format_request(request), secure)
|
59
64
|
end
|
60
65
|
|
61
|
-
|
62
|
-
|
63
|
-
|
66
|
+
RSpec::Matchers.define :send_handshake do |response|
|
67
|
+
match do |actual|
|
68
|
+
actual.handshake.lines.sort == format_response(response).lines.sort
|
64
69
|
end
|
65
70
|
end
|