From e401ee05f5e984f28a3f638ae6ca5b5848d9dfd1 Mon Sep 17 00:00:00 2001 From: Robert Date: Fri, 12 Jul 2024 16:40:13 -0400 Subject: [PATCH] Introduce buffering to send channel packet for capability to resume across timeout --- phpseclib/Net/SSH2.php | 27 +++++++- tests/Unit/Net/SSH2UnitTest.php | 113 ++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/phpseclib/Net/SSH2.php b/phpseclib/Net/SSH2.php index 088713e8..df8fd3d7 100644 --- a/phpseclib/Net/SSH2.php +++ b/phpseclib/Net/SSH2.php @@ -626,7 +626,7 @@ class SSH2 protected $server_channels = []; /** - * Channel Buffers + * Channel Read Buffers * * If a client requests a packet from one channel but receives two packets from another those packets should * be placed in a buffer @@ -637,6 +637,17 @@ class SSH2 */ private $channel_buffers = []; + /** + * Channel Write Buffers + * + * If a client sends a packet and receives a timeout error mid-transmission, buffer the data written so it + * can be de-duplicated upon resuming write + * + * @see self::send_channel_packet() + * @var array + */ + private $channel_buffers_write = []; + /** * Channel Status * @@ -3446,6 +3457,8 @@ class SSH2 $this->get_seq_no = $this->send_seq_no = 0; $this->channel_status = []; $this->channel_id_last_interactive = 0; + $this->channel_buffers = []; + $this->channel_buffers_write = []; } /** @@ -4480,6 +4493,14 @@ class SSH2 */ protected function send_channel_packet($client_channel, $data) { + if (isset($this->channel_buffers_write[$client_channel]) + && strpos($data, $this->channel_buffers_write[$client_channel]) === 0 + ) { + // if buffer holds identical initial data content, resume send from the unmatched data portion + $data = substr($data, strlen($this->channel_buffers_write[$client_channel])); + } else { + $this->channel_buffers_write[$client_channel] = ''; + } while (strlen($data)) { if (!$this->window_size_client_to_server[$client_channel]) { // using an invalid channel will let the buffers be built up for the valid channels @@ -4487,7 +4508,7 @@ class SSH2 if ($this->isTimeout()) { throw new TimeoutException('Timed out waiting for server'); } elseif (!$this->window_size_client_to_server[$client_channel]) { - throw new \RuntimeException('Client to server window was not adjusted'); + throw new \RuntimeException('Data window was not adjusted'); } } @@ -4509,7 +4530,9 @@ class SSH2 ); $this->window_size_client_to_server[$client_channel] -= strlen($temp); $this->send_binary_packet($packet); + $this->channel_buffers_write[$client_channel] .= $temp; } + unset($this->channel_buffers_write[$client_channel]); } /** diff --git a/tests/Unit/Net/SSH2UnitTest.php b/tests/Unit/Net/SSH2UnitTest.php index 0d8a1817..2f285012 100644 --- a/tests/Unit/Net/SSH2UnitTest.php +++ b/tests/Unit/Net/SSH2UnitTest.php @@ -8,8 +8,11 @@ namespace phpseclib3\Tests\Unit\Net; +use phpseclib3\Common\Functions\Strings; use phpseclib3\Exception\InsufficientSetupException; +use phpseclib3\Exception\TimeoutException; use phpseclib3\Net\SSH2; +use phpseclib3\Net\SSH2\MessageType; use phpseclib3\Tests\PhpseclibTestCase; class SSH2UnitTest extends PhpseclibTestCase @@ -271,6 +274,116 @@ class SSH2UnitTest extends PhpseclibTestCase $this->assertEquals([0, 0], self::callFunc($ssh, 'get_stream_timeout')); } + /** + * @requires PHPUnit < 10 + */ + public function testSendChannelPacketNoBufferedData() + { + $ssh = $this->getMockBuilder('phpseclib3\Net\SSH2') + ->disableOriginalConstructor() + ->setMethods(['get_channel_packet', 'send_binary_packet']) + ->getMock(); + $ssh->expects($this->once()) + ->method('get_channel_packet') + ->with(-1) + ->willReturnCallback(function () use ($ssh) { + self::setVar($ssh, 'window_size_client_to_server', [1 => 0x7FFFFFFF]); + }); + $ssh->expects($this->once()) + ->method('send_binary_packet') + ->with(Strings::packSSH2('CNs', MessageType::CHANNEL_DATA, 1, 'hello world')); + self::setVar($ssh, 'server_channels', [1 => 1]); + self::setVar($ssh, 'packet_size_client_to_server', [1 => 0x7FFFFFFF]); + self::setVar($ssh, 'window_size_client_to_server', [1 => 0]); + self::setVar($ssh, 'window_size_server_to_client', [1 => 0x7FFFFFFF]); + + self::callFunc($ssh, 'send_channel_packet', [1, 'hello world']); + $this->assertEmpty(self::getVar($ssh, 'channel_buffers_write')); + } + + /** + * @requires PHPUnit < 10 + */ + public function testSendChannelPacketBufferedData() + { + $ssh = $this->getMockBuilder('phpseclib3\Net\SSH2') + ->disableOriginalConstructor() + ->setMethods(['get_channel_packet', 'send_binary_packet']) + ->getMock(); + $ssh->expects($this->once()) + ->method('get_channel_packet') + ->with(-1) + ->willReturnCallback(function () use ($ssh) { + self::setVar($ssh, 'window_size_client_to_server', [1 => 0x7FFFFFFF]); + }); + $ssh->expects($this->once()) + ->method('send_binary_packet') + ->with(Strings::packSSH2('CNs', MessageType::CHANNEL_DATA, 1, ' world')); + self::setVar($ssh, 'channel_buffers_write', [1 => 'hello']); + self::setVar($ssh, 'server_channels', [1 => 1]); + self::setVar($ssh, 'packet_size_client_to_server', [1 => 0x7FFFFFFF]); + self::setVar($ssh, 'window_size_client_to_server', [1 => 0]); + self::setVar($ssh, 'window_size_server_to_client', [1 => 0x7FFFFFFF]); + + self::callFunc($ssh, 'send_channel_packet', [1, 'hello world']); + $this->assertEmpty(self::getVar($ssh, 'channel_buffers_write')); + } + + /** + * @requires PHPUnit < 10 + */ + public function testSendChannelPacketTimeout() + { + $this->expectException(TimeoutException::class); + $this->expectExceptionMessage('Timed out waiting for server'); + + $ssh = $this->getMockBuilder('phpseclib3\Net\SSH2') + ->disableOriginalConstructor() + ->setMethods(['get_channel_packet', 'send_binary_packet']) + ->getMock(); + $ssh->expects($this->once()) + ->method('get_channel_packet') + ->with(-1) + ->willReturnCallback(function () use ($ssh) { + self::setVar($ssh, 'is_timeout', true); + }); + $ssh->expects($this->once()) + ->method('send_binary_packet') + ->with(Strings::packSSH2('CNs', MessageType::CHANNEL_DATA, 1, 'hello')); + self::setVar($ssh, 'server_channels', [1 => 1]); + self::setVar($ssh, 'packet_size_client_to_server', [1 => 0x7FFFFFFF]); + self::setVar($ssh, 'window_size_client_to_server', [1 => 5]); + self::setVar($ssh, 'window_size_server_to_client', [1 => 0x7FFFFFFF]); + + self::callFunc($ssh, 'send_channel_packet', [1, 'hello world']); + $this->assertEquals([1 => 'hello'], self::getVar($ssh, 'channel_buffers_write')); + } + + /** + * @requires PHPUnit < 10 + */ + public function testSendChannelPacketNoWindowAdjustment() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Data window was not adjusted'); + + $ssh = $this->getMockBuilder('phpseclib3\Net\SSH2') + ->disableOriginalConstructor() + ->setMethods(['get_channel_packet', 'send_binary_packet']) + ->getMock(); + $ssh->expects($this->once()) + ->method('get_channel_packet') + ->with(-1); + $ssh->expects($this->never()) + ->method('send_binary_packet'); + self::setVar($ssh, 'server_channels', [1 => 1]); + self::setVar($ssh, 'packet_size_client_to_server', [1 => 0x7FFFFFFF]); + self::setVar($ssh, 'window_size_client_to_server', [1 => 0]); + self::setVar($ssh, 'window_size_server_to_client', [1 => 0x7FFFFFFF]); + + self::callFunc($ssh, 'send_channel_packet', [1, 'hello world']); + } + /** * @return \phpseclib3\Net\SSH2 */