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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .github/workflows/mysql-proxy-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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
Expand Down
24 changes: 20 additions & 4 deletions packages/mysql-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,18 @@ composer require wordpress/mysql-proxy wordpress/mysql-on-sqlite:^3.0
### CLI:

```bash
$ ./vendor/bin/wp-mysql-proxy.php [--port <port>] [--database <path/to/db.sqlite>] [--log-level <log_level>]
$ ./vendor/bin/wp-mysql-proxy.php --user <username> [--password-file <path>] [--bind-address <address>] [--port <port>] [--database <path/to/db.sqlite>] [--log-level <log_level>]

Options:
-h, --help Show this help message and exit.
-p, --port=<port> The port to listen on. Default: 3306
-u, --user=<username> The proxy account name. Required.
--password-file=<path> Read the password from a file instead of WP_MYSQL_PROXY_PASSWORD.
--bind-address=<ip> The IPv4 or IPv6 address to listen on. Default: 127.0.0.1
--auth-plugin=<name> 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=<port> The port to listen on. Default: 3306
-d, --database=<path> The path to the SQLite database file. Default: :memory:
-l, --log-level=<level> The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info
```
Expand All @@ -45,7 +52,7 @@ When working from a checked-out repository, Composer does not create a proxy for
the root package's own binary. Run it directly from the package directory:

```bash
php bin/wp-mysql-proxy.php [--port <port>] [--database <path/to/db.sqlite>] [--log-level <log_level>]
php bin/wp-mysql-proxy.php --user <username> [--password-file <path>] [--bind-address <address>] [--port <port>] [--database <path/to/db.sqlite>] [--log-level <log_level>]
```

### PHP:
Expand All @@ -57,7 +64,16 @@ require_once __DIR__ . '/vendor/autoload.php';

