From c384aec3fbc56045c5df809b7f86006a2ed520ac 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 af643ee3be7b9b969fff527f4d948d84016e6e66 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. 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 | 21 +- packages/mysql-proxy/bin/wp-mysql-proxy.php | 118 +++++- 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 | 163 ++++---- .../mysql-proxy/src/class-mysql-session.php | 262 +++++++++---- .../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 | 192 ++++++++++ .../tests/WP_MySQL_Proxy_Options_Test.php | 38 +- .../mysql-proxy/tests/WP_MySQL_Proxy_Test.php | 17 +- .../WP_MySQL_Session_Authentication_Test.php | 360 ++++++++++++++++++ .../mysql-proxy/tests/bootstrap/bootstrap.php | 1 + .../tests/bootstrap/mysql-server-process.php | 12 +- .../tests/bootstrap/mysql-test-client.php | 91 +++++ .../tests/bootstrap/run-server.php | 13 +- 19 files changed, 1571 insertions(+), 243 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..c64d13207 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
] [--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,7 +52,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 [--bind-address
] [--port ] [--database ] [--log-level ] +php bin/wp-mysql-proxy.php --user [--password-file ] [--bind-address
] [--port ] [--database ] [--log-level ] ``` ### PHP: @@ -58,11 +64,16 @@ require_once __DIR__ . '/vendor/autoload.php'; $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), - array( 'port' => $port, 'log_level' => $log_level ) + array( + 'username' => 'proxy', + 'password' => getenv( 'WP_MYSQL_PROXY_PASSWORD' ), + 'port' => $port, + 'log_level' => $log_level, + ) ); $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. +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..2988c2112 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
] [--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 and credentials too, but only after that. // Bind address. $bind_address = $opts['bind-address'] ?? '127.0.0.1'; @@ -40,19 +101,50 @@ } // 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 ); } +// Credentials. Do not accept passwords as command-line arguments. +$username = $opts['user'] ?? ''; +if ( '' === $username || false !== strpos( $username, "\0" ) ) { + fwrite( STDERR, "Error: --user must specify a nonempty username without NUL bytes.\n" ); + exit( 1 ); +} + +// Authentication plugin. +$auth_plugin = $opts['auth-plugin'] ?? MySQL_Protocol::DEFAULT_AUTH_PLUGIN; +if ( ! in_array( $auth_plugin, MySQL_Authentication::SUPPORTED_PLUGINS, true ) ) { + fwrite( + STDERR, + 'Error: --auth-plugin must be one of: ' . implode( ', ', MySQL_Authentication::SUPPORTED_PLUGINS ) . ".\n" + ); + exit( 1 ); +} + +$password = getenv( 'WP_MYSQL_PROXY_PASSWORD' ); +if ( isset( $opts['password-file'] ) ) { + $password_file = $opts['password-file']; + if ( ! is_file( $password_file ) || ! is_readable( $password_file ) ) { + fwrite( STDERR, "Error: --password-file must name a readable file.\n" ); + exit( 1 ); + } + $password = file_get_contents( $password_file ); +} +if ( false === $password || '' === $password || false !== strpos( $password, "\0" ) ) { + fwrite( STDERR, "Error: Set a nonempty password without NUL bytes using WP_MYSQL_PROXY_PASSWORD or --password-file.\n" ); + exit( 1 ); +} + if ( ! class_exists( 'WP_MySQL_On_SQLite' ) ) { fwrite( STDERR, @@ -65,9 +157,13 @@ $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), array( + 'username' => $username, + 'password' => $password, + 'auth_plugin' => $auth_plugin, 'bind_address' => $bind_address, 'port' => $port, 'log_level' => $log_level, ) ); +unset( $password ); $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..c046bce03 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,17 @@ 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. Username and password are required. * + * @type string $username The nonempty proxy account name. Required. + * @type string $password The nonempty proxy password. Required. + * @type string $auth_plugin The account's authentication plugin, one of + * MySQL_Authentication::SUPPORTED_PLUGINS. + * Default: caching_sha2_password * @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 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 +94,20 @@ 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 ); + $username = $options['username'] ?? ''; + $password = $options['password'] ?? ''; + if ( ! is_string( $username ) || ! is_string( $password ) ) { + throw new InvalidArgumentException( 'The username and password must be strings.' ); + } + $auth_plugin = $options['auth_plugin'] ?? MySQL_Protocol::DEFAULT_AUTH_PLUGIN; + if ( ! is_string( $auth_plugin ) ) { + throw new InvalidArgumentException( 'The authentication plugin must be a plugin name.' ); + } + + $this->authentication = new MySQL_Authentication( $username, $password, $auth_plugin ); + $this->adapter = $adapter; + $this->port = $options['port'] ?? 3306; + $this->logger = new Logger( $options['log_level'] ?? Logger::LEVEL_WARNING ); } /** @@ -119,7 +142,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 +194,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 +203,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 +221,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 +290,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 +307,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..522e3567d 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,36 @@ 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 ) { - $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 __construct( Adapter $adapter, int $connection_id, MySQL_Authentication $authentication ) { + $this->authentication = $authentication; + $this->adapter = $adapter; + $this->connection_id = $connection_id; + $this->packet_id = 0; + + // Avoid NUL bytes because some clients treat the challenge as a C string. + $this->auth_plugin_data = str_replace( "\0", "\1", random_bytes( 20 ) ); + } + + /** + * Whether the connection has completed authentication. + * + * @return bool + */ + 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 +192,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 +237,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 +272,144 @@ 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 ) { + $plugin = 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; } } - // 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..bc903f139 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php @@ -0,0 +1,192 @@ +expectException( InvalidArgumentException::class ); + new MySQL_Proxy( $this->createMock( Adapter::class ), $options ); + } + + public function missing_credentials(): array { + return array( + array( array() ), + array( array( 'username' => 'user' ) ), + array( array( 'password' => 'password' ) ), + array( + array( + 'username' => 'user', + 'password' => '', + ), + ), + array( + array( + 'username' => 'user', + 'password' => false, + ), + ), + array( + array( + 'username' => 1, + 'password' => 'password', + ), + ), + array( + array( + 'username' => 'user', + 'password' => 'password', + 'auth_plugin' => array( 'caching_sha2_password' ), + ), + ), + array( + array( + 'username' => 'user', + 'password' => 'password', + 'auth_plugin' => 'mysql_clear_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', '--user must specify' ), + array( array( '--user=user' ), '', 'Set a nonempty password' ), + 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', '--auth-plugin must be one of' ), + array( array( '--user=user', '--auth-plugin=' ), 'password', '--auth-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( bool $use_file, string $option_style ): void { + $listener = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $error ); + $address = stream_socket_get_name( $listener, false ); + $port = (int) substr( strrchr( $address, ':' ), 1 ); + fclose( $listener ); + 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(); + if ( $use_file ) { + $file = tempnam( sys_get_temp_dir(), 'proxy-password-' ); + chmod( $file, 0600 ); + file_put_contents( $file, "file-password\n" ); + if ( 'long-equals' === $option_style || 'short-attached' === $option_style ) { + $args[] = '--password-file=' . $file; + } else { + $args[] = '--password-file'; + $args[] = $file; + } + } + $process = $this->cli( $args, 'environment-password' ); + $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() ); + $password = $use_file ? "file-password\n" : 'environment-password'; + $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 ( $use_file ) { + unlink( $file ); + } + } + } + + public function password_sources(): array { + return array( + array( false, 'long-equals' ), + array( false, 'long-separate' ), + array( true, 'short-attached' ), + array( true, 'short-separate' ), + ); + } + + /** @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( 'Set a nonempty password', $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..b8ca66044 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php @@ -6,33 +6,43 @@ 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( + 'username' => 'user', + 'password' => '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, + 'username' => 'user', + 'password' => '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..f719bb4f9 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php @@ -4,16 +4,27 @@ 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. + $listener = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $error ); + $address = stream_socket_get_name( $listener, false ); + $this->port = (int) substr( strrchr( $address, ':' ), 1 ); + fclose( $listener ); + $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..2ea59d137 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php @@ -0,0 +1,360 @@ +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 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_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_truncated_handshake_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' ) ); + for ( $length = 0; $length < $packet_length - 4; $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_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( $header . "\x01\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..008938cf7 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; 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..142c4cd01 100644 --- a/packages/mysql-proxy/tests/bootstrap/run-server.php +++ b/packages/mysql-proxy/tests/bootstrap/run-server.php @@ -6,12 +6,19 @@ require_once __DIR__ . '/../../vendor/autoload.php'; // Configuration. -$port = (int) ( $_ENV['PORT'] ?? 3306 ); -$db_path = $_ENV['DB_PATH'] ?? ':memory:'; +$port = (int) ( getenv( 'PORT' ) ); +$db_path = getenv( 'DB_PATH' ); // Server. $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), - array( 'port' => $port ) + array( + 'port' => $port, + 'username' => 'user', + 'password' => 'password', + 'auth_plugin' => getenv( 'AUTH_PLUGIN' ), + 'log_level' => getenv( 'LOG_LEVEL' ), + 'bind_address' => getenv( 'BIND_ADDRESS' ), + ) ); $proxy->start(); From 9453b2728cd3229a70e7d95cbae4cd4ac117551d 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 763831d7e0050a5da5a76c206dfc242e0b7c3245 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 | 75 +++++++++++++++++++ .../WP_MySQL_Session_Authentication_Test.php | 26 +++++++ 4 files changed, 161 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 c046bce03..f8af16c87 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(); @@ -150,11 +157,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(); } @@ -180,6 +190,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. */ @@ -196,8 +236,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 522e3567d..f69cdfef9 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. */ @@ -209,6 +218,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..cc0e8b8d8 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php @@ -0,0 +1,75 @@ +createMock( Adapter::class ), + array( + 'username' => 'user', + 'password' => '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 2ea59d137..59bb7cc7d 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php @@ -294,6 +294,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 4383a478645f67cb62ba5052c10e2b7b3557b709 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 f69cdfef9..03f7dbff0 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -264,7 +264,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: @@ -385,7 +385,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; @@ -426,6 +426,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 bf7351db1450b3290e8d7c69c0143be2228ddf6f 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 c64d13207..27856a3e8 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 2988c2112..3ae3253eb 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 bc903f139..b12e479ff 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php @@ -90,13 +90,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(); @@ -118,10 +124,10 @@ public function test_cli_authenticates_with_configured_password( bool $use_file, $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(); if ( $use_file ) { From 3510d875392505e0ad93cf230901980c950fd35a 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 | 43 +++++++++++++++---- .../tests/WP_MySQL_Session_Command_Test.php | 22 ++++++++++ 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index f8af16c87..9b3fe2015 100644 --- a/packages/mysql-proxy/src/class-mysql-proxy.php +++ b/packages/mysql-proxy/src/class-mysql-proxy.php @@ -297,7 +297,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 ); } @@ -305,6 +305,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 03f7dbff0..0c1d2fc25 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. @@ -153,7 +159,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; } /** @@ -201,7 +216,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; } @@ -262,6 +277,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 ) ) ); @@ -391,8 +408,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, @@ -420,10 +436,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 a9a4ba6b2436050984553a3fb5a053cdf7894777 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 | 138 +++++++++++++----- .../tests/WP_MySQL_Session_Command_Test.php | 58 ++++++++ 2 files changed, 161 insertions(+), 35 deletions(-) diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index 0c1d2fc25..6188bab1d 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -12,11 +12,29 @@ 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. 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 @@ -223,41 +241,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. * @@ -296,6 +288,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..d0464d2bd 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 { + ini_set( '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 ); + + // The limit allows one more byte, which is MySQL's own 16 MB boundary. + $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 { + ini_set( '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 b08ea746f8497eb46479b91bd44c28b8703a6e11 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 6188bab1d..0fdbba721 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. @@ -265,7 +266,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 d0464d2bd..41d990801 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 9363ccbff0b526d339d7e7632eebb64647248d5b 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 41d990801..68a0bbb05 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 8513a92fb205ca504b96d32603dc3b08b01214b1 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. --- .../mysql-proxy/src/class-mysql-proxy.php | 2 +- .../mysql-proxy/src/class-mysql-session.php | 26 ++++++++++++++----- .../tests/WP_MySQL_Session_Command_Test.php | 16 ++++++++++++ 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index 9b3fe2015..1f4e8f2a1 100644 --- a/packages/mysql-proxy/src/class-mysql-proxy.php +++ b/packages/mysql-proxy/src/class-mysql-proxy.php @@ -234,7 +234,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 0fdbba721..ef2f2b75b 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -132,6 +132,13 @@ class MySQL_Session { */ private $close_reason; + /** + * Logger instance. + * + * @var Logger + */ + private $logger; + /** * Packet sequence ID. * @@ -151,12 +158,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; // Avoid NUL bytes because some clients treat the challenge as a C string. @@ -571,12 +580,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_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php index 68a0bbb05..c2cfe3044 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 0e748aaafb9ac8833c9884bd6a8145730401c872 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 | 144 ++++++++++++++++++ .../WP_MySQL_Proxy_Login_Timeout_Test.php | 75 --------- 3 files changed, 165 insertions(+), 75 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 1f4e8f2a1..896beb041 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. * @@ -232,6 +239,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..d05f2aa48 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php @@ -0,0 +1,144 @@ +createMock( Adapter::class ), + array( + 'username' => 'user', + 'password' => '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( + 'username' => 'user', + 'password' => '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 cc0e8b8d8..000000000 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Login_Timeout_Test.php +++ /dev/null @@ -1,75 +0,0 @@ -createMock( Adapter::class ), - array( - 'username' => 'user', - 'password' => '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 2fb6e2f0f40a87e60e8908d7beadf8500d130996 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 | 71 ++++++++++++------- .../tests/WP_MySQL_Session_Command_Test.php | 24 +++++++ 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/packages/mysql-proxy/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index ef2f2b75b..5084df53a 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -288,7 +288,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: @@ -484,20 +485,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; } } @@ -535,13 +526,31 @@ 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. * * @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 ) . '`' ); + $failed = (bool) $result->error_info; + } catch ( Throwable $e ) { + $this->log_internal_error( $e ); + $failed = true; + } + if ( ! $failed ) { + return null; + } + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 1049, + '42000', + sprintf( "Unknown database: '%s'", $database ) + ); } /** @@ -580,18 +589,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 c2cfe3044..e648ac29f 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -26,6 +26,30 @@ 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' ) ); + } + $session = $this->authenticated_session( $adapter ); + $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 { ini_set( 'memory_limit', '512M' ); $queries = array(); From dd6566370ac876cc0c82b6363005d7082b6999b2 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 | 54 +++++++----- .../tests/WP_MySQL_Proxy_Limits_Test.php | 82 +++++++++++++++---- 2 files changed, 101 insertions(+), 35 deletions(-) diff --git a/packages/mysql-proxy/src/class-mysql-proxy.php b/packages/mysql-proxy/src/class-mysql-proxy.php index 896beb041..82ca7fac5 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; + /** + * Seconds a logged-in client may stay connected without sending anything. + * + * This matches the default MySQL "wait_timeout". + */ + const IDLE_TIMEOUT = 28800; + /** * Number of client connections served at once. * @@ -73,7 +80,7 @@ class MySQL_Proxy { * * Maps client IDs to their associated socket and session instances. * - * @var array + * @var array */ private $clients = array(); @@ -164,10 +171,10 @@ 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(); + // Drop clients that never completed a login or stayed idle too long. + $this->close_timed_out_clients(); - // Wait for activity on any socket, waking up for the next login deadline. + // Wait for activity on any socket, waking up for the next deadline. $read = array_merge( array( $this->socket ), array_column( $this->clients, 'socket' ) ); $write = null; $except = null; @@ -198,14 +205,22 @@ 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, and a logged-in client must + * send something within IDLE_TIMEOUT of its last request. */ - private function close_timed_out_logins(): void { + private function close_timed_out_clients(): void { foreach ( $this->clients as $client_id => $client ) { - if ( $client['session']->is_authenticated() || $client['login_deadline'] > time() ) { + if ( $client['deadline'] > time() ) { continue; } - $this->logger->error( 'Client [{client_id}]: login timed out, closing connection', array( 'client_id' => $client_id ) ); + $context = array( 'client_id' => $client_id ); + if ( $client['session']->is_authenticated() ) { + $this->logger->info( 'Client [{client_id}]: idle for too long, closing connection', $context ); + } else { + $this->logger->error( 'Client [{client_id}]: login timed out, closing connection', $context ); + } unset( $this->clients[ $client_id ] ); @socket_close( $client['socket'] ); } @@ -214,17 +229,13 @@ private function close_timed_out_logins(): void { /** * 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' ) ) - time() ); } /** @@ -257,9 +268,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->logger ); $this->clients[ $client_id ] = array( - 'socket' => $socket, - 'session' => $session, - 'login_deadline' => time() + self::LOGIN_TIMEOUT, + 'socket' => $socket, + 'session' => $session, + 'deadline' => time() + self::LOGIN_TIMEOUT, ); $this->logger->info( 'Client [{client_id}]: connected', array( 'client_id' => $client_id ) ); @@ -324,6 +335,11 @@ private function handle_client_activity( int $client_id ): void { } } } while ( ! $session->is_closed() && $session->has_buffered_data() ); + + // Restart the idle clock. Before login, data must not extend the login deadline. + if ( isset( $this->clients[ $client_id ] ) && $session->is_authenticated() ) { + $this->clients[ $client_id ]['deadline'] = time() + self::IDLE_TIMEOUT; + } } /** 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 d05f2aa48..a42a72534 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php @@ -3,12 +3,13 @@ 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_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 { + /** @dataProvider deadlines */ + public function test_deadline( int $deadline_offset, bool $authenticated, bool $expected_open, int $expected_timeout ): void { $proxy = new MySQL_Proxy( $this->createMock( Adapter::class ), array( @@ -21,23 +22,72 @@ public function test_login_deadline( int $deadline_offset, bool $authenticated, $proxy, array( 1 => array( - 'socket' => $this->socket(), - 'session' => $this->session( $authenticated ), - 'login_deadline' => time() + $deadline_offset, + 'socket' => $this->socket(), + 'session' => $this->session( $authenticated ), + 'deadline' => time() + $deadline_offset, ), ) ); $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 ), + ); + } + + public function test_select_waits_indefinitely_without_clients(): void { + $proxy = new MySQL_Proxy( + $this->createMock( Adapter::class ), + array( + 'username' => 'user', + 'password' => 'password', + ) + ); + $this->assertNull( $this->call( $proxy, 'get_select_timeout' ) ); + } + + /** @dataProvider client_activity */ + public function test_activity_restarts_only_the_idle_clock( bool $authenticated, string $request, int $expected_deadline ): void { + $proxy = new MySQL_Proxy( + $this->createMock( Adapter::class ), + array( + 'username' => 'user', + 'password' => 'password', + ) + ); + $pair = array(); + socket_create_pair( AF_UNIX, SOCK_STREAM, 0, $pair ); + $this->property( 'clients' )->setValue( + $proxy, + array( + 1 => array( + 'socket' => $pair[1], + 'session' => $this->session( $authenticated ), + 'deadline' => time() + 5, + ), + ) + ); + + socket_write( $pair[0], $request ); + $this->call( $proxy, 'handle_client_activity', 1 ); + + $deadline = $this->property( 'clients' )->getValue( $proxy )[1]['deadline']; + $this->assertEqualsWithDelta( time() + $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 ), + 'client sends part of its login' => array( false, "\x40\0\0\x01partial", 5 ), ); } @@ -62,9 +112,9 @@ public function test_connection_limit( int $open, bool $expected_accepted ): voi $clients = $this->property( 'clients' ); $occupant = array( - 'socket' => $this->socket(), - 'session' => $this->session( true ), - 'login_deadline' => time() + 60, + 'socket' => $this->socket(), + 'session' => $this->session( true ), + 'deadline' => time() + 60, ); // Key the occupants away from any object ID the accepted socket can get. @@ -83,7 +133,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( time(), end( $accepted )['deadline'] ); } else { $this->assertSame( 0xff, ord( $response[4] ) ); $this->assertSame( 1040, unpack( 'v', substr( $response, 5, 2 ) )[1] ); @@ -134,11 +184,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 ); } }