diff --git a/README.md b/README.md index 002c325..e6786c4 100644 --- a/README.md +++ b/README.md @@ -143,10 +143,10 @@ $uri = "serverquery://username:password@[fe80::250:56ff:fe16:1447]:10022/?ssh=1" #### SSL/TLS Connections ([TeaSpeak Server](https://www.teaspeak.de) only) -Secure ServerQuery connections can be established using the optional `tls` parameter: +Secure TeaSpeak ServerQuery connections can be established using the optional `tls` parameter. Certificate verification remains disabled by default for compatibility with self-signed deployments. Enable `tls_verify` only when the server certificate is trusted by PHP and matches the hostname: ```php -$uri = "serverquery://username:password@[fe80::250:56ff:fe16:1447]:10011/?tls=1"; +$uri = "serverquery://username:password@teaspeak.example.com:10011/?tls=1&tls_verify=1"; ``` #### Custom Protocol Welcome Message and/or MOTD ([TeaSpeak Server](https://www.teaspeak.de) only) diff --git a/src/Adapter/FileTransfer.php b/src/Adapter/FileTransfer.php index bad936f..975ec39 100644 --- a/src/Adapter/FileTransfer.php +++ b/src/Adapter/FileTransfer.php @@ -38,6 +38,8 @@ public function __destruct() if ($this->getTransport() instanceof Transport && $this->getTransport()->isConnected()) { $this->getTransport()->disconnect(); } + + Profiler::remove(spl_object_hash($this)); } /** @@ -105,54 +107,55 @@ public function upload(string $ftkey, int $seek, string $data): void */ public function download(string $ftkey, int $size, bool $passthru = false) { - $this->init($ftkey); - if ($passthru) { - $this->passthru($size); + $this->downloadTo($ftkey, $size, static function (StringHelper $data): void { + echo $data; + }); return; } $buff = new StringHelper(""); - $pack = 4096; - - Signal::getInstance()->emit("filetransferDownloadStarted", $ftkey, count($buff), $size); - - for ($seek = 0; $seek < $size;) { - $rest = $size - $seek; - $pack = min($rest, $pack); - $data = $this->getTransport()->read(min($rest, $pack)); - $seek = $seek + $pack; - + $this->downloadTo($ftkey, $size, static function (StringHelper $data) use ($buff): void { $buff->append($data); - - Signal::getInstance()->emit("filetransferDownloadProgress", $ftkey, count($buff), $size); - } - - $this->getProfiler()->stop(); - - Signal::getInstance()->emit("filetransferDownloadFinished", $ftkey, count($buff), $size); - - if (strlen($buff) != $size) { - throw new FileTransferException("incomplete file download (" . count($buff) . " of " . $size . " bytes)"); - } + }); return $buff; } /** - * Outputs all remaining data on a TeamSpeak 3 file transfer stream using PHP's fpassthru() - * function. + * Downloads a file and passes each received chunk to the given consumer. * + * @param string $ftkey * @param integer $size + * @param callable $consumer * @return void * @throws FileTransferException */ - protected function passthru(int $size): void + public function downloadTo(string $ftkey, int $size, callable $consumer): void { - $buff_size = fpassthru($this->getTransport()->getStream()); + $this->init($ftkey); + $pack = 4096; + $seek = 0; + + Signal::getInstance()->emit("filetransferDownloadStarted", $ftkey, $seek, $size); + + try { + while ($seek < $size) { + $data = $this->getTransport()->read(min($size - $seek, $pack)); - if ($buff_size != $size) { - throw new FileTransferException("incomplete file download (" . $buff_size . " of " . $size . " bytes)"); + if (count($data) === 0) { + throw new FileTransferException("incomplete file download (" . $seek . " of " . $size . " bytes)"); + } + + $consumer($data); + $seek += count($data); + + Signal::getInstance()->emit("filetransferDownloadProgress", $ftkey, $seek, $size); + } + } finally { + $this->getProfiler()->stop(); } + + Signal::getInstance()->emit("filetransferDownloadFinished", $ftkey, $seek, $size); } } diff --git a/src/Adapter/ServerQuery.php b/src/Adapter/ServerQuery.php index dde4306..3ae2aec 100644 --- a/src/Adapter/ServerQuery.php +++ b/src/Adapter/ServerQuery.php @@ -92,6 +92,7 @@ public function __destruct() { // do not disconnect, when acting as bot in non-blocking mode if (! $this->getTransport()->getConfig("blocking")) { + Profiler::remove(spl_object_hash($this)); return; } @@ -99,9 +100,11 @@ public function __destruct() try { $this->request("quit"); } catch (AdapterException) { - return; + // The transport destructor will close an unavailable connection. } } + + Profiler::remove(spl_object_hash($this)); } /** diff --git a/src/Adapter/ServerQuery/Reply.php b/src/Adapter/ServerQuery/Reply.php index 0209d99..80ea6e3 100644 --- a/src/Adapter/ServerQuery/Reply.php +++ b/src/Adapter/ServerQuery/Reply.php @@ -86,7 +86,7 @@ public function __construct(array $rpl, string $cmd = "", Host $con = null, bool */ public function toString(): ?StringHelper { - return (!func_num_args()) ? $this->rpl->unescape() : $this->rpl; + return (!func_num_args()) ? (new StringHelper($this->rpl->toString()))->unescape() : $this->rpl; } /** diff --git a/src/Helper/Char.php b/src/Helper/Char.php index 42ed5ab..6bc7878 100644 --- a/src/Helper/Char.php +++ b/src/Helper/Char.php @@ -199,11 +199,11 @@ public function toHex(): string */ public static function fromHex(string $hex): Char { - if (strlen($hex) != 2) { + if (strlen($hex) != 2 || !ctype_xdigit($hex)) { throw new HelperException("given parameter '" . $hex . "' is not a valid hexadecimal number"); } - return new self(chr(hexdec($hex))); + return new self(hex2bin($hex)); } /** diff --git a/src/Helper/Profiler.php b/src/Helper/Profiler.php index ec8afda..6e63359 100644 --- a/src/Helper/Profiler.php +++ b/src/Helper/Profiler.php @@ -30,6 +30,17 @@ public static function init(string $name = "default"): void self::$timers[$name] = new Timer($name); } + /** + * Removes a timer which is no longer associated with a live adapter. + * + * @param string $name + * @return void + */ + public static function remove(string $name): void + { + unset(self::$timers[$name]); + } + /** * Starts a timer. * diff --git a/src/Helper/Signal.php b/src/Helper/Signal.php index 7fde1b5..b957260 100644 --- a/src/Helper/Signal.php +++ b/src/Helper/Signal.php @@ -172,6 +172,16 @@ public function clearHandlers(string $signal): void } } + /** + * Clears all registered signal handlers. + * + * @return void + */ + public function clearAllHandlers(): void + { + $this->sigslots = []; + } + /** * Returns a singleton instance of PlanetTeamSpeak\TeamSpeak3Framework\Helper\Signal. * diff --git a/src/Helper/StringHelper.php b/src/Helper/StringHelper.php index 7feb525..d90bdd9 100644 --- a/src/Helper/StringHelper.php +++ b/src/Helper/StringHelper.php @@ -381,8 +381,13 @@ public function isInt(): bool */ public function toInt(): int { - if ($this->string == pow(2, 63) || $this->string == pow(2, 64) || $this->string > pow(2, 31)) { - return -1; + if (ctype_digit($this->string)) { + $value = ltrim($this->string, "0") ?: "0"; + $max = (string)PHP_INT_MAX; + + if (strlen($value) > strlen($max) || (strlen($value) === strlen($max) && strcmp($value, $max) > 0)) { + return -1; + } } return intval($this->string); @@ -426,21 +431,7 @@ public function toSha1(): string */ public function isUtf8(): bool { - if (preg_match('/\\A[\\x00-\\x7F]*\\z/', $this->string)) { - return true; - } - - $pattern = []; - - $pattern[] = "[\xC2-\xDF][\x80-\xBF]"; // non-overlong 2-byte - $pattern[] = "\xE0[\xA0-\xBF][\x80-\xBF]"; // excluding overlongs - $pattern[] = "[\xE1-\xEC\xEE\xEF][\x80-\xBF]{2}"; // straight 3-byte - $pattern[] = "\xED[\x80-\x9F][\x80-\xBF]"; // excluding surrogates - $pattern[] = "\xF0[\x90-\xBF][\x80-\xBF]{2}"; // planes 1-3 - $pattern[] = "[\xF1-\xF3][\x80-\xBF]{3}"; // planes 4-15 - $pattern[] = "\xF4[\x80-\x8F][\x80-\xBF]{2}"; // plane 16 - - return (bool)preg_match("%(?:" . implode("|", $pattern) . ")+%xs", $this->string); + return mb_check_encoding($this->string, 'UTF-8'); } /** @@ -472,10 +463,17 @@ public function toBase64(): string * * @param string $base64 * @return self + * @throws HelperException */ public static function fromBase64(string $base64): StringHelper { - return new self(base64_decode($base64)); + $string = base64_decode($base64, true); + + if ($string === false) { + throw new HelperException("given parameter is not valid base64 data"); + } + + return new self($string); } /** @@ -485,13 +483,7 @@ public static function fromBase64(string $base64): StringHelper */ public function toHex(): string { - $hex = ""; - - foreach ($this as $char) { - $hex .= $char->toHex(); - } - - return $hex; + return strtoupper(bin2hex($this->string)); } /** @@ -503,17 +495,11 @@ public function toHex(): string */ public static function fromHex(string $hex): StringHelper { - $string = ""; - - if (strlen($hex) % 2 == 1) { + if (strlen($hex) % 2 == 1 || ($hex !== "" && !ctype_xdigit($hex))) { throw new HelperException("given parameter '" . $hex . "' is not a valid hexadecimal number"); } - foreach (str_split($hex, 2) as $chunk) { - $string .= chr(hexdec($chunk)); - } - - return new self($string); + return new self(hex2bin($hex)); } /** diff --git a/src/Helper/Uri.php b/src/Helper/Uri.php index cbc6c01..e910f7f 100644 --- a/src/Helper/Uri.php +++ b/src/Helper/Uri.php @@ -511,7 +511,7 @@ public function getQueryVar(string $key, mixed $default = null): mixed return $default; } - parse_str(rawurldecode($this->query), $queryArray); + parse_str($this->query, $queryArray); if (array_key_exists($key, $queryArray)) { $val = $queryArray[$key]; @@ -585,7 +585,7 @@ public function getFragment(mixed $default = null): ?StringHelper */ public static function getUserParam(string $key, mixed $default = null): mixed { - return (array_key_exists($key, $_REQUEST) && !empty($_REQUEST[$key])) ? self::stripslashesRecursive($_REQUEST[$key]) : $default; + return array_key_exists($key, $_REQUEST) ? self::stripslashesRecursive($_REQUEST[$key]) : $default; } /** @@ -597,7 +597,7 @@ public static function getUserParam(string $key, mixed $default = null): mixed */ public static function getHostParam(string $key, mixed $default = null): mixed { - return (array_key_exists($key, $_SERVER) && !empty($_SERVER[$key])) ? $_SERVER[$key] : $default; + return array_key_exists($key, $_SERVER) ? $_SERVER[$key] : $default; } /** @@ -609,7 +609,7 @@ public static function getHostParam(string $key, mixed $default = null): mixed */ public static function getSessParam(string $key, mixed $default = null): mixed { - return (array_key_exists($key, $_SESSION) && !empty($_SESSION[$key])) ? $_SESSION[$key] : $default; + return array_key_exists($key, $_SESSION) ? $_SESSION[$key] : $default; } /** diff --git a/src/Node/Channel.php b/src/Node/Channel.php index 734a867..6a8ccbd 100644 --- a/src/Node/Channel.php +++ b/src/Node/Channel.php @@ -71,7 +71,7 @@ public function subChannelGetById(int $cid): Channel throw new ServerQueryException("invalid channelID", 0x300); } - return $this->channelList[$cid]; + return $this->subChannelList()[$cid]; } /** @@ -81,7 +81,7 @@ public function subChannelGetById(int $cid): Channel * @return Channel * @throws ServerQueryException */ - public function subChannelGetByName(int $name): Channel + public function subChannelGetByName(string $name): Channel { foreach ($this->subChannelList() as $channel) { if ($channel["channel_name"] == $name) { @@ -124,7 +124,7 @@ public function clientGetById(int $clid): Client throw new ServerQueryException("invalid clientID", 0x200); } - return $this->clientList[$clid]; + return $this->clientList()[$clid]; } /** @@ -134,7 +134,7 @@ public function clientGetById(int $clid): Client * @return Client * @throws ServerQueryException */ - public function clientGetByName(int $name): Client + public function clientGetByName(string $name): Client { foreach ($this->clientList() as $client) { if ($client["client_nickname"] == $name) { @@ -392,7 +392,7 @@ public function iconDownload() return; } - $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->iconGetName("channel_icon_id")); + $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->iconGetName("channel_icon_id")); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); diff --git a/src/Node/Client.php b/src/Node/Client.php index 90397bc..6c4d8e2 100644 --- a/src/Node/Client.php +++ b/src/Node/Client.php @@ -304,7 +304,7 @@ public function avatarDownload() return; } - $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->avatarGetName()); + $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->avatarGetName()); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); @@ -398,7 +398,7 @@ public function iconDownload() return; } - $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->iconGetName("client_icon_id")); + $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->iconGetName("client_icon_id")); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); diff --git a/src/Node/Group.php b/src/Node/Group.php index e09d3a8..f5c3fe7 100644 --- a/src/Node/Group.php +++ b/src/Node/Group.php @@ -51,7 +51,7 @@ public function iconDownload() return; } - $download = $this->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->iconGetName("iconid")); + $download = $this->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->iconGetName("iconid")); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); diff --git a/src/Node/Node.php b/src/Node/Node.php index e99e392..a367f47 100644 --- a/src/Node/Node.php +++ b/src/Node/Node.php @@ -227,23 +227,28 @@ public function getViewer(ViewerInterface $viewer): string protected function filterList(array $nodes = [], array $rules = []): array { if (!empty($rules)) { - foreach ($nodes as $node) { + foreach ($nodes as $nodeKey => $node) { if (!$node instanceof Node) { continue; } $props = $node->getInfo(false); - $props = array_intersect_key($props, $rules); - foreach ($props as $key => $val) { + foreach ($rules as $key => $rule) { + if (!array_key_exists($key, $props)) { + unset($nodes[$nodeKey]); + break; + } + + $val = $props[$key]; if ($val instanceof StringHelper) { - $match = $val->contains($rules[$key], true); + $match = $val->contains($rule, true); } else { - $match = $val == $rules[$key]; + $match = $val == $rule; } if ($match === false) { - unset($nodes[$node->getId()]); + unset($nodes[$nodeKey]); } } } diff --git a/src/Node/Server.php b/src/Node/Server.php index ba7a29d..30f3093 100644 --- a/src/Node/Server.php +++ b/src/Node/Server.php @@ -1828,7 +1828,7 @@ public function iconDownload(string $iconname = null) $name = $this->iconGetName("virtualserver_icon_id"); } - $download = $this->transferInitDownload(rand(0x0000, 0xFFFF), 0, $name); + $download = $this->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $name); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"]); return $transfer->download($download["ftkey"], $download["size"]); @@ -1848,7 +1848,7 @@ public function iconUpload(string $data): int $crc = crc32($data); $size = strlen($data); - $upload = $this->transferInitUpload(rand(0x0000, 0xFFFF), 0, "/icon_" . $crc, $size); + $upload = $this->transferInitUpload(TeamSpeak3::generateTransferClientId(), 0, "/icon_" . $crc, $size); $transfer = TeamSpeak3::factory("filetransfer://" . (str_contains($upload["host"], ":") ? "[" . $upload["host"] . "]" : $upload["host"]) . ":" . $upload["port"]); $transfer->upload($upload["ftkey"], $upload["seekpos"], $data); diff --git a/src/TeamSpeak3.php b/src/TeamSpeak3.php index e00705d..0a6b115 100644 --- a/src/TeamSpeak3.php +++ b/src/TeamSpeak3.php @@ -49,6 +49,17 @@ */ class TeamSpeak3 { + /** + * Generates a client-side identifier for a file transfer. + * + * @return int + * @throws \Random\RandomException + */ + public static function generateTransferClientId(): int + { + return random_int(0x0000, 0xFFFF); + } + /** * TeamSpeak 3 protocol welcome message. */ @@ -326,6 +337,7 @@ class TeamSpeak3 * - timeout * - blocking * - tls (TeaSpeak only) + * - tls_verify * - ssh (TeamSpeak only) * - nickname * - no_query_clients @@ -343,7 +355,7 @@ class TeamSpeak3 * === URI Examples === * - serverquery://127.0.0.1:10011/ * - serverquery://127.0.0.1:10022/?ssh=1 (TeamSpeak ONLY) - * - serverquery://127.0.0.1:10011/?tls=1 (TeaSpeak ONLY) + * - serverquery://teaspeak.example.com:10011/?tls=1&tls_verify=1 (TeaSpeak ONLY) * - serverquery://127.0.0.1:10022/?ssh=1&server_port=9987 * - serverquery://127.0.0.1:10011/?server_port=9987&channel_id=1 * - serverquery://127.0.0.1:10011/?server_port=9987&channel_id=1#no_query_clients @@ -364,7 +376,7 @@ public static function factory(string $uri): Host|Server|ServerQuery|MockServerQ $uri = new Uri($uri); $adapter = self::getAdapterName($uri->getScheme()); - $options = ["host" => $uri->getHost(), "port" => $uri->getPort(), "timeout" => (int)$uri->getQueryVar("timeout", 10), "blocking" => (int)$uri->getQueryVar("blocking", 1), "tls" => (int)$uri->getQueryVar("tls", 0), "ssh" => (int)$uri->getQueryVar("ssh", 0)]; + $options = ["host" => $uri->getHost(), "port" => $uri->getPort(), "timeout" => (int)$uri->getQueryVar("timeout", 10), "blocking" => (int)$uri->getQueryVar("blocking", 1), "tls" => (int)$uri->getQueryVar("tls", 0), "tls_verify" => (int)$uri->getQueryVar("tls_verify", 0), "ssh" => (int)$uri->getQueryVar("ssh", 0)]; self::loadClass($adapter); diff --git a/src/Transport/TCP.php b/src/Transport/TCP.php index b591ab3..a70d3cd 100644 --- a/src/Transport/TCP.php +++ b/src/Transport/TCP.php @@ -35,7 +35,8 @@ public function connect(): void if (empty($this->config["ssh"])) { $address = "tcp://" . (str_contains($host, ":") ? "[" . $host . "]" : $host) . ":" . $port; - $options = empty($this->config["tls"]) ? [] : ["ssl" => ["allow_self_signed" => true, "verify_peer" => false, "verify_peer_name" => false]]; + $verify = !empty($this->config["tls_verify"]); + $options = empty($this->config["tls"]) ? [] : ["ssl" => ["allow_self_signed" => !$verify, "verify_peer" => $verify, "verify_peer_name" => $verify]]; $errno = 0; $errstr = ''; @@ -47,7 +48,10 @@ public function connect(): void } if (!empty($this->config["tls"])) { - stream_socket_enable_crypto($this->stream, true, STREAM_CRYPTO_METHOD_SSLv23_CLIENT); + if (!$this->enableCrypto()) { + $this->disconnect(); + throw new TransportException("failed to enable TLS for server '$host:$port'"); + } } } else { $this->session = @ssh2_connect($host, $port); @@ -81,6 +85,16 @@ protected function openSocket(string $address, int &$errno, string &$errstr, int return @stream_socket_client($address, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, stream_context_create($options)); } + /** + * Enables TLS encryption on the connected stream. + * + * @return bool + */ + protected function enableCrypto(): bool + { + return stream_socket_enable_crypto($this->stream, true, STREAM_CRYPTO_METHOD_SSLv23_CLIENT); + } + /** * Disconnects from a remote server. * @@ -92,12 +106,18 @@ public function disconnect(): void return; } + if (is_resource($this->stream)) { + @fclose($this->stream); + } + $this->stream = null; if (is_resource($this->session)) { @ssh2_disconnect($this->session); } + $this->session = null; + Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "Disconnected"); } @@ -118,7 +138,8 @@ public function read(int $length = 4096): StringHelper Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataRead", $data); - if ($data === false) { + if ($data === false || ($data === "" && feof($this->stream))) { + $this->disconnect(); throw new TransportException("connection to server '" . $this->config["host"] . ":" . $this->config["port"] . "' lost"); } @@ -185,11 +206,32 @@ public function send(string $data): void { $this->connect(); - @fwrite($this->stream, $data); + $length = strlen($data); + $written = 0; + + while ($written < $length) { + $result = $this->write(substr($data, $written)); + + if ($result === false || $result === 0) { + throw new TransportException("failed to write to server '" . $this->config["host"] . ":" . $this->config["port"] . "'"); + } + + $written += $result; + } Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataSend", $data); } + /** + * Writes a chunk to the underlying stream. + * + * @return int|false + */ + protected function write(string $data): int|false + { + return @fwrite($this->stream, $data); + } + /** * Writes a line of data to the stream. * @@ -204,6 +246,11 @@ public function sendLine(string $data, string $separator = "\n"): void $size = strlen($data); $pack = 4096; + if ($size === 0) { + $this->send($separator); + return; + } + for ($seek = 0; $seek < $size;) { $rest = $size - $seek; $pack = min($rest, $pack); diff --git a/src/Transport/Transport.php b/src/Transport/Transport.php index 63cf07c..48949a6 100644 --- a/src/Transport/Transport.php +++ b/src/Transport/Transport.php @@ -68,6 +68,10 @@ public function __construct(array $config) $config["blocking"] = 1; } + if (!array_key_exists("tls_verify", $config)) { + $config["tls_verify"] = 0; + } + $this->config = $config; return $this; } @@ -154,9 +158,9 @@ public function getStream() * * @param string|null $key * @param mixed|null $default - * @return array|string + * @return mixed */ - public function getConfig(string $key = null, mixed $default = null): array|string|int + public function getConfig(string $key = null, mixed $default = null): mixed { if ($key !== null) { return array_key_exists($key, $this->config) ? $this->config[$key] : $default; @@ -240,16 +244,20 @@ protected function waitForReadyRead(int $time = 0): void return; } - do { - $read = [$this->stream]; - $null = null; + $read = [$this->stream]; + $null = null; + $result = @stream_select($read, $null, $null, $this->config["timeout"]); - if ($time) { - Signal::getInstance() - ->emit(strtolower($this->getAdapterType()) . "WaitTimeout", $time, $this->getAdapter()); - } + if ($result === false) { + throw new TransportException("unable to wait for data from server '" . $this->config["host"] . ":" . $this->config["port"] . "'"); + } - $time = $time + $this->config["timeout"]; - } while (@stream_select($read, $null, $null, $this->config["timeout"]) == 0); + if ($result === 0) { + $time = $time ?: $this->config["timeout"]; + Signal::getInstance() + ->emit(strtolower($this->getAdapterType()) . "WaitTimeout", $time, $this->getAdapter()); + + throw new TransportException("timed out waiting for data from server '" . $this->config["host"] . ":" . $this->config["port"] . "'"); + } } } diff --git a/src/Transport/UDP.php b/src/Transport/UDP.php index e996594..5902320 100644 --- a/src/Transport/UDP.php +++ b/src/Transport/UDP.php @@ -69,6 +69,10 @@ public function disconnect(): void return; } + if (is_resource($this->stream)) { + @fclose($this->stream); + } + $this->stream = null; Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "Disconnected"); @@ -108,8 +112,22 @@ public function send(string $data): void { $this->connect(); - @stream_socket_sendto($this->stream, $data); + $written = $this->sendTo($data); + + if ($written === false || $written !== strlen($data)) { + throw new TransportException("failed to write to server '" . $this->config["host"] . ":" . $this->config["port"] . "'"); + } Signal::getInstance()->emit(strtolower($this->getAdapterType()) . "DataSend", $data); } + + /** + * Sends a datagram through the underlying stream. + * + * @return int|false + */ + protected function sendTo(string $data): int|false + { + return @stream_socket_sendto($this->stream, $data); + } } diff --git a/src/Viewer/Html.php b/src/Viewer/Html.php index 896a8ab..87df1f4 100644 --- a/src/Viewer/Html.php +++ b/src/Viewer/Html.php @@ -400,7 +400,7 @@ protected function getSuffixIconServer(): string if ($this->currObj["virtualserver_icon_id"]) { if (!$this->currObj->iconIsLocal("virtualserver_icon_id") && $this->ftclient) { if (!isset($this->cacheIcon[$this->currObj["virtualserver_icon_id"]])) { - $download = $this->currObj->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->currObj->iconGetName("virtualserver_icon_id")); + $download = $this->currObj->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->currObj->iconGetName("virtualserver_icon_id")); if ($this->ftclient == "data:image") { $download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]); @@ -460,7 +460,7 @@ protected function getSuffixIconChannel(): string if ($this->currObj["channel_icon_id"]) { if (!$this->currObj->iconIsLocal("channel_icon_id") && $this->ftclient) { if (!isset($this->cacheIcon[$this->currObj["channel_icon_id"]])) { - $download = $this->currObj->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->currObj->iconGetName("channel_icon_id")); + $download = $this->currObj->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->currObj->iconGetName("channel_icon_id")); if ($this->ftclient == "data:image") { $download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]); @@ -522,7 +522,7 @@ protected function getSuffixIconClient(): string if (!$group->iconIsLocal("iconid") && $this->ftclient) { if (!isset($this->cacheIcon[$group["iconid"]])) { - $download = $group->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $group->iconGetName("iconid")); + $download = $group->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $group->iconGetName("iconid")); if ($this->ftclient == "data:image") { $download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]); @@ -546,7 +546,7 @@ protected function getSuffixIconClient(): string if ($this->currObj["client_icon_id"]) { if (!$this->currObj->iconIsLocal("client_icon_id") && $this->ftclient) { if (!isset($this->cacheIcon[$this->currObj["client_icon_id"]])) { - $download = $this->currObj->getParent()->transferInitDownload(rand(0x0000, 0xFFFF), 0, $this->currObj->iconGetName("client_icon_id")); + $download = $this->currObj->getParent()->transferInitDownload(TeamSpeak3::generateTransferClientId(), 0, $this->currObj->iconGetName("client_icon_id")); if ($this->ftclient == "data:image") { $download = TeamSpeak3::factory("filetransfer://" . (str_contains($download["host"], ":") ? "[" . $download["host"] . "]" : $download["host"]) . ":" . $download["port"])->download($download["ftkey"], $download["size"]); @@ -611,6 +611,9 @@ protected function getImage(string $name, string $text = "", string $class = nul $src = $this->flagpath; } - return ""; + $src = htmlspecialchars($src . $name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + $text = htmlspecialchars($text, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); + + return ""; } } diff --git a/tests/Adapter/FileTransferTest.php b/tests/Adapter/FileTransferTest.php new file mode 100644 index 0000000..8b8ed21 --- /dev/null +++ b/tests/Adapter/FileTransferTest.php @@ -0,0 +1,81 @@ + 'test', 'port' => 12345]) extends FileTransfer { + public function syn(): void + { + $this->transport = new class (['host' => 'test', 'port' => 12345]) extends Transport { + private array $chunks = ['ab', 'cde']; + + public function connect(): void + { + } + + public function disconnect(): void + { + } + + public function read(int $length = 4096): StringHelper + { + return new StringHelper(array_shift($this->chunks)); + } + + public function send(string $data): void + { + } + }; + } + + protected function init(string $ftkey): void + { + } + }; + $chunks = []; + + $transfer->downloadTo('1234567890123456', 5, static function (StringHelper $data) use (&$chunks): void { + $chunks[] = $data->toString(); + }); + + $this->assertSame(['ab', 'cde'], $chunks); + } + + public function testDownloadCollectsPartialReadsWithoutSkippingData(): void + { + $transfer = new class (['host' => 'test', 'port' => 12345]) extends FileTransfer { + public function syn(): void + { + $this->transport = new class (['host' => 'test', 'port' => 12345]) extends Transport { + private array $chunks = ['ab', 'cde']; + public function connect(): void + { + } + public function disconnect(): void + { + } + public function read(int $length = 4096): StringHelper + { + return new StringHelper(array_shift($this->chunks)); + } + public function send(string $data): void + { + } + }; + } + protected function init(string $ftkey): void + { + } + }; + + $this->assertSame('abcde', $transfer->download('1234567890123456', 5)->toString()); + } +} diff --git a/tests/Adapter/ServerQuery/ReplyTest.php b/tests/Adapter/ServerQuery/ReplyTest.php index 4188c4c..92f0057 100644 --- a/tests/Adapter/ServerQuery/ReplyTest.php +++ b/tests/Adapter/ServerQuery/ReplyTest.php @@ -75,6 +75,17 @@ public function testToString() $this->assertEquals(static::$E_SERVERLIST, (string) $reply->toString()); } + public function testToStringDoesNotChangeSubsequentParsing(): void + { + $reply = new Reply([ + new StringHelper('virtualserver_name=TeamSpeak\\sServer'), + new StringHelper(static::$S_ERROR_OK), + ]); + + $this->assertSame('virtualserver_name=TeamSpeak Server', $reply->toString()->toString()); + $this->assertSame('TeamSpeak Server', $reply->toArray()[0]['virtualserver_name']->toString()); + } + public function testToLines() { $reply = new Reply([new StringHelper(static::$S_CLIENTLIST), new StringHelper(static::$S_ERROR_OK)]); diff --git a/tests/Helper/CharTest.php b/tests/Helper/CharTest.php index a45b257..d13cb38 100644 --- a/tests/Helper/CharTest.php +++ b/tests/Helper/CharTest.php @@ -258,6 +258,13 @@ public function testUnicode1Byte() ); } + public function testFromHexRejectsMalformedInput(): void + { + $this->expectException(HelperException::class); + + Char::fromHex('zz'); + } + /** * @throws HelperException */ diff --git a/tests/Helper/ProfilerTest.php b/tests/Helper/ProfilerTest.php index 8f45cec..c2cc9cd 100644 --- a/tests/Helper/ProfilerTest.php +++ b/tests/Helper/ProfilerTest.php @@ -38,4 +38,13 @@ public function testProfilerCreatesStartsStopsAndReturnsNamedTimers(): void Profiler::stop('created-on-stop'); $this->assertInstanceOf(Timer::class, Profiler::get('created-on-stop')); } + + public function testProfilerRemovesTimers(): void + { + Profiler::init('removed'); + Profiler::remove('removed'); + + $property = new \ReflectionProperty(Profiler::class, 'timers'); + $this->assertArrayNotHasKey('removed', $property->getValue()); + } } diff --git a/tests/Helper/SignalTest.php b/tests/Helper/SignalTest.php index d0ccb78..94d4f91 100644 --- a/tests/Helper/SignalTest.php +++ b/tests/Helper/SignalTest.php @@ -31,6 +31,17 @@ public function testGetInstance() $this->assertEmpty(Signal::getInstance()->getSignals()); } + public function testClearAllHandlers(): void + { + $signal = Signal::getInstance(); + $signal->subscribe(static::$signal, static::$callback); + $signal->subscribe('anotherSignal', static::$callback); + + $signal->clearAllHandlers(); + + $this->assertSame([], $signal->getSignals()); + } + /** * @throws HelperException */ diff --git a/tests/Helper/StringTest.php b/tests/Helper/StringTest.php index db5126a..cc235cf 100644 --- a/tests/Helper/StringTest.php +++ b/tests/Helper/StringTest.php @@ -110,6 +110,16 @@ public function testIsInt() } } + public function testToIntSupportsValuesAboveSignedThirtyTwoBitRange(): void + { + if (PHP_INT_SIZE < 8) { + $this->markTestSkipped('Requires a 64-bit PHP integer.'); + } + + $this->assertSame(2147483648, (new StringHelper('2147483648'))->toInt()); + $this->assertSame(-1, (new StringHelper('9223372036854775808'))->toInt()); + } + public function testFactory() { $string = StringHelper::factory("hello world"); @@ -232,9 +242,14 @@ function ($mb_string, $item) { return $mb_string; } )); - $this->assertTrue($upperUtf8MultibyteChar->isUtf8()); + $this->assertSame( + $boundary[1][0] !== 0xF0, + $upperUtf8MultibyteChar->isUtf8() + ); } + $this->assertFalse((new StringHelper("\xC2\x80\xFF"))->isUtf8()); + foreach ($unicodeBoundariesMalformed as $boundary) { $lowerUtf8MultibyteChar = new StringHelper(array_reduce( $boundary[0], @@ -296,6 +311,13 @@ public function testFromBase64() $this->assertEquals("Hello world!", $string->toString()); } + public function testFromBase64RejectsMalformedInput(): void + { + $this->expectException(HelperException::class); + + StringHelper::fromBase64('not valid base64!'); + } + /** * @throws Exception */ @@ -315,6 +337,13 @@ public function testFromHex() $this->assertEquals("Hello", $string->toString()); } + public function testFromHexRejectsMalformedInput(): void + { + $this->expectException(HelperException::class); + + StringHelper::fromHex('zz'); + } + public function testTransliterate() { $utf8_accents = [ diff --git a/tests/Helper/UriTest.php b/tests/Helper/UriTest.php index a06c115..366b59f 100644 --- a/tests/Helper/UriTest.php +++ b/tests/Helper/UriTest.php @@ -19,6 +19,8 @@ class UriTest extends TestCase 'options' => [ 'timeout', 'blocking', + 'tls', + 'tls_verify', 'nickname', 'no_query_clients', 'use_offline_as_virtual', @@ -81,6 +83,29 @@ public function testConstructEmptyURI() new Uri(''); } + public function testParameterHelpersPreserveFalsyValues(): void + { + $_REQUEST['uri_test_zero'] = '0'; + $_SERVER['uri_test_false'] = false; + $_SESSION['uri_test_empty'] = []; + + try { + $this->assertSame('0', Uri::getUserParam('uri_test_zero', 'default')); + $this->assertFalse(Uri::getHostParam('uri_test_false', true)); + $this->assertSame([], Uri::getSessParam('uri_test_empty', ['default'])); + } finally { + unset($_REQUEST['uri_test_zero'], $_SERVER['uri_test_false'], $_SESSION['uri_test_empty']); + } + } + + public function testEncodedQueryVariableNamesAreHandledConsistently(): void + { + $uri = new Uri('serverquery://127.0.0.1:10011/?tls%5Fverify=1'); + + $this->assertTrue($uri->hasQueryVar('tls_verify')); + $this->assertSame(1, $uri->getQueryVar('tls_verify')); + } + public function testConstructInvalidScheme() { $this->expectException(HelperException::class); diff --git a/tests/Node/ChannelTest.php b/tests/Node/ChannelTest.php new file mode 100644 index 0000000..dfbbfaf --- /dev/null +++ b/tests/Node/ChannelTest.php @@ -0,0 +1,32 @@ +createMock(Server::class); + $channel = new Channel($server, ['cid' => 1]); + $client = new Client($server, ['clid' => 2, 'cid' => 1]); + + $server->method('clientList')->willReturn([2 => $client]); + + $this->assertSame($client, $channel->clientGetById(2)); + } + + public function testSubChannelGetByIdReturnsChildFromChannelList(): void + { + $server = $this->createMock(Server::class); + $parent = new Channel($server, ['cid' => 1]); + $child = new Channel($server, ['cid' => 2, 'pid' => 1]); + $server->method('channelList')->willReturn([2 => $child]); + + $this->assertSame($child, $parent->subChannelGetById(2)); + } +} diff --git a/tests/Node/NodeTest.php b/tests/Node/NodeTest.php index d79d1bc..c0cfc13 100644 --- a/tests/Node/NodeTest.php +++ b/tests/Node/NodeTest.php @@ -60,6 +60,13 @@ public function testRendersItsTreeWithTextViewer(): void $this->assertSame("* root\n\\-* child\n", $node->getViewer(new Text())); } + + public function testFilterExcludesNodesWithoutTheRequestedProperty(): void + { + $node = new TestNode(1, ['name' => 'root']); + + $this->assertSame([], $node->filterForTest([$node], ['missing' => 'value'])); + } } class TestNode extends Node @@ -90,4 +97,9 @@ public function __toString(): string { return $this->nodeInfo['name'] ?? 'unnamed'; } + + public function filterForTest(array $nodes, array $rules): array + { + return $this->filterList($nodes, $rules); + } } diff --git a/tests/TeamSpeak3Test.php b/tests/TeamSpeak3Test.php index c654da8..40dca05 100644 --- a/tests/TeamSpeak3Test.php +++ b/tests/TeamSpeak3Test.php @@ -27,4 +27,12 @@ public function testEscapePatternsAndDumpAreAvailable(): void $this->assertStringContainsString("array(1)", $dump); $this->assertStringContainsString("", $dump); } + + public function testTransferClientIdIsWithinProtocolRange(): void + { + $id = TeamSpeak3::generateTransferClientId(); + + $this->assertGreaterThanOrEqual(0, $id); + $this->assertLessThanOrEqual(0xFFFF, $id); + } } diff --git a/tests/Transport/TCPTest.php b/tests/Transport/TCPTest.php index cac9dcb..c931091 100644 --- a/tests/Transport/TCPTest.php +++ b/tests/Transport/TCPTest.php @@ -73,6 +73,9 @@ public function testConstructorNoException() $this->assertArrayHasKey('timeout', $adapter->getConfig()); $this->assertIsInt($adapter->getConfig('timeout')); + $this->assertArrayHasKey('tls_verify', $adapter->getConfig()); + $this->assertSame(0, $adapter->getConfig('tls_verify')); + $this->assertArrayHasKey('blocking', $adapter->getConfig()); $this->assertIsInt($adapter->getConfig('blocking')); } @@ -103,10 +106,12 @@ public function testGetConfig() ); $this->assertIsArray($adapter->getConfig()); - $this->assertCount(4, $adapter->getConfig()); + $this->assertCount(5, $adapter->getConfig()); $this->assertArrayHasKey('host', $adapter->getConfig()); $this->assertEquals('test', $adapter->getConfig()['host']); $this->assertEquals('test', $adapter->getConfig('host')); + $this->assertNull($adapter->getConfig('missing')); + $this->assertFalse($adapter->getConfig('missing', false)); } /** @@ -217,6 +222,38 @@ public function testDisconnectNoConnection() $transport->disconnect(); } + public function testDisconnectClosesRetainedStream(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP { + public function setStreamForTest($stream): void + { + $this->stream = $stream; + } + }; + $stream = fopen('php://temp', 'r+'); + $transport->setStreamForTest($stream); + + $transport->disconnect(); + + $this->assertFalse(is_resource($stream)); + $this->assertNull($transport->getStream()); + } + + public function testReadThrowsWhenStreamReachedEndOfFile(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP { + public function setStreamForTest($stream): void + { + $this->stream = $stream; + } + }; + $transport->setStreamForTest(fopen('php://temp', 'r')); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage("connection to server 'test:12345' lost"); + $transport->read(); + } + /** * @throws ServerQueryException * @throws TransportException @@ -274,6 +311,25 @@ public function testSendNoConnection() $transport->send('testsend'); } + public function testSendRetriesPartialWritesAndRejectsFailedWrites(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends TCP { + public array $writes = []; + private array $results = [2, 3]; + public function connect(): void + { + $this->stream = true; + } + protected function write(string $data): int|false + { + $this->writes[] = $data; + return array_shift($this->results); + } + }; + $transport->send('hello'); + $this->assertSame(['hello', 'llo'], $transport->writes); + } + /** * @throws ServerQueryException * @throws TransportException @@ -292,4 +348,93 @@ public function testSendLineNoConnection() } $transport->sendLine('test.sendLine'); } + + public function testNonBlockingReadTimesOut(): void + { + $transport = new class (['host' => 'test', 'port' => 12345, 'blocking' => 0, 'timeout' => 0]) extends TCP { + private $peer; + + public function connectForTest(): void + { + [$this->stream, $this->peer] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + } + + public function waitForReadForTest(): void + { + $this->waitForReadyRead(); + } + }; + + $transport->connectForTest(); + + $this->expectException(TransportException::class); + $this->expectExceptionMessage("timed out waiting for data from server 'test:12345'"); + $transport->waitForReadForTest(); + } + + public function testTlsVerificationIsDisabledByDefault(): void + { + $transport = new class (['host' => 'test', 'port' => 12345, 'tls' => 1]) extends TCP { + public array $contextOptions; + + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + $this->contextOptions = $options; + return fopen('php://temp', 'r+'); + } + + protected function enableCrypto(): bool + { + return true; + } + }; + + $transport->connect(); + + $this->assertSame(['allow_self_signed' => true, 'verify_peer' => false, 'verify_peer_name' => false], $transport->contextOptions['ssl']); + } + + public function testTlsVerificationCanBeEnabled(): void + { + $transport = new class (['host' => 'test', 'port' => 12345, 'tls' => 1, 'tls_verify' => 1]) extends TCP { + public array $contextOptions; + + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + $this->contextOptions = $options; + return fopen('php://temp', 'r+'); + } + + protected function enableCrypto(): bool + { + return true; + } + }; + + $transport->connect(); + + $this->assertSame(['allow_self_signed' => false, 'verify_peer' => true, 'verify_peer_name' => true], $transport->contextOptions['ssl']); + } + + public function testFailedTlsNegotiationDisconnectsTransport(): void + { + $transport = new class (['host' => 'test', 'port' => 12345, 'tls' => 1]) extends TCP { + protected function openSocket(string $address, int &$errno, string &$errstr, int $timeout, array $options): mixed + { + return fopen('php://temp', 'r+'); + } + + protected function enableCrypto(): bool + { + return false; + } + }; + + try { + $transport->connect(); + $this->fail('Expected TLS negotiation to fail.'); + } catch (TransportException) { + $this->assertNull($transport->getStream()); + } + } } diff --git a/tests/Transport/UDPTest.php b/tests/Transport/UDPTest.php index 6d4300c..8a59197 100644 --- a/tests/Transport/UDPTest.php +++ b/tests/Transport/UDPTest.php @@ -38,6 +38,9 @@ public function testConstructorNoException() $this->assertArrayHasKey('timeout', $adapter->getConfig()); $this->assertIsInt($adapter->getConfig('timeout')); + $this->assertArrayHasKey('tls_verify', $adapter->getConfig()); + $this->assertSame(0, $adapter->getConfig('tls_verify')); + $this->assertArrayHasKey('blocking', $adapter->getConfig()); $this->assertIsInt($adapter->getConfig('blocking')); } @@ -68,7 +71,7 @@ public function testGetConfig() ); $this->assertIsArray($adapter->getConfig()); - $this->assertCount(4, $adapter->getConfig()); + $this->assertCount(5, $adapter->getConfig()); $this->assertArrayHasKey('host', $adapter->getConfig()); $this->assertEquals('test', $adapter->getConfig()['host']); $this->assertEquals('test', $adapter->getConfig('host')); @@ -152,6 +155,23 @@ public function testDisconnectNoConnection() $transport->disconnect(); } + public function testDisconnectClosesRetainedStream(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends UDP { + public function setStreamForTest($stream): void + { + $this->stream = $stream; + } + }; + $stream = fopen('php://temp', 'r+'); + $transport->setStreamForTest($stream); + + $transport->disconnect(); + + $this->assertFalse(is_resource($stream)); + $this->assertNull($transport->getStream()); + } + /** * @throws TransportException */ @@ -187,4 +207,20 @@ public function testSendNoConnection() } $transport->send('test.send'); } + + public function testSendRejectsPartialDatagrams(): void + { + $transport = new class (['host' => 'test', 'port' => 12345]) extends UDP { + public function connect(): void + { + $this->stream = true; + } + protected function sendTo(string $data): int|false + { + return 1; + } + }; + $this->expectException(TransportException::class); + $transport->send('ab'); + } } diff --git a/tests/Viewer/ViewerTest.php b/tests/Viewer/ViewerTest.php index 6de241e..5427e00 100644 --- a/tests/Viewer/ViewerTest.php +++ b/tests/Viewer/ViewerTest.php @@ -47,6 +47,17 @@ public function testHtmlViewerRendersEscapedServerInformationAndIcons(): void $this->assertStringContainsString('Clients: 3/32', $html); } + public function testHtmlImageAttributesAreEscaped(): void + { + $viewer = new HtmlForTest("icons/'onerror='alert(1)"); + + $html = $viewer->getImageForTest("image.png' onerror='alert(2)", "title' onmouseover='alert(3)"); + + $this->assertStringNotContainsString("' onerror=", $html); + $this->assertStringNotContainsString("' onmouseover=", $html); + $this->assertStringContainsString(''', $html); + } + public function testJsonViewerProducesStructuredServerData(): void { $data = []; @@ -127,3 +138,11 @@ public function count(): int return 0; } } + +class HtmlForTest extends Html +{ + public function getImageForTest(string $name, string $text): string + { + return $this->getImage($name, $text); + } +}