Skip to content

Commit 7fb23cc

Browse files
authored
Merge pull request #126 from xp-forge/feature/ws-origin
Verify websocket origin
2 parents cc90136 + 1c53789 commit 7fb23cc

3 files changed

Lines changed: 139 additions & 14 deletions

File tree

src/it/php/web/unittest/IntegrationTest.class.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ public function with_large_cookie($length) {
170170
public function websocket_message($input, $output) {
171171
try {
172172
$ws= new WebSocket($this->server->connection, '/ws');
173-
$ws->connect();
173+
$ws->connect(['Origin' => 'http://localhost', 'Host' => 'localhost:80']);
174174
$ws->send($input);
175175
$result= $ws->receive();
176176
} finally {
@@ -183,7 +183,7 @@ public function websocket_message($input, $output) {
183183
public function invalid_utf8_passed_to_websocket_text_message() {
184184
try {
185185
$ws= new WebSocket($this->server->connection, '/ws');
186-
$ws->connect();
186+
$ws->connect(['Origin' => 'http://localhost', 'Host' => 'localhost:80']);
187187
$ws->send("\xfc");
188188
$ws->receive();
189189
} finally {

src/main/php/web/handler/WebSocket.class.php

Lines changed: 60 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<?php namespace web\handler;
22

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

1617
private $listener;
18+
private $allowed= [];
1719

18-
/** @param function(websocket.protocol.Connection, string|util.Bytes): var|websocket.Listener $listener */
19-
public function __construct($listener) {
20+
/**
21+
* Creates a new websocket handler
22+
*
23+
* @param function(websocket.protocol.Connection, string|util.Bytes): var|websocket.Listener $listener
24+
* @param string[] $origins
25+
*/
26+
public function __construct($listener, array $origins= []) {
2027
$this->listener= Listeners::cast($listener);
28+
foreach ($origins as $allowed) {
29+
$this->allowed[]= '#^'.strtr(preg_quote($allowed, '#'), ['\\*' => '.+']).'$#i';
30+
}
31+
}
32+
33+
/**
34+
* Returns canonicalized base URI
35+
*
36+
* @param util.URI $uri
37+
* @return string
38+
*/
39+
private function base($uri) {
40+
static $ports= ['http' => 80, 'https' => 443];
41+
42+
return $uri->scheme().'://'.$uri->host().':'.($uri->port() ?? $ports[$uri->scheme()] ?? 0);
43+
}
44+
45+
/**
46+
* Verifies request `Origin` header matches the allowed origins. This
47+
* header cannot be set by client-side JavaScript in browsers!
48+
*
49+
* @param web.Request $request
50+
* @param web.Response $response
51+
* @return bool
52+
*/
53+
public function verify($request, $response) {
54+
if ($origin= $request->header('Origin')) {
55+
$base= $this->base(new URI($origin));
56+
foreach ($this->allowed as $pattern) {
57+
if (preg_match($pattern, $base)) return true;
58+
}
59+
60+
// Same-origin policy
61+
if (0 === strcasecmp($this->base($request->uri()), $base)) return true;
62+
}
63+
64+
$response->answer(403);
65+
$response->send('Origin not allowed', 'text/plain');
66+
return false;
2167
}
2268

2369
/**
@@ -30,15 +76,26 @@ public function __construct($listener) {
3076
public function handle($request, $response) {
3177
switch ($version= (int)$request->header('Sec-WebSocket-Version')) {
3278
case 13: // RFC 6455
79+
if (!$this->verify($request, $response)) return;
80+
3381
$key= $request->header('Sec-WebSocket-Key');
3482
$response->answer(101);
3583
$response->header('Sec-WebSocket-Accept', base64_encode(sha1($key.self::GUID, true)));
3684
foreach ($this->listener->protocols ?? [] as $protocol) {
3785
$response->header('Sec-WebSocket-Protocol', $protocol, true);
3886
}
39-
break;
87+
88+
// Signal server implementation to switch protocols
89+
yield 'connection' => ['websocket', [
90+
'path' => $request->uri()->resource(),
91+
'headers' => $request->headers(),
92+
'listener' => $this->listener,
93+
]];
94+
return;
4095

4196
case 9: // Reserved version, use for WS <-> SSE translation
97+
if (!$this->verify($request, $response)) return;
98+
4299
$response->answer(200);
43100
$response->header('Content-Type', 'text/event-stream');
44101
$response->header('Transfer-Encoding', 'chunked');
@@ -60,11 +117,5 @@ public function handle($request, $response) {
60117
$response->send('This service does not support WebSocket version '.$version, 'text/plain');
61118
return;
62119
}
63-
64-
yield 'connection' => ['websocket', [
65-
'path' => $request->uri()->resource(),
66-
'headers' => $request->headers(),
67-
'listener' => $this->listener,
68-
]];
69120
}
70121
}

src/test/php/web/unittest/handler/WebSocketTest.class.php

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php namespace web\unittest\handler;
22

3-
use test\{Assert, Test};
3+
use test\{Assert, Test, Values};
44
use util\Bytes;
55
use web\handler\WebSocket;
66
use web\io\{TestInput, TestOutput};
@@ -9,7 +9,7 @@
99
class WebSocketTest {
1010

1111
/** Handles a request and returns the response generated from the handler */
12-
private function handle(Request $request): Response {
12+
private function handle(Request $request, $origins= ['*']): Response {
1313
$response= new Response(new TestOutput());
1414
$echo= function($conn, $payload) {
1515
if ($payload instanceof Bytes) {
@@ -18,10 +18,47 @@ private function handle(Request $request): Response {
1818
$conn->send('Re: '.$payload);
1919
}
2020
};
21-
(new WebSocket($echo))->handle($request, $response)->next();
21+
(new WebSocket($echo, $origins))->handle($request, $response)->next();
2222
return $response;
2323
}
2424

25+
/** @return iterable */
26+
private function same() {
27+
28+
// By default, enforces same-origin policy
29+
yield ['http://localhost:80', 101];
30+
yield ['http://localhost', 101];
31+
yield ['http://Localhost', 101];
32+
33+
// Not allowed: Differing hosts, ports or scheme
34+
yield ['http://localhost:81', 403];
35+
yield ['http://example.localhost', 403];
36+
yield ['http://localhost.example.com', 403];
37+
yield ['https://localhost', 403];
38+
yield ['http://localhost:81@evil.example.com', 403];
39+
yield ['http://evil.example.com/#http://localhost', 403];
40+
yield ['http://evil.example.com/?page=http://localhost', 403];
41+
}
42+
43+
/** @return iterable */
44+
private function origins() {
45+
46+
// We allow all ports and schemes on localhost
47+
yield ['http://localhost', 101];
48+
yield ['https://localhost', 101];
49+
yield ['http://localhost:8080', 101];
50+
yield ['https://localhost:8443', 101];
51+
yield ['http://Localhost', 101];
52+
yield ['http://api.localhost', 101];
53+
54+
// Not allowed: Other localhost subdomains and unrelated domains
55+
yield ['http://example.localhost', 403];
56+
yield ['http://localhost.example.com', 403];
57+
yield ['http://localhost:81@evil.example.com', 403];
58+
yield ['http://evil.example.com/#http://localhost', 403];
59+
yield ['http://evil.example.com/?page=http://localhost', 403];
60+
}
61+
2562
#[Test]
2663
public function can_create() {
2764
new WebSocket(function($conn, $payload) { });
@@ -30,16 +67,52 @@ public function can_create() {
3067
#[Test]
3168
public function switching_protocols() {
3269
$response= $this->handle(new Request(new TestInput('GET', '/ws', [
70+
'Origin' => 'http://localhost:8080',
3371
'Sec-WebSocket-Version' => 13,
3472
'Sec-WebSocket-Key' => 'test',
3573
])));
3674
Assert::equals(101, $response->status());
3775
Assert::equals('tNpbgC8ZQDOcSkHAWopKzQjJ1hI=', $response->headers()['Sec-WebSocket-Accept']);
3876
}
3977

78+
#[Test]
79+
public function missing_origin() {
80+
$request= new Request(new TestInput('GET', '/ws', [
81+
'Sec-WebSocket-Version' => 13,
82+
'Sec-WebSocket-Key' => 'test',
83+
]));
84+
85+
Assert::equals(403, $this->handle($request)->status());
86+
}
87+
88+
#[Test, Values(from: 'same')]
89+
public function verify_same_origin($origin, $expected) {
90+
$request= new Request(new TestInput('GET', '/ws', [
91+
'Origin' => $origin,
92+
'Host' => 'localhost:80',
93+
'Sec-WebSocket-Version' => 13,
94+
'Sec-WebSocket-Key' => 'test',
95+
]));
96+
97+
Assert::equals($expected, $this->handle($request, [])->status());
98+
}
99+
100+
#[Test, Values(from: 'origins')]
101+
public function verify_localhost_origin($origin, $expected) {
102+
$request= new Request(new TestInput('GET', '/ws', [
103+
'Origin' => $origin,
104+
'Host' => 'localhost:8080',
105+
'Sec-WebSocket-Version' => 13,
106+
'Sec-WebSocket-Key' => 'test',
107+
]));
108+
109+
Assert::equals($expected, $this->handle($request, ['*://localhost:*', '*://api.localhost:*'])->status());
110+
}
111+
40112
#[Test]
41113
public function translate_text_message() {
42114
$response= $this->handle(new Request(new TestInput('POST', '/ws', [
115+
'Origin' => 'http://localhost:8080',
43116
'Sec-WebSocket-Version' => 9,
44117
'Sec-WebSocket-Id' => 123,
45118
'Content-Type' => 'text/plain',
@@ -53,6 +126,7 @@ public function translate_text_message() {
53126
#[Test]
54127
public function translate_binary_message() {
55128
$response= $this->handle(new Request(new TestInput('POST', '/ws', [
129+
'Origin' => 'http://localhost:8080',
56130
'Sec-WebSocket-Version' => 9,
57131
'Sec-WebSocket-Id' => 123,
58132
'Content-Type' => 'application/octet-stream',

0 commit comments

Comments
 (0)