Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/Ratchet/WebSocket/StringHeaderResponse.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<?php
namespace Ratchet\WebSocket;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Message\MessageInterface;

/**
* [internal] A PSR-7 response that casts non-string header values to string before storing them.
*
* guzzlehttp/psr7 2.11+ raises `E_USER_DEPRECATED` when a non-string scalar is passed to
* `withHeader()`/`withAddedHeader()`, and guzzlehttp/psr7 3.0 will reject it outright.
* `Ratchet\RFC6455\Handshake\ServerNegotiator::handshake()` sets `Sec-WebSocket-Version` from
* `getVersionNumber()`, which the RFC6455 interface declares as `int`, so every successful
* WebSocket handshake trips that deprecation. Ratchet supplies the response factory the negotiator
* builds from, so normalising here keeps the int from ever reaching guzzle.
*
* Only reachable with ratchet/rfc6455 ^0.4 (PHP 7.4+, guzzlehttp/psr7 ^2), which is the only
* version that accepts a response factory. On ratchet/rfc6455 ^0.3 the negotiator constructs its
* own response and there is no injection point, so that path is unaffected.
*
* @internal used internally only, should not be referenced directly
* @see StringHeaderResponseFactory
*/
class StringHeaderResponse extends Response {
/**
* {@inheritdoc}
*/
public function withHeader($header, $value): MessageInterface {
return parent::withHeader($header, self::stringifyHeaderValue($value));
}

/**
* {@inheritdoc}
*/
public function withAddedHeader($header, $value): MessageInterface {
return parent::withAddedHeader($header, self::stringifyHeaderValue($value));
}

/**
* Cast the values guzzle deprecates (any non-string scalar, plus null) to string.
* Anything else is handed through untouched so guzzle still validates it.
*
* @param mixed $value
* @return mixed
*/
private static function stringifyHeaderValue($value) {
if (is_array($value)) {
foreach ($value as $key => $item) {
if (self::needsCast($item)) {
$value[$key] = (string)$item;
}
}

return $value;
}

return self::needsCast($value) ? (string)$value : $value;
}

/**
* @param mixed $value
* @return bool
*/
private static function needsCast($value) {
return !is_string($value) && (is_scalar($value) || null === $value);
}
}
22 changes: 22 additions & 0 deletions src/Ratchet/WebSocket/StringHeaderResponseFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php
namespace Ratchet\WebSocket;
use Psr\Http\Message\ResponseFactoryInterface;
use Psr\Http\Message\ResponseInterface;

/**
* [internal] Builds the responses `Ratchet\RFC6455\Handshake\ServerNegotiator` returns.
*
* Substituted for `GuzzleHttp\Psr7\HttpFactory` so the negotiator's handshake response tolerates
* the `int` it sets `Sec-WebSocket-Version` to without raising a guzzlehttp/psr7 deprecation.
*
* @internal used internally only, should not be referenced directly
* @see StringHeaderResponse
*/
class StringHeaderResponseFactory implements ResponseFactoryInterface {
/**
* {@inheritdoc}
*/
public function createResponse(int $code = 200, string $reasonPhrase = ''): ResponseInterface {
return new StringHeaderResponse($code, [], null, '1.1', $reasonPhrase);
}
}
3 changes: 1 addition & 2 deletions src/Ratchet/WebSocket/WsServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
use Ratchet\RFC6455\Handshake\ServerNegotiator;
use Ratchet\RFC6455\Handshake\RequestVerifier;
use React\EventLoop\LoopInterface;
use GuzzleHttp\Psr7\HttpFactory;
use GuzzleHttp\Psr7\Message;

/**
Expand Down Expand Up @@ -91,7 +90,7 @@ public function __construct(ComponentInterface $component) {
if (self::isRFC6455v03()) {
$this->handshakeNegotiator = new ServerNegotiator(new RequestVerifier);
} else {
$this->handshakeNegotiator = new ServerNegotiator(new RequestVerifier, new HttpFactory);
$this->handshakeNegotiator = new ServerNegotiator(new RequestVerifier, new StringHeaderResponseFactory);
}

$this->handshakeNegotiator->setStrictSubProtocolCheck(true);
Expand Down
110 changes: 110 additions & 0 deletions tests/unit/WebSocket/WsServerHandshakeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php
namespace Ratchet\WebSocket;
use PHPUnit\Framework\TestCase;
use Ratchet\Mock\Connection;
use Ratchet\NullComponent;

/**
* @covers Ratchet\WebSocket\WsServer
* @covers Ratchet\WebSocket\StringHeaderResponse
* @covers Ratchet\WebSocket\StringHeaderResponseFactory
*/
class WsServerHandshakeTest extends TestCase {
/**
* The response factory is only injectable with ratchet/rfc6455 ^0.4 on guzzlehttp/psr7 ^2;
* older combinations have no injection point and are not expected to pass these.
*/
protected function requireInjectableNegotiator() {
if (!class_exists('GuzzleHttp\Psr7\HttpFactory') || !interface_exists('Psr\Http\Message\ResponseFactoryInterface')) {
$this->markTestSkipped('Requires guzzlehttp/psr7 ^2');
}

$reflection = new \ReflectionClass('Ratchet\RFC6455\Handshake\ServerNegotiator');
if ($reflection->getMethod('__construct')->getNumberOfRequiredParameters() === 1) {
$this->markTestSkipped('Requires ratchet/rfc6455 ^0.4');
}
}

protected function newUpgradeRequest() {
return new \GuzzleHttp\Psr7\Request('GET', 'ws://localhost/echo', array(
'Host' => 'localhost'
, 'Upgrade' => 'websocket'
, 'Connection' => 'Upgrade'
, 'Sec-WebSocket-Key' => 'x3JJHMbDL1EzLkh9GBhXDw=='
, 'Sec-WebSocket-Version' => '13'
));
}

/**
* guzzlehttp/psr7 2.11+ deprecates non-string header values and 3.0 rejects them outright.
* The negotiator sets Sec-WebSocket-Version from an int, so without the response factory
* substituted in WsServer every successful handshake raises a deprecation.
*/
public function testHandshakeRaisesNoDeprecation() {
$this->requireInjectableNegotiator();

$raised = array();
set_error_handler(function ($errno, $errstr) use (&$raised) {
$raised[] = $errstr;

return true;
}, E_DEPRECATED | E_USER_DEPRECATED);

try {
$server = new WsServer(new NullComponent);
$server->onOpen(new Connection, $this->newUpgradeRequest());
} catch (\Exception $e) {
restore_error_handler();
throw $e;
}

restore_error_handler();

$this->assertSame(array(), $raised, "Handshake raised: " . implode('; ', $raised));
}

public function testHandshakeStillSucceeds() {
$this->requireInjectableNegotiator();

$conn = new Connection;
$server = new WsServer(new NullComponent);
$server->onOpen($conn, $this->newUpgradeRequest());

$this->assertStringStartsWith('HTTP/1.1 101', $conn->last['send']);
$this->assertFalse($conn->last['close']);
}

/**
* A rejected upgrade is the one response that actually carries Sec-WebSocket-Version out to
* the client, so it is where a mangled cast would be visible on the wire.
*/
public function testRejectedUpgradeSendsVersionHeaderAsString() {
$this->requireInjectableNegotiator();

$request = new \GuzzleHttp\Psr7\Request('GET', 'ws://localhost/echo', array(
'Host' => 'localhost'
, 'Sec-WebSocket-Key' => 'x3JJHMbDL1EzLkh9GBhXDw=='
, 'Sec-WebSocket-Version' => '13'
));

$conn = new Connection;
$server = new WsServer(new NullComponent);
$server->onOpen($conn, $request);

$this->assertStringStartsWith('HTTP/1.1 426', $conn->last['send']);
$this->assertTrue(false !== strpos($conn->last['send'], "Sec-WebSocket-Version: 13\r\n"), $conn->last['send']);
}

public function testResponseCastsScalarHeaderValuesToString() {
$this->requireInjectableNegotiator();

$factory = new StringHeaderResponseFactory;
$response = $factory->createResponse();

$this->assertSame(array('13'), $response->withHeader('Sec-WebSocket-Version', 13)->getHeader('Sec-WebSocket-Version'));
$this->assertSame(array('1.5'), $response->withHeader('X-Float', 1.5)->getHeader('X-Float'));
$this->assertSame(array('7', '9'), $response->withHeader('X-List', array(7, 9))->getHeader('X-List'));
$this->assertSame(array('kept'), $response->withHeader('X-String', 'kept')->getHeader('X-String'));
$this->assertSame(array('13'), $response->withAddedHeader('X-Added', 13)->getHeader('X-Added'));
}
}