diff --git a/src/MySQLReplication/BinLog/BinLogSocketConnect.php b/src/MySQLReplication/BinLog/BinLogSocketConnect.php index 7755f38..5f3bf1f 100644 --- a/src/MySQLReplication/BinLog/BinLogSocketConnect.php +++ b/src/MySQLReplication/BinLog/BinLogSocketConnect.php @@ -338,5 +338,42 @@ private function switchAuth(string $response): void $this->logger->debug('Auth switch packet received, switching to ' . $authPluginSwitched->value); $this->socket->writeToSocket(pack('L', (strlen($auth)) | (3 << 24)) . $auth); + + // caching_sha2_password sends an AuthMoreData packet (0x01 status byte) + // followed by either fast_auth_success (0x03) or perform_full_authentication + // (0x04). Both packets must be drained here so that subsequent setup + // commands (executeSQL, registerSlave, setBinLogDump) each see their own + // response on the wire rather than a leftover auth packet. + // + // Without this drain, every getResponse() call in getBinlogStream() is + // offset by 2 packets, and the final misaligned packet reaches + // Event::consume() where its 0x00 status byte is misread as a binlog + // event header, causing a TypeError on readInt32() (PHP 8 strict types) + // or an unpack() underflow on older PHP. + // + // See: https://dev.mysql.com/doc/dev/mysql-server/latest/page_caching_sha2_authentication_exchanges.html + if ($authPluginSwitched === BinLogAuthPluginMode::CachingSha2Password) { + $authMoreData = $this->getResponse(false); + if ($authMoreData !== '' && ord($authMoreData[0]) === 0x01) { + $marker = isset($authMoreData[1]) ? ord($authMoreData[1]) : 0x00; + if ($marker === 0x04) { + // Server is requesting full authentication (password not yet in + // server cache). Full-auth requires sending the password over a + // secure channel (TLS or unix socket) which this client does not + // currently implement. Surface a clear error rather than hanging. + throw new BinLogException( + 'caching_sha2_password full authentication required ' . + '(server sent perform_full_authentication 0x04). ' . + 'This client supports only fast-auth (the user must already ' . + 'be in the server auth cache, or connect via TLS/unix socket).', + 0 + ); + } + // 0x03 = fast_auth_success: server now sends the final auth OK packet. + $this->getResponse(); + } + // If the first byte was not 0x01 the server skipped AuthMoreData and + // sent the OK directly — nothing more to drain. + } } } diff --git a/src/MySQLReplication/Event/Event.php b/src/MySQLReplication/Event/Event.php index 25f519d..f0cc202 100644 --- a/src/MySQLReplication/Event/Event.php +++ b/src/MySQLReplication/Event/Event.php @@ -35,7 +35,19 @@ public function __construct( public function consume(): void { - $binaryDataReader = new BinaryDataReader($this->binLogSocketConnect->getResponse()); + $rawResponse = $this->binLogSocketConnect->getResponse(); + + // A binlog event is at minimum 1 status byte + 19 bytes of event header. + // Any response shorter than 20 bytes cannot be a valid event packet — it + // is most likely a leftover OK packet from an incomplete or misaligned + // handshake (e.g. an un-drained auth response). Skip it and let the next + // iteration read the real first event rather than crashing with a + // TypeError on readInt32() when the buffer is too short. + if (strlen($rawResponse) < 20) { + return; + } + + $binaryDataReader = new BinaryDataReader($rawResponse); // check EOF_Packet -> https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_eof_packet.html if ($binaryDataReader->readUInt8() === self::EOF_HEADER_VALUE) { diff --git a/tests/Integration/CachingSha2PasswordAuthTest.php b/tests/Integration/CachingSha2PasswordAuthTest.php new file mode 100644 index 0000000..af70dc1 --- /dev/null +++ b/tests/Integration/CachingSha2PasswordAuthTest.php @@ -0,0 +1,40 @@ +checkForVersion(8.0)) { + self::markTestSkipped('caching_sha2_password and switchAuth() apply to MySQL 8+ only'); + } + + self::assertGreaterThanOrEqual( + 8.0, + $this->mySQLReplicationFactory->getServerInfo()->versionRevision + ); + + $this->connection->executeStatement('CREATE TABLE caching_sha2_handshake_check (id INT)'); + self::assertInstanceOf(QueryDTO::class, $this->getEvent()); + } +} diff --git a/tests/Unit/Event/EventConsumeShortPacketTest.php b/tests/Unit/Event/EventConsumeShortPacketTest.php new file mode 100644 index 0000000..8e35b3f --- /dev/null +++ b/tests/Unit/Event/EventConsumeShortPacketTest.php @@ -0,0 +1,56 @@ +createMock(BinLogSocketConnect::class); + $socket->method('getResponse')->willReturn($response); + + $dispatcher = $this->createMock(EventDispatcherInterface::class); + $dispatcher->expects(self::never())->method('dispatch'); + + $event = new Event( + $socket, + $this->createMock(RowEventFactory::class), + $dispatcher, + $this->createMock(CacheInterface::class), + $this->createMock(Config::class), + $this->createMock(BinLogServerInfo::class) + ); + + $event->consume(); + } + + public static function shortPackets(): array + { + return [ + 'empty response' => [''], + 'OK packet (7 bytes)' => ["\x00\x00\x00\x02\x00\x00\x00"], + 'one byte under the 20-byte header' => [str_repeat("\x00", 19)], + ]; + } +}