diff --git a/classes/Visualizer/Source/Csv.php b/classes/Visualizer/Source/Csv.php
index 1a39e5fcb..679714e58 100644
--- a/classes/Visualizer/Source/Csv.php
+++ b/classes/Visualizer/Source/Csv.php
@@ -117,7 +117,7 @@ private function _fetchSeries( &$handle ) {
*
* @access protected
* @param string $filename Optional file name to get handle. If omitted, $_filename is used.
- * @return resource File handle resource on success, otherwise FALSE.
+ * @return resource|false File handle resource on success, otherwise FALSE.
*/
protected function _get_file_handle( $filename = false ) {
// open file and return handle
@@ -141,23 +141,29 @@ public function fetch() {
// read file and fill arrays
$handle = $this->_get_file_handle();
- if ( $handle ) {
- // fetch series
- if ( ! $this->_fetchSeries( $handle ) ) {
- return false;
- }
-
- // fetch data
- $data = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE );
- while ( $data !== false ) {
- $this->_data[] = $this->_normalizeData( $data );
- $data = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE );
+ if ( ! $handle ) {
+ if ( empty( $this->_error ) ) {
+ $this->_error = esc_html__( 'The file could not be opened. Please try again.', 'visualizer' );
}
+ return false;
+ }
- // close file handle
+ // fetch series
+ if ( ! $this->_fetchSeries( $handle ) ) {
fclose( $handle );
+ return false;
}
+ // fetch data
+ $data = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE );
+ while ( $data !== false ) {
+ $this->_data[] = $this->_normalizeData( $data );
+ $data = fgetcsv( $handle, 0, VISUALIZER_CSV_DELIMITER, VISUALIZER_CSV_ENCLOSURE );
+ }
+
+ // close file handle
+ fclose( $handle );
+
return true;
}
diff --git a/classes/Visualizer/Source/Csv/Remote.php b/classes/Visualizer/Source/Csv/Remote.php
index e034ca15e..1554dd0cb 100644
--- a/classes/Visualizer/Source/Csv/Remote.php
+++ b/classes/Visualizer/Source/Csv/Remote.php
@@ -30,12 +30,12 @@
class Visualizer_Source_Csv_Remote extends Visualizer_Source_Csv {
/**
- * Temporary file name used when allow_url_fopen option is disabled.
+ * Path to the safely downloaded temporary file.
*
* @since 1.4.2
*
* @access private
- * @var string
+ * @var string|false
*/
private $_tmpfile = false;
@@ -74,7 +74,7 @@ private function _repopulate( $chart_id ) {
// if filename is empty, extract it from chart content
if ( empty( $this->_filename ) ) {
$chart = get_post( $chart_id );
- $data = unserialize( html_entity_decode( $chart->post_content ) );
+ $data = Visualizer_Module::decode_content( html_entity_decode( $chart->post_content ) );
if ( ! isset( $data['source'] ) ) {
return false;
}
@@ -129,36 +129,44 @@ public function getSourceName() {
return __CLASS__;
}
+ /**
+ * Fetches the remote file and removes its temporary copy.
+ *
+ * @access public
+ * @return boolean TRUE on success, otherwise FALSE.
+ */
+ public function fetch() {
+ $result = parent::fetch();
+
+ if ( $this->_tmpfile && is_file( $this->_tmpfile ) ) {
+ wp_delete_file( $this->_tmpfile );
+ $this->_tmpfile = false;
+ }
+
+ return $result;
+ }
+
/**
* Returns file handle to fetch data from.
*
* @since 1.4.2
*
* @access protected
- * @staticvar boolean $allow_url_fopen Determines whether or not allow_url_fopen option is enabled.
* @param string $filename Optional file name to get handle. If omitted, $_filename is used.
- * @return resource File handle resource on success, otherwise FALSE.
+ * @return resource|false File handle resource on success, otherwise FALSE.
*/
protected function _get_file_handle( $filename = false ) {
- static $allow_url_fopen = null;
-
- if ( ! is_wp_error( $this->_tmpfile ) && $this->_tmpfile && is_readable( $this->_tmpfile ) ) {
+ if ( $this->_tmpfile && is_readable( $this->_tmpfile ) ) {
return parent::_get_file_handle( $this->_tmpfile );
}
- if ( is_null( $allow_url_fopen ) ) {
- $allow_url_fopen = filter_var( ini_get( 'allow_url_fopen' ), FILTER_VALIDATE_BOOLEAN );
+ $tmpfile = Visualizer_Remote_Fetch::download( $this->_filename );
+ if ( is_wp_error( $tmpfile ) ) {
+ $this->_error = esc_html__( 'Could not download the file. Please check the URL and try again.', 'visualizer' );
+ return false;
}
- $scheme = parse_url( $this->_filename, PHP_URL_SCHEME );
- if ( $allow_url_fopen && in_array( $scheme, stream_get_wrappers(), true ) ) {
- return parent::_get_file_handle( $filename );
- }
-
- require_once ABSPATH . 'wp-admin/includes/file.php';
-
- $this->_tmpfile = download_url( $this->_filename );
-
- return ! is_wp_error( $this->_tmpfile ) ? parent::_get_file_handle( $this->_tmpfile ) : false;
+ $this->_tmpfile = $tmpfile;
+ return parent::_get_file_handle( $this->_tmpfile );
}
}
diff --git a/classes/Visualizer/Source/Json.php b/classes/Visualizer/Source/Json.php
index 5edf29d19..0da21be8f 100644
--- a/classes/Visualizer/Source/Json.php
+++ b/classes/Visualizer/Source/Json.php
@@ -122,7 +122,7 @@ public function __construct( $params = null ) {
if ( isset( $this->_args['additional_headers'] ) ) {
$this->_additional_headers = $this->_args['additional_headers'];
}
- do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, sprintf( 'Constructor called for params = %s', print_r( $params, true ) ), 'debug', __FILE__, __LINE__ );
+ do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, 'JSON source initialized.', 'debug', __FILE__, __LINE__ );
}
/**
@@ -283,7 +283,7 @@ public function fetch() {
$this->_data = $data;
- do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, sprintf( 'Parsed data endpoint %s with root %s is %s', $this->_url, $this->_root, print_r( $data, true ) ), 'debug', __FILE__, __LINE__ );
+ do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, 'JSON endpoint data parsed.', 'debug', __FILE__, __LINE__ );
return true;
}
@@ -379,7 +379,7 @@ private function getRootElements( $parent_key, $now, $root, $data ) {
}
}
$roots = array_unique( $root );
- do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, sprintf( 'Roots found for %s = %s', $this->_url, print_r( $roots, true ) ), 'debug', __FILE__, __LINE__ );
+ do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, 'JSON root elements parsed.', 'debug', __FILE__, __LINE__ );
return $roots;
}
@@ -397,7 +397,8 @@ private function getJSON( $url = null ) {
$response = $this->connect( $url );
if ( is_wp_error( $response ) || ! in_array( intval( $response['response']['code'] ), array( 200, 201 ), true ) ) {
- do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, sprintf( 'Error while fetching JSON endpoint %s = %s', $url, print_r( $response, true ) ), 'error', __FILE__, __LINE__ );
+ $error_code = is_wp_error( $response ) ? $response->get_error_code() : (string) wp_remote_retrieve_response_code( $response );
+ do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, sprintf( 'Error while fetching JSON endpoint: %s', $error_code ), 'error', __FILE__, __LINE__ );
return null;
}
@@ -408,7 +409,7 @@ private function getJSON( $url = null ) {
$response_body = preg_replace( "/^$bom/", '', $response_body );
$array = apply_filters( 'visualizer_json_massage_data', json_decode( $response_body, true ), $url );
- do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, sprintf( 'JSON array for the endpoint is %s = ', print_r( $array, true ) ), 'debug', __FILE__, __LINE__ );
+ do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, 'JSON response decoded.', 'debug', __FILE__, __LINE__ );
return $array;
}
@@ -467,8 +468,8 @@ function ( $headers ) {
$args['headers']['X-Visualizer-Token'] = $token;
}
- do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, sprintf( 'Connecting to %s with args = %s ', $url, print_r( $args, true ) ), 'debug', __FILE__, __LINE__ );
- return wp_safe_remote_request( $url, $args );
+ do_action( 'themeisle_log_event', Visualizer_Plugin::NAME, sprintf( 'Connecting to JSON endpoint with method %s', $args['method'] ), 'debug', __FILE__, __LINE__ );
+ return Visualizer_Remote_Fetch::request( $url, $args );
}
/**
diff --git a/classes/Visualizer/Source/Xlsx.php b/classes/Visualizer/Source/Xlsx.php
index f295209c4..f2cf42cd3 100644
--- a/classes/Visualizer/Source/Xlsx.php
+++ b/classes/Visualizer/Source/Xlsx.php
@@ -55,8 +55,13 @@ public function fetch() {
}
$reader = \OpenSpout\Reader\Common\Creator\ReaderEntityFactory::createXLSXReader();
+ $file_path = $this->_get_file_path();
+ if ( ! $file_path ) {
+ return false;
+ }
+
try {
- $reader->open( $this->_get_file_path() );
+ $reader->open( $file_path );
$all_rows = array();
foreach ( $reader->getSheetIterator() as $sheet ) {
diff --git a/classes/Visualizer/Source/Xlsx/Remote.php b/classes/Visualizer/Source/Xlsx/Remote.php
index 857c2a988..ab482a2fb 100644
--- a/classes/Visualizer/Source/Xlsx/Remote.php
+++ b/classes/Visualizer/Source/Xlsx/Remote.php
@@ -86,41 +86,30 @@ public function getSourceName() {
* guarding against ZIP-bomb and DoS attacks.
*
* @access protected
- * @return string Path to the temporary file, or the original URL if download failed.
+ * @return string|false Path to the temporary file, or false if download failed.
*/
protected function _get_file_path() {
- if ( $this->_tmpfile && ! is_wp_error( $this->_tmpfile ) && is_readable( $this->_tmpfile ) ) {
+ if ( $this->_tmpfile && is_readable( $this->_tmpfile ) ) {
return $this->_tmpfile;
}
- require_once ABSPATH . 'wp-admin/includes/file.php';
-
- $this->_tmpfile = download_url( $this->_filename );
-
- if ( is_wp_error( $this->_tmpfile ) ) {
- $this->_error = esc_html__( 'Could not download the XLSX file. Please check the URL and try again.', 'visualizer' );
- $this->_tmpfile = false;
- // Return the original URL so the parent's open() call will fail
- // gracefully and set an error rather than throwing a PHP error.
- return $this->_filename;
+ $max_bytes = (int) apply_filters( 'visualizer_xlsx_max_filesize', 10 * 1024 * 1024 );
+ $tmpfile = Visualizer_Remote_Fetch::download(
+ $this->_filename,
+ array( 'limit_response_size' => $max_bytes )
+ );
+ if ( is_wp_error( $tmpfile ) ) {
+ $this->_error = 'visualizer_remote_size' === $tmpfile->get_error_code()
+ ? esc_html__( 'The XLSX file exceeds the maximum allowed size and cannot be imported.', 'visualizer' )
+ : esc_html__( 'Could not download the XLSX file. Please check the URL and try again.', 'visualizer' );
+ return false;
}
+ $this->_tmpfile = $tmpfile;
if ( ! is_file( $this->_tmpfile ) ) {
$this->_tmpfile = false;
$this->_error = esc_html__( 'Could not access the downloaded XLSX file. Please try again.', 'visualizer' );
- return $this->_filename;
- }
-
- // Maximum allowed file size in bytes. Default 10 MB; override via filter.
- $max_bytes = (int) apply_filters( 'visualizer_xlsx_max_filesize', 10 * 1024 * 1024 );
- if ( filesize( $this->_tmpfile ) > $max_bytes ) {
- @unlink( $this->_tmpfile ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
- $this->_tmpfile = false;
- $this->_error = esc_html__(
- 'The XLSX file exceeds the maximum allowed size and cannot be imported.',
- 'visualizer'
- );
- return $this->_filename;
+ return false;
}
return $this->_tmpfile;
@@ -136,8 +125,8 @@ public function fetch() {
$result = parent::fetch();
// Clean up the temporary file after parsing.
- if ( $this->_tmpfile && ! is_wp_error( $this->_tmpfile ) && is_file( $this->_tmpfile ) ) {
- @unlink( $this->_tmpfile ); // phpcs:ignore WordPress.PHP.NoSilencedErrors
+ if ( $this->_tmpfile && is_file( $this->_tmpfile ) ) {
+ wp_delete_file( $this->_tmpfile );
$this->_tmpfile = false;
}
diff --git a/composer.lock b/composer.lock
index 5dc651a04..106a4329b 100644
--- a/composer.lock
+++ b/composer.lock
@@ -8,16 +8,16 @@
"packages": [
{
"name": "codeinwp/themeisle-sdk",
- "version": "3.3.54",
+ "version": "3.3.55",
"source": {
"type": "git",
"url": "https://github.com/Codeinwp/themeisle-sdk.git",
- "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1"
+ "reference": "bd601798d209a4bc5962d2a19a22dc6dddf341cc"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/095c2d0f1388af0b0196c492a7f79e2fd092dab1",
- "reference": "095c2d0f1388af0b0196c492a7f79e2fd092dab1",
+ "url": "https://api.github.com/repos/Codeinwp/themeisle-sdk/zipball/bd601798d209a4bc5962d2a19a22dc6dddf341cc",
+ "reference": "bd601798d209a4bc5962d2a19a22dc6dddf341cc",
"shasum": ""
},
"require-dev": {
@@ -36,16 +36,16 @@
"homepage": "https://themeisle.com"
}
],
- "description": "Themeisle SDK.",
+ "description": "Themeisle SDK library.",
"homepage": "https://github.com/Codeinwp/themeisle-sdk",
"keywords": [
"wordpress"
],
"support": {
"issues": "https://github.com/Codeinwp/themeisle-sdk/issues",
- "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.54"
+ "source": "https://github.com/Codeinwp/themeisle-sdk/tree/v3.3.55"
},
- "time": "2026-06-23T13:43:47+00:00"
+ "time": "2026-07-20T10:57:27+00:00"
},
{
"name": "neitanod/forceutf8",
diff --git a/js/media/collection.js b/js/media/collection.js
index a24e3c0ca..fe1d60e8d 100644
--- a/js/media/collection.js
+++ b/js/media/collection.js
@@ -7,7 +7,8 @@
options = options || {};
options.type = 'GET';
options.data = _.extend( options.data || {}, {
- action: wp.media.view.l10n.visualizer.actions.get_charts
+ action: wp.media.view.l10n.visualizer.actions.get_charts,
+ nonce: wp.media.view.l10n.visualizer.nonce
});
return wp.media.ajax( options );
diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon
index d300949c1..5c58b35b0 100644
--- a/phpstan-baseline.neon
+++ b/phpstan-baseline.neon
@@ -1218,24 +1218,6 @@ parameters:
count: 1
path: classes/Visualizer/Module/Chart.php
- -
- message: '#^Method Visualizer_Module_Chart\:\:save_chart_image\(\) has invalid return type attachment\.$#'
- identifier: class.notFound
- count: 1
- path: classes/Visualizer/Module/Chart.php
-
- -
- message: '#^Method Visualizer_Module_Chart\:\:save_chart_image\(\) should return attachment but returns int\.$#'
- identifier: return.type
- count: 1
- path: classes/Visualizer/Module/Chart.php
-
- -
- message: '#^Method Visualizer_Module_Chart\:\:save_chart_image\(\) should return attachment but returns int\<0, max\>\.$#'
- identifier: return.type
- count: 1
- path: classes/Visualizer/Module/Chart.php
-
-
message: '#^Method Visualizer_Module_Chart\:\:setJsonData\(\) has no return type specified\.$#'
identifier: missingType.return
diff --git a/tests/e2e/config/force-lazy-render.php b/tests/e2e/config/force-lazy-render.php
new file mode 100644
index 000000000..2de83bd0a
--- /dev/null
+++ b/tests/e2e/config/force-lazy-render.php
@@ -0,0 +1,10 @@
+ {
+ test.beforeEach( async ( { requestUtils } ) => {
+ await deleteAllCharts( requestUtils );
+ } );
+
+ async function createChartOnPost( admin, page, requestUtils ) {
+ // Lazy rendering is forced on by tests/e2e/config/force-lazy-render.php
+ // (mapped as an mu-plugin in .wp-env.json).
+ const chartId = await createChartWithAdmin( admin, page );
+
+ const post = await requestUtils.createPost( {
+ title: 'Lazy render',
+ content: `[visualizer id="${ chartId }" lazy="no" class=""]`,
+ status: 'publish',
+ } );
+
+ return { chartId, post };
+ }
+
+ async function triggerLazyLoader( page ) {
+ // Scripts are swapped to `data-visualizer-script` placeholders.
+ await expect( page.locator( 'script[data-visualizer-script]' ).first() ).toBeAttached();
+
+ // The loader starts on the first user interaction.
+ await page.evaluate( () => window.dispatchEvent( new Event( 'scroll' ) ) );
+ }
+
+ test( 'chart renders when the renderer script loads after render-facade.js', async ( { admin, page, requestUtils } ) => {
+ const { chartId, post } = await createChartOnPost( admin, page, requestUtils );
+
+ // Delay the chart renderer scripts so render-facade.js would win the
+ // load race — the deterministic reproduction of issue #1319.
+ await page.route( /js\/render-(google|chartjs|datatables)\.js/, async ( route ) => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 2000 ) );
+ await route.continue();
+ } );
+
+ await page.goto( `/?p=${ post.id }` );
+ await triggerLazyLoader( page );
+
+ const chart = page.locator( `.visualizer-front-${ chartId }` ).first();
+ await expect( chart ).toHaveClass( /visualizer-chart-loaded/, { timeout: 15000 } );
+ await expect( chart.locator( 'svg, canvas, table' ).first() ).toBeVisible();
+ } );
+
+ test( 'chart renders without artificial script delay', async ( { admin, page, requestUtils } ) => {
+ const { chartId, post } = await createChartOnPost( admin, page, requestUtils );
+
+ await page.goto( `/?p=${ post.id }` );
+ await triggerLazyLoader( page );
+
+ const chart = page.locator( `.visualizer-front-${ chartId }` ).first();
+ await expect( chart ).toHaveClass( /visualizer-chart-loaded/, { timeout: 15000 } );
+ await expect( chart.locator( 'svg, canvas, table' ).first() ).toBeVisible();
+ } );
+} );
diff --git a/tests/e2e/specs/remote-import.spec.js b/tests/e2e/specs/remote-import.spec.js
new file mode 100644
index 000000000..aa459a70f
--- /dev/null
+++ b/tests/e2e/specs/remote-import.spec.js
@@ -0,0 +1,70 @@
+/**
+ * WordPress dependencies
+ */
+const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
+
+/**
+ * Internal dependencies
+ */
+const { deleteAllCharts, getAssetFilePath, CHART_JS_LABELS, selectChartAdmin } = require('../utils/common');
+
+/**
+ * The remote-import UI is Pro-gated, but the admin-ajax endpoint runs in the free
+ * plugin, so these tests drive the endpoint directly (issue #591 SSRF fix).
+ *
+ * On error the endpoint responds with `alert("...")`; on success it assigns
+ * `win.visualizer.charts.canvas.series` / `.data`.
+ */
+test.describe( 'Remote import (secure fetch)', () => {
+ test.beforeEach( async ( { admin, requestUtils, page } ) => {
+ await deleteAllCharts( requestUtils );
+ page.setDefaultTimeout( 5000 );
+ } );
+
+ /**
+ * Opens the classic builder and returns the upload-data admin-ajax URL
+ * (carries the nonce and chart id) from the one-time-import form.
+ */
+ async function getUploadAction( admin, page ) {
+ await admin.visitAdminPage( 'admin.php?page=visualizer&vaction=addnew' );
+ await page.waitForURL( '**/admin.php?page=visualizer&vaction=addnew' );
+ await page.getByRole('button', { name: 'Classic Builder Step-by-step' }).click();
+ await page.waitForSelector('h1:text("Visualizer")');
+
+ await selectChartAdmin( page.frameLocator('iframe'), CHART_JS_LABELS.table );
+
+ return page.frameLocator('iframe').locator('#vz-one-time-import').getAttribute('action');
+ }
+
+ async function importFromUrl( page, action, url ) {
+ const response = await page.request.post( action, {
+ form: {
+ remote_data: url,
+ 'vz-import-time': '-1',
+ },
+ } );
+ return response.text();
+ }
+
+ test( 'blocks import from a non-public address', async ( { admin, page } ) => {
+ const action = await getUploadAction( admin, page );
+
+ const body = await importFromUrl( page, action, 'http://169.254.169.254/latest/meta-data/data.csv' );
+
+ expect( body ).toContain( 'alert(' );
+ expect( body ).not.toContain( 'canvas.series' );
+ } );
+
+ test( 'imports a CSV served from the site itself', async ( { admin, page, requestUtils } ) => {
+ const media = await requestUtils.uploadMedia( getAssetFilePath( 'pie.csv' ) );
+ const action = await getUploadAction( admin, page );
+
+ // Inside the wp-env container the mapped port is unreachable; Apache serves the
+ // same site on port 80, so PHP can only fetch the file through that.
+ const containerUrl = media.source_url.replace( /:\d+\//, '/' );
+ const body = await importFromUrl( page, action, containerUrl );
+
+ expect( body ).not.toContain( 'alert(' );
+ expect( body ).toContain( 'canvas.series' );
+ } );
+} );
diff --git a/tests/test-ajax.php b/tests/test-ajax.php
index 20b1a26f1..1e39e2962 100644
--- a/tests/test-ajax.php
+++ b/tests/test-ajax.php
@@ -60,6 +60,147 @@ public function setUp(): void {
}
+ /**
+ * Test that the AI Builder URL import rejects local file paths.
+ */
+ public function test_ai_builder_file_url_rejects_local_path() {
+ wp_set_current_user( $this->contibutor_user_id );
+ $chart_id = $this->factory->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_status' => 'draft',
+ 'post_author' => $this->contibutor_user_id,
+ )
+ );
+ $file = wp_tempnam( 'visualizer-local.csv' );
+ file_put_contents( $file, "Label,Value\nstring,number\nSecret,42" );
+
+ $_POST = array(
+ 'chart_id' => $chart_id,
+ 'nonce' => wp_create_nonce( 'visualizer-ai-upload-' . $chart_id ),
+ 'source_type' => 'file_url',
+ 'file_url' => $file,
+ );
+
+ try {
+ $this->_handleAjax( 'visualizer-ai-upload' );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected after wp_send_json_error().
+ }
+ wp_delete_file( $file );
+
+ $response = json_decode( $this->_last_response );
+ $this->assertFalse( $response->success );
+ $this->assertSame( 'Invalid URL. Please check the URL and try again.', $response->data->message );
+ }
+
+ /**
+ * Test that saving chart settings sanitizes the stored settings meta.
+ */
+ public function test_edit_chart_save_sanitizes_settings_meta() {
+ wp_set_current_user( $this->admin_user_id );
+ $chart_id = $this->factory->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_status' => 'publish',
+ 'post_author' => $this->admin_user_id,
+ )
+ );
+ add_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, 'line' );
+
+ $original_request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? $_SERVER['REQUEST_METHOD'] : null;
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+ $_GET = array(
+ 'chart' => $chart_id,
+ 'tab' => 'settings',
+ 'nonce' => wp_create_nonce(),
+ );
+ // No literal '<' so the payload survives the wp_strip_all_tags() pass at
+ // the top of renderChartPages(); only sanitizeSettings() removes the
+ // percent-encoded octets. This fails if the sanitizeSettings() call is
+ // dropped from the save path.
+ $_POST = array(
+ 'backend-title' => 'Chart %3Cscript%3E',
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_EDIT_CHART );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected.
+ } finally {
+ if ( null === $original_request_method ) {
+ unset( $_SERVER['REQUEST_METHOD'] );
+ } else {
+ $_SERVER['REQUEST_METHOD'] = $original_request_method;
+ }
+ }
+
+ $settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true );
+ $this->assertSame( 'Chart script', $settings['backend-title'] );
+ }
+
+ /**
+ * Test that a user cannot request an upload nonce for another user's chart.
+ */
+ public function test_ai_builder_chart_nonce_requires_chart_edit_permission() {
+ $chart_id = $this->factory->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_status' => 'publish',
+ 'post_author' => $this->admin_user_id,
+ )
+ );
+ wp_set_current_user( $this->contibutor_user_id );
+
+ $_POST = array(
+ 'chart_id' => $chart_id,
+ 'nonce' => wp_create_nonce( 'visualizer-ai-builder' ),
+ );
+
+ try {
+ $this->_handleAjax( 'visualizer-ai-chart-nonce' );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected after wp_send_json_error().
+ }
+
+ $response = json_decode( $this->_last_response );
+ $this->assertFalse( $response->success );
+ $this->assertSame( 'Unauthorized.', $response->data->message );
+ }
+
+ /**
+ * Test that a user cannot upload data to another user's chart.
+ */
+ public function test_ai_builder_upload_requires_chart_edit_permission() {
+ $chart_id = $this->factory->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_status' => 'publish',
+ 'post_author' => $this->admin_user_id,
+ )
+ );
+ $original_content = get_post_field( 'post_content', $chart_id );
+ wp_set_current_user( $this->contibutor_user_id );
+
+ $_POST = array(
+ 'chart_id' => $chart_id,
+ 'nonce' => wp_create_nonce( 'visualizer-ai-upload-' . $chart_id ),
+ 'source_type' => 'csv_string',
+ 'csv_data' => "Label,Value\nstring,number\nSecret,42",
+ );
+
+ try {
+ $this->_handleAjax( 'visualizer-ai-upload' );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected after wp_send_json_error().
+ }
+
+ $response = json_decode( $this->_last_response );
+ $this->assertFalse( $response->success );
+ $this->assertSame( 'Unauthorized.', $response->data->message );
+ $this->assertSame( $original_content, get_post_field( 'post_content', $chart_id ) );
+ }
+
/**
* Test the AJAX response for fetching the database data.
*/
@@ -382,39 +523,102 @@ public function test_json_get_data_denied_for_subscriber() {
}
/**
- * A permitted user (contributor) is not blocked by the guard, and the JSON source fetches through the
- * SSRF-safe transport (`reject_unsafe_urls`), matching the CSV path (issue #591 SSRF fix).
+ * JSON get-data admits a user who can edit the chart (issue #591 access-control fix).
*/
- public function test_json_get_roots_allowed_for_contributor_uses_safe_transport() {
- wp_set_current_user( $this->contibutor_user_id );
+ public function test_json_get_data_allows_editor_for_editable_chart() {
+ $chart_id = $this->factory->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_author' => $this->admin_user_id,
+ )
+ );
+ $this->_setRole( 'editor' );
+
+ $filter = function () {
+ return array(
+ 'headers' => array(),
+ 'body' => wp_json_encode( array( array( 'name' => 'a', 'value' => 1 ), array( 'name' => 'b', 'value' => 2 ) ) ),
+ 'response' => array( 'code' => 200, 'message' => '' ),
+ 'cookies' => array(),
+ 'filename' => null,
+ );
+ };
+ add_filter( 'pre_http_request', $filter );
+
+ $_GET['security'] = wp_create_nonce( Visualizer_Plugin::ACTION_JSON_GET_DATA . Visualizer_Plugin::VERSION );
+ $_POST['params'] = array(
+ 'url' => 'http://93.184.216.34/data.json',
+ 'method' => 'GET',
+ 'chart' => $chart_id,
+ 'root' => 'root',
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_JSON_GET_DATA );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // We expected this, do nothing.
+ }
+ remove_filter( 'pre_http_request', $filter );
+
+ $response = json_decode( $this->_last_response );
+ $this->assertIsObject( $response );
+ $this->assertTrue( $response->success );
+ }
+
+ /**
+ * JSON get-data dies for a chart the user cannot edit (issue #591 access-control fix).
+ */
+ public function test_json_get_data_denied_for_chart_user_cannot_edit() {
+ $chart_id = $this->factory->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_author' => $this->admin_user_id,
+ )
+ );
$this->_setRole( 'contributor' );
- $captured = array();
- add_filter(
- 'pre_http_request',
- function ( $pre, $args, $url ) use ( &$captured ) {
- $captured[] = array(
- 'url' => $url,
- 'reject' => ! empty( $args['reject_unsafe_urls'] ),
- );
- return array(
- 'headers' => array(),
- 'body' => wp_json_encode( array( 'results' => array( array( 'id' => 1 ) ) ) ),
- 'response' => array(
- 'code' => 200,
- 'message' => 'OK',
- ),
- 'cookies' => array(),
- 'filename' => null,
- );
- },
- 10,
- 3
+ $requests = 0;
+ $filter = function ( $preempt ) use ( &$requests ) {
+ $requests++;
+ return $preempt;
+ };
+ add_filter( 'pre_http_request', $filter );
+
+ $_GET['security'] = wp_create_nonce( Visualizer_Plugin::ACTION_JSON_GET_DATA . Visualizer_Plugin::VERSION );
+ $_POST['params'] = array(
+ 'url' => 'http://93.184.216.34/data.json',
+ 'method' => 'GET',
+ 'chart' => $chart_id,
);
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_JSON_GET_DATA );
+ $this->fail( 'Expected the request to die for a chart the user cannot edit.' );
+ } catch ( WPAjaxDieStopException $e ) {
+ $this->assertSame( '', $e->getMessage() );
+ }
+ remove_filter( 'pre_http_request', $filter );
+
+ $this->assertSame( 0, $requests );
+ }
+
+ /**
+ * A contributor may use JSON import, but link-local destinations are blocked before transport.
+ */
+ public function test_json_get_roots_blocks_link_local_for_contributor() {
+ wp_set_current_user( $this->contibutor_user_id );
+ $this->_setRole( 'contributor' );
+
+ $requests = 0;
+ $filter = function ( $preempt ) use ( &$requests ) {
+ $requests++;
+ return $preempt;
+ };
+ add_filter( 'pre_http_request', $filter );
+
$_GET['security'] = wp_create_nonce( Visualizer_Plugin::ACTION_JSON_GET_ROOTS . Visualizer_Plugin::VERSION );
$_POST['params'] = array(
- 'url' => 'http://127.0.0.1:9999/latest/meta-data/',
+ 'url' => 'http://169.254.169.254/latest/meta-data/',
'method' => 'GET',
);
@@ -423,17 +627,319 @@ function ( $pre, $args, $url ) use ( &$captured ) {
} catch ( WPAjaxDieContinueException $e ) {
// We expected this, do nothing.
}
+ remove_filter( 'pre_http_request', $filter );
$response = json_decode( $this->_last_response );
$this->assertIsObject( $response );
- // The capability guard must NOT block a user who has edit_posts.
- $msg = isset( $response->data->msg ) ? $response->data->msg : '';
- $this->assertNotEquals( 'You do not have permission to perform this action.', $msg );
- // Every outbound request for the JSON source must use the SSRF-safe transport.
- $this->assertNotEmpty( $captured, 'The JSON source did not attempt any fetch.' );
- foreach ( $captured as $req ) {
- $this->assertTrue( $req['reject'], 'JSON fetch must use wp_safe_remote_* (reject_unsafe_urls => true).' );
+ $this->assertFalse( $response->success );
+ $this->assertSame( 0, $requests );
+ }
+
+ /**
+ * Chart authorization validates both the object type and meta capability.
+ */
+ public function test_chart_authorization_is_object_specific() {
+ $own_chart = $this->create_chart_for_user( $this->contibutor_user_id, 'publish' );
+ $other_chart = $this->create_chart_for_user( $this->admin_user_id );
+ $regular_post = $this->factory->post->create(
+ array(
+ 'post_author' => $this->contibutor_user_id,
+ )
+ );
+ wp_set_current_user( $this->contibutor_user_id );
+
+ $this->assertTrue( Visualizer_Module::can_edit_chart( $own_chart ) );
+ $this->assertFalse( Visualizer_Module::can_edit_chart( $other_chart ) );
+ $this->assertFalse( Visualizer_Module::can_edit_chart( $regular_post ) );
+ $this->assertFalse( Visualizer_Module::can_edit_chart( PHP_INT_MAX ) );
+ }
+
+ /**
+ * The chart editor rejects another user's chart before saving settings.
+ */
+ public function test_edit_chart_denied_for_chart_user_cannot_edit() {
+ $chart_id = $this->create_chart_for_user( $this->admin_user_id );
+ $original = array( 'title' => 'Original' );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, $original );
+ wp_set_current_user( $this->contibutor_user_id );
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+ $_GET = array(
+ 'chart' => $chart_id,
+ 'tab' => 'settings',
+ 'nonce' => wp_create_nonce(),
+ );
+ $_POST = array(
+ 'title' => 'Overwritten',
+ 'save' => 1,
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_EDIT_CHART );
+ $this->fail( 'Expected the request to die for a chart the user cannot edit.' );
+ } catch ( WPAjaxDieStopException $e ) {
+ $this->assertStringContainsString( 'permission', $e->getMessage() );
}
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ $this->assertSame( $original, get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true ) );
+ }
+
+ /**
+ * Chart editors cannot update the site-wide map API key.
+ */
+ public function test_edit_chart_map_api_key_requires_manage_options() {
+ $chart_id = $this->create_chart_for_user( $this->contibutor_user_id );
+ add_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, 'line' );
+ update_option( 'visualizer-map-api-key', 'original-key' );
+ wp_set_current_user( $this->contibutor_user_id );
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+ $_GET = array(
+ 'chart' => $chart_id,
+ 'tab' => 'settings',
+ 'nonce' => wp_create_nonce(),
+ );
+ $_POST = array(
+ 'map_api_key' => 'attacker-key',
+ 'save' => 1,
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_EDIT_CHART );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected when the editor response completes.
+ }
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ $this->assertSame( 'original-key', get_option( 'visualizer-map-api-key' ) );
+ }
+
+ /**
+ * Contributors only receive charts they own from the library endpoint.
+ */
+ public function test_get_charts_only_lists_editable_scope_for_contributor() {
+ $own_chart = $this->create_chart_for_user( $this->contibutor_user_id, 'publish' );
+ $other_chart = $this->create_chart_for_user( $this->admin_user_id, 'publish' );
+ wp_set_current_user( $this->contibutor_user_id );
+ $_GET = array(
+ 'nonce' => wp_create_nonce( Visualizer_Plugin::ACTION_GET_CHARTS ),
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_GET_CHARTS );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected after the JSON response.
+ }
+
+ $response = json_decode( $this->_last_response, true );
+ $chart_ids = wp_list_pluck( $response['data'], 'id' );
+ $this->assertTrue( $response['success'] );
+ $this->assertContains( $own_chart, $chart_ids );
+ $this->assertNotContains( $other_chart, $chart_ids );
+ }
+
+ /**
+ * A valid nonce does not permit deleting another user's chart.
+ */
+ public function test_delete_chart_denied_for_chart_user_cannot_delete() {
+ $chart_id = $this->create_chart_for_user( $this->admin_user_id );
+ wp_set_current_user( $this->contibutor_user_id );
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+ $_POST = array(
+ 'chart' => $chart_id,
+ 'nonce' => wp_create_nonce(),
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_DELETE_CHART );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected after the JSON response.
+ }
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ $response = json_decode( $this->_last_response );
+ $this->assertFalse( $response->success );
+ $this->assertNotNull( get_post( $chart_id ) );
+ }
+
+ /**
+ * A contributor may delete their own published chart.
+ */
+ public function test_delete_chart_allows_owner_without_delete_published_posts() {
+ $chart_id = $this->create_chart_for_user( $this->contibutor_user_id, 'publish' );
+ wp_set_current_user( $this->contibutor_user_id );
+ $_SERVER['REQUEST_METHOD'] = 'POST';
+ $_POST = array(
+ 'chart' => $chart_id,
+ 'nonce' => wp_create_nonce(),
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_DELETE_CHART );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected after the JSON response.
+ }
+ $_SERVER['REQUEST_METHOD'] = 'GET';
+
+ $response = json_decode( $this->_last_response );
+ $this->assertTrue( $response->success );
+ $this->assertNull( get_post( $chart_id ) );
+ }
+
+ /**
+ * A valid nonce does not permit cloning another user's chart.
+ */
+ public function test_clone_chart_denied_for_chart_user_cannot_edit() {
+ $chart_id = $this->create_chart_for_user( $this->admin_user_id );
+ wp_set_current_user( $this->contibutor_user_id );
+ $_GET = array(
+ 'chart' => $chart_id,
+ 'nonce' => wp_create_nonce( Visualizer_Plugin::ACTION_CLONE_CHART ),
+ );
+ $before = wp_count_posts( Visualizer_Plugin::CPT_VISUALIZER )->draft;
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_CLONE_CHART );
+ $this->fail( 'Expected the request to die for a chart the user cannot edit.' );
+ } catch ( WPAjaxDieStopException $e ) {
+ // cloneChart() ends with wp_die() in the test environment when denied.
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected when the handler ends normally.
+ }
+
+ $this->assertSame( $before, wp_count_posts( Visualizer_Plugin::CPT_VISUALIZER )->draft );
+ }
+
+ /**
+ * A user cannot alter another user's JSON refresh schedule.
+ */
+ public function test_json_schedule_denied_for_chart_user_cannot_edit() {
+ $chart_id = $this->create_chart_for_user( $this->admin_user_id );
+ wp_set_current_user( $this->contibutor_user_id );
+ $_POST = array(
+ 'chart' => $chart_id,
+ 'time' => 12,
+ 'security' => wp_create_nonce( Visualizer_Plugin::ACTION_JSON_SET_SCHEDULE . Visualizer_Plugin::VERSION ),
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_JSON_SET_SCHEDULE );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected after the JSON response.
+ }
+
+ $response = json_decode( $this->_last_response );
+ $this->assertFalse( $response->success );
+ $this->assertSame( '', get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_SCHEDULE, true ) );
+ }
+
+ /**
+ * A user cannot replace another user's JSON chart data.
+ */
+ public function test_json_set_data_denied_for_chart_user_cannot_edit() {
+ $chart_id = $this->create_chart_for_user( $this->admin_user_id );
+ wp_set_current_user( $this->contibutor_user_id );
+ $_GET = array(
+ 'chart' => $chart_id,
+ 'security' => wp_create_nonce( Visualizer_Plugin::ACTION_JSON_SET_DATA . Visualizer_Plugin::VERSION ),
+ );
+ $_POST = array(
+ 'url' => 'https://example.com/data.json',
+ 'method' => 'GET',
+ 'root' => 'items',
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_JSON_SET_DATA );
+ $this->fail( 'Expected the request to die for a chart the user cannot edit.' );
+ } catch ( WPAjaxDieStopException $e ) {
+ $this->assertStringContainsString( 'permission', $e->getMessage() );
+ }
+
+ $this->assertSame( '', get_post_meta( $chart_id, Visualizer_Plugin::CF_JSON_URL, true ) );
+ }
+
+ /**
+ * A user cannot save filters on another user's chart.
+ */
+ public function test_save_filter_denied_for_chart_user_cannot_edit() {
+ $chart_id = $this->create_chart_for_user( $this->admin_user_id );
+ wp_set_current_user( $this->contibutor_user_id );
+ $_GET = array(
+ 'chart' => $chart_id,
+ 'security' => wp_create_nonce( Visualizer_Plugin::ACTION_SAVE_FILTER_QUERY . Visualizer_Plugin::VERSION ),
+ );
+
+ try {
+ $this->_handleAjax( Visualizer_Plugin::ACTION_SAVE_FILTER_QUERY );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // Expected after the JSON response.
+ }
+
+ $response = json_decode( $this->_last_response );
+ $this->assertFalse( $response->success );
+ }
+
+ /**
+ * Test that the setup wizard import step builds per-row settings from the
+ * decoded sample data, covering the decode_content() call in the wizard.
+ */
+ public function test_wizard_import_chart_builds_settings_from_decoded_sample_data() {
+ wp_set_current_user( $this->admin_user_id );
+
+ $_POST = array(
+ 'security' => wp_create_nonce( VISUALIZER_ABSPATH ),
+ 'step' => 'step_2',
+ 'chart_type' => 'pie',
+ );
+
+ try {
+ $this->_handleAjax( 'visualizer_wizard_step_process' );
+ } catch ( WPAjaxDieContinueException $e ) {
+ // We expected this, do nothing.
+ }
+
+ // Skip any PHP notices emitted before the JSON payload.
+ $response = json_decode( substr( $this->_last_response, (int) strpos( $this->_last_response, '{' ) ) );
+ $this->assertSame( 1, $response->success );
+
+ // Data rows in the bundled sample = total lines minus label + type rows.
+ $expected_rows = count( array_filter( array_map( 'trim', file( VISUALIZER_ABSPATH . '/samples/pie.csv' ) ) ) ) - 2;
+ $settings = get_post_meta( $response->chart_id, Visualizer_Plugin::CF_SETTINGS, true );
+
+ $this->assertGreaterThan( 0, $expected_rows );
+ $this->assertCount( $expected_rows, $settings['series'] );
+ $this->assertCount( $expected_rows, $settings['slices'] );
+ }
+
+ /**
+ * Creates a chart owned by a specific user.
+ *
+ * @param int $user_id User ID.
+ * @param string $status Post status.
+ * @return int
+ */
+ private function create_chart_for_user( $user_id, $status = 'draft' ) {
+ $chart_id = $this->factory->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_status' => $status,
+ 'post_author' => $user_id,
+ 'post_content' => wp_slash( serialize( array( array( 'Label' ), array( 'Value' ) ) ) ),
+ )
+ );
+ add_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, 'line' );
+ add_post_meta(
+ $chart_id,
+ Visualizer_Plugin::CF_SERIES,
+ array(
+ array(
+ 'label' => 'Label',
+ 'type' => 'string',
+ ),
+ )
+ );
+
+ return $chart_id;
}
/**
diff --git a/tests/test-export.php b/tests/test-export.php
index 7f47aeb60..3c56887f1 100644
--- a/tests/test-export.php
+++ b/tests/test-export.php
@@ -163,4 +163,21 @@ public function test_csv_export_invalid_chart_returns_no_success() {
$response = json_decode( $this->_last_response );
$this->assertTrue( empty( $response ) || empty( $response->success ) );
}
+
+ /**
+ * Exporting requires permission to edit the requested chart.
+ */
+ public function test_csv_export_denied_for_chart_user_cannot_edit() {
+ $this->create_chart();
+ $user_id = $this->factory->user->create(
+ array(
+ 'role' => 'contributor',
+ )
+ );
+ wp_set_current_user( $user_id );
+
+ $response = $this->run_export();
+
+ $this->assertTrue( empty( $response ) || empty( $response->success ) );
+ }
}
diff --git a/tests/test-remote-fetch.php b/tests/test-remote-fetch.php
new file mode 100644
index 000000000..308ffc403
--- /dev/null
+++ b/tests/test-remote-fetch.php
@@ -0,0 +1,704 @@
+assertWPError( $response );
+ $this->assertSame( 0, $requests );
+ }
+
+ /**
+ * Non-public URL examples.
+ *
+ * @return array[]
+ */
+ public function blocked_urls() {
+ return array(
+ 'loopback' => array( 'http://127.0.0.1/' ),
+ 'private' => array( 'http://10.0.0.1/' ),
+ 'link-local' => array( 'http://169.254.169.254/latest/meta-data/' ),
+ 'carrier-grade' => array( 'http://100.64.0.1/' ),
+ 'documentation' => array( 'http://192.0.2.1/' ),
+ 'multicast' => array( 'http://224.0.0.1/' ),
+ 'reserved' => array( 'http://240.0.0.1/' ),
+ 'blocked-port' => array( 'http://93.184.216.34:8443/' ),
+ 'ipv4-mapped' => array( 'http://[::ffff:169.254.169.254]/' ),
+ );
+ }
+
+ /**
+ * A validated public destination reaches the WordPress HTTP transport.
+ */
+ public function test_allows_public_destination() {
+ $filter = function ( $preempt, $args, $url ) {
+ $this->assertSame( 'http://93.184.216.34/data.json', $url );
+ $this->assertTrue( $args['reject_unsafe_urls'] );
+ return $this->response( 200, array(), '{"ok":true}' );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 3 );
+
+ $response = Visualizer_Remote_Fetch::request( 'http://93.184.216.34/data.json' );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $response );
+ $this->assertSame( '{"ok":true}', wp_remote_retrieve_body( $response ) );
+ }
+
+ /**
+ * Every redirect target must pass the same destination policy.
+ */
+ public function test_blocks_redirect_to_link_local_destination() {
+ $requests = 0;
+ $filter = function () use ( &$requests ) {
+ $requests++;
+ return $this->response( 302, array( 'location' => 'http://169.254.169.254/latest/meta-data/' ) );
+ };
+ add_filter( 'pre_http_request', $filter );
+
+ $response = Visualizer_Remote_Fetch::request( 'http://93.184.216.34/start' );
+
+ remove_filter( 'pre_http_request', $filter );
+ $this->assertWPError( $response );
+ $this->assertSame( 'visualizer_unsafe_remote_url', $response->get_error_code() );
+ $this->assertSame( 1, $requests );
+ }
+
+ /**
+ * Sensitive headers must not follow a redirect to another origin.
+ */
+ public function test_strips_sensitive_headers_on_cross_origin_redirect() {
+ $requests = array();
+ $filter = function ( $preempt, $args, $url ) use ( &$requests ) {
+ $requests[] = array( $url, $args['headers'] );
+ if ( 1 === count( $requests ) ) {
+ return $this->response( 302, array( 'location' => 'http://93.184.216.35/data' ) );
+ }
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 3 );
+
+ $response = Visualizer_Remote_Fetch::request(
+ 'http://93.184.216.34/start',
+ array(
+ 'headers' => array(
+ 'Accept' => 'application/json',
+ 'Authorization' => 'Bearer secret',
+ 'X-Api-Key' => 'secret',
+ ),
+ )
+ );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $response );
+ $this->assertCount( 2, $requests );
+ $this->assertSame( array( 'Accept' => 'application/json' ), $requests[1][1] );
+ }
+
+ /**
+ * Cookies must not follow a redirect to another origin.
+ */
+ public function test_drops_cookies_on_cross_origin_redirect() {
+ $requests = array();
+ $filter = function ( $preempt, $args, $url ) use ( &$requests ) {
+ $requests[] = array( $url, $args['cookies'] );
+ if ( 1 === count( $requests ) ) {
+ $cookie = new WP_Http_Cookie( 'redirect=secret; Path=/', 'http://93.184.216.34/start' );
+ return $this->response( 302, array( 'location' => 'http://93.184.216.35/data' ), '', array( $cookie ) );
+ }
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 3 );
+
+ $response = Visualizer_Remote_Fetch::request(
+ 'http://93.184.216.34/start',
+ array( 'cookies' => array( 'session' => 'secret' ) )
+ );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $response );
+ $this->assertCount( 2, $requests );
+ $this->assertNotEmpty( $requests[0][1] );
+ $this->assertSame( array(), $requests[1][1] );
+ }
+
+ /**
+ * Cookies set by a response must be available to its same-origin redirect target.
+ */
+ public function test_carries_response_cookies_on_same_origin_redirect() {
+ $requests = array();
+ $filter = function ( $preempt, $args ) use ( &$requests ) {
+ $requests[] = $args['cookies'];
+ if ( 1 === count( $requests ) ) {
+ $cookie = new WP_Http_Cookie( 'session=secret; Path=/', 'http://93.184.216.34/start' );
+ return $this->response( 302, array( 'location' => '/next' ), '', array( $cookie ) );
+ }
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $response = Visualizer_Remote_Fetch::request( 'http://93.184.216.34/start' );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $response );
+ $this->assertCount( 2, $requests );
+ $this->assertCount( 1, $requests[1] );
+ $this->assertSame( 'session', $requests[1][0]->name );
+ }
+
+ /**
+ * WordPress accepts request headers as a string, including across redirects.
+ */
+ public function test_normalizes_string_headers_before_cross_origin_redirect() {
+ $requests = array();
+ $filter = function ( $preempt, $args ) use ( &$requests ) {
+ $requests[] = $args['headers'];
+ if ( 1 === count( $requests ) ) {
+ return $this->response( 302, array( 'location' => 'http://93.184.216.35/next' ) );
+ }
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $response = Visualizer_Remote_Fetch::request(
+ 'http://93.184.216.34/start',
+ array( 'headers' => "Accept: application/json\r\nAuthorization: Bearer secret" )
+ );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $response );
+ $this->assertCount( 2, $requests );
+ $this->assertSame( array( 'accept' => 'application/json' ), $requests[1] );
+ }
+
+ /**
+ * The validated addresses are pinned on the cURL transport only while the request dispatches.
+ */
+ public function test_pins_validated_addresses_during_dispatch() {
+ $pinned_during = null;
+ $filter = function () use ( &$pinned_during ) {
+ $pinned_during = has_action( 'http_api_curl' );
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter );
+
+ $response = Visualizer_Remote_Fetch::request( 'http://example.com/data.json' );
+
+ remove_filter( 'pre_http_request', $filter );
+ $this->assertNotWPError( $response );
+ $this->assertTrue( $pinned_during );
+ $this->assertFalse( has_action( 'http_api_curl' ) );
+ }
+
+ /**
+ * Addresses use syntax supported by the installed cURL version.
+ *
+ * @dataProvider curl_resolve_entries
+ * @param string $curl_version cURL version.
+ * @param string[] $ips Validated addresses.
+ * @param string $expected Expected resolve entry.
+ */
+ public function test_formats_addresses_for_curl_resolve( $curl_version, $ips, $expected ) {
+ $method = new ReflectionMethod( Visualizer_Remote_Fetch::class, 'pin_validated_addresses' );
+ $method->setAccessible( true );
+ $pin = $method->invoke(
+ null,
+ 'https://example.com/data.json',
+ $ips,
+ $curl_version
+ );
+
+ $this->assertIsCallable( $pin );
+ $reflection = new ReflectionFunction( $pin );
+ $this->assertSame( $expected, $reflection->getStaticVariables()['entry'] );
+ remove_action( 'http_api_curl', $pin );
+ }
+
+ /**
+ * Resolve entries for supported cURL syntax generations.
+ *
+ * @return array[]
+ */
+ public function curl_resolve_entries() {
+ return array(
+ 'modern mixed addresses' => array(
+ '7.59.0',
+ array( '93.184.216.34', '2606:2800:220:1:248:1893:25c8:1946' ),
+ 'example.com:443:93.184.216.34,[2606:2800:220:1:248:1893:25c8:1946]',
+ ),
+ 'legacy mixed addresses' => array(
+ '7.58.0',
+ array( '2606:2800:220:1:248:1893:25c8:1946', '93.184.216.34' ),
+ 'example.com:443:93.184.216.34',
+ ),
+ 'pre-IPv6 mixed addresses' => array(
+ '7.56.0',
+ array( '2606:2800:220:1:248:1893:25c8:1946', '93.184.216.34' ),
+ 'example.com:443:93.184.216.34',
+ ),
+ 'legacy IPv6 address' => array(
+ '7.57.0',
+ array( '2606:2800:220:1:248:1893:25c8:1946' ),
+ 'example.com:443:[2606:2800:220:1:248:1893:25c8:1946]',
+ ),
+ );
+ }
+
+ /**
+ * cURL versions without bracketed IPv6 resolve support fail closed for IPv6-only hosts.
+ */
+ public function test_rejects_ipv6_pinning_on_unsupported_curl() {
+ $method = new ReflectionMethod( Visualizer_Remote_Fetch::class, 'pin_validated_addresses' );
+ $method->setAccessible( true );
+ $response = $method->invoke(
+ null,
+ 'https://example.com/data.json',
+ array( '2606:2800:220:1:248:1893:25c8:1946' ),
+ '7.56.0'
+ );
+
+ $this->assertWPError( $response );
+ $this->assertSame( 'visualizer_remote_transport', $response->get_error_code() );
+ }
+
+ /**
+ * JSON imports retain the established extended request methods.
+ *
+ * @dataProvider extended_json_request_methods
+ * @param string $method HTTP method.
+ */
+ public function test_allows_extended_json_request_methods( $method ) {
+ $filter = function ( $preempt, $args ) use ( $method ) {
+ $this->assertSame( $method, $args['method'] );
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $response = Visualizer_Remote_Fetch::request( 'http://93.184.216.34/', array( 'method' => $method ) );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $response );
+ }
+
+ /**
+ * Extended JSON request methods.
+ *
+ * @return array[]
+ */
+ public function extended_json_request_methods() {
+ return array(
+ 'PUT' => array( 'PUT' ),
+ 'PATCH' => array( 'PATCH' ),
+ 'DELETE' => array( 'DELETE' ),
+ 'HEAD' => array( 'HEAD' ),
+ 'OPTIONS' => array( 'OPTIONS' ),
+ 'PROPFIND' => array( 'PROPFIND' ),
+ );
+ }
+
+ /**
+ * Header and method policy is applied before dispatch.
+ */
+ public function test_rejects_unsupported_method_before_request() {
+ $requests = 0;
+ $responses = array();
+ $filter = function ( $preempt ) use ( &$requests ) {
+ $requests++;
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter );
+
+ foreach ( array( 'CONNECT', 'TRACE', "GET\r\nX-Injected: true" ) as $method ) {
+ $responses[] = Visualizer_Remote_Fetch::request( 'http://93.184.216.34/', array( 'method' => $method ) );
+ }
+
+ remove_filter( 'pre_http_request', $filter );
+
+ foreach ( $responses as $response ) {
+ $this->assertWPError( $response );
+ $this->assertSame( 'visualizer_remote_method', $response->get_error_code() );
+ }
+ $this->assertSame( 0, $requests );
+ }
+
+ /**
+ * URLs on the site's own host skip the public-address check, matching core.
+ */
+ public function test_allows_same_host_destination_without_dns_check() {
+ // A filter beats the WP_HOME constant pinned by some test configs (e.g. wp-env), which makes update_option() a no-op.
+ add_filter(
+ 'option_home',
+ function () {
+ return 'http://visualizer.internal';
+ },
+ 100
+ );
+
+ $requests = 0;
+ $filter = function ( $preempt ) use ( &$requests ) {
+ $requests++;
+ return $this->response( 200, array(), 'a,b' );
+ };
+ add_filter( 'pre_http_request', $filter );
+
+ $response = Visualizer_Remote_Fetch::request( 'http://visualizer.internal/wp-content/uploads/data.csv' );
+
+ remove_filter( 'pre_http_request', $filter );
+ $this->assertNotWPError( $response );
+ $this->assertSame( 1, $requests );
+ }
+
+ /**
+ * A same-origin redirect (relative Location) keeps request headers and resolves the target URL.
+ */
+ public function test_same_origin_redirect_keeps_headers_and_resolves_relative_location() {
+ $requests = array();
+ $filter = function ( $preempt, $args, $url ) use ( &$requests ) {
+ $requests[] = array( $url, $args['headers'] );
+ if ( 1 === count( $requests ) ) {
+ return $this->response( 302, array( 'location' => '/next' ) );
+ }
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 3 );
+
+ $headers = array(
+ 'Accept' => 'application/json',
+ 'Authorization' => 'Bearer secret',
+ );
+ $response = Visualizer_Remote_Fetch::request( 'http://93.184.216.34/start', array( 'headers' => $headers ) );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $response );
+ $this->assertCount( 2, $requests );
+ $this->assertSame( 'http://93.184.216.34/next', $requests[1][0] );
+ $this->assertSame( $headers, $requests[1][1] );
+ }
+
+ /**
+ * A 301 redirect preserves the request method and body, matching WordPress Requests.
+ */
+ public function test_301_redirect_preserves_post_method_and_body() {
+ $requests = array();
+ $filter = function ( $preempt, $args ) use ( &$requests ) {
+ $requests[] = array( $args['method'], isset( $args['body'] ) ? $args['body'] : null );
+ return 1 === count( $requests )
+ ? $this->response( 301, array( 'location' => '/next' ) )
+ : $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $response = Visualizer_Remote_Fetch::request(
+ 'http://93.184.216.34/start',
+ array(
+ 'method' => 'POST',
+ 'body' => '{"query":"data"}',
+ )
+ );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $response );
+ $this->assertSame(
+ array(
+ array( 'POST', '{"query":"data"}' ),
+ array( 'POST', '{"query":"data"}' ),
+ ),
+ $requests
+ );
+ }
+
+ /**
+ * Browser-compatible redirects switch extended methods to GET without a body.
+ *
+ * @dataProvider get_redirect_statuses
+ * @param int $status Redirect status.
+ */
+ public function test_browser_redirect_switches_extended_method_to_get( $status ) {
+ $requests = array();
+ $filter = function ( $preempt, $args ) use ( &$requests, $status ) {
+ $requests[] = array( $args['method'], isset( $args['body'] ) ? $args['body'] : null );
+ return 1 === count( $requests )
+ ? $this->response( $status, array( 'location' => '/next' ) )
+ : $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $response = Visualizer_Remote_Fetch::request(
+ 'http://93.184.216.34/start',
+ array(
+ 'method' => 'PATCH',
+ 'body' => '{"query":"data"}',
+ )
+ );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $response );
+ $this->assertSame( array( 'GET', null ), $requests[1] );
+ }
+
+ /**
+ * Redirect statuses that WordPress follows with GET.
+ *
+ * @return array[]
+ */
+ public function get_redirect_statuses() {
+ return array(
+ '302' => array( 302 ),
+ '303' => array( 303 ),
+ );
+ }
+
+ /**
+ * The redirect chain must stop after MAX_REDIRECTS hops.
+ */
+ public function test_stops_after_max_redirects() {
+ $requests = 0;
+ $filter = function () use ( &$requests ) {
+ $requests++;
+ return $this->response( 302, array( 'location' => 'http://93.184.216.34/hop' . $requests ) );
+ };
+ add_filter( 'pre_http_request', $filter );
+
+ $response = Visualizer_Remote_Fetch::request( 'http://93.184.216.34/start' );
+
+ remove_filter( 'pre_http_request', $filter );
+ $this->assertWPError( $response );
+ $this->assertSame( 'visualizer_too_many_redirects', $response->get_error_code() );
+ $this->assertSame( Visualizer_Remote_Fetch::MAX_REDIRECTS + 1, $requests );
+ }
+
+ /**
+ * A successful download streams to a temporary file and returns its path.
+ */
+ public function test_download_returns_temp_file_with_body() {
+ $filter = function ( $preempt, $args ) {
+ $this->assertTrue( $args['stream'] );
+ $this->assertNotEmpty( $args['filename'] );
+ file_put_contents( $args['filename'], 'col1,col2' );
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $tmpfile = Visualizer_Remote_Fetch::download( 'http://93.184.216.34/data.csv' );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $tmpfile );
+ $this->assertSame( 'col1,col2', file_get_contents( $tmpfile ) );
+ wp_delete_file( $tmpfile );
+ }
+
+ /**
+ * A failed download must not leave the temporary file behind.
+ */
+ public function test_download_removes_temp_file_on_error_status() {
+ $tmpfile = null;
+ $filter = function ( $preempt, $args ) use ( &$tmpfile ) {
+ $tmpfile = $args['filename'];
+ file_put_contents( $tmpfile, 'not found' );
+ return $this->response( 404 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $response = Visualizer_Remote_Fetch::download( 'http://93.184.216.34/data.csv' );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertWPError( $response );
+ $this->assertSame( 'visualizer_remote_status', $response->get_error_code() );
+ $this->assertNotEmpty( $tmpfile );
+ $this->assertFileDoesNotExist( $tmpfile );
+ }
+
+ /**
+ * Downloads are size-capped during transfer and possibly-truncated files are rejected.
+ */
+ public function test_download_rejects_file_over_size_limit() {
+ $tmpfile = null;
+ $filter = function ( $preempt, $args ) use ( &$tmpfile ) {
+ $this->assertSame( Visualizer_Remote_Fetch::MAX_DOWNLOAD_BYTES + 1, $args['limit_response_size'] );
+ $tmpfile = $args['filename'];
+ file_put_contents( $tmpfile, str_repeat( 'a', Visualizer_Remote_Fetch::MAX_DOWNLOAD_BYTES + 1 ) );
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $response = Visualizer_Remote_Fetch::download( 'http://93.184.216.34/data.csv' );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertWPError( $response );
+ $this->assertSame( 'visualizer_remote_size', $response->get_error_code() );
+ $this->assertNotEmpty( $tmpfile );
+ $this->assertFileDoesNotExist( $tmpfile );
+ }
+
+ /**
+ * A complete file exactly at the configured limit remains valid.
+ */
+ public function test_download_accepts_file_at_size_limit() {
+ $filter = function ( $preempt, $args ) {
+ $this->assertSame( 5, $args['limit_response_size'] );
+ file_put_contents( $args['filename'], '1234' );
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $tmpfile = Visualizer_Remote_Fetch::download(
+ 'http://93.184.216.34/data.csv',
+ array( 'limit_response_size' => 4 )
+ );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertNotWPError( $tmpfile );
+ $this->assertSame( '1234', file_get_contents( $tmpfile ) );
+ wp_delete_file( $tmpfile );
+ }
+
+ /**
+ * Callers may provide a smaller or larger download limit.
+ */
+ public function test_download_honors_custom_size_limit() {
+ $tmpfile = null;
+ $filter = function ( $preempt, $args ) use ( &$tmpfile ) {
+ $this->assertSame( 5, $args['limit_response_size'] );
+ $tmpfile = $args['filename'];
+ file_put_contents( $tmpfile, '12345' );
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $response = Visualizer_Remote_Fetch::download(
+ 'http://93.184.216.34/data.csv',
+ array( 'limit_response_size' => 4 )
+ );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertWPError( $response );
+ $this->assertSame( 'visualizer_remote_size', $response->get_error_code() );
+ $this->assertNotEmpty( $tmpfile );
+ $this->assertFileDoesNotExist( $tmpfile );
+ }
+
+ /**
+ * XLSX probes only need the four-byte ZIP magic number.
+ *
+ * @dataProvider xlsx_probe_callbacks
+ * @param string $class Probe owner.
+ */
+ public function test_xlsx_probes_limit_response_to_magic_bytes( $class ) {
+ $filter = function ( $preempt, $args ) {
+ $this->assertSame( 4, $args['limit_response_size'] );
+ file_put_contents( $args['filename'], "PK\x03\x04" );
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $method = new ReflectionMethod( $class, '_url_is_xlsx' );
+ $method->setAccessible( true );
+ $result = $method->invoke( null, 'http://93.184.216.34/download' );
+
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertTrue( $result );
+ }
+
+ /**
+ * XLSX probe owners.
+ *
+ * @return array[]
+ */
+ public function xlsx_probe_callbacks() {
+ return array(
+ 'classic' => array( Visualizer_Module_Chart::class ),
+ 'ai builder' => array( Visualizer_Module_AIBuilder::class ),
+ );
+ }
+
+ /**
+ * The XLSX-specific filter controls the gateway download limit.
+ */
+ public function test_xlsx_download_uses_filtered_size_limit() {
+ $max_bytes = 12 * 1024 * 1024;
+ $limit = function () use ( $max_bytes ) {
+ return $max_bytes;
+ };
+ add_filter( 'visualizer_xlsx_max_filesize', $limit );
+
+ $filter = function ( $preempt, $args ) use ( $max_bytes ) {
+ $this->assertSame( $max_bytes + 1, $args['limit_response_size'] );
+ file_put_contents( $args['filename'], 'xlsx' );
+ return $this->response( 200 );
+ };
+ add_filter( 'pre_http_request', $filter, 10, 2 );
+
+ $source = new Visualizer_Source_Xlsx_Remote( 'http://93.184.216.34/data.xlsx' );
+ $method = new ReflectionMethod( $source, '_get_file_path' );
+ $method->setAccessible( true );
+ $path = $method->invoke( $source );
+
+ remove_filter( 'visualizer_xlsx_max_filesize', $limit );
+ remove_filter( 'pre_http_request', $filter, 10 );
+ $this->assertIsString( $path );
+ wp_delete_file( $path );
+ }
+
+ /**
+ * Downloads enforce the same destination policy as plain requests.
+ */
+ public function test_download_blocks_non_public_destination() {
+ $requests = 0;
+ $filter = function ( $preempt ) use ( &$requests ) {
+ $requests++;
+ return $preempt;
+ };
+ add_filter( 'pre_http_request', $filter );
+
+ $response = Visualizer_Remote_Fetch::download( 'http://169.254.169.254/latest/meta-data/' );
+
+ remove_filter( 'pre_http_request', $filter );
+ $this->assertWPError( $response );
+ $this->assertSame( 0, $requests );
+ }
+
+ /**
+ * Builds a WordPress HTTP response fixture.
+ *
+ * @param int $code Status code.
+ * @param array $headers Response headers.
+ * @param string $body Response body.
+ * @param array $cookies Response cookies.
+ * @return array
+ */
+ private function response( $code, $headers = array(), $body = '', $cookies = array() ) {
+ return array(
+ 'headers' => $headers,
+ 'body' => $body,
+ 'response' => array(
+ 'code' => $code,
+ 'message' => '',
+ ),
+ 'cookies' => $cookies,
+ 'filename' => null,
+ );
+ }
+}
diff --git a/tests/test-sanitize-settings.php b/tests/test-sanitize-settings.php
new file mode 100644
index 000000000..fa055dab4
--- /dev/null
+++ b/tests/test-sanitize-settings.php
@@ -0,0 +1,119 @@
+setAccessible( true );
+ return $method->invoke( $module, $post_data );
+ }
+
+ /**
+ * Script tags in settings values are stripped.
+ */
+ public function test_strips_script_tags_from_values() {
+ $result = $this->sanitize(
+ array(
+ 'backend-title' => 'My Chart',
+ )
+ );
+ $this->assertSame( 'My Chart', $result['backend-title'] );
+ }
+
+ /**
+ * Sanitization is applied to nested arrays.
+ */
+ public function test_sanitizes_nested_values() {
+ $result = $this->sanitize(
+ array(
+ 'title' => array(
+ 'text' => '

Sales',
+ ),
+ 'series' => array(
+ array( 'label' => '
Q1' ),
+ ),
+ )
+ );
+ $this->assertSame( 'Sales', $result['title']['text'] );
+ $this->assertSame( 'Q1', $result['series'][0]['label'] );
+ }
+
+ /**
+ * The chart-img value (a data URI) must pass through unsanitized.
+ */
+ public function test_chart_img_is_preserved() {
+ $img = 'data:image/png;base64,iVBORw0KGgo=';
+ $result = $this->sanitize(
+ array(
+ 'chart-img' => $img,
+ 'backend-title' => 'Title',
+ )
+ );
+ $this->assertSame( $img, $result['chart-img'] );
+ $this->assertSame( 'Title', $result['backend-title'] );
+ }
+
+ /**
+ * An empty chart-img is dropped, not re-added.
+ */
+ public function test_empty_chart_img_is_dropped() {
+ $result = $this->sanitize( array( 'chart-img' => '' ) );
+ $this->assertArrayNotHasKey( 'chart-img', $result );
+ }
+
+ /**
+ * A chart-img that is not a base64 image data URI is dropped entirely.
+ */
+ public function test_hostile_chart_img_is_dropped() {
+ foreach ( array(
+ '',
+ 'data:text/html;base64,PHNjcmlwdD4=',
+ 'data:image/svg+xml;base64,PHN2Zz4=',
+ 'data:image/png;base64,abc">',
+ array( 'nested' => 'data:image/png;base64,iVBORw0KGgo=' ),
+ ) as $payload ) {
+ $result = $this->sanitize( array( 'chart-img' => $payload ) );
+ $this->assertArrayNotHasKey( 'chart-img', $result );
+ }
+ }
+
+ /**
+ * save_chart_image() refuses to write anything whose bytes are not PNG.
+ */
+ public function test_save_chart_image_rejects_non_png_payloads() {
+ $module = new Visualizer_Module_Chart( Visualizer_Plugin::instance() );
+ $chart = self::factory()->post->create( array( 'post_type' => Visualizer_Plugin::CPT_VISUALIZER ) );
+ foreach ( array(
+ 'data:image/png;base64,' . base64_encode( '' ),
+ 'data:image/png;base64,%%%not-base64%%%',
+ 'plain text',
+ ) as $payload ) {
+ $this->assertSame( 0, $module->save_chart_image( $payload, $chart ) );
+ }
+ }
+
+ /**
+ * Multi-line values keep their newlines (textarea sanitization, not text).
+ */
+ public function test_newlines_are_preserved() {
+ $result = $this->sanitize( array( 'description' => "Line one\nLine two" ) );
+ $this->assertSame( "Line one\nLine two", $result['description'] );
+ }
+}
diff --git a/tests/test-schedule.php b/tests/test-schedule.php
new file mode 100644
index 000000000..20d570045
--- /dev/null
+++ b/tests/test-schedule.php
@@ -0,0 +1,45 @@
+assertFalse( current_user_can( 'edit_posts' ), 'precondition: background context has no editing user' );
+
+ $chart_id = self::factory()->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_status' => 'publish',
+ 'post_content' => 'OLD-CONTENT',
+ )
+ );
+
+ // mimic Pro's internal invocation; clean superglobals so prior tests don't reroute uploadData().
+ $_GET = $_POST = $_FILES = array();
+ define( 'VISUALIZER_DO_NOT_DIE', true );
+ $_GET['nonce'] = wp_create_nonce( 'visualizer-upload-data' );
+ $_GET['chart'] = $chart_id;
+ $_POST['editor-type'] = 'text';
+ $_POST['chart_data'] = "Name,Value\nstring,number\nAlpha,111\nBeta,222";
+
+ do_action( 'wp_ajax_' . Visualizer_Plugin::ACTION_UPLOAD_DATA );
+
+ $content = get_post_field( 'post_content', $chart_id );
+ $this->assertStringContainsString( 'Alpha', $content, 'scheduled/background import must update chart data without a logged-in user' );
+ }
+}
diff --git a/tests/test-security-object-injection.php b/tests/test-security-object-injection.php
new file mode 100644
index 000000000..d69ad9177
--- /dev/null
+++ b/tests/test-security-object-injection.php
@@ -0,0 +1,371 @@
+ false ) guard, a serialized object in the
+ * content must NOT be instantiated. The canary below flips a static flag
+ * from its __wakeup(); if the guard is removed, unserialize() instantiates
+ * the canary, __wakeup() fires, and the assertion fails.
+ *
+ * @package Visualizer
+ * @subpackage Tests
+ */
+
+if ( ! class_exists( 'Visualizer_POI_Canary' ) ) {
+ /**
+ * Canary "gadget": records whether it was ever instantiated by unserialize().
+ */
+ class Visualizer_POI_Canary {
+ /**
+ * Flag to track whether the canary was instantiated.
+ *
+ * @var bool
+ */
+ public static $awoke = false;
+
+ /**
+ * Records that this object was instantiated by unserialize().
+ */
+ public function __wakeup() {
+ self::$awoke = true;
+ }
+ }
+}
+
+/**
+ * Security tests for object injection vulnerabilities.
+ */
+class Test_Security_Object_Injection extends WP_UnitTestCase {
+
+ /**
+ * Serialized array payload carrying a Visualizer_POI_Canary object.
+ *
+ * @param array $data Plain payload data.
+ * @return string
+ */
+ private function object_payload( $data = array() ) {
+ Visualizer_POI_Canary::$awoke = false;
+ $data[] = new Visualizer_POI_Canary();
+ return serialize( $data );
+ }
+
+ /**
+ * Visualizer_Module::get_chart_data() must not instantiate objects from content.
+ */
+ public function test_get_chart_data_does_not_instantiate_objects() {
+ $expected = array(
+ array( 'Label', 'Value' ),
+ array( 'Safe', 10 ),
+ );
+ $chart = new stdClass();
+ $chart->post_content = $this->object_payload( $expected );
+
+ $result = Visualizer_Module::get_chart_data( $chart, 'line', false );
+
+ $this->assertSame( $expected, $result );
+ $this->assertFalse(
+ Visualizer_POI_Canary::$awoke,
+ 'unserialize() must not instantiate objects from chart post_content.'
+ );
+ }
+
+ /**
+ * The remote CSV source (_repopulate) must not instantiate objects from content.
+ */
+ public function test_remote_csv_source_does_not_instantiate_objects() {
+ $chart_id = self::factory()->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_content' => wp_slash( $this->object_payload( array( 'marker' => 'remote-csv' ) ) ),
+ )
+ );
+
+ $source = new Visualizer_Source_Csv_Remote();
+ $method = new ReflectionMethod( 'Visualizer_Source_Csv_Remote', '_repopulate' );
+ $method->setAccessible( true );
+
+ $result = $method->invoke( $source, $chart_id );
+
+ $this->assertFalse( $result, 'Content without a remote source must fail cleanly.' );
+ $this->assertFalse(
+ Visualizer_POI_Canary::$awoke,
+ 'Remote CSV source must not instantiate objects from chart post_content.'
+ );
+ }
+
+ /**
+ * The shared decode_content() chokepoint must not instantiate objects.
+ *
+ * Covers the helper-level guarantee shared by chart/source content callers.
+ */
+ public function test_decode_content_does_not_instantiate_objects() {
+ $result = Visualizer_Module::decode_content( $this->object_payload( array( 'marker' => 'decoded' ) ) );
+
+ $this->assertFalse(
+ Visualizer_POI_Canary::$awoke,
+ 'decode_content() must not instantiate objects from source content.'
+ );
+ $this->assertSame(
+ array( 'marker' => 'decoded' ),
+ $result,
+ 'decode_content() must keep legitimate data and strip object stubs entirely.'
+ );
+ }
+
+ /**
+ * The shared decoder must reject cyclic serialized arrays without recursing.
+ *
+ * The allowed_classes guard only blocks objects; a self-referential array
+ * (R:/r: token) survives it and would drive strip_incomplete_objects() into
+ * unbounded recursion. decode_content() must reject it up front.
+ */
+ public function test_decode_content_rejects_cyclic_arrays() {
+ $this->assertFalse(
+ Visualizer_Module::decode_content( 'a:1:{i:0;R:1;}' ),
+ 'decode_content() must reject cyclic arrays.'
+ );
+ $this->assertSame(
+ array( 'marker' => 'R:1;' ),
+ Visualizer_Module::decode_content( serialize( array( 'marker' => 'R:1;' ) ) ),
+ 'Reference-like text inside strings must remain valid.'
+ );
+ }
+
+ /**
+ * The Utility pie/polarArea render palette call site must not instantiate objects.
+ *
+ * Covers the public global-style filter seam and preserves the trimming
+ * behavior of WordPress's maybe_unserialize().
+ */
+ public function test_utility_pie_palette_call_site_is_guarded() {
+ $chart_id = self::factory()->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_content' => wp_slash(
+ " \n" . $this->object_payload(
+ array(
+ array( 'One' ),
+ array( 'Two' ),
+ )
+ ) . " \n"
+ ),
+ )
+ );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, 'ChartJS' );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, 'pie' );
+ update_post_meta(
+ $chart_id,
+ Visualizer_Plugin::CF_SERIES,
+ array(
+ array( 'label' => 'Label', 'type' => 'string' ),
+ array( 'label' => 'Value', 'type' => 'number' ),
+ )
+ );
+ update_option(
+ Visualizer_Module_Admin::OPTION_GLOBAL_SETTINGS,
+ array(
+ 'color_primary' => '#3366cc',
+ 'apply_existing' => '1',
+ )
+ );
+
+ $utility = Visualizer_Plugin::instance()->getModule( Visualizer_Module_Utility::NAME );
+ $result = $utility->apply_global_style_settings( array(), $chart_id, 'pie' );
+
+ $this->assertCount( 2, $result['slices'], 'Palette size must match the legitimate rows in whitespace-wrapped chart data; object stubs are stripped.' );
+ $this->assertFalse(
+ Visualizer_POI_Canary::$awoke,
+ 'Utility pie palette call site must not instantiate objects from post_content.'
+ );
+ }
+
+ /**
+ * The ChartJS default-settings call site must not instantiate objects.
+ */
+ public function test_utility_chartjs_defaults_call_site_is_guarded() {
+ $chart_id = self::factory()->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_status' => 'auto-draft',
+ 'post_content' => wp_slash(
+ $this->object_payload(
+ array(
+ array( 'One' ),
+ array( 'Two' ),
+ )
+ )
+ ),
+ )
+ );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, 'ChartJS' );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, 'pie' );
+ update_post_meta(
+ $chart_id,
+ Visualizer_Plugin::CF_SERIES,
+ array(
+ array( 'label' => 'Label', 'type' => 'string' ),
+ array( 'label' => 'Value', 'type' => 'number' ),
+ )
+ );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, array() );
+
+ Visualizer_Module_Utility::set_defaults( get_post( $chart_id ) );
+ $settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true );
+
+ $this->assertCount( 2, $settings['slices'] );
+ $this->assertFalse(
+ Visualizer_POI_Canary::$awoke,
+ 'ChartJS defaults must not instantiate objects from post_content.'
+ );
+ }
+
+ /**
+ * The Gutenberg block render call site must not instantiate objects.
+ *
+ * Drives the front-end/REST data path with a requested chart that differs
+ * from the global post. Reverting the guard or reading the global post fails.
+ */
+ public function test_gutenberg_block_render_call_site_is_guarded() {
+ $chart_id = self::factory()->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_content' => wp_slash(
+ $this->object_payload(
+ array(
+ array( 'requested-chart' ),
+ )
+ )
+ ),
+ )
+ );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_TYPE, 'line' );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, 'ChartJS' );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, array() );
+ update_post_meta(
+ $chart_id,
+ Visualizer_Plugin::CF_SERIES,
+ array(
+ array( 'label' => 'Label', 'type' => 'string' ),
+ )
+ );
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'editor' ) ) );
+ $decoy = self::factory()->post->create(
+ array(
+ 'post_content' => wp_slash( serialize( array( array( 'global-post' ) ) ) ),
+ )
+ );
+ $GLOBALS['post'] = get_post( $decoy );
+ setup_postdata( $GLOBALS['post'] );
+
+ try {
+ $result = Visualizer_Gutenberg_Block::get_instance()->get_visualizer_data( array( 'id' => $chart_id ) );
+ } finally {
+ wp_reset_postdata();
+ }
+
+ $this->assertIsArray( $result );
+ $this->assertSame( 'requested-chart', $result['visualizer-data'][0][0] );
+ $this->assertFalse(
+ Visualizer_POI_Canary::$awoke,
+ 'Gutenberg block render call site must not instantiate objects from post_content.'
+ );
+ }
+
+ /**
+ * Cloning a chart copies raw post meta through maybe_decode_content(); it must
+ * neither instantiate objects from meta nor corrupt legitimate serialized meta.
+ */
+ public function test_clone_chart_meta_is_guarded_and_round_trips() {
+ $settings = array( 'series' => array( array( 'color' => '#ff0000' ) ) );
+ $chart_id = self::factory()->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_content' => wp_slash( serialize( array( array( 'Label' ), array( 'Value' ) ) ) ),
+ )
+ );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, $settings );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, array( new Visualizer_POI_Canary() ) );
+ Visualizer_POI_Canary::$awoke = false;
+
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ $_GET['nonce'] = wp_create_nonce( Visualizer_Plugin::ACTION_CLONE_CHART );
+ $_GET['chart'] = (string) $chart_id;
+
+ try {
+ Visualizer_Plugin::instance()->getModule( Visualizer_Module_Chart::NAME )->cloneChart();
+ } catch ( WPDieException $e ) {
+ // Expected test-mode exit before the redirect.
+ }
+
+ $this->assertFalse(
+ Visualizer_POI_Canary::$awoke,
+ 'Chart clone must not instantiate objects from raw post meta.'
+ );
+
+ $clones = get_posts(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_status' => 'any',
+ 'exclude' => array( $chart_id ),
+ 'numberposts' => -1,
+ )
+ );
+ $this->assertCount( 1, $clones, 'Clone action must create exactly one new chart.' );
+ $this->assertSame(
+ $settings,
+ get_post_meta( $clones[0]->ID, Visualizer_Plugin::CF_SETTINGS, true ),
+ 'Legitimate serialized meta must survive cloning unchanged.'
+ );
+ }
+
+ /**
+ * Saving and restoring a chart revision copies raw post meta through
+ * maybe_decode_content(); neither direction may instantiate objects, and
+ * legitimate serialized meta must survive the round trip.
+ */
+ public function test_revision_meta_copy_is_guarded_and_round_trips() {
+ $settings = array( 'series' => array( array( 'color' => '#00ff00' ) ) );
+ $chart_id = self::factory()->post->create(
+ array(
+ 'post_type' => Visualizer_Plugin::CPT_VISUALIZER,
+ 'post_content' => wp_slash( serialize( array( array( 'Label' ), array( 'Value' ) ) ) ),
+ )
+ );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, $settings );
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, array( new Visualizer_POI_Canary() ) );
+ Visualizer_POI_Canary::$awoke = false;
+
+ // _wp_put_post_revision fires the hook that runs Admin::addRevision().
+ $revision_id = _wp_put_post_revision( get_post( $chart_id ) );
+
+ $this->assertIsInt( $revision_id );
+ $this->assertFalse(
+ Visualizer_POI_Canary::$awoke,
+ 'Saving a revision must not instantiate objects from raw post meta.'
+ );
+ $this->assertSame(
+ $settings,
+ get_metadata( 'post', $revision_id, Visualizer_Plugin::CF_SETTINGS, true ),
+ 'Legitimate serialized meta must be copied to the revision unchanged.'
+ );
+
+ // Change the live meta, then restore; wp_restore_post_revision fires
+ // the hook that runs Admin::restoreRevision().
+ update_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, array( 'series' => array() ) );
+ Visualizer_POI_Canary::$awoke = false;
+
+ wp_restore_post_revision( $revision_id );
+
+ $this->assertFalse(
+ Visualizer_POI_Canary::$awoke,
+ 'Restoring a revision must not instantiate objects from raw revision meta.'
+ );
+ $this->assertSame(
+ $settings,
+ get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true ),
+ 'Legitimate serialized meta must survive the revision restore unchanged.'
+ );
+ }
+}