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..169d7db70 100644 --- a/packages/mysql-proxy/README.md +++ b/packages/mysql-proxy/README.md @@ -32,24 +32,36 @@ 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
] [--auth-plugin ] [--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. +Connections are not encrypted (no TLS). For remote clients, use an SSH tunnel. ``` 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
] [--auth-plugin ] [--port ] [--database ] [--log-level ] ``` ### PHP: ```php +use WP_MySQL_Proxy\MySQL_Authentication; use WP_MySQL_Proxy\MySQL_Proxy; use WP_MySQL_Proxy\Adapter\SQLite_Adapter; @@ -57,7 +69,23 @@ require_once __DIR__ . '/vendor/autoload.php'; $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), - array( 'port' => $port, 'log_level' => $log_level ) + array( + 'authentication' => new MySQL_Authentication( 'proxy', getenv( 'WP_MYSQL_PROXY_PASSWORD' ) ), + 'port' => $port, + 'log_level' => $log_level, + ) ); $proxy->start(); ``` + +The account uses `caching_sha2_password` by default, which PDO and MySQLi on PHP +below 7.4.4 can't use. For those clients, set `--auth-plugin=mysql_native_password`, +or pass `mysql_native_password` as the third `MySQL_Authentication` argument. + +The proxy listens on `127.0.0.1` by default. Set `--bind-address` (or the PHP +`bind_address` option) to an IPv4 or IPv6 address to change the listener. Binding +to `0.0.0.0` or `::` explicitly exposes the proxy on all interfaces. + +The proxy doesn't support TLS. Queries, results, and logins travel unencrypted, +and anyone who records a login can try to guess the password offline. For remote +clients, keep the default address and connect through an SSH tunnel. diff --git a/packages/mysql-proxy/bin/wp-mysql-proxy.php b/packages/mysql-proxy/bin/wp-mysql-proxy.php index a7f3bbf18..c908ffac1 100644 --- a/packages/mysql-proxy/bin/wp-mysql-proxy.php +++ b/packages/mysql-proxy/bin/wp-mysql-proxy.php @@ -1,50 +1,163 @@ '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; + } + // A separate value can't look like an option, so a missing value isn't + // filled with the next option. Use --option=-value for such a value. + if ( null === $value && isset( $args[ $i + 1 ] ) && 0 !== strpos( $args[ $i + 1 ], '-' ) ) { + $value = $args[ ++$i ]; + } + 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
] [--auth-plugin ] [--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. +Connections are not encrypted (no TLS). For remote clients, use an SSH tunnel. + USAGE; // Help. -if ( isset( $opts['h'] ) || isset( $opts['help'] ) ) { +if ( isset( $opts['help'] ) ) { fwrite( STDERR, $help ); exit( 0 ); } // Database path. -$db_path = $opts['d'] ?? $opts['database'] ?? ':memory:'; +$db_path = $opts['database'] ?? ':memory:'; + +// Validate options before SQLite_Adapter opens or creates the database file. +// MySQL_Proxy checks the bind address too, but only after that. + +// Bind address. +$bind_address = $opts['bind-address'] ?? '127.0.0.1'; +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 ); } +// Account. Do not accept passwords as command-line arguments. +$password = getenv( 'WP_MYSQL_PROXY_PASSWORD' ); +if ( isset( $opts['password-file'] ) ) { + $password_file = $opts['password-file']; + + // Pipes, such as /dev/stdin or a process substitution, keep the password off the disk. + if ( ! is_readable( $password_file ) || is_dir( $password_file ) ) { + fwrite( STDERR, "Error: --password-file must name a readable file.\n" ); + exit( 1 ); + } + + // On Linux, PHP can't open a pipe by its /dev/fd path, so read a descriptor of + // this process through php://fd. See https://github.com/php/php-src/issues/9551. + $source = $password_file; + if ( '/dev/stdin' === $password_file ) { + $source = 'php://fd/0'; + } elseif ( preg_match( '~^/(?:dev|proc/self)/fd/(\d+)$~', $password_file, $match ) ) { + $source = 'php://fd/' . $match[1]; + } + + // Report a failed read without PHP's warning, which names the path. + $password = @file_get_contents( $source ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( false === $password ) { + fwrite( STDERR, "Error: --password-file could not be read.\n" ); + exit( 1 ); + } +} +try { + $authentication = new MySQL_Authentication( + $opts['user'] ?? '', + (string) $password, + $opts['auth-plugin'] ?? MySQL_Protocol::DEFAULT_AUTH_PLUGIN + ); +} catch ( InvalidArgumentException $e ) { + fwrite( STDERR, 'Error: ' . $e->getMessage() . " Use --help for more information.\n" ); + exit( 1 ); +} +unset( $password ); + if ( ! class_exists( 'WP_MySQL_On_SQLite' ) ) { fwrite( STDERR, @@ -57,8 +170,10 @@ $proxy = new MySQL_Proxy( new SQLite_Adapter( $db_path ), array( - 'port' => $port, - 'log_level' => $log_level, + 'authentication' => $authentication, + 'bind_address' => $bind_address, + 'port' => $port, + 'log_level' => $log_level, ) ); $proxy->start(); diff --git a/packages/mysql-proxy/composer.json b/packages/mysql-proxy/composer.json index 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..9fae58e8b 100644 --- a/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php +++ b/packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php @@ -2,10 +2,10 @@ namespace WP_MySQL_Proxy\Adapter; +use Error; use PDO; use PDOException; use PDOStatement; -use Throwable; use WP_MySQL_Proxy\MySQL_Result; use WP_MySQL_On_SQLite; use WP_MySQL_Proxy\MySQL_Protocol; @@ -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(); @@ -39,12 +39,13 @@ public function handle_query( string $query ): MySQL_Result { $columns = $this->computeColumnInfo( $statement ); } return MySQL_Result::from_data( $affected_rows, $last_insert_id, $columns, $rows ?? array() ); - } catch ( Throwable $e ) { - $error_info = $e->errorInfo ?? null; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - if ( $e instanceof PDOException && $error_info ) { - return MySQL_Result::from_error( $error_info[0], $error_info[1], $error_info[2] ); + } catch ( PDOException $e ) { + // Only query errors reach the client. The session logs internal errors instead. + $error_info = $e->errorInfo; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + if ( ! $error_info || $this->is_internal_error( $e ) ) { + throw $e; } - return MySQL_Result::from_error( 'HY000', 1105, $e->getMessage() ?? 'Unknown error' ); + return MySQL_Result::from_error( $error_info[0], $error_info[1], $error_info[2] ); } } @@ -101,4 +102,22 @@ public function computeColumnInfo( PDOStatement $statement ) { } return $columns; } + + /** + * Whether a query exception is a PHP error that the driver reported as a query error. + * + * A PHP error is a bug, not a query error, and its message can contain server + * file paths. + * + * @param PDOException $e The query exception. + * @return bool + */ + private function is_internal_error( PDOException $e ): bool { + for ( $cause = $e->getPrevious(); null !== $cause; $cause = $cause->getPrevious() ) { + if ( $cause instanceof Error ) { + return true; + } + } + return false; + } } diff --git a/packages/mysql-proxy/src/class-mysql-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..f44a5a7ea 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; @@ -303,18 +306,21 @@ public static function build_ok_packet_as_eof( * * @see https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_err_packet.html * - * @param int $sequence_id The sequence ID of the packet. - * @param int $error_code The error code. - * @param string $sql_state The SQLSTATE value. - * @param string $message The error message. - * @return string The ERR packet. + * @param int $sequence_id The sequence ID of the packet. + * @param int $error_code The error code. + * @param string|null $sql_state The SQLSTATE value, or null to omit it, as MySQL does + * before the client has sent its capabilities. + * @param string $message The error message. + * @return string The ERR packet. */ public static function build_err_packet( int $sequence_id, int $error_code, - string $sql_state, + ?string $sql_state, string $message ): string { + $state = null === $sql_state ? '' : '#' . strtoupper( $sql_state ); + /** * Assemble the ERR packet payload. * @@ -328,10 +334,10 @@ public static function build_err_packet( */ $payload = pack( 'Cva*a*', - self::ERR_PACKET_HEADER, // (C) ERR packet header. - $error_code, // (v) Error code. - '#' . strtoupper( $sql_state ), // (a*) SQL state. - $message // (a*) Message. + self::ERR_PACKET_HEADER, // (C) ERR packet header. + $error_code, // (v) Error code. + $state, // (a*) SQL state marker and SQL state, if any. + $message // (a*) Message. ); return self::build_packet( $sequence_id, $payload ); } @@ -385,6 +391,8 @@ public static function build_eof_packet( * @param string $auth_plugin_data The authentication plugin data (scramble). * @param int $capabilities The capabilities that are supported by the server. * @param int $status_flags The status flags representing the server state. + * @param string $auth_plugin The authentication plugin the client should use. + * Default: DEFAULT_AUTH_PLUGIN. * @return string The handshake packet. */ public static function build_handshake_packet( @@ -394,7 +402,8 @@ public static function build_handshake_packet( int $connection_id, string $auth_plugin_data, int $capabilities, - int $status_flags + int $status_flags, + string $auth_plugin = self::DEFAULT_AUTH_PLUGIN ): string { $cap_flags_lower = $capabilities & 0xffff; $cap_flags_upper = $capabilities >> 16; @@ -403,7 +412,7 @@ public static function build_handshake_packet( if ( $capabilities & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { $auth_plugin_data_length = strlen( $auth_plugin_data ) + 1; - $auth_plugin_name = self::DEFAULT_AUTH_PLUGIN . "\0"; + $auth_plugin_name = $auth_plugin . "\0"; } else { $auth_plugin_data_length = 0; $auth_plugin_name = ''; @@ -569,49 +578,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 +652,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..956ccc485 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,56 @@ * execute MySQL queries and return their results. */ class MySQL_Proxy { + /** + * Seconds a client may take to log in, and again to answer an authentication switch. + * + * 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; + + /** + * Seconds a logged-in client may take to send more of a request it has started. + * + * This matches the default MySQL "net_read_timeout". + */ + const READ_TIMEOUT = 30; + + /** + * Seconds a client may take to accept more of a pending response. + * + * This matches the default MySQL "net_write_timeout". + */ + const WRITE_TIMEOUT = 60; + + /** + * Largest number of bytes read from a client at once. + * + * Large reads keep the passes through the proxy loop few for a large message. + */ + const READ_CHUNK_SIZE = 65536; + + /** + * Largest number of bytes passed to a single socket write. + * + * Writing a pending response in chunks avoids copying the rest of it on + * every write. + */ + const WRITE_CHUNK_SIZE = 65536; + + /** + * Number of client connections served at once. + * + * This matches the default MySQL "max_connections". + */ + const MAX_CONNECTIONS = 151; + /** * The socket to listen on. * @@ -32,6 +83,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 +97,20 @@ 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. + * Maps client IDs to their socket, session, deadline, and the response that + * the client has not accepted yet, with the number of its bytes already sent. * - * @var array + * @var array */ private $clients = array(); @@ -60,17 +126,30 @@ class MySQL_Proxy { * * @param Adapter $adapter The adapter to use to execute MySQL queries. * @param array $options { - * Optional. An associative array of options. Default empty array. + * An associative array of options. The account is required. * - * @type int $port The port to listen on. Default: 3306 - * @type string $log_level The log level to use. One of 'error', 'warning', 'info', 'debug'. - * Default: 'warning' + * @type MySQL_Authentication $authentication The account clients log in with. Required. + * @type string $bind_address The IPv4 or IPv6 address to listen on. Default: 127.0.0.1 + * @type int $port The port to listen on. Default: 3306 + * @type string $log_level The log level to use. One of 'error', 'warning', 'info', 'debug'. + * Default: 'warning' * } */ public function __construct( Adapter $adapter, $options = array() ) { - $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.' ); + } + + $authentication = $options['authentication'] ?? null; + if ( ! $authentication instanceof MySQL_Authentication ) { + throw new InvalidArgumentException( 'The authentication option must be a MySQL_Authentication account.' ); + } + + $this->authentication = $authentication; + $this->adapter = $adapter; + $this->port = $options['port'] ?? 3306; + $this->logger = new Logger( $options['log_level'] ?? Logger::LEVEL_WARNING ); } /** @@ -80,7 +159,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 +172,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,39 +181,124 @@ 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. - $read = array_merge( array( $this->socket ), array_column( $this->clients, 'socket' ) ); - $write = null; - $except = null; - $activity = @socket_select( $read, $write, $except, null ); - if ( false === $activity ) { - throw $this->new_socket_error(); - } + $this->handle_socket_activity(); + } catch ( Throwable $e ) { + $this->logger->error( $e->getMessage() ); + } + } + } - // No activity on any socket. - if ( $activity <= 0 ) { - continue; - } + /** + * Wait for socket activity, close clients past their deadline, and serve the rest. + */ + private function handle_socket_activity(): void { + // Wait for activity on any socket, waking up for the next deadline. + // A client is not read while it has a response it has not accepted. + $read = array( $this->socket ); + $write = array(); + foreach ( $this->clients as $client ) { + if ( '' === $client['output'] ) { + $read[] = $client['socket']; + } else { + $write[] = $client['socket']; + } + } + $except = null; + if ( false === @socket_select( $read, $write, $except, $this->get_select_timeout() ) ) { + throw $this->new_socket_error(); + } - // New client connection. - if ( in_array( $this->socket, $read, true ) ) { - $this->handle_new_client(); - unset( $read[ array_search( $this->socket, $read, true ) ] ); - } + // Drop clients that missed their login, idle, or write deadline. Check only + // after waiting, so data that arrived while the proxy was busy is served. + $this->close_timed_out_clients( array_merge( $read, $write ) ); - // Handle client activity. - foreach ( $read as $socket ) { - $this->handle_client_activity( $this->get_client_id( $socket ) ); - } - } catch ( Throwable $e ) { - $this->logger->error( $e->getMessage() ); + // Clients that can accept more of a pending response. + foreach ( $write as $socket ) { + $this->handle_client_writable( $this->get_client_id( $socket ) ); + } + + // New client connection. + if ( in_array( $this->socket, $read, true ) ) { + $this->handle_new_client(); + unset( $read[ array_search( $this->socket, $read, true ) ] ); + } + + // Handle client activity. + foreach ( $read as $socket ) { + $this->handle_client_activity( $this->get_client_id( $socket ) ); + } + } + + /** + * Close connections past their deadline. + * + * A client must log in within LOGIN_TIMEOUT. A logged-in client must then + * accept more of a pending response within WRITE_TIMEOUT, send more of a + * started request within READ_TIMEOUT, and otherwise send something within + * IDLE_TIMEOUT. + * + * A client with activity is served even past its deadline. The proxy may have + * been busy with another client's query when that client's data arrived. + * + * @param array $ready_sockets The sockets that have activity to serve. + */ + private function close_timed_out_clients( array $ready_sockets ): void { + $now = $this->now(); + foreach ( $this->clients as $client_id => $client ) { + if ( $client['deadline'] > $now || in_array( $client['socket'], $ready_sockets, true ) ) { + continue; + } + $context = array( 'client_id' => $client_id ); + if ( ! $client['session']->is_authenticated() ) { + $this->logger->error( 'Client [{client_id}]: login timed out, closing connection', $context ); + } elseif ( '' !== $client['output'] ) { + $this->logger->error( 'Client [{client_id}]: response not accepted in time, closing connection', $context ); + } elseif ( $client['session']->has_buffered_data() ) { + $this->logger->error( 'Client [{client_id}]: request not completed in time, closing connection', $context ); + } else { + $this->logger->info( 'Client [{client_id}]: idle for too long, closing connection', $context ); } + $this->close_client( $client_id ); + } + } + + /** + * Get the seconds to wait for socket activity. + * + * @return int|null The seconds until the next 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' ) ) - $this->now() ); + } + + /** + * Send more of a pending response, then serve requests that wait behind it. + * + * @param int $client_id The numeric ID of the client. + */ + private function handle_client_writable( int $client_id ): void { + if ( ! $this->send_pending_output( $client_id ) ) { + return; + } + $client = $this->clients[ $client_id ] ?? null; + if ( null !== $client && '' === $client['output'] && $client['session']->has_buffered_data() ) { + $this->process_client_input( $client_id, '' ); + } + $this->restart_deadline( $client_id ); } /** @@ -148,21 +313,41 @@ 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 ) ) + ); + // The send buffer of a new connection is empty, so it takes this short packet whole. + // The client has not sent its capabilities, so the packet has no SQL state. + @socket_write( $socket, MySQL_Protocol::build_err_packet( 0, 1040, null, 'Too many connections' ) ); + @socket_close( $socket ); + return; + } + + // Never wait for a client, so one that stops reading cannot stall the proxy. + if ( false === @socket_set_nonblock( $socket ) ) { + $error = $this->new_socket_error(); + @socket_close( $socket ); + throw $error; + } + // Create a new session for the client. $client_id = $this->get_client_id( $socket ); - $session = new MySQL_Session( $this->adapter, $client_id ); + $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' => $this->now() + self::LOGIN_TIMEOUT, + 'output' => '', + 'sent' => 0, ); $this->logger->info( 'Client [{client_id}]: connected', array( 'client_id' => $client_id ) ); // Handle the initial handshake. $this->logger->info( 'Client [{client_id}]: initial handshake', array( 'client_id' => $client_id ) ); - $handshake = $session->get_initial_handshake(); - if ( false === @socket_write( $socket, $handshake ) ) { - throw $this->new_socket_error(); - } + $this->send_response( $client_id, $session->get_initial_handshake() ); } /** @@ -173,76 +358,203 @@ private function handle_new_client(): void { private function handle_client_activity( int $client_id ): void { $this->logger->info( 'Client [{client_id}]: reading data from client', array( 'client_id' => $client_id ) ); - // Read data from the client. + // Read data from the client. The socket does not block, so it may have none yet. $socket = $this->clients[ $client_id ]['socket']; - $data = @socket_read( $socket, 4096 ); - if ( false === $data ) { - throw $this->new_socket_error(); + $data = @socket_read( $socket, self::READ_CHUNK_SIZE ); + if ( false === $data && SOCKET_EWOULDBLOCK === socket_last_error( $socket ) ) { + return; } - - // 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 ), - ) - ); + if ( false === $data ) { + $this->close_client_on_error( $client_id ); + return; } // 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 ); + $this->close_client( $client_id ); 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}', + $this->process_client_input( $client_id, $data ); + $this->restart_deadline( $client_id ); + } + + /** + * Pass client data to the session and send the responses to complete messages. + * + * Incomplete input stays in the session for the next read. Processing stops + * at a response the client has not accepted, so a client that does not read + * makes the proxy hold one response, not one for each request. + * + * @param int $client_id The numeric ID of the client. + * @param string $data The data received from the client. + */ + private function process_client_input( int $client_id, string $data ): void { + // Do not dump request bytes: even authenticated clients can send credentials + // in COM_CHANGE_USER, including across multiple reads or buffered packets. + $session = $this->clients[ $client_id ]['session']; + do { + try { + $response = $session->receive_bytes( $data ); + } catch ( Incomplete_Input_Exception $e ) { + return; + } + $data = ''; + + 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, - 'response' => $this->format_data( $response ), + 'bytes' => strlen( $response ), ) ); + + // Before login, only an authentication switch request keeps the connection + // open, and a session sends at most one. Like MySQL's per-read connect_timeout, + // the client gets a full login timeout to answer it, even after a late read. + if ( ! $session->is_authenticated() && ! $session->is_closed() ) { + $this->clients[ $client_id ]['deadline'] = $this->now() + self::LOGIN_TIMEOUT; + } } - if ( false === @socket_write( $socket, $response ) ) { - throw $this->new_socket_error(); + $this->send_response( $client_id, (string) $response ); + } while ( + isset( $this->clients[ $client_id ] ) + && '' === $this->clients[ $client_id ]['output'] + && $session->has_buffered_data() + ); + } + + /** + * Queue a response and send as much of it as the client accepts now. + * + * @param int $client_id The numeric ID of the client. + * @param string $response The response packets. + */ + private function send_response( int $client_id, string $response ): void { + $this->clients[ $client_id ]['output'] .= $response; + $this->send_pending_output( $client_id ); + } + + /** + * Send as much of a pending response as the client socket accepts now. + * + * The connection of a closed session is closed once its response is sent. + * + * @param int $client_id The numeric ID of the client. + * @return bool Whether any bytes were sent. + */ + private function send_pending_output( int $client_id ): bool { + $client = $this->clients[ $client_id ]; + $length = strlen( $client['output'] ); + $sent = $client['sent']; + while ( $sent < $length ) { + $written = @socket_write( $client['socket'], substr( $client['output'], $sent, self::WRITE_CHUNK_SIZE ) ); + if ( false === $written && SOCKET_EWOULDBLOCK !== socket_last_error( $client['socket'] ) ) { + $this->close_client_on_error( $client_id ); + return false; } + if ( ! $written ) { + break; + } + $sent += $written; } - // 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(); - } - } - } - } catch ( Incomplete_Input_Exception $e ) { - break; + if ( $sent < $length ) { + $this->clients[ $client_id ]['sent'] = $sent; + } else { + $this->clients[ $client_id ]['output'] = ''; + $this->clients[ $client_id ]['sent'] = 0; + if ( $client['session']->is_closed() ) { + $this->log_closed_session( $client_id, $client['session']->get_close_reason() ); + $this->close_client( $client_id ); } } + return $sent > $client['sent']; + } + + /** + * Restart the deadline of a logged-in client that made progress. + * + * Before login, progress does not extend the login deadline. + * + * @param int $client_id The numeric ID of the client. + */ + private function restart_deadline( int $client_id ): void { + $client = $this->clients[ $client_id ] ?? null; + if ( null === $client || ! $client['session']->is_authenticated() ) { + return; + } + if ( '' !== $client['output'] ) { + $timeout = self::WRITE_TIMEOUT; + } elseif ( $client['session']->has_buffered_data() ) { + // The rest of a started request is pending. + $timeout = self::READ_TIMEOUT; + } else { + $timeout = self::IDLE_TIMEOUT; + } + $this->clients[ $client_id ]['deadline'] = $this->now() + $timeout; + } + + /** + * Get the current time in seconds for deadlines. + * + * Unlike time(), the monotonic hrtime() does not jump when the system clock + * changes. PHP 7.2 has no monotonic clock, so it uses time(). + * + * @return int The seconds from an arbitrary starting point. + */ + private function now(): int { + $time = function_exists( 'hrtime' ) ? hrtime() : false; + return false === $time ? time() : $time[0]; + } + + /** + * 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 ); + } + } + + /** + * Log the error of a failed client socket and close the connection. + * + * @param int $client_id The numeric ID of the client. + */ + private function close_client_on_error( int $client_id ): void { + $this->logger->error( + 'Client [{client_id}]: {error}, closing connection', + array( + 'client_id' => $client_id, + 'error' => socket_strerror( socket_last_error( $this->clients[ $client_id ]['socket'] ) ), + ) + ); + $this->close_client( $client_id ); + } + + /** + * Forget a client and close its connection, discarding any unsent response. + * + * @param int $client_id The numeric ID of the client. + */ + private function close_client( int $client_id ): void { + $socket = $this->clients[ $client_id ]['socket']; + unset( $this->clients[ $client_id ] ); + @socket_close( $socket ); } /** @@ -253,7 +565,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 +582,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..384e62761 100644 --- a/packages/mysql-proxy/src/class-mysql-session.php +++ b/packages/mysql-proxy/src/class-mysql-session.php @@ -9,6 +9,32 @@ * 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 message accepted from an authenticated client. + * + * This is the "max_allowed_packet" equivalent, at the 16 MB default of MySQL + * clients rather than the 64 MB server default. It bounds the memory a single + * connection can hold, because a split message is assembled in memory. + */ + const MAX_MESSAGE_SIZE = 16777216; + + /** + * Largest message accepted before the client is authenticated. + * + * A login packet carries a username, a challenge response, a database name, + * and connection attributes, so it stays well below MySQL's own 16 KB + * 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 +103,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 +148,48 @@ 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, MySQL_Authentication $authentication, ?Logger $logger = null ) { + $this->authentication = $authentication; + $this->adapter = $adapter; + $this->connection_id = $connection_id; + $this->logger = $logger ?? new Logger(); + $this->packet_id = 0; + + // Like MySQL's generate_user_salt(), use 7-bit characters other than NUL and '$'. + // Some clients read the challenge as a C string or as text. + $this->auth_plugin_data = strtr( random_bytes( 20 ) & str_repeat( "\x7f", 20 ), "\0$", "\1%" ); + } + + /** + * Whether the connection has completed authentication. + * + * @return bool */ - public function __construct( Adapter $adapter, int $connection_id ) { - $this->adapter = $adapter; - $this->connection_id = $connection_id; - $this->auth_plugin_data = ''; - $this->packet_id = 0; + public function is_authenticated(): bool { + return $this->is_authenticated; + } - // Generate random auth plugin data (20-byte salt) - $this->auth_plugin_data = random_bytes( 20 ); + /** + * 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; } /** @@ -133,6 +218,7 @@ public function get_buffer_size(): int { * @return string The initial handshake packet. */ public function get_initial_handshake(): string { + // Ask for the account's plugin, so clients that follow the server need no switch. return MySQL_Protocol::build_handshake_packet( 0, $this->server_version, @@ -140,7 +226,8 @@ public function get_initial_handshake(): string { $this->connection_id, $this->auth_plugin_data, self::CAPABILITIES, - $this->status_flags + $this->status_flags, + $this->authentication->get_plugin() ); } @@ -152,39 +239,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' ); - } - - // 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 - ) - ); + // 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(); } - // 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 +263,33 @@ 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 ) ); + $database = substr( $payload, 1 ); + if ( '' === $database ) { + // Like MySQL, refuse an empty name without looking it up. + return MySQL_Protocol::build_err_packet( $this->packet_id++, 1046, '3D000', 'No database selected' ); + } + return $this->use_database( $database ) + ?? 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,83 @@ 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. + // See https://dev.mysql.com/doc/dev/mysql-server/latest/page_protocol_basic_packets.html. + if ( MySQL_Protocol::MAX_PACKET_LENGTH !== $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 +388,187 @@ public function receive_bytes( string $data ): ?string { * @return string The authentication response packet. */ private function process_authentication( string $payload ): string { - $payload_length = strlen( $payload ); + if ( null !== $this->pending_authentication ) { + $login = $this->pending_authentication; + $this->pending_authentication = null; + return $this->complete_authentication( + $login['username'], + $login['plugin'], + $payload, + $login['database'], + $login['client_flags'] + ); + } - // Decode the first 5 fields. - $data = unpack( - 'Vclient_flags/Vmax_packet_size/Ccharacter_set/x23filler/Z*username', - $payload - ); + try { + if ( strlen( $payload ) < 32 ) { + return $this->reject_authentication(); + } + $client_flags = unpack( 'V', $payload )[1]; + if ( ! ( $client_flags & MySQL_Protocol::CLIENT_PROTOCOL_41 ) + || ( $client_flags & MySQL_Protocol::CLIENT_SSL ) + ) { + return $this->reject_authentication(); + } - // Calculate the offset of the authentication response. - $offset = 32 + strlen( $data['username'] ) + 1; + $offset = 32; + $username = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { + $auth_response = MySQL_Protocol::read_length_encoded_string( $payload, $offset ); + } elseif ( $client_flags & MySQL_Protocol::CLIENT_SECURE_CONNECTION ) { + if ( $offset >= strlen( $payload ) ) { + return $this->reject_authentication(); + } + $length = ord( $payload[ $offset++ ] ); + if ( $length > strlen( $payload ) - $offset ) { + return $this->reject_authentication(); + } + $auth_response = substr( $payload, $offset, $length ); + $offset += $length; + } else { + return $this->reject_authentication(); + } - $client_flags = $data['client_flags']; - $this->client_capabilities = $client_flags; + $database = ''; + if ( $client_flags & MySQL_Protocol::CLIENT_CONNECT_WITH_DB ) { + $database = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + } - // Decode the authentication response. - $auth_response = ''; - if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA ) { - $auth_response = MySQL_Protocol::read_length_encoded_string( $payload, $offset ); - } else { - $length = ord( $payload[ $offset++ ] ); - $auth_response = substr( $payload, $offset, $length ); - $offset += $length; + $plugin = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD; + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + // Like MySQL, read a missing or unterminated name as empty, which + // switches the client to the account's plugin. + $plugin = false === strpos( $payload, "\0", $offset ) + ? '' + : MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + } + } catch ( MySQL_Proxy_Exception $e ) { + return $this->reject_authentication(); } - // Get the database name. - if ( $client_flags & MySQL_Protocol::CLIENT_CONNECT_WITH_DB ) { - $database = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); - if ( '' !== $database ) { - $result = $this->adapter->handle_query( 'USE ' . $database ); - if ( $result->error_info ) { - return MySQL_Protocol::build_err_packet( - $this->packet_id++, - 1049, - '42000', - sprintf( "Unknown database: '%s'", $database ) - ); - } + $switch_plugin = null; + if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + if ( $this->authentication->get_plugin() !== $plugin ) { + // Like MySQL, switch a client that chose another method to the account's. + $switch_plugin = $this->authentication->get_plugin(); + } elseif ( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD === $plugin && '' === $auth_response ) { + // Older clients request native authentication without sending a response yet. + $switch_plugin = $plugin; } + } elseif ( $this->authentication->get_plugin() !== $plugin ) { + // Like MySQL, refuse a client that cannot switch to the account's method. + $this->close( self::CLOSE_LOGIN_FAILED ); + return MySQL_Protocol::build_err_packet( + $this->packet_id++, + 1251, + '08004', + 'Client does not support authentication protocol requested by server; consider upgrading MySQL client' + ); } - // Get the authentication plugin name. - $auth_plugin_name = ''; - if ( $client_flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { - $auth_plugin_name = MySQL_Protocol::read_null_terminated_string( $payload, $offset ); + if ( null !== $switch_plugin ) { + $this->pending_authentication = array( + 'username' => $username, + 'plugin' => $switch_plugin, + 'database' => $database, + 'client_flags' => $client_flags, + ); + return MySQL_Protocol::build_packet( + $this->packet_id++, + chr( MySQL_Protocol::AUTH_SWITCH_REQUEST_HEADER ) . $switch_plugin . "\0" . $this->auth_plugin_data . "\0" + ); } - // Get the connection attributes. - if ( $client_flags & MySQL_Protocol::CLIENT_CONNECT_ATTRS ) { - $attrs_length = MySQL_Protocol::read_length_encoded_int( $payload, $offset ); - $offset = min( $payload_length, $offset + $attrs_length ); - // TODO: Process connection attributes. + return $this->complete_authentication( $username, $plugin, $auth_response, $database, $client_flags ); + } + + /** + * Verify credentials before selecting the database and completing the login. + * + * @param string $username The client account name. + * @param string $plugin The authentication plugin. + * @param string $auth_response The client challenge response. + * @param string $database The initial database name. + * @param int $client_flags The client capabilities. + * @return string The authentication result packets. + */ + private function complete_authentication( string $username, string $plugin, string $auth_response, string $database, int $client_flags ): string { + if ( ! $this->authentication->verify( $username, $plugin, $auth_response, $this->auth_plugin_data ) ) { + return $this->reject_authentication(); } - /** - * Authentication flow. - * - * @see https://dev.mysql.com/doc/dev/mysql-server/8.4.6/page_caching_sha2_authentication_exchanges.html - */ - if ( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD === $auth_plugin_name ) { - // TODO: Implement authentication. - $this->is_authenticated = true; - if ( "\0" === $auth_response || '' === $auth_response ) { - /* - * Fast path for empty password. - * - * With the "caching_sha2_password" and "sha256_password" plugins, - * an empty password is represented as a single "\0" character. - * - * @see https://github.com/mysql/mysql-server/blob/aa461240270d809bcac336483b886b3d1789d4d9/sql/auth/sha2_password.cc#L1017-L1022 - */ - return MySQL_Protocol::build_ok_packet( $this->packet_id++, $this->status_flags ); + // No adapter calls are allowed until the credentials have been verified. + if ( '' !== $database ) { + $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. The adapter's reason goes to the log. + * + * @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 ) . '`' ); + if ( ! $result->error_info ) { + return null; + } + $this->logger->warning( + 'Client [{client_id}]: cannot select a database: {error}', + array( + 'client_id' => $this->connection_id, + 'error' => $result->error_info[2], + ) + ); + } catch ( Throwable $e ) { + $this->log_internal_error( $e ); + } return MySQL_Protocol::build_err_packet( $this->packet_id++, - 0x04D2, - 'HY000', - 'Unsupported authentication plugin: ' . $auth_plugin_name + 1049, + '42000', + sprintf( "Unknown database '%s'", $database ) ); } @@ -350,15 +608,36 @@ 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. The log + * names the original error and where it was raised, because the driver + * wraps PHP errors in its own exceptions. + * + * @param Throwable $error The internal error. + */ + private function log_internal_error( Throwable $error ): void { + while ( null !== $error->getPrevious() ) { + $error = $error->getPrevious(); + } + $this->logger->error( + 'Client [{client_id}]: internal error: {class}: {error} in {file}:{line}', + array( + 'client_id' => $this->connection_id, + 'class' => get_class( $error ), + 'error' => $error->getMessage(), + 'file' => $error->getFile(), + 'line' => $error->getLine(), + ) + ); + } + /** * 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..5fe2a8510 --- /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_pdo_logs_in_to_a_native_password_account(): void { + $this->server->stop(); + $this->server = new MySQL_Server_Process( + array( + 'port' => $this->port, + 'auth_plugin' => 'mysql_native_password', + ) + ); + + // PDO uses the plugin that the handshake asks for. + $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..ab001aa20 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_CLI_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_CLI_Test.php @@ -1,21 +1,22 @@ 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 +24,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,32 +52,32 @@ 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(); $this->assertEquals( 1, $process->getExitCode() ); - $this->assertStringContainsString( "Unknown database: 'unknown_database'", $process->getErrorOutput() ); + $this->assertStringContainsString( "ERROR 1049 (42000): Unknown database 'unknown_database'", $process->getErrorOutput() ); } 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() ); @@ -70,4 +86,9 @@ public function test_query(): void { $process->getOutput() ); } + + protected function get_auth_plugin(): string { + // The external CLI's authentication support is independent of PHP's mysqlnd. + return MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD; + } } 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..e5bf5cc5e --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Command_Test.php @@ -0,0 +1,220 @@ +expectException( InvalidArgumentException::class ); + new MySQL_Proxy( $this->createMock( Adapter::class ), $options ); + } + + public function missing_accounts(): array { + return array( + 'no options' => array( array() ), + 'credentials options' => array( + array( + 'username' => 'user', + 'password' => 'password', + ), + ), + 'credentials string' => array( array( 'authentication' => 'user:password' ) ), + 'credentials array' => array( array( 'authentication' => array( 'user', 'password' ) ) ), + ); + } + + /** @dataProvider invalid_arguments */ + public function test_cli_rejects_invalid_credentials( array $arguments, string $password, string $error ): void { + $process = $this->cli( $arguments, $password ); + $process->setTimeout( 2 ); + $process->run(); + $this->assertSame( 1, $process->getExitCode() ); + $this->assertStringContainsString( $error, $process->getErrorOutput() ); + $this->assertStringNotContainsString( 'listening', $process->getErrorOutput() ); + } + + public function invalid_arguments(): array { + return array( + array( array( '--user=user', '--password-file' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '-uother' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '-x' ), 'password', 'Invalid command-line options' ), + array( array( '--help=value' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '--port=3306garbage' ), 'password', '--port must be an integer' ), + array( array( '--user=user', '--port=0' ), 'password', '--port must be an integer' ), + array( array( '--user=user', '--port=65536' ), '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( '--user=user', '--' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '--port', '--log-level=info' ), 'password', 'Invalid command-line options' ), + array( array( '--database', '--port=5', '--user=user' ), 'password', 'Invalid command-line options' ), + array( array( '--user', '--auth-plugin=mysql_native_password' ), 'password', 'Invalid command-line options' ), + array( array( '-u', '-P', '3306' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '--log-level=-x' ), 'password', '--log-level must be one of' ), + array( array( '--user=user', '-l-x' ), 'password', '--log-level must be one of' ), + array( array(), 'password', 'Error: A nonempty username without NUL bytes is required. Use --help for more information.' ), + array( array( '--user=user' ), '', 'Error: A nonempty password without NUL bytes is required.' ), + array( array( '--user=user', '--password-file=/nonexistent/proxy-password' ), 'password', 'must name a readable file' ), + array( array( '--user=user', '--password-file=' . __DIR__ ), 'password', 'must name a readable file' ), + array( array( '--user=user', '--user=other' ), 'password', 'Invalid command-line options' ), + array( array( '--user=user', '--auth-plugin=mysql_clear_password' ), 'password', 'Error: The authentication plugin must be one of' ), + array( array( '--user=user', '--auth-plugin=' ), 'password', 'Error: The authentication plugin must be one of' ), + ); + } + + public function test_help_explains_credentials_and_bind_default(): void { + $process = $this->cli( array( '--help' ), '' ); + $process->run(); + $this->assertSame( 0, $process->getExitCode() ); + $this->assertStringContainsString( 'WP_MYSQL_PROXY_PASSWORD', $process->getErrorOutput() ); + $this->assertStringContainsString( 'Default: 127.0.0.1', $process->getErrorOutput() ); + $this->assertStringContainsString( 'Connections are not encrypted (no TLS).', $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( string $source, string $option_style ): void { + $port = MySQL_Server_Process::free_port(); + switch ( $option_style ) { + case 'long-equals': + $args = array( '--user=user', '--port=' . $port ); + break; + case 'long-separate': + $args = array( '--user', 'user', '--port', (string) $port ); + break; + case 'short-attached': + $args = array( '-uuser', '-P' . $port, '-linfo', '-d:memory:' ); + break; + default: + $args = array( '-u', 'user', '-P', (string) $port, '-l', 'info', '-d', ':memory:' ); + } + $args[] = '--auth-plugin=' . MySQL_Test_Client::auth_plugin(); + $password = 'environment-password'; + $input = null; + if ( 'file' === $source ) { + $file = tempnam( sys_get_temp_dir(), 'proxy-password-' ); + chmod( $file, 0600 ); + file_put_contents( $file, "file-password\n" ); + $password = "file-password\n"; + if ( 'long-equals' === $option_style || 'short-attached' === $option_style ) { + $args[] = '--password-file=' . $file; + } else { + $args[] = '--password-file'; + $args[] = $file; + } + } elseif ( 'pipe' === $source ) { + // Like a process substitution, a pipe is not a regular file. + $args[] = '--password-file=/dev/stdin'; + $input = 'pipe-password'; + $password = 'pipe-password'; + } elseif ( 'substitution' === $source ) { + $password = 'substitution-password'; + } + $process = 'substitution' === $source + ? $this->cli_with_password_substitution( $args, $password ) + : $this->cli( $args, 'environment-password' ); + $process->setInput( $input ); + $process->setTimeout( 2 ); + try { + $process->start(); + while ( false === strpos( $process->getErrorOutput(), 'listening on 127.0.0.1:' ) && $process->isRunning() ) { + $process->checkTimeout(); + usleep( 50000 ); + } + $this->assertStringContainsString( 'listening on 127.0.0.1:', $process->getErrorOutput() ); + $pdo = new PDO( "mysql:host=127.0.0.1;port=$port", 'user', $password ); + $this->assertEquals( 123, $pdo->query( 'SELECT 123' )->fetchColumn() ); + } finally { + $process->stop(); + if ( isset( $file ) ) { + unlink( $file ); + } + } + } + + public function password_sources(): array { + return array( + array( 'environment', 'long-equals' ), + array( 'environment', 'long-separate' ), + array( 'file', 'short-attached' ), + array( 'file', 'short-separate' ), + array( 'pipe', 'long-equals' ), + array( 'substitution', 'long-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( 'A nonempty password without NUL bytes is required.', $process->getErrorOutput() ); + } finally { + unlink( $file ); + } + } + + public function invalid_password_files(): array { + return array( array( '' ), array( "pass\0word" ) ); + } + + public function test_cli_reports_an_unreadable_password_file(): void { + // A socket passes the readability check, but cannot be opened like a file. + $path = sys_get_temp_dir() . '/proxy-password-' . getmypid() . '.sock'; + $socket = stream_socket_server( 'unix://' . $path ); + try { + $process = $this->cli( array( '--user=user', '--password-file=' . $path ), 'valid-environment-password' ); + $process->run(); + $this->assertSame( 1, $process->getExitCode() ); + $this->assertSame( "Error: --password-file could not be read.\n", $process->getErrorOutput() ); + } finally { + fclose( $socket ); + unlink( $path ); + } + } + + 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 ) + ); + } + + /** + * Run the CLI from bash, which passes a process substitution as a /dev/fd path. + */ + private function cli_with_password_substitution( array $arguments, string $password ): Process { + $command = 'exec "$@" --password-file=<(printf %s ' . escapeshellarg( $password ) . ')'; + return new Process( + array_merge( array( 'bash', '-c', $command, 'bash', PHP_BINARY, __DIR__ . '/../bin/wp-mysql-proxy.php' ), $arguments ), + null, + array( 'WP_MYSQL_PROXY_PASSWORD' => 'environment-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..ab67eaec5 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Limits_Test.php @@ -0,0 +1,403 @@ +proxy(); + $clients = $this->property( 'clients' ); + $clients->setValue( + $proxy, + array( 1 => $this->client( $this->socket(), $this->session( $authenticated ), $this->now( $proxy ) + $deadline_offset, $output ) ) + ); + + $this->assertSame( $expected_timeout, $this->call( $proxy, 'get_select_timeout' ) ); + $this->call( $proxy, 'close_timed_out_clients', array() ); + $this->assertCount( $expected_open ? 1 : 0, $clients->getValue( $proxy ) ); + } + + /** @dataProvider logins_read_past_the_deadline */ + public function test_login_read_past_the_deadline( bool $sent, bool $expected_open ): void { + $proxy = $this->proxy(); + $port = $this->listen( $proxy ); + $stream = stream_socket_client( "tcp://127.0.0.1:$port", $errno, $error, 2 ); + stream_set_timeout( $stream, 2 ); + $this->call( $proxy, 'handle_new_client' ); + $clients = $this->property( 'clients' ); + $client_id = key( $clients->getValue( $proxy ) ); + $challenge = MySQL_Test_Client::challenge( MySQL_Test_Client::read_packet( $stream ) ); + if ( $sent ) { + fwrite( $stream, MySQL_Test_Client::handshake_response( $challenge ) ); + $read = array( $clients->getValue( $proxy )[ $client_id ]['socket'] ); + $write = null; + $except = null; + $this->assertSame( 1, socket_select( $read, $write, $except, 2 ) ); + } + + // The deadline passes before the proxy gets to the client, such as while + // it runs a long query for another client. + $value = $clients->getValue( $proxy ); + $value[ $client_id ]['deadline'] = $this->now( $proxy ) - 1; + $clients->setValue( $proxy, $value ); + $this->call( $proxy, 'handle_socket_activity' ); + + if ( $expected_open ) { + $this->assertTrue( $clients->getValue( $proxy )[ $client_id ]['session']->is_authenticated() ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_login_result( $stream )[4] ) ); + } else { + $this->assertSame( array(), $clients->getValue( $proxy ) ); + $this->assertSame( '', fread( $stream, 1 ), 'The connection is closed.' ); + } + fclose( $stream ); + } + + public function logins_read_past_the_deadline(): array { + return array( + 'login sent before the deadline' => array( true, true ), + 'nothing sent' => array( false, false ), + ); + } + + public function test_login_read_past_the_deadline_has_time_to_answer_a_switch(): void { + $proxy = $this->proxy( null, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $port = $this->listen( $proxy ); + $stream = stream_socket_client( "tcp://127.0.0.1:$port", $errno, $error, 2 ); + stream_set_timeout( $stream, 2 ); + $this->call( $proxy, 'handle_new_client' ); + $clients = $this->property( 'clients' ); + $client_id = key( $clients->getValue( $proxy ) ); + $challenge = MySQL_Test_Client::challenge( MySQL_Test_Client::read_packet( $stream ) ); + + // The client logs in with caching_sha2_password, so the native account needs a switch. + fwrite( $stream, MySQL_Test_Client::handshake_response( $challenge ) ); + $read = array( $clients->getValue( $proxy )[ $client_id ]['socket'] ); + $write = null; + $except = null; + $this->assertSame( 1, socket_select( $read, $write, $except, 2 ) ); + + // The deadline passes before the proxy gets to the client. + $value = $clients->getValue( $proxy ); + $value[ $client_id ]['deadline'] = $this->now( $proxy ) - 1; + $clients->setValue( $proxy, $value ); + $this->call( $proxy, 'handle_socket_activity' ); + $this->assertSame( 0xfe, ord( MySQL_Test_Client::read_packet( $stream )[4] ), 'The proxy asks for a switch.' ); + + // The proxy checks the deadlines again before the client can answer. + $this->call( $proxy, 'close_timed_out_clients', array() ); + $this->assertArrayHasKey( $client_id, $clients->getValue( $proxy ), 'The client has time to answer.' ); + $this->assertEqualsWithDelta( $this->now( $proxy ) + MySQL_Proxy::LOGIN_TIMEOUT, $clients->getValue( $proxy )[ $client_id ]['deadline'], 2 ); + + $scramble = MySQL_Test_Client::scramble( 'password', $challenge, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + fwrite( $stream, MySQL_Protocol::build_packet( 3, $scramble ) ); + $this->receive( $proxy, $client_id ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_login_result( $stream )[4] ) ); + fclose( $stream ); + } + + 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 ), + 'response within the write deadline' => array( 5, true, 'pending', true, 5 ), + 'response past the write deadline' => array( -1, true, 'pending', false, 0 ), + ); + } + + public function test_select_waits_indefinitely_without_clients(): void { + $proxy = $this->proxy(); + $this->assertNull( $this->call( $proxy, 'get_select_timeout' ) ); + } + + public function test_deadlines_use_a_monotonic_clock(): void { + $proxy = $this->proxy(); + + // The monotonic clock counts from an arbitrary point, such as the system start. + $expected = PHP_VERSION_ID >= 70300 ? hrtime()[0] : time(); + $this->assertEqualsWithDelta( $expected, $this->now( $proxy ), 1 ); + } + + /** @dataProvider client_activity */ + public function test_activity_restarts_the_deadline_after_login( bool $authenticated, string $request, int $expected_deadline ): void { + $proxy = $this->proxy(); + $pair = array(); + socket_create_pair( AF_UNIX, SOCK_STREAM, 0, $pair ); + $this->property( 'clients' )->setValue( + $proxy, + array( 1 => $this->client( $pair[1], $this->session( $authenticated ), $this->now( $proxy ) + 5 ) ) + ); + + socket_write( $pair[0], $request ); + $this->call( $proxy, 'handle_client_activity', 1 ); + + $deadline = $this->property( 'clients' )->getValue( $proxy )[1]['deadline']; + $this->assertEqualsWithDelta( $this->now( $proxy ) + $expected_deadline, $deadline, 2 ); + } + + public function client_activity(): array { + $ping = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ); + return array( + 'logged-in client sends a command' => array( true, $ping, MySQL_Proxy::IDLE_TIMEOUT ), + 'logged-in client sends part of a command' => array( true, "\x40\0\0\0\x03SELECT", MySQL_Proxy::READ_TIMEOUT ), + 'client sends part of its login' => array( false, "\x40\0\0\x01partial", 5 ), + ); + } + + public function test_client_that_stops_reading_does_not_block_the_proxy(): void { + // A result much larger than the socket buffers can hold. + $column = array( + 'name' => 'v', + 'length' => 65536, + 'type' => MySQL_Protocol::FIELD_TYPE_LONG_BLOB, + 'flags' => 0, + 'decimals' => 0, + ); + $rows = array_fill( 0, 128, (object) array( 'v' => str_repeat( 'x', 65536 ) ) ); + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->willReturn( MySQL_Result::from_data( 0, 0, array( $column ), $rows ) ); + $proxy = $this->proxy( $adapter ); + + // Connect a client with a small receive buffer. + $port = $this->listen( $proxy ); + $socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_set_option( $socket, SOL_SOCKET, SO_RCVBUF, 4096 ); + socket_connect( $socket, '127.0.0.1', $port ); + $stream = socket_export_stream( $socket ); + stream_set_timeout( $stream, 2 ); + $this->call( $proxy, 'handle_new_client' ); + $clients = $this->property( 'clients' ); + $client_id = key( $clients->getValue( $proxy ) ); + + // Bound a blocking write, so a regression fails instead of hanging the test. + $connection = $clients->getValue( $proxy )[ $client_id ]['socket']; + socket_set_option( + $connection, + SOL_SOCKET, + SO_SNDTIMEO, + array( + 'sec' => 1, + 'usec' => 0, + ) + ); + + $challenge = MySQL_Test_Client::challenge( MySQL_Test_Client::read_packet( $stream ) ); + fwrite( $stream, MySQL_Test_Client::handshake_response( $challenge ) ); + $this->receive( $proxy, $client_id ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_login_result( $stream )[4] ) ); + + // Send a query and a ping without reading the result. + $ping = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ); + fwrite( $stream, MySQL_Protocol::build_packet( 0, "\x03SELECT v" ) . $ping ); + $start = microtime( true ); + $this->receive( $proxy, $client_id ); + $this->assertLessThan( 1, microtime( true ) - $start, 'The proxy does not wait for the client.' ); + + $client = $clients->getValue( $proxy )[ $client_id ]; + $this->assertNotSame( '', $client['output'], 'The rest of the result waits for the client.' ); + $this->assertTrue( $client['session']->has_buffered_data(), 'The ping waits behind the result.' ); + $this->assertEqualsWithDelta( $this->now( $proxy ) + MySQL_Proxy::WRITE_TIMEOUT, $client['deadline'], 2 ); + + // Once the client reads, it gets the whole result and then the ping response. + // The result is a column count, a column, an EOF, the rows, and an EOF. + $packets = $this->read_while_sending( $proxy, $client_id, $stream, 3 + count( $rows ) + 1 + 1 ); + $this->assertSame( "\x01", substr( $packets[0], 4 ) ); + $this->assertSame( 0xfe, ord( $packets[ count( $rows ) + 3 ][4] ) ); + $this->assertSame( "\x07\0\0\x01\0\0\0\x02\0\0\0", end( $packets ) ); + $this->assertEqualsWithDelta( $this->now( $proxy ) + MySQL_Proxy::IDLE_TIMEOUT, $clients->getValue( $proxy )[ $client_id ]['deadline'], 2 ); + } + + public function test_client_that_disconnects_before_accepting_a_response_is_closed(): void { + $proxy = $this->proxy(); + $pair = array(); + socket_create_pair( AF_UNIX, SOCK_STREAM, 0, $pair ); + socket_set_nonblock( $pair[1] ); + $clients = $this->property( 'clients' ); + $clients->setValue( + $proxy, + array( 1 => $this->client( $pair[1], $this->session( true ), $this->now( $proxy ) + 5, str_repeat( 'x', 1048576 ) ) ) + ); + + socket_close( $pair[0] ); + $this->call( $proxy, 'handle_client_writable', 1 ); + $this->assertSame( array(), $clients->getValue( $proxy ) ); + } + + public function test_client_whose_connection_resets_is_closed(): void { + $proxy = $this->proxy(); + $port = $this->listen( $proxy ); + $socket = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_connect( $socket, '127.0.0.1', $port ); + $this->call( $proxy, 'handle_new_client' ); + $clients = $this->property( 'clients' ); + $client_id = key( $clients->getValue( $proxy ) ); + + // Closing without lingering resets the connection, so the proxy's read fails. + socket_set_option( + $socket, + SOL_SOCKET, + SO_LINGER, + array( + 'l_onoff' => 1, + 'l_linger' => 0, + ) + ); + socket_close( $socket ); + $this->receive( $proxy, $client_id ); + $this->assertSame( array(), $clients->getValue( $proxy ) ); + } + + /** @dataProvider connection_counts */ + public function test_connection_limit( int $open, bool $expected_accepted ): void { + $proxy = $this->proxy(); + $port = $this->listen( $proxy ); + + // Key the occupants away from any object ID the accepted socket can get. + $clients = $this->property( 'clients' ); + $occupant = $this->client( $this->socket(), $this->session( true ), $this->now( $proxy ) + 60 ); + $occupants = array(); + for ( $i = 1; $i <= $open; $i++ ) { + $occupants[ PHP_INT_MAX - $i ] = $occupant; + } + $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( $this->now( $proxy ), end( $accepted )['deadline'] ); + } else { + // The bytes MySQL sends, without an SQL state before the client's capabilities. + $this->assertSame( "\x17\0\0\0\xff\x10\x04Too many connections", $response ); + $this->assertCount( $open, $clients->getValue( $proxy ), 'A refused client is not registered.' ); + $this->assertSame( '', fread( $client, 1 ), 'A refused connection is closed.' ); + } + + fclose( $client ); + } + + 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 proxy( ?Adapter $adapter = null, string $plugin = MySQL_Protocol::DEFAULT_AUTH_PLUGIN ): MySQL_Proxy { + return new MySQL_Proxy( + $adapter ?? $this->createMock( Adapter::class ), + array( 'authentication' => new MySQL_Authentication( 'user', 'password', $plugin ) ) + ); + } + + /** + * Stand in for the listener the proxy creates in start(). + * + * @return int The port the listener is bound to. + */ + private function listen( MySQL_Proxy $proxy ): int { + $listener = socket_create( AF_INET, SOCK_STREAM, SOL_TCP ); + socket_bind( $listener, '127.0.0.1', 0 ); + socket_listen( $listener ); + socket_getsockname( $listener, $address, $port ); + $this->property( 'socket' )->setValue( $proxy, $listener ); + return $port; + } + + /** + * Let the proxy handle data from a client once the data has arrived. + */ + private function receive( MySQL_Proxy $proxy, int $client_id ): void { + $read = array( $this->property( 'clients' )->getValue( $proxy )[ $client_id ]['socket'] ); + $write = null; + $except = null; + $this->assertSame( 1, socket_select( $read, $write, $except, 2 ) ); + $this->call( $proxy, 'handle_client_activity', $client_id ); + } + + /** + * Read packets from a client, letting the proxy send more of its pending response. + */ + private function read_while_sending( MySQL_Proxy $proxy, int $client_id, $stream, int $count ): array { + $packets = array(); + $buffer = ''; + while ( count( $packets ) < $count ) { + if ( '' !== $this->property( 'clients' )->getValue( $proxy )[ $client_id ]['output'] ) { + $this->call( $proxy, 'handle_client_writable', $client_id ); + } + $data = fread( $stream, 1048576 ); + $this->assertNotEmpty( $data, 'The proxy keeps sending.' ); + $buffer .= $data; + while ( strlen( $buffer ) >= 4 ) { + $length = unpack( 'V', substr( $buffer, 0, 3 ) . "\0" )[1]; + if ( strlen( $buffer ) < 4 + $length ) { + break; + } + $packets[] = substr( $buffer, 0, 4 + $length ); + $buffer = (string) substr( $buffer, 4 + $length ); + } + } + return $packets; + } + + /** + * The time on the clock the proxy uses for deadlines. + */ + private function now( MySQL_Proxy $proxy ): int { + return $this->call( $proxy, 'now' ); + } + + private function client( $socket, MySQL_Session $session, int $deadline, string $output = '' ): array { + return array( + 'socket' => $socket, + 'session' => $session, + 'deadline' => $deadline, + 'output' => $output, + 'sent' => 0, + ); + } + + private function session( bool $authenticated ): MySQL_Session { + $session = new MySQL_Session( $this->createMock( Adapter::class ), 1, new MySQL_Authentication( 'user', 'password' ) ); + if ( $authenticated ) { + $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..529db0290 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Options_Test.php @@ -0,0 +1,55 @@ +createMock( Adapter::class ), + $options + array( 'authentication' => new MySQL_Authentication( 'user', 'password' ) ) + ); + $property = new ReflectionProperty( MySQL_Proxy::class, 'bind_address' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $this->assertSame( $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, + 'authentication' => new MySQL_Authentication( 'user', '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_Slow_Client_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Slow_Client_Test.php new file mode 100644 index 000000000..f15998af9 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Slow_Client_Test.php @@ -0,0 +1,44 @@ +port ); + $stalled = socket_export_stream( $socket ); + $this->log_in( $stalled ); + $value = str_repeat( 'x', 65536 ); + $query = "WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 128) SELECT '$value' AS v FROM r"; + fwrite( $stalled, MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . $query ) ); + + // The result has started to arrive, and the client stops reading it. + $this->assertSame( "\x01", substr( MySQL_Test_Client::read_packet( $stalled ), 4 ) ); + + // Another client is served meanwhile. + $other = stream_socket_client( "tcp://127.0.0.1:{$this->port}", $errno, $error, 2 ); + $this->log_in( $other ); + fwrite( $other, MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ) ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_packet( $other )[4] ) ); + fclose( $other ); + + // The first client still gets the whole result once it reads. + MySQL_Test_Client::read_packet( $stalled ); // Column definition. + MySQL_Test_Client::read_packet( $stalled ); // EOF. + for ( $i = 0; $i < 128; $i++ ) { + $this->assertSame( "\xfd\0\0\x01" . $value, substr( MySQL_Test_Client::read_packet( $stalled ), 4 ) ); + } + $this->assertSame( 0xfe, ord( MySQL_Test_Client::read_packet( $stalled )[4] ) ); + fclose( $stalled ); + } + + private function log_in( $stream ): void { + stream_set_timeout( $stream, 2 ); + $challenge = MySQL_Test_Client::challenge( MySQL_Test_Client::read_packet( $stream ) ); + fwrite( $stream, MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Test_Client::auth_plugin() ) ); + $this->assertSame( 0, ord( MySQL_Test_Client::read_login_result( $stream )[4] ) ); + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php index b4504a030..cd0d8f962 100644 --- a/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php +++ b/packages/mysql-proxy/tests/WP_MySQL_Proxy_Test.php @@ -4,16 +4,24 @@ abstract class WP_MySQL_Proxy_Test extends TestCase { /** @var int */ - protected $port = 3306; + protected $port; + + /** @var string */ + protected $log_level = 'error'; /** @var MySQL_Server_Process */ protected $server; public function setUp(): void { + // Keep the tests away from any local MySQL server on its default port. + $this->port = MySQL_Server_Process::free_port(); + $this->server = new MySQL_Server_Process( array( - 'port' => $this->port, - 'db_path' => ':memory:', + 'port' => $this->port, + 'db_path' => ':memory:', + 'log_level' => $this->log_level, + 'auth_plugin' => $this->get_auth_plugin(), ) ); } @@ -32,4 +40,8 @@ public function tearDown(): void { ); } } + + protected function get_auth_plugin(): string { + return MySQL_Test_Client::auth_plugin(); + } } diff --git a/packages/mysql-proxy/tests/WP_MySQL_SQLite_Adapter_Test.php b/packages/mysql-proxy/tests/WP_MySQL_SQLite_Adapter_Test.php new file mode 100644 index 000000000..39e649e37 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_SQLite_Adapter_Test.php @@ -0,0 +1,74 @@ +handle_query( 'SELECT * FROM missing_table' ); + $this->assertSame( array( '42S02', 1146, "Table 'missing_table' doesn't exist" ), $result->error_info ); + } + + public function test_driver_message_is_reported(): void { + $cause = new RuntimeException( 'Unsupported ALTER TABLE ADD action: X' ); + $result = $this->adapter_failing_with( $cause )->handle_query( 'SELECT 1' ); + $this->assertSame( array( 'HY000', 1105, 'Unsupported ALTER TABLE ADD action: X' ), $result->error_info ); + } + + /** @dataProvider internal_errors */ + public function test_internal_error_is_not_reported( Throwable $cause ): void { + $this->expectException( WP_MySQL_On_SQLite_Exception::class ); + $this->adapter_failing_with( $cause )->handle_query( 'SELECT 1' ); + } + + public function internal_errors(): array { + return array( + 'PHP error' => array( new TypeError( 'Private detail from /srv/secret/path.php' ) ), + 'wrapped PHP error' => array( new RuntimeException( 'Wrapper', 0, new TypeError( 'Private detail' ) ) ), + ); + } + + public function test_internal_error_does_not_reach_the_client(): void { + $cause = new TypeError( 'f(): Argument #1 must be of type string, called in /srv/secret/path.php on line 1' ); + $session = new MySQL_Session( $this->adapter_failing_with( $cause ), 1, new MySQL_Authentication( 'user', 'password' ) ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); + $this->assertTrue( $session->is_authenticated() ); + + // The detail goes to the log, which the test runner shows on stderr. + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . 'SELECT 1' ) ); + $this->assertSame( 1105, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( 'Unknown error', substr( $response, 13 ) ); + $this->assertStringNotContainsString( '/srv/secret/path.php', $response ); + } + + /** + * An adapter whose driver fails a query the way the driver reports any error. + */ + private function adapter_failing_with( Throwable $cause ): SQLite_Adapter { + $adapter = new SQLite_Adapter( ':memory:' ); + $property = new ReflectionProperty( SQLite_Adapter::class, 'sqlite_driver' ); + if ( PHP_VERSION_ID < 80100 ) { + $property->setAccessible( true ); + } + $exception = new WP_MySQL_On_SQLite_Exception( $property->getValue( $adapter ), $cause->getMessage(), $cause->getCode(), $cause ); + $property->setValue( + $adapter, + new class( $exception ) { + private $exception; + + public function __construct( Throwable $exception ) { + $this->exception = $exception; + } + + public function query(): void { + throw $this->exception; + } + } + ); + return $adapter; + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php new file mode 100644 index 000000000..d6352d3fc --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Authentication_Test.php @@ -0,0 +1,457 @@ +createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account( $plugin ) ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $response = $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', $plugin, '', $flags ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + if ( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD === $plugin ) { + $this->assertSame( "\x02\0\0\x02\x01\x03", substr( $response, 0, 6 ) ); + $this->assertSame( 3, ord( $response[9] ) ); + $this->assertSame( 0, ord( $response[10] ) ); + } else { + $this->assertSame( 2, ord( $response[3] ) ); + $this->assertSame( 0, ord( $response[4] ) ); + } + } + + public function authentication_methods(): array { + $base = MySQL_Protocol::CLIENT_PROTOCOL_41 | MySQL_Protocol::CLIENT_SECURE_CONNECTION; + $plugin = $base | MySQL_Protocol::CLIENT_PLUGIN_AUTH; + $lenenc = $plugin | MySQL_Protocol::CLIENT_PLUGIN_AUTH_LENENC_CLIENT_DATA; + return array( + array( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, $plugin ), + array( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, $lenenc ), + array( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, $plugin ), + array( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, $lenenc ), + array( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, $base ), + ); + } + + /** @dataProvider rejected_logins */ + public function test_failed_login_never_calls_adapter( string $username, string $password, string $plugin ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account( $plugin ) ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $login = MySQL_Test_Client::handshake_response( $challenge, $username, $password, $plugin, 'private_database' ); + $query = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . 'DELETE FROM wp_users' ); + $response = $session->receive_bytes( $login . $query ); + if ( 'mysql_clear_password' === $plugin + || ( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD === $plugin && '' === $password ) + ) { + $this->assertSame( 0xfe, ord( $response[4] ) ); + $this->assertFalse( $session->is_authenticated() ); + $response = $session->receive_bytes( '' ); + } + $this->assert_denied( $session, $response ); + $this->assertFalse( $session->has_buffered_data() ); + $this->assertNull( $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) . $query ) ); + } + + public function rejected_logins(): array { + $cases = array(); + foreach ( array( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ) as $plugin ) { + $cases[] = array( 'other', 'password', $plugin ); + $cases[] = array( 'user', 'wrong', $plugin ); + $cases[] = array( 'user', '', $plugin ); + $cases[] = array( '', 'password', $plugin ); + } + $cases[] = array( 'user', 'password', 'mysql_clear_password' ); + return $cases; + } + + public function test_native_authentication_switch_preserves_database_and_buffered_command(): void { + $queries = array(); + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->willReturnCallback( + function ( string $query ) use ( &$queries ): MySQL_Result { + $queries[] = $query; + return new MySQL_Result(); + } + ); + $session = new MySQL_Session( $adapter, 1, $this->account( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ) ); + $challenge = $this->start_native_authentication( $session, 'user', 'a`b' ); + $this->assertSame( array(), $queries ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $reply = MySQL_Protocol::build_packet( 3, $token ); + try { + $session->receive_bytes( substr( $reply, 0, 7 ) ); + $this->fail( 'A partial authentication response must wait for more input.' ); + } catch ( Incomplete_Input_Exception $e ) { + $this->assertFalse( $session->is_authenticated() ); + $this->assertSame( array(), $queries ); + } + $ping = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ); + $response = $session->receive_bytes( substr( $reply, 7 ) . $ping ); + $this->assertSame( 4, ord( $response[3] ) ); + $this->assertSame( 0, ord( $response[4] ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + $this->assertSame( array( 'USE `a``b`' ), $queries ); + $this->assertTrue( $session->has_buffered_data() ); + $this->assertSame( 0, ord( $session->receive_bytes( '' )[4] ) ); + $this->assertFalse( $session->has_buffered_data() ); + } + + /** @dataProvider invalid_native_responses */ + public function test_invalid_native_switch_response_is_denied( string $username, string $password, int $sequence, int $length ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ) ); + $challenge = $this->start_native_authentication( $session, $username, 'private_database' ); + $token = MySQL_Test_Client::scramble( $password, $challenge, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $token = substr( $token . "\0", 0, $length ); + $query = MySQL_Protocol::build_packet( 0, "\x03DELETE FROM wp_users" ); + $this->assert_denied( $session, $session->receive_bytes( MySQL_Protocol::build_packet( $sequence, $token ) . $query ) ); + $this->assertFalse( $session->has_buffered_data() ); + $this->assertNull( $session->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ) ); + } + + public function invalid_native_responses(): array { + return array( + array( 'other', 'password', 3, 20 ), + array( 'user', 'wrong', 3, 20 ), + array( 'user', '', 3, 0 ), + array( 'user', 'password', 3, 19 ), + array( 'user', 'password', 3, 21 ), + array( 'user', 'password', 0, 20 ), + array( 'user', 'password', 1, 20 ), + array( 'user', 'password', 2, 20 ), + array( 'user', 'password', 4, 20 ), + ); + } + + /** @dataProvider unsupported_plugins */ + public function test_unsupported_plugin_switches_to_caching_sha2_password( string $plugin, string $password ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = $this->start_authentication_switch( $session, $plugin, $password, MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD ); + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ); + $this->assertSame( "\x02\0\0\x04\x01\x03", substr( $response, 0, 6 ) ); + $this->assertSame( 5, ord( $response[9] ) ); + $this->assertSame( 0, ord( $response[10] ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + } + + public function unsupported_plugins(): array { + return array( + array( 'sha256_password', '' ), + array( 'mysql_clear_password', 'password' ), + array( '', '' ), + ); + } + + public function test_switch_targets_the_plugin_the_account_accepts(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $native = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD; + $session = new MySQL_Session( $adapter, 1, $this->account( $native ) ); + $challenge = $this->start_authentication_switch( $session, MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 'password', $native ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, $native ); + $session->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + } + + /** @dataProvider credentials_without_plugin_auth */ + public function test_client_that_cannot_switch_gets_unsupported_auth_mode( string $username, string $password ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $flags = MySQL_Protocol::CLIENT_PROTOCOL_41 | MySQL_Protocol::CLIENT_SECURE_CONNECTION | MySQL_Protocol::CLIENT_CONNECT_WITH_DB; + $login = MySQL_Test_Client::handshake_response( $challenge, $username, $password, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, 'private_database', $flags ); + $query = MySQL_Protocol::build_packet( 0, "\x03DELETE FROM wp_users" ); + $response = $session->receive_bytes( $login . $query ); + $this->assertSame( 2, ord( $response[3] ) ); + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1251, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( '#08004', substr( $response, 7, 6 ) ); + $this->assertFalse( $session->is_authenticated() ); + $this->assertTrue( $session->is_closed() ); + $this->assertFalse( $session->has_buffered_data() ); + } + + public function credentials_without_plugin_auth(): array { + // The answer must not depend on the credentials, so it reveals nothing about them. + return array( + array( 'user', 'password' ), + array( 'user', 'wrong' ), + array( 'other', 'password' ), + ); + } + + /** @dataProvider invalid_caching_sha2_switch_responses */ + public function test_invalid_caching_sha2_switch_response_is_denied( string $password, string $plugin, int $sequence ): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = $this->start_authentication_switch( $session, 'sha256_password', '', MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 'user', 'private_database' ); + $token = MySQL_Test_Client::scramble( $password, $challenge, $plugin ); + $this->assert_denied( $session, $session->receive_bytes( MySQL_Protocol::build_packet( $sequence, $token ) ) ); + } + + public function invalid_caching_sha2_switch_responses(): array { + return array( + array( 'wrong', MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 3 ), + array( '', MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 3 ), + array( 'password', MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD, 3 ), + array( 'password', MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, 4 ), + ); + } + + public function test_native_switch_response_cannot_be_replayed(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $account = $this->account( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $first = new MySQL_Session( $adapter, 1, $account ); + $second = new MySQL_Session( $adapter, 2, $account ); + $challenge = $this->start_native_authentication( $first ); + $this->start_native_authentication( $second ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $first->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ); + $this->assertTrue( $first->is_authenticated() ); + $this->assert_denied( $second, $second->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ) ); + } + + public function test_database_is_quoted_after_authentication(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->with( 'USE `a``; DROP TABLE wp_users`' )->willReturn( new MySQL_Result() ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Protocol::DEFAULT_AUTH_PLUGIN, 'a`; DROP TABLE wp_users' ) ); + $this->assertTrue( $session->is_authenticated() ); + } + + /** @dataProvider database_errors */ + public function test_database_failure_closes_connection( bool $throws, bool $native_switch ): void { + $adapter = $this->createMock( Adapter::class ); + $expectation = $adapter->expects( $this->once() )->method( 'handle_query' ); + if ( $throws ) { + $expectation->willThrowException( new RuntimeException( 'Private adapter error' ) ); + } else { + $expectation->willReturn( MySQL_Result::from_error( '42000', 1049, 'Unknown database' ) ); + } + $plugin = $native_switch ? MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD : MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD; + $session = new MySQL_Session( $adapter, 1, $this->account( $plugin ) ); + if ( $native_switch ) { + $challenge = $this->start_native_authentication( $session, 'user', 'unknown' ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ); + $login = MySQL_Protocol::build_packet( 3, $token ); + } else { + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $login = MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Protocol::DEFAULT_AUTH_PLUGIN, 'unknown' ); + } + $response = $session->receive_bytes( $login . MySQL_Protocol::build_packet( 0, "\x03SELECT 1" ) ); + $this->assertSame( 1049, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertFalse( $session->is_authenticated() ); + $this->assertTrue( $session->is_closed() ); + $this->assertFalse( $session->has_buffered_data() ); + $this->assertStringNotContainsString( 'Private adapter error', $response ); + } + + public function database_errors(): array { + return array( array( false, false ), array( true, false ), array( false, true ), array( true, true ) ); + } + + public function test_challenge_has_the_characters_mysql_uses(): void { + // Like MySQL, 7-bit characters other than NUL and '$', so clients can read it as text. + $adapter = $this->createMock( Adapter::class ); + for ( $i = 0; $i < 100; $i++ ) { + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $this->assertSame( 1, preg_match( '/^[\x01-\x23\x25-\x7f]{20}$/', $challenge ), bin2hex( $challenge ) ); + } + } + + /** @dataProvider account_plugins */ + public function test_handshake_asks_for_the_account_plugin( string $plugin ): void { + $session = new MySQL_Session( $this->createMock( Adapter::class ), 1, $this->account( $plugin ) ); + $this->assertStringEndsWith( "\0" . $plugin . "\0", $session->get_initial_handshake() ); + } + + public function account_plugins(): array { + return array( + array( MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD ), + array( MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD ), + ); + } + + public function test_response_cannot_be_replayed_on_another_connection(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $account = $this->account(); + $first = new MySQL_Session( $adapter, 1, $account ); + $second = new MySQL_Session( $adapter, 2, $account ); + $salt = MySQL_Test_Client::challenge( $first->get_initial_handshake() ); + $this->assertSame( 20, strlen( $salt ) ); + $this->assertStringNotContainsString( "\0", $salt ); + $this->assertNotSame( $salt, MySQL_Test_Client::challenge( $second->get_initial_handshake() ) ); + $login = MySQL_Test_Client::handshake_response( $salt ); + $first->receive_bytes( $login ); + $this->assertTrue( $first->is_authenticated() ); + $this->assert_denied( $second, $second->receive_bytes( $login ) ); + } + + public function test_every_handshake_truncated_before_the_plugin_name_is_denied(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $account = $this->account(); + $packet_length = strlen( MySQL_Test_Client::handshake_response( str_repeat( 'x', 20 ), 'user', 'password', MySQL_Protocol::DEFAULT_AUTH_PLUGIN, 'db' ) ); + $plugin_offset = $packet_length - 4 - strlen( MySQL_Protocol::DEFAULT_AUTH_PLUGIN ) - 1; + for ( $length = 0; $length < $plugin_offset; $length++ ) { + $session = new MySQL_Session( $adapter, 1, $account ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $payload = substr( MySQL_Test_Client::handshake_response( $challenge, 'user', 'password', MySQL_Protocol::DEFAULT_AUTH_PLUGIN, 'db' ), 4 ); + $this->assert_denied( $session, $session->receive_bytes( MySQL_Protocol::build_packet( 1, substr( $payload, 0, $length ) ) ) ); + } + } + + public function test_truncated_plugin_name_switches_to_the_account_plugin(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $account = $this->account(); + $plugin = MySQL_Protocol::DEFAULT_AUTH_PLUGIN; + + // Like MySQL, a missing or unterminated plugin name is read as empty. + for ( $cut = 1; $cut <= strlen( $plugin ) + 1; $cut++ ) { + $session = new MySQL_Session( $adapter, 1, $account ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $payload = substr( MySQL_Test_Client::handshake_response( $challenge ), 4, -$cut ); + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 1, $payload ) ); + $this->assertSame( MySQL_Protocol::build_packet( 2, "\xfe" . $plugin . "\0" . $challenge . "\0" ), $response ); + $token = MySQL_Test_Client::scramble( 'password', $challenge, $plugin ); + $session->receive_bytes( MySQL_Protocol::build_packet( 3, $token ) ); + $this->assertTrue( $session->is_authenticated() ); + } + } + + public function test_fragmented_handshake_and_pipelined_command(): void { + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = new MySQL_Session( $adapter, 1, $this->account() ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $login = MySQL_Test_Client::handshake_response( $challenge ); + for ( $i = 0; $i < strlen( $login ) - 1; $i++ ) { + try { + $session->receive_bytes( $login[ $i ] ); + $this->fail( 'An incomplete packet must wait for more input.' ); + } catch ( Incomplete_Input_Exception $e ) { + $this->assertFalse( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + } + } + $session->receive_bytes( substr( $login, -1 ) . MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_PING ) ) ); + $this->assertTrue( $session->is_authenticated() ); + $this->assertTrue( $session->has_buffered_data() ); + $this->assertSame( 0, ord( $session->receive_bytes( '' )[4] ) ); + $this->assertFalse( $session->has_buffered_data() ); + } + + 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( pack( 'V', $base | MySQL_Protocol::CLIENT_PLUGIN_AUTH ) . str_repeat( "\0", 28 ) . "user\0\x01\0caching_sha2_password\0", 1 ), + ); + } + + /** + * An account using the given plugin, or the default for a plugin no account can use. + */ + private function account( string $plugin = MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD ): MySQL_Authentication { + if ( ! in_array( $plugin, MySQL_Authentication::SUPPORTED_PLUGINS, true ) ) { + $plugin = MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD; + } + return new MySQL_Authentication( 'user', 'password', $plugin ); + } + + private function start_native_authentication( MySQL_Session $session, string $username = 'user', string $database = '' ): string { + $plugin = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD; + return $this->start_authentication_switch( $session, $plugin, '', $plugin, $username, $database ); + } + + private function start_authentication_switch( + MySQL_Session $session, + string $client_plugin, + string $password, + string $plugin, + string $username = 'user', + string $database = '' + ): string { + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $response = $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge, $username, $password, $client_plugin, $database ) ); + $this->assertSame( MySQL_Protocol::build_packet( 2, "\xfe" . $plugin . "\0" . $challenge . "\0" ), $response ); + $this->assertFalse( $session->is_authenticated() ); + $this->assertFalse( $session->is_closed() ); + return $challenge; + } + + private function assert_denied( MySQL_Session $session, string $response ): void { + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1045, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( '#28000', substr( $response, 7, 6 ) ); + $this->assertFalse( $session->is_authenticated() ); + $this->assertTrue( $session->is_closed() ); + } +} diff --git a/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php new file mode 100644 index 000000000..02bec81c4 --- /dev/null +++ b/packages/mysql-proxy/tests/WP_MySQL_Session_Command_Test.php @@ -0,0 +1,221 @@ +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' ) ); + } + + // The adapter's reason goes to the log, not to the client. + $logger = $this->createMock( Logger::class ); + $logger->expects( $this->once() )->method( $throws ? 'error' : 'warning' )->with( + $this->anything(), + $this->callback( + function ( array $context ): bool { + return 'Private adapter error' === $context['error']; + } + ) + ); + + $session = $this->authenticated_session( $adapter, $logger ); + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_INIT_DB ) . 'missing' ) ); + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1049, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( "#42000Unknown database 'missing'", substr( $response, 7 ) ); + $this->assertFalse( $session->is_closed(), 'Unlike the login, a failed COM_INIT_DB keeps the connection.' ); + } + + public function init_db_failures(): array { + return array( + 'adapter error' => array( false ), + 'adapter throws' => array( true ), + ); + } + + public function test_init_db_refuses_an_empty_name(): 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, chr( MySQL_Protocol::COM_INIT_DB ) ) ); + $this->assertSame( 0xff, ord( $response[4] ) ); + $this->assertSame( 1046, unpack( 'v', substr( $response, 5, 2 ) )[1] ); + $this->assertSame( '#3D000No database selected', substr( $response, 7 ) ); + $this->assertFalse( $session->is_closed() ); + } + + public function test_split_message_is_assembled(): void { + $this->iniSet( 'memory_limit', '512M' ); + $queries = array(); + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->willReturnCallback( + function ( string $query ) use ( &$queries ): MySQL_Result { + $queries[] = $query; + return MySQL_Result::from_data( 0, 0, array(), array() ); + } + ); + $session = $this->authenticated_session( $adapter ); + + // A packet with a full payload continues in the next packet. + $prefix = chr( MySQL_Protocol::COM_QUERY ) . 'SELECT 1 -- '; + $packet = MySQL_Protocol::build_packet( 0, $prefix . str_repeat( 'x', MySQL_Protocol::MAX_PACKET_LENGTH - strlen( $prefix ) ) ); + try { + $session->receive_bytes( $packet ); + $this->fail( 'A split message must wait for the next packet.' ); + } catch ( Incomplete_Input_Exception $e ) { + $this->assertSame( array(), $queries, 'The first packet must not run on its own.' ); + } + unset( $packet ); + + // One more byte makes the message exactly 16 MB, the limit. + $response = $session->receive_bytes( MySQL_Protocol::build_packet( 1, '1' ) ); + $this->assertSame( 0, ord( $response[4] ) ); + $this->assertSame( 2, ord( $response[3] ), 'The response continues the client sequence.' ); + $this->assertCount( 1, $queries ); + $this->assertSame( MySQL_Session::MAX_MESSAGE_SIZE - 1, strlen( $queries[0] ) ); + $this->assertStringEndsWith( 'xx1', $queries[0] ); + $this->assertFalse( $session->is_closed() ); + } + + public function test_oversized_message_is_refused(): void { + $this->iniSet( 'memory_limit', '512M' ); + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->never() )->method( 'handle_query' ); + $session = $this->authenticated_session( $adapter ); + + $packet = MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . str_repeat( 'x', MySQL_Protocol::MAX_PACKET_LENGTH - 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_internal_error_is_logged_where_it_was_raised(): void { + $cause = new TypeError( 'Private detail' ); + $line = __LINE__ - 1; + $adapter = $this->createMock( Adapter::class ); + $adapter->expects( $this->once() )->method( 'handle_query' )->willThrowException( new RuntimeException( 'Wrapper', 0, $cause ) ); + + // The log names the wrapped PHP error, not the wrapper. + $logger = $this->createMock( Logger::class ); + $logger->expects( $this->once() )->method( 'error' )->with( + 'Client [{client_id}]: internal error: {class}: {error} in {file}:{line}', + array( + 'client_id' => 1, + 'class' => TypeError::class, + 'error' => 'Private detail', + 'file' => __FILE__, + 'line' => $line, + ) + ); + + $session = $this->authenticated_session( $adapter, $logger ); + $session->receive_bytes( MySQL_Protocol::build_packet( 0, chr( MySQL_Protocol::COM_QUERY ) . 'SELECT 1' ) ); + } + + 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, ?Logger $logger = null ): MySQL_Session { + $session = new MySQL_Session( $adapter, 1, new MySQL_Authentication( 'user', 'password' ), $logger ); + $challenge = MySQL_Test_Client::challenge( $session->get_initial_handshake() ); + $session->receive_bytes( MySQL_Test_Client::handshake_response( $challenge ) ); + $this->assertTrue( $session->is_authenticated() ); + 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..6c5f19875 100644 --- a/packages/mysql-proxy/tests/bootstrap/mysql-server-process.php +++ b/packages/mysql-proxy/tests/bootstrap/mysql-server-process.php @@ -14,8 +14,11 @@ public function __construct( array $options = array() ) { $env = array_merge( $_ENV, array( - 'PORT' => $port, - 'DB_PATH' => $options['db_path'] ?? ':memory:', + 'PORT' => $port, + 'DB_PATH' => $options['db_path'] ?? ':memory:', + 'LOG_LEVEL' => $options['log_level'] ?? 'error', + 'AUTH_PLUGIN' => $options['auth_plugin'] ?? 'caching_sha2_password', + 'BIND_ADDRESS' => $options['bind_address'] ?? '127.0.0.1', ) ); $this->process = new Process( @@ -27,7 +30,10 @@ public function __construct( array $options = array() ) { // Wait for the server to be ready. for ( $i = 0; $i < 20; $i++ ) { - $connection = @fsockopen( '127.0.0.1', $port ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged + if ( ! $this->process->isRunning() ) { + break; + } + $connection = @fsockopen( $options['connect_address'] ?? '127.0.0.1', $port, $errno, $error, 0.1 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged if ( $connection ) { fclose( $connection ); return; @@ -69,4 +75,17 @@ public function get_stderr(): string { } return $this->process->getErrorOutput(); } + + /** + * Find a free local port for a server. + * + * The port is released before the server binds it, so another process could + * take it in between, which is unlikely in a test run. + */ + public static function free_port(): int { + $listener = stream_socket_server( 'tcp://127.0.0.1:0', $errno, $error ); + $address = stream_socket_get_name( $listener, false ); + fclose( $listener ); + return (int) substr( strrchr( $address, ':' ), 1 ); + } } diff --git a/packages/mysql-proxy/tests/bootstrap/mysql-test-client.php b/packages/mysql-proxy/tests/bootstrap/mysql-test-client.php new file mode 100644 index 000000000..d12437593 --- /dev/null +++ b/packages/mysql-proxy/tests/bootstrap/mysql-test-client.php @@ -0,0 +1,91 @@ += 70404 + ? MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD + : MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD; + } + + /** + * Read the result of a login, skipping the caching_sha2_password fast path packet. + */ + public static function read_login_result( $stream ): string { + $packet = self::read_packet( $stream ); + if ( "\x01\x03" === substr( $packet, 4 ) ) { + return self::read_packet( $stream ); + } + return $packet; + } + + public static function handshake_response( + string $challenge, + string $username = 'user', + string $password = 'password', + string $plugin = MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD, + string $database = '', + ?int $flags = null + ): string { + $flags = $flags ?? ( MySQL_Protocol::CLIENT_PROTOCOL_41 | MySQL_Protocol::CLIENT_SECURE_CONNECTION + | MySQL_Protocol::CLIENT_PLUGIN_AUTH | MySQL_Protocol::CLIENT_CONNECT_WITH_DB ); + $token = self::scramble( $password, $challenge, $plugin ); + $data = pack( 'VVC', $flags, 0, MySQL_Protocol::CHARSET_UTF8MB4 ) . str_repeat( "\0", 23 ); + $data .= $username . "\0" . chr( strlen( $token ) ) . $token; + if ( $flags & MySQL_Protocol::CLIENT_CONNECT_WITH_DB ) { + $data .= $database . "\0"; + } + if ( $flags & MySQL_Protocol::CLIENT_PLUGIN_AUTH ) { + $data .= $plugin . "\0"; + } + return MySQL_Protocol::build_packet( 1, $data ); + } + + public static function scramble( string $password, string $challenge, string $plugin ): string { + if ( 'mysql_clear_password' === $plugin ) { + return $password . "\0"; + } + if ( '' === $password ) { + return ''; + } + $algorithm = MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD === $plugin ? 'sha1' : 'sha256'; + $stage1 = hash( $algorithm, $password, true ); + $stage2 = hash( $algorithm, $stage1, true ); + $input = 'sha1' === $algorithm ? $challenge . $stage2 : $stage2 . $challenge; + return $stage1 ^ hash( $algorithm, $input, true ); + } + + public static function challenge( string $handshake ): string { + $offset = strpos( $handshake, "\0", 5 ) + 5; + return substr( $handshake, $offset, 8 ) . substr( $handshake, $offset + 27, 12 ); + } + + public static function read_packet( $stream ): string { + $header = self::read_bytes( $stream, 4 ); + $length = unpack( 'V', substr( $header, 0, 3 ) . "\0" )[1]; + return $header . self::read_bytes( $stream, $length ); + } + + private static function read_bytes( $stream, int $length ): string { + $data = ''; + while ( strlen( $data ) < $length ) { + $chunk = fread( $stream, $length - strlen( $data ) ); + if ( false === $chunk || '' === $chunk ) { + throw new RuntimeException( 'Connection closed or timed out before a complete packet arrived.' ); + } + $data .= $chunk; + } + return $data; + } +} diff --git a/packages/mysql-proxy/tests/bootstrap/run-server.php b/packages/mysql-proxy/tests/bootstrap/run-server.php index 6b58e2abd..d0d9128a7 100644 --- a/packages/mysql-proxy/tests/bootstrap/run-server.php +++ b/packages/mysql-proxy/tests/bootstrap/run-server.php @@ -1,17 +1,23 @@ $port ) + array( + 'port' => $port, + 'authentication' => new MySQL_Authentication( 'user', 'password', getenv( 'AUTH_PLUGIN' ) ), + 'log_level' => getenv( 'LOG_LEVEL' ), + 'bind_address' => getenv( 'BIND_ADDRESS' ), + ) ); $proxy->start();