From ad949a8469de60deb48e9316002c798d77e141d7 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Sun, 2 Aug 2026 15:03:20 +0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(=E5=AE=89=E5=85=A8):=20=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20upload=5Ffile=20=E4=BB=BB=E6=84=8F=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E8=AF=BB=E5=8F=96=E4=B8=8E=20downloadFile=20SSRF?= =?UTF-8?q?=EF=BC=8C=E5=88=86=E7=89=87=E4=B8=8A=E4=BC=A0=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E6=95=B0=E9=87=8F=E4=B8=8A=E9=99=90=E4=B8=8E=E8=BF=87=E6=9C=9F?= =?UTF-8?q?=E6=B8=85=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upload_file 的 path 类型增加目录限制,仅允许读取配置的上传目录内文件(realpath 前缀校验,符号链接逃逸一并拦截) - downloadFile 的 URL 校验改为 fail-closed:拒绝内网/保留 IP(含 IPv4/IPv6 各保留段)、DNS 解析失败拒绝、 拒绝十进制/八进制/十六进制等 IP 混淆形式;修复 AAAA 记录(ipv6 键)被跳过导致仅 IPv6 域名绕过校验的问题 - 分片上传 prepare 增加 100 个上限与 10 分钟过期清理,每次 transfer 刷新过期时间;修复 md5(microtime) 快速连续 prepare 时碰撞覆盖的问题 --- src/OneBot/V12/Action/ActionHandlerBase.php | 44 ++- src/OneBot/V12/Validator.php | 174 ++++++++++- tests/OneBot/V12/Action/ActionBaseTest.php | 11 +- .../Action/ActionHandlerBaseSecurityTest.php | 260 +++++++++++++++++ tests/OneBot/V12/ValidatorTest.php | 270 ++++++++++++++++++ 5 files changed, 752 insertions(+), 7 deletions(-) create mode 100644 tests/OneBot/V12/Action/ActionHandlerBaseSecurityTest.php create mode 100644 tests/OneBot/V12/ValidatorTest.php diff --git a/src/OneBot/V12/Action/ActionHandlerBase.php b/src/OneBot/V12/Action/ActionHandlerBase.php index a1c083f..e430c1a 100644 --- a/src/OneBot/V12/Action/ActionHandlerBase.php +++ b/src/OneBot/V12/Action/ActionHandlerBase.php @@ -17,6 +17,12 @@ abstract class ActionHandlerBase { + /** @var int 分片上传缓存的最大数量,防止恶意请求耗尽内存 */ + private const UPLOAD_FRAGMENT_MAX_COUNT = 100; + + /** @var int 分片上传缓存的过期时间(秒),防止长期不结束的传输占用内存 */ + private const UPLOAD_FRAGMENT_EXPIRE_TIME = 600; + /** @internal 内部使用的缓存 */ public static $core_cache; @@ -115,7 +121,15 @@ public function onUploadFile(Action $action, int $stream_type = ONEBOT_JSON): Ac case 'path': Validator::validateParamsByAction($action, ['path' => ONEBOT_TYPE_STRING]); $from_path = $action->params['path']; - if (!file_exists($from_path = FileUtil::getRealPath($from_path))) { + // 限制只能读取上传目录内的文件,防止任意文件读取漏洞 + FileUtil::mkdir($path, 0755, true); + $path_real = realpath($path); + $from_path_real = realpath($from_path); + if ($from_path_real === false || $path_real === false || strpos($from_path_real, $path_real . DIRECTORY_SEPARATOR) !== 0) { + return ActionResponse::create($action)->fail(RetCode::FILESYSTEM_ERROR, 'file not found for path: ' . $from_path); + } + $from_path = $from_path_real; + if (!file_exists($from_path)) { return ActionResponse::create($action)->fail(RetCode::FILESYSTEM_ERROR, 'file not found for path: ' . $from_path); } $file_id = md5_file($from_path); @@ -170,14 +184,26 @@ public function onUploadFileFragmented(Action $action, int $stream_type = ONEBOT if (!is_int($action->params['total_size']) || $action->params['total_size'] <= 0) { return ActionResponse::create($action)->fail(RetCode::BAD_PARAM); } - // 文件ID无法通过文件内容算出来,就通过时间戳获取一个文件ID - $file_id = md5(strval(microtime(true))); + // 先清理掉过期的缓存,防止内存被长期占用 + foreach (self::$upload_fragment as $k => $v) { + if (time() - $v['time'] > self::UPLOAD_FRAGMENT_EXPIRE_TIME) { + unset(self::$upload_fragment[$k]); + } + } + // 超过数量上限则拒绝继续 prepare,防止内存被耗尽 + if (count(self::$upload_fragment) >= self::UPLOAD_FRAGMENT_MAX_COUNT) { + return ActionResponse::create($action)->fail(RetCode::FILESYSTEM_ERROR, 'too many fragmented uploads prepared, please retry later'); + } + // 文件ID无法通过文件内容算出来,就通过时间戳 + 随机数获取一个唯一的文件ID + // (仅用 microtime 的话,快速连续 prepare 时会产生重复 ID,导致分片互相覆盖) + $file_id = md5(strval(microtime(true)) . uniqid('', true)); // 缓存段 self::$upload_fragment[$file_id] = [ 'name' => $action->params['name'], 'total_size' => $action->params['total_size'], 'cache' => [], 'stream' => Stream::create(), + 'time' => time(), ]; // 返回文件ID return ActionResponse::create($action)->ok(['file_id' => $file_id]); @@ -189,6 +215,11 @@ public function onUploadFileFragmented(Action $action, int $stream_type = ONEBOT return ActionResponse::create($action)->fail(RetCode::FILESYSTEM_ERROR, 'file id ' . $action->params['file_id'] . ' not found or not prepared yet'); } $file_id = $action->params['file_id']; + // 检查分片上传是否过期,过期则清理并拒绝继续传输 + if (time() - self::$upload_fragment[$file_id]['time'] > self::UPLOAD_FRAGMENT_EXPIRE_TIME) { + unset(self::$upload_fragment[$file_id]); + return ActionResponse::create($action)->fail(RetCode::FILESYSTEM_ERROR, 'file id ' . $file_id . ' expired'); + } $data = $stream_type === ONEBOT_JSON ? base64_decode($action->params['data']) : $action->params['data']; // 额外的验证,如果数据的长度和传入的size不一致,那么就给个warning if (isset($action->params['size']) && $action->params['size'] !== strlen($data)) { @@ -214,6 +245,8 @@ public function onUploadFileFragmented(Action $action, int $stream_type = ONEBOT // 传入的 offset 比 stream 长度要长,说明乱序了,要先缓存起来 self::$upload_fragment[$file_id]['cache'][$action->params['offset']] = $data; } + // 每次成功传输都刷新过期时间,避免慢速大文件传输超过 UPLOAD_FRAGMENT_EXPIRE_TIME 被中途拒绝 + self::$upload_fragment[$file_id]['time'] = time(); return ActionResponse::create($action)->ok(); case 'finish': // 结束阶段 Validator::validateParamsByAction($action, ['file_id' => ONEBOT_TYPE_STRING, 'sha256' => ONEBOT_TYPE_STRING]); @@ -222,6 +255,11 @@ public function onUploadFileFragmented(Action $action, int $stream_type = ONEBOT return ActionResponse::create($action)->fail(RetCode::FILESYSTEM_ERROR, 'file id ' . $action->params['file_id'] . ' not found or not prepared yet'); } $file_id = $action->params['file_id']; + // 检查分片上传是否过期,过期则清理并拒绝结束 + if (time() - self::$upload_fragment[$file_id]['time'] > self::UPLOAD_FRAGMENT_EXPIRE_TIME) { + unset(self::$upload_fragment[$file_id]); + return ActionResponse::create($action)->fail(RetCode::FILESYSTEM_ERROR, 'file id ' . $file_id . ' expired'); + } // 首先验证文件是不是全的 $total = self::$upload_fragment[$file_id]['total_size']; /** @var Stream $stream */ diff --git a/src/OneBot/V12/Validator.php b/src/OneBot/V12/Validator.php index 6a929f7..a9e1e01 100644 --- a/src/OneBot/V12/Validator.php +++ b/src/OneBot/V12/Validator.php @@ -93,12 +93,48 @@ public static function validateParamsByAction(Action $action_obj, array $array): } } + /** + * 验证 URL 是否为合法的 http(s) 地址,且解析后的 IP 不能是内网地址(防止 SSRF) + * + * 校验策略为 fail-closed:host 不合法、疑似 IP 混淆、或 DNS 无法解析出任何 + * 记录时,一律拒绝该 URL。 + * + * @throws OneBotFailureException + */ public static function validateHttpUrl(string $url): void { $parse = parse_url($url); - if (!isset($parse['scheme']) || $parse['scheme'] !== 'http' && $parse['scheme'] !== 'https') { + if (!isset($parse['scheme']) || !isset($parse['host']) || !is_string($parse['scheme']) || !is_string($parse['host']) + || $parse['scheme'] !== 'http' && $parse['scheme'] !== 'https') { throw new OneBotFailureException(RetCode::NETWORK_ERROR); } + // parse_url 解析出的 IPv6 host 会带有方括号,需要去掉 + $host = trim($parse['host'], '[]'); + // host 为空或包含非法字符(空格、% 等)时直接拒绝 + if (!self::isValidHostSyntax($host)) { + throw new OneBotFailureException(RetCode::NETWORK_ERROR, null, 'URL host is invalid'); + } + // 如果 host 本身是 IP 地址,则直接判断 + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { + if (self::isPrivateOrReservedIp($host)) { + throw new OneBotFailureException(RetCode::NETWORK_ERROR, null, 'URL host resolves to a private or reserved IP address'); + } + return; + } + // host 不是合法 IP 字面量,却只包含数字/点/0x 等字符,疑似十进制/八进制/十六进制/短横线 IP 混淆,直接拒绝 + if (self::isSuspiciousIpLiteral($host)) { + throw new OneBotFailureException(RetCode::NETWORK_ERROR, null, 'URL host is not a valid hostname or IP address'); + } + // 域名:解析 DNS(同时支持 A 记录的 ip 键与 AAAA 记录的 ipv6 键),解析失败或无记录时 fail-closed 拒绝 + $ips = static::lookupHostIps($host); + if ($ips === []) { + throw new OneBotFailureException(RetCode::NETWORK_ERROR, null, 'URL host cannot be resolved'); + } + foreach ($ips as $ip) { + if (self::isPrivateOrReservedIp($ip)) { + throw new OneBotFailureException(RetCode::NETWORK_ERROR, null, 'URL host resolves to a private or reserved IP address'); + } + } } /** @@ -225,6 +261,142 @@ public static function validateEventParams(array $data) } } + /** + * 判断 IP 是否为内网或保留地址(IPv4/IPv6),用于防止 SSRF + */ + public static function isPrivateOrReservedIp(string $ip): bool + { + $packed = @inet_pton($ip); + if ($packed === false) { + return false; + } + if (strlen($packed) === 4) { + $n = unpack('N', $packed)[1]; + // 0.0.0.0/8 保留地址 + if (($n & 0xFF000000) === 0x00000000) { + return true; + } + // 10.0.0.0/8 私网地址 + if (($n & 0xFF000000) === 0x0A000000) { + return true; + } + // 100.64.0.0/10 运营商级 NAT 地址 + if (($n & 0xFFC00000) === 0x64400000) { + return true; + } + // 127.0.0.0/8 回环地址 + if (($n & 0xFF000000) === 0x7F000000) { + return true; + } + // 169.254.0.0/16 链路本地地址 + if (($n & 0xFFFF0000) === 0xA9FE0000) { + return true; + } + // 172.16.0.0/12 私网地址 + if (($n & 0xFFF00000) === 0xAC100000) { + return true; + } + // 192.168.0.0/16 私网地址 + if (($n & 0xFFFF0000) === 0xC0A80000) { + return true; + } + // 224.0.0.0/4 组播地址 + if (($n & 0xF0000000) === 0xE0000000) { + return true; + } + // 240.0.0.0/4 保留地址 + if (($n & 0xF0000000) === 0xF0000000) { + return true; + } + return false; + } + if (strlen($packed) === 16) { + $bytes = unpack('C16', $packed); + // ::1 回环地址 + if ($packed === str_repeat("\0", 15) . "\1") { + return true; + } + // :: 未指定地址 + if ($packed === str_repeat("\0", 16)) { + return true; + } + // fc00::/7 唯一本地地址 + if (($bytes[1] & 0xFE) === 0xFC) { + return true; + } + // fe80::/10 链路本地地址 + if ($bytes[1] === 0xFE && ($bytes[2] & 0xC0) === 0x80) { + return true; + } + // ::ffff:0:0/96 IPv4 映射地址(要求前 80 位全零,否则是合法全局地址),按 IPv4 规则判断 + if ($bytes[11] === 0xFF && $bytes[12] === 0xFF && strncmp($packed, str_repeat("\0", 10), 10) === 0) { + return self::isPrivateOrReservedIp(sprintf('%d.%d.%d.%d', $bytes[13], $bytes[14], $bytes[15], $bytes[16])); + } + return false; + } + return false; + } + + /** + * 解析域名的 DNS 记录并提取全部 IP 列表(A 记录的 ip 键 + AAAA 记录的 ipv6 键) + * + * 查询失败或无任何有效记录时返回空数组,由调用方决定 fail-closed。 + */ + protected static function lookupHostIps(string $host): array + { + $records = @dns_get_record($host, DNS_A | DNS_AAAA); + if ($records === false) { + return []; + } + return static::extractIpsFromDnsRecords($records); + } + + /** + * 从 DNS 查询结果中提取 IP 列表 + * + * 注意:dns_get_record 对 AAAA 记录返回的键是 ipv6 而非 ip,两者都需要处理, + * 否则仅解析到内网 IPv6 的域名会绕过 SSRF 校验。 + */ + protected static function extractIpsFromDnsRecords(array $records): array + { + $ips = []; + foreach ($records as $record) { + if (!is_array($record)) { + continue; + } + foreach (['ip', 'ipv6'] as $key) { + if (isset($record[$key]) && is_string($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP) !== false) { + $ips[] = $record[$key]; + } + } + } + return array_values(array_unique($ips)); + } + + /** + * 判断 host 是否满足基本的 URL 主机名语法(允许域名、IPv4、IPv6 字面量的合法字符) + */ + private static function isValidHostSyntax(string $host): bool + { + return $host !== '' && preg_match('/^[0-9a-zA-Z:._-]+$/', $host) === 1; + } + + /** + * 判断 host 是否为疑似 IP 混淆形式(十进制/八进制/十六进制/短横线等) + * + * 这类 host 无法通过 FILTER_VALIDATE_IP,但某些网络库仍会将其当作 IP 使用, + * 且它们不可能同时是合法的域名(域名必须包含字母),因此直接拒绝。 + */ + private static function isSuspiciousIpLiteral(string $host): bool + { + // 不含任何 ASCII 字母,只能是数字/点/冒号等(如 2130706433、127.1、0177.0.0.1) + if (preg_match('/[a-zA-Z]/', $host) === 0) { + return true; + } + // 0x 开头的十六进制形式(如 0x7f000001、0x7f.0.0.1) + return preg_match('/^0[xX][0-9a-fA-F.]+$/', $host) === 1; + } + private static function validateExist(Action $action_obj, $k): bool { return isset($action_obj->params[$k]); diff --git a/tests/OneBot/V12/Action/ActionBaseTest.php b/tests/OneBot/V12/Action/ActionBaseTest.php index b94b98a..c3c2bf1 100644 --- a/tests/OneBot/V12/Action/ActionBaseTest.php +++ b/tests/OneBot/V12/Action/ActionBaseTest.php @@ -125,17 +125,22 @@ public function testOnUploadFileUrl() public function testOnUploadFilePath() { + // 只能上传上传目录内的文件,先在上传目录内创建一个文件 + $path = ob_config('file_upload.path', getcwd() . '/data/files'); + $tmp_file = FileUtil::getRealPath($path . '/' . md5('path-upload-test') . '.txt'); + FileUtil::mkdir($path, 0755, true); + file_put_contents($tmp_file, 'upload dir content'); $resp = self::$handler->onUploadFile(new Action('upload_file', [ 'type' => 'path', 'name' => 'a.txt', - 'path' => __FILE__, + 'path' => $tmp_file, ]), ONEBOT_JSON); $this->assertEquals(RetCode::OK, $resp->retcode); $this->assertArrayHasKey('file_id', $resp->data); - $path = ob_config('file_upload.path', getcwd() . '/data/files'); [$meta, $content] = FileUtil::getMetaFile($path, $resp->data['file_id']); $this->assertEquals('a.txt', $meta['name']); - $this->assertEquals(file_get_contents(__FILE__), $content); + $this->assertEquals('upload dir content', $content); + unlink($tmp_file); } public function testOnUploadFileData() diff --git a/tests/OneBot/V12/Action/ActionHandlerBaseSecurityTest.php b/tests/OneBot/V12/Action/ActionHandlerBaseSecurityTest.php new file mode 100644 index 0000000..65c3531 --- /dev/null +++ b/tests/OneBot/V12/Action/ActionHandlerBaseSecurityTest.php @@ -0,0 +1,260 @@ +getConfig()->set('file_upload.path', self::$upload_dir); + } + + public static function tearDownAfterClass(): void + { + OneBot::getInstance()->getConfig()->set('file_upload.path', self::$origin_upload_dir); + FileUtil::removeDirRecursive(self::$upload_dir); + } + + public function tearDown(): void + { + // 清理分片上传的静态缓存,避免影响其他测试 + self::setUploadFragment([]); + } + + public function testUploadFilePathInsideUploadDir() + { + FileUtil::mkdir(self::$upload_dir, 0755, true); + $inside_file = FileUtil::getRealPath(self::$upload_dir . '/inside.txt'); + file_put_contents($inside_file, 'inside content'); + $resp = self::$handler->onUploadFile(new Action('upload_file', [ + 'type' => 'path', + 'name' => 'inside.txt', + 'path' => $inside_file, + ]), ONEBOT_JSON); + $this->assertEquals(RetCode::OK, $resp->retcode); + $this->assertArrayHasKey('file_id', $resp->data); + [$meta, $content] = FileUtil::getMetaFile(self::$upload_dir, $resp->data['file_id']); + $this->assertEquals('inside content', $content); + unlink($inside_file); + } + + public function testUploadFilePathOutsideUploadDirRejected() + { + // 上传目录之外的文件(如系统敏感文件)必须被拒绝 + $resp = self::$handler->onUploadFile(new Action('upload_file', [ + 'type' => 'path', + 'name' => 'outside.txt', + 'path' => __FILE__, + ]), ONEBOT_JSON); + $this->assertNotEquals(RetCode::OK, $resp->retcode); + } + + public function testUploadFilePathNonexistentRejected() + { + $resp = self::$handler->onUploadFile(new Action('upload_file', [ + 'type' => 'path', + 'name' => 'notexist.txt', + 'path' => FileUtil::getRealPath(self::$upload_dir . '/not-exist-file.txt'), + ]), ONEBOT_JSON); + $this->assertNotEquals(RetCode::OK, $resp->retcode); + } + + public function testUploadFilePathSymlinkOutsideRejected() + { + // 通过符号链接指向目录外文件也必须被拒绝(realpath 会解析符号链接) + if (!function_exists('symlink')) { + $this->markTestSkipped('symlink is not available'); + } + FileUtil::mkdir(self::$upload_dir, 0755, true); + $outside_file = tempnam(sys_get_temp_dir(), 'ob-outside-'); + file_put_contents($outside_file, 'secret'); + $link = FileUtil::getRealPath(self::$upload_dir . '/evil-link.txt'); + if (!@symlink($outside_file, $link)) { + $this->markTestSkipped('cannot create symlink'); + } + $resp = self::$handler->onUploadFile(new Action('upload_file', [ + 'type' => 'path', + 'name' => 'evil.txt', + 'path' => $link, + ]), ONEBOT_JSON); + $this->assertNotEquals(RetCode::OK, $resp->retcode); + unlink($link); + unlink($outside_file); + } + + public function testUploadFileFragmentedPrepareLimit() + { + $ok_count = 0; + // 先准备 100 个,应该全部成功 + for ($i = 0; $i < 100; ++$i) { + $prepare = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'prepare', + 'name' => 'a' . $i . '.txt', + 'total_size' => 10, + ])); + if ($prepare->retcode === RetCode::OK) { + ++$ok_count; + } + } + $this->assertEquals(100, $ok_count); + // 第 101 个必须被拒绝 + $prepare = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'prepare', + 'name' => 'overflow.txt', + 'total_size' => 10, + ])); + $this->assertNotEquals(RetCode::OK, $prepare->retcode); + } + + public function testUploadFileFragmentedTransferExpired() + { + $prepare = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'prepare', + 'name' => 'expired.txt', + 'total_size' => 10, + ])); + $this->assertEquals(RetCode::OK, $prepare->retcode); + $file_id = $prepare->data['file_id']; + // 把缓存条目的时间戳改到 10 分钟之前,模拟过期 + $fragments = self::getUploadFragment(); + $fragments[$file_id]['time'] = time() - 601; + self::setUploadFragment($fragments); + $transfer = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'transfer', + 'file_id' => $file_id, + 'offset' => 0, + 'data' => base64_encode('1234567890'), + ]), ONEBOT_JSON); + $this->assertNotEquals(RetCode::OK, $transfer->retcode); + // 过期条目应已被清理 + $this->assertArrayNotHasKey($file_id, self::getUploadFragment()); + } + + public function testUploadFileFragmentedFinishExpired() + { + $prepare = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'prepare', + 'name' => 'expired2.txt', + 'total_size' => 10, + ])); + $this->assertEquals(RetCode::OK, $prepare->retcode); + $file_id = $prepare->data['file_id']; + $fragments = self::getUploadFragment(); + $fragments[$file_id]['time'] = time() - 601; + self::setUploadFragment($fragments); + $finish = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'finish', + 'file_id' => $file_id, + 'sha256' => hash('sha256', '1234567890'), + ])); + $this->assertNotEquals(RetCode::OK, $finish->retcode); + $this->assertArrayNotHasKey($file_id, self::getUploadFragment()); + } + + public function testUploadFileFragmentedExpiredCleanedOnPrepare() + { + // prepare 一个分片,改旧时间戳后,再次 prepare 时应被清理掉 + $prepare = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'prepare', + 'name' => 'expired3.txt', + 'total_size' => 10, + ])); + $file_id = $prepare->data['file_id']; + $fragments = self::getUploadFragment(); + $fragments[$file_id]['time'] = time() - 601; + self::setUploadFragment($fragments); + self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'prepare', + 'name' => 'new.txt', + 'total_size' => 10, + ])); + $this->assertArrayNotHasKey($file_id, self::getUploadFragment()); + } + + public function testUploadFileFragmentedTransferRefreshesExpiryTime() + { + $prepare = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'prepare', + 'name' => 'slow.txt', + 'total_size' => 20, + ])); + $this->assertEquals(RetCode::OK, $prepare->retcode); + $file_id = $prepare->data['file_id']; + // 模拟一个即将过期(599 秒前写入)的分片:单次 transfer 成功应刷新过期时间 + $fragments = self::getUploadFragment(); + $fragments[$file_id]['time'] = time() - 599; + self::setUploadFragment($fragments); + $transfer = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'transfer', + 'file_id' => $file_id, + 'offset' => 0, + 'data' => base64_encode('1234567890'), + ]), ONEBOT_JSON); + $this->assertEquals(RetCode::OK, $transfer->retcode); + // 时间应被刷新为当前时间,而不是停留在 prepare 时的值 + $fragments = self::getUploadFragment(); + $this->assertTrue(time() - $fragments[$file_id]['time'] < 10, 'transfer 成功后过期时间应被刷新'); + // 再次模拟接近过期(连续 transfer 之间间隔接近 600 秒),仍不应被拒绝 + $fragments[$file_id]['time'] = time() - 599; + self::setUploadFragment($fragments); + $transfer2 = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'transfer', + 'file_id' => $file_id, + 'offset' => 10, + 'data' => base64_encode('0987654321'), + ]), ONEBOT_JSON); + $this->assertEquals(RetCode::OK, $transfer2->retcode); + // 全程慢速传输完成后,finish 应成功而不是被判定过期 + $finish = self::$handler->onUploadFileFragmented(new Action('upload_file_fragmented', [ + 'stage' => 'finish', + 'file_id' => $file_id, + 'sha256' => hash('sha256', '12345678900987654321'), + ])); + $this->assertEquals(RetCode::OK, $finish->retcode); + } + + private static function getUploadFragment(): array + { + $prop = new \ReflectionProperty(ActionHandlerBase::class, 'upload_fragment'); + if (PHP_VERSION_ID < 80100) { + $prop->setAccessible(true); + } + return $prop->getValue(); + } + + private static function setUploadFragment(array $value): void + { + $prop = new \ReflectionProperty(ActionHandlerBase::class, 'upload_fragment'); + if (PHP_VERSION_ID < 80100) { + $prop->setAccessible(true); + } + $prop->setValue(null, $value); + } +} diff --git a/tests/OneBot/V12/ValidatorTest.php b/tests/OneBot/V12/ValidatorTest.php new file mode 100644 index 0000000..63e975a --- /dev/null +++ b/tests/OneBot/V12/ValidatorTest.php @@ -0,0 +1,270 @@ +fail('Expected OneBotFailureException for URL: ' . $url); + } catch (OneBotFailureException $e) { + $this->assertEquals(RetCode::NETWORK_ERROR, $e->getRetCode(), 'URL: ' . $url); + } + } + } + + public function testValidateHttpUrlPublicIpPasses(): void + { + Validator::validateHttpUrl('http://8.8.8.8/'); + Validator::validateHttpUrl('https://1.1.1.1/'); + // ::ffff:0:0/96 之外、看起来像 IPv4 映射的合法全局地址不应被误判为私网 + Validator::validateHttpUrl('http://[0:0:0:0:1:ffff:7f00:1]/'); + Validator::validateHttpUrl('http://[::ffff:8.8.8.8]/'); + $this->assertTrue(true); + } + + public function testValidateHttpUrlPublicHostnamePassesWithResolvableDns(): void + { + // 域名解析到公网 IP 时必须放行(DNS 结果可注入,不依赖真实网络) + TestableValidator::$dns_records = ['example.com' => [ + ['host' => 'example.com', 'type' => 'A', 'ip' => '8.8.8.8', 'ttl' => 300, 'class' => 'IN'], + ['host' => 'example.com', 'type' => 'AAAA', 'ipv6' => '2606:4700::1111', 'ttl' => 300, 'class' => 'IN'], + ]]; + TestableValidator::validateHttpUrl('https://example.com/'); + $this->assertTrue(true); + } + + public function testValidateHttpUrlDnsFailureRejected(): void + { + // DNS 查询失败(返回 false 或无任何记录)时 fail-closed:必须拒绝 + TestableValidator::$dns_records = []; + try { + TestableValidator::validateHttpUrl('https://unresolvable.invalid/'); + $this->fail('Expected OneBotFailureException for unresolvable host'); + } catch (OneBotFailureException $e) { + $this->assertEquals(RetCode::NETWORK_ERROR, $e->getRetCode()); + } + // 返回空记录数组同样拒绝 + TestableValidator::$dns_records = ['unresolvable.invalid' => []]; + try { + TestableValidator::validateHttpUrl('https://unresolvable.invalid/'); + $this->fail('Expected OneBotFailureException for host without records'); + } catch (OneBotFailureException $e) { + $this->assertEquals(RetCode::NETWORK_ERROR, $e->getRetCode()); + } + } + + public function testValidateHttpUrlDnsRecordWithIpv6KeyRejected(): void + { + // dns_get_record 对 AAAA 记录返回的键是 ipv6,仅解析到内网 IPv6 的域名必须被拦截 + TestableValidator::$dns_records = ['internal-v6.example' => [ + ['host' => 'internal-v6.example', 'type' => 'AAAA', 'ipv6' => '::1', 'ttl' => 300, 'class' => 'IN'], + ]]; + try { + TestableValidator::validateHttpUrl('https://internal-v6.example/'); + $this->fail('Expected OneBotFailureException for IPv6-only private host'); + } catch (OneBotFailureException $e) { + $this->assertEquals(RetCode::NETWORK_ERROR, $e->getRetCode()); + } + // fc00::/7 唯一本地地址同样拦截 + TestableValidator::$dns_records = ['internal-v6.example' => [ + ['host' => 'internal-v6.example', 'type' => 'AAAA', 'ipv6' => 'fc00::1', 'ttl' => 300, 'class' => 'IN'], + ]]; + try { + TestableValidator::validateHttpUrl('https://internal-v6.example/'); + $this->fail('Expected OneBotFailureException for ULA IPv6 host'); + } catch (OneBotFailureException $e) { + $this->assertEquals(RetCode::NETWORK_ERROR, $e->getRetCode()); + } + // 公网 IPv6 域名放行 + TestableValidator::$dns_records = ['public-v6.example' => [ + ['host' => 'public-v6.example', 'type' => 'AAAA', 'ipv6' => '2606:4700::1111', 'ttl' => 300, 'class' => 'IN'], + ]]; + TestableValidator::validateHttpUrl('https://public-v6.example/'); + $this->assertTrue(true); + } + + public function testExtractIpsFromDnsRecords(): void + { + $ips = TestableValidator::extractIpsFromDnsRecordsPublic([ + ['host' => 'example.com', 'type' => 'A', 'ip' => '8.8.8.8', 'ttl' => 300, 'class' => 'IN'], + ['host' => 'example.com', 'type' => 'AAAA', 'ipv6' => '::1', 'ttl' => 300, 'class' => 'IN'], + ]); + $this->assertContains('8.8.8.8', $ips); + $this->assertContains('::1', $ips); + // 无效的 ip/ipv6 值应被忽略 + $ips = TestableValidator::extractIpsFromDnsRecordsPublic([ + ['host' => 'example.com', 'type' => 'CNAME', 'target' => 'other.example.com'], + ['host' => 'example.com', 'type' => 'A', 'ip' => 'not-an-ip'], + ['host' => 'example.com', 'type' => 'AAAA', 'ipv6' => 'not-an-ip'], + ]); + $this->assertSame([], $ips); + } + + public function testValidateHttpUrlInvalidHostRejected(): void + { + $invalid_urls = [ + 'http:///path', + 'https://[]/', + 'http://exa mple.com/', + 'http://exa%20mple.com/', + ]; + foreach ($invalid_urls as $url) { + try { + Validator::validateHttpUrl($url); + $this->fail('Expected OneBotFailureException for URL: ' . $url); + } catch (OneBotFailureException $e) { + $this->assertEquals(RetCode::NETWORK_ERROR, $e->getRetCode(), 'URL: ' . $url); + } + } + } + + public function testValidateHttpUrlIpConfusionRejected(): void + { + // 十进制/短横线/八进制/十六进制等 IP 混淆形式,FILTER_VALIDATE_IP 与 DNS 都无法识别,必须拒绝 + $confusion_urls = [ + 'http://2130706433/', + 'http://127.1/', + 'http://127.0.1/', + 'http://0177.0.0.1/', + 'http://0x7f000001/', + 'http://0x7f.0.0.1/', + 'http://999.999.999.999/', + ]; + foreach ($confusion_urls as $url) { + try { + Validator::validateHttpUrl($url); + $this->fail('Expected OneBotFailureException for URL: ' . $url); + } catch (OneBotFailureException $e) { + $this->assertEquals(RetCode::NETWORK_ERROR, $e->getRetCode(), 'URL: ' . $url); + } + } + } + + public function testValidateHttpUrlNonHttpSchemeRejected(): void + { + $invalid_urls = [ + 'ftp://example.com/file', + 'file:///etc/passwd', + 'javascript:alert(1)', + 'http:no-host', + 'https://', + ]; + foreach ($invalid_urls as $url) { + try { + Validator::validateHttpUrl($url); + $this->fail('Expected OneBotFailureException for URL: ' . $url); + } catch (OneBotFailureException $e) { + $this->assertEquals(RetCode::NETWORK_ERROR, $e->getRetCode(), 'URL: ' . $url); + } + } + } + + public function testIsPrivateOrReservedIp(): void + { + // IPv4 私网/保留 + $private = [ + '127.0.0.1', + '10.0.0.1', + '172.16.0.1', + '192.168.1.1', + '169.254.0.1', + '0.0.0.0', + '100.64.0.1', + '224.0.0.1', + '240.0.0.1', + ]; + foreach ($private as $ip) { + $this->assertTrue(Validator::isPrivateOrReservedIp($ip), $ip . ' should be private/reserved'); + } + $public = [ + '8.8.8.8', + '1.1.1.1', + '9.9.9.9', + ]; + foreach ($public as $ip) { + $this->assertFalse(Validator::isPrivateOrReservedIp($ip), $ip . ' should be public'); + } + // IPv6 私网/保留 + $private_v6 = [ + '::1', + '::', + 'fc00::1', + 'fd12:3456:789a::1', + 'fe80::1', + '::ffff:127.0.0.1', + '::ffff:192.168.1.1', + ]; + foreach ($private_v6 as $ip) { + $this->assertTrue(Validator::isPrivateOrReservedIp($ip), $ip . ' should be private/reserved'); + } + $public_v6 = [ + '2606:4700::1111', + '2001:4860:4860::8888', + '::ffff:8.8.8.8', + '0:0:0:0:1:ffff:7f00:1', + ]; + foreach ($public_v6 as $ip) { + $this->assertFalse(Validator::isPrivateOrReservedIp($ip), $ip . ' should be public'); + } + // 非法输入 + $this->assertFalse(Validator::isPrivateOrReservedIp('not-an-ip')); + } +} + +/** + * 可注入 DNS 查询结果的 Validator 子类,用于离线单测 + * + * @internal + */ +class TestableValidator extends Validator +{ + /** @var array host => dns_get_record 返回的记录数组 */ + public static array $dns_records = []; + + public static function extractIpsFromDnsRecordsPublic(array $records): array + { + return static::extractIpsFromDnsRecords($records); + } + + protected static function lookupHostIps(string $host): array + { + if (!isset(self::$dns_records[$host])) { + // 模拟 DNS 查询失败 + return []; + } + return static::extractIpsFromDnsRecords(self::$dns_records[$host]); + } +} From 4940664dbf1db2a87b36d102f1c361805aa3901a Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Sun, 2 Aug 2026 15:03:23 +0800 Subject: [PATCH 2/4] =?UTF-8?q?fix(Workerman):=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E5=AF=B9=E8=B1=A1=E6=B1=A0=E8=B7=A8=E6=B1=A0=E4=B8=B2=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E3=80=81return=20=E5=BF=85=E7=84=B6=20TypeError?= =?UTF-8?q?=E3=80=81=E5=BD=92=E8=BF=98=E9=9D=99=E9=BB=98=E4=B8=A2=E5=AF=B9?= =?UTF-8?q?=E8=B1=A1=E4=B8=8E=20HTTP=20=E5=BC=82=E5=B8=B8=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=8C=82=E8=B5=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ObjectPool 的等待协程队列由静态改为实例属性,多个池共存时不再互相 resume 串对象 - AbstractObjectPool::return 对 SplQueue 的 push 返回 void 不再触发 TypeError - 协程环境失效时归还的对象不再被静默丢弃 - HTTP 请求处理异常发生在响应创建前时,兜底发送 500 响应避免客户端永久等待 --- src/OneBot/Driver/Workerman/ObjectPool.php | 20 +-- .../Driver/Workerman/TopEventListener.php | 10 +- src/OneBot/ObjectPool/AbstractObjectPool.php | 8 +- tests/OneBot/Driver/WebSocketClientTest.php | 130 ++++++++++++++++++ .../Driver/Workerman/ObjectPoolTest.php | 122 ++++++++++++++++ .../ObjectPool/AbstractObjectPoolTest.php | 45 ++++++ 6 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 tests/OneBot/Driver/WebSocketClientTest.php create mode 100644 tests/OneBot/Driver/Workerman/ObjectPoolTest.php create mode 100644 tests/OneBot/ObjectPool/AbstractObjectPoolTest.php diff --git a/src/OneBot/Driver/Workerman/ObjectPool.php b/src/OneBot/Driver/Workerman/ObjectPool.php index 3ceed5d..1b9ac04 100644 --- a/src/OneBot/Driver/Workerman/ObjectPool.php +++ b/src/OneBot/Driver/Workerman/ObjectPool.php @@ -24,8 +24,8 @@ class ObjectPool implements PoolInterface /** @var array 借出去的对象 Hash 表 */ protected $out = []; - /** @var array 用于 Workerman Fiber 对接时保存协程 ID 的 */ - private static $coroutine_cid = []; + /** @var array 用于 Workerman Fiber 对接时保存协程 ID 的(每个池实例独立,避免跨池串对象) */ + private $coroutine_cid = []; public function __construct(int $size, string $construct_class, ...$args) { @@ -46,9 +46,9 @@ public function __destruct() public function get($recursive = 0): object { if ($this->getFreeCount() <= 0) { // 当池子见底了,就自动用 Swoole 的 Channel 消费者模型堵起来 - if (($cid = Adaptive::getCoroutine()->getCid()) !== -1) { - self::$coroutine_cid[] = $cid; - $result = Adaptive::getCoroutine()->suspend(); + if (($co = Adaptive::getCoroutine()) !== null && $co->getCid() !== -1) { + $this->coroutine_cid[] = $co->getCid(); + $result = $co->suspend(); } elseif ($recursive <= 10) { Adaptive::sleep(1); return $this->get(++$recursive); @@ -72,9 +72,13 @@ public function put(object $object): bool throw new \RuntimeException('Cannot put object that not got from here'); } unset($this->out[spl_object_hash($object)]); - if (!empty(self::$coroutine_cid)) { - $cid = array_shift(self::$coroutine_cid); - Adaptive::getCoroutine()->resume($cid, $object); + if (!empty($this->coroutine_cid)) { + $cid = array_shift($this->coroutine_cid); + $co = Adaptive::getCoroutine(); + if ($co === null || $co->resume($cid, $object) === false) { + // 协程环境已不可用或等待协程已不存在,无法唤醒;把对象放回空闲队列,避免对象丢失 + $this->queue->push($object); + } return true; } try { diff --git a/src/OneBot/Driver/Workerman/TopEventListener.php b/src/OneBot/Driver/Workerman/TopEventListener.php index 2cbc49e..3ec5a50 100644 --- a/src/OneBot/Driver/Workerman/TopEventListener.php +++ b/src/OneBot/Driver/Workerman/TopEventListener.php @@ -210,11 +210,13 @@ public function onHttpRequest(array $config, TcpConnection $connection, Request } } catch (\Throwable $e) { ExceptionHandler::getInstance()->handle($e); - if (isset($response)) { - $response->withStatus(500); - $response->withBody('Internal Server Error'); - $connection->send($response); + // 如果异常发生在响应对象创建之前,需要先创建一个 500 响应,否则连接会一直挂起 + if (!isset($response)) { + $response = new WorkermanResponse(); } + $response->withStatus(500); + $response->withBody('Internal Server Error'); + $connection->send($response); } } } diff --git a/src/OneBot/ObjectPool/AbstractObjectPool.php b/src/OneBot/ObjectPool/AbstractObjectPool.php index 0e50d3d..521fc6f 100644 --- a/src/OneBot/ObjectPool/AbstractObjectPool.php +++ b/src/OneBot/ObjectPool/AbstractObjectPool.php @@ -68,7 +68,13 @@ public function return(object $object): bool unset($this->actives[$hash]); // 放回队列里 - return $this->queue->push($object); + if ($this->queue instanceof Channel) { + // Swoole Channel 的 push 返回 bool + return $this->queue->push($object); + } + // SplQueue 的 push 返回 void,视为成功 + $this->queue->push($object); + return true; } abstract protected function makeObject(): object; diff --git a/tests/OneBot/Driver/WebSocketClientTest.php b/tests/OneBot/Driver/WebSocketClientTest.php new file mode 100644 index 0000000..0e89230 --- /dev/null +++ b/tests/OneBot/Driver/WebSocketClientTest.php @@ -0,0 +1,130 @@ +markTestSkipped('swoole extension not loaded'); + } + $client = new SwooleWebSocketClient(); + // 通过反射注入 request,避免创建真实连接 + $request = HttpFactory::createRequest('GET', 'ws://example.com/path?q=1#frag'); + $request_prop = new \ReflectionProperty(SwooleWebSocketClient::class, 'request'); + if (PHP_VERSION_ID < 80100) { + $request_prop->setAccessible(true); + } + $request_prop->setValue($client, $request); + // 注入伪造的客户端,只记录 upgrade 的 URI,不进行真实连接 + $fake_client = new class { + public $errCode = 0; + + public $errMsg = ''; + + /** @var null|string */ + public $upgrade_uri; + + public function upgrade(string $uri) + { + $this->upgrade_uri = $uri; + return false; + } + }; + $client_prop = new \ReflectionProperty(SwooleWebSocketClient::class, 'client'); + if (PHP_VERSION_ID < 80100) { + $client_prop->setAccessible(true); + } + $client_prop->setValue($client, $fake_client); + + $this->assertFalse($client->connect()); + // fragment 必须用 # 拼接,而不是 ? + $this->assertSame('/path?q=1#frag', $fake_client->upgrade_uri); + } + + public function testSwooleClientNoQueryNoFragment() + { + if (!extension_loaded('swoole')) { + $this->markTestSkipped('swoole extension not loaded'); + } + $client = new SwooleWebSocketClient(); + $request = HttpFactory::createRequest('GET', 'ws://example.com/'); + $request_prop = new \ReflectionProperty(SwooleWebSocketClient::class, 'request'); + if (PHP_VERSION_ID < 80100) { + $request_prop->setAccessible(true); + } + $request_prop->setValue($client, $request); + $fake_client = new class { + public $errCode = 0; + + public $errMsg = ''; + + /** @var null|string */ + public $upgrade_uri; + + public function upgrade(string $uri) + { + $this->upgrade_uri = $uri; + return false; + } + }; + $client_prop = new \ReflectionProperty(SwooleWebSocketClient::class, 'client'); + if (PHP_VERSION_ID < 80100) { + $client_prop->setAccessible(true); + } + $client_prop->setValue($client, $fake_client); + + $client->connect(); + $this->assertSame('/', $fake_client->upgrade_uri); + } + + public function testWorkermanClientDefaultPortWhenNotSpecified() + { + $client = new WorkermanWebSocketClient(); + $request = HttpFactory::createRequest('GET', 'ws://example.com/path'); + $client->withRequest($request); + $connection = $this->getConnection($client); + $this->assertSame(80, $this->getPrivateValue($connection, '_remotePort')); + $this->assertSame('example.com', $this->getPrivateValue($connection, '_remoteHost')); + } + + public function testWorkermanClientKeepsExplicitPort() + { + $client = new WorkermanWebSocketClient(); + $request = HttpFactory::createRequest('GET', 'ws://example.com:8080/path'); + $client->withRequest($request); + $connection = $this->getConnection($client); + $this->assertSame(8080, $this->getPrivateValue($connection, '_remotePort')); + } + + private function getConnection(WorkermanWebSocketClient $client) + { + $prop = new \ReflectionProperty(WorkermanWebSocketClient::class, 'connection'); + if (PHP_VERSION_ID < 80100) { + $prop->setAccessible(true); + } + return $prop->getValue($client); + } + + private function getPrivateValue($object, string $name) + { + $prop = new \ReflectionProperty($object, $name); + if (PHP_VERSION_ID < 80100) { + $prop->setAccessible(true); + } + return $prop->getValue($object); + } +} diff --git a/tests/OneBot/Driver/Workerman/ObjectPoolTest.php b/tests/OneBot/Driver/Workerman/ObjectPoolTest.php new file mode 100644 index 0000000..e23b0d5 --- /dev/null +++ b/tests/OneBot/Driver/Workerman/ObjectPoolTest.php @@ -0,0 +1,122 @@ +markTestSkipped('Fiber 协程仅在 PHP >= 8.1 可用'); + } + // 初始化 Fiber 协程环境 + Adaptive::initWithDriver(WorkermanDriver::getInstance()); + } + + protected function tearDown(): void + { + // 恢复未初始化协程的状态,避免影响其他测试 + self::setCoroutineNull(); + } + + public function testMultiPoolNotMixObjects() + { + $pool_a = new ObjectPool(1, \stdClass::class); + $pool_b = new ObjectPool(1, \stdClass::class); + // 先从两个池子各取出一个对象,让两个池子的队列都为空 + $object_a = $pool_a->get(); + $object_b = $pool_b->get(); + + // 在协程中等待池 A 归还对象,此时池 A 见底,协程会挂起 + $co = Adaptive::getCoroutine(); + $this->assertNotNull($co); + $fiber_result = null; + $cid = $co->create(function () use ($pool_a, &$fiber_result) { + $fiber_result = $pool_a->get(); + }); + $this->assertTrue($co->exists($cid), '协程应在等待池 A 时挂起'); + + // 先归还对象到池 B:修复前会错误地唤醒等待池 A 的协程,并给它池 B 的对象 + $this->assertTrue($pool_b->put($object_b)); + $this->assertTrue($co->exists($cid), '归还到池 B 不应唤醒等待池 A 的协程'); + + // 归还对象到池 A,此时才应该唤醒等待中的协程,并且拿到的必须是池 A 的对象 + $this->assertTrue($pool_a->put($object_a)); + $this->assertFalse($co->exists($cid), '归还到池 A 后协程应已唤醒并结束'); + $this->assertSame($object_a, $fiber_result); + } + + public function testPoolGetPutInSyncMode() + { + // 未初始化协程时(同步模式),get/put 应该走递归重试与队列路径,不抛异常 + self::setCoroutineNull(); + + // 单对象往返:取出-归还-再取出,必须是同一个对象 + $pool = new ObjectPool(3, \stdClass::class); + $obj = $pool->get(); + $this->assertTrue($pool->put($obj)); + $this->assertSame($obj, $pool->get()); + // 归还,释放一个名额,避免池子见底走等待路径 + $this->assertTrue($pool->put($obj)); + + // 两个对象的往返:归还的对象必须还能被取回(SplQueue 的 pop 顺序依赖 PHP 版本,只校验归属) + $obj1 = $pool->get(); + $obj2 = $pool->get(); + $this->assertNotSame($obj1, $obj2); + $this->assertTrue($pool->put($obj1)); + $this->assertTrue($pool->put($obj2)); + $got1 = $pool->get(); + $got2 = $pool->get(); + $this->assertNotSame($got1, $got2); + // 归还的对象必须还能被取回(SplQueue 的 pop 顺序依赖 PHP 版本,只校验归属) + $this->assertTrue(in_array($got1, [$obj1, $obj2], true)); + $this->assertTrue(in_array($got2, [$obj1, $obj2], true)); + } + + public function testPutWithDeadCoroutineEnvironmentPushesBackToQueue() + { + $pool = new ObjectPool(1, \stdClass::class); + $obj = $pool->get(); + $co = Adaptive::getCoroutine(); + $this->assertNotNull($co); + // 协程等待池中对象,挂起并记录 coroutine_cid + $cid = $co->create(function () use ($pool) { + $pool->get(); + }); + $this->assertTrue($co->exists($cid), '协程应在等待对象时挂起'); + + // 模拟协程环境被销毁(Adaptive::$coroutine 置空),等待协程无法被唤醒 + self::setCoroutineNull(); + + // 修复前:对象被静默丢弃(既不入队也不唤醒);修复后:对象必须回到空闲队列 + $this->assertTrue($pool->put($obj)); + $this->assertSame($obj, $pool->get(), '协程环境不可用时归还的对象不应丢失'); + + // 清理:恢复协程环境并唤醒挂起的协程,避免 FiberCoroutine 静态 map 泄漏 + Adaptive::initWithDriver(WorkermanDriver::getInstance()); + $co->resume($cid, $obj); + $this->assertFalse($co->exists($cid), '挂起的协程应已被唤醒并结束'); + $pool->put($obj); + } + + private static function setCoroutineNull() + { + $prop = new \ReflectionProperty(Adaptive::class, 'coroutine'); + if (PHP_VERSION_ID < 80100) { + $prop->setAccessible(true); + } + $prop->setValue(null, null); + } +} diff --git a/tests/OneBot/ObjectPool/AbstractObjectPoolTest.php b/tests/OneBot/ObjectPool/AbstractObjectPoolTest.php new file mode 100644 index 0000000..975f4ef --- /dev/null +++ b/tests/OneBot/ObjectPool/AbstractObjectPoolTest.php @@ -0,0 +1,45 @@ +take(); + // SplQueue::push 返回 void,修复前在 strict_types=1 下会抛 TypeError + $this->assertTrue($pool->return($object)); + } + + public function testTakeReturnsSameObjectAfterReturn() + { + $pool = new TestPool(); + $object = $pool->take(); + $pool->return($object); + // 归还后再次取出,应取回同一个对象 + $this->assertSame($object, $pool->take()); + } +} From 02c8c04574c6ae63e5f4cec3f39716f21d44af01 Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Sun, 2 Aug 2026 15:03:27 +0800 Subject: [PATCH 3/4] =?UTF-8?q?fix(=E9=A9=B1=E5=8A=A8):=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=20Swoole=20=E6=8F=A1=E6=89=8B=E8=B0=83=E8=AF=95?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E3=80=81=E5=AE=9E=E7=8E=B0=20WSServerSocket?= =?UTF-8?q?=20=E5=85=B3=E9=97=AD=E8=BF=9E=E6=8E=A5=E3=80=81=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20WebSocket=20=E5=AE=A2=E6=88=B7=E7=AB=AF=20URI=20?= =?UTF-8?q?=E6=8B=BC=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 删除 Swoole WebSocket 握手时的 echo 调试残留,保留 Sec-WebSocket-Key 校验逻辑 - 实现 Swoole WSServerSocket::close,此前该接口永远返回 false 导致无法主动关闭连接 - WebSocket 客户端 URI fragment 由 ? 修正为 #,Workerman 客户端端口为 null 时默认 80 - OneBotBuilder 组件构建不再要求配置键的注册顺序 - Driver 未知通信类型不再静默忽略,输出警告日志 --- src/OneBot/Driver/Driver.php | 4 ++++ src/OneBot/Driver/Swoole/Socket/WSServerSocket.php | 6 +++++- src/OneBot/Driver/Swoole/TopEventListener.php | 1 - src/OneBot/Driver/Swoole/WebSocketClient.php | 2 +- src/OneBot/Driver/Workerman/WebSocketClient.php | 6 +++++- src/OneBot/V12/OneBotBuilder.php | 8 ++++++-- 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/OneBot/Driver/Driver.php b/src/OneBot/Driver/Driver.php index 306941b..c24afe9 100644 --- a/src/OneBot/Driver/Driver.php +++ b/src/OneBot/Driver/Driver.php @@ -88,6 +88,10 @@ public function initDriverProtocols(array $comm) case 'websocket_reverse': $has_ws_reverse[] = $v; break; + default: + // 未知的通信类型直接跳过,并打警告日志 + ob_logger()->warning('未知的通信类型: ' . $v['type'] . ',已跳过该配置'); + break; } } [$http, $webhook, $ws, $ws_reverse] = $this->initInternalDriverClasses($http_index, $has_http_webhook, $ws_index, $has_ws_reverse); diff --git a/src/OneBot/Driver/Swoole/Socket/WSServerSocket.php b/src/OneBot/Driver/Swoole/Socket/WSServerSocket.php index 272d6af..319577c 100644 --- a/src/OneBot/Driver/Swoole/Socket/WSServerSocket.php +++ b/src/OneBot/Driver/Swoole/Socket/WSServerSocket.php @@ -24,7 +24,11 @@ public function __construct(?Server $server = null, ?Port $port = null, array $c public function close($fd): bool { - return false; + if ($this->server === null || !$this->server->exists($fd)) { + ob_logger()->warning('链接不存在,可能已被关闭或未连接'); + return false; + } + return $this->server->close($fd); } public function send($data, $fd): bool diff --git a/src/OneBot/Driver/Swoole/TopEventListener.php b/src/OneBot/Driver/Swoole/TopEventListener.php index bc66620..8f9bf2c 100644 --- a/src/OneBot/Driver/Swoole/TopEventListener.php +++ b/src/OneBot/Driver/Swoole/TopEventListener.php @@ -207,7 +207,6 @@ public function onHandshake(array $config, Request $request, Response $response) $response->end(); return false; } - echo $request->header['sec-websocket-key']; $key = base64_encode( sha1( $request->header['sec-websocket-key'] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11', diff --git a/src/OneBot/Driver/Swoole/WebSocketClient.php b/src/OneBot/Driver/Swoole/WebSocketClient.php index 5418fb9..1842ac7 100644 --- a/src/OneBot/Driver/Swoole/WebSocketClient.php +++ b/src/OneBot/Driver/Swoole/WebSocketClient.php @@ -85,7 +85,7 @@ public function connect(): bool $uri .= '?' . $query; } if (($fragment = $this->request->getUri()->getFragment()) !== '') { - $uri .= '?' . $fragment; + $uri .= '#' . $fragment; } $r = $this->client->upgrade($uri); if ($this->client->errCode !== 0) { diff --git a/src/OneBot/Driver/Workerman/WebSocketClient.php b/src/OneBot/Driver/Workerman/WebSocketClient.php index 6dc6e48..73bd475 100644 --- a/src/OneBot/Driver/Workerman/WebSocketClient.php +++ b/src/OneBot/Driver/Workerman/WebSocketClient.php @@ -59,7 +59,11 @@ public static function createFromAddress($address, array $header = []): WebSocke public function withRequest(RequestInterface $request): WebSocketClientInterface { // 通过 AsyncTcpConnection 建立连接 - $this->connection = new AsyncTcpConnection('ws://' . $request->getUri()->getHost() . ':' . $request->getUri()->getPort()); + $port = $request->getUri()->getPort(); + if ($port === null) { + $port = 80; + } + $this->connection = new AsyncTcpConnection('ws://' . $request->getUri()->getHost() . ':' . $port); // 通过 walkor 的隐藏魔法(无语了),设置请求的 Header。因为 PSR 的 Request 对象返回 Headers 是数组形式的,我们不需要重复的 Header 只取一个就行 /* @phpstan-ignore-next-line */ $this->connection->headers = array_map(function ($x) { diff --git a/src/OneBot/V12/OneBotBuilder.php b/src/OneBot/V12/OneBotBuilder.php index 244ee9d..2a672a5 100644 --- a/src/OneBot/V12/OneBotBuilder.php +++ b/src/OneBot/V12/OneBotBuilder.php @@ -110,8 +110,12 @@ public function build(): OneBot { $required_config = ['name', 'platform', 'self_id', 'logger', 'driver', 'communications']; - if (array_keys($this->components) !== $required_config) { - $missing = implode(', ', array_diff($required_config, array_keys($this->components))); + // 不校验组件配置的键顺序,只校验是否都配置齐全 + $component_keys = array_keys($this->components); + sort($required_config); + sort($component_keys); + if ($required_config !== $component_keys) { + $missing = implode(', ', array_diff($required_config, $component_keys)); throw new \InvalidArgumentException('Builder must be configured before building, missing: ' . $missing); } From 3a466ee44dfe6300dff49d35843b62cd2018378f Mon Sep 17 00:00:00 2001 From: crazywhalecc Date: Sun, 2 Aug 2026 15:03:27 +0800 Subject: [PATCH 4/4] bump version to 0.6.11 --- src/OneBot/global_defines.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/OneBot/global_defines.php b/src/OneBot/global_defines.php index 7b4b38e..b5e2f10 100644 --- a/src/OneBot/global_defines.php +++ b/src/OneBot/global_defines.php @@ -14,7 +14,7 @@ use ZM\Logger\ConsoleLogger; const ONEBOT_VERSION = '12'; -const ONEBOT_LIBOB_VERSION = '0.6.10'; +const ONEBOT_LIBOB_VERSION = '0.6.11'; const ONEBOT_JSON = 1; const ONEBOT_MSGPACK = 2;