PluginProbe
Extendify / 3.1.0
Extendify v3.1.0
3.2.1 3.2.0 3.1.6 3.1.5 3.1.4 3.1.3 3.1.2 3.1.1 3.1.0 3.0.6 3.0.5 3.0.4 trunk 0.1.0 0.10.0 0.10.1 0.10.2 0.11.0 0.11.1 0.2.0 0.3.0 0.3.1 0.4.0 0.5.0 0.6.0 All 127 releases
extendify / tests / Integration / QuickEdit / Controllers / ImageImportSsrfTest.php

ImageImportSsrfTest.php in Extendify 3.1.0, at tests/Integration/QuickEdit/Controllers/ImageImportSsrfTest.php

110 lines 4.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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