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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

All notable changes to ext-websocket are documented here.

## Unreleased

### Added

- Added `WebSocket\Server::onHandshake()` with `WebSocket\Request`, `WebSocket\HandshakeResponse`, and `WebSocket\HandshakeException` for pre-upgrade handshake validation.

## 1.2.1 - 2026-05-23

### Added
Expand Down
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ Methods:
| `__construct(ServerOptions\|array $options = [])` | Create a server |
| `listen(string $host, int $port): void` | Bind address for `run()` |
| `subprotocols(string ...$protocols): void` | Configure supported `Sec-WebSocket-Protocol` tokens |
| `onHandshake(Closure $handler): void` | Accept or reject valid HTTP Upgrade requests before `101 Switching Protocols` |
| `onOpen(Closure $handler): void` | Register upgraded connection callback |
| `onMessage(Closure $handler): void` | Register text/binary message callback |
| `onClose(Closure $handler): void` | Register close callback |
Expand All @@ -124,6 +125,43 @@ Methods:
| `stop(): void` | Request shutdown |
| `getDriver(): string` | Return selected I/O driver |

Handshake callbacks receive a `WebSocket\Request`. Return normally to continue the WebSocket upgrade, or throw `WebSocket\HandshakeException` to reject it before `101 Switching Protocols` is sent:

```php
$server->onHandshake(static function (WebSocket\Request $request): void {
if ($request->header('Origin') !== 'https://app.test') {
throw new WebSocket\HandshakeException(
new WebSocket\HandshakeResponse(403, ['X-Reject' => 'origin'])
);
}
});
```

### `WebSocket\Request`

| Method / property | Description |
|---|---|
| `header(string $name): ?string` | Return a case-insensitive request header value |
| `readonly string $method` | HTTP request method |
| `readonly string $target` | HTTP request target |
| `readonly array $headers` | Lower-case request headers |

### `WebSocket\HandshakeResponse`

| Method / property | Description |
|---|---|
| `__construct(int $status = 403, array $headers = [], string $body = '')` | Create a custom handshake rejection response |
| `readonly int $status` | HTTP status code |
| `readonly array $headers` | HTTP response headers |
| `readonly string $body` | HTTP response body |

### `WebSocket\HandshakeException`

| Method / property | Description |
|---|---|
| `__construct(?HandshakeResponse $response = null)` | Create a handshake rejection exception; defaults to `403 Forbidden` |
| `readonly HandshakeResponse $response` | HTTP response sent before closing the connection |

### `WebSocket\Connection`