$proxy = new MySQL_Proxy(
new SQLite_Adapter( $db_path ),
array( 'port' => $port, 'log_level' => $log_level )
array(
'username' => 'proxy',
'password' => getenv( 'WP_MYSQL_PROXY_PASSWORD' ),
'port' => $port,
'log_level' => $log_level,
)
);
$proxy->start();
```

The proxy listens on `127.0.0.1` by default. Set `--bind-address` (or the PHP
`bind_address` option) to an IPv4 or IPv6 address to change the listener. Binding
to `0.0.0.0` or `::` explicitly exposes the proxy on all interfaces.
131 changes: 118 additions & 13 deletions packages/mysql-proxy/bin/wp-mysql-proxy.php
Original file line number Diff line number Diff line change
@@ -1,50 +1,150 @@
<?php declare( strict_types = 1 );

use WP_MySQL_Proxy\MySQL_Authentication;
use WP_MySQL_Proxy\MySQL_Protocol;
use WP_MySQL_Proxy\MySQL_Proxy;
use WP_MySQL_Proxy\Adapter\SQLite_Adapter;
use WP_MySQL_Proxy\Logger;

require_once $_composer_autoload_path ?? __DIR__ . '/../vendor/autoload.php';

// Process CLI arguments:
$shortopts = 'hd:p:l:';
$longopts = array( 'help', 'database:', 'port:', 'log-level:' );
$opts = getopt( $shortopts, $longopts );
// Parse every argument: getopt() silently discards unknown or incomplete options.
$aliases = array(
'h' => 'help',
'd' => 'database',
'P' => 'port',
'l' => 'log-level',
'u' => 'user',
);
$options = array( 'help', 'database', 'port', 'log-level', 'bind-address', 'user', 'password-file', 'auth-plugin' );
$args = array_slice( $argv, 1 );
$opts = array();

// Never include argument values in errors: an unknown option may contain a password.
$option_error = "Error: Invalid command-line options. Use --help for supported options and required values.\n";

for ( $i = 0; $i < count( $args ); $i++ ) {
$arg = $args[ $i ];
$name = '';
$value = null;
if ( 0 === strpos( $arg, '--' ) ) {
$parts = explode( '=', substr( $arg, 2 ), 2 );
$name = $parts[0];
$value = $parts[1] ?? null;
} elseif ( '-' === substr( $arg, 0, 1 ) && strlen( $arg ) > 1 ) {
$name = $aliases[ $arg[1] ] ?? '';
if ( strlen( $arg ) > 2 ) {
if ( 'help' === $name ) {
array_splice( $args, $i + 1, 0, array( '-' . substr( $arg, 2 ) ) );
} else {
$value = substr( $arg, 2 );
}
}
}

if ( ! in_array( $name, $options, true ) || array_key_exists( $name, $opts )
|| ( 'help' === $name && null !== $value )
) {
fwrite( STDERR, $option_error );
exit( 1 );
}
if ( 'help' === $name ) {
$opts[ $name ] = true;
continue;
}
$value = $value ?? ( $args[ ++$i ] ?? null );
if ( null === $value ) {
fwrite( STDERR, $option_error );
exit( 1 );
}
$opts[ $name ] = $value;
}

$help = <<<USAGE
Usage: wp-mysql-proxy.php [--port <port>] [--database <path/to/db.sqlite>] [--log-level <log_level>]
Usage: wp-mysql-proxy.php --user <username> [--password-file <path>] [--bind-address <address>] [--port <port>] [--database <path/to/db.sqlite>] [--log-level <log_level>]

Options:
-h, --help Show this help message and exit.
-p, --port=<port> The port to listen on. Default: 3306
-u, --user=<username> The proxy account name. Required.
--password-file=<path> Read the password from a file instead of WP_MYSQL_PROXY_PASSWORD.
--bind-address=<ip> The IPv4 or IPv6 address to listen on. Default: 127.0.0.1
--auth-plugin=<name> 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=<port> The port to listen on. Default: 3306
-d, --database=<path> The path to the SQLite database file. Default: :memory:
-l, --log-level=<level> The log level to use. One of 'error', 'warning', 'info', 'debug'. Default: info

A nonempty password is required via WP_MYSQL_PROXY_PASSWORD or --password-file.
Password files are read verbatim, including any trailing newline.

USAGE;

// Help.
if ( isset( $opts['h'] ) || isset( $opts['help'] ) ) {
if ( isset( $opts['help'] ) ) {
fwrite( STDERR, $help );
exit( 0 );
}

// Database path.
$db_path = $opts['d'] ?? $opts['database'] ?? ':memory:';
$db_path = $opts['database'] ?? ':memory:';

// Validate options before SQLite_Adapter opens or creates the database file.
// MySQL_Proxy checks the bind address and credentials too, but only after that.

// Bind address.
$bind_address = $opts['bind-address'] ?? '127.0.0.1';
if ( false === filter_var( $bind_address, FILTER_VALIDATE_IP ) ) {
fwrite( STDERR, "Error: --bind-address must be a valid IPv4 or IPv6 address.\n" );
exit( 1 );
}

// Port.
$port = (int) ( $opts['p'] ?? $opts['port'] ?? 3306 );
if ( $port < 1 || $port > 65535 ) {
$port = filter_var( $opts['port'] ?? 3306, FILTER_VALIDATE_INT );
if ( false === $port || $port < 1 || $port > 65535 ) {
fwrite( STDERR, "Error: --port must be an integer between 1 and 65535. Use --help for more information.\n" );
exit( 1 );
}

// Log level.
$log_level = $opts['l'] ?? $opts['log-level'] ?? 'info';
$log_level = $opts['log-level'] ?? 'info';
if ( ! in_array( $log_level, Logger::LEVELS, true ) ) {
fwrite( STDERR, 'Error: --log-level must be one of: ' . implode( ', ', Logger::LEVELS ) . ". Use --help for more information.\n" );
exit( 1 );
}

// Credentials. Do not accept passwords as command-line arguments.
$username = $opts['user'] ?? '';
if ( '' === $username || false !== strpos( $username, "\0" ) ) {
fwrite( STDERR, "Error: --user must specify a nonempty username without NUL bytes.\n" );
exit( 1 );
}

// Authentication plugin.
$auth_plugin = $opts['auth-plugin'] ?? MySQL_Protocol::DEFAULT_AUTH_PLUGIN;
if ( ! in_array( $auth_plugin, MySQL_Authentication::SUPPORTED_PLUGINS, true ) ) {
fwrite(
STDERR,
'Error: --auth-plugin must be one of: ' . implode( ', ', MySQL_Authentication::SUPPORTED_PLUGINS ) . ".\n"
);
exit( 1 );
}

$password = getenv( 'WP_MYSQL_PROXY_PASSWORD' );
if ( isset( $opts['password-file'] ) ) {
$password_file = $opts['password-file'];
if ( ! is_file( $password_file ) || ! is_readable( $password_file ) ) {
fwrite( STDERR, "Error: --password-file must name a readable file.\n" );
exit( 1 );
}
$password = file_get_contents( $password_file );
}
if ( false === $password || '' === $password || false !== strpos( $password, "\0" ) ) {
fwrite( STDERR, "Error: Set a nonempty password without NUL bytes using WP_MYSQL_PROXY_PASSWORD or --password-file.\n" );
exit( 1 );
}

if ( ! class_exists( 'WP_MySQL_On_SQLite' ) ) {
fwrite(
STDERR,
Expand All @@ -57,8 +157,13 @@
$proxy = new MySQL_Proxy(
new SQLite_Adapter( $db_path ),
array(
'port' => $port,
'log_level' => $log_level,
'username' => $username,
'password' => $password,
'auth_plugin' => $auth_plugin,
'bind_address' => $bind_address,
'port' => $port,
'log_level' => $log_level,
)
);
unset( $password );
$proxy->start();
4 changes: 4 additions & 0 deletions packages/mysql-proxy/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
},
"require": {
"php": ">=7.2",
"ext-filter": "*",
"ext-hash": "*",
"ext-sockets": "*",
"composer-runtime-api": "^2.2"
},
Expand All @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion packages/mysql-proxy/src/Adapter/class-sqlite-adapter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
120 changes: 120 additions & 0 deletions packages/mysql-proxy/src/class-mysql-authentication.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
<?php declare( strict_types = 1 );

namespace WP_MySQL_Proxy;

use InvalidArgumentException;

/**
* Verifies MySQL challenge responses for a configured proxy account.
*/
class MySQL_Authentication {
/**
* The account name.
*
* @var string
*/
private $username;

/**
* Authentication plugins an account can use.
*/
const SUPPORTED_PLUGINS = array(
MySQL_Protocol::AUTH_PLUGIN_CACHING_SHA2_PASSWORD,
MySQL_Protocol::AUTH_PLUGIN_MYSQL_NATIVE_PASSWORD,
);

/**
* The authentication plugin of this account.
*
* @var string
*/
private $plugin;

/**
* The double password hash for the account's plugin.
*
* @var string
*/
private $password_hash;

/**
* Configure the account. The plaintext password is not retained.
*
* Like MySQL, an account uses a single authentication plugin, so a client
* cannot choose a weaker one.
*
* @param string $username The nonempty account name.
* @param string $password The nonempty password.
* @param string $plugin One of SUPPORTED_PLUGINS. Default: caching_sha2_password.
*/
public function __construct(
string $username,
string $password,
string $plugin = MySQL_Protocol::DEFAULT_AUTH_PLUGIN
) {
if ( '' === $username || false !== strpos( $username, "\0" ) ) {
throw new InvalidArgumentException( 'A nonempty username without NUL bytes is required.' );
}
if ( '' === $password || false !== strpos( $password, "\0" ) ) {
throw new InvalidArgumentException( 'A nonempty password without NUL bytes is required.' );
}
if ( ! in_array( $plugin, self::SUPPORTED_PLUGINS, true ) ) {
throw new InvalidArgumentException(
'The authentication plugin must be one of: ' . implode( ', ', self::SUPPORTED_PLUGINS ) . '.'
);
}

$this->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;
}
}
Loading
Loading