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/README.md b/packages/mysql-proxy/README.md index a2e514fee..27856a3e8 100644 --- a/packages/mysql-proxy/README.md +++ b/packages/mysql-proxy/README.md @@ -32,11 +32,18 @@ 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 --user [--password-file ] [--bind-address
] [--port ] [--database ] [--log-level ] Options: -h, --help Show this help message and exit. - -p, --port= The port to listen on. Default: 3306 + -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 ``` @@ -45,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 [--port ] [--database ] [--log-level ] +php bin/wp-mysql-proxy.php --user [--password-file ] [--bind-address
] [--port ] [--database ] [--log-level ] ``` ### PHP: @@ -57,7 +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. diff --git a/packages/mysql-proxy/bin/wp-mysql-proxy.php b/packages/mysql-proxy/bin/wp-mysql-proxy.php index a7f3bbf18..3ae3253eb 100644 --- a/packages/mysql-proxy/bin/wp-mysql-proxy.php +++ b/packages/mysql-proxy/bin/wp-mysql-proxy.php @@ -1,50 +1,150 @@ '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 = <<] [--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. - -p, --port= The port to listen on. Default: 3306 + -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'; +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 ) { +$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, @@ -57,8 +157,13 @@ $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), array( - 'port' => $port, - 'log_level' => $log_level, + '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 582c2006d..ee8f3bb45 100644 --- a/packages/mysql-proxy/composer.json +++ b/packages/mysql-proxy/composer.json @@ -17,6 +17,8 @@ }, "require": { "php": ">=7.2", + "ext-filter": "*", + "ext-hash": "*", "ext-sockets": "*", "composer-runtime-api": "^2.2" }, @@ -33,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" 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-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 dc2ba6066..82ca7fac5 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; @@ -18,6 +19,27 @@ * 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; + + /** + * 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. + * + * This matches the default MySQL "max_connections". + */ + const MAX_CONNECTIONS = 151; + /** * The socket to listen on. * @@ -32,6 +54,13 @@ class MySQL_Proxy { */ private $port; + /** + * The IP address to listen on. + * + * @var string + */ + private $bind_address; + /** * The adapter to use to execute queries. * @@ -39,12 +68,19 @@ class MySQL_Proxy { */ private $adapter; + /** + * The configured account verifier. + * + * @var MySQL_Authentication + */ + private $authentication; + /** * A map of connected clients. * * Maps client IDs to their associated socket and session instances. * - * @var array + * @var array */ private $clients = array(); @@ -60,17 +96,39 @@ 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 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 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' * } */ public function __construct( Adapter $adapter, $options = array() ) { - $this->adapter = $adapter; - $this->port = $options['port'] ?? 3306; - $this->logger = new Logger( $options['log_level'] ?? Logger::LEVEL_WARNING ); + $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.' ); + } + + $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 ); } /** @@ -80,7 +138,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 +151,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,16 +160,25 @@ 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' => AF_INET6 === $family ? '[' . $this->bind_address . ']' : $this->bind_address, + 'port' => $this->port, + ) + ); // Start the main proxy loop. while ( true ) { try { - // Wait for activity on any socket. + // 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 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(); } @@ -136,6 +204,40 @@ public function start(): void { } } + /** + * 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_clients(): void { + foreach ( $this->clients as $client_id => $client ) { + if ( $client['deadline'] > time() ) { + continue; + } + $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'] ); + } + } + + /** + * Get the seconds to wait for socket activity. + * + * @return int|null The seconds until the next deadline, or null to wait indefinitely. + */ + private function get_select_timeout(): ?int { + if ( array() === $this->clients ) { + return null; + } + return max( 0, min( array_column( $this->clients, 'deadline' ) ) - time() ); + } + /** * Handle a new MySQL client connection. */ @@ -148,21 +250,33 @@ 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 ); + $session = new MySQL_Session( $this->adapter, $client_id, $this->authentication, $this->logger ); $this->clients[ $client_id ] = array( - 'socket' => $socket, - 'session' => $session, + 'socket' => $socket, + 'session' => $session, + 'deadline' => time() + self::LOGIN_TIMEOUT, ); $this->logger->info( 'Client [{client_id}]: connected', array( 'client_id' => $client_id ) ); // Handle the initial handshake. $this->logger->info( 'Client [{client_id}]: initial handshake', array( 'client_id' => $client_id ) ); - $handshake = $session->get_initial_handshake(); - if ( false === @socket_write( $socket, $handshake ) ) { - throw $this->new_socket_error(); - } + $this->write_response( $socket, $session->get_initial_handshake() ); } /** @@ -180,68 +294,88 @@ 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 ); + } + } finally { + if ( $session->is_closed() ) { + $this->log_closed_session( $client_id, $session->get_close_reason() ); + unset( $this->clients[ $client_id ] ); + @socket_close( $socket ); } - } catch ( Incomplete_Input_Exception $e ) { - break; } + } 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; + } + } + + /** + * 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. + * + * @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; } } @@ -253,7 +387,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 ); } @@ -270,25 +404,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-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/src/class-mysql-session.php b/packages/mysql-proxy/src/class-mysql-session.php index b370e0bbe..5084df53a 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -9,6 +9,40 @@ * 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'; + const CLOSE_PACKET_TOO_LARGE = 'packet too large'; + const CLOSE_MALFORMED_PACKET = 'malformed packet'; + + /** + * 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 + * network buffer. Anything larger is a malformed or hostile login. + */ + const MAX_UNAUTHENTICATED_PACKET_SIZE = 16384; + /** * Client capabilities that are supported by the server. */ @@ -77,6 +111,34 @@ 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; + + /** + * Why the connection must be closed, or null while it stays open. + * + * @var string|null + */ + private $close_reason; + + /** + * Logger instance. + * + * @var Logger + */ + private $logger; + /** * Packet sequence ID. * @@ -94,17 +156,47 @@ 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. + * @param Logger|null $logger The logger for internal errors. Default: a new logger. */ - public function __construct( Adapter $adapter, int $connection_id ) { - $this->adapter = $adapter; - $this->connection_id = $connection_id; - $this->auth_plugin_data = ''; - $this->packet_id = 0; + 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. + $this->auth_plugin_data = str_replace( "\0", "\1", random_bytes( 20 ) ); + } - // Generate random auth plugin data (20-byte salt) - $this->auth_plugin_data = 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 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; } /** @@ -152,39 +244,22 @@ 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; - // 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; - - // 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. * @@ -193,16 +268,28 @@ 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 ); } - // 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: + // 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( 'USE ' . 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: @@ -217,6 +304,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. * @@ -224,93 +387,169 @@ 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 ) { + $error = $this->use_database( $database ); + if ( null !== $error ) { + $this->close( self::CLOSE_LOGIN_FAILED ); + return $error; } - $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. + $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->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; + } + + /** + * 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|null The error packet, or null when the database is selected. + */ + 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++, - 0x04D2, - 'HY000', - 'Unsupported authentication plugin: ' . $auth_plugin_name + 1049, + '42000', + sprintf( "Unknown database: '%s'", $database ) ); } @@ -350,15 +589,28 @@ 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() - ); + $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_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..b12e479ff --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php @@ -0,0 +1,198 @@ +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() ); + } + + /** @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(); + $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_Limits_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php new file mode 100644 index 000000000..a42a72534 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php @@ -0,0 +1,194 @@ +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 ), + 'deadline' => time() + $deadline_offset, + ), + ) + ); + + $this->assertSame( $expected_timeout, $this->call( $proxy, 'get_select_timeout' ) ); + $this->call( $proxy, 'close_timed_out_clients' ); + $this->assertCount( $expected_open ? 1 : 0, $clients->getValue( $proxy ) ); + } + + public function deadlines(): array { + return array( + '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 ), + ); + } + + /** @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 ), + '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 )['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, ...$arguments ) { + $method = new ReflectionMethod( MySQL_Proxy::class, $name ); + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( $proxy, ...$arguments ); + } +} 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..b8ca66044 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php @@ -0,0 +1,58 @@ +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( $expected, $property->getValue( $proxy ) ); + } + + public function bind_addresses(): 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, + 'username' => 'user', + 'password' => 'password', + ) + ); + } + + 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() ); + } +} 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..59bb7cc7d --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php @@ -0,0 +1,386 @@ +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() ); + } + + 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 ); + $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/WP_MySQL_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php new file mode 100644 index 000000000..e648ac29f --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -0,0 +1,174 @@ +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`' ), + ); + } + + /** @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(); + $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_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() ); + $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' ); + $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' ); + $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() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); + $this->assertTrue( $session->is_authenticated() ); + return $session; + } +} 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();