Skip to content
Merged
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
103 changes: 71 additions & 32 deletions includes/class-saddle-http.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 ) ) {
Expand All @@ -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.
*
Expand All @@ -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(
Expand Down
28 changes: 17 additions & 11 deletions languages/saddle.pot
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -5575,43 +5575,49 @@ msgstr ""
msgid "A directory slug has only lowercase letters, digits and hyphens, as in wordpress.org/plugins/<slug>/ or wordpress.org/themes/<slug>/."
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 ""

Expand Down
3 changes: 3 additions & 0 deletions readme.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 70 additions & 0 deletions tests/http-fetch-json-test.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
<?php
/**
* Saddle_HTTP::fetch_json() refuses a host it cannot clear, and says which of
* two things went wrong (#335): the host could not be looked up at all (PHP on
* WordPress Playground and Studio has no DNS), or it points inside a private
* network. Both stay refused; only the message differs.
*
* Literal IPs and an RFC 6761 `.invalid` host keep resolution deterministic, and
* no request is sent: the guard refuses before any fetch.
*
* @package Saddle
*/

class Saddle_HTTP_Fetch_JSON_Test extends WP_UnitTestCase {

public function tear_down() {
remove_all_filters( 'saddle_url_is_safe' );
parent::tear_down();
}

/**
* The symptom in #335: an outside host that this server cannot look up was
* reported as "resolves to a private or unreachable network".
*/
public function test_a_host_that_cannot_be_looked_up_says_so_and_names_it() {
$result = Saddle_HTTP::fetch_json( 'https://claude-app.invalid/oauth/client.json' );

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