| 1 |
<?php |
| 2 |
|
| 3 |
namespace Extendify\Tests\Integration\QuickEdit\Controllers; |
| 4 |
|
| 5 |
use Extendify\Draft\Controllers\ImageController; |
| 6 |
use WP_UnitTestCase; |
| 7 |
|
| 8 |
/** |
| 9 |
* SSRF guard for QuickEdit's only server-side image fetch. |
| 10 |
* |
| 11 |
* QuickEdit's AI-image + Unsplash pickers (src/QuickEdit/components/modals/*) import |
| 12 |
* a chosen image via importImageServer()/downloadImage() (src/Shared/api/wp.js) → |
| 13 |
* POST /extendify/v1/draft/upload-image → ImageController::uploadMedia(), which hands |
| 14 |
* the client-supplied `source` URL to WordPress core media_sideload_image(). |
| 15 |
* |
| 16 |
* The SSRF defense lives in core: media_sideload_image() → download_url() → |
| 17 |
* wp_safe_remote_get(), which runs the URL through wp_http_validate_url() and rejects |
| 18 |
* loopback / private / link-local targets before any socket opens. Our code's |
| 19 |
* contribution — and what these pins guard against regressing — is routing the import |
| 20 |
* through that safe path rather than a raw wp_remote_get($source). The four other |
| 21 |
* QuickEdit write surfaces never fetch a URL (WCProduct/SiteIdentity take attachment |
| 22 |
* IDs; Save/WPNav/WPForms write post_content), so this is the whole server-fetch surface. |
| 23 |
*/ |
| 24 |
class ImageImportSsrfTest extends WP_UnitTestCase |
| 25 |
{ |
| 26 |
public function setUp(): void |
| 27 |
{ |
| 28 |
parent::setUp(); |
| 29 |
require_once ABSPATH . 'wp-admin/includes/file.php'; |
| 30 |
require_once ABSPATH . 'wp-admin/includes/media.php'; |
| 31 |
require_once ABSPATH . 'wp-admin/includes/image.php'; |
| 32 |
$admin = self::factory()->user->create(['role' => 'administrator']); |
| 33 |
wp_set_current_user($admin); |
| 34 |
} |
| 35 |
|
| 36 |
/** |
| 37 |
* Adversarial: internal / loopback / link-local source URLs are refused by the |
| 38 |
* safe-fetch path before any request leaves the box — cloud-metadata exfil, |
| 39 |
* loopback port-probe, RFC1918 reach all fail. |
| 40 |
*/ |
| 41 |
public function test_internal_target_urls_are_rejected_by_the_safe_fetch_path() |
| 42 |
{ |
| 43 |
$internal = [ |
| 44 |
'http://169.254.169.254/latest/meta-data/x.png', // cloud metadata |
| 45 |
'http://127.0.0.1/x.png', // loopback |
| 46 |
'http://localhost/x.png', |
| 47 |
'http://10.0.0.1/x.png', // RFC1918 |
| 48 |
'http://192.168.1.1/x.png', |
| 49 |
]; |
| 50 |
|
| 51 |
foreach ($internal as $url) { |
| 52 |
$this->assertWPError( |
| 53 |
download_url($url), |
| 54 |
"Expected the safe-fetch path to refuse internal target {$url}" |
| 55 |
); |
| 56 |
} |
| 57 |
} |
| 58 |
|
| 59 |
/** |
| 60 |
* The import fetch goes through the SSRF-safe path: the outbound request for the |
| 61 |
* source URL carries reject_unsafe_urls=true, which only wp_safe_remote_get() sets |
| 62 |
* (download_url() uses it). A regression to a raw wp_remote_get($source) would drop |
| 63 |
* the flag and reopen SSRF. |
| 64 |
* |
| 65 |
* Mutation-verified: replacing media_sideload_image() in uploadMedia() with a direct |
| 66 |
* wp_remote_get($source)+wp_upload_bits() drops reject_unsafe_urls and turns this red. |
| 67 |
*/ |
| 68 |
public function test_upload_fetches_source_through_the_unsafe_url_rejecting_path() |
| 69 |
{ |
| 70 |
$captured = []; |
| 71 |
add_filter('http_request_args', function ($args, $url) use (&$captured) { |
| 72 |
$captured[$url] = $args; |
| 73 |
return $args; |
| 74 |
}, 10, 2); |
| 75 |
|
| 76 |
// Short-circuit the network: write valid PNG bytes to download_url()'s stream |
| 77 |
// target so the sideload completes without a real socket, then report 200. |
| 78 |
$png = base64_decode( |
| 79 |
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=' |
| 80 |
); |
| 81 |
add_filter('pre_http_request', function ($pre, $args, $url) use ($png) { |
| 82 |
if (!empty($args['filename'])) { |
| 83 |
file_put_contents($args['filename'], $png); |
| 84 |
} |
| 85 |
return [ |
| 86 |
'headers' => [], |
| 87 |
'body' => '', |
| 88 |
'response' => ['code' => 200, 'message' => 'OK'], |
| 89 |
'cookies' => [], |
| 90 |
'filename' => $args['filename'] ?? null, |
| 91 |
]; |
| 92 |
}, 10, 3); |
| 93 |
|
| 94 |
$src = 'https://images.unsplash.com/photo-ssrf-pin.png'; |
| 95 |
$req = new \WP_REST_Request('POST', '/extendify/v1/draft/upload-image'); |
| 96 |
$req->set_param('source', $src); |
| 97 |
|
| 98 |
$res = ImageController::uploadMedia($req); |
| 99 |
$id = $res->get_data()['id']; |
| 100 |
|
| 101 |
$this->assertIsInt($id); |
| 102 |
$this->assertGreaterThan(0, $id, 'media_sideload_image() should create an attachment'); |
| 103 |
$this->assertArrayHasKey($src, $captured, 'the client source URL was fetched'); |
| 104 |
$this->assertTrue( |
| 105 |
(bool) $captured[$src]['reject_unsafe_urls'], |
| 106 |
'the source fetch must use the SSRF-safe (reject_unsafe_urls) path' |
| 107 |
); |
| 108 |
} |
| 109 |
} |
| 110 |
|