From 25b41d408b411608b078ca2c13289c6ad44df9dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Wed, 9 Sep 2026 18:22:04 +0200 Subject: [PATCH 01/14] Default the proxy listener to loopback Add a bind_address option and --bind-address flag for explicit IPv4 or IPv6 listeners. Reject invalid addresses and include the address in the startup log. --- packages/mysql-proxy/README.md | 9 +++- packages/mysql-proxy/bin/wp-mysql-proxy.php | 17 +++++-- packages/mysql-proxy/composer.json | 1 + .../mysql-proxy/src/class-mysql-proxy.php | 27 +++++++++-- .../tests/WP_MySQL_Proxy_Options_Test.php | 48 +++++++++++++++++++ 5 files changed, 93 insertions(+), 9 deletions(-) create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php diff --git a/packages/mysql-proxy/README.md b/packages/mysql-proxy/README.md index a2e514fee..43a72106e 100644 --- a/packages/mysql-proxy/README.md +++ b/packages/mysql-proxy/README.md @@ -32,10 +32,11 @@ composer require wordpress/mysql-proxy wordpress/mysql-on-sqlite:^3.0 ### CLI: ```bash -$ ./vendor/bin/wp-mysql-proxy.php [--port ] [--database ] [--log-level ] +$ ./vendor/bin/wp-mysql-proxy.php [--bind-address
] [--port ] [--database ] [--log-level ] Options: -h, --help Show this help message and exit. + --bind-address= The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 -p, --port= The port to listen on. Default: 3306 -d, --database= The path to the SQLite database file. Default: :memory: -l, --log-level= The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info @@ -45,7 +46,7 @@ When working from a checked-out repository, Composer does not create a proxy for the root package's own binary. Run it directly from the package directory: ```bash -php bin/wp-mysql-proxy.php [--port ] [--database ] [--log-level ] +php bin/wp-mysql-proxy.php [--bind-address
] [--port ] [--database ] [--log-level ] ``` ### PHP: @@ -61,3 +62,7 @@ $proxy = new MySQL_Proxy( ); $proxy->start(); ``` + +The proxy listens on `127.0.0.1` by default. Set `--bind-address` (or the PHP +`bind_address` option) to an IPv4 or IPv6 address to change the listener. Binding +to `0.0.0.0` or `::` explicitly exposes the proxy on all interfaces of that family. diff --git a/packages/mysql-proxy/bin/wp-mysql-proxy.php b/packages/mysql-proxy/bin/wp-mysql-proxy.php index a7f3bbf18..d05c8e0e7 100644 --- a/packages/mysql-proxy/bin/wp-mysql-proxy.php +++ b/packages/mysql-proxy/bin/wp-mysql-proxy.php @@ -8,14 +8,15 @@ // Process CLI arguments: $shortopts = 'hd:p:l:'; -$longopts = array( 'help', 'database:', 'port:', 'log-level:' ); +$longopts = array( 'help', 'database:', 'port:', 'log-level:', 'bind-address:' ); $opts = getopt( $shortopts, $longopts ); $help = <<] [--database ] [--log-level ] +Usage: wp-mysql-proxy.php [--bind-address
] [--port ] [--database ] [--log-level ] Options: -h, --help Show this help message and exit. + --bind-address= The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 -p, --port= The port to listen on. Default: 3306 -d, --database= The path to the SQLite database file. Default: :memory: -l, --log-level= The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info @@ -31,6 +32,13 @@ // Database path. $db_path = $opts['d'] ?? $opts['database'] ?? ':memory:'; +// Bind address. +$bind_address = $opts['bind-address'] ?? '127.0.0.1'; +if ( false === filter_var( $bind_address, FILTER_VALIDATE_IP ) ) { + fwrite( STDERR, "Error: --bind-address must be a valid IPv4 or IPv6 address.\n" ); + exit( 1 ); +} + // Port. $port = (int) ( $opts['p'] ?? $opts['port'] ?? 3306 ); if ( $port < 1 || $port > 65535 ) { @@ -57,8 +65,9 @@ $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), array( - 'port' => $port, - 'log_level' => $log_level, + 'bind_address' => $bind_address, + 'port' => $port, + 'log_level' => $log_level, ) ); $proxy->start(); diff --git a/packages/mysql-proxy/composer.json b/packages/mysql-proxy/composer.json index 582c2006d..058c75ef1 100644 --- a/packages/mysql-proxy/composer.json +++ b/packages/mysql-proxy/composer.json @@ -17,6 +17,7 @@ }, "require": { "php": ">=7.2", + "ext-filter": "*", "ext-sockets": "*", "composer-runtime-api": "^2.2" }, diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index dc2ba6066..57e56b384 100644 --- a/packages/mysql-proxy/src/class-mysql-proxy.php +++ b/packages/mysql-proxy/src/class-mysql-proxy.php @@ -7,6 +7,7 @@ namespace WP_MySQL_Proxy; +use InvalidArgumentException; use Socket; use Throwable; use WP_MySQL_Proxy\Adapter\Adapter; @@ -32,6 +33,13 @@ class MySQL_Proxy { */ private $port; + /** + * The IP address to listen on. + * + * @var string + */ + private $bind_address; + /** * The adapter to use to execute queries. * @@ -62,12 +70,18 @@ class MySQL_Proxy { * @param array $options { * Optional. An associative array of options. Default empty array. * + * @type string $bind_address The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 * @type int $port The port to listen on. Default: 3306 * @type string $log_level The log level to use. One of 'error', 'warning', 'info', 'debug'. * Default: 'warning' * } */ public function __construct( Adapter $adapter, $options = array() ) { + $this->bind_address = $options['bind_address'] ?? '127.0.0.1'; + if ( ! is_string( $this->bind_address ) || false === filter_var( $this->bind_address, FILTER_VALIDATE_IP ) ) { + throw new InvalidArgumentException( 'The bind address must be a valid IPv4 or IPv6 address.' ); + } + $this->adapter = $adapter; $this->port = $options['port'] ?? 3306; $this->logger = new Logger( $options['log_level'] ?? Logger::LEVEL_WARNING ); @@ -80,7 +94,8 @@ public function __construct( Adapter $adapter, $options = array() ) { */ public function start(): void { // Create a socket. - $socket = @socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + $family = false === strpos( $this->bind_address, ':' ) ? AF_INET : AF_INET6; + $socket = @socket_create( $family, SOCK_STREAM, SOL_TCP ); if ( false === $socket ) { throw $this->new_socket_error(); } @@ -92,7 +107,7 @@ public function start(): void { } // Bind the socket to a port. - if ( false === @socket_bind( $this->socket, '0.0.0.0', $this->port ) ) { + if ( false === @socket_bind( $this->socket, $this->bind_address, $this->port ) ) { throw $this->new_socket_error(); } @@ -101,7 +116,13 @@ public function start(): void { throw $this->new_socket_error(); } - $this->logger->info( 'MySQL proxy listening on port {port}', array( 'port' => $this->port ) ); + $this->logger->info( + 'MySQL proxy listening on {address}:{port}', + array( + 'address' => $this->bind_address, + 'port' => $this->port, + ) + ); // Start the main proxy loop. while ( true ) { diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php new file mode 100644 index 000000000..155ec2998 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php @@ -0,0 +1,48 @@ +createMock( Adapter::class ) ); + $property = new ReflectionProperty( MySQL_Proxy::class, 'bind_address' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $this->assertSame( '127.0.0.1', $property->getValue( $proxy ) ); + } + + /** @dataProvider bind_addresses */ + public function test_explicit_bind_address( string $address ): void { + $proxy = new MySQL_Proxy( $this->createMock( Adapter::class ), array( 'bind_address' => $address ) ); + $property = new ReflectionProperty( MySQL_Proxy::class, 'bind_address' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $this->assertSame( $address, $property->getValue( $proxy ) ); + } + + public function bind_addresses(): array { + return array( array( '127.0.0.1' ), array( '0.0.0.0' ), array( '::1' ), array( '::' ) ); + } + + /** @dataProvider invalid_bind_addresses */ + public function test_invalid_bind_address( $address ): void { + $this->expectException( InvalidArgumentException::class ); + new MySQL_Proxy( $this->createMock( Adapter::class ), array( 'bind_address' => $address ) ); + } + + public function invalid_bind_addresses(): array { + return array( array( '' ), array( 'localhost' ), array( '256.0.0.1' ), array( '::g' ), array( array() ), array( false ) ); + } + + public function test_cli_rejects_invalid_bind_address(): void { + $process = new Process( array( PHP_BINARY, __DIR__ . '/../bin/wp-mysql-proxy.php', '--bind-address=localhost' ) ); + $process->run(); + $this->assertSame( 1, $process->getExitCode() ); + $this->assertStringContainsString( '--bind-address must be a valid IPv4 or IPv6 address', $process->getErrorOutput() ); + } +} From e52a5e01a83a3becef1f6a5ffca5972037328166 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Wed, 9 Sep 2026 18:32:38 +0200 Subject: [PATCH 02/14] Require authentication before proxy database access Verify challenge responses against a configured account. An account uses one authentication plugin, caching_sha2_password by default, with mysql_native_password available for clients that need it. Reject failed or malformed logins before selecting a database, discard queued commands, and close the connection. MySQL_Proxy takes the account as a MySQL_Authentication, as MySQL_Session does, so callers can check it before creating an adapter, and the plaintext password never reaches the proxy. Require CLI credentials through a password file or environment variable. Keep client requests and response data out of debug logs, and cover authentication, startup, and client behavior with regression tests. Protocol references: https://dev.mysql.com/doc/dev/mysql-server/latest/page_caching_sha2_authentication_exchanges.html https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_authentication_methods_native_password_authentication.html --- packages/mysql-proxy/README.md | 25 +- packages/mysql-proxy/bin/wp-mysql-proxy.php | 114 ++++- packages/mysql-proxy/composer.json | 1 + .../src/class-mysql-authentication.php | 120 +++++ .../mysql-proxy/src/class-mysql-protocol.php | 79 ++-- .../mysql-proxy/src/class-mysql-proxy.php | 156 +++---- .../mysql-proxy/src/class-mysql-session.php | 277 ++++++++---- .../tests/WP_MySQL_Authentication_Test.php | 93 ++++ .../WP_MySQL_Protocol_Authentication_Test.php | 73 +++ .../WP_MySQL_Proxy_Authentication_Test.php | 125 ++++++ .../tests/WP_MySQL_Proxy_CLI_Test.php | 35 +- .../tests/WP_MySQL_Proxy_Command_Test.php | 172 +++++++ .../tests/WP_MySQL_Proxy_Options_Test.php | 35 +- .../mysql-proxy/tests/WP_MySQL_Proxy_Test.php | 14 +- .../WP_MySQL_Session_Authentication_Test.php | 418 ++++++++++++++++++ .../mysql-proxy/tests/bootstrap/bootstrap.php | 1 + .../tests/bootstrap/mysql-server-process.php | 25 +- .../tests/bootstrap/mysql-test-client.php | 91 ++++ .../tests/bootstrap/run-server.php | 12 +- 19 files changed, 1619 insertions(+), 247 deletions(-) create mode 100644 packages/mysql-proxy/src/class-mysql-authentication.php create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Authentication_Test.php create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Protocol_Authentication_Test.php create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Proxy_Authentication_Test.php create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php create mode 100644 packages/mysql-proxy/tests/bootstrap/mysql-test-client.php diff --git a/packages/mysql-proxy/README.md b/packages/mysql-proxy/README.md index 43a72106e..2d275de70 100644 --- a/packages/mysql-proxy/README.md +++ b/packages/mysql-proxy/README.md @@ -32,11 +32,17 @@ composer require wordpress/mysql-proxy wordpress/mysql-on-sqlite:^3.0 ### CLI: ```bash -$ ./vendor/bin/wp-mysql-proxy.php [--bind-address
] [--port ] [--database ] [--log-level ] +$ ./vendor/bin/wp-mysql-proxy.php --user [--password-file ] [--bind-address
] [--auth-plugin ] [--port ] [--database ] [--log-level ] Options: -h, --help Show this help message and exit. - --bind-address= The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 + -u, --user= The proxy account name. Required. + --password-file= Read the password from a file instead of WP_MYSQL_PROXY_PASSWORD. + --bind-address= The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 + --auth-plugin= The account's authentication plugin, either + caching_sha2_password or mysql_native_password. + Use the latter only for clients that need it. + Default: caching_sha2_password -p, --port= The port to listen on. Default: 3306 -d, --database= The path to the SQLite database file. Default: :memory: -l, --log-level= The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info @@ -46,11 +52,12 @@ When working from a checked-out repository, Composer does not create a proxy for the root package's own binary. Run it directly from the package directory: ```bash -php bin/wp-mysql-proxy.php [--bind-address
] [--port ] [--database ] [--log-level ] +php bin/wp-mysql-proxy.php --user [--password-file ] [--bind-address
] [--auth-plugin ] [--port ] [--database ] [--log-level ] ``` ### PHP: ```php +use WP_MySQL_Proxy\MySQL_Authentication; use WP_MySQL_Proxy\MySQL_Proxy; use WP_MySQL_Proxy\Adapter\SQLite_Adapter; @@ -58,11 +65,19 @@ require_once __DIR__ . '/vendor/autoload.php'; $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), - array( 'port' => $port, 'log_level' => $log_level ) + array( + 'authentication' => new MySQL_Authentication( 'proxy', getenv( 'WP_MYSQL_PROXY_PASSWORD' ) ), + 'port' => $port, + 'log_level' => $log_level, + ) ); $proxy->start(); ``` +The account uses `caching_sha2_password` by default, which PDO and MySQLi on PHP +below 7.4.4 can't use. For those clients, set `--auth-plugin=mysql_native_password`, +or pass `mysql_native_password` as the third `MySQL_Authentication` argument. + The proxy listens on `127.0.0.1` by default. Set `--bind-address` (or the PHP `bind_address` option) to an IPv4 or IPv6 address to change the listener. Binding -to `0.0.0.0` or `::` explicitly exposes the proxy on all interfaces of that family. +to `0.0.0.0` or `::` explicitly exposes the proxy on all interfaces. diff --git a/packages/mysql-proxy/bin/wp-mysql-proxy.php b/packages/mysql-proxy/bin/wp-mysql-proxy.php index d05c8e0e7..6f75ce508 100644 --- a/packages/mysql-proxy/bin/wp-mysql-proxy.php +++ b/packages/mysql-proxy/bin/wp-mysql-proxy.php @@ -1,36 +1,97 @@ 'help', + 'd' => 'database', + 'p' => 'port', + 'l' => 'log-level', + 'u' => 'user', +); +$options = array( 'help', 'database', 'port', 'log-level', 'bind-address', 'user', 'password-file', 'auth-plugin' ); +$args = array_slice( $argv, 1 ); +$opts = array(); + +// Never include argument values in errors: an unknown option may contain a password. +$option_error = "Error: Invalid command-line options. Use --help for supported options and required values.\n"; + +for ( $i = 0; $i < count( $args ); $i++ ) { + $arg = $args[ $i ]; + $name = ''; + $value = null; + if ( 0 === strpos( $arg, '--' ) ) { + $parts = explode( '=', substr( $arg, 2 ), 2 ); + $name = $parts[0]; + $value = $parts[1] ?? null; + } elseif ( '-' === substr( $arg, 0, 1 ) && strlen( $arg ) > 1 ) { + $name = $aliases[ $arg[1] ] ?? ''; + if ( strlen( $arg ) > 2 ) { + if ( 'help' === $name ) { + array_splice( $args, $i + 1, 0, array( '-' . substr( $arg, 2 ) ) ); + } else { + $value = substr( $arg, 2 ); + } + } + } + + if ( ! in_array( $name, $options, true ) || array_key_exists( $name, $opts ) + || ( 'help' === $name && null !== $value ) + ) { + fwrite( STDERR, $option_error ); + exit( 1 ); + } + if ( 'help' === $name ) { + $opts[ $name ] = true; + continue; + } + $value = $value ?? ( $args[ ++$i ] ?? null ); + if ( null === $value ) { + fwrite( STDERR, $option_error ); + exit( 1 ); + } + $opts[ $name ] = $value; +} $help = <<] [--port ] [--database ] [--log-level ] +Usage: wp-mysql-proxy.php --user [--password-file ] [--bind-address
] [--auth-plugin ] [--port ] [--database ] [--log-level ] Options: -h, --help Show this help message and exit. - --bind-address= The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 + -u, --user= The proxy account name. Required. + --password-file= Read the password from a file instead of WP_MYSQL_PROXY_PASSWORD. + --bind-address= The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 + --auth-plugin= The account's authentication plugin, either + caching_sha2_password or mysql_native_password. + Use the latter only for clients that need it. + Default: caching_sha2_password -p, --port= The port to listen on. Default: 3306 -d, --database= The path to the SQLite database file. Default: :memory: -l, --log-level= The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info +A nonempty password is required via WP_MYSQL_PROXY_PASSWORD or --password-file. +Password files are read verbatim, including any trailing newline. + USAGE; // Help. -if ( isset( $opts['h'] ) || isset( $opts['help'] ) ) { +if ( isset( $opts['help'] ) ) { fwrite( STDERR, $help ); exit( 0 ); } // Database path. -$db_path = $opts['d'] ?? $opts['database'] ?? ':memory:'; +$db_path = $opts['database'] ?? ':memory:'; + +// Validate options before SQLite_Adapter opens or creates the database file. +// MySQL_Proxy checks the bind address too, but only after that. // Bind address. $bind_address = $opts['bind-address'] ?? '127.0.0.1'; @@ -40,19 +101,43 @@ } // Port. -$port = (int) ( $opts['p'] ?? $opts['port'] ?? 3306 ); -if ( $port < 1 || $port > 65535 ) { +$port = filter_var( $opts['port'] ?? 3306, FILTER_VALIDATE_INT ); +if ( false === $port || $port < 1 || $port > 65535 ) { fwrite( STDERR, "Error: --port must be an integer between 1 and 65535. Use --help for more information.\n" ); exit( 1 ); } // Log level. -$log_level = $opts['l'] ?? $opts['log-level'] ?? 'info'; +$log_level = $opts['log-level'] ?? 'info'; if ( ! in_array( $log_level, Logger::LEVELS, true ) ) { fwrite( STDERR, 'Error: --log-level must be one of: ' . implode( ', ', Logger::LEVELS ) . ". Use --help for more information.\n" ); exit( 1 ); } +// Account. Do not accept passwords as command-line arguments. +$password = getenv( 'WP_MYSQL_PROXY_PASSWORD' ); +if ( isset( $opts['password-file'] ) ) { + $password_file = $opts['password-file']; + + // Pipes, such as /dev/stdin or a process substitution, keep the password off the disk. + if ( ! is_readable( $password_file ) || is_dir( $password_file ) ) { + fwrite( STDERR, "Error: --password-file must name a readable file.\n" ); + exit( 1 ); + } + $password = file_get_contents( $password_file ); +} +try { + $authentication = new MySQL_Authentication( + $opts['user'] ?? '', + (string) $password, + $opts['auth-plugin'] ?? MySQL_Protocol::DEFAULT_AUTH_PLUGIN + ); +} catch ( InvalidArgumentException $e ) { + fwrite( STDERR, 'Error: ' . $e->getMessage() . " Use --help for more information.\n" ); + exit( 1 ); +} +unset( $password ); + if ( ! class_exists( 'WP_MySQL_On_SQLite' ) ) { fwrite( STDERR, @@ -65,9 +150,10 @@ $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), array( - 'bind_address' => $bind_address, - 'port' => $port, - 'log_level' => $log_level, + 'authentication' => $authentication, + 'bind_address' => $bind_address, + 'port' => $port, + 'log_level' => $log_level, ) ); $proxy->start(); diff --git a/packages/mysql-proxy/composer.json b/packages/mysql-proxy/composer.json index 058c75ef1..4fd5431b5 100644 --- a/packages/mysql-proxy/composer.json +++ b/packages/mysql-proxy/composer.json @@ -18,6 +18,7 @@ "require": { "php": ">=7.2", "ext-filter": "*", + "ext-hash": "*", "ext-sockets": "*", "composer-runtime-api": "^2.2" }, diff --git a/packages/mysql-proxy/src/class-mysql-authentication.php b/packages/mysql-proxy/src/class-mysql-authentication.php new file mode 100644 index 000000000..b1d85b55b --- /dev/null +++ b/packages/mysql-proxy/src/class-mysql-authentication.php @@ -0,0 +1,120 @@ +username = $username; + $this->plugin = $plugin; + $this->password_hash = MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD === $plugin + ? hash( 'sha256', hash( 'sha256', $password, true ), true ) + : sha1( sha1( $password, true ), true ); + } + + /** + * The authentication plugin this account uses. + * + * A client that chose another plugin must switch to this one. + * + * @return string + */ + public function get_plugin(): string { + return $this->plugin; + } + + /** + * Verify a response using the challenge issued to this connection. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_caching_sha2_authentication_exchanges.html + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_connection_phase_authentication_methods_native_password_authentication.html + * + * @param string $username The client account name. + * @param string $plugin The client authentication plugin. + * @param string $response The client challenge response. + * @param string $challenge The server's 20-byte challenge. + * @return bool Whether the credentials match. + */ + public function verify( string $username, string $plugin, string $response, string $challenge ): bool { + if ( $plugin !== $this->plugin || 20 !== strlen( $challenge ) ) { + return false; + } + + $password_hash = $this->password_hash; + if ( strlen( $response ) !== strlen( $password_hash ) ) { + return false; + } + + if ( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD === $plugin ) { + $mask = hash( 'sha256', $password_hash . $challenge, true ); + $candidate = hash( 'sha256', $response ^ $mask, true ); + } else { + $mask = sha1( $challenge . $password_hash, true ); + $candidate = sha1( $response ^ $mask, true ); + } + + // Verify the password even for an unknown username. + $password_matches = hash_equals( $password_hash, $candidate ); + $username_matches = hash_equals( $this->username, $username ); + return $password_matches && $username_matches; + } +} diff --git a/packages/mysql-proxy/src/class-mysql-protocol.php b/packages/mysql-proxy/src/class-mysql-protocol.php index ea73422a5..a75057b61 100644 --- a/packages/mysql-proxy/src/class-mysql-protocol.php +++ b/packages/mysql-proxy/src/class-mysql-protocol.php @@ -212,6 +212,9 @@ class MySQL_Protocol { const AUTH_PLUGIN_NO_LOGIN = 'no_login_plugin'; // Disable client connection for specific accounts. const AUTH_PLUGIN_SOCKET = 'auth_socket'; // Authenticate local Unix socket connections. + // Authentication switch request. + const AUTH_SWITCH_REQUEST_HEADER = 0xfe; + // Auth specific markers for caching_sha2_password const AUTH_MORE_DATA_HEADER = 0x01; // followed by 1 byte (caching_sha2_password specific) const CACHING_SHA2_FAST_AUTH = 3; @@ -569,49 +572,65 @@ public static function encode_length_encoded_string( string $value ): string { } /** - * Read MySQL length-encoded integer from a payload and advance the offset. + * Read MySQL length-encoded string from a payload and advance the offset. * - * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_integers.html + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_strings.html * * @param string $payload A payload of bytes to read from. * @param int $offset And offset to start reading from within the payload. * The value will be advanced by the number of bytes read. - * @return int The decoded integer value. + * @return string The decoded string value. */ - public static function read_length_encoded_int( string $payload, int &$offset ): int { - $first_byte = ord( $payload[ $offset ] ?? "\0" ); - $offset += 1; - - if ( $first_byte < 0xfb ) { - $value = $first_byte; - } elseif ( 0xfb === $first_byte ) { - $value = 0; - } elseif ( 0xfc === $first_byte ) { - $value = unpack( 'v', $payload, $offset )[1]; - $offset += 2; - } elseif ( 0xfd === $first_byte ) { - $value = unpack( 'VX', $payload, $offset )[1]; - $offset += 3; - } else { - $value = unpack( 'P', $payload, $offset )[1]; - $offset += 8; + public static function read_length_encoded_string( string $payload, int &$offset ): string { + $length = self::read_length_encoded_int( $payload, $offset ); + if ( $length > strlen( $payload ) - $offset ) { + throw new MySQL_Proxy_Exception( 'Incomplete length-encoded string.' ); } + $value = substr( $payload, $offset, $length ); + $offset += $length; return $value; } /** - * Read MySQL length-encoded string from a payload and advance the offset. + * Read MySQL length-encoded integer from a payload and advance the offset. * - * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_strings.html + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_dt_integers.html * * @param string $payload A payload of bytes to read from. * @param int $offset And offset to start reading from within the payload. * The value will be advanced by the number of bytes read. - * @return string The decoded string value. + * @return int The decoded integer value. */ - public static function read_length_encoded_string( string $payload, int &$offset ): string { - $length = self::read_length_encoded_int( $payload, $offset ); - $value = substr( $payload, $offset, $length ); + public static function read_length_encoded_int( string $payload, int &$offset ): int { + if ( $offset < 0 || $offset >= strlen( $payload ) ) { + throw new MySQL_Proxy_Exception( 'Missing length-encoded integer.' ); + } + $first_byte = ord( $payload[ $offset++ ] ); + if ( $first_byte < 0xfb ) { + return $first_byte; + } + if ( 0xfb === $first_byte ) { + return 0; + } + + $lengths = array( + 0xfc => 2, + 0xfd => 3, + 0xfe => 8, + ); + $length = $lengths[ $first_byte ] ?? 0; + if ( 0 === $length || $length > strlen( $payload ) - $offset ) { + throw new MySQL_Proxy_Exception( 'Invalid length-encoded integer.' ); + } + + $value = 0; + for ( $i = $length - 1; $i >= 0; $i-- ) { + $byte = ord( $payload[ $offset + $i ] ); + if ( $value > intdiv( PHP_INT_MAX - $byte, 256 ) ) { + throw new MySQL_Proxy_Exception( 'Length-encoded integer is too large.' ); + } + $value = $value * 256 + $byte; + } $offset += $length; return $value; } @@ -627,8 +646,12 @@ public static function read_length_encoded_string( string $payload, int &$offset * @return string The decoded string value. */ public static function read_null_terminated_string( string $payload, int &$offset ): string { - $value = unpack( 'Z*', $payload, $offset )[1]; - $offset += strlen( $value ) + 1; + $end = $offset < 0 || $offset >= strlen( $payload ) ? false : strpos( $payload, "\0", $offset ); + if ( false === $end ) { + throw new MySQL_Proxy_Exception( 'Missing string terminator.' ); + } + $value = substr( $payload, $offset, $end - $offset ); + $offset = $end + 1; return $value; } } diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index 57e56b384..c2dc3d0a3 100644 --- a/packages/mysql-proxy/src/class-mysql-proxy.php +++ b/packages/mysql-proxy/src/class-mysql-proxy.php @@ -47,6 +47,13 @@ class MySQL_Proxy { */ private $adapter; + /** + * The configured account verifier. + * + * @var MySQL_Authentication + */ + private $authentication; + /** * A map of connected clients. * @@ -68,12 +75,13 @@ class MySQL_Proxy { * * @param Adapter $adapter The adapter to use to execute MySQL queries. * @param array $options { - * Optional. An associative array of options. Default empty array. + * An associative array of options. The account is required. * - * @type string $bind_address The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 - * @type int $port The port to listen on. Default: 3306 - * @type string $log_level The log level to use. One of 'error', 'warning', 'info', 'debug'. - * Default: 'warning' + * @type MySQL_Authentication $authentication The account clients log in with. Required. + * @type string $bind_address The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 + * @type int $port The port to listen on. Default: 3306 + * @type string $log_level The log level to use. One of 'error', 'warning', 'info', 'debug'. + * Default: 'warning' * } */ public function __construct( Adapter $adapter, $options = array() ) { @@ -82,9 +90,15 @@ public function __construct( Adapter $adapter, $options = array() ) { throw new InvalidArgumentException( 'The bind address must be a valid IPv4 or IPv6 address.' ); } - $this->adapter = $adapter; - $this->port = $options['port'] ?? 3306; - $this->logger = new Logger( $options['log_level'] ?? Logger::LEVEL_WARNING ); + $authentication = $options['authentication'] ?? null; + if ( ! $authentication instanceof MySQL_Authentication ) { + throw new InvalidArgumentException( 'The authentication option must be a MySQL_Authentication account.' ); + } + + $this->authentication = $authentication; + $this->adapter = $adapter; + $this->port = $options['port'] ?? 3306; + $this->logger = new Logger( $options['log_level'] ?? Logger::LEVEL_WARNING ); } /** @@ -119,7 +133,7 @@ public function start(): void { $this->logger->info( 'MySQL proxy listening on {address}:{port}', array( - 'address' => $this->bind_address, + 'address' => AF_INET6 === $family ? '[' . $this->bind_address . ']' : $this->bind_address, 'port' => $this->port, ) ); @@ -171,7 +185,7 @@ private function handle_new_client(): void { // Create a new session for the client. $client_id = $this->get_client_id( $socket ); - $session = new MySQL_Session( $this->adapter, $client_id ); + $session = new MySQL_Session( $this->adapter, $client_id, $this->authentication ); $this->clients[ $client_id ] = array( 'socket' => $socket, 'session' => $session, @@ -180,10 +194,7 @@ private function handle_new_client(): void { // Handle the initial handshake. $this->logger->info( 'Client [{client_id}]: initial handshake', array( 'client_id' => $client_id ) ); - $handshake = $session->get_initial_handshake(); - if ( false === @socket_write( $socket, $handshake ) ) { - throw $this->new_socket_error(); - } + $this->write_response( $socket, $session->get_initial_handshake() ); } /** @@ -201,68 +212,64 @@ private function handle_client_activity( int $client_id ): void { throw $this->new_socket_error(); } - // When debugging, display the data in a readable format. - if ( $this->logger->is_log_level_enabled( Logger::LEVEL_DEBUG ) ) { - $this->logger->debug( - 'Client [{client_id}] request data: {data}', - array( - 'client_id' => $client_id, - 'data' => $this->format_data( $data ), - ) - ); - } + // Do not dump request bytes: even authenticated clients can send credentials + // in COM_CHANGE_USER, including across multiple reads or buffered packets. + $session = $this->clients[ $client_id ]['session']; // Handle client disconnection. - if ( false === $data || '' === $data ) { + if ( '' === $data ) { $this->logger->info( 'Client [{client_id}]: disconnected', array( 'client_id' => $client_id ) ); unset( $this->clients[ $client_id ] ); @socket_close( $socket ); return; } - // Process client data. - $this->logger->info( 'Client [{client_id}]: processing data', array( 'client_id' => $client_id ) ); - $session = $this->clients[ $client_id ]['session']; - $response = $session->receive_bytes( $data ); - if ( $response ) { - $this->logger->info( 'Client [{client_id}]: writing response', array( 'client_id' => $client_id ) ); - if ( $this->logger->is_log_level_enabled( Logger::LEVEL_DEBUG ) ) { - $this->logger->debug( - 'Client [{client_id}] response data: {response}', - array( - 'client_id' => $client_id, - 'response' => $this->format_data( $response ), - ) - ); - } - if ( false === @socket_write( $socket, $response ) ) { - throw $this->new_socket_error(); + // Process complete packets, retaining incomplete input for the next read. + do { + try { + $response = $session->receive_bytes( $data ); + } catch ( Incomplete_Input_Exception $e ) { + break; } - } + $data = ''; - // Process buffered data. - while ( $session->has_buffered_data() ) { - $this->logger->info( 'Client [{client_id}]: processing buffered data', array( 'client_id' => $client_id ) ); try { - $response = $session->receive_bytes( '' ); - if ( $response ) { - $this->logger->info( 'Client [{client_id}]: writing response', array( 'client_id' => $client_id ) ); - if ( $this->logger->is_log_level_enabled( Logger::LEVEL_DEBUG ) ) { - $this->logger->debug( - 'Client [{client_id}] response data: {response}', - array( - 'client_id' => $client_id, - 'response' => $this->format_data( $response ), - ) - ); - if ( false === @socket_write( $socket, $response ) ) { - throw $this->new_socket_error(); - } - } + if ( null !== $response && '' !== $response ) { + // Do not dump response bytes: they carry the data a query returned. + $this->logger->info( + 'Client [{client_id}]: writing {bytes} response bytes', + array( + 'client_id' => $client_id, + 'bytes' => strlen( $response ), + ) + ); + $this->write_response( $socket, $response ); } - } catch ( Incomplete_Input_Exception $e ) { - break; + } finally { + if ( $session->is_closed() ) { + $this->logger->error( 'Client [{client_id}]: login failed, closing connection', array( 'client_id' => $client_id ) ); + unset( $this->clients[ $client_id ] ); + @socket_close( $socket ); + } + } + } while ( ! $session->is_closed() && $session->has_buffered_data() ); + } + + /** + * Write the complete response, including any authentication error. + * + * @param Socket|resource $socket The client socket. + * @param string $response The response packets. + */ + private function write_response( $socket, string $response ): void { + $offset = 0; + $length = strlen( $response ); + while ( $offset < $length ) { + $written = @socket_write( $socket, substr( $response, $offset ) ); + if ( false === $written || 0 === $written ) { + throw $this->new_socket_error(); } + $offset += $written; } } @@ -274,7 +281,7 @@ private function handle_client_activity( int $client_id ): void { */ private function get_client_id( $socket ): int { if ( is_resource( $socket ) ) { - return get_resource_id( $socket ); + return (int) $socket; } else { return spl_object_id( $socket ); } @@ -291,25 +298,4 @@ private function new_socket_error(): MySQL_Proxy_Exception { @socket_clear_error(); return new MySQL_Proxy_Exception( sprintf( 'Socket error: %s', $error_message ) ); } - - /** - * Format MySQL protocol data for display in debug logs. - * - * @param string $data The binary data to format. - * @return string The formatted data. - */ - private function format_data( string $data ): string { - $display = ''; - for ( $i = 0; $i < strlen( $data ); $i++ ) { - $byte = ord( $data[ $i ] ); - if ( $byte >= 32 && $byte <= 126 ) { - // Printable ASCII character - $display .= $data[ $i ]; - } else { - // Non-printable, show as hex - $display .= sprintf( '%02x ', $byte ); - } - } - return $display; - } } diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index b370e0bbe..2373d073c 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -77,6 +77,27 @@ class MySQL_Session { */ private $is_authenticated = false; + /** + * The account verifier shared by proxy sessions. + * + * @var MySQL_Authentication + */ + private $authentication; + + /** + * Login details retained while waiting for an authentication switch response. + * + * @var array{username: string, plugin: string, database: string, client_flags: int}|null + */ + private $pending_authentication; + + /** + * Whether the connection must be closed. + * + * @var bool + */ + private $is_closed = false; + /** * Packet sequence ID. * @@ -94,17 +115,37 @@ class MySQL_Session { /** * Constructor. * - * @param Adapter $adapter The MySQL query adapter instance. - * @param int $connection_id The connection ID. + * @param Adapter $adapter The MySQL query adapter instance. + * @param int $connection_id The connection ID. + * @param MySQL_Authentication $authentication The configured account verifier. + */ + public function __construct( Adapter $adapter, int $connection_id, MySQL_Authentication $authentication ) { + $this->authentication = $authentication; + $this->adapter = $adapter; + $this->connection_id = $connection_id; + $this->packet_id = 0; + + // Like MySQL's generate_user_salt(), use 7-bit characters other than NUL and '$'. + // Some clients read the challenge as a C string or as text. + $this->auth_plugin_data = strtr( random_bytes( 20 ) & str_repeat( "\x7f", 20 ), "\0$", "\1%" ); + } + + /** + * Whether the connection has completed authentication. + * + * @return bool */ - public function __construct( Adapter $adapter, int $connection_id ) { - $this->adapter = $adapter; - $this->connection_id = $connection_id; - $this->auth_plugin_data = ''; - $this->packet_id = 0; - - // Generate random auth plugin data (20-byte salt) - $this->auth_plugin_data = random_bytes( 20 ); + public function is_authenticated(): bool { + return $this->is_authenticated; + } + + /** + * Whether the transport must close this connection. + * + * @return bool + */ + public function is_closed(): bool { + return $this->is_closed; } /** @@ -152,6 +193,10 @@ public function get_initial_handshake(): string { * @throws Incomplete_Input_Exception When more data is needed to complete a packet. */ public function receive_bytes( string $data ): ?string { + if ( $this->is_closed ) { + return null; + } + // Append new data to the existing buffer. $this->buffer .= $data; @@ -193,6 +238,10 @@ public function receive_bytes( string $data ): ?string { // Authentication phase. if ( ! $this->is_authenticated ) { + $expected_sequence_id = null === $this->pending_authentication ? 1 : 3; + if ( $expected_sequence_id !== $received_sequence_id ) { + return $this->reject_authentication(); + } return $this->process_authentication( $payload ); } @@ -224,94 +273,158 @@ public function receive_bytes( string $data ): ?string { * @return string The authentication response packet. */ private function process_authentication( string $payload ): string { - $payload_length = strlen( $payload ); + if ( null !== $this->pending_authentication ) { + $login = $this->pending_authentication; + $this->pending_authentication = null; + return $this->complete_authentication( + $login['username'], + $login['plugin'], + $payload, + $login['database'], + $login['client_flags'] + ); + } - // Decode the first 5 fields. - $data = unpack( - 'Vclient_flags/Vmax_packet_size/Ccharacter_set/x23filler/Z*username', - $payload - ); + try { + if ( strlen( $payload ) < 32 ) { + return $this->reject_authentication(); + } + $client_flags = unpack( 'V', $payload )[1]; + if ( ! ( $client_flags & MySQL_Protocol::CLIENT_PROTOCOL_41 ) + || ( $client_flags & MySQL_Protocol::CLIENT_SSL ) + ) { + return $this->reject_authentication(); + } - // Calculate the offset of the authentication response. - $offset = 32 + strlen( $data['username'] ) + 1; + $offset = 32; + $username = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { + $auth_response = MySQL_Protocol::read_length_encoded_string( $payload, $offset ); + } elseif ( $client_flags & MySQL_Protocol::CLIENT_SECURE_CONNECTION ) { + if ( $offset >= strlen( $payload ) ) { + return $this->reject_authentication(); + } + $length = ord( $payload[ $offset++ ] ); + if ( $length > strlen( $payload ) - $offset ) { + return $this->reject_authentication(); + } + $auth_response = substr( $payload, $offset, $length ); + $offset += $length; + } else { + return $this->reject_authentication(); + } - $client_flags = $data['client_flags']; - $this->client_capabilities = $client_flags; + $database = ''; + if ( $client_flags & MySQL_Protocol::CLIENT_CONNECT_WITH_DB ) { + $database = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + } - // Decode the authentication response. - $auth_response = ''; - if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { - $auth_response = MySQL_Protocol::read_length_encoded_string( $payload, $offset ); - } else { - $length = ord( $payload[ $offset++ ] ); - $auth_response = substr( $payload, $offset, $length ); - $offset += $length; + $plugin = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD; + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + // Like MySQL, read a missing or unterminated name as empty, which + // switches the client to the account's plugin. + $plugin = false === strpos( $payload, "\0", $offset ) + ? '' + : MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + } + } catch ( MySQL_Proxy_Exception $e ) { + return $this->reject_authentication(); } - // Get the database name. - if ( $client_flags & MySQL_Protocol::CLIENT_CONNECT_WITH_DB ) { - $database = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); - if ( '' !== $database ) { - $result = $this->adapter->handle_query( 'USE ' . $database ); - if ( $result->error_info ) { - return MySQL_Protocol::build_err_packet( - $this->packet_id++, - 1049, - '42000', - sprintf( "Unknown database: '%s'", $database ) - ); - } + $switch_plugin = null; + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + if ( $this->authentication->get_plugin() !== $plugin ) { + // Like MySQL, switch a client that chose another method to the account's. + $switch_plugin = $this->authentication->get_plugin(); + } elseif ( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD === $plugin && '' === $auth_response ) { + // Older clients request native authentication without sending a response yet. + $switch_plugin = $plugin; } + } elseif ( $this->authentication->get_plugin() !== $plugin ) { + // Like MySQL, refuse a client that cannot switch to the account's method. + $this->is_closed = true; + $this->buffer = ''; + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 1251, + '08004', + 'Client does not support authentication protocol requested by server; consider upgrading MySQL client' + ); } - // Get the authentication plugin name. - $auth_plugin_name = ''; - if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { - $auth_plugin_name = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + if ( null !== $switch_plugin ) { + $this->pending_authentication = array( + 'username' => $username, + 'plugin' => $switch_plugin, + 'database' => $database, + 'client_flags' => $client_flags, + ); + return MySQL_Protocol::build_packet( + $this->packet_id++, + chr( MySQL_Protocol::AUTH_SWITCH_REQUEST_HEADER ) . $switch_plugin . "\0" . $this->auth_plugin_data . "\0" + ); } - // Get the connection attributes. - if ( $client_flags & MySQL_Protocol::CLIENT_CONNECT_ATTRS ) { - $attrs_length = MySQL_Protocol::read_length_encoded_int( $payload, $offset ); - $offset = min( $payload_length, $offset + $attrs_length ); - // TODO: Process connection attributes. + return $this->complete_authentication( $username, $plugin, $auth_response, $database, $client_flags ); + } + + /** + * Verify credentials before selecting the database and completing the login. + * + * @param string $username The client account name. + * @param string $plugin The authentication plugin. + * @param string $auth_response The client challenge response. + * @param string $database The initial database name. + * @param int $client_flags The client capabilities. + * @return string The authentication result packets. + */ + private function complete_authentication( string $username, string $plugin, string $auth_response, string $database, int $client_flags ): string { + if ( ! $this->authentication->verify( $username, $plugin, $auth_response, $this->auth_plugin_data ) ) { + return $this->reject_authentication(); } - /** - * Authentication flow. - * - * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.6/page_caching_sha2_authentication_exchanges.html - */ - if ( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD === $auth_plugin_name ) { - // TODO: Implement authentication. - $this->is_authenticated = true; - if ( "\0" === $auth_response || '' === $auth_response ) { - /* - * Fast path for empty password. - * - * With the "caching_sha2_password" and "sha256_password" plugins, - * an empty password is represented as a single "\0" character. - * - * @see https://github.com/mysql/mysql-server/blob/aa461240270d809bcac336483b886b3d1789d4d9/sql/auth/sha2_password.cc#L1017-L1022 - */ - return MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); + // No adapter calls are allowed until the credentials have been verified. + if ( '' !== $database ) { + try { + $result = $this->adapter->handle_query( 'USE `' . str_replace( '`', '``', $database ) . '`' ); + $failed = (bool) $result->error_info; + } catch ( Throwable $e ) { + $failed = true; + } + if ( $failed ) { + $this->is_closed = true; + $this->buffer = ''; + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 1049, + '42000', + sprintf( "Unknown database: '%s'", $database ) + ); } - $fast_auth_payload = pack( 'CC', MySQL_Protocol::AUTH_MORE_DATA_HEADER, MySQL_Protocol::CACHING_SHA2_FAST_AUTH ); - $fast_auth_packet = MySQL_Protocol::build_packet( $this->packet_id++, $fast_auth_payload ); - return $fast_auth_packet . MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); - } elseif ( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD === $auth_plugin_name ) { - // TODO: Implement authentication. - $this->is_authenticated = true; - return MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); } - // Unsupported authentication plugin. - return MySQL_Protocol::build_err_packet( - $this->packet_id++, - 0x04D2, - 'HY000', - 'Unsupported authentication plugin: ' . $auth_plugin_name - ); + $this->client_capabilities = $client_flags & self::CAPABILITIES; + $this->is_authenticated = true; + $response = ''; + if ( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD === $plugin ) { + $response = MySQL_Protocol::build_packet( + $this->packet_id++, + pack( 'CC', MySQL_Protocol::AUTH_MORE_DATA_HEADER, MySQL_Protocol::CACHING_SHA2_FAST_AUTH ) + ); + } + return $response . MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); + } + + /** + * Deny authentication and discard any pipelined commands. + * + * @return string The access denied packet. + */ + private function reject_authentication(): string { + $this->is_closed = true; + $this->buffer = ''; + $this->pending_authentication = null; + return MySQL_Protocol::build_err_packet( $this->packet_id++, 1045, '28000', 'Access denied' ); } /** diff --git a/packages/mysql-proxy/tests/WP_MySQL_Authentication_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Authentication_Test.php new file mode 100644 index 000000000..a2f2f4674 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Authentication_Test.php @@ -0,0 +1,93 @@ +assertTrue( $authentication->verify( 'user', $plugin, $response, $challenge ) ); + $this->assertFalse( $authentication->verify( 'other', $plugin, $response, $challenge ) ); + $this->assertFalse( $authentication->verify( 'User', $plugin, $response, $challenge ) ); + $this->assertFalse( $authentication->verify( 'user', $plugin, $response, strrev( $challenge ) ) ); + $this->assertFalse( $authentication->verify( 'user', $plugin, $response, '' ) ); + $this->assertFalse( $authentication->verify( 'user', $plugin, $response . "\0", $challenge ) ); + $this->assertFalse( $authentication->verify( 'user', $plugin, substr( $response, 1 ), $challenge ) ); + $this->assertFalse( $authentication->verify( 'user', $plugin, '', $challenge ) ); + $this->assertFalse( $authentication->verify( 'user', $plugin, "\0", $challenge ) ); + $response[0] = $response[0] ^ "\x01"; + $this->assertFalse( $authentication->verify( 'user', $plugin, $response, $challenge ) ); + } + + public function known_responses(): array { + // Fixed vectors calculated independently with Python's hashlib. + return array( + array( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, '718d80fb92b693f7ee1fcad95bfe7a2be1f332ac2c3ec6cdc85893b65b240650' ), + array( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, '1957dce2724282e018f40d905824cb6361f88d41' ), + ); + } + + /** @dataProvider account_plugins */ + public function test_only_the_account_plugin_is_accepted( string $accepted, string $rejected ): void { + $authentication = new MySQL_Authentication( 'user', 'password', $accepted ); + $challenge = '12345678901234567890'; + $this->assertSame( $accepted, $authentication->get_plugin() ); + $this->assertTrue( $authentication->verify( 'user', $accepted, $this->response( $accepted, $challenge ), $challenge ) ); + $this->assertFalse( $authentication->verify( 'user', $rejected, $this->response( $rejected, $challenge ), $challenge ) ); + } + + public function account_plugins(): array { + $sha2 = MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD; + $native = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD; + return array( + 'caching_sha2_password' => array( $sha2, $native ), + 'mysql_native_password' => array( $native, $sha2 ), + ); + } + + public function test_caching_sha2_password_is_the_default(): void { + $authentication = new MySQL_Authentication( 'user', 'password' ); + $this->assertSame( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, $authentication->get_plugin() ); + } + + /** @dataProvider invalid_plugins */ + public function test_invalid_plugin_is_rejected( string $plugin ): void { + $this->expectException( InvalidArgumentException::class ); + new MySQL_Authentication( 'user', 'password', $plugin ); + } + + public function invalid_plugins(): array { + return array( + 'empty' => array( '' ), + 'unknown' => array( 'mysql_clear_password' ), + 'sha256' => array( 'sha256_password' ), + ); + } + + private function response( string $plugin, string $challenge ): string { + $algorithm = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD === $plugin ? 'sha1' : 'sha256'; + $stage1 = hash( $algorithm, 'password', true ); + $stage2 = hash( $algorithm, $stage1, true ); + $input = 'sha1' === $algorithm ? $challenge . $stage2 : $stage2 . $challenge; + return $stage1 ^ hash( $algorithm, $input, true ); + } + + public function test_unknown_plugin_is_denied(): void { + $authentication = new MySQL_Authentication( 'user', 'password' ); + $this->assertFalse( $authentication->verify( 'user', 'mysql_clear_password', 'password', '12345678901234567890' ) ); + } + + /** @dataProvider invalid_credentials */ + public function test_invalid_credentials( string $username, string $password ): void { + $this->expectException( InvalidArgumentException::class ); + new MySQL_Authentication( $username, $password ); + } + + public function invalid_credentials(): array { + return array( array( '', 'password' ), array( 'user', '' ), array( "user\0", 'password' ), array( 'user', "pass\0word" ) ); + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Protocol_Authentication_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Protocol_Authentication_Test.php new file mode 100644 index 000000000..d27b7196e --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Protocol_Authentication_Test.php @@ -0,0 +1,73 @@ +assertSame( $expected, MySQL_Protocol::read_length_encoded_int( 'x' . $bytes . 'suffix', $offset ) ); + $this->assertSame( 1 + strlen( $bytes ), $offset ); + } + + public function encoded_lengths(): array { + return array( + array( "\0", 0 ), + array( "\xfa", 250 ), + array( "\xfb", 0 ), + array( "\xfc\x34\x12", 0x1234 ), + array( "\xfd\x56\x34\x12", 0x123456 ), + array( "\xfe\x56\x34\x12\0\0\0\0\0", 0x123456 ), + ); + } + + /** @dataProvider string_lengths */ + public function test_length_encoded_string( string $prefix ): void { + $offset = 0; + $this->assertSame( 'abc', MySQL_Protocol::read_length_encoded_string( $prefix . 'abc', $offset ) ); + $this->assertSame( strlen( $prefix ) + 3, $offset ); + } + + public function string_lengths(): array { + return array( array( "\x03" ), array( "\xfc\x03\0" ), array( "\xfd\x03\0\0" ), array( "\xfe\x03\0\0\0\0\0\0\0" ) ); + } + + /** @dataProvider malformed_lengths */ + public function test_malformed_length_is_rejected( string $bytes ): void { + $offset = 0; + $this->expectException( MySQL_Proxy_Exception::class ); + MySQL_Protocol::read_length_encoded_int( $bytes, $offset ); + } + + public function malformed_lengths(): array { + return array( + array( '' ), + array( "\xfc" ), + array( "\xfc\0" ), + array( "\xfd\0\0" ), + array( "\xfe\0\0\0\0\0\0\0" ), + array( "\xfe\xff\xff\xff\xff\xff\xff\xff\xff" ), + array( "\xff" ), + ); + } + + public function test_truncated_string_is_rejected(): void { + $offset = 0; + $this->expectException( MySQL_Proxy_Exception::class ); + MySQL_Protocol::read_length_encoded_string( "\x04abc", $offset ); + } + + public function test_null_terminated_string_preserves_spaces(): void { + $offset = 1; + $this->assertSame( 'user ', MySQL_Protocol::read_null_terminated_string( "xuser \0rest", $offset ) ); + $this->assertSame( 7, $offset ); + } + + public function test_missing_string_terminator_is_rejected(): void { + $offset = 0; + $this->expectException( MySQL_Proxy_Exception::class ); + MySQL_Protocol::read_null_terminated_string( 'user', $offset ); + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Authentication_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Authentication_Test.php new file mode 100644 index 000000000..fa865ac9b --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Authentication_Test.php @@ -0,0 +1,125 @@ +expectException( PDOException::class ); + $this->expectExceptionCode( 1045 ); + new PDO( "mysql:host=127.0.0.1;port={$this->port};dbname=sqlite_database", $username, $password ); + } + + public function invalid_credentials(): array { + return array( array( 'user', 'wrong' ), array( 'other', 'password' ), array( 'user', '' ) ); + } + + /** @dataProvider invalid_credentials */ + public function test_mysqli_rejects_invalid_credentials( string $username, string $password ): void { + $driver = new mysqli_driver(); + $mode = $driver->report_mode; + $driver->report_mode = MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT; + try { + $this->expectException( mysqli_sql_exception::class ); + $this->expectExceptionCode( 1045 ); + new mysqli( '127.0.0.1', $username, $password, 'sqlite_database', $this->port ); + } finally { + $driver->report_mode = $mode; + } + } + + public function test_failed_login_closes_socket_without_logging_credentials(): void { + $stream = $this->connect(); + $challenge = MySQL_Test_Client::challenge( MySQL_Test_Client::read_packet( $stream ) ); + $login = MySQL_Test_Client::handshake_response( $challenge, 'private_username', 'private_password', 'mysql_clear_password', 'sqlite_database' ); + $query = MySQL_Protocol::build_packet( 0, "\x03CREATE TABLE forbidden (id INT)" ); + fwrite( $stream, $login . $query ); + $plugin = MySQL_Test_Client::auth_plugin(); + $switch = MySQL_Test_Client::read_packet( $stream ); + $this->assertSame( "\xfe" . $plugin . "\0", substr( $switch, 4, strlen( $plugin ) + 2 ) ); + $response = MySQL_Test_Client::read_packet( $stream ); + $this->assertSame( "\xff\x15\x04#28000", substr( $response, 4, 9 ) ); + $this->assertSame( '', fread( $stream, 1 ) ); + $this->assertTrue( feof( $stream ) ); + fclose( $stream ); + + $pdo = new PDO( "mysql:host=127.0.0.1;port={$this->port}", 'user', 'password' ); + $this->assertSame( array(), $pdo->query( "SHOW TABLES LIKE 'forbidden'" )->fetchAll() ); + $this->assertStringContainsString( 'login failed, closing connection', $this->server->get_stderr() ); + $this->assertStringNotContainsString( 'private_username', $this->server->get_stderr() ); + $this->assertStringNotContainsString( 'private_password', $this->server->get_stderr() ); + $this->assertStringNotContainsString( 'mysql_clear_password', $this->server->get_stderr() ); + } + + public function test_change_user_credentials_are_not_logged(): void { + $stream = $this->connect(); + $challenge = MySQL_Test_Client::challenge( MySQL_Test_Client::read_packet( $stream ) ); + fwrite( $stream, MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Test_Client::auth_plugin() ) ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_login_result( $stream )[4] ) ); + + fwrite( $stream, MySQL_Protocol::build_packet( 0, "\x11private_username\0\x10private_password\0mysql_clear_password\0" ) ); + $this->assertSame( 0xff, ord( MySQL_Test_Client::read_packet( $stream )[4] ) ); + fclose( $stream ); + $this->assertStringNotContainsString( 'private_username', $this->server->get_stderr() ); + $this->assertStringNotContainsString( 'private_password', $this->server->get_stderr() ); + } + + public function test_pipelined_commands_receive_responses_without_debug_logging(): void { + $this->server->stop(); + $this->server = new MySQL_Server_Process( + array( + 'port' => $this->port, + 'log_level' => 'error', + 'auth_plugin' => MySQL_Test_Client::auth_plugin(), + ) + ); + $stream = $this->connect(); + $challenge = MySQL_Test_Client::challenge( MySQL_Test_Client::read_packet( $stream ) ); + $login = MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Test_Client::auth_plugin() ); + $ping = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ); + fwrite( $stream, $login . $ping . $ping ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_login_result( $stream )[4] ) ); + for ( $i = 0; $i < 2; $i++ ) { + $this->assertSame( 0, ord( MySQL_Test_Client::read_packet( $stream )[4] ) ); + } + fclose( $stream ); + } + + public function test_native_password_account_switches_the_client(): void { + $this->server->stop(); + $this->server = new MySQL_Server_Process( + array( + 'port' => $this->port, + 'auth_plugin' => 'mysql_native_password', + ) + ); + + // The client asks for caching_sha2_password and the server switches it. + $pdo = new PDO( "mysql:host=127.0.0.1;port={$this->port}", 'user', 'password' ); + $this->assertEquals( 123, $pdo->query( 'SELECT 123' )->fetchColumn() ); + } + + public function test_explicit_ipv6_loopback_listener(): void { + $this->server->stop(); + $this->server = new MySQL_Server_Process( + array( + 'port' => $this->port, + 'bind_address' => '::1', + 'connect_address' => '[::1]', + 'log_level' => 'info', + 'auth_plugin' => MySQL_Test_Client::auth_plugin(), + ) + ); + $pdo = new PDO( "mysql:host=[::1];port={$this->port}", 'user', 'password' ); + $this->assertEquals( 123, $pdo->query( 'SELECT 123' )->fetchColumn() ); + $this->assertStringContainsString( 'MySQL proxy listening on [::1]:', $this->server->get_stderr() ); + } + + private function connect() { + $stream = stream_socket_client( "tcp://127.0.0.1:{$this->port}", $errno, $error, 2 ); + stream_set_timeout( $stream, 2 ); + return $stream; + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_CLI_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_CLI_Test.php index 339c27392..94945d305 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_CLI_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_CLI_Test.php @@ -3,19 +3,19 @@ use Symfony\Component\Process\Process; class WP_MySQL_Proxy_CLI_Test extends WP_MySQL_Proxy_Test { - public function test_auth_without_password(): void { + public function test_auth_without_password_is_denied(): void { $process = Process::fromShellCommandline( - "mysql -h 127.0.0.1 -P {$this->port} -u root -e 'SELECT 123'" + "mysql -h 127.0.0.1 -P {$this->port} -u user -e 'SELECT 123'" ); $process->run(); - $this->assertEquals( 0, $process->getExitCode() ); - $this->assertStringContainsString( '123', $process->getOutput() ); + $this->assertEquals( 1, $process->getExitCode() ); + $this->assertStringContainsString( 'ERROR 1045 (28000): Access denied', $process->getErrorOutput() ); } public function test_auth_with_password(): void { $process = Process::fromShellCommandline( - "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -e 'SELECT 123'" + "mysql -h 127.0.0.1 -P {$this->port} -u user -ppassword -e 'SELECT 123'" ); $process->run(); @@ -23,9 +23,24 @@ public function test_auth_with_password(): void { $this->assertStringContainsString( '123', $process->getOutput() ); } + /** @dataProvider client_auth_plugins */ + public function test_auth_with_client_plugin( string $plugin ): void { + $process = Process::fromShellCommandline( + "mysql -h 127.0.0.1 -P {$this->port} -u user -ppassword --default-auth={$plugin} -e 'SELECT 123'" + ); + $process->run(); + + $this->assertEquals( 0, $process->getExitCode(), $process->getErrorOutput() ); + $this->assertStringContainsString( '123', $process->getOutput() ); + } + + public function client_auth_plugins(): array { + return array( array( 'mysql_native_password' ), array( 'sha256_password' ) ); + } + public function test_auth_with_database(): void { $process = Process::fromShellCommandline( - "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -D sqlite_database -e 'SELECT 123'" + "mysql -h 127.0.0.1 -P {$this->port} -u user -ppassword -D sqlite_database -e 'SELECT 123'" ); $process->run(); @@ -36,7 +51,7 @@ public function test_auth_with_database(): void { public function test_auth_with_unknown_database(): void { $process = Process::fromShellCommandline( - "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -D unknown_database -e 'SELECT 123'" + "mysql -h 127.0.0.1 -P {$this->port} -u user -ppassword -D unknown_database -e 'SELECT 123'" ); $process->run(); @@ -47,21 +62,21 @@ public function test_auth_with_unknown_database(): void { public function test_query(): void { $query = 'CREATE TABLE t (id INT PRIMARY KEY, name TEXT)'; $process = Process::fromShellCommandline( - "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -e " . escapeshellarg( $query ) + "mysql -h 127.0.0.1 -P {$this->port} -u user -ppassword -e " . escapeshellarg( $query ) ); $process->run(); $this->assertEquals( 0, $process->getExitCode() ); $query = 'INSERT INTO t (id, name) VALUES (123, "abc"), (456, "def")'; $process = Process::fromShellCommandline( - "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -e " . escapeshellarg( $query ) + "mysql -h 127.0.0.1 -P {$this->port} -u user -ppassword -e " . escapeshellarg( $query ) ); $process->run(); $this->assertEquals( 0, $process->getExitCode() ); $query = 'SELECT * FROM t'; $process = Process::fromShellCommandline( - "mysql -h 127.0.0.1 -P {$this->port} -u root -proot -e " . escapeshellarg( $query ) + "mysql -h 127.0.0.1 -P {$this->port} -u user -ppassword -e " . escapeshellarg( $query ) ); $process->run(); $this->assertEquals( 0, $process->getExitCode() ); diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php new file mode 100644 index 000000000..4f515f45a --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php @@ -0,0 +1,172 @@ +expectException( InvalidArgumentException::class ); + new MySQL_Proxy( $this->createMock( Adapter::class ), $options ); + } + + public function missing_accounts(): array { + return array( + 'no options' => array( array() ), + 'credentials options' => array( + array( + 'username' => 'user', + 'password' => 'password', + ), + ), + 'credentials string' => array( array( 'authentication' => 'user:password' ) ), + 'credentials array' => array( array( 'authentication' => array( 'user', 'password' ) ) ), + ); + } + + /** @dataProvider invalid_arguments */ + public function test_cli_rejects_invalid_credentials( array $arguments, string $password, string $error ): void { + $process = $this->cli( $arguments, $password ); + $process->setTimeout( 2 ); + $process->run(); + $this->assertSame( 1, $process->getExitCode() ); + $this->assertStringContainsString( $error, $process->getErrorOutput() ); + $this->assertStringNotContainsString( 'listening', $process->getErrorOutput() ); + } + + public function invalid_arguments(): array { + return array( + array( array( '--user=user', '--password-file' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '-uother' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '-x' ), 'password', 'Invalid command-line options' ), + array( array( '--help=value' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '--port=3306garbage' ), 'password', '--port must be an integer' ), + array( array( '--user=user', '--password-fil=/missing' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '--bind-address' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', 'unexpected-argument' ), 'password', 'Invalid command-line options' ), + array( array(), 'password', 'Error: A nonempty username without NUL bytes is required. Use --help for more information.' ), + array( array( '--user=user' ), '', 'Error: A nonempty password without NUL bytes is required.' ), + array( array( '--user=user', '--password-file=/nonexistent/proxy-password' ), 'password', 'must name a readable file' ), + array( array( '--user=user', '--password-file=' . __DIR__ ), 'password', 'must name a readable file' ), + array( array( '--user=user', '--user=other' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '--auth-plugin=mysql_clear_password' ), 'password', 'Error: The authentication plugin must be one of' ), + array( array( '--user=user', '--auth-plugin=' ), 'password', 'Error: The authentication plugin must be one of' ), + ); + } + + public function test_help_explains_credentials_and_bind_default(): void { + $process = $this->cli( array( '--help' ), '' ); + $process->run(); + $this->assertSame( 0, $process->getExitCode() ); + $this->assertStringContainsString( 'WP_MYSQL_PROXY_PASSWORD', $process->getErrorOutput() ); + $this->assertStringContainsString( 'Default: 127.0.0.1', $process->getErrorOutput() ); + } + + public function test_unknown_options_do_not_expose_their_values(): void { + $process = $this->cli( array( '--password=private_password' ), '' ); + $process->run(); + $this->assertSame( 1, $process->getExitCode() ); + $this->assertStringNotContainsString( 'private_password', $process->getErrorOutput() ); + } + + public function test_grouped_short_options(): void { + $process = $this->cli( array( '-huuser' ), '' ); + $process->run(); + $this->assertSame( 0, $process->getExitCode() ); + $this->assertStringContainsString( 'Usage:', $process->getErrorOutput() ); + } + + /** @dataProvider password_sources */ + public function test_cli_authenticates_with_configured_password( string $source, string $option_style ): void { + $port = MySQL_Server_Process::free_port(); + switch ( $option_style ) { + case 'long-equals': + $args = array( '--user=user', '--port=' . $port ); + break; + case 'long-separate': + $args = array( '--user', 'user', '--port', (string) $port ); + break; + case 'short-attached': + $args = array( '-uuser', '-p' . $port, '-linfo', '-d:memory:' ); + break; + default: + $args = array( '-u', 'user', '-p', (string) $port, '-l', 'info', '-d', ':memory:' ); + } + $args[] = '--auth-plugin=' . MySQL_Test_Client::auth_plugin(); + $password = 'environment-password'; + $input = null; + if ( 'file' === $source ) { + $file = tempnam( sys_get_temp_dir(), 'proxy-password-' ); + chmod( $file, 0600 ); + file_put_contents( $file, "file-password\n" ); + $password = "file-password\n"; + if ( 'long-equals' === $option_style || 'short-attached' === $option_style ) { + $args[] = '--password-file=' . $file; + } else { + $args[] = '--password-file'; + $args[] = $file; + } + } elseif ( 'pipe' === $source ) { + // Like a process substitution, a pipe is not a regular file. + $args[] = '--password-file=/dev/stdin'; + $input = 'pipe-password'; + $password = 'pipe-password'; + } + $process = $this->cli( $args, 'environment-password' ); + $process->setInput( $input ); + $process->setTimeout( 2 ); + try { + $process->start(); + while ( false === strpos( $process->getErrorOutput(), 'listening on 127.0.0.1:' ) && $process->isRunning() ) { + $process->checkTimeout(); + usleep( 50000 ); + } + $this->assertStringContainsString( 'listening on 127.0.0.1:', $process->getErrorOutput() ); + $pdo = new PDO( "mysql:host=127.0.0.1;port=$port", 'user', $password ); + $this->assertEquals( 123, $pdo->query( 'SELECT 123' )->fetchColumn() ); + } finally { + $process->stop(); + if ( isset( $file ) ) { + unlink( $file ); + } + } + } + + public function password_sources(): array { + return array( + array( 'environment', 'long-equals' ), + array( 'environment', 'long-separate' ), + array( 'file', 'short-attached' ), + array( 'file', 'short-separate' ), + array( 'pipe', 'long-equals' ), + ); + } + + /** @dataProvider invalid_password_files */ + public function test_cli_rejects_invalid_password_file( string $contents ): void { + $file = tempnam( sys_get_temp_dir(), 'proxy-password-' ); + file_put_contents( $file, $contents ); + try { + $process = $this->cli( array( '--user=user', '--password-file=' . $file ), 'valid-environment-password' ); + $process->run(); + $this->assertSame( 1, $process->getExitCode() ); + $this->assertStringContainsString( 'A nonempty password without NUL bytes is required.', $process->getErrorOutput() ); + } finally { + unlink( $file ); + } + } + + public function invalid_password_files(): array { + return array( array( '' ), array( "pass\0word" ) ); + } + + private function cli( array $arguments, string $password ): Process { + return new Process( + array_merge( array( PHP_BINARY, __DIR__ . '/../bin/wp-mysql-proxy.php' ), $arguments ), + null, + array( 'WP_MYSQL_PROXY_PASSWORD' => $password ) + ); + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php index 155ec2998..529db0290 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php @@ -3,36 +3,43 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Process\Process; use WP_MySQL_Proxy\Adapter\Adapter; +use WP_MySQL_Proxy\MySQL_Authentication; use WP_MySQL_Proxy\MySQL_Proxy; class WP_MySQL_Proxy_Options_Test extends TestCase { - public function test_default_bind_address_is_loopback(): void { - $proxy = new MySQL_Proxy( $this->createMock( Adapter::class ) ); - $property = new ReflectionProperty( MySQL_Proxy::class, 'bind_address' ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - $this->assertSame( '127.0.0.1', $property->getValue( $proxy ) ); - } - /** @dataProvider bind_addresses */ - public function test_explicit_bind_address( string $address ): void { - $proxy = new MySQL_Proxy( $this->createMock( Adapter::class ), array( 'bind_address' => $address ) ); + public function test_bind_address( array $options, string $expected ): void { + $proxy = new MySQL_Proxy( + $this->createMock( Adapter::class ), + $options + array( 'authentication' => new MySQL_Authentication( 'user', 'password' ) ) + ); $property = new ReflectionProperty( MySQL_Proxy::class, 'bind_address' ); if ( PHP_VERSION_ID < 80100 ) { $property->setAccessible( true ); } - $this->assertSame( $address, $property->getValue( $proxy ) ); + $this->assertSame( $expected, $property->getValue( $proxy ) ); } public function bind_addresses(): array { - return array( array( '127.0.0.1' ), array( '0.0.0.0' ), array( '::1' ), array( '::' ) ); + return array( + 'default' => array( array(), '127.0.0.1' ), + 'IPv4 loopback' => array( array( 'bind_address' => '127.0.0.1' ), '127.0.0.1' ), + 'IPv4 all' => array( array( 'bind_address' => '0.0.0.0' ), '0.0.0.0' ), + 'IPv6 loopback' => array( array( 'bind_address' => '::1' ), '::1' ), + 'IPv6 all' => array( array( 'bind_address' => '::' ), '::' ), + ); } /** @dataProvider invalid_bind_addresses */ public function test_invalid_bind_address( $address ): void { $this->expectException( InvalidArgumentException::class ); - new MySQL_Proxy( $this->createMock( Adapter::class ), array( 'bind_address' => $address ) ); + new MySQL_Proxy( + $this->createMock( Adapter::class ), + array( + 'bind_address' => $address, + 'authentication' => new MySQL_Authentication( 'user', 'password' ), + ) + ); } public function invalid_bind_addresses(): array { diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php index b4504a030..8f7e3641b 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php @@ -4,16 +4,24 @@ abstract class WP_MySQL_Proxy_Test extends TestCase { /** @var int */ - protected $port = 3306; + protected $port; + + /** @var string */ + protected $log_level = 'error'; /** @var MySQL_Server_Process */ protected $server; public function setUp(): void { + // Keep the tests away from any local MySQL server on its default port. + $this->port = MySQL_Server_Process::free_port(); + $this->server = new MySQL_Server_Process( array( - 'port' => $this->port, - 'db_path' => ':memory:', + 'port' => $this->port, + 'db_path' => ':memory:', + 'log_level' => $this->log_level, + 'auth_plugin' => MySQL_Test_Client::auth_plugin(), ) ); } diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php new file mode 100644 index 000000000..d9a476dea --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php @@ -0,0 +1,418 @@ +createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account( $plugin ) ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $response = $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', $plugin, '', $flags ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + if ( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD === $plugin ) { + $this->assertSame( "\x02\0\0\x02\x01\x03", substr( $response, 0, 6 ) ); + $this->assertSame( 3, ord( $response[9] ) ); + $this->assertSame( 0, ord( $response[10] ) ); + } else { + $this->assertSame( 2, ord( $response[3] ) ); + $this->assertSame( 0, ord( $response[4] ) ); + } + } + + public function authentication_methods(): array { + $base = MySQL_Protocol::CLIENT_PROTOCOL_41 | MySQL_Protocol::CLIENT_SECURE_CONNECTION; + $plugin = $base | MySQL_Protocol::CLIENT_PLUGIN_AUTH; + $lenenc = $plugin | MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA; + return array( + array( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, $plugin ), + array( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, $lenenc ), + array( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, $plugin ), + array( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, $lenenc ), + array( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, $base ), + ); + } + + /** @dataProvider rejected_logins */ + public function test_failed_login_never_calls_adapter( string $username, string $password, string $plugin ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account( $plugin ) ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $login = MySQL_Test_Client::handshake_response( $challenge, $username, $password, $plugin, 'private_database' ); + $query = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . 'DELETE FROM wp_users' ); + $response = $session->receive_bytes( $login . $query ); + if ( 'mysql_clear_password' === $plugin + || ( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD === $plugin && '' === $password ) + ) { + $this->assertSame( 0xfe, ord( $response[4] ) ); + $this->assertFalse( $session->is_authenticated() ); + $response = $session->receive_bytes( '' ); + } + $this->assert_denied( $session, $response ); + $this->assertFalse( $session->has_buffered_data() ); + $this->assertNull( $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) . $query ) ); + } + + public function rejected_logins(): array { + $cases = array(); + foreach ( array( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ) as $plugin ) { + $cases[] = array( 'other', 'password', $plugin ); + $cases[] = array( 'user', 'wrong', $plugin ); + $cases[] = array( 'user', '', $plugin ); + $cases[] = array( '', 'password', $plugin ); + } + $cases[] = array( 'user', 'password', 'mysql_clear_password' ); + return $cases; + } + + public function test_native_authentication_switch_preserves_database_and_buffered_command(): void { + $queries = array(); + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->willReturnCallback( + function ( string $query ) use ( &$queries ): MySQL_Result { + $queries[] = $query; + return new MySQL_Result(); + } + ); + $session = new MySQL_Session( $adapter, 1, $this->account( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ) ); + $challenge = $this->start_native_authentication( $session, 'user', 'a`b' ); + $this->assertSame( array(), $queries ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $reply = MySQL_Protocol::build_packet( 3, $token ); + try { + $session->receive_bytes( substr( $reply, 0, 7 ) ); + $this->fail( 'A partial authentication response must wait for more input.' ); + } catch ( Incomplete_Input_Exception $e ) { + $this->assertFalse( $session->is_authenticated() ); + $this->assertSame( array(), $queries ); + } + $ping = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ); + $response = $session->receive_bytes( substr( $reply, 7 ) . $ping ); + $this->assertSame( 4, ord( $response[3] ) ); + $this->assertSame( 0, ord( $response[4] ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + $this->assertSame( array( 'USE `a``b`' ), $queries ); + $this->assertTrue( $session->has_buffered_data() ); + $this->assertSame( 0, ord( $session->receive_bytes( '' )[4] ) ); + $this->assertFalse( $session->has_buffered_data() ); + } + + /** @dataProvider invalid_native_responses */ + public function test_invalid_native_switch_response_is_denied( string $username, string $password, int $sequence, int $length ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ) ); + $challenge = $this->start_native_authentication( $session, $username, 'private_database' ); + $token = MySQL_Test_Client::scramble( $password, $challenge, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $token = substr( $token . "\0", 0, $length ); + $query = MySQL_Protocol::build_packet( 0, "\x03DELETE FROM wp_users" ); + $this->assert_denied( $session, $session->receive_bytes( MySQL_Protocol::build_packet( $sequence, $token ) . $query ) ); + $this->assertFalse( $session->has_buffered_data() ); + $this->assertNull( $session->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ) ); + } + + public function invalid_native_responses(): array { + return array( + array( 'other', 'password', 3, 20 ), + array( 'user', 'wrong', 3, 20 ), + array( 'user', '', 3, 0 ), + array( 'user', 'password', 3, 19 ), + array( 'user', 'password', 3, 21 ), + array( 'user', 'password', 0, 20 ), + array( 'user', 'password', 1, 20 ), + array( 'user', 'password', 2, 20 ), + array( 'user', 'password', 4, 20 ), + ); + } + + /** @dataProvider unsupported_plugins */ + public function test_unsupported_plugin_switches_to_caching_sha2_password( string $plugin, string $password ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = $this->start_authentication_switch( $session, $plugin, $password, MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD ); + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ); + $this->assertSame( "\x02\0\0\x04\x01\x03", substr( $response, 0, 6 ) ); + $this->assertSame( 5, ord( $response[9] ) ); + $this->assertSame( 0, ord( $response[10] ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + } + + public function unsupported_plugins(): array { + return array( + array( 'sha256_password', '' ), + array( 'mysql_clear_password', 'password' ), + array( '', '' ), + ); + } + + public function test_switch_targets_the_plugin_the_account_accepts(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $native = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD; + $session = new MySQL_Session( $adapter, 1, $this->account( $native ) ); + $challenge = $this->start_authentication_switch( $session, MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 'password', $native ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, $native ); + $session->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + } + + /** @dataProvider credentials_without_plugin_auth */ + public function test_client_that_cannot_switch_gets_unsupported_auth_mode( string $username, string $password ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $flags = MySQL_Protocol::CLIENT_PROTOCOL_41 | MySQL_Protocol::CLIENT_SECURE_CONNECTION | MySQL_Protocol::CLIENT_CONNECT_WITH_DB; + $login = MySQL_Test_Client::handshake_response( $challenge, $username, $password, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, 'private_database', $flags ); + $query = MySQL_Protocol::build_packet( 0, "\x03DELETE FROM wp_users" ); + $response = $session->receive_bytes( $login . $query ); + $this->assertSame( 2, ord( $response[3] ) ); + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1251, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( '#08004', substr( $response, 7, 6 ) ); + $this->assertFalse( $session->is_authenticated() ); + $this->assertTrue( $session->is_closed() ); + $this->assertFalse( $session->has_buffered_data() ); + } + + public function credentials_without_plugin_auth(): array { + // The answer must not depend on the credentials, so it reveals nothing about them. + return array( + array( 'user', 'password' ), + array( 'user', 'wrong' ), + array( 'other', 'password' ), + ); + } + + /** @dataProvider invalid_caching_sha2_switch_responses */ + public function test_invalid_caching_sha2_switch_response_is_denied( string $password, string $plugin, int $sequence ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = $this->start_authentication_switch( $session, 'sha256_password', '', MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 'user', 'private_database' ); + $token = MySQL_Test_Client::scramble( $password, $challenge, $plugin ); + $this->assert_denied( $session, $session->receive_bytes( MySQL_Protocol::build_packet( $sequence, $token ) ) ); + } + + public function invalid_caching_sha2_switch_responses(): array { + return array( + array( 'wrong', MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 3 ), + array( '', MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 3 ), + array( 'password', MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, 3 ), + array( 'password', MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 4 ), + ); + } + + public function test_native_switch_response_cannot_be_replayed(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $account = $this->account( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $first = new MySQL_Session( $adapter, 1, $account ); + $second = new MySQL_Session( $adapter, 2, $account ); + $challenge = $this->start_native_authentication( $first ); + $this->start_native_authentication( $second ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $first->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ); + $this->assertTrue( $first->is_authenticated() ); + $this->assert_denied( $second, $second->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ) ); + } + + public function test_database_is_quoted_after_authentication(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->with( 'USE `a``; DROP TABLE wp_users`' )->willReturn( new MySQL_Result() ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Protocol::DEFAULT_AUTH_PLUGIN, 'a`; DROP TABLE wp_users' ) ); + $this->assertTrue( $session->is_authenticated() ); + } + + /** @dataProvider database_errors */ + public function test_database_failure_closes_connection( bool $throws, bool $native_switch ): void { + $adapter = $this->createMock( Adapter::class ); + $expectation = $adapter->expects( $this->once() )->method( 'handle_query' ); + if ( $throws ) { + $expectation->willThrowException( new RuntimeException( 'Private adapter error' ) ); + } else { + $expectation->willReturn( MySQL_Result::from_error( '42000', 1049, 'Unknown database' ) ); + } + $plugin = $native_switch ? MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD : MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD; + $session = new MySQL_Session( $adapter, 1, $this->account( $plugin ) ); + if ( $native_switch ) { + $challenge = $this->start_native_authentication( $session, 'user', 'unknown' ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $login = MySQL_Protocol::build_packet( 3, $token ); + } else { + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $login = MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Protocol::DEFAULT_AUTH_PLUGIN, 'unknown' ); + } + $response = $session->receive_bytes( $login . MySQL_Protocol::build_packet( 0, "\x03SELECT 1" ) ); + $this->assertSame( 1049, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertFalse( $session->is_authenticated() ); + $this->assertTrue( $session->is_closed() ); + $this->assertFalse( $session->has_buffered_data() ); + $this->assertStringNotContainsString( 'Private adapter error', $response ); + } + + public function database_errors(): array { + return array( array( false, false ), array( true, false ), array( false, true ), array( true, true ) ); + } + + public function test_challenge_has_the_characters_mysql_uses(): void { + // Like MySQL, 7-bit characters other than NUL and '$', so clients can read it as text. + $adapter = $this->createMock( Adapter::class ); + for ( $i = 0; $i < 100; $i++ ) { + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $this->assertSame( 1, preg_match( '/^[\x01-\x23\x25-\x7f]{20}$/', $challenge ), bin2hex( $challenge ) ); + } + } + + public function test_response_cannot_be_replayed_on_another_connection(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $account = $this->account(); + $first = new MySQL_Session( $adapter, 1, $account ); + $second = new MySQL_Session( $adapter, 2, $account ); + $salt = MySQL_Test_Client::challenge( $first->get_initial_handshake() ); + $this->assertSame( 20, strlen( $salt ) ); + $this->assertStringNotContainsString( "\0", $salt ); + $this->assertNotSame( $salt, MySQL_Test_Client::challenge( $second->get_initial_handshake() ) ); + $login = MySQL_Test_Client::handshake_response( $salt ); + $first->receive_bytes( $login ); + $this->assertTrue( $first->is_authenticated() ); + $this->assert_denied( $second, $second->receive_bytes( $login ) ); + } + + public function test_every_handshake_truncated_before_the_plugin_name_is_denied(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $account = $this->account(); + $packet_length = strlen( MySQL_Test_Client::handshake_response( str_repeat( 'x', 20 ), 'user', 'password', MySQL_Protocol::DEFAULT_AUTH_PLUGIN, 'db' ) ); + $plugin_offset = $packet_length - 4 - strlen( MySQL_Protocol::DEFAULT_AUTH_PLUGIN ) - 1; + for ( $length = 0; $length < $plugin_offset; $length++ ) { + $session = new MySQL_Session( $adapter, 1, $account ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $payload = substr( MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Protocol::DEFAULT_AUTH_PLUGIN, 'db' ), 4 ); + $this->assert_denied( $session, $session->receive_bytes( MySQL_Protocol::build_packet( 1, substr( $payload, 0, $length ) ) ) ); + } + } + + public function test_truncated_plugin_name_switches_to_the_account_plugin(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $account = $this->account(); + $plugin = MySQL_Protocol::DEFAULT_AUTH_PLUGIN; + + // Like MySQL, a missing or unterminated plugin name is read as empty. + for ( $cut = 1; $cut <= strlen( $plugin ) + 1; $cut++ ) { + $session = new MySQL_Session( $adapter, 1, $account ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $payload = substr( MySQL_Test_Client::handshake_response( $challenge ), 4, -$cut ); + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 1, $payload ) ); + $this->assertSame( MySQL_Protocol::build_packet( 2, "\xfe" . $plugin . "\0" . $challenge . "\0" ), $response ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, $plugin ); + $session->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ); + $this->assertTrue( $session->is_authenticated() ); + } + } + + public function test_fragmented_handshake_and_pipelined_command(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $login = MySQL_Test_Client::handshake_response( $challenge ); + for ( $i = 0; $i < strlen( $login ) - 1; $i++ ) { + try { + $session->receive_bytes( $login[ $i ] ); + $this->fail( 'An incomplete packet must wait for more input.' ); + } catch ( Incomplete_Input_Exception $e ) { + $this->assertFalse( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + } + } + $session->receive_bytes( substr( $login, -1 ) . MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertTrue( $session->has_buffered_data() ); + $this->assertSame( 0, ord( $session->receive_bytes( '' )[4] ) ); + $this->assertFalse( $session->has_buffered_data() ); + } + + /** @dataProvider invalid_packets */ + public function test_invalid_packet_is_denied( string $payload, int $sequence ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $this->assert_denied( $session, $session->receive_bytes( MySQL_Protocol::build_packet( $sequence, $payload ) ) ); + } + + public function invalid_packets(): array { + $base = MySQL_Protocol::CLIENT_PROTOCOL_41 | MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA; + $header = pack( 'V', $base ) . str_repeat( "\0", 28 ) . "user\0"; + return array( + array( "\x03SELECT 1", 0 ), + array( "\x02sqlite_database", 0 ), + array( str_repeat( "\0", 40 ), 1 ), + array( pack( 'V', $base | MySQL_Protocol::CLIENT_SSL ) . str_repeat( "\0", 28 ), 1 ), + array( $header . "\xfc\x20", 1 ), + array( $header . "\xfd\x20\0", 1 ), + array( $header . "\xfe\xff\xff\xff\xff\xff\xff\xff\xff", 1 ), + array( $header . "\xff", 1 ), + array( $header . "\x20short", 1 ), + array( pack( 'V', $base | MySQL_Protocol::CLIENT_PLUGIN_AUTH ) . str_repeat( "\0", 28 ) . "user\0\x01\0caching_sha2_password\0", 1 ), + ); + } + + /** + * An account using the given plugin, or the default for a plugin no account can use. + */ + private function account( string $plugin = MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD ): MySQL_Authentication { + if ( ! in_array( $plugin, MySQL_Authentication::SUPPORTED_PLUGINS, true ) ) { + $plugin = MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD; + } + return new MySQL_Authentication( 'user', 'password', $plugin ); + } + + private function start_native_authentication( MySQL_Session $session, string $username = 'user', string $database = '' ): string { + $plugin = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD; + return $this->start_authentication_switch( $session, $plugin, '', $plugin, $username, $database ); + } + + private function start_authentication_switch( + MySQL_Session $session, + string $client_plugin, + string $password, + string $plugin, + string $username = 'user', + string $database = '' + ): string { + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $response = $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge, $username, $password, $client_plugin, $database ) ); + $this->assertSame( MySQL_Protocol::build_packet( 2, "\xfe" . $plugin . "\0" . $challenge . "\0" ), $response ); + $this->assertFalse( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + return $challenge; + } + + private function assert_denied( MySQL_Session $session, string $response ): void { + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1045, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( '#28000', substr( $response, 7, 6 ) ); + $this->assertFalse( $session->is_authenticated() ); + $this->assertTrue( $session->is_closed() ); + } +} diff --git a/packages/mysql-proxy/tests/bootstrap/bootstrap.php b/packages/mysql-proxy/tests/bootstrap/bootstrap.php index 3f517288b..0b7127f17 100644 --- a/packages/mysql-proxy/tests/bootstrap/bootstrap.php +++ b/packages/mysql-proxy/tests/bootstrap/bootstrap.php @@ -3,3 +3,4 @@ require_once __DIR__ . '/../../vendor/autoload.php'; require_once __DIR__ . '/mysql-server-process.php'; require_once __DIR__ . '/../WP_MySQL_Proxy_Test.php'; +require_once __DIR__ . '/mysql-test-client.php'; diff --git a/packages/mysql-proxy/tests/bootstrap/mysql-server-process.php b/packages/mysql-proxy/tests/bootstrap/mysql-server-process.php index 6e286d035..6c5f19875 100644 --- a/packages/mysql-proxy/tests/bootstrap/mysql-server-process.php +++ b/packages/mysql-proxy/tests/bootstrap/mysql-server-process.php @@ -14,8 +14,11 @@ public function __construct( array $options = array() ) { $env = array_merge( $_ENV, array( - 'PORT' => $port, - 'DB_PATH' => $options['db_path'] ?? ':memory:', + 'PORT' => $port, + 'DB_PATH' => $options['db_path'] ?? ':memory:', + 'LOG_LEVEL' => $options['log_level'] ?? 'error', + 'AUTH_PLUGIN' => $options['auth_plugin'] ?? 'caching_sha2_password', + 'BIND_ADDRESS' => $options['bind_address'] ?? '127.0.0.1', ) ); $this->process = new Process( @@ -27,7 +30,10 @@ public function __construct( array $options = array() ) { // Wait for the server to be ready. for ( $i = 0; $i < 20; $i++ ) { - $connection = @fsockopen( '127.0.0.1', $port ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( ! $this->process->isRunning() ) { + break; + } + $connection = @fsockopen( $options['connect_address'] ?? '127.0.0.1', $port, $errno, $error, 0.1 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( $connection ) { fclose( $connection ); return; @@ -69,4 +75,17 @@ public function get_stderr(): string { } return $this->process->getErrorOutput(); } + + /** + * Find a free local port for a server. + * + * The port is released before the server binds it, so another process could + * take it in between, which is unlikely in a test run. + */ + public static function free_port(): int { + $listener = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $error ); + $address = stream_socket_get_name( $listener, false ); + fclose( $listener ); + return (int) substr( strrchr( $address, ':' ), 1 ); + } } diff --git a/packages/mysql-proxy/tests/bootstrap/mysql-test-client.php b/packages/mysql-proxy/tests/bootstrap/mysql-test-client.php new file mode 100644 index 000000000..d12437593 --- /dev/null +++ b/packages/mysql-proxy/tests/bootstrap/mysql-test-client.php @@ -0,0 +1,91 @@ += 70404 + ? MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD + : MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD; + } + + /** + * Read the result of a login, skipping the caching_sha2_password fast path packet. + */ + public static function read_login_result( $stream ): string { + $packet = self::read_packet( $stream ); + if ( "\x01\x03" === substr( $packet, 4 ) ) { + return self::read_packet( $stream ); + } + return $packet; + } + + public static function handshake_response( + string $challenge, + string $username = 'user', + string $password = 'password', + string $plugin = MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, + string $database = '', + ?int $flags = null + ): string { + $flags = $flags ?? ( MySQL_Protocol::CLIENT_PROTOCOL_41 | MySQL_Protocol::CLIENT_SECURE_CONNECTION + | MySQL_Protocol::CLIENT_PLUGIN_AUTH | MySQL_Protocol::CLIENT_CONNECT_WITH_DB ); + $token = self::scramble( $password, $challenge, $plugin ); + $data = pack( 'VVC', $flags, 0, MySQL_Protocol::CHARSET_UTF8MB4 ) . str_repeat( "\0", 23 ); + $data .= $username . "\0" . chr( strlen( $token ) ) . $token; + if ( $flags & MySQL_Protocol::CLIENT_CONNECT_WITH_DB ) { + $data .= $database . "\0"; + } + if ( $flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + $data .= $plugin . "\0"; + } + return MySQL_Protocol::build_packet( 1, $data ); + } + + public static function scramble( string $password, string $challenge, string $plugin ): string { + if ( 'mysql_clear_password' === $plugin ) { + return $password . "\0"; + } + if ( '' === $password ) { + return ''; + } + $algorithm = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD === $plugin ? 'sha1' : 'sha256'; + $stage1 = hash( $algorithm, $password, true ); + $stage2 = hash( $algorithm, $stage1, true ); + $input = 'sha1' === $algorithm ? $challenge . $stage2 : $stage2 . $challenge; + return $stage1 ^ hash( $algorithm, $input, true ); + } + + public static function challenge( string $handshake ): string { + $offset = strpos( $handshake, "\0", 5 ) + 5; + return substr( $handshake, $offset, 8 ) . substr( $handshake, $offset + 27, 12 ); + } + + public static function read_packet( $stream ): string { + $header = self::read_bytes( $stream, 4 ); + $length = unpack( 'V', substr( $header, 0, 3 ) . "\0" )[1]; + return $header . self::read_bytes( $stream, $length ); + } + + private static function read_bytes( $stream, int $length ): string { + $data = ''; + while ( strlen( $data ) < $length ) { + $chunk = fread( $stream, $length - strlen( $data ) ); + if ( false === $chunk || '' === $chunk ) { + throw new RuntimeException( 'Connection closed or timed out before a complete packet arrived.' ); + } + $data .= $chunk; + } + return $data; + } +} diff --git a/packages/mysql-proxy/tests/bootstrap/run-server.php b/packages/mysql-proxy/tests/bootstrap/run-server.php index 6b58e2abd..d0d9128a7 100644 --- a/packages/mysql-proxy/tests/bootstrap/run-server.php +++ b/packages/mysql-proxy/tests/bootstrap/run-server.php @@ -1,17 +1,23 @@ $port ) + array( + 'port' => $port, + 'authentication' => new MySQL_Authentication( 'user', 'password', getenv( 'AUTH_PLUGIN' ) ), + 'log_level' => getenv( 'LOG_LEVEL' ), + 'bind_address' => getenv( 'BIND_ADDRESS' ), + ) ); $proxy->start(); From eb2f5ddbef34ebcde4a5a705153afccbf0b6ba57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Wed, 9 Sep 2026 18:32:39 +0200 Subject: [PATCH 03/14] Test the proxy on PHP 8.5 as well as PHP 7.2 --- .github/workflows/mysql-proxy-tests.yml | 9 +++++++-- packages/mysql-proxy/composer.json | 2 ++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mysql-proxy-tests.yml b/.github/workflows/mysql-proxy-tests.yml index f4d10f415..f560ba747 100644 --- a/.github/workflows/mysql-proxy-tests.yml +++ b/.github/workflows/mysql-proxy-tests.yml @@ -12,9 +12,13 @@ permissions: {} jobs: test: - name: MySQL Proxy Tests + name: MySQL Proxy Tests (PHP ${{ matrix.php }}) runs-on: ubuntu-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + php: ['7.2', '8.5'] permissions: contents: read # Required to clone the repo. @@ -25,7 +29,8 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: '7.2' + php-version: ${{ matrix.php }} + extensions: sockets, pdo_mysql, pdo_sqlite, mysqli coverage: none - name: Configure monorepo package repository diff --git a/packages/mysql-proxy/composer.json b/packages/mysql-proxy/composer.json index 4fd5431b5..ee8f3bb45 100644 --- a/packages/mysql-proxy/composer.json +++ b/packages/mysql-proxy/composer.json @@ -35,6 +35,8 @@ "bin/wp-mysql-proxy.php" ], "require-dev": { + "ext-mysqli": "*", + "ext-pdo_mysql": "*", "phpunit/phpunit": "^8.5", "symfony/process": "^5.4", "wordpress/mysql-on-sqlite": "*@dev" From 01f154b8ca58971232212504245e8d9a69dfa453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 11:42:07 +0200 Subject: [PATCH 04/14] Limit what a connection can use before logging in Reject a packet larger than 16 KB while the client is unauthenticated, so a partial login packet cannot make the session buffer up to 16 MB. Close a connection that has not completed a login within 10 seconds, which matches the default MySQL connect_timeout. Without it, idle connections held the proxy's file descriptors indefinitely. --- .../mysql-proxy/src/class-mysql-proxy.php | 51 +++++++++++-- .../mysql-proxy/src/class-mysql-session.php | 14 ++++ .../WP_MySQL_Proxy_Login_Timeout_Test.php | 72 +++++++++++++++++++ .../WP_MySQL_Session_Authentication_Test.php | 26 +++++++ 4 files changed, 158 insertions(+), 5 deletions(-) create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index c2dc3d0a3..734a0e84f 100644 --- a/packages/mysql-proxy/src/class-mysql-proxy.php +++ b/packages/mysql-proxy/src/class-mysql-proxy.php @@ -19,6 +19,13 @@ * execute MySQL queries and return their results. */ class MySQL_Proxy { + /** + * Seconds a client may stay connected without completing a login. + * + * This matches the default MySQL "connect_timeout". + */ + const LOGIN_TIMEOUT = 10; + /** * The socket to listen on. * @@ -59,7 +66,7 @@ class MySQL_Proxy { * * Maps client IDs to their associated socket and session instances. * - * @var array + * @var array */ private $clients = array(); @@ -141,11 +148,14 @@ public function start(): void { // Start the main proxy loop. while ( true ) { try { - // Wait for activity on any socket. + // Drop clients that connected but never completed a login. + $this->close_timed_out_logins(); + + // Wait for activity on any socket, waking up for the next login deadline. $read = array_merge( array( $this->socket ), array_column( $this->clients, 'socket' ) ); $write = null; $except = null; - $activity = @socket_select( $read, $write, $except, null ); + $activity = @socket_select( $read, $write, $except, $this->get_select_timeout() ); if ( false === $activity ) { throw $this->new_socket_error(); } @@ -171,6 +181,36 @@ public function start(): void { } } + /** + * Close connections that have not logged in before their deadline. + */ + private function close_timed_out_logins(): void { + foreach ( $this->clients as $client_id => $client ) { + if ( $client['session']->is_authenticated() || $client['login_deadline'] > time() ) { + continue; + } + $this->logger->error( 'Client [{client_id}]: login timed out, closing connection', array( 'client_id' => $client_id ) ); + unset( $this->clients[ $client_id ] ); + @socket_close( $client['socket'] ); + } + } + + /** + * Get the seconds to wait for socket activity. + * + * @return int|null The seconds until the next login deadline, or null to wait indefinitely. + */ + private function get_select_timeout(): ?int { + $deadline = null; + foreach ( $this->clients as $client ) { + if ( $client['session']->is_authenticated() ) { + continue; + } + $deadline = null === $deadline ? $client['login_deadline'] : min( $deadline, $client['login_deadline'] ); + } + return null === $deadline ? null : max( 0, $deadline - time() ); + } + /** * Handle a new MySQL client connection. */ @@ -187,8 +227,9 @@ private function handle_new_client(): void { $client_id = $this->get_client_id( $socket ); $session = new MySQL_Session( $this->adapter, $client_id, $this->authentication ); $this->clients[ $client_id ] = array( - 'socket' => $socket, - 'session' => $session, + 'socket' => $socket, + 'session' => $session, + 'login_deadline' => time() + self::LOGIN_TIMEOUT, ); $this->logger->info( 'Client [{client_id}]: connected', array( 'client_id' => $client_id ) ); diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index 2373d073c..dd05fc690 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -9,6 +9,15 @@ * MySQL server session handling a single client connection. */ class MySQL_Session { + /** + * Largest packet accepted before the client is authenticated. + * + * A login packet carries a username, a challenge response, a database name, + * and connection attributes, so it stays well below MySQL's own 16 KB + * network buffer. Anything larger is a malformed or hostile login. + */ + const MAX_UNAUTHENTICATED_PACKET_SIZE = 16384; + /** * Client capabilities that are supported by the server. */ @@ -210,6 +219,11 @@ public function receive_bytes( string $data ): ?string { $received_sequence_id = ord( $this->buffer[3] ); $this->packet_id = $received_sequence_id + 1; + // Never buffer a large packet for a client that has not logged in yet. + if ( ! $this->is_authenticated && $payload_length > self::MAX_UNAUTHENTICATED_PACKET_SIZE ) { + return $this->reject_authentication(); + } + // Check if we have the complete packet. $packet_length = 4 + $payload_length; if ( strlen( $this->buffer ) < $packet_length ) { diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php new file mode 100644 index 000000000..d22de2e76 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php @@ -0,0 +1,72 @@ +createMock( Adapter::class ), + array( 'authentication' => new MySQL_Authentication( 'user', 'password' ) ) + ); + $clients = $this->property( 'clients' ); + $clients->setValue( + $proxy, + array( + 1 => array( + 'socket' => $this->socket(), + 'session' => $this->session( $authenticated ), + 'login_deadline' => time() + $deadline_offset, + ), + ) + ); + + $this->assertSame( $expected_timeout, $this->call( $proxy, 'get_select_timeout' ) ); + $this->call( $proxy, 'close_timed_out_logins' ); + $this->assertCount( $expected_open ? 1 : 0, $clients->getValue( $proxy ) ); + } + + public function pending_logins(): array { + return array( + 'pending login within the deadline' => array( 5, false, true, 5 ), + 'pending login past the deadline' => array( -1, false, false, 0 ), + 'authenticated past the deadline' => array( -1, true, true, null ), + ); + } + + private function session( bool $authenticated ): MySQL_Session { + $session = new MySQL_Session( $this->createMock( Adapter::class ), 1, new MySQL_Authentication( 'user', 'password' ) ); + if ( $authenticated ) { + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); + $this->assertTrue( $session->is_authenticated() ); + } + return $session; + } + + private function socket() { + $pair = array(); + socket_create_pair( AF_UNIX, SOCK_STREAM, 0, $pair ); + return $pair[0]; + } + + private function property( string $name ): ReflectionProperty { + $property = new ReflectionProperty( MySQL_Proxy::class, $name ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + return $property; + } + + private function call( MySQL_Proxy $proxy, string $name ) { + $method = new ReflectionMethod( MySQL_Proxy::class, $name ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $proxy ); + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php index d9a476dea..0c52043f2 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php @@ -352,6 +352,32 @@ public function test_fragmented_handshake_and_pipelined_command(): void { $this->assertFalse( $session->has_buffered_data() ); } + public function test_oversized_login_packet_is_denied(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $session->get_initial_handshake(); + $header = substr( pack( 'V', MySQL_Session::MAX_UNAUTHENTICATED_PACKET_SIZE + 1 ), 0, 3 ) . chr( 1 ); + $this->assert_denied( $session, $session->receive_bytes( $header . str_repeat( 'A', 4096 ) ) ); + $this->assertFalse( $session->has_buffered_data() ); + } + + public function test_large_packet_is_buffered_after_authentication(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); + $this->assertTrue( $session->is_authenticated() ); + $header = substr( pack( 'V', MySQL_Session::MAX_UNAUTHENTICATED_PACKET_SIZE + 1 ), 0, 3 ) . chr( 0 ); + try { + $session->receive_bytes( $header . str_repeat( 'A', 4096 ) ); + $this->fail( 'An incomplete packet must wait for more input.' ); + } catch ( Incomplete_Input_Exception $e ) { + $this->assertFalse( $session->is_closed() ); + } + } + /** @dataProvider invalid_packets */ public function test_invalid_packet_is_denied( string $payload, int $sequence ): void { $adapter = $this->createMock( Adapter::class ); From 3367db3e94d0b1fd4d6f81c6ddc51377a417419e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 11:43:29 +0200 Subject: [PATCH 05/14] Quote the database name in COM_INIT_DB The command built "USE " from the client's bytes, so a name was parsed as SQL instead of an identifier. Reuse the quoting that the login path already applies, so a name with a backtick, a space, or a semicolon stays a name. --- .../mysql-proxy/src/class-mysql-session.php | 14 ++++++-- .../tests/WP_MySQL_Session_Command_Test.php | 35 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index dd05fc690..2a058c654 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -265,7 +265,7 @@ public function receive_bytes( string $data ): ?string { case MySQL_Protocol::COM_QUIT: return ''; case MySQL_Protocol::COM_INIT_DB: - return $this->process_query( 'USE ' . substr( $payload, 1 ) ); + return $this->process_query( $this->build_use_query( substr( $payload, 1 ) ) ); case MySQL_Protocol::COM_QUERY: return $this->process_query( substr( $payload, 1 ) ); case MySQL_Protocol::COM_PING: @@ -400,7 +400,7 @@ private function complete_authentication( string $username, string $plugin, stri // No adapter calls are allowed until the credentials have been verified. if ( '' !== $database ) { try { - $result = $this->adapter->handle_query( 'USE `' . str_replace( '`', '``', $database ) . '`' ); + $result = $this->adapter->handle_query( $this->build_use_query( $database ) ); $failed = (bool) $result->error_info; } catch ( Throwable $e ) { $failed = true; @@ -441,6 +441,16 @@ private function reject_authentication(): string { return MySQL_Protocol::build_err_packet( $this->packet_id++, 1045, '28000', 'Access denied' ); } + /** + * Build a USE statement for a client-supplied database name. + * + * @param string $database The database name. + * @return string The USE statement with a quoted identifier. + */ + private function build_use_query( string $database ): string { + return 'USE `' . str_replace( '`', '``', $database ) . '`'; + } + /** * Process a MySQL query from the client. * diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php new file mode 100644 index 000000000..216244479 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -0,0 +1,35 @@ +createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->with( $expected )->willReturn( MySQL_Result::from_data( 0, 0, array(), array() ) ); + $session = $this->authenticated_session( $adapter ); + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_INIT_DB ) . $database ) ); + $this->assertSame( 0, ord( $response[4] ) ); + } + + public function database_names(): array { + return array( + array( 'sqlite_database', 'USE `sqlite_database`' ), + array( 'my db', 'USE `my db`' ), + array( 'a`; DROP TABLE wp_users', 'USE `a``; DROP TABLE wp_users`' ), + ); + } + + private function authenticated_session( Adapter $adapter ): MySQL_Session { + $session = new MySQL_Session( $adapter, 1, new MySQL_Authentication( 'user', 'password' ) ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); + $this->assertTrue( $session->is_authenticated() ); + return $session; + } +} From ac7b5d774c704580cdfc0e04ed7d1405572dc114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 11:44:13 +0200 Subject: [PATCH 06/14] Use -P for the port, as MySQL clients do The port was -p, which every MySQL client reads as the password. Someone pasting "-psecret" set the port instead. The port is now -P, and -p is an unknown option, so a password passed that way is rejected, not misread. --- packages/mysql-proxy/README.md | 2 +- packages/mysql-proxy/bin/wp-mysql-proxy.php | 4 ++-- .../tests/WP_MySQL_Proxy_Command_Test.php | 14 ++++++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/mysql-proxy/README.md b/packages/mysql-proxy/README.md index 2d275de70..e1480b927 100644 --- a/packages/mysql-proxy/README.md +++ b/packages/mysql-proxy/README.md @@ -43,7 +43,7 @@ Options: caching_sha2_password or mysql_native_password. Use the latter only for clients that need it. Default: caching_sha2_password - -p, --port= The port to listen on. Default: 3306 + -P, --port= The port to listen on. Default: 3306 -d, --database= The path to the SQLite database file. Default: :memory: -l, --log-level= The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info ``` diff --git a/packages/mysql-proxy/bin/wp-mysql-proxy.php b/packages/mysql-proxy/bin/wp-mysql-proxy.php index 6f75ce508..57e05a851 100644 --- a/packages/mysql-proxy/bin/wp-mysql-proxy.php +++ b/packages/mysql-proxy/bin/wp-mysql-proxy.php @@ -12,7 +12,7 @@ $aliases = array( 'h' => 'help', 'd' => 'database', - 'p' => 'port', + 'P' => 'port', 'l' => 'log-level', 'u' => 'user', ); @@ -72,7 +72,7 @@ caching_sha2_password or mysql_native_password. Use the latter only for clients that need it. Default: caching_sha2_password - -p, --port= The port to listen on. Default: 3306 + -P, --port= The port to listen on. Default: 3306 -d, --database= The path to the SQLite database file. Default: :memory: -l, --log-level= The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php index 4f515f45a..a4538b43d 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php @@ -64,13 +64,19 @@ public function test_help_explains_credentials_and_bind_default(): void { $this->assertStringContainsString( 'Default: 127.0.0.1', $process->getErrorOutput() ); } - public function test_unknown_options_do_not_expose_their_values(): void { - $process = $this->cli( array( '--password=private_password' ), '' ); + /** @dataProvider password_arguments */ + public function test_unknown_options_do_not_expose_their_values( string $argument ): void { + $process = $this->cli( array( $argument ), '' ); $process->run(); $this->assertSame( 1, $process->getExitCode() ); $this->assertStringNotContainsString( 'private_password', $process->getErrorOutput() ); } + public function password_arguments(): array { + // The MySQL client reads -p as a password, so the proxy must not take it as a port. + return array( array( '--password=private_password' ), array( '-pprivate_password' ) ); + } + public function test_grouped_short_options(): void { $process = $this->cli( array( '-huuser' ), '' ); $process->run(); @@ -89,10 +95,10 @@ public function test_cli_authenticates_with_configured_password( string $source, $args = array( '--user', 'user', '--port', (string) $port ); break; case 'short-attached': - $args = array( '-uuser', '-p' . $port, '-linfo', '-d:memory:' ); + $args = array( '-uuser', '-P' . $port, '-linfo', '-d:memory:' ); break; default: - $args = array( '-u', 'user', '-p', (string) $port, '-l', 'info', '-d', ':memory:' ); + $args = array( '-u', 'user', '-P', (string) $port, '-l', 'info', '-d', ':memory:' ); } $args[] = '--auth-plugin=' . MySQL_Test_Client::auth_plugin(); $password = 'environment-password'; From a067506839bac6c05441b05d0f0b8008b63d6b6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 12:50:27 +0200 Subject: [PATCH 07/14] Close the connection on COM_QUIT The session kept serving after a quit, so a client that never closed the socket held it open forever. MySQL closes the connection itself. Track why a session closes, so a quit is logged as a quit while a failed login still stands out as an error. --- .../mysql-proxy/src/class-mysql-proxy.php | 21 ++++++++- .../mysql-proxy/src/class-mysql-session.php | 46 ++++++++++++++----- .../tests/WP_MySQL_Session_Command_Test.php | 22 +++++++++ 3 files changed, 77 insertions(+), 12 deletions(-) diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index 734a0e84f..c183498ff 100644 --- a/packages/mysql-proxy/src/class-mysql-proxy.php +++ b/packages/mysql-proxy/src/class-mysql-proxy.php @@ -288,7 +288,7 @@ private function handle_client_activity( int $client_id ): void { } } finally { if ( $session->is_closed() ) { - $this->logger->error( 'Client [{client_id}]: login failed, closing connection', array( 'client_id' => $client_id ) ); + $this->log_closed_session( $client_id, $session->get_close_reason() ); unset( $this->clients[ $client_id ] ); @socket_close( $socket ); } @@ -296,6 +296,25 @@ private function handle_client_activity( int $client_id ): void { } while ( ! $session->is_closed() && $session->has_buffered_data() ); } + /** + * Log why a session stopped serving its connection. + * + * @param int $client_id The numeric ID of the client. + * @param string $reason One of the MySQL_Session CLOSE_* reasons. + */ + private function log_closed_session( int $client_id, string $reason ): void { + $message = 'Client [{client_id}]: {reason}, closing connection'; + $context = array( + 'client_id' => $client_id, + 'reason' => $reason, + ); + if ( MySQL_Session::CLOSE_CLIENT_QUIT === $reason ) { + $this->logger->info( $message, $context ); + } else { + $this->logger->error( $message, $context ); + } + } + /** * Write the complete response, including any authentication error. * diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index 2a058c654..bf71e4a6b 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -9,6 +9,12 @@ * MySQL server session handling a single client connection. */ class MySQL_Session { + /** + * Reasons the session closes a connection. + */ + const CLOSE_LOGIN_FAILED = 'login failed'; + const CLOSE_CLIENT_QUIT = 'client quit'; + /** * Largest packet accepted before the client is authenticated. * @@ -101,11 +107,11 @@ class MySQL_Session { private $pending_authentication; /** - * Whether the connection must be closed. + * Why the connection must be closed, or null while it stays open. * - * @var bool + * @var string|null */ - private $is_closed = false; + private $close_reason; /** * Packet sequence ID. @@ -154,7 +160,16 @@ public function is_authenticated(): bool { * @return bool */ public function is_closed(): bool { - return $this->is_closed; + return null !== $this->close_reason; + } + + /** + * Why the connection is closing. + * + * @return string|null One of the CLOSE_* reasons, or null while it stays open. + */ + public function get_close_reason(): ?string { + return $this->close_reason; } /** @@ -202,7 +217,7 @@ public function get_initial_handshake(): string { * @throws Incomplete_Input_Exception When more data is needed to complete a packet. */ public function receive_bytes( string $data ): ?string { - if ( $this->is_closed ) { + if ( $this->is_closed() ) { return null; } @@ -263,6 +278,8 @@ public function receive_bytes( string $data ): ?string { $command = ord( $payload[0] ); switch ( $command ) { case MySQL_Protocol::COM_QUIT: + // MySQL closes the connection itself, without a response. + $this->close( self::CLOSE_CLIENT_QUIT ); return ''; case MySQL_Protocol::COM_INIT_DB: return $this->process_query( $this->build_use_query( substr( $payload, 1 ) ) ); @@ -356,8 +373,7 @@ private function process_authentication( string $payload ): string { } } elseif ( $this->authentication->get_plugin() !== $plugin ) { // Like MySQL, refuse a client that cannot switch to the account's method. - $this->is_closed = true; - $this->buffer = ''; + $this->close( self::CLOSE_LOGIN_FAILED ); return MySQL_Protocol::build_err_packet( $this->packet_id++, 1251, @@ -406,8 +422,7 @@ private function complete_authentication( string $username, string $plugin, stri $failed = true; } if ( $failed ) { - $this->is_closed = true; - $this->buffer = ''; + $this->close( self::CLOSE_LOGIN_FAILED ); return MySQL_Protocol::build_err_packet( $this->packet_id++, 1049, @@ -435,10 +450,19 @@ private function complete_authentication( string $username, string $plugin, stri * @return string The access denied packet. */ private function reject_authentication(): string { - $this->is_closed = true; + $this->close( self::CLOSE_LOGIN_FAILED ); + return MySQL_Protocol::build_err_packet( $this->packet_id++, 1045, '28000', 'Access denied' ); + } + + /** + * Stop serving this connection and discard any pipelined commands. + * + * @param string $reason One of the CLOSE_* reasons. + */ + private function close( string $reason ): void { + $this->close_reason = $reason; $this->buffer = ''; $this->pending_authentication = null; - return MySQL_Protocol::build_err_packet( $this->packet_id++, 1045, '28000', 'Access denied' ); } /** diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php index 216244479..90e2325c5 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -25,6 +25,28 @@ public function database_names(): array { ); } + public function test_quit_closes_the_session(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = $this->authenticated_session( $adapter ); + + // A pipelined command after COM_QUIT must not run. + $quit = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUIT ) ); + $query = MySQL_Protocol::build_packet( 1, chr( MySQL_Protocol::COM_QUERY ) . 'DELETE FROM wp_users' ); + $this->assertSame( '', $session->receive_bytes( $quit . $query ) ); + $this->assertTrue( $session->is_closed() ); + $this->assertSame( MySQL_Session::CLOSE_CLIENT_QUIT, $session->get_close_reason() ); + $this->assertFalse( $session->has_buffered_data() ); + $this->assertNull( $session->receive_bytes( $query ) ); + } + + public function test_failed_login_reports_its_close_reason(): void { + $session = new MySQL_Session( $this->createMock( Adapter::class ), 1, new MySQL_Authentication( 'user', 'password' ) ); + $session->get_initial_handshake(); + $session->receive_bytes( MySQL_Protocol::build_packet( 1, str_repeat( "\0", 40 ) ) ); + $this->assertSame( MySQL_Session::CLOSE_LOGIN_FAILED, $session->get_close_reason() ); + } + private function authenticated_session( Adapter $adapter ): MySQL_Session { $session = new MySQL_Session( $adapter, 1, new MySQL_Authentication( 'user', 'password' ) ); $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); From 75cf66213815d08635c4d2e86478f753f8e9669f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 12:56:50 +0200 Subject: [PATCH 08/14] Join packets that a client split A payload of exactly 16 MB - 1 means the message continues in the next packet. Each packet was treated as a whole message, so a larger query was cut at that boundary and its prefix was run on its own. Cap an assembled message at 16 MB and answer MySQL's 1153 past it, so joining packets cannot let one connection buffer without limit. --- .../mysql-proxy/src/class-mysql-session.php | 139 +++++++++++++----- .../tests/WP_MySQL_Session_Command_Test.php | 58 ++++++++ 2 files changed, 162 insertions(+), 35 deletions(-) diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index bf71e4a6b..4d5fd4bb4 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -12,11 +12,30 @@ class MySQL_Session { /** * Reasons the session closes a connection. */ - const CLOSE_LOGIN_FAILED = 'login failed'; - const CLOSE_CLIENT_QUIT = 'client quit'; + const CLOSE_LOGIN_FAILED = 'login failed'; + const CLOSE_CLIENT_QUIT = 'client quit'; + const CLOSE_PACKET_TOO_LARGE = 'packet too large'; /** - * Largest packet accepted before the client is authenticated. + * Largest payload a single packet can carry. + * + * A packet with a payload of exactly this size continues in the next packet. + * + * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_packets.html + */ + const MAX_PACKET_PAYLOAD = 0xffffff; + + /** + * Largest message accepted from an authenticated client. + * + * This is the "max_allowed_packet" equivalent, at the 16 MB default of MySQL + * clients rather than the 64 MB server default. It bounds the memory a single + * connection can hold, because a split message is assembled in memory. + */ + const MAX_MESSAGE_SIZE = 16777216; + + /** + * Largest message accepted before the client is authenticated. * * A login packet carries a username, a challenge response, a database name, * and connection attributes, so it stays well below MySQL's own 16 KB @@ -224,41 +243,15 @@ public function receive_bytes( string $data ): ?string { // Append new data to the existing buffer. $this->buffer .= $data; - // Check if we have enough data for a packet header. - if ( strlen( $this->buffer ) < 4 ) { - throw new Incomplete_Input_Exception( 'Incomplete packet header, need more bytes' ); + // Read one message, which the client may have split across packets. + $received_sequence_id = null; + $payload = $this->read_message( $received_sequence_id ); + if ( null === $payload ) { + return $this->reject_oversized_message(); } - // Parse packet header. - $payload_length = unpack( 'V', substr( $this->buffer, 0, 3 ) . "\x00" )[1]; - $received_sequence_id = ord( $this->buffer[3] ); - $this->packet_id = $received_sequence_id + 1; - - // Never buffer a large packet for a client that has not logged in yet. - if ( ! $this->is_authenticated && $payload_length > self::MAX_UNAUTHENTICATED_PACKET_SIZE ) { - return $this->reject_authentication(); - } - - // Check if we have the complete packet. - $packet_length = 4 + $payload_length; - if ( strlen( $this->buffer ) < $packet_length ) { - throw new Incomplete_Input_Exception( - sprintf( - 'Incomplete packet payload, have %d bytes, but need %d bytes', - strlen( $this->buffer ), - $packet_length - ) - ); - } - - // Extract the packet payload. - $payload = substr( $this->buffer, 4, $payload_length ); - - // Remove the whole packet from the buffer. - $this->buffer = substr( $this->buffer, $packet_length ); - /* - * Process the packet. + * Process the message. * * Depending on the lifecycle phase, handle authentication or a command. * @@ -297,6 +290,82 @@ public function receive_bytes( string $data ): ?string { } } + /** + * Read one client message from the buffer, joining packets the client split. + * + * The message is removed from the buffer only once it is complete, so an + * incomplete message stays buffered for the next read. + * + * @param int|null $first_sequence_id Receives the sequence ID of the first packet. + * @return string|null The message payload, or null if it is too large. + * @throws Incomplete_Input_Exception When more data is needed to complete the message. + */ + private function read_message( ?int &$first_sequence_id ): ?string { + $limit = $this->is_authenticated ? self::MAX_MESSAGE_SIZE : self::MAX_UNAUTHENTICATED_PACKET_SIZE; + $offset = 0; + $payload = ''; + + while ( true ) { + // Check if we have enough data for a packet header. + if ( strlen( $this->buffer ) < $offset + 4 ) { + throw new Incomplete_Input_Exception( 'Incomplete packet header, need more bytes' ); + } + + // Parse the packet header. A response continues the client's sequence. + $payload_length = unpack( 'V', substr( $this->buffer, $offset, 3 ) . "\x00" )[1]; + $sequence_id = ord( $this->buffer[ $offset + 3 ] ); + $first_sequence_id = $first_sequence_id ?? $sequence_id; + $this->packet_id = $sequence_id + 1; + + // Refuse the message before buffering more than the limit allows. + if ( strlen( $payload ) + $payload_length > $limit ) { + return null; + } + + // Check if we have the complete packet. + $packet_length = 4 + $payload_length; + if ( strlen( $this->buffer ) < $offset + $packet_length ) { + throw new Incomplete_Input_Exception( + sprintf( + 'Incomplete packet payload, have %d bytes, but need %d bytes', + strlen( $this->buffer ) - $offset, + $packet_length + ) + ); + } + + $payload .= substr( $this->buffer, $offset + 4, $payload_length ); + $offset += $packet_length; + + // A full payload means the message continues in the next packet. + if ( self::MAX_PACKET_PAYLOAD !== $payload_length ) { + break; + } + } + + // Remove the whole message from the buffer. + $this->buffer = substr( $this->buffer, $offset ); + return $payload; + } + + /** + * Refuse a message larger than this session accepts. + * + * @return string The error packet. + */ + private function reject_oversized_message(): string { + if ( ! $this->is_authenticated ) { + return $this->reject_authentication(); + } + $this->close( self::CLOSE_PACKET_TOO_LARGE ); + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 1153, + '08S01', + "Got a packet bigger than 'max_allowed_packet' bytes" + ); + } + /** * Process authentication payload from the client. * diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php index 90e2325c5..11a8a8792 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -2,6 +2,7 @@ use PHPUnit\Framework\TestCase; use WP_MySQL_Proxy\Adapter\Adapter; +use WP_MySQL_Proxy\Incomplete_Input_Exception; use WP_MySQL_Proxy\MySQL_Authentication; use WP_MySQL_Proxy\MySQL_Protocol; use WP_MySQL_Proxy\MySQL_Result; @@ -25,6 +26,63 @@ public function database_names(): array { ); } + public function test_split_message_is_assembled(): void { + $this->iniSet( 'memory_limit', '512M' ); + $queries = array(); + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->willReturnCallback( + function ( string $query ) use ( &$queries ): MySQL_Result { + $queries[] = $query; + return MySQL_Result::from_data( 0, 0, array(), array() ); + } + ); + $session = $this->authenticated_session( $adapter ); + + // A packet with a full payload continues in the next packet. + $prefix = chr( MySQL_Protocol::COM_QUERY ) . 'SELECT 1 -- '; + $packet = MySQL_Protocol::build_packet( 0, $prefix . str_repeat( 'x', MySQL_Session::MAX_PACKET_PAYLOAD - strlen( $prefix ) ) ); + try { + $session->receive_bytes( $packet ); + $this->fail( 'A split message must wait for the next packet.' ); + } catch ( Incomplete_Input_Exception $e ) { + $this->assertSame( array(), $queries, 'The first packet must not run on its own.' ); + } + unset( $packet ); + + // One more byte makes the message exactly 16 MB, the limit. + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 1, '1' ) ); + $this->assertSame( 0, ord( $response[4] ) ); + $this->assertSame( 2, ord( $response[3] ), 'The response continues the client sequence.' ); + $this->assertCount( 1, $queries ); + $this->assertSame( MySQL_Session::MAX_MESSAGE_SIZE - 1, strlen( $queries[0] ) ); + $this->assertStringEndsWith( 'xx1', $queries[0] ); + $this->assertFalse( $session->is_closed() ); + } + + public function test_oversized_message_is_refused(): void { + $this->iniSet( 'memory_limit', '512M' ); + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = $this->authenticated_session( $adapter ); + + $packet = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . str_repeat( 'x', MySQL_Session::MAX_PACKET_PAYLOAD - 1 ) ); + try { + $session->receive_bytes( $packet ); + $this->fail( 'A split message must wait for the next packet.' ); + } catch ( Incomplete_Input_Exception $e ) { + $this->assertFalse( $session->is_closed() ); + } + unset( $packet ); + + // One byte past the limit, which is checked before the payload is buffered. + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 1, '12' ) ); + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1153, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( '#08S01', substr( $response, 7, 6 ) ); + $this->assertSame( MySQL_Session::CLOSE_PACKET_TOO_LARGE, $session->get_close_reason() ); + $this->assertFalse( $session->has_buffered_data() ); + } + public function test_quit_closes_the_session(): void { $adapter = $this->createMock( Adapter::class ); $adapter->expects( $this->never() )->method( 'handle_query' ); From 6f2343ba5decac5b1e41b93597b9bcc49360e3b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 12:58:03 +0200 Subject: [PATCH 09/14] Refuse a command packet with no payload Reading the command byte of an empty packet raised a PHP warning per packet, and a deprecation on PHP 8.5, so a client could flood the log with them. Answer MySQL's 1835 and close the connection instead. --- packages/mysql-proxy/src/class-mysql-session.php | 8 +++++++- .../tests/WP_MySQL_Session_Command_Test.php | 10 ++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index 4d5fd4bb4..66b5fb177 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -15,6 +15,7 @@ class MySQL_Session { const CLOSE_LOGIN_FAILED = 'login failed'; const CLOSE_CLIENT_QUIT = 'client quit'; const CLOSE_PACKET_TOO_LARGE = 'packet too large'; + const CLOSE_MALFORMED_PACKET = 'malformed packet'; /** * Largest payload a single packet can carry. @@ -267,7 +268,12 @@ public function receive_bytes( string $data ): ?string { return $this->process_authentication( $payload ); } - // Command phase. + // Command phase. Like MySQL, refuse a packet that carries no command. + if ( '' === $payload ) { + $this->close( self::CLOSE_MALFORMED_PACKET ); + return MySQL_Protocol::build_err_packet( $this->packet_id++, 1835, 'HY000', 'Malformed communication packet' ); + } + $command = ord( $payload[0] ); switch ( $command ) { case MySQL_Protocol::COM_QUIT: diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php index 11a8a8792..f6aa1bd5f 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -83,6 +83,16 @@ public function test_oversized_message_is_refused(): void { $this->assertFalse( $session->has_buffered_data() ); } + public function test_empty_command_packet_is_refused(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = $this->authenticated_session( $adapter ); + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 0, '' ) ); + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1835, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( MySQL_Session::CLOSE_MALFORMED_PACKET, $session->get_close_reason() ); + } + public function test_quit_closes_the_session(): void { $adapter = $this->createMock( Adapter::class ); $adapter->expects( $this->never() )->method( 'handle_query' ); From d22065561f903ca06a992823f174c94232ee9a9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 12:58:35 +0200 Subject: [PATCH 10/14] Default a result's last insert ID to 0 The OK packet builder requires an int, so a result built without an insert ID raised a TypeError that reached the client as an unknown error. --- .../mysql-proxy/src/Adapter/class-sqlite-adapter.php | 2 +- packages/mysql-proxy/src/class-mysql-result.php | 2 +- .../mysql-proxy/tests/WP_MySQL_Session_Command_Test.php | 9 +++++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php b/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php index a851c43f7..dc3e5416f 100644 --- a/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php +++ b/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php @@ -23,7 +23,7 @@ public function __construct( $sqlite_database_path ) { public function handle_query( string $query ): MySQL_Result { $affected_rows = 0; - $last_insert_id = null; + $last_insert_id = 0; $columns = array(); $rows = array(); diff --git a/packages/mysql-proxy/src/class-mysql-result.php b/packages/mysql-proxy/src/class-mysql-result.php index 181ec1c91..833a478cd 100644 --- a/packages/mysql-proxy/src/class-mysql-result.php +++ b/packages/mysql-proxy/src/class-mysql-result.php @@ -4,7 +4,7 @@ class MySQL_Result { public $affected_rows = 0; - public $last_insert_id = null; + public $last_insert_id = 0; public $columns = array(); public $rows = array(); diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php index f6aa1bd5f..ab7167eeb 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -83,6 +83,15 @@ public function test_oversized_message_is_refused(): void { $this->assertFalse( $session->has_buffered_data() ); } + public function test_result_without_an_insert_id_is_an_ok_packet(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->willReturn( new MySQL_Result() ); + $session = $this->authenticated_session( $adapter ); + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . 'DELETE FROM t' ) ); + $this->assertSame( 0, ord( $response[4] ) ); + $this->assertFalse( $session->is_closed() ); + } + public function test_empty_command_packet_is_refused(): void { $adapter = $this->createMock( Adapter::class ); $adapter->expects( $this->never() )->method( 'handle_query' ); From e7bde026fb37dfe26b5071d97d7d224f74690d93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 12:59:27 +0200 Subject: [PATCH 11/14] Keep internal error details out of client errors PHP type errors carry the absolute path of the calling file, so the server's layout could reach any authenticated client. Answer a plain 1105 and log the detail instead. --- .../src/Adapter/class-sqlite-adapter.php | 31 ++++++-- .../mysql-proxy/src/class-mysql-proxy.php | 2 +- .../mysql-proxy/src/class-mysql-session.php | 26 +++++-- .../tests/WP_MySQL_SQLite_Adapter_Test.php | 74 +++++++++++++++++++ .../tests/WP_MySQL_Session_Command_Test.php | 16 ++++ 5 files changed, 135 insertions(+), 14 deletions(-) create mode 100644 packages/mysql-proxy/tests/WP_MySQL_SQLite_Adapter_Test.php diff --git a/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php b/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php index dc3e5416f..9fae58e8b 100644 --- a/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php +++ b/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php @@ -2,10 +2,10 @@ namespace WP_MySQL_Proxy\Adapter; +use Error; use PDO; use PDOException; use PDOStatement; -use Throwable; use WP_MySQL_Proxy\MySQL_Result; use WP_MySQL_On_SQLite; use WP_MySQL_Proxy\MySQL_Protocol; @@ -39,12 +39,13 @@ public function handle_query( string $query ): MySQL_Result { $columns = $this->computeColumnInfo( $statement ); } return MySQL_Result::from_data( $affected_rows, $last_insert_id, $columns, $rows ?? array() ); - } catch ( Throwable $e ) { - $error_info = $e->errorInfo ?? null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - if ( $e instanceof PDOException && $error_info ) { - return MySQL_Result::from_error( $error_info[0], $error_info[1], $error_info[2] ); + } catch ( PDOException $e ) { + // Only query errors reach the client. The session logs internal errors instead. + $error_info = $e->errorInfo; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + if ( ! $error_info || $this->is_internal_error( $e ) ) { + throw $e; } - return MySQL_Result::from_error( 'HY000', 1105, $e->getMessage() ?? 'Unknown error' ); + return MySQL_Result::from_error( $error_info[0], $error_info[1], $error_info[2] ); } } @@ -101,4 +102,22 @@ public function computeColumnInfo( PDOStatement $statement ) { } return $columns; } + + /** + * Whether a query exception is a PHP error that the driver reported as a query error. + * + * A PHP error is a bug, not a query error, and its message can contain server + * file paths. + * + * @param PDOException $e The query exception. + * @return bool + */ + private function is_internal_error( PDOException $e ): bool { + for ( $cause = $e->getPrevious(); null !== $cause; $cause = $cause->getPrevious() ) { + if ( $cause instanceof Error ) { + return true; + } + } + return false; + } } diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index c183498ff..8ff5c5033 100644 --- a/packages/mysql-proxy/src/class-mysql-proxy.php +++ b/packages/mysql-proxy/src/class-mysql-proxy.php @@ -225,7 +225,7 @@ private function handle_new_client(): void { // Create a new session for the client. $client_id = $this->get_client_id( $socket ); - $session = new MySQL_Session( $this->adapter, $client_id, $this->authentication ); + $session = new MySQL_Session( $this->adapter, $client_id, $this->authentication, $this->logger ); $this->clients[ $client_id ] = array( 'socket' => $socket, 'session' => $session, diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index 66b5fb177..e118cc67b 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -133,6 +133,13 @@ class MySQL_Session { */ private $close_reason; + /** + * Logger instance. + * + * @var Logger + */ + private $logger; + /** * Packet sequence ID. * @@ -152,12 +159,14 @@ class MySQL_Session { * * @param Adapter $adapter The MySQL query adapter instance. * @param int $connection_id The connection ID. - * @param MySQL_Authentication $authentication The configured account verifier. + * @param MySQL_Authentication $authentication The configured account verifier. + * @param Logger|null $logger The logger for internal errors. Default: a new logger. */ - public function __construct( Adapter $adapter, int $connection_id, MySQL_Authentication $authentication ) { + public function __construct( Adapter $adapter, int $connection_id, MySQL_Authentication $authentication, ?Logger $logger = null ) { $this->authentication = $authentication; $this->adapter = $adapter; $this->connection_id = $connection_id; + $this->logger = $logger ?? new Logger(); $this->packet_id = 0; // Like MySQL's generate_user_salt(), use 7-bit characters other than NUL and '$'. @@ -586,12 +595,15 @@ private function process_query( string $query ): string { $result->last_insert_id ); } catch ( Throwable $e ) { - return MySQL_Protocol::build_err_packet( - $this->packet_id++, - 0, - 'HY000', - 'Unknown error: ' . $e->getMessage() + // Keep internal details, such as file paths, out of the client's error. + $this->logger->error( + 'Client [{client_id}]: internal error: {error}', + array( + 'client_id' => $this->connection_id, + 'error' => $e->getMessage(), + ) ); + return MySQL_Protocol::build_err_packet( $this->packet_id++, 1105, 'HY000', 'Unknown error' ); } } diff --git a/packages/mysql-proxy/tests/WP_MySQL_SQLite_Adapter_Test.php b/packages/mysql-proxy/tests/WP_MySQL_SQLite_Adapter_Test.php new file mode 100644 index 000000000..39e649e37 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_SQLite_Adapter_Test.php @@ -0,0 +1,74 @@ +handle_query( 'SELECT * FROM missing_table' ); + $this->assertSame( array( '42S02', 1146, "Table 'missing_table' doesn't exist" ), $result->error_info ); + } + + public function test_driver_message_is_reported(): void { + $cause = new RuntimeException( 'Unsupported ALTER TABLE ADD action: X' ); + $result = $this->adapter_failing_with( $cause )->handle_query( 'SELECT 1' ); + $this->assertSame( array( 'HY000', 1105, 'Unsupported ALTER TABLE ADD action: X' ), $result->error_info ); + } + + /** @dataProvider internal_errors */ + public function test_internal_error_is_not_reported( Throwable $cause ): void { + $this->expectException( WP_MySQL_On_SQLite_Exception::class ); + $this->adapter_failing_with( $cause )->handle_query( 'SELECT 1' ); + } + + public function internal_errors(): array { + return array( + 'PHP error' => array( new TypeError( 'Private detail from /srv/secret/path.php' ) ), + 'wrapped PHP error' => array( new RuntimeException( 'Wrapper', 0, new TypeError( 'Private detail' ) ) ), + ); + } + + public function test_internal_error_does_not_reach_the_client(): void { + $cause = new TypeError( 'f(): Argument #1 must be of type string, called in /srv/secret/path.php on line 1' ); + $session = new MySQL_Session( $this->adapter_failing_with( $cause ), 1, new MySQL_Authentication( 'user', 'password' ) ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); + $this->assertTrue( $session->is_authenticated() ); + + // The detail goes to the log, which the test runner shows on stderr. + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . 'SELECT 1' ) ); + $this->assertSame( 1105, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( 'Unknown error', substr( $response, 13 ) ); + $this->assertStringNotContainsString( '/srv/secret/path.php', $response ); + } + + /** + * An adapter whose driver fails a query the way the driver reports any error. + */ + private function adapter_failing_with( Throwable $cause ): SQLite_Adapter { + $adapter = new SQLite_Adapter( ':memory:' ); + $property = new ReflectionProperty( SQLite_Adapter::class, 'sqlite_driver' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $exception = new WP_MySQL_On_SQLite_Exception( $property->getValue( $adapter ), $cause->getMessage(), $cause->getCode(), $cause ); + $property->setValue( + $adapter, + new class( $exception ) { + private $exception; + + public function __construct( Throwable $exception ) { + $this->exception = $exception; + } + + public function query(): void { + throw $this->exception; + } + } + ); + return $adapter; + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php index ab7167eeb..5903fc8b3 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -83,6 +83,22 @@ public function test_oversized_message_is_refused(): void { $this->assertFalse( $session->has_buffered_data() ); } + public function test_internal_error_is_not_sent_to_the_client(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->willThrowException( + new RuntimeException( 'Private detail from /srv/secret/path.php' ) + ); + $session = $this->authenticated_session( $adapter ); + + // The detail goes to the log, which the test runner shows on stderr. + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . 'SELECT 1' ) ); + + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1105, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( 'Unknown error', substr( $response, 13 ) ); + $this->assertStringNotContainsString( '/srv/secret/path.php', $response ); + } + public function test_result_without_an_insert_id_is_an_ok_packet(): void { $adapter = $this->createMock( Adapter::class ); $adapter->expects( $this->once() )->method( 'handle_query' )->willReturn( new MySQL_Result() ); From d97ff1bd73284efadc6e93e23377dc87b3e7b544 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 13:00:20 +0200 Subject: [PATCH 12/14] Limit the number of open connections Refuse a connection past 151 open ones with MySQL's 1040 error, so the proxy keeps serving instead of running out of file descriptors. 151 is MySQL's own max_connections default. --- .../mysql-proxy/src/class-mysql-proxy.php | 21 +++ .../tests/WP_MySQL_Proxy_Limits_Test.php | 140 ++++++++++++++++++ .../WP_MySQL_Proxy_Login_Timeout_Test.php | 72 --------- 3 files changed, 161 insertions(+), 72 deletions(-) create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php delete mode 100644 packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index 8ff5c5033..1bd8c1929 100644 --- a/packages/mysql-proxy/src/class-mysql-proxy.php +++ b/packages/mysql-proxy/src/class-mysql-proxy.php @@ -26,6 +26,13 @@ class MySQL_Proxy { */ const LOGIN_TIMEOUT = 10; + /** + * Number of client connections served at once. + * + * This matches the default MySQL "max_connections". + */ + const MAX_CONNECTIONS = 151; + /** * The socket to listen on. * @@ -223,6 +230,20 @@ private function handle_new_client(): void { throw $this->new_socket_error(); } + // Refuse the connection while the proxy is full, as MySQL does. + if ( count( $this->clients ) >= self::MAX_CONNECTIONS ) { + $this->logger->error( + 'Refusing a new client: {count} connections are already open', + array( 'count' => count( $this->clients ) ) + ); + try { + $this->write_response( $socket, MySQL_Protocol::build_err_packet( 0, 1040, '08004', 'Too many connections' ) ); + } finally { + @socket_close( $socket ); + } + return; + } + // Create a new session for the client. $client_id = $this->get_client_id( $socket ); $session = new MySQL_Session( $this->adapter, $client_id, $this->authentication, $this->logger ); diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php new file mode 100644 index 000000000..f93648513 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php @@ -0,0 +1,140 @@ +createMock( Adapter::class ), + array( 'authentication' => new MySQL_Authentication( 'user', 'password' ) ) + ); + $clients = $this->property( 'clients' ); + $clients->setValue( + $proxy, + array( + 1 => array( + 'socket' => $this->socket(), + 'session' => $this->session( $authenticated ), + 'login_deadline' => time() + $deadline_offset, + ), + ) + ); + + $this->assertSame( $expected_timeout, $this->call( $proxy, 'get_select_timeout' ) ); + $this->call( $proxy, 'close_timed_out_logins' ); + $this->assertCount( $expected_open ? 1 : 0, $clients->getValue( $proxy ) ); + } + + public function pending_logins(): array { + return array( + 'pending login within the deadline' => array( 5, false, true, 5 ), + 'pending login past the deadline' => array( -1, false, false, 0 ), + 'authenticated past the deadline' => array( -1, true, true, null ), + ); + } + + /** @dataProvider connection_counts */ + public function test_connection_limit( int $open, bool $expected_accepted ): void { + $port = $this->free_port(); + $proxy = new MySQL_Proxy( + $this->createMock( Adapter::class ), + array( + 'authentication' => new MySQL_Authentication( 'user', 'password' ), + 'port' => $port, + ) + ); + + // Stand in for the listener the proxy creates in start(). + $listener = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_set_option( $listener, SOL_SOCKET, SO_REUSEADDR, 1 ); + socket_bind( $listener, '127.0.0.1', $port ); + socket_listen( $listener ); + $this->property( 'socket' )->setValue( $proxy, $listener ); + + $clients = $this->property( 'clients' ); + $occupant = array( + 'socket' => $this->socket(), + 'session' => $this->session( true ), + 'login_deadline' => time() + 60, + ); + + // Key the occupants away from any object ID the accepted socket can get. + $occupants = array(); + for ( $i = 1; $i <= $open; $i++ ) { + $occupants[ PHP_INT_MAX - $i ] = $occupant; + } + $clients->setValue( $proxy, $occupants ); + + $client = stream_socket_client( "tcp://127.0.0.1:$port", $errno, $error, 2 ); + stream_set_timeout( $client, 2 ); + $this->call( $proxy, 'handle_new_client' ); + $response = fread( $client, 256 ); + + if ( $expected_accepted ) { + $this->assertSame( 10, ord( $response[4] ), 'The client gets the handshake packet.' ); + $this->assertCount( $open + 1, $clients->getValue( $proxy ) ); + $accepted = $clients->getValue( $proxy ); + $this->assertGreaterThan( time(), end( $accepted )['login_deadline'] ); + } else { + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1040, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( 'Too many connections', substr( $response, 13 ) ); + $this->assertCount( $open, $clients->getValue( $proxy ), 'A refused client is not registered.' ); + $this->assertSame( '', fread( $client, 1 ), 'A refused connection is closed.' ); + } + + fclose( $client ); + socket_close( $listener ); + } + + public function connection_counts(): array { + return array( + 'below the limit' => array( MySQL_Proxy::MAX_CONNECTIONS - 1, true ), + 'at the limit' => array( MySQL_Proxy::MAX_CONNECTIONS, false ), + ); + } + + private function free_port(): int { + $listener = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $error ); + $port = (int) substr( strrchr( stream_socket_get_name( $listener, false ), ':' ), 1 ); + fclose( $listener ); + return $port; + } + + private function session( bool $authenticated ): MySQL_Session { + $session = new MySQL_Session( $this->createMock( Adapter::class ), 1, new MySQL_Authentication( 'user', 'password' ) ); + if ( $authenticated ) { + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); + $this->assertTrue( $session->is_authenticated() ); + } + return $session; + } + + private function socket() { + $pair = array(); + socket_create_pair( AF_UNIX, SOCK_STREAM, 0, $pair ); + return $pair[0]; + } + + private function property( string $name ): ReflectionProperty { + $property = new ReflectionProperty( MySQL_Proxy::class, $name ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + return $property; + } + + private function call( MySQL_Proxy $proxy, string $name ) { + $method = new ReflectionMethod( MySQL_Proxy::class, $name ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $proxy ); + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php deleted file mode 100644 index d22de2e76..000000000 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php +++ /dev/null @@ -1,72 +0,0 @@ -createMock( Adapter::class ), - array( 'authentication' => new MySQL_Authentication( 'user', 'password' ) ) - ); - $clients = $this->property( 'clients' ); - $clients->setValue( - $proxy, - array( - 1 => array( - 'socket' => $this->socket(), - 'session' => $this->session( $authenticated ), - 'login_deadline' => time() + $deadline_offset, - ), - ) - ); - - $this->assertSame( $expected_timeout, $this->call( $proxy, 'get_select_timeout' ) ); - $this->call( $proxy, 'close_timed_out_logins' ); - $this->assertCount( $expected_open ? 1 : 0, $clients->getValue( $proxy ) ); - } - - public function pending_logins(): array { - return array( - 'pending login within the deadline' => array( 5, false, true, 5 ), - 'pending login past the deadline' => array( -1, false, false, 0 ), - 'authenticated past the deadline' => array( -1, true, true, null ), - ); - } - - private function session( bool $authenticated ): MySQL_Session { - $session = new MySQL_Session( $this->createMock( Adapter::class ), 1, new MySQL_Authentication( 'user', 'password' ) ); - if ( $authenticated ) { - $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); - $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); - $this->assertTrue( $session->is_authenticated() ); - } - return $session; - } - - private function socket() { - $pair = array(); - socket_create_pair( AF_UNIX, SOCK_STREAM, 0, $pair ); - return $pair[0]; - } - - private function property( string $name ): ReflectionProperty { - $property = new ReflectionProperty( MySQL_Proxy::class, $name ); - if ( PHP_VERSION_ID < 80100 ) { - $property->setAccessible( true ); - } - return $property; - } - - private function call( MySQL_Proxy $proxy, string $name ) { - $method = new ReflectionMethod( MySQL_Proxy::class, $name ); - if ( PHP_VERSION_ID < 80100 ) { - $method->setAccessible( true ); - } - return $method->invoke( $proxy ); - } -} From 53c22a706692876fe82c8addd6f16fdf01e7c67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 16:44:01 +0200 Subject: [PATCH 13/14] Report an unknown database from COM_INIT_DB as MySQL does COM_INIT_DB passed the adapter's error to the client, so selecting a missing database returned 1105 with the adapter's message, while the login returned MySQL's 1049 for the same name. Select the database the same way in both places, so both answer 1049 and the adapter's details stay in the log. Unlike the login, a failed COM_INIT_DB keeps the connection open. --- .../mysql-proxy/src/class-mysql-session.php | 76 ++++++++++++------- .../tests/WP_MySQL_Session_Command_Test.php | 41 +++++++++- 2 files changed, 89 insertions(+), 28 deletions(-) diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index e118cc67b..8aa25ee0f 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -290,7 +290,8 @@ public function receive_bytes( string $data ): ?string { $this->close( self::CLOSE_CLIENT_QUIT ); return ''; case MySQL_Protocol::COM_INIT_DB: - return $this->process_query( $this->build_use_query( substr( $payload, 1 ) ) ); + return $this->use_database( substr( $payload, 1 ) ) + ?? MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); case MySQL_Protocol::COM_QUERY: return $this->process_query( substr( $payload, 1 ) ); case MySQL_Protocol::COM_PING: @@ -499,20 +500,10 @@ private function complete_authentication( string $username, string $plugin, stri // No adapter calls are allowed until the credentials have been verified. if ( '' !== $database ) { - try { - $result = $this->adapter->handle_query( $this->build_use_query( $database ) ); - $failed = (bool) $result->error_info; - } catch ( Throwable $e ) { - $failed = true; - } - if ( $failed ) { + $error = $this->use_database( $database ); + if ( null !== $error ) { $this->close( self::CLOSE_LOGIN_FAILED ); - return MySQL_Protocol::build_err_packet( - $this->packet_id++, - 1049, - '42000', - sprintf( "Unknown database: '%s'", $database ) - ); + return $error; } } @@ -550,13 +541,36 @@ private function close( string $reason ): void { } /** - * Build a USE statement for a client-supplied database name. + * Select a client-supplied database, for COM_INIT_DB and the login. + * + * The name is quoted as an identifier. Like MySQL, any failure is reported + * as an unknown database. The adapter's reason goes to the log. * * @param string $database The database name. - * @return string The USE statement with a quoted identifier. + * @return string|null The error packet, or null when the database is selected. */ - private function build_use_query( string $database ): string { - return 'USE `' . str_replace( '`', '``', $database ) . '`'; + private function use_database( string $database ): ?string { + try { + $result = $this->adapter->handle_query( 'USE `' . str_replace( '`', '``', $database ) . '`' ); + if ( ! $result->error_info ) { + return null; + } + $this->logger->warning( + 'Client [{client_id}]: cannot select a database: {error}', + array( + 'client_id' => $this->connection_id, + 'error' => $result->error_info[2], + ) + ); + } catch ( Throwable $e ) { + $this->log_internal_error( $e ); + } + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 1049, + '42000', + sprintf( "Unknown database: '%s'", $database ) + ); } /** @@ -595,18 +609,28 @@ private function process_query( string $query ): string { $result->last_insert_id ); } catch ( Throwable $e ) { - // Keep internal details, such as file paths, out of the client's error. - $this->logger->error( - 'Client [{client_id}]: internal error: {error}', - array( - 'client_id' => $this->connection_id, - 'error' => $e->getMessage(), - ) - ); + $this->log_internal_error( $e ); return MySQL_Protocol::build_err_packet( $this->packet_id++, 1105, 'HY000', 'Unknown error' ); } } + /** + * Log an internal error, which must not reach the client. + * + * Its message can contain internal details, such as file paths. + * + * @param Throwable $error The internal error. + */ + private function log_internal_error( Throwable $error ): void { + $this->logger->error( + 'Client [{client_id}]: internal error: {error}', + array( + 'client_id' => $this->connection_id, + 'error' => $error->getMessage(), + ) + ); + } + /** * Build the result set packets for a MySQL query. * diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php index 5903fc8b3..80c6ce1a7 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -3,6 +3,7 @@ use PHPUnit\Framework\TestCase; use WP_MySQL_Proxy\Adapter\Adapter; use WP_MySQL_Proxy\Incomplete_Input_Exception; +use WP_MySQL_Proxy\Logger; use WP_MySQL_Proxy\MySQL_Authentication; use WP_MySQL_Proxy\MySQL_Protocol; use WP_MySQL_Proxy\MySQL_Result; @@ -26,6 +27,42 @@ public function database_names(): array { ); } + /** @dataProvider init_db_failures */ + public function test_init_db_reports_an_unknown_database( bool $throws ): void { + $adapter = $this->createMock( Adapter::class ); + $expectation = $adapter->expects( $this->once() )->method( 'handle_query' )->with( 'USE `missing`' ); + if ( $throws ) { + $expectation->willThrowException( new RuntimeException( 'Private adapter error' ) ); + } else { + $expectation->willReturn( MySQL_Result::from_error( 'HY000', 1105, 'Private adapter error' ) ); + } + + // The adapter's reason goes to the log, not to the client. + $logger = $this->createMock( Logger::class ); + $logger->expects( $this->once() )->method( $throws ? 'error' : 'warning' )->with( + $this->anything(), + $this->callback( + function ( array $context ): bool { + return 'Private adapter error' === $context['error']; + } + ) + ); + + $session = $this->authenticated_session( $adapter, $logger ); + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_INIT_DB ) . 'missing' ) ); + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1049, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( "#42000Unknown database: 'missing'", substr( $response, 7 ) ); + $this->assertFalse( $session->is_closed(), 'Unlike the login, a failed COM_INIT_DB keeps the connection.' ); + } + + public function init_db_failures(): array { + return array( + 'adapter error' => array( false ), + 'adapter throws' => array( true ), + ); + } + public function test_split_message_is_assembled(): void { $this->iniSet( 'memory_limit', '512M' ); $queries = array(); @@ -140,8 +177,8 @@ public function test_failed_login_reports_its_close_reason(): void { $this->assertSame( MySQL_Session::CLOSE_LOGIN_FAILED, $session->get_close_reason() ); } - private function authenticated_session( Adapter $adapter ): MySQL_Session { - $session = new MySQL_Session( $adapter, 1, new MySQL_Authentication( 'user', 'password' ) ); + private function authenticated_session( Adapter $adapter, ?Logger $logger = null ): MySQL_Session { + $session = new MySQL_Session( $adapter, 1, new MySQL_Authentication( 'user', 'password' ), $logger ); $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); $this->assertTrue( $session->is_authenticated() ); From 23851115ef3b5526cfad3755d75bf0b3e4bfd4d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 16:45:45 +0200 Subject: [PATCH 14/14] Close connections that stay idle after login The login timeout only covered clients that had not logged in yet, so a logged-in client could stay connected without sending anything and keep one of the connection slots forever. Give every connection a single deadline: LOGIN_TIMEOUT until it logs in, then IDLE_TIMEOUT after each request. The idle limit matches MySQL's wait_timeout default of 8 hours. Data from a client that has not logged in does not extend its login deadline. --- .../mysql-proxy/src/class-mysql-proxy.php | 324 ++++++++++++++---- .../tests/WP_MySQL_Proxy_Limits_Test.php | 250 +++++++++++--- .../tests/WP_MySQL_Proxy_Slow_Client_Test.php | 44 +++ 3 files changed, 501 insertions(+), 117 deletions(-) create mode 100644 packages/mysql-proxy/tests/WP_MySQL_Proxy_Slow_Client_Test.php diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index 1bd8c1929..07a7bbdf8 100644 --- a/packages/mysql-proxy/src/class-mysql-proxy.php +++ b/packages/mysql-proxy/src/class-mysql-proxy.php @@ -26,6 +26,42 @@ class MySQL_Proxy { */ const LOGIN_TIMEOUT = 10; + /** + * Seconds a logged-in client may stay connected without sending anything. + * + * This matches the default MySQL "wait_timeout". + */ + const IDLE_TIMEOUT = 28800; + + /** + * Seconds a logged-in client may take to send more of a request it has started. + * + * This matches the default MySQL "net_read_timeout". + */ + const READ_TIMEOUT = 30; + + /** + * Seconds a client may take to accept more of a pending response. + * + * This matches the default MySQL "net_write_timeout". + */ + const WRITE_TIMEOUT = 60; + + /** + * Largest number of bytes read from a client at once. + * + * Large reads keep the passes through the proxy loop few for a large message. + */ + const READ_CHUNK_SIZE = 65536; + + /** + * Largest number of bytes passed to a single socket write. + * + * Writing a pending response in chunks avoids copying the rest of it on + * every write. + */ + const WRITE_CHUNK_SIZE = 65536; + /** * Number of client connections served at once. * @@ -71,9 +107,10 @@ class MySQL_Proxy { /** * A map of connected clients. * - * Maps client IDs to their associated socket and session instances. + * Maps client IDs to their socket, session, deadline, and the response that + * the client has not accepted yet, with the number of its bytes already sent. * - * @var array + * @var array */ private $clients = array(); @@ -155,12 +192,20 @@ public function start(): void { // Start the main proxy loop. while ( true ) { try { - // Drop clients that connected but never completed a login. - $this->close_timed_out_logins(); - - // Wait for activity on any socket, waking up for the next login deadline. - $read = array_merge( array( $this->socket ), array_column( $this->clients, 'socket' ) ); - $write = null; + // Drop clients that missed their login, idle, or write deadline. + $this->close_timed_out_clients(); + + // Wait for activity on any socket, waking up for the next deadline. + // A client is not read while it has a response it has not accepted. + $read = array( $this->socket ); + $write = array(); + foreach ( $this->clients as $client ) { + if ( '' === $client['output'] ) { + $read[] = $client['socket']; + } else { + $write[] = $client['socket']; + } + } $except = null; $activity = @socket_select( $read, $write, $except, $this->get_select_timeout() ); if ( false === $activity ) { @@ -172,6 +217,11 @@ public function start(): void { continue; } + // Clients that can accept more of a pending response. + foreach ( $write as $socket ) { + $this->handle_client_writable( $this->get_client_id( $socket ) ); + } + // New client connection. if ( in_array( $this->socket, $read, true ) ) { $this->handle_new_client(); @@ -189,33 +239,59 @@ public function start(): void { } /** - * Close connections that have not logged in before their deadline. + * Close connections past their deadline. + * + * A client must log in within LOGIN_TIMEOUT. A logged-in client must then + * accept more of a pending response within WRITE_TIMEOUT, send more of a + * started request within READ_TIMEOUT, and otherwise send something within + * IDLE_TIMEOUT. */ - private function close_timed_out_logins(): void { + private function close_timed_out_clients(): void { + $now = $this->now(); foreach ( $this->clients as $client_id => $client ) { - if ( $client['session']->is_authenticated() || $client['login_deadline'] > time() ) { + if ( $client['deadline'] > $now ) { continue; } - $this->logger->error( 'Client [{client_id}]: login timed out, closing connection', array( 'client_id' => $client_id ) ); - unset( $this->clients[ $client_id ] ); - @socket_close( $client['socket'] ); + $context = array( 'client_id' => $client_id ); + if ( ! $client['session']->is_authenticated() ) { + $this->logger->error( 'Client [{client_id}]: login timed out, closing connection', $context ); + } elseif ( '' !== $client['output'] ) { + $this->logger->error( 'Client [{client_id}]: response not accepted in time, closing connection', $context ); + } elseif ( $client['session']->has_buffered_data() ) { + $this->logger->error( 'Client [{client_id}]: request not completed in time, closing connection', $context ); + } else { + $this->logger->info( 'Client [{client_id}]: idle for too long, closing connection', $context ); + } + $this->close_client( $client_id ); } } /** * Get the seconds to wait for socket activity. * - * @return int|null The seconds until the next login deadline, or null to wait indefinitely. + * @return int|null The seconds until the next deadline, or null to wait indefinitely. */ private function get_select_timeout(): ?int { - $deadline = null; - foreach ( $this->clients as $client ) { - if ( $client['session']->is_authenticated() ) { - continue; - } - $deadline = null === $deadline ? $client['login_deadline'] : min( $deadline, $client['login_deadline'] ); + if ( array() === $this->clients ) { + return null; } - return null === $deadline ? null : max( 0, $deadline - time() ); + return max( 0, min( array_column( $this->clients, 'deadline' ) ) - $this->now() ); + } + + /** + * Send more of a pending response, then serve requests that wait behind it. + * + * @param int $client_id The numeric ID of the client. + */ + private function handle_client_writable( int $client_id ): void { + if ( ! $this->send_pending_output( $client_id ) ) { + return; + } + $client = $this->clients[ $client_id ] ?? null; + if ( null !== $client && '' === $client['output'] && $client['session']->has_buffered_data() ) { + $this->process_client_input( $client_id, '' ); + } + $this->restart_deadline( $client_id ); } /** @@ -236,27 +312,34 @@ private function handle_new_client(): void { 'Refusing a new client: {count} connections are already open', array( 'count' => count( $this->clients ) ) ); - try { - $this->write_response( $socket, MySQL_Protocol::build_err_packet( 0, 1040, '08004', 'Too many connections' ) ); - } finally { - @socket_close( $socket ); - } + // The send buffer of a new connection is empty, so it takes this short packet whole. + @socket_write( $socket, MySQL_Protocol::build_err_packet( 0, 1040, '08004', 'Too many connections' ) ); + @socket_close( $socket ); return; } + // Never wait for a client, so one that stops reading cannot stall the proxy. + if ( false === @socket_set_nonblock( $socket ) ) { + $error = $this->new_socket_error(); + @socket_close( $socket ); + throw $error; + } + // Create a new session for the client. $client_id = $this->get_client_id( $socket ); $session = new MySQL_Session( $this->adapter, $client_id, $this->authentication, $this->logger ); $this->clients[ $client_id ] = array( - 'socket' => $socket, - 'session' => $session, - 'login_deadline' => time() + self::LOGIN_TIMEOUT, + 'socket' => $socket, + 'session' => $session, + 'deadline' => $this->now() + self::LOGIN_TIMEOUT, + 'output' => '', + 'sent' => 0, ); $this->logger->info( 'Client [{client_id}]: connected', array( 'client_id' => $client_id ) ); // Handle the initial handshake. $this->logger->info( 'Client [{client_id}]: initial handshake', array( 'client_id' => $client_id ) ); - $this->write_response( $socket, $session->get_initial_handshake() ); + $this->send_response( $client_id, $session->get_initial_handshake() ); } /** @@ -267,54 +350,156 @@ private function handle_new_client(): void { private function handle_client_activity( int $client_id ): void { $this->logger->info( 'Client [{client_id}]: reading data from client', array( 'client_id' => $client_id ) ); - // Read data from the client. + // Read data from the client. The socket does not block, so it may have none yet. $socket = $this->clients[ $client_id ]['socket']; - $data = @socket_read( $socket, 4096 ); + $data = @socket_read( $socket, self::READ_CHUNK_SIZE ); + if ( false === $data && SOCKET_EWOULDBLOCK === socket_last_error( $socket ) ) { + return; + } if ( false === $data ) { throw $this->new_socket_error(); } - // Do not dump request bytes: even authenticated clients can send credentials - // in COM_CHANGE_USER, including across multiple reads or buffered packets. - $session = $this->clients[ $client_id ]['session']; - // Handle client disconnection. if ( '' === $data ) { $this->logger->info( 'Client [{client_id}]: disconnected', array( 'client_id' => $client_id ) ); - unset( $this->clients[ $client_id ] ); - @socket_close( $socket ); + $this->close_client( $client_id ); return; } - // Process complete packets, retaining incomplete input for the next read. + $this->process_client_input( $client_id, $data ); + $this->restart_deadline( $client_id ); + } + + /** + * Pass client data to the session and send the responses to complete messages. + * + * Incomplete input stays in the session for the next read. Processing stops + * at a response the client has not accepted, so a client that does not read + * makes the proxy hold one response, not one for each request. + * + * @param int $client_id The numeric ID of the client. + * @param string $data The data received from the client. + */ + private function process_client_input( int $client_id, string $data ): void { + // Do not dump request bytes: even authenticated clients can send credentials + // in COM_CHANGE_USER, including across multiple reads or buffered packets. + $session = $this->clients[ $client_id ]['session']; do { try { $response = $session->receive_bytes( $data ); } catch ( Incomplete_Input_Exception $e ) { - break; + return; } $data = ''; - try { - if ( null !== $response && '' !== $response ) { - // Do not dump response bytes: they carry the data a query returned. - $this->logger->info( - 'Client [{client_id}]: writing {bytes} response bytes', - array( - 'client_id' => $client_id, - 'bytes' => strlen( $response ), - ) - ); - $this->write_response( $socket, $response ); - } - } finally { - if ( $session->is_closed() ) { - $this->log_closed_session( $client_id, $session->get_close_reason() ); - unset( $this->clients[ $client_id ] ); - @socket_close( $socket ); - } + if ( null !== $response && '' !== $response ) { + // Do not dump response bytes: they carry the data a query returned. + $this->logger->info( + 'Client [{client_id}]: writing {bytes} response bytes', + array( + 'client_id' => $client_id, + 'bytes' => strlen( $response ), + ) + ); } - } while ( ! $session->is_closed() && $session->has_buffered_data() ); + $this->send_response( $client_id, (string) $response ); + } while ( + isset( $this->clients[ $client_id ] ) + && '' === $this->clients[ $client_id ]['output'] + && $session->has_buffered_data() + ); + } + + /** + * Queue a response and send as much of it as the client accepts now. + * + * @param int $client_id The numeric ID of the client. + * @param string $response The response packets. + */ + private function send_response( int $client_id, string $response ): void { + $this->clients[ $client_id ]['output'] .= $response; + $this->send_pending_output( $client_id ); + } + + /** + * Send as much of a pending response as the client socket accepts now. + * + * The connection of a closed session is closed once its response is sent. + * + * @param int $client_id The numeric ID of the client. + * @return bool Whether any bytes were sent. + */ + private function send_pending_output( int $client_id ): bool { + $client = $this->clients[ $client_id ]; + $length = strlen( $client['output'] ); + $sent = $client['sent']; + while ( $sent < $length ) { + $written = @socket_write( $client['socket'], substr( $client['output'], $sent, self::WRITE_CHUNK_SIZE ) ); + if ( false === $written && SOCKET_EWOULDBLOCK !== socket_last_error( $client['socket'] ) ) { + $this->logger->error( + 'Client [{client_id}]: {error}, closing connection', + array( + 'client_id' => $client_id, + 'error' => socket_strerror( socket_last_error( $client['socket'] ) ), + ) + ); + $this->close_client( $client_id ); + return false; + } + if ( ! $written ) { + break; + } + $sent += $written; + } + + if ( $sent < $length ) { + $this->clients[ $client_id ]['sent'] = $sent; + } else { + $this->clients[ $client_id ]['output'] = ''; + $this->clients[ $client_id ]['sent'] = 0; + if ( $client['session']->is_closed() ) { + $this->log_closed_session( $client_id, $client['session']->get_close_reason() ); + $this->close_client( $client_id ); + } + } + return $sent > $client['sent']; + } + + /** + * Restart the deadline of a logged-in client that made progress. + * + * Before login, nothing extends the login deadline. + * + * @param int $client_id The numeric ID of the client. + */ + private function restart_deadline( int $client_id ): void { + $client = $this->clients[ $client_id ] ?? null; + if ( null === $client || ! $client['session']->is_authenticated() ) { + return; + } + if ( '' !== $client['output'] ) { + $timeout = self::WRITE_TIMEOUT; + } elseif ( $client['session']->has_buffered_data() ) { + // The rest of a started request is pending. + $timeout = self::READ_TIMEOUT; + } else { + $timeout = self::IDLE_TIMEOUT; + } + $this->clients[ $client_id ]['deadline'] = $this->now() + $timeout; + } + + /** + * Get the current time in seconds for deadlines. + * + * Unlike time(), the monotonic hrtime() does not jump when the system clock + * changes. PHP 7.2 has no monotonic clock, so it uses time(). + * + * @return int The seconds from an arbitrary starting point. + */ + private function now(): int { + $time = function_exists( 'hrtime' ) ? hrtime() : false; + return false === $time ? time() : $time[0]; } /** @@ -337,21 +522,14 @@ private function log_closed_session( int $client_id, string $reason ): void { } /** - * Write the complete response, including any authentication error. + * Forget a client and close its connection, discarding any unsent response. * - * @param Socket|resource $socket The client socket. - * @param string $response The response packets. + * @param int $client_id The numeric ID of the client. */ - private function write_response( $socket, string $response ): void { - $offset = 0; - $length = strlen( $response ); - while ( $offset < $length ) { - $written = @socket_write( $socket, substr( $response, $offset ) ); - if ( false === $written || 0 === $written ) { - throw $this->new_socket_error(); - } - $offset += $written; - } + private function close_client( int $client_id ): void { + $socket = $this->clients[ $client_id ]['socket']; + unset( $this->clients[ $client_id ] ); + @socket_close( $socket ); } /** diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php index f93648513..8db243437 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php @@ -3,67 +3,163 @@ use PHPUnit\Framework\TestCase; use WP_MySQL_Proxy\Adapter\Adapter; use WP_MySQL_Proxy\MySQL_Authentication; +use WP_MySQL_Proxy\MySQL_Protocol; use WP_MySQL_Proxy\MySQL_Proxy; +use WP_MySQL_Proxy\MySQL_Result; use WP_MySQL_Proxy\MySQL_Session; class WP_MySQL_Proxy_Limits_Test extends TestCase { - /** @dataProvider pending_logins */ - public function test_login_deadline( int $deadline_offset, bool $authenticated, bool $expected_open, ?int $expected_timeout ): void { - $proxy = new MySQL_Proxy( - $this->createMock( Adapter::class ), - array( 'authentication' => new MySQL_Authentication( 'user', 'password' ) ) - ); + /** @dataProvider deadlines */ + public function test_deadline( int $deadline_offset, bool $authenticated, string $output, bool $expected_open, int $expected_timeout ): void { + $proxy = $this->proxy(); $clients = $this->property( 'clients' ); $clients->setValue( $proxy, - array( - 1 => array( - 'socket' => $this->socket(), - 'session' => $this->session( $authenticated ), - 'login_deadline' => time() + $deadline_offset, - ), - ) + array( 1 => $this->client( $this->socket(), $this->session( $authenticated ), $this->now( $proxy ) + $deadline_offset, $output ) ) ); $this->assertSame( $expected_timeout, $this->call( $proxy, 'get_select_timeout' ) ); - $this->call( $proxy, 'close_timed_out_logins' ); + $this->call( $proxy, 'close_timed_out_clients' ); $this->assertCount( $expected_open ? 1 : 0, $clients->getValue( $proxy ) ); } - public function pending_logins(): array { + public function deadlines(): array { return array( - 'pending login within the deadline' => array( 5, false, true, 5 ), - 'pending login past the deadline' => array( -1, false, false, 0 ), - 'authenticated past the deadline' => array( -1, true, true, null ), + 'login within the deadline' => array( 5, false, '', true, 5 ), + 'login past the deadline' => array( -1, false, '', false, 0 ), + 'idle within the deadline' => array( 5, true, '', true, 5 ), + 'idle past the deadline' => array( -1, true, '', false, 0 ), + 'response within the write deadline' => array( 5, true, 'pending', true, 5 ), + 'response past the write deadline' => array( -1, true, 'pending', false, 0 ), ); } - /** @dataProvider connection_counts */ - public function test_connection_limit( int $open, bool $expected_accepted ): void { - $port = $this->free_port(); - $proxy = new MySQL_Proxy( - $this->createMock( Adapter::class ), + public function test_select_waits_indefinitely_without_clients(): void { + $proxy = $this->proxy(); + $this->assertNull( $this->call( $proxy, 'get_select_timeout' ) ); + } + + public function test_deadlines_use_a_monotonic_clock(): void { + $proxy = $this->proxy(); + + // The monotonic clock counts from an arbitrary point, such as the system start. + $expected = PHP_VERSION_ID >= 70300 ? hrtime()[0] : time(); + $this->assertEqualsWithDelta( $expected, $this->now( $proxy ), 1 ); + } + + /** @dataProvider client_activity */ + public function test_activity_restarts_the_deadline_after_login( bool $authenticated, string $request, int $expected_deadline ): void { + $proxy = $this->proxy(); + $pair = array(); + socket_create_pair( AF_UNIX, SOCK_STREAM, 0, $pair ); + $this->property( 'clients' )->setValue( + $proxy, + array( 1 => $this->client( $pair[1], $this->session( $authenticated ), $this->now( $proxy ) + 5 ) ) + ); + + socket_write( $pair[0], $request ); + $this->call( $proxy, 'handle_client_activity', 1 ); + + $deadline = $this->property( 'clients' )->getValue( $proxy )[1]['deadline']; + $this->assertEqualsWithDelta( $this->now( $proxy ) + $expected_deadline, $deadline, 2 ); + } + + public function client_activity(): array { + $ping = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ); + return array( + 'logged-in client sends a command' => array( true, $ping, MySQL_Proxy::IDLE_TIMEOUT ), + 'logged-in client sends part of a command' => array( true, "\x40\0\0\0\x03SELECT", MySQL_Proxy::READ_TIMEOUT ), + 'client sends part of its login' => array( false, "\x40\0\0\x01partial", 5 ), + ); + } + + public function test_client_that_stops_reading_does_not_block_the_proxy(): void { + // A result much larger than the socket buffers can hold. + $column = array( + 'name' => 'v', + 'length' => 65536, + 'type' => MySQL_Protocol::FIELD_TYPE_LONG_BLOB, + 'flags' => 0, + 'decimals' => 0, + ); + $rows = array_fill( 0, 128, (object) array( 'v' => str_repeat( 'x', 65536 ) ) ); + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->willReturn( MySQL_Result::from_data( 0, 0, array( $column ), $rows ) ); + $proxy = $this->proxy( $adapter ); + + // Connect a client with a small receive buffer. + $port = $this->listen( $proxy ); + $socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_set_option( $socket, SOL_SOCKET, SO_RCVBUF, 4096 ); + socket_connect( $socket, '127.0.0.1', $port ); + $stream = socket_export_stream( $socket ); + stream_set_timeout( $stream, 2 ); + $this->call( $proxy, 'handle_new_client' ); + $clients = $this->property( 'clients' ); + $client_id = key( $clients->getValue( $proxy ) ); + + // Bound a blocking write, so a regression fails instead of hanging the test. + $connection = $clients->getValue( $proxy )[ $client_id ]['socket']; + socket_set_option( + $connection, + SOL_SOCKET, + SO_SNDTIMEO, array( - 'authentication' => new MySQL_Authentication( 'user', 'password' ), - 'port' => $port, + 'sec' => 1, + 'usec' => 0, ) ); - // Stand in for the listener the proxy creates in start(). - $listener = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); - socket_set_option( $listener, SOL_SOCKET, SO_REUSEADDR, 1 ); - socket_bind( $listener, '127.0.0.1', $port ); - socket_listen( $listener ); - $this->property( 'socket' )->setValue( $proxy, $listener ); + $challenge = MySQL_Test_Client::challenge( MySQL_Test_Client::read_packet( $stream ) ); + fwrite( $stream, MySQL_Test_Client::handshake_response( $challenge ) ); + $this->receive( $proxy, $client_id ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_login_result( $stream )[4] ) ); + + // Send a query and a ping without reading the result. + $ping = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ); + fwrite( $stream, MySQL_Protocol::build_packet( 0, "\x03SELECT v" ) . $ping ); + $start = microtime( true ); + $this->receive( $proxy, $client_id ); + $this->assertLessThan( 1, microtime( true ) - $start, 'The proxy does not wait for the client.' ); - $clients = $this->property( 'clients' ); - $occupant = array( - 'socket' => $this->socket(), - 'session' => $this->session( true ), - 'login_deadline' => time() + 60, + $client = $clients->getValue( $proxy )[ $client_id ]; + $this->assertNotSame( '', $client['output'], 'The rest of the result waits for the client.' ); + $this->assertTrue( $client['session']->has_buffered_data(), 'The ping waits behind the result.' ); + $this->assertEqualsWithDelta( $this->now( $proxy ) + MySQL_Proxy::WRITE_TIMEOUT, $client['deadline'], 2 ); + + // Once the client reads, it gets the whole result and then the ping response. + // The result is a column count, a column, an EOF, the rows, and an EOF. + $packets = $this->read_while_sending( $proxy, $client_id, $stream, 3 + count( $rows ) + 1 + 1 ); + $this->assertSame( "\x01", substr( $packets[0], 4 ) ); + $this->assertSame( 0xfe, ord( $packets[ count( $rows ) + 3 ][4] ) ); + $this->assertSame( "\x07\0\0\x01\0\0\0\x02\0\0\0", end( $packets ) ); + $this->assertEqualsWithDelta( $this->now( $proxy ) + MySQL_Proxy::IDLE_TIMEOUT, $clients->getValue( $proxy )[ $client_id ]['deadline'], 2 ); + } + + public function test_client_that_disconnects_before_accepting_a_response_is_closed(): void { + $proxy = $this->proxy(); + $pair = array(); + socket_create_pair( AF_UNIX, SOCK_STREAM, 0, $pair ); + socket_set_nonblock( $pair[1] ); + $clients = $this->property( 'clients' ); + $clients->setValue( + $proxy, + array( 1 => $this->client( $pair[1], $this->session( true ), $this->now( $proxy ) + 5, str_repeat( 'x', 1048576 ) ) ) ); + socket_close( $pair[0] ); + $this->call( $proxy, 'handle_client_writable', 1 ); + $this->assertSame( array(), $clients->getValue( $proxy ) ); + } + + /** @dataProvider connection_counts */ + public function test_connection_limit( int $open, bool $expected_accepted ): void { + $proxy = $this->proxy(); + $port = $this->listen( $proxy ); + // Key the occupants away from any object ID the accepted socket can get. + $clients = $this->property( 'clients' ); + $occupant = $this->client( $this->socket(), $this->session( true ), $this->now( $proxy ) + 60 ); $occupants = array(); for ( $i = 1; $i <= $open; $i++ ) { $occupants[ PHP_INT_MAX - $i ] = $occupant; @@ -79,7 +175,7 @@ public function test_connection_limit( int $open, bool $expected_accepted ): voi $this->assertSame( 10, ord( $response[4] ), 'The client gets the handshake packet.' ); $this->assertCount( $open + 1, $clients->getValue( $proxy ) ); $accepted = $clients->getValue( $proxy ); - $this->assertGreaterThan( time(), end( $accepted )['login_deadline'] ); + $this->assertGreaterThan( $this->now( $proxy ), end( $accepted )['deadline'] ); } else { $this->assertSame( 0xff, ord( $response[4] ) ); $this->assertSame( 1040, unpack( 'v', substr( $response, 5, 2 ) )[1] ); @@ -89,7 +185,6 @@ public function test_connection_limit( int $open, bool $expected_accepted ): voi } fclose( $client ); - socket_close( $listener ); } public function connection_counts(): array { @@ -99,13 +194,80 @@ public function connection_counts(): array { ); } - private function free_port(): int { - $listener = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $error ); - $port = (int) substr( strrchr( stream_socket_get_name( $listener, false ), ':' ), 1 ); - fclose( $listener ); + private function proxy( ?Adapter $adapter = null ): MySQL_Proxy { + return new MySQL_Proxy( + $adapter ?? $this->createMock( Adapter::class ), + array( 'authentication' => new MySQL_Authentication( 'user', 'password' ) ) + ); + } + + /** + * Stand in for the listener the proxy creates in start(). + * + * @return int The port the listener is bound to. + */ + private function listen( MySQL_Proxy $proxy ): int { + $listener = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_bind( $listener, '127.0.0.1', 0 ); + socket_listen( $listener ); + socket_getsockname( $listener, $address, $port ); + $this->property( 'socket' )->setValue( $proxy, $listener ); return $port; } + /** + * Let the proxy handle data from a client once the data has arrived. + */ + private function receive( MySQL_Proxy $proxy, int $client_id ): void { + $read = array( $this->property( 'clients' )->getValue( $proxy )[ $client_id ]['socket'] ); + $write = null; + $except = null; + $this->assertSame( 1, socket_select( $read, $write, $except, 2 ) ); + $this->call( $proxy, 'handle_client_activity', $client_id ); + } + + /** + * Read packets from a client, letting the proxy send more of its pending response. + */ + private function read_while_sending( MySQL_Proxy $proxy, int $client_id, $stream, int $count ): array { + $packets = array(); + $buffer = ''; + while ( count( $packets ) < $count ) { + if ( '' !== $this->property( 'clients' )->getValue( $proxy )[ $client_id ]['output'] ) { + $this->call( $proxy, 'handle_client_writable', $client_id ); + } + $data = fread( $stream, 1048576 ); + $this->assertNotEmpty( $data, 'The proxy keeps sending.' ); + $buffer .= $data; + while ( strlen( $buffer ) >= 4 ) { + $length = unpack( 'V', substr( $buffer, 0, 3 ) . "\0" )[1]; + if ( strlen( $buffer ) < 4 + $length ) { + break; + } + $packets[] = substr( $buffer, 0, 4 + $length ); + $buffer = (string) substr( $buffer, 4 + $length ); + } + } + return $packets; + } + + /** + * The time on the clock the proxy uses for deadlines. + */ + private function now( MySQL_Proxy $proxy ): int { + return $this->call( $proxy, 'now' ); + } + + private function client( $socket, MySQL_Session $session, int $deadline, string $output = '' ): array { + return array( + 'socket' => $socket, + 'session' => $session, + 'deadline' => $deadline, + 'output' => $output, + 'sent' => 0, + ); + } + private function session( bool $authenticated ): MySQL_Session { $session = new MySQL_Session( $this->createMock( Adapter::class ), 1, new MySQL_Authentication( 'user', 'password' ) ); if ( $authenticated ) { @@ -130,11 +292,11 @@ private function property( string $name ): ReflectionProperty { return $property; } - private function call( MySQL_Proxy $proxy, string $name ) { + private function call( MySQL_Proxy $proxy, string $name, ...$arguments ) { $method = new ReflectionMethod( MySQL_Proxy::class, $name ); if ( PHP_VERSION_ID < 80100 ) { $method->setAccessible( true ); } - return $method->invoke( $proxy ); + return $method->invoke( $proxy, ...$arguments ); } } diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Slow_Client_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Slow_Client_Test.php new file mode 100644 index 000000000..f15998af9 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Slow_Client_Test.php @@ -0,0 +1,44 @@ +port ); + $stalled = socket_export_stream( $socket ); + $this->log_in( $stalled ); + $value = str_repeat( 'x', 65536 ); + $query = "WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 128) SELECT '$value' AS v FROM r"; + fwrite( $stalled, MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . $query ) ); + + // The result has started to arrive, and the client stops reading it. + $this->assertSame( "\x01", substr( MySQL_Test_Client::read_packet( $stalled ), 4 ) ); + + // Another client is served meanwhile. + $other = stream_socket_client( "tcp://127.0.0.1:{$this->port}", $errno, $error, 2 ); + $this->log_in( $other ); + fwrite( $other, MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ) ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_packet( $other )[4] ) ); + fclose( $other ); + + // The first client still gets the whole result once it reads. + MySQL_Test_Client::read_packet( $stalled ); // Column definition. + MySQL_Test_Client::read_packet( $stalled ); // EOF. + for ( $i = 0; $i < 128; $i++ ) { + $this->assertSame( "\xfd\0\0\x01" . $value, substr( MySQL_Test_Client::read_packet( $stalled ), 4 ) ); + } + $this->assertSame( 0xfe, ord( MySQL_Test_Client::read_packet( $stalled )[4] ) ); + fclose( $stalled ); + } + + private function log_in( $stream ): void { + stream_set_timeout( $stream, 2 ); + $challenge = MySQL_Test_Client::challenge( MySQL_Test_Client::read_packet( $stream ) ); + fwrite( $stream, MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Test_Client::auth_plugin() ) ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_login_result( $stream )[4] ) ); + } +}