From 702cb2ebb6fe36cd1e14d75f6e5630c59d3a1244 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 9 May 2026 10:55:49 -0600 Subject: [PATCH 1/2] fix(domain-mapping): avoid get_option(blog_charset) in early bootstrap (#1164) `Domain_Mapping::verify_dns_mapping()` is hooked on `pre_get_site_by_path` and `ms_site_not_found`, both of which fire from inside `ms_load_current_site_and_network()` at `wp-includes/ms-settings.php:77`. At that point `$wpdb->set_prefix()` (line 102 of the same file) has not yet run, so `$wpdb->options` is the empty string. The async DNS-check path called `wp_send_json($mapping->to_array())`, and `wp_send_json()` calls `get_option('blog_charset')` before sending the `Content-Type` header. With an empty `$wpdb->options`, `get_option()` issued the malformed query SELECT option_value FROM WHERE option_name = 'blog_charset' LIMIT 1 producing a noisy MariaDB syntax error on every triggering request. Replace the `wp_send_json()` call with a direct emit of `Content-Type: application/json; charset=UTF-8` (the WordPress default for `blog_charset`) plus `wp_json_encode()` and `exit`, none of which touch `get_option()` or `$wpdb->options`. Extract the emit step into a protected `send_async_dns_response()` method gated by a new `wu_async_dns_response_short_circuit` filter so plugins and tests can intercept the response without invoking `exit`. Add a regression test that simulates the empty-`$wpdb->options` state and asserts `verify_dns_mapping()` produces no `wpdb->last_error` and delivers the expected payload. Resolves #1164 --- inc/class-domain-mapping.php | 65 +++++++++++- tests/WP_Ultimo/Domain_Mapping_Test.php | 135 +++++++++++++++++++++--- 2 files changed, 182 insertions(+), 18 deletions(-) diff --git a/inc/class-domain-mapping.php b/inc/class-domain-mapping.php index 4cf9df21..1362e97e 100644 --- a/inc/class-domain-mapping.php +++ b/inc/class-domain-mapping.php @@ -372,12 +372,71 @@ public function verify_dns_mapping($current_site, $domain, $path) { // phpcs:ign $mapping = Domain::get_by_domain($domains); if ($mapping) { - wp_send_json($mapping->to_array()); + $this->send_async_dns_response($mapping->to_array()); } } } } + /** + * Emits the async DNS-check JSON response and terminates the request. + * + * This is invoked from {@see verify_dns_mapping()}, which fires very early + * in WordPress's multisite bootstrap (`pre_get_site_by_path` / + * `ms_site_not_found`) — before `$wpdb->set_prefix()` runs in + * `wp-includes/ms-settings.php`. At that point `$wpdb->options` is an + * empty string, so calling `wp_send_json()` is unsafe: it would call + * `get_option( 'blog_charset' )`, which executes + * `SELECT option_value FROM {$wpdb->options} WHERE option_name = 'blog_charset' LIMIT 1` + * with an empty table name and triggers a MariaDB syntax error + * ("WordPress database error You have an error in your SQL syntax … + * near 'WHERE option_name = 'blog_charset' LIMIT 1'"). + * + * We therefore emit the JSON directly with a hard-coded UTF-8 charset + * (the WordPress default for `blog_charset`) and `exit`, avoiding any + * dependency on `get_option()` or the options table. + * + * Extracted as a protected method so tests (and plugins) can intercept + * the emit-and-terminate step without invoking PHP's `exit` via the + * `wu_async_dns_response_short_circuit` filter. + * + * @since 2.10.2 + * + * @param array $payload Mapping data to encode as JSON. + * @return void + */ + protected function send_async_dns_response(array $payload): void { + + /** + * Filters the async DNS-check JSON response payload before it is emitted. + * + * Returning a non-null value short-circuits the default `header()` / + * `echo` / `exit` sequence so tests (and plugins) can inspect the + * payload without terminating the request. Any non-null return value + * causes this method to return immediately without sending headers, + * writing output, or exiting. + * + * @since 2.10.2 + * + * @param mixed $short_circuit Default `null`. Return any non-null value to + * short-circuit the default emit/exit behaviour. + * @param array $payload The mapping payload that would be JSON-encoded. + */ + $short_circuit = apply_filters('wu_async_dns_response_short_circuit', null, $payload); + + if (null !== $short_circuit) { + return; + } + + if ( ! headers_sent()) { + header('Content-Type: application/json; charset=UTF-8'); + } + + echo wp_json_encode($payload); + + exit; + } + /** * Checks if we have a site associated with the domain being accessed * @@ -646,8 +705,8 @@ public function replace_url($url, $current_mapping = null) { $domain = rtrim($domain . '/' . preg_quote(ltrim($path, '/'), '#'), '/'); } - $regex = '#^(\w+://)' . $domain . '#i'; - $mangled = preg_replace($regex, '${1}' . $current_mapping->get_domain(), $url); + $regex = '#^(\w+://)' . $domain . '#i'; + $mangled = preg_replace($regex, '${1}' . $current_mapping->get_domain(), $url); /* * Another try if we don't need to deal with subdirectory. diff --git a/tests/WP_Ultimo/Domain_Mapping_Test.php b/tests/WP_Ultimo/Domain_Mapping_Test.php index fea60ef9..c6a280f7 100644 --- a/tests/WP_Ultimo/Domain_Mapping_Test.php +++ b/tests/WP_Ultimo/Domain_Mapping_Test.php @@ -639,8 +639,14 @@ public function test_fix_srcset_no_mapping_returns_unchanged(): void { $this->domain_mapping->current_mapping = null; $sources = [ - 100 => ['url' => 'http://example.com/image-100.jpg', 'value' => 100], - 200 => ['url' => 'http://example.com/image-200.jpg', 'value' => 200], + 100 => [ + 'url' => 'http://example.com/image-100.jpg', + 'value' => 100, + ], + 200 => [ + 'url' => 'http://example.com/image-200.jpg', + 'value' => 200, + ], ]; $result = $this->domain_mapping->fix_srcset($sources); @@ -654,7 +660,10 @@ public function test_fix_srcset_no_mapping_returns_unchanged(): void { public function test_fix_srcset_returns_array(): void { $sources = [ - 100 => ['url' => 'http://example.com/image.jpg', 'value' => 100], + 100 => [ + 'url' => 'http://example.com/image.jpg', + 'value' => 100, + ], ]; $result = $this->domain_mapping->fix_srcset($sources); @@ -960,7 +969,7 @@ public function test_original_url_can_be_set(): void { * @param string $domain Domain string. * @return Domain */ - private function create_db_domain( string $domain ): Domain { + private function create_db_domain(string $domain): Domain { $result = wu_create_domain( [ @@ -974,7 +983,7 @@ private function create_db_domain( string $domain ): Domain { ); if ( is_wp_error( $result ) || ! $result instanceof Domain ) { - $this->markTestSkipped( 'Could not create domain record: ' . ( is_wp_error( $result ) ? $result->get_error_message() : 'unknown' ) ); + $this->markTestSkipped( 'Could not create domain record: ' . (is_wp_error( $result ) ? $result->get_error_message() : 'unknown') ); } return $result; @@ -985,7 +994,7 @@ private function create_db_domain( string $domain ): Domain { * * @param string $domain Domain string. */ - private function flush_domain_cache( string $domain ): void { + private function flush_domain_cache(string $domain): void { wp_cache_delete( 'domain:' . $domain, 'domain_mappings' ); wp_cache_delete( 'domain:www.' . $domain, 'domain_mappings' ); @@ -1066,6 +1075,94 @@ public function test_verify_dns_mapping_is_callable(): void { $this->assertTrue(is_callable([$this->domain_mapping, 'verify_dns_mapping'])); } + /** + * Regression: when verify_dns_mapping fires very early in the multisite + * bootstrap (`pre_get_site_by_path` / `ms_site_not_found`), `$wpdb->options` + * is empty because `$wpdb->set_prefix()` runs later in `ms-settings.php`. + * + * Calling `wp_send_json()` at that stage internally calls + * `get_option( 'blog_charset' )`, which produced + * + * "WordPress database error You have an error in your SQL syntax; + * check the manual that corresponds to your MariaDB server version + * for the right syntax to use near 'WHERE option_name = 'blog_charset' + * LIMIT 1' at line 1 for query SELECT option_value FROM WHERE + * option_name = 'blog_charset' LIMIT 1" + * + * The fix emits the JSON response directly via `send_async_dns_response()` + * with a hard-coded UTF-8 charset, bypassing `get_option()` entirely. + * + * This test verifies the response path no longer touches the options + * table, by simulating an empty `$wpdb->options` and asserting the call + * succeeds without producing a `wpdb->last_error`. + */ + public function test_verify_dns_mapping_does_not_query_options_table_in_early_bootstrap(): void { + + global $wpdb; + + // Capture any wpdb error produced during the call. + $wpdb->last_error = ''; + + // Persist a real mapping so Domain::get_by_domain() returns it. + $mapping_obj = new Domain(); + $mapping_obj->set_domain('regression-blog-charset.test'); + $mapping_obj->set_blog_id(1); + $mapping_obj->set_active(true); + $mapping_obj->set_primary_domain(true); + $mapping_obj->set_secure(false); + $mapping_obj->save(); + + // Hook the short-circuit filter to capture the payload without + // emitting headers or calling exit(). + $captured = (object) [ + 'payload' => null, + 'called' => false, + ]; + + $intercept = static function ($short_circuit, $payload) use ($captured) { + + $captured->called = true; + $captured->payload = $payload; + // Returning any non-null value prevents the default emit/exit path. + return true; + }; + + add_filter('wu_async_dns_response_short_circuit', $intercept, 10, 2); + + // Simulate the empty $wpdb->options state seen in the real bug: + // at `pre_get_site_by_path` time, `set_prefix` has not run yet. + $saved_options = $wpdb->options; + $wpdb->options = ''; + $_REQUEST['async_check_dns_nonce'] = wp_hash('regression-blog-charset.test'); + + try { + $this->domain_mapping->verify_dns_mapping(null, 'regression-blog-charset.test', '/'); + } finally { + // Restore options table reference and clean up no matter what. + $wpdb->options = $saved_options; + unset($_REQUEST['async_check_dns_nonce']); + remove_filter('wu_async_dns_response_short_circuit', $intercept, 10); + $mapping_obj->delete(); + } + + // The response path was reached. + $this->assertTrue( + $captured->called, + 'send_async_dns_response should fire when nonce matches and a mapping exists.' + ); + $this->assertIsArray($captured->payload); + $this->assertSame('regression-blog-charset.test', $captured->payload['domain'] ?? null); + + // And critically, no SQL syntax error was produced — the old code path + // would have populated wpdb->last_error with the malformed + // "SELECT option_value FROM WHERE option_name = 'blog_charset'" query. + $this->assertSame( + '', + $wpdb->last_error, + 'verify_dns_mapping must not query the options table during early multisite bootstrap.' + ); + } + // ---------------------------------------------------------------- // register_mapped_filters (with current_blog set) // ---------------------------------------------------------------- @@ -1117,7 +1214,10 @@ public function test_fix_srcset_mapping_no_site_returns_unchanged(): void { $this->domain_mapping->current_mapping = $mapping; $sources = [ - 100 => ['url' => 'http://example.com/image.jpg', 'value' => 100], + 100 => [ + 'url' => 'http://example.com/image.jpg', + 'value' => 100, + ], ]; $result = $this->domain_mapping->fix_srcset($sources); @@ -1325,7 +1425,7 @@ public function test_clear_mappings_on_delete_with_mappings(): void { // Verify the domain is gone from the DB. $this->flush_domain_cache( $domain_str ); - $fetched = Domain::get_by_domain( [ $domain_str ] ); + $fetched = Domain::get_by_domain( [$domain_str] ); $this->assertNull( $fetched ); } @@ -1349,8 +1449,14 @@ public function test_fix_srcset_with_valid_mapping_replaces_urls(): void { $this->domain_mapping->current_mapping = $mapping; $sources = [ - 100 => [ 'url' => 'http://example.org/image-100.jpg', 'value' => 100 ], - 200 => [ 'url' => 'http://example.org/image-200.jpg', 'value' => 200 ], + 100 => [ + 'url' => 'http://example.org/image-100.jpg', + 'value' => 100, + ], + 200 => [ + 'url' => 'http://example.org/image-200.jpg', + 'value' => 200, + ], ]; $result = $this->domain_mapping->fix_srcset( $sources ); @@ -1358,7 +1464,7 @@ public function test_fix_srcset_with_valid_mapping_replaces_urls(): void { $this->assertIsArray( $result ); // At least one URL should be mangled to the mapped domain. $all_urls = array_column( $result, 'url' ); - $mangled = array_filter( $all_urls, fn( $u ) => str_contains( $u, 'srcset-mapped.example.com' ) ); + $mangled = array_filter( $all_urls, fn($u) => str_contains( $u, 'srcset-mapped.example.com' ) ); $this->assertNotEmpty( $mangled ); $this->domain_mapping->current_mapping = null; @@ -1396,11 +1502,11 @@ public function test_register_mapped_filters_with_db_mapping_registers_filters() $this->domain_mapping->register_mapped_filters(); // site_url filter should now be registered. - $this->assertTrue( has_filter( 'site_url', [ $this->domain_mapping, 'mangle_url' ] ) !== false ); + $this->assertTrue( has_filter( 'site_url', [$this->domain_mapping, 'mangle_url'] ) !== false ); // Clean up. - remove_filter( 'site_url', [ $this->domain_mapping, 'mangle_url' ], -10 ); - remove_filter( 'home_url', [ $this->domain_mapping, 'mangle_url' ], -10 ); + remove_filter( 'site_url', [$this->domain_mapping, 'mangle_url'], -10 ); + remove_filter( 'home_url', [$this->domain_mapping, 'mangle_url'], -10 ); unset( $_SERVER['HTTP_HOST'] ); $domain->delete(); $this->flush_domain_cache( $domain_str ); @@ -1512,5 +1618,4 @@ public function test_replace_url_second_attempt_branch(): void { $this->domain_mapping->current_mapping = null; } - } From da65e2baa7d01395c56a1b2c3f310f2f20c25176 Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 11 May 2026 15:19:47 -0600 Subject: [PATCH 2/2] refactor(domain-mapping): inline async DNS response, drop helper method Remove the protected send_async_dns_response() method and the wu_async_dns_response_short_circuit filter; emit the headers, JSON body, and exit inline at the call site in verify_dns_mapping() with a single-line comment explaining why wp_send_json() is unsafe during early multisite bootstrap (it calls get_option('blog_charset') against an empty options table name). Update the regression test to exercise the early-bootstrap path with a matching nonce but no persisted mapping, so the method returns without hitting the exit branch while still asserting no SQL error against the empty $wpdb->options. --- inc/class-domain-mapping.php | 68 +++---------------------- tests/WP_Ultimo/Domain_Mapping_Test.php | 53 ++++--------------- 2 files changed, 19 insertions(+), 102 deletions(-) diff --git a/inc/class-domain-mapping.php b/inc/class-domain-mapping.php index 1362e97e..abbbcaa8 100644 --- a/inc/class-domain-mapping.php +++ b/inc/class-domain-mapping.php @@ -372,69 +372,17 @@ public function verify_dns_mapping($current_site, $domain, $path) { // phpcs:ign $mapping = Domain::get_by_domain($domains); if ($mapping) { - $this->send_async_dns_response($mapping->to_array()); - } - } - } - } + // Not using wp_send_json(): it calls get_option('blog_charset') before $wpdb->set_prefix() has run during early multisite bootstrap, producing a MariaDB syntax error against an empty options table name. + if ( ! headers_sent()) { + header('Content-Type: application/json; charset=UTF-8'); + } - /** - * Emits the async DNS-check JSON response and terminates the request. - * - * This is invoked from {@see verify_dns_mapping()}, which fires very early - * in WordPress's multisite bootstrap (`pre_get_site_by_path` / - * `ms_site_not_found`) — before `$wpdb->set_prefix()` runs in - * `wp-includes/ms-settings.php`. At that point `$wpdb->options` is an - * empty string, so calling `wp_send_json()` is unsafe: it would call - * `get_option( 'blog_charset' )`, which executes - * `SELECT option_value FROM {$wpdb->options} WHERE option_name = 'blog_charset' LIMIT 1` - * with an empty table name and triggers a MariaDB syntax error - * ("WordPress database error You have an error in your SQL syntax … - * near 'WHERE option_name = 'blog_charset' LIMIT 1'"). - * - * We therefore emit the JSON directly with a hard-coded UTF-8 charset - * (the WordPress default for `blog_charset`) and `exit`, avoiding any - * dependency on `get_option()` or the options table. - * - * Extracted as a protected method so tests (and plugins) can intercept - * the emit-and-terminate step without invoking PHP's `exit` via the - * `wu_async_dns_response_short_circuit` filter. - * - * @since 2.10.2 - * - * @param array $payload Mapping data to encode as JSON. - * @return void - */ - protected function send_async_dns_response(array $payload): void { + echo wp_json_encode($mapping->to_array()); - /** - * Filters the async DNS-check JSON response payload before it is emitted. - * - * Returning a non-null value short-circuits the default `header()` / - * `echo` / `exit` sequence so tests (and plugins) can inspect the - * payload without terminating the request. Any non-null return value - * causes this method to return immediately without sending headers, - * writing output, or exiting. - * - * @since 2.10.2 - * - * @param mixed $short_circuit Default `null`. Return any non-null value to - * short-circuit the default emit/exit behaviour. - * @param array $payload The mapping payload that would be JSON-encoded. - */ - $short_circuit = apply_filters('wu_async_dns_response_short_circuit', null, $payload); - - if (null !== $short_circuit) { - return; - } - - if ( ! headers_sent()) { - header('Content-Type: application/json; charset=UTF-8'); + exit; + } + } } - - echo wp_json_encode($payload); - - exit; } /** diff --git a/tests/WP_Ultimo/Domain_Mapping_Test.php b/tests/WP_Ultimo/Domain_Mapping_Test.php index c6a280f7..2427ba83 100644 --- a/tests/WP_Ultimo/Domain_Mapping_Test.php +++ b/tests/WP_Ultimo/Domain_Mapping_Test.php @@ -1089,12 +1089,13 @@ public function test_verify_dns_mapping_is_callable(): void { * LIMIT 1' at line 1 for query SELECT option_value FROM WHERE * option_name = 'blog_charset' LIMIT 1" * - * The fix emits the JSON response directly via `send_async_dns_response()` - * with a hard-coded UTF-8 charset, bypassing `get_option()` entirely. + * The fix emits the JSON response inline with a hard-coded UTF-8 charset, + * bypassing `get_option()` entirely. * - * This test verifies the response path no longer touches the options - * table, by simulating an empty `$wpdb->options` and asserting the call - * succeeds without producing a `wpdb->last_error`. + * Because the matching branch calls `exit`, this regression test exercises + * the early-bootstrap path with a matching nonce but no persisted mapping — + * that path still runs `wp_hash()` and the `require_once` includes against + * the empty `$wpdb->options`, and must not produce a `wpdb->last_error`. */ public function test_verify_dns_mapping_does_not_query_options_table_in_early_bootstrap(): void { @@ -1103,32 +1104,6 @@ public function test_verify_dns_mapping_does_not_query_options_table_in_early_bo // Capture any wpdb error produced during the call. $wpdb->last_error = ''; - // Persist a real mapping so Domain::get_by_domain() returns it. - $mapping_obj = new Domain(); - $mapping_obj->set_domain('regression-blog-charset.test'); - $mapping_obj->set_blog_id(1); - $mapping_obj->set_active(true); - $mapping_obj->set_primary_domain(true); - $mapping_obj->set_secure(false); - $mapping_obj->save(); - - // Hook the short-circuit filter to capture the payload without - // emitting headers or calling exit(). - $captured = (object) [ - 'payload' => null, - 'called' => false, - ]; - - $intercept = static function ($short_circuit, $payload) use ($captured) { - - $captured->called = true; - $captured->payload = $payload; - // Returning any non-null value prevents the default emit/exit path. - return true; - }; - - add_filter('wu_async_dns_response_short_circuit', $intercept, 10, 2); - // Simulate the empty $wpdb->options state seen in the real bug: // at `pre_get_site_by_path` time, `set_prefix` has not run yet. $saved_options = $wpdb->options; @@ -1136,24 +1111,18 @@ public function test_verify_dns_mapping_does_not_query_options_table_in_early_bo $_REQUEST['async_check_dns_nonce'] = wp_hash('regression-blog-charset.test'); try { + // No mapping is persisted for this domain, so Domain::get_by_domain() + // returns false and the method returns without reaching the `exit` + // branch. That still exercises the wp_hash() and require_once paths + // against the empty options table. $this->domain_mapping->verify_dns_mapping(null, 'regression-blog-charset.test', '/'); } finally { // Restore options table reference and clean up no matter what. $wpdb->options = $saved_options; unset($_REQUEST['async_check_dns_nonce']); - remove_filter('wu_async_dns_response_short_circuit', $intercept, 10); - $mapping_obj->delete(); } - // The response path was reached. - $this->assertTrue( - $captured->called, - 'send_async_dns_response should fire when nonce matches and a mapping exists.' - ); - $this->assertIsArray($captured->payload); - $this->assertSame('regression-blog-charset.test', $captured->payload['domain'] ?? null); - - // And critically, no SQL syntax error was produced — the old code path + // Critically, no SQL syntax error was produced — the old code path // would have populated wpdb->last_error with the malformed // "SELECT option_value FROM WHERE option_name = 'blog_charset'" query. $this->assertSame(