From a4ba507f65317229c2c79b3890aafc36761ae595 Mon Sep 17 00:00:00 2001 From: fahimreza-dev Date: Wed, 14 Oct 2026 04:24:30 +0600 Subject: [PATCH] fix(http): say when an app's address can't be looked up (#335) Sign-in by address on WordPress Playground and Studio stopped with "That address resolves to a private or unreachable network", because PHP there has no DNS. fetch_json() now tells the two cases apart: a host that can't be looked up names the host and points to a key; a private address keeps the old message. Both stay refused; the guard is unchanged. Closes #335 Co-Authored-By: Claude Opus 5.5 --- includes/class-saddle-http.php | 103 +++++++++++++++++++++++---------- languages/saddle.pot | 28 +++++---- readme.txt | 3 + tests/http-fetch-json-test.php | 70 ++++++++++++++++++++++ 4 files changed, 161 insertions(+), 43 deletions(-) create mode 100644 tests/http-fetch-json-test.php diff --git a/includes/class-saddle-http.php b/includes/class-saddle-http.php index d3001ce..f00b885 100644 --- a/includes/class-saddle-http.php +++ b/includes/class-saddle-http.php @@ -53,37 +53,7 @@ public static function url_is_safe( $url ) { if ( ! $host ) { return false; } - $host = trim( $host, '[]' ); // Strip IPv6 brackets. - - $ips = array(); - if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { - $ips[] = $host; - } else { - foreach ( array( DNS_A, DNS_AAAA ) as $dns_type ) { - // Silenced by design: this is an SSRF pre-flight resolving a - // caller-supplied host to reject internal targets. A lookup - // failure just means "no records" — we must not warn or throw on - // an attacker-chosen name, only fall through to refusal. - $records = @dns_get_record( $host, $dns_type ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see comment above. - if ( is_array( $records ) ) { - foreach ( $records as $record ) { - if ( ! empty( $record['ip'] ) ) { - $ips[] = $record['ip']; - } - if ( ! empty( $record['ipv6'] ) ) { - $ips[] = $record['ipv6']; - } - } - } - } - if ( empty( $ips ) ) { - $resolved = gethostbyname( $host ); - if ( $resolved && $resolved !== $host ) { - $ips[] = $resolved; - } - } - } - + $ips = self::resolve( $host ); $safe = ! empty( $ips ); foreach ( $ips as $ip ) { if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) { @@ -106,6 +76,75 @@ public static function url_is_safe( $url ) { return (bool) apply_filters( 'saddle_url_is_safe', $safe, $url, $ips ); } + /** + * The addresses a host resolves to, or none when it cannot be looked up. + * + * @param string $host Host name or IP, with or without IPv6 brackets. + * @return string[] + */ + private static function resolve( $host ) { + $host = trim( (string) $host, '[]' ); // Strip IPv6 brackets. + + if ( filter_var( $host, FILTER_VALIDATE_IP ) ) { + return array( $host ); + } + + $ips = array(); + foreach ( array( DNS_A, DNS_AAAA ) as $dns_type ) { + // Silenced by design: this is an SSRF pre-flight resolving a + // caller-supplied host to reject internal targets. A lookup + // failure just means "no records" — we must not warn or throw on + // an attacker-chosen name, only fall through to refusal. + $records = @dns_get_record( $host, $dns_type ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- see comment above. + if ( is_array( $records ) ) { + foreach ( $records as $record ) { + if ( ! empty( $record['ip'] ) ) { + $ips[] = $record['ip']; + } + if ( ! empty( $record['ipv6'] ) ) { + $ips[] = $record['ipv6']; + } + } + } + } + if ( empty( $ips ) ) { + $resolved = gethostbyname( $host ); + if ( $resolved && $resolved !== $host ) { + $ips[] = $resolved; + } + } + + return $ips; + } + + /** + * Why url_is_safe() refused a URL, in words the site owner can act on. + * + * A host that could not be looked up at all is not the same problem as one + * that points inside a private network. PHP on WordPress Playground and + * Studio has no DNS, so every outside name lands in the first case there + * (#335). Both stay refused; only the message differs. + * + * @param string $url The refused URL. + * @return WP_Error + */ + private static function blocked_error( $url ) { + $host = trim( (string) wp_parse_url( $url, PHP_URL_HOST ), '[]' ); + + if ( '' !== $host && ! self::resolve( $host ) ) { + return new WP_Error( + 'saddle_http_unresolved', + sprintf( + /* translators: %s: a host name, such as claude.ai. */ + __( 'This server could not look up %s, so Saddle could not check where it points. Some hosts, such as WordPress Playground and Studio, cannot look up outside addresses. Use a key there instead.', 'saddle' ), + $host + ) + ); + } + + return new WP_Error( 'saddle_http_blocked', __( 'That address resolves to a private or unreachable network.', 'saddle' ) ); + } + /** * Fetch a small JSON document from a third-party origin. * @@ -130,7 +169,7 @@ public static function fetch_json( $url, $max_bytes = 65536 ) { } if ( ! self::url_is_safe( $url ) ) { - return new WP_Error( 'saddle_http_blocked', __( 'That address resolves to a private or unreachable network.', 'saddle' ) ); + return self::blocked_error( $url ); } $response = wp_safe_remote_get( diff --git a/languages/saddle.pot b/languages/saddle.pot index 6f4aed6..207913a 100644 --- a/languages/saddle.pot +++ b/languages/saddle.pot @@ -9,7 +9,7 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" -"POT-Creation-Date: 2026-10-12T23:25:10+00:00\n" +"POT-Creation-Date: 2026-10-13T22:24:03+00:00\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "X-Generator: WP-CLI 2.12.0\n" "X-Domain: saddle\n" @@ -5575,43 +5575,49 @@ msgstr "" msgid "A directory slug has only lowercase letters, digits and hyphens, as in wordpress.org/plugins// or wordpress.org/themes//." msgstr "" -#: includes/class-saddle-http.php:129 -msgid "Only HTTPS URLs can be fetched." +#. translators: %s: a host name, such as claude.ai. +#: includes/class-saddle-http.php:139 +#, php-format +msgid "This server could not look up %s, so Saddle could not check where it points. Some hosts, such as WordPress Playground and Studio, cannot look up outside addresses. Use a key there instead." msgstr "" -#: includes/class-saddle-http.php:133 +#: includes/class-saddle-http.php:145 msgid "That address resolves to a private or unreachable network." msgstr "" +#: includes/class-saddle-http.php:168 +msgid "Only HTTPS URLs can be fetched." +msgstr "" + #. translators: %d: HTTP status code. -#: includes/class-saddle-http.php:157 +#: includes/class-saddle-http.php:196 #, php-format msgid "That address answered with HTTP %d." msgstr "" -#: includes/class-saddle-http.php:165 +#: includes/class-saddle-http.php:204 msgid "That address did not return JSON." msgstr "" -#: includes/class-saddle-http.php:170 +#: includes/class-saddle-http.php:209 msgid "That document is too large to read." msgstr "" -#: includes/class-saddle-http.php:175 +#: includes/class-saddle-http.php:214 msgid "That document is not valid JSON." msgstr "" -#: includes/class-saddle-http.php:208 +#: includes/class-saddle-http.php:247 msgid "Only this site's own pages can be fetched this way." msgstr "" #. translators: %s: the other site's host name. -#: includes/class-saddle-http.php:246 +#: includes/class-saddle-http.php:285 #, php-format msgid "The public page redirects to another site (%s), so Saddle did not follow it." msgstr "" -#: includes/class-saddle-http.php:256 +#: includes/class-saddle-http.php:295 msgid "The public page redirected more than three times, so Saddle stopped." msgstr "" diff --git a/readme.txt b/readme.txt index a7c357c..63c6378 100644 --- a/readme.txt +++ b/readme.txt @@ -210,6 +210,9 @@ The WordPress.org version never checks for its own updates. The version from plu == Changelog == += 1.6.0 = +* Fix: When sign-in by address cannot read an app's identity because this server cannot look up the app's address, Saddle now says so and suggests a key. It used to say the address pointed to a private network. WordPress Playground and Studio cannot look up outside addresses. + = 1.5.2 = * New: Your AI app can search the WordPress.org plugin directory. Each result shows the plugin's version, active installs, rating, last update and the WordPress and PHP versions it needs, and whether it is already installed and active on your site. * New: Your AI app can install a plugin from the WordPress.org directory by its name. It shows a preview first, with warnings when the plugin is not tested with your WordPress or has not been updated in two years, and waits for your approval. WordPress's own installer then adds it in the background. The plugin arrives inactive, so turning it on is a separate step you approve. Saddle never installs from a zip file or a link, and never deletes a plugin. diff --git a/tests/http-fetch-json-test.php b/tests/http-fetch-json-test.php new file mode 100644 index 0000000..a2f9354 --- /dev/null +++ b/tests/http-fetch-json-test.php @@ -0,0 +1,70 @@ +assertWPError( $result ); + $this->assertSame( 'saddle_http_unresolved', $result->get_error_code() ); + $this->assertStringContainsString( 'claude-app.invalid', $result->get_error_message() ); + $this->assertStringContainsString( 'key', $result->get_error_message() ); + $this->assertStringNotContainsString( 'private', $result->get_error_message() ); + } + + public function test_a_private_address_keeps_the_private_network_message() { + $result = Saddle_HTTP::fetch_json( 'https://10.0.0.5/client.json' ); + + $this->assertWPError( $result ); + $this->assertSame( 'saddle_http_blocked', $result->get_error_code() ); + $this->assertStringContainsString( 'private', $result->get_error_message() ); + } + + public function test_link_local_metadata_address_is_still_refused() { + $result = Saddle_HTTP::fetch_json( 'https://169.254.169.254/latest/meta-data/' ); + + $this->assertWPError( $result ); + $this->assertSame( 'saddle_http_blocked', $result->get_error_code() ); + } + + /** + * Failing closed stays: the clearer message does not let an unresolvable + * host through, and only the filter can. + */ + public function test_an_unresolvable_host_is_still_refused_by_the_guard() { + $this->assertFalse( Saddle_HTTP::url_is_safe( 'https://claude-app.invalid/x' ) ); + } + + /** + * The sign-in flow wraps the reason, so the owner sees it on the authorize + * page in place of the old misleading one. + */ + public function test_the_sign_in_flow_passes_the_clear_reason_through() { + add_filter( 'saddle_oauth_enabled', '__return_true' ); + $result = Saddle_OAuth_Clients::resolve( 'https://claude-app.invalid/oauth/client.json' ); + remove_filter( 'saddle_oauth_enabled', '__return_true' ); + + $this->assertWPError( $result ); + $this->assertStringContainsString( 'could not look up claude-app.invalid', $result->get_error_message() ); + } +}