Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/MySQLReplication/BinLog/BinLogSocketConnect.php
Original file line number Diff line number Diff line change
Expand Up @@ -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.
}
}
}
14 changes: 13 additions & 1 deletion src/MySQLReplication/Event/Event.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions tests/Integration/CachingSha2PasswordAuthTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

/** @noinspection PhpUnhandledExceptionInspection */

declare(strict_types=1);

namespace MySQLReplication\Tests\Integration;

use MySQLReplication\Event\DTO\QueryDTO;

class CachingSha2PasswordAuthTest extends BaseCase
{
/**
* Regression guard for the caching_sha2_password auth-switch drain.
*
* On MySQL 8+, root authenticates with caching_sha2_password. With its
* password already in the server auth cache (the test harness logs in as
* root before the suite runs), the binlog handshake takes the fast-auth
* path: the server sends an AuthMoreData packet (status byte 0x01) carrying
* fast_auth_success (0x03), then the OK packet. switchAuth() must drain both
* packets; without that drain every later getResponse() is offset by two
* packets and the first read crashes in Event::consume(). BaseCase::setUp()
* already completes that handshake, so reaching this test proves the drain
* worked; we read one more event to confirm the stream is still aligned.
*/
public function testBinlogHandshakeOverCachingSha2Password(): void
{
if ($this->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());
}
}
56 changes: 56 additions & 0 deletions tests/Unit/Event/EventConsumeShortPacketTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php

declare(strict_types=1);

namespace MySQLReplication\Tests\Unit\Event;

use MySQLReplication\BinLog\BinLogServerInfo;
use MySQLReplication\BinLog\BinLogSocketConnect;
use MySQLReplication\Config\Config;
use MySQLReplication\Event\Event;
use MySQLReplication\Event\RowEvent\RowEventFactory;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Psr\SimpleCache\CacheInterface;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;

class EventConsumeShortPacketTest extends TestCase
{
/**
* consume() must skip any response shorter than the 20-byte minimum
* (1 status byte + 19-byte event header). Such a response is a leftover OK
* packet from a misaligned or un-drained auth handshake, not an event.
* Without the guard it crashes with a TypeError on readInt32()/readUInt16()
* (PHP 8 strict types); with it, the short packet is skipped and nothing is
* dispatched.
*/
#[DataProvider('shortPackets')]
public function testConsumeSkipsResponsesShorterThanAnEventHeader(string $response): void
{
$socket = $this->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)],
];
}
}
Loading