| Method / property | Description |
Expand Down
4 changes: 4 additions & 0 deletions php_websocket.h
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ typedef struct _websocket_server_object {
zval options;
zval subprotocols;
zval on_open;
zval on_handshake;
zval on_message;
zval on_close;
zval on_error;
Expand Down Expand Up @@ -142,6 +143,9 @@ typedef struct _websocket_connection_object {

extern zend_class_entry *websocket_server_ce;
extern zend_class_entry *websocket_server_options_ce;
extern zend_class_entry *websocket_request_ce;
extern zend_class_entry *websocket_handshake_response_ce;
extern zend_class_entry *websocket_handshake_exception_ce;
extern zend_class_entry *websocket_connection_ce;
extern zend_class_entry *websocket_message_type_ce;
extern zend_class_entry *websocket_frame_ce;
Expand Down
28 changes: 28 additions & 0 deletions tests/001-contracts.phpt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ websocket
<?php
var_dump(class_exists(WebSocket\Server::class));
var_dump(class_exists(WebSocket\ServerOptions::class));
var_dump(class_exists(WebSocket\Request::class));
var_dump(class_exists(WebSocket\HandshakeResponse::class));
var_dump(class_exists(WebSocket\HandshakeException::class));
var_dump(class_exists(WebSocket\Connection::class));
var_dump(enum_exists(WebSocket\MessageType::class));
var_dump(class_exists(WebSocket\Frame::class));
Expand All @@ -14,7 +17,11 @@ var_dump(class_exists(WebSocket\Protocol::class));
var_dump(method_exists(WebSocket\Server::class, 'send'));
var_dump(method_exists(WebSocket\Server::class, 'close'));
var_dump(method_exists(WebSocket\Server::class, 'subprotocols'));
var_dump(method_exists(WebSocket\Server::class, 'onHandshake'));
var_dump((new ReflectionMethod(WebSocket\Server::class, 'subprotocols'))->isVariadic());
var_dump((new ReflectionMethod(WebSocket\Request::class, 'header'))->getReturnType()->allowsNull());
var_dump((new ReflectionMethod(WebSocket\HandshakeResponse::class, '__construct'))->getNumberOfParameters());
var_dump((new ReflectionMethod(WebSocket\HandshakeException::class, '__construct'))->getNumberOfParameters());
var_dump((new ReflectionMethod(WebSocket\Connection::class, 'send'))->getNumberOfParameters());
var_dump((new ReflectionProperty(WebSocket\Connection::class, 'subprotocol'))->getType()->allowsNull());
var_dump((new ReflectionMethod(WebSocket\ServerOptions::class, '__construct'))->getNumberOfParameters());
Expand All @@ -41,9 +48,16 @@ try {
echo $e->getMessage(), "\n";
}
$server->onOpen(static function () {});
$server->onHandshake(static function () {});
$server->onMessage(static function () {});
$server->onClose(static function () {});
$server->onError(static function () {});
$response = new WebSocket\HandshakeResponse(401, ['X-Test' => 'ok'], 'nope');
var_dump($response->status);
var_dump($response->headers);
var_dump($response->body);
$exception = new WebSocket\HandshakeException($response);
var_dump($exception->response === $response);
var_dump(in_array($server->getDriver(), ['kqueue', 'epoll', 'poll', 'select'], true));
?>
--EXPECT--
Expand All @@ -54,10 +68,17 @@ bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
bool(false)
bool(false)
bool(true)
bool(true)
bool(true)
bool(true)
int(3)
int(1)
int(2)
bool(true)
int(5)
Expand All @@ -70,4 +91,11 @@ WebSocket\ServerOptions::__construct(): Argument #1 ($maxMessageSize) must be at
int(3)
int(2)
WebSocket\Server::subprotocols(): Argument #1 must be a valid WebSocket subprotocol token
int(401)
array(1) {
["X-Test"]=>
string(2) "ok"
}
string(4) "nope"
bool(true)
bool(true)
200 changes: 200 additions & 0 deletions tests/016-server-handshake-hook.phpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
--TEST--
WebSocket\Server validates handshakes before upgrade
--EXTENSIONS--
websocket
--SKIPIF--
<?php
if (!function_exists('proc_open')) {
die('skip proc_open is unavailable');
}
if (!function_exists('stream_socket_server') || !function_exists('stream_socket_client')) {
die('skip stream sockets are unavailable');
}
?>
--FILE--
<?php
$root = dirname(__DIR__);
$extension = $root . '/modules/websocket.so';
$tmpDir = sys_get_temp_dir() . '/websocket-server-handshake-hook-test-' . getmypid();
$eventsFile = $tmpDir . '/events.txt';
$serverFile = $tmpDir . '/server.php';

mkdir($tmpDir);

$probe = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr);
if (!$probe) {
echo "cannot allocate port\n";
exit;
}

$name = stream_socket_get_name($probe, false);
fclose($probe);
$port = (int) substr(strrchr($name, ':'), 1);

$serverCode = <<<'PHP'
<?php

use WebSocket\Connection;
use WebSocket\HandshakeException;
use WebSocket\HandshakeResponse;
use WebSocket\Request;
use WebSocket\Server;

$server = new Server();
$server->listen('127.0.0.1', PORT_PLACEHOLDER);

$server->onHandshake(static function (Request $request): void {
file_put_contents(EVENTS_PLACEHOLDER, $request->target . ':' . ($request->header('Origin') ?? '(none)') . "\n", FILE_APPEND);

if ($request->header('Origin') === 'https://app.test') {
return;
}

if ($request->target === '/custom') {
throw new HandshakeException(new HandshakeResponse(401, ['X-Reject' => 'origin'], 'nope'));
}

throw new HandshakeException();
});

$server->onOpen(static function (Connection $connection) use ($server): void {
file_put_contents(EVENTS_PLACEHOLDER, "open\n", FILE_APPEND);
$server->stop();
});

$server->run();
file_put_contents(EVENTS_PLACEHOLDER, "returned\n", FILE_APPEND);
PHP;

$serverCode = str_replace(
['PORT_PLACEHOLDER', 'EVENTS_PLACEHOLDER'],
[(string) $port, var_export($eventsFile, true)],
$serverCode,
);
file_put_contents($serverFile, $serverCode);

$process = proc_open(
[PHP_BINARY, '-n', '-d', 'extension=' . $extension, $serverFile],
[
1 => ['pipe', 'w'],
2 => ['pipe', 'w'],
],
$pipes,
);

if (!is_resource($process)) {
echo "cannot start server\n";
exit;
}

$connect = static function () use ($port, $process): mixed {
$client = false;
$deadline = microtime(true) + 5.0;

do {
$client = @stream_socket_client('tcp://127.0.0.1:' . $port, $errno, $errstr, 0.1);
if ($client !== false) {
return $client;
}

$status = proc_get_status($process);
if (!$status['running']) {
break;
}

usleep(10000);
} while (microtime(true) < $deadline);

return false;
};

$handshake = static function (string $target, ?string $origin) use ($connect, $port): string {
$client = $connect();
if ($client === false) {
return '';
}

$lines = [
'GET ' . $target . ' HTTP/1.1',
'Host: 127.0.0.1:' . $port,
'Upgrade: websocket',
'Connection: Upgrade',
'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==',
'Sec-WebSocket-Version: 13',
];

if ($origin !== null) {
$lines[] = 'Origin: ' . $origin;
}

$lines[] = '';
$lines[] = '';

fwrite($client, implode("\r\n", $lines));
stream_set_timeout($client, 1);
$response = fread($client, 4096);
fclose($client);

return $response;
};

$forbiddenResponse = $handshake('/chat', 'https://evil.test');
$customResponse = $handshake('/custom', null);
$acceptedResponse = $handshake('/chat', 'https://app.test');

$deadline = microtime(true) + 5.0;
do {
$status = proc_get_status($process);
if (!$status['running']) {
break;
}

usleep(10000);
} while (microtime(true) < $deadline);

$status = proc_get_status($process);
if ($status['running']) {
proc_terminate($process);
}

$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
proc_close($process);

$events = file_exists($eventsFile) ? file($eventsFile, FILE_IGNORE_NEW_LINES) : [];

var_dump(str_contains($forbiddenResponse, "HTTP/1.1 403 Forbidden\r\n"));
var_dump(str_contains($customResponse, "HTTP/1.1 401 Unauthorized\r\n"));
var_dump(str_contains($customResponse, "X-Reject: origin\r\n"));
var_dump(str_ends_with($customResponse, "nope"));
var_dump(str_contains($acceptedResponse, "HTTP/1.1 101 Switching Protocols\r\n"));
var_dump($events);
var_dump($stdout === '');
var_dump($stderr === '');

@unlink($eventsFile);
@unlink($serverFile);
@rmdir($tmpDir);
?>
--EXPECT--
bool(true)
bool(true)
bool(true)
bool(true)
bool(true)
array(5) {
[0]=>
string(23) "/chat:https://evil.test"
[1]=>
string(14) "/custom:(none)"
[2]=>
string(22) "/chat:https://app.test"
[3]=>
string(4) "open"
[4]=>
string(8) "returned"
}
bool(true)
bool(true)
3 changes: 3 additions & 0 deletions websocket.c
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ ZEND_DECLARE_MODULE_GLOBALS(websocket)

zend_class_entry *websocket_server_ce;
zend_class_entry *websocket_server_options_ce;
zend_class_entry *websocket_request_ce;
zend_class_entry *websocket_handshake_response_ce;
zend_class_entry *websocket_handshake_exception_ce;
zend_class_entry *websocket_connection_ce;
zend_class_entry *websocket_message_type_ce;
zend_class_entry *websocket_frame_ce;
Expand Down
Loading
Loading