File "ImageImportSsrfTest.php"
Full Path: /home/buyiwexj/public_html/wp-content/plugins/extendify/tests/Integration/QuickEdit/Controllers/ImageImportSsrfTest.php
File size: 4.61 KB
MIME-type: text/x-php
Charset: utf-8
<?php
namespace Extendify\Tests\Integration\QuickEdit\Controllers;
use Extendify\Draft\Controllers\ImageController;
use WP_UnitTestCase;
/**
* SSRF guard for QuickEdit's only server-side image fetch.
*
* QuickEdit's AI-image + Unsplash pickers (src/QuickEdit/components/modals/*) import
* a chosen image via importImageServer()/downloadImage() (src/Shared/api/wp.js) →
* POST /extendify/v1/draft/upload-image → ImageController::uploadMedia(), which hands
* the client-supplied `source` URL to WordPress core media_sideload_image().
*
* The SSRF defense lives in core: media_sideload_image() → download_url() →
* wp_safe_remote_get(), which runs the URL through wp_http_validate_url() and rejects
* loopback / private / link-local targets before any socket opens. Our code's
* contribution — and what these pins guard against regressing — is routing the import
* through that safe path rather than a raw wp_remote_get($source). The four other
* QuickEdit write surfaces never fetch a URL (WCProduct/SiteIdentity take attachment
* IDs; Save/WPNav/WPForms write post_content), so this is the whole server-fetch surface.
*/
class ImageImportSsrfTest extends WP_UnitTestCase
{
public function setUp(): void
{
parent::setUp();
require_once ABSPATH . 'wp-admin/includes/file.php';
require_once ABSPATH . 'wp-admin/includes/media.php';
require_once ABSPATH . 'wp-admin/includes/image.php';
$admin = self::factory()->user->create(['role' => 'administrator']);
wp_set_current_user($admin);
}
/**
* Adversarial: internal / loopback / link-local source URLs are refused by the
* safe-fetch path before any request leaves the box — cloud-metadata exfil,
* loopback port-probe, RFC1918 reach all fail.
*/
public function test_internal_target_urls_are_rejected_by_the_safe_fetch_path()
{
$internal = [
'http://169.254.169.254/latest/meta-data/x.png', // cloud metadata
'http://127.0.0.1/x.png', // loopback
'http://localhost/x.png',
'http://10.0.0.1/x.png', // RFC1918
'http://192.168.1.1/x.png',
];
foreach ($internal as $url) {
$this->assertWPError(
download_url($url),
"Expected the safe-fetch path to refuse internal target {$url}"
);
}
}
/**
* The import fetch goes through the SSRF-safe path: the outbound request for the
* source URL carries reject_unsafe_urls=true, which only wp_safe_remote_get() sets
* (download_url() uses it). A regression to a raw wp_remote_get($source) would drop
* the flag and reopen SSRF.
*
* Mutation-verified: replacing media_sideload_image() in uploadMedia() with a direct
* wp_remote_get($source)+wp_upload_bits() drops reject_unsafe_urls and turns this red.
*/
public function test_upload_fetches_source_through_the_unsafe_url_rejecting_path()
{
$captured = [];
add_filter('http_request_args', function ($args, $url) use (&$captured) {
$captured[$url] = $args;
return $args;
}, 10, 2);
// Short-circuit the network: write valid PNG bytes to download_url()'s stream
// target so the sideload completes without a real socket, then report 200.
$png = base64_decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='
);
add_filter('pre_http_request', function ($pre, $args, $url) use ($png) {
if (!empty($args['filename'])) {
file_put_contents($args['filename'], $png);
}
return [
'headers' => [],
'body' => '',
'response' => ['code' => 200, 'message' => 'OK'],
'cookies' => [],
'filename' => $args['filename'] ?? null,
];
}, 10, 3);
$src = 'https://images.unsplash.com/photo-ssrf-pin.png';
$req = new \WP_REST_Request('POST', '/extendify/v1/draft/upload-image');
$req->set_param('source', $src);
$res = ImageController::uploadMedia($req);
$id = $res->get_data()['id'];
$this->assertIsInt($id);
$this->assertGreaterThan(0, $id, 'media_sideload_image() should create an attachment');
$this->assertArrayHasKey($src, $captured, 'the client source URL was fetched');
$this->assertTrue(
(bool) $captured[$src]['reject_unsafe_urls'],
'the source fetch must use the SSRF-safe (reject_unsafe_urls) path'
);
}
}