Skip to content
Merged
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
4 changes: 2 additions & 2 deletions src/it/php/web/unittest/IntegrationTest.class.php
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ public function with_large_cookie($length) {
public function websocket_message($input, $output) {
try {
$ws= new WebSocket($this->server->connection, '/ws');
$ws->connect();
$ws->connect(['Origin' => 'http://localhost', 'Host' => 'localhost:80']);
$ws->send($input);
$result= $ws->receive();
} finally {
Expand All @@ -183,7 +183,7 @@ public function websocket_message($input, $output) {
public function invalid_utf8_passed_to_websocket_text_message() {
try {
$ws= new WebSocket($this->server->connection, '/ws');
$ws->connect();
$ws->connect(['Origin' => 'http://localhost', 'Host' => 'localhost:80']);
$ws->send("\xfc");
$ws->receive();
} finally {
Expand Down
69 changes: 60 additions & 9 deletions src/main/php/web/handler/WebSocket.class.php
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<?php namespace web\handler;

use util\URI;
use web\Handler;
use web\io\EventSink;
use websocket\Listeners;
Expand All @@ -14,10 +15,55 @@ class WebSocket implements Handler {
const GUID= '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

private $listener;
private $allowed= [];

/** @param function(websocket.protocol.Connection, string|util.Bytes): var|websocket.Listener $listener */
public function __construct($listener) {
/**
* Creates a new websocket handler
*
* @param function(websocket.protocol.Connection, string|util.Bytes): var|websocket.Listener $listener
* @param string[] $origins
*/
public function __construct($listener, array $origins= []) {
$this->listener= Listeners::cast($listener);
foreach ($origins as $allowed) {
$this->allowed[]= '#^'.strtr(preg_quote($allowed, '#'), ['\\*' => '.+']).'$#i';
}
}

/**
* Returns canonicalized base URI
*
* @param util.URI $uri
* @return string
*/
private function base($uri) {
static $ports= ['http' => 80, 'https' => 443];

return $uri->scheme().'://'.$uri->host().':'.($uri->port() ?? $ports[$uri->scheme()] ?? 0);
}

/**
* Verifies request `Origin` header matches the allowed origins. This
* header cannot be set by client-side JavaScript in browsers!
*
* @param web.Request $request
* @param web.Response $response
* @return bool
*/
public function verify($request, $response) {
if ($origin= $request->header('Origin')) {
$base= $this->base(new URI($origin));
foreach ($this->allowed as $pattern) {
if (preg_match($pattern, $base)) return true;
}

// Same-origin policy
if (0 === strcasecmp($this->base($request->uri()), $base)) return true;
}

$response->answer(403);
$response->send('Origin not allowed', 'text/plain');
return false;
}

/**
Expand All @@ -30,15 +76,26 @@ public function __construct($listener) {
public function handle($request, $response) {
switch ($version= (int)$request->header('Sec-WebSocket-Version')) {
case 13: // RFC 6455
if (!$this->verify($request, $response)) return;

$key= $request->header('Sec-WebSocket-Key');
$response->answer(101);
$response->header('Sec-WebSocket-Accept', base64_encode(sha1($key.self::GUID, true)));
foreach ($this->listener->protocols ?? [] as $protocol) {
$response->header('Sec-WebSocket-Protocol', $protocol, true);
}
break;

// Signal server implementation to switch protocols
yield 'connection' => ['websocket', [
'path' => $request->uri()->resource(),
'headers' => $request->headers(),
'listener' => $this->listener,
]];
return;

case 9: // Reserved version, use for WS <-> SSE translation
if (!$this->verify($request, $response)) return;

$response->answer(200);
$response->header('Content-Type', 'text/event-stream');
$response->header('Transfer-Encoding', 'chunked');
Expand All @@ -60,11 +117,5 @@ public function handle($request, $response) {
$response->send('This service does not support WebSocket version '.$version, 'text/plain');
return;
}

yield 'connection' => ['websocket', [
'path' => $request->uri()->resource(),
'headers' => $request->headers(),
'listener' => $this->listener,
]];
}
}
80 changes: 77 additions & 3 deletions src/test/php/web/unittest/handler/WebSocketTest.class.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<?php namespace web\unittest\handler;

use test\{Assert, Test};
use test\{Assert, Test, Values};
use util\Bytes;
use web\handler\WebSocket;
use web\io\{TestInput, TestOutput};
Expand All @@ -9,7 +9,7 @@
class WebSocketTest {

/** Handles a request and returns the response generated from the handler */
private function handle(Request $request): Response {
private function handle(Request $request, $origins= ['*']): Response {
$response= new Response(new TestOutput());
$echo= function($conn, $payload) {
if ($payload instanceof Bytes) {
Expand All @@ -18,10 +18,47 @@ private function handle(Request $request): Response {
$conn->send('Re: '.$payload);
}
};
(new WebSocket($echo))->handle($request, $response)->next();
(new WebSocket($echo, $origins))->handle($request, $response)->next();
return $response;
}

/** @return iterable */
private function same() {

// By default, enforces same-origin policy
yield ['http://localhost:80', 101];
yield ['http://localhost', 101];
yield ['http://Localhost', 101];

// Not allowed: Differing hosts, ports or scheme
yield ['http://localhost:81', 403];
yield ['http://example.localhost', 403];
yield ['http://localhost.example.com', 403];
yield ['https://localhost', 403];
yield ['http://localhost:81@evil.example.com', 403];
yield ['http://evil.example.com/#http://localhost', 403];
yield ['http://evil.example.com/?page=http://localhost', 403];
}

/** @return iterable */
private function origins() {

// We allow all ports and schemes on localhost
yield ['http://localhost', 101];
yield ['https://localhost', 101];
yield ['http://localhost:8080', 101];
yield ['https://localhost:8443', 101];
yield ['http://Localhost', 101];
yield ['http://api.localhost', 101];

// Not allowed: Other localhost subdomains and unrelated domains
yield ['http://example.localhost', 403];
yield ['http://localhost.example.com', 403];
yield ['http://localhost:81@evil.example.com', 403];
yield ['http://evil.example.com/#http://localhost', 403];
yield ['http://evil.example.com/?page=http://localhost', 403];
}

#[Test]
public function can_create() {
new WebSocket(function($conn, $payload) { });
Expand All @@ -30,16 +67,52 @@ public function can_create() {
#[Test]
public function switching_protocols() {
$response= $this->handle(new Request(new TestInput('GET', '/ws', [
'Origin' => 'http://localhost:8080',
'Sec-WebSocket-Version' => 13,
'Sec-WebSocket-Key' => 'test',
])));
Assert::equals(101, $response->status());
Assert::equals('tNpbgC8ZQDOcSkHAWopKzQjJ1hI=', $response->headers()['Sec-WebSocket-Accept']);
}

#[Test]
public function missing_origin() {
$request= new Request(new TestInput('GET', '/ws', [
'Sec-WebSocket-Version' => 13,
'Sec-WebSocket-Key' => 'test',
]));

Assert::equals(403, $this->handle($request)->status());
}

#[Test, Values(from: 'same')]
public function verify_same_origin($origin, $expected) {
$request= new Request(new TestInput('GET', '/ws', [
'Origin' => $origin,
'Host' => 'localhost:80',
'Sec-WebSocket-Version' => 13,
'Sec-WebSocket-Key' => 'test',
]));

Assert::equals($expected, $this->handle($request, [])->status());
}

#[Test, Values(from: 'origins')]
public function verify_localhost_origin($origin, $expected) {
$request= new Request(new TestInput('GET', '/ws', [
'Origin' => $origin,
'Host' => 'localhost:8080',
'Sec-WebSocket-Version' => 13,
'Sec-WebSocket-Key' => 'test',
]));

Assert::equals($expected, $this->handle($request, ['*://localhost:*', '*://api.localhost:*'])->status());
}

#[Test]
public function translate_text_message() {
$response= $this->handle(new Request(new TestInput('POST', '/ws', [
'Origin' => 'http://localhost:8080',
'Sec-WebSocket-Version' => 9,
'Sec-WebSocket-Id' => 123,
'Content-Type' => 'text/plain',
Expand All @@ -53,6 +126,7 @@ public function translate_text_message() {
#[Test]
public function translate_binary_message() {
$response= $this->handle(new Request(new TestInput('POST', '/ws', [
'Origin' => 'http://localhost:8080',
'Sec-WebSocket-Version' => 9,
'Sec-WebSocket-Id' => 123,
'Content-Type' => 'application/octet-stream',
Expand Down
Loading