Completed
Push — master ( ab923c...f43d3a )
by Arthur
01:40
created

WscMain::getStreamContext()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 15
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 15
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 7
nc 3
nop 0
1
<?php
2
3
namespace WSSC\Components;
4
5
use WSSC\Contracts\CommonsContract;
6
use WSSC\Contracts\WscCommonsContract;
7
use WSSC\Exceptions\BadOpcodeException;
8
use WSSC\Exceptions\BadUriException;
9
use WSSC\Exceptions\ConnectionException;
10
11
class WscMain implements WscCommonsContract
12
{
13
14
    private $socket;
15
    private $isConnected = false;
16
    private $isClosing = false;
17
    private $lastOpcode;
18
    private $closeStatus;
19
    private $hugePayload;
20
21
    private static $opcodes = [
22
        CommonsContract::EVENT_TYPE_CONTINUATION => 0,
23
        CommonsContract::EVENT_TYPE_TEXT         => 1,
24
        CommonsContract::EVENT_TYPE_BINARY       => 2,
25
        CommonsContract::EVENT_TYPE_CLOSE        => 8,
26
        CommonsContract::EVENT_TYPE_PING         => 9,
27
        CommonsContract::EVENT_TYPE_PONG         => 10,
28
    ];
29
30
    protected $socketUrl = '';
31
    protected $options = [];
32
33
    /**
34
     * @throws \InvalidArgumentException
35
     * @throws BadUriException
36
     * @throws ConnectionException
37
     * @throws \Exception
38
     */
39
    protected function connect() : void
40
    {
41
        $urlParts = parse_url($this->socketUrl);
42
        $scheme = $urlParts['scheme'];
43
        $host = $urlParts['host'];
44
        $user = isset($urlParts['user']) ? $urlParts['user'] : '';
45
        $pass = isset($urlParts['pass']) ? $urlParts['pass'] : '';
46
        $port = isset($urlParts['port']) ? $urlParts['port'] : ($scheme === 'wss' ? 443 : 80);
47
48
        $pathWithQuery = $this->getPathWithQuery($urlParts);
0 ignored issues
show
Security Bug introduced by
It seems like $urlParts defined by parse_url($this->socketUrl) on line 41 can also be of type false; however, WSSC\Components\WscMain::getPathWithQuery() does only seem to accept array, did you maybe forget to handle an error condition?

This check looks for type mismatches where the missing type is false. This is usually indicative of an error condtion.

Consider the follow example

<?php

function getDate($date)
{
    if ($date !== null) {
        return new DateTime($date);
    }

    return false;
}

This function either returns a new DateTime object or false, if there was an error. This is a typical pattern in PHP programming to show that an error has occurred without raising an exception. The calling code should check for this returned false before passing on the value to another function or method that may not be able to handle a false.

Loading history...
49
        if (in_array($scheme, ['ws', 'wss'], true) === false) {
50
            throw new BadUriException(
51
                "Url should have scheme ws or wss, not '$scheme' from URI '$this->socketUrl' ."
52
            );
53
        }
54
55
        $hostUri = ($scheme === 'wss' ? 'ssl' : 'tcp') . '://' . $host;
56
        // Set the stream context options if they're already set in the config
57
        $context = $this->getStreamContext();
58
        $this->socket = @stream_socket_client(
59
            $hostUri . ':' . $port, $errno, $errstr, $this->options['timeout'], STREAM_CLIENT_CONNECT, $context
60
        );
61
        if ($this->socket === false) {
62
            throw new ConnectionException(
63
                "Could not open socket to \"$host:$port\": $errstr ($errno)."
64
            );
65
        }
66
67
        // Set timeout on the stream as well.
68
        stream_set_timeout($this->socket, $this->options['timeout']);
69
70
        // Generate the WebSocket key.
71
        $key = $this->generateKey();
72
        $headers = [
73
            'Host'                  => $host . ':' . $port,
74
            'User-Agent'            => 'websocket-client-php',
75
            'Connection'            => 'Upgrade',
76
            'Upgrade'               => 'WebSocket',
77
            'Sec-WebSocket-Key'     => $key,
78
            'Sec-Websocket-Version' => '13',
79
        ];
80
81
        // Handle basic authentication.
82
        if ($user || $pass) {
83
            $headers['authorization'] = 'Basic ' . base64_encode($user . ':' . $pass) . "\r\n";
84
        }
85
        // Add and override with headers from options.
86
        if (isset($this->options['headers'])) {
87
            $headers = array_merge($headers, $this->options['headers']);
88
        }
89
90
        $header = $this->getHeaders($pathWithQuery, $headers);
91
        // Send headers.
92
        $this->write($header);
93
        // Get server response header 
94
        $response = stream_get_line($this->socket, self::DEFAULT_RESPONSE_HEADER, "\r\n\r\n");
95
        /// @todo Handle version switching
96
        // Validate response.
97
        if (!preg_match(self::SEC_WEBSOCKET_ACCEPT_PTTRN, $response, $matches)) {
98
            $address = $scheme . '://' . $host . $pathWithQuery;
99
            throw new ConnectionException(
100
                "Connection to '{$address}' failed: Server sent invalid upgrade response:\n"
101
                . $response
102
            );
103
        }
104
105
        $keyAccept = trim($matches[1]);
106
        $expectedResonse = base64_encode(pack('H*', sha1($key . self::SERVER_KEY_ACCEPT)));
107
        if ($keyAccept !== $expectedResonse) {
108
            throw new ConnectionException('Server sent bad upgrade response.');
109
        }
110
        $this->isConnected = true;
111
    }
112
113
    /**
114
     * @return mixed|resource
115
     * @throws \InvalidArgumentException
116
     */
117
    private function getStreamContext()
118
    {
119
        if (isset($this->options['context'])) {
120
            // Suppress the error since we'll catch it below
121
            if (@get_resource_type($this->options['context']) === 'stream-context') {
122
                return $this->options['context'];
123
            }
124
125
            throw new \InvalidArgumentException(
126
                "Stream context in \$options['context'] isn't a valid context"
127
            );
128
        }
129
130
        return stream_context_create();
131
    }
132
133
    private function getPathWithQuery(array $urlParts) : string
134
    {
135
        $path = isset($urlParts['path']) ? $urlParts['path'] : '/';
136
        $query = isset($urlParts['query']) ? $urlParts['query'] : '';
137
        $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
138
        $pathWithQuery = $path;
139
        if (!empty($query)) {
140
            $pathWithQuery .= '?' . $query;
141
        }
142
        if (!empty($fragment)) {
143
            $pathWithQuery .= '#' . $fragment;
144
        }
145
        return $pathWithQuery;
146
    }
147
148
    /**
149
     * @param string $pathWithQuery
150
     * @param array $headers
151
     * @return string
152
     */
153
    private function getHeaders(string $pathWithQuery, array $headers) : string
154
    {
155
        return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
156
            . implode(
157
                "\r\n", array_map(
158
                    function ($key, $value) {
159
                        return "$key: $value";
160
                    }, array_keys($headers), $headers
161
                )
162
            )
163
            . "\r\n\r\n";
164
    }
165
166
    /**
167
     * @return string
168
     */
169
    public function getLastOpcode() : string
170
    {
171
        return $this->lastOpcode;
172
    }
173
174
    /**
175
     * @return int
176
     */
177
    public function getCloseStatus() : int
178
    {
179
        return $this->closeStatus;
180
    }
181
182
    /**
183
     * @return bool
184
     */
185
    public function isConnected() : bool
186
    {
187
        return $this->isConnected;
188
    }
189
190
    /**
191
     * @param int $timeout
192
     * @param null $microSecs
193
     */
194
    public function setTimeout(int $timeout, $microSecs = null)
195
    {
196
        $this->options['timeout'] = $timeout;
197
198
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
199
            stream_set_timeout($this->socket, $timeout, $microSecs);
200
        }
201
    }
202
203
    public function setFragmentSize($fragmentSize)
204
    {
205
        $this->options['fragment_size'] = $fragmentSize;
206
        return $this;
207
    }
208
209
    public function getFragmentSize()
210
    {
211
        return $this->options['fragment_size'];
212
    }
213
214
    public function send($payload, $opcode = 'text', $masked = true)
215
    {
216
        if (!$this->isConnected) {
217
            $this->connect();
218
        }
219
        if (array_key_exists($opcode, self::$opcodes) === false) {
220
            throw new BadOpcodeException("Bad opcode '$opcode'.  Try 'text' or 'binary'.");
221
        }
222
        echo $payload;
223
        // record the length of the payload
224
        $payload_length = strlen($payload);
225
226
        $fragment_cursor = 0;
227
        // while we have data to send
228
        while ($payload_length > $fragment_cursor) {
229
            // get a fragment of the payload
230
            $sub_payload = substr($payload, $fragment_cursor, $this->options['fragment_size']);
231
232
            // advance the cursor
233
            $fragment_cursor += $this->options['fragment_size'];
234
235
            // is this the final fragment to send?
236
            $final = $payload_length <= $fragment_cursor;
237
238
            // send the fragment
239
            $this->sendFragment($final, $sub_payload, $opcode, $masked);
240
241
            // all fragments after the first will be marked a continuation
242
            $opcode = 'continuation';
243
        }
244
    }
245
246
    /**
247
     * @param $final
248
     * @param $payload
249
     * @param $opcode
250
     * @param $masked
251
     * @throws ConnectionException
252
     * @throws \Exception
253
     */
254
    protected function sendFragment($final, $payload, $opcode, $masked)
255
    {
256
        // Binary string for header.
257
        $frameHeadBin = '';
258
        // Write FIN, final fragment bit.
259
        $frameHeadBin .= (bool)$final ? '1' : '0';
260
        // RSV 1, 2, & 3 false and unused.
0 ignored issues
show
Unused Code Comprehensibility introduced by
43% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
261
        $frameHeadBin .= '000';
262
        // Opcode rest of the byte.
263
        $frameHeadBin .= sprintf('%04b', self::$opcodes[$opcode]);
264
        // Use masking?
265
        $frameHeadBin .= $masked ? '1' : '0';
266
267
        // 7 bits of payload length...
268
        $payloadLen = strlen($payload);
269
        if ($payloadLen > self::MAX_BYTES_READ) {
270
            $frameHeadBin .= decbin(self::MASK_127);
271
            $frameHeadBin .= sprintf('%064b', $payloadLen);
272
        } else if ($payloadLen > self::MASK_125) {
273
            $frameHeadBin .= decbin(self::MASK_126);
274
            $frameHeadBin .= sprintf('%016b', $payloadLen);
275
        } else {
276
            $frameHeadBin .= sprintf('%07b', $payloadLen);
277
        }
278
279
        $frame = '';
280
281
        // Write frame head to frame.
282
        foreach (str_split($frameHeadBin, 8) as $binstr) {
283
            $frame .= chr(bindec($binstr));
284
        }
285
        // Handle masking
286
        if ($masked) {
287
            // generate a random mask:
288
            $mask = '';
289 View Code Duplication
            for ($i = 0; $i < 4; $i++) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
290
                $mask .= chr(random_int(0, 255));
291
            }
292
            $frame .= $mask;
293
        }
294
295
        // Append payload to frame:
296 View Code Duplication
        for ($i = 0; $i < $payloadLen; $i++) {
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated across your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
297
            $frame .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i];
0 ignored issues
show
Bug introduced by
The variable $mask does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
298
        }
299
300
        $this->write($frame);
301
    }
302
303
    public function receive()
304
    {
305
        if (!$this->isConnected) {
306
            $this->connect();
307
        }
308
        $this->hugePayload = '';
309
310
        $response = NULL;
311
        while (NULL === $response) {
312
            $response = $this->receiveFragment();
313
        }
314
        return $response;
315
    }
316
317
    /**
318
     * @return string
319
     * @throws BadOpcodeException
320
     * @throws ConnectionException
321
     */
322
    protected function receiveFragment() : string
323
    {
324
        // Just read the main fragment information first.
325
        $data = $this->read(2);
326
327
        // Is this the final fragment?  // Bit 0 in byte 0
328
        /// @todo Handle huge payloads with multiple fragments.
329
        $final = (bool)(ord($data[0]) & 1 << 7);
330
331
        // Parse opcode
332
        $opcode_int = ord($data[0]) & 31; // Bits 4-7
333
        $opcode_ints = array_flip(self::$opcodes);
334
        if (!array_key_exists($opcode_int, $opcode_ints)) {
335
            throw new ConnectionException("Bad opcode in websocket frame: $opcode_int");
336
        }
337
        $opcode = $opcode_ints[$opcode_int];
338
339
        // record the opcode if we are not receiving a continutation fragment
340
        if ($opcode !== 'continuation') {
341
            $this->lastOpcode = $opcode;
342
        }
343
344
        // Masking?
345
        $mask = (bool)(ord($data[1]) >> 7);  // Bit 0 in byte 1
346
347
        $payload = '';
348
349
        // Payload length
350
        $payload_length = (int)ord($data[1]) & self::MASK_127; // Bits 1-7 in byte 1
351
        if ($payload_length > self::MASK_125) {
352
            if ($payload_length === self::MASK_126) {
353
                $data = $this->read(2); // 126: Payload is a 16-bit unsigned int
354
            } else {
355
                $data = $this->read(8); // 127: Payload is a 64-bit unsigned int
356
            }
357
            $payload_length = bindec(self::sprintB($data));
358
        }
359
360
        $maskingKey = '';
361
        // Get masking key.
362
        if ($mask) {
363
            $maskingKey = $this->read(4);
364
        }
365
        // Get the actual payload, if any (might not be for e.g. close frames.
366
        if ($payload_length > 0) {
367
            $data = $this->read($payload_length);
368
369
            if ($mask) {
370
                // Unmask payload.
371
                for ($i = 0; $i < $payload_length; $i++) {
372
                    $payload .= ($data[$i] ^ $maskingKey[$i % 4]);
373
                }
374
            } else {
375
                $payload = $data;
376
            }
377
        }
378
379
        if ($opcode === CommonsContract::EVENT_TYPE_CLOSE) {
380
            // Get the close status.
381
            if ($payload_length >= 2) {
382
                $status_bin = $payload[0] . $payload[1];
383
                $status = bindec(sprintf('%08b%08b', ord($payload[0]), ord($payload[1])));
384
                $this->closeStatus = $status;
385
                $payload = substr($payload, 2);
386
387
                if (!$this->isClosing) {
388
                    $this->send($status_bin . 'Close acknowledged: ' . $status, 'close'); // Respond.
389
                }
390
            }
391
392
            if ($this->isClosing) {
393
                $this->isClosing = false; // A close response, all done.
394
            }
395
396
            fclose($this->socket);
397
            $this->isConnected = false;
398
        }
399
400
        if (!$final) {
401
            $this->hugePayload .= $payload;
402
            return NULL;
403
        } // this is the last fragment, and we are processing a huge_payload
404
405
        if ($this->hugePayload) {
406
            $payload = $this->hugePayload .= $payload;
407
            $this->hugePayload = NULL;
408
        }
409
410
        return $payload;
411
    }
412
413
    /**
414
     * Tell the socket to close.
415
     *
416
     * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
417
     * @param string $message A closing message, max 125 bytes.
418
     * @return bool|null|string
419
     * @throws BadOpcodeException
420
     */
421
    public function close(int $status = 1000, string $message = 'ttfn')
422
    {
423
        $statusBin = sprintf('%016b', $status);
424
        $status_str = '';
425
        foreach (str_split($statusBin, 8) as $binstr) {
426
            $status_str .= chr(bindec($binstr));
427
        }
428
        $this->send($status_str . $message, 'close', true);
429
        $this->isClosing = true;
430
        return $this->receive(); // Receiving a close frame will close the socket now.
431
    }
432
433
    /**
434
     * @param $data
435
     * @throws ConnectionException
436
     */
437
    protected function write(string $data) : void
438
    {
439
        $written = fwrite($this->socket, $data);
440
441
        if ($written < strlen($data)) {
442
            throw new ConnectionException(
443
                "Could only write $written out of " . strlen($data) . " bytes."
444
            );
445
        }
446
    }
447
448
    /**
449
     * @param int $len
450
     * @return string
451
     * @throws ConnectionException
452
     */
453
    protected function read(int $len) : string
454
    {
455
        $data = '';
456
        while (($dataLen = strlen($data)) < $len) {
457
            $buff = fread($this->socket, $len - $dataLen);
458
            if ($buff === false) {
459
                $metadata = stream_get_meta_data($this->socket);
460
                throw new ConnectionException(
461
                    'Broken frame, read ' . strlen($data) . ' of stated '
462
                    . $len . ' bytes.  Stream state: '
463
                    . json_encode($metadata)
464
                );
465
            }
466
            if ($buff === '') {
467
                $metadata = stream_get_meta_data($this->socket);
468
                throw new ConnectionException(
469
                    'Empty read; connection dead?  Stream state: ' . json_encode($metadata)
470
                );
471
            }
472
            $data .= $buff;
473
        }
474
        return $data;
475
    }
476
477
    /**
478
     * Helper to convert a binary to a string of '0' and '1'.
479
     *
480
     * @param $string
481
     * @return string
482
     */
483
    protected static function sprintB(string $string) : string
484
    {
485
        $return = '';
486
        $strLen = strlen($string);
487
        for ($i = 0; $i < $strLen; $i++) {
488
            $return .= sprintf('%08b', ord($string[$i]));
489
        }
490
        return $return;
491
    }
492
493
    /**
494
     * Sec-WebSocket-Key generator
495
     *
496
     * @return string   the 16 character length key
497
     * @throws \Exception
498
     */
499
    private function generateKey() : string
500
    {
501
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
502
        $key = '';
503
        $chLen = strlen($chars);
504
        for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
505
            $key .= $chars[random_int(0, $chLen - 1)];
506
        }
507
        return base64_encode($key);
508
    }
509
510
}
511