diff --git a/src/it/php/web/unittest/IntegrationTest.class.php b/src/it/php/web/unittest/IntegrationTest.class.php index 3096af75..945d0e6e 100755 --- a/src/it/php/web/unittest/IntegrationTest.class.php +++ b/src/it/php/web/unittest/IntegrationTest.class.php @@ -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 { @@ -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 { diff --git a/src/main/php/web/handler/WebSocket.class.php b/src/main/php/web/handler/WebSocket.class.php index 8d3cdb1a..43be3610 100755 --- a/src/main/php/web/handler/WebSocket.class.php +++ b/src/main/php/web/handler/WebSocket.class.php @@ -1,5 +1,6 @@ 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; } /** @@ -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'); @@ -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, - ]]; } } \ No newline at end of file diff --git a/src/test/php/web/unittest/handler/WebSocketTest.class.php b/src/test/php/web/unittest/handler/WebSocketTest.class.php index 392ec832..8b2ca030 100755 --- a/src/test/php/web/unittest/handler/WebSocketTest.class.php +++ b/src/test/php/web/unittest/handler/WebSocketTest.class.php @@ -1,6 +1,6 @@ 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) { }); @@ -30,6 +67,7 @@ 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', ]))); @@ -37,9 +75,44 @@ public function switching_protocols() { 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', @@ -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',