From c01f0ec28fa2f950ede898dd4b575ca6b569b49f Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Tue, 14 Jul 2026 16:46:54 +0300 Subject: [PATCH 01/11] fix: harden unserialize() against PHP object injection Add array( 'allowed_classes' => false ) to every raw unserialize() of chart post_content / fetched source content, preventing PHP object injection when combined with the broken access control write path. Sinks fixed: - classes/Visualizer/Source/Csv/Remote.php:77 (issue #596) - classes/Visualizer/Module/Chart.php:1339 (issue #596) - classes/Visualizer/Module.php:796 get_chart_data() - classes/Visualizer/Module/Utility.php:437 pie/polarArea colors - classes/Visualizer/Gutenberg/Block.php:310 block render Behavior-preserving: all sinks expect a plain array. The last three are the same sink class found in passing, hardened for defence-in-depth. Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Gutenberg/Block.php | 2 +- classes/Visualizer/Module.php | 2 +- classes/Visualizer/Module/Chart.php | 4 ++-- classes/Visualizer/Module/Utility.php | 2 +- classes/Visualizer/Source/Csv/Remote.php | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php index 61d58fe13..61a2b5be0 100644 --- a/classes/Visualizer/Gutenberg/Block.php +++ b/classes/Visualizer/Gutenberg/Block.php @@ -307,7 +307,7 @@ public function get_visualizer_data( $post ) { $data['visualizer-settings'] = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SETTINGS, $data['visualizer-settings'], $post_id, $data['visualizer-chart-type'] ); // handle data filter hooks - $data['visualizer-data'] = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_DATA, unserialize( html_entity_decode( get_the_content( $post_id ) ) ), $post_id, $data['visualizer-chart-type'] ); + $data['visualizer-data'] = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_DATA, unserialize( html_entity_decode( get_the_content( $post_id ) ), array( 'allowed_classes' => false ) ), $post_id, $data['visualizer-chart-type'] ); // we are going to format only for tabular charts, because we are not sure of the effect on others. // this is to solve the case where boolean data shows up as all-ticks on gutenberg. diff --git a/classes/Visualizer/Module.php b/classes/Visualizer/Module.php index 87add67a5..8785bb692 100644 --- a/classes/Visualizer/Module.php +++ b/classes/Visualizer/Module.php @@ -793,7 +793,7 @@ function ( $matches ) { }, $post_content ); - $data = unserialize( $post_content ); + $data = unserialize( $post_content, array( 'allowed_classes' => false ) ); $altered = array(); if ( ! empty( $data ) ) { foreach ( $data as $index => $array ) { diff --git a/classes/Visualizer/Module/Chart.php b/classes/Visualizer/Module/Chart.php index f71f1fc9a..17dd6285e 100644 --- a/classes/Visualizer/Module/Chart.php +++ b/classes/Visualizer/Module/Chart.php @@ -1336,8 +1336,8 @@ public function uploadData() { if ( $source->fetch() ) { $content = $source->getData( get_post_meta( $chart_id, Visualizer_Plugin::CF_EDITABLE_TABLE, true ) ); $populate = true; - if ( is_string( $content ) && is_array( unserialize( $content ) ) ) { - $json = unserialize( $content ); + $json = is_string( $content ) ? unserialize( $content, array( 'allowed_classes' => false ) ) : false; + if ( is_array( $json ) ) { // if source exists, so should data. if source exists but data is blank, do not populate the chart. // if we populate the data even if it is empty, the chart will show "Table has no columns". if ( array_key_exists( 'source', $json ) && ! empty( $json['source'] ) && ( ! array_key_exists( 'data', $json ) || empty( $json['data'] ) ) ) { diff --git a/classes/Visualizer/Module/Utility.php b/classes/Visualizer/Module/Utility.php index 395ae1407..913def58b 100644 --- a/classes/Visualizer/Module/Utility.php +++ b/classes/Visualizer/Module/Utility.php @@ -434,7 +434,7 @@ private static function set_defaults_chartjs( $chart, $post_status ) { case 'polarArea': // fall through. case 'pie': - $data = unserialize( $chart->post_content ); + $data = unserialize( $chart->post_content, array( 'allowed_classes' => false ) ); $name = 'slices'; $max = count( $data ); // fall through. diff --git a/classes/Visualizer/Source/Csv/Remote.php b/classes/Visualizer/Source/Csv/Remote.php index e034ca15e..73a3853a2 100644 --- a/classes/Visualizer/Source/Csv/Remote.php +++ b/classes/Visualizer/Source/Csv/Remote.php @@ -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 = unserialize( html_entity_decode( $chart->post_content ), array( 'allowed_classes' => false ) ); if ( ! isset( $data['source'] ) ) { return false; } From a9a333b6ee3bf8994a735b82c47555aa5f3cd991 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Tue, 14 Jul 2026 16:56:26 +0300 Subject: [PATCH 02/11] test: add object-injection regression test for unserialize() guard Canary gadget class flips a static flag from __wakeup(); the test asserts it stays false after get_chart_data() and the remote CSV source process chart post_content containing a serialized object. Fails if the allowed_classes guard is removed. Co-Authored-By: Claude Fable 5 --- tests/test-security-object-injection.php | 85 ++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 tests/test-security-object-injection.php diff --git a/tests/test-security-object-injection.php b/tests/test-security-object-injection.php new file mode 100644 index 000000000..d43904efe --- /dev/null +++ b/tests/test-security-object-injection.php @@ -0,0 +1,85 @@ + 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 { + public static $awoke = false; + public function __wakeup() { + self::$awoke = true; + } + } +} + +class Test_Security_Object_Injection extends WP_UnitTestCase { + + /** + * Serialized payload carrying a Visualizer_POI_Canary object. + * + * @return string + */ + private function object_payload() { + Visualizer_POI_Canary::$awoke = false; + return serialize( new Visualizer_POI_Canary() ); + } + + /** + * Visualizer_Module::get_chart_data() must not instantiate objects from content. + */ + public function test_get_chart_data_does_not_instantiate_objects() { + $chart = new stdClass(); + $chart->post_content = $this->object_payload(); + + try { + Visualizer_Module::get_chart_data( $chart, 'line', false ); + } catch ( \Throwable $e ) { + // Downstream handling of the neutralized payload is irrelevant here; + // we only assert that no object was instantiated during unserialize(). + } + + $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() ), + ) + ); + + $source = new Visualizer_Source_Csv_Remote(); + $method = new ReflectionMethod( 'Visualizer_Source_Csv_Remote', '_repopulate' ); + $method->setAccessible( true ); + + try { + $method->invoke( $source, $chart_id ); + } catch ( \Throwable $e ) { + // The neutralized payload has no 'source' key and returns false; only the canary matters. + } + + $this->assertFalse( + Visualizer_POI_Canary::$awoke, + 'Remote CSV source must not instantiate objects from chart post_content.' + ); + } +} From 3570a8b054b166e3537d66c43531e84c284680f8 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Tue, 14 Jul 2026 17:09:57 +0300 Subject: [PATCH 03/11] test: satisfy PHPCS doc-comment rules on object-injection test Co-Authored-By: Claude Fable 5 --- tests/test-security-object-injection.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test-security-object-injection.php b/tests/test-security-object-injection.php index d43904efe..9295d48da 100644 --- a/tests/test-security-object-injection.php +++ b/tests/test-security-object-injection.php @@ -17,13 +17,25 @@ * 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 { /** From 53b332d0b77fe5da1991d440fcf57828f67751d3 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Wed, 15 Jul 2026 09:21:20 +0300 Subject: [PATCH 04/11] refactor: route all unserialize sinks through one guarded helper + test Extract the allowed_classes guard into Visualizer_Module::decode_content() and route all five sinks (get_chart_data, updateBusinessJson, Utility pie colors, Gutenberg block render, remote CSV) through it, so the guard is a single chokepoint that cannot be dropped from one call site independently. Add a regression test feeding a serialized canary through a source getData() into decode_content(), asserting __wakeup() is never called. Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Gutenberg/Block.php | 2 +- classes/Visualizer/Module.php | 15 +++++++++++- classes/Visualizer/Module/Chart.php | 2 +- classes/Visualizer/Module/Utility.php | 2 +- classes/Visualizer/Source/Csv/Remote.php | 2 +- tests/test-security-object-injection.php | 31 ++++++++++++++++++++++++ 6 files changed, 49 insertions(+), 5 deletions(-) diff --git a/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php index 61a2b5be0..426019b53 100644 --- a/classes/Visualizer/Gutenberg/Block.php +++ b/classes/Visualizer/Gutenberg/Block.php @@ -307,7 +307,7 @@ public function get_visualizer_data( $post ) { $data['visualizer-settings'] = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SETTINGS, $data['visualizer-settings'], $post_id, $data['visualizer-chart-type'] ); // handle data filter hooks - $data['visualizer-data'] = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_DATA, unserialize( html_entity_decode( get_the_content( $post_id ) ), array( 'allowed_classes' => false ) ), $post_id, $data['visualizer-chart-type'] ); + $data['visualizer-data'] = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_DATA, Visualizer_Module::decode_content( html_entity_decode( get_the_content( $post_id ) ) ), $post_id, $data['visualizer-chart-type'] ); // we are going to format only for tabular charts, because we are not sure of the effect on others. // this is to solve the case where boolean data shows up as all-ticks on gutenberg. diff --git a/classes/Visualizer/Module.php b/classes/Visualizer/Module.php index 8785bb692..713e94529 100644 --- a/classes/Visualizer/Module.php +++ b/classes/Visualizer/Module.php @@ -778,6 +778,19 @@ final public static function get_features_for_license( $plan ) { } } + /** + * Safely unserialize chart/source content, blocking PHP object injection. + * + * Single guarded chokepoint shared by every unserialize() sink so the + * allowed_classes guard cannot be dropped from one call site independently. + * + * @param string $content The serialized content. + * @return mixed The decoded value (array for valid chart data), or false. + */ + public static function decode_content( $content ) { + return is_string( $content ) ? unserialize( $content, array( 'allowed_classes' => false ) ) : false; + } + /** * Gets the chart content after common manipulations. */ @@ -793,7 +806,7 @@ function ( $matches ) { }, $post_content ); - $data = unserialize( $post_content, array( 'allowed_classes' => false ) ); + $data = self::decode_content( $post_content ); $altered = array(); if ( ! empty( $data ) ) { foreach ( $data as $index => $array ) { diff --git a/classes/Visualizer/Module/Chart.php b/classes/Visualizer/Module/Chart.php index 17dd6285e..e6df2fb1f 100644 --- a/classes/Visualizer/Module/Chart.php +++ b/classes/Visualizer/Module/Chart.php @@ -1336,7 +1336,7 @@ public function uploadData() { if ( $source->fetch() ) { $content = $source->getData( get_post_meta( $chart_id, Visualizer_Plugin::CF_EDITABLE_TABLE, true ) ); $populate = true; - $json = is_string( $content ) ? unserialize( $content, array( 'allowed_classes' => false ) ) : false; + $json = self::decode_content( $content ); if ( is_array( $json ) ) { // if source exists, so should data. if source exists but data is blank, do not populate the chart. // if we populate the data even if it is empty, the chart will show "Table has no columns". diff --git a/classes/Visualizer/Module/Utility.php b/classes/Visualizer/Module/Utility.php index 913def58b..56d030610 100644 --- a/classes/Visualizer/Module/Utility.php +++ b/classes/Visualizer/Module/Utility.php @@ -434,7 +434,7 @@ private static function set_defaults_chartjs( $chart, $post_status ) { case 'polarArea': // fall through. case 'pie': - $data = unserialize( $chart->post_content, array( 'allowed_classes' => false ) ); + $data = self::decode_content( $chart->post_content ); $name = 'slices'; $max = count( $data ); // fall through. diff --git a/classes/Visualizer/Source/Csv/Remote.php b/classes/Visualizer/Source/Csv/Remote.php index 73a3853a2..27d8b7b14 100644 --- a/classes/Visualizer/Source/Csv/Remote.php +++ b/classes/Visualizer/Source/Csv/Remote.php @@ -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 ), array( 'allowed_classes' => false ) ); + $data = Visualizer_Module::decode_content( html_entity_decode( $chart->post_content ) ); if ( ! isset( $data['source'] ) ) { return false; } diff --git a/tests/test-security-object-injection.php b/tests/test-security-object-injection.php index 9295d48da..5ffb99f33 100644 --- a/tests/test-security-object-injection.php +++ b/tests/test-security-object-injection.php @@ -94,4 +94,35 @@ public function test_remote_csv_source_does_not_instantiate_objects() { 'Remote CSV source must not instantiate objects from chart post_content.' ); } + + /** + * The shared decode_content() chokepoint must not instantiate objects. + * + * The business/scheduled JSON sink (updateBusinessJson) reads + * serialize()'d source data via getData() and decodes it through + * Visualizer_Module::decode_content(). This feeds a serialized canary the + * same way and asserts the object is never instantiated. + */ + public function test_decode_content_does_not_instantiate_objects() { + Visualizer_POI_Canary::$awoke = false; + + // Reproduce the content the sink reads: serialize() of the source data + // (getData()), here carrying a canary object. + $source = new Visualizer_Source_Json( array( 'url' => '', 'root' => '', 'paging' => '' ) ); + $data_prop = new ReflectionProperty( 'Visualizer_Source', '_data' ); + $data_prop->setAccessible( true ); + $data_prop->setValue( $source, array( new Visualizer_POI_Canary() ) ); + $content = $source->getData(); + + $result = Visualizer_Module::decode_content( $content ); + + $this->assertFalse( + Visualizer_POI_Canary::$awoke, + 'decode_content() must not instantiate objects from source content.' + ); + $this->assertIsArray( + $result, + 'decode_content() should still return the (neutralized) array.' + ); + } } From 71f043ffbf2a14786d70446249ce4cfbff986203 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Wed, 15 Jul 2026 10:03:36 +0300 Subject: [PATCH 05/11] fix: guard maybe_unserialize() render sink + PHPStan/test coverage - apply_chartjs_palette() (pie/polarArea render) used maybe_unserialize() on post_content, which unserializes objects; route it through the shared Visualizer_Module::decode_content() guard while preserving the return-as-is-when-not-serialized behavior via is_serialized(). - decode_content() @param string -> mixed (is_string guard is intentional; string typedoc made PHPStan flag it as always-true). - Add a call-site regression test for apply_chartjs_palette(). Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Module.php | 2 +- classes/Visualizer/Module/Utility.php | 5 +++- tests/test-security-object-injection.php | 33 ++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/classes/Visualizer/Module.php b/classes/Visualizer/Module.php index 713e94529..5934bf970 100644 --- a/classes/Visualizer/Module.php +++ b/classes/Visualizer/Module.php @@ -784,7 +784,7 @@ final public static function get_features_for_license( $plan ) { * Single guarded chokepoint shared by every unserialize() sink so the * allowed_classes guard cannot be dropped from one call site independently. * - * @param string $content The serialized content. + * @param mixed $content The serialized content (only strings are decoded). * @return mixed The decoded value (array for valid chart data), or false. */ public static function decode_content( $content ) { diff --git a/classes/Visualizer/Module/Utility.php b/classes/Visualizer/Module/Utility.php index 56d030610..34fdb38b6 100644 --- a/classes/Visualizer/Module/Utility.php +++ b/classes/Visualizer/Module/Utility.php @@ -302,7 +302,10 @@ private static function apply_chartjs_palette( array $settings, string $type, ar // fall through. case 'pie': $chart = get_post( $chart_id ); - $data = $chart instanceof WP_Post ? maybe_unserialize( $chart->post_content ) : array(); + $raw = $chart instanceof WP_Post ? $chart->post_content : array(); + // Object-injection-safe replacement for maybe_unserialize(); keeps + // the "return as-is when not serialized" behavior. + $data = is_serialized( $raw ) ? self::decode_content( $raw ) : $raw; $name = 'slices'; $max = is_array( $data ) ? count( $data ) : $count; // fall through. diff --git a/tests/test-security-object-injection.php b/tests/test-security-object-injection.php index 5ffb99f33..ca6d4fd26 100644 --- a/tests/test-security-object-injection.php +++ b/tests/test-security-object-injection.php @@ -125,4 +125,37 @@ public function test_decode_content_does_not_instantiate_objects() { 'decode_content() should still return the (neutralized) array.' ); } + + /** + * The Utility pie/polarArea render palette call site must not instantiate objects. + * + * Drives the real sink (Visualizer_Module_Utility::apply_chartjs_palette), + * which previously used maybe_unserialize() on post_content, with a chart + * whose content is a serialized canary. Reverting that call site to an + * unrestricted (maybe_)unserialize() would fail this test. + */ + public function test_utility_pie_palette_call_site_is_guarded() { + Visualizer_POI_Canary::$awoke = false; + + $chart_id = self::factory()->post->create( + array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_content' => wp_slash( serialize( array( new Visualizer_POI_Canary() ) ) ), + ) + ); + + $method = new ReflectionMethod( 'Visualizer_Module_Utility', 'apply_chartjs_palette' ); + $method->setAccessible( true ); + + try { + $method->invoke( null, array(), 'pie', array(), $chart_id ); + } catch ( \Throwable $e ) { + // Only the canary matters here. + } + + $this->assertFalse( + Visualizer_POI_Canary::$awoke, + 'Utility pie palette call site must not instantiate objects from post_content.' + ); + } } From 72d20389285414fe4690ed13dca40200ce7bcd97 Mon Sep 17 00:00:00 2001 From: lucadobrescu Date: Wed, 15 Jul 2026 10:14:50 +0300 Subject: [PATCH 06/11] test: cover Gutenberg block render call site for object injection Add a call-site regression test for get_visualizer_data() (the front-end/ REST render path), so reverting that sink to an unrestricted unserialize() fails a test. Co-Authored-By: Claude Fable 5 --- tests/test-security-object-injection.php | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/tests/test-security-object-injection.php b/tests/test-security-object-injection.php index ca6d4fd26..a121375da 100644 --- a/tests/test-security-object-injection.php +++ b/tests/test-security-object-injection.php @@ -158,4 +158,39 @@ public function test_utility_pie_palette_call_site_is_guarded() { 'Utility pie palette call site must not instantiate objects from post_content.' ); } + + /** + * The Gutenberg block render call site must not instantiate objects. + * + * Drives the front-end/REST data path (get_visualizer_data), which reads + * chart content via get_the_content(), with a chart whose content is a + * serialized canary. Reverting that call site to an unrestricted + * unserialize() would fail this test. + */ + public function test_gutenberg_block_render_call_site_is_guarded() { + Visualizer_POI_Canary::$awoke = false; + + $chart_id = self::factory()->post->create( + array( + 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, + 'post_content' => wp_slash( serialize( array( new Visualizer_POI_Canary() ) ) ), + ) + ); + // get_the_content() reads the global post. + $GLOBALS['post'] = get_post( $chart_id ); + setup_postdata( $GLOBALS['post'] ); + + try { + Visualizer_Gutenberg_Block::get_instance()->get_visualizer_data( array( 'id' => $chart_id ) ); + } catch ( \Throwable $e ) { + // Only the canary matters here. + } + + wp_reset_postdata(); + + $this->assertFalse( + Visualizer_POI_Canary::$awoke, + 'Gutenberg block render call site must not instantiate objects from post_content.' + ); + } } From b5825471a287615e0f4244c3af1f061b77270954 Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Wed, 15 Jul 2026 13:25:59 +0300 Subject: [PATCH 07/11] fix: read requested chart's raw post_content in block render get_the_content( $post_id ) passed the ID as $more_link_text and read the global post, so the block could decode the wrong post's content. Use get_post_field( 'post_content', $post_id, 'raw' ) to read the requested chart without content filters. Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Gutenberg/Block.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/classes/Visualizer/Gutenberg/Block.php b/classes/Visualizer/Gutenberg/Block.php index 426019b53..1111c86bf 100644 --- a/classes/Visualizer/Gutenberg/Block.php +++ b/classes/Visualizer/Gutenberg/Block.php @@ -307,7 +307,7 @@ public function get_visualizer_data( $post ) { $data['visualizer-settings'] = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_SETTINGS, $data['visualizer-settings'], $post_id, $data['visualizer-chart-type'] ); // handle data filter hooks - $data['visualizer-data'] = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_DATA, Visualizer_Module::decode_content( html_entity_decode( get_the_content( $post_id ) ) ), $post_id, $data['visualizer-chart-type'] ); + $data['visualizer-data'] = apply_filters( Visualizer_Plugin::FILTER_GET_CHART_DATA, Visualizer_Module::decode_content( html_entity_decode( get_post_field( 'post_content', $post_id, 'raw' ) ) ), $post_id, $data['visualizer-chart-type'] ); // we are going to format only for tabular charts, because we are not sure of the effect on others. // this is to solve the case where boolean data shows up as all-ticks on gutenberg. From e1af41718020cd225df838a2ae7cc98bd5ad0421 Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Wed, 15 Jul 2026 13:26:08 +0300 Subject: [PATCH 08/11] fix: decode wizard sample data through the safe unserialize chokepoint The setup wizard import step decoded serialized sample data with maybe_unserialize(); route it through decode_content() so no call site bypasses the allowed_classes guard. Behavior-preserving: the input is always the serialized string from Visualizer_Source::getData(). Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Module/Wizard.php | 2 +- tests/test-ajax.php | 32 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/classes/Visualizer/Module/Wizard.php b/classes/Visualizer/Module/Wizard.php index 607500f10..3a1007521 100644 --- a/classes/Visualizer/Module/Wizard.php +++ b/classes/Visualizer/Module/Wizard.php @@ -234,7 +234,7 @@ private function setup_wizard_import_chart() { update_post_meta( $chart_id, Visualizer_Plugin::CF_SERIES, $series ); update_post_meta( $chart_id, Visualizer_Plugin::CF_CHART_LIBRARY, '' ); - $data = maybe_unserialize( $data ); + $data = Visualizer_Module::decode_content( $data ); $setting_series = array(); $setting_slices = array(); foreach ( $data as $s ) { diff --git a/tests/test-ajax.php b/tests/test-ajax.php index ac9c99755..392fbc491 100644 --- a/tests/test-ajax.php +++ b/tests/test-ajax.php @@ -635,6 +635,38 @@ public function test_json_get_roots_blocks_link_local_for_contributor() { $this->assertSame( 0, $requests ); } + /** + * 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'] ); + } + /** * Utility method to mock pro version. */ From e56012597afa17392649f9022e1398cf059003d8 Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Wed, 15 Jul 2026 13:26:20 +0300 Subject: [PATCH 09/11] fix: guard chart-clone meta copy against object injection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unkeyed get_post_meta() returns raw serialized strings, so the two chart clone loops fed attacker-writable meta to maybe_unserialize(). Add Visualizer_Module::maybe_decode_content() — an object-injection-safe drop-in for maybe_unserialize() — and use it in both clone loops and the existing Utility palette call site. decode_content() now trims its input, preserving maybe_unserialize()'s handling of whitespace-wrapped serialized content for all callers. Tests: canary + round-trip coverage for the clone path, and the existing sink tests now assert the decoded payload instead of swallowing errors. Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Module.php | 14 +- classes/Visualizer/Module/Chart.php | 4 +- classes/Visualizer/Module/Utility.php | 4 +- tests/test-security-object-injection.php | 227 +++++++++++++++++------ 4 files changed, 182 insertions(+), 67 deletions(-) diff --git a/classes/Visualizer/Module.php b/classes/Visualizer/Module.php index 5934bf970..708ebc83d 100644 --- a/classes/Visualizer/Module.php +++ b/classes/Visualizer/Module.php @@ -781,14 +781,24 @@ final public static function get_features_for_license( $plan ) { /** * Safely unserialize chart/source content, blocking PHP object injection. * - * Single guarded chokepoint shared by every unserialize() sink so the + * Single guarded chokepoint shared by chart/source content sinks so the * allowed_classes guard cannot be dropped from one call site independently. * * @param mixed $content The serialized content (only strings are decoded). * @return mixed The decoded value (array for valid chart data), or false. */ public static function decode_content( $content ) { - return is_string( $content ) ? unserialize( $content, array( 'allowed_classes' => false ) ) : false; + return is_string( $content ) ? unserialize( trim( $content ), array( 'allowed_classes' => false ) ) : false; + } + + /** + * Object-injection-safe drop-in for maybe_unserialize(). + * + * @param mixed $value Raw meta/content value. + * @return mixed The decoded value for serialized input, the value unchanged otherwise. + */ + public static function maybe_decode_content( $value ) { + return is_serialized( $value ) ? self::decode_content( $value ) : $value; } /** diff --git a/classes/Visualizer/Module/Chart.php b/classes/Visualizer/Module/Chart.php index c5e0757ca..65b9fcef7 100644 --- a/classes/Visualizer/Module/Chart.php +++ b/classes/Visualizer/Module/Chart.php @@ -614,7 +614,7 @@ public function renderChartPages() { $chart_id = $new_chart_id; foreach ( $post_meta as $key => $value ) { if ( strpos( $key, 'visualizer-' ) !== false ) { - add_post_meta( $new_chart_id, $key, maybe_unserialize( $value[0] ) ); + add_post_meta( $new_chart_id, $key, self::maybe_decode_content( $value[0] ) ); } } } @@ -1456,7 +1456,7 @@ public function cloneChart() { $post_meta = get_post_meta( $chart_id ); foreach ( $post_meta as $key => $value ) { if ( strpos( $key, 'visualizer-' ) !== false ) { - add_post_meta( $new_chart_id, $key, maybe_unserialize( $value[0] ) ); + add_post_meta( $new_chart_id, $key, self::maybe_decode_content( $value[0] ) ); } } $redirect = esc_url( diff --git a/classes/Visualizer/Module/Utility.php b/classes/Visualizer/Module/Utility.php index 34fdb38b6..3858190f1 100644 --- a/classes/Visualizer/Module/Utility.php +++ b/classes/Visualizer/Module/Utility.php @@ -303,9 +303,7 @@ private static function apply_chartjs_palette( array $settings, string $type, ar case 'pie': $chart = get_post( $chart_id ); $raw = $chart instanceof WP_Post ? $chart->post_content : array(); - // Object-injection-safe replacement for maybe_unserialize(); keeps - // the "return as-is when not serialized" behavior. - $data = is_serialized( $raw ) ? self::decode_content( $raw ) : $raw; + $data = self::maybe_decode_content( $raw ); $name = 'slices'; $max = is_array( $data ) ? count( $data ) : $count; // fall through. diff --git a/tests/test-security-object-injection.php b/tests/test-security-object-injection.php index a121375da..e9fef37c8 100644 --- a/tests/test-security-object-injection.php +++ b/tests/test-security-object-injection.php @@ -39,29 +39,31 @@ public function __wakeup() { class Test_Security_Object_Injection extends WP_UnitTestCase { /** - * Serialized payload carrying a Visualizer_POI_Canary object. + * Serialized array payload carrying a Visualizer_POI_Canary object. * + * @param array $data Plain payload data. * @return string */ - private function object_payload() { + private function object_payload( $data = array() ) { Visualizer_POI_Canary::$awoke = false; - return serialize( new Visualizer_POI_Canary() ); + $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(); + $chart->post_content = $this->object_payload( $expected ); - try { - Visualizer_Module::get_chart_data( $chart, 'line', false ); - } catch ( \Throwable $e ) { - // Downstream handling of the neutralized payload is irrelevant here; - // we only assert that no object was instantiated during unserialize(). - } + $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.' @@ -75,7 +77,7 @@ 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() ), + 'post_content' => wp_slash( $this->object_payload( array( 'marker' => 'remote-csv' ) ) ), ) ); @@ -83,12 +85,9 @@ public function test_remote_csv_source_does_not_instantiate_objects() { $method = new ReflectionMethod( 'Visualizer_Source_Csv_Remote', '_repopulate' ); $method->setAccessible( true ); - try { - $method->invoke( $source, $chart_id ); - } catch ( \Throwable $e ) { - // The neutralized payload has no 'source' key and returns false; only the canary matters. - } + $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.' @@ -98,23 +97,10 @@ public function test_remote_csv_source_does_not_instantiate_objects() { /** * The shared decode_content() chokepoint must not instantiate objects. * - * The business/scheduled JSON sink (updateBusinessJson) reads - * serialize()'d source data via getData() and decodes it through - * Visualizer_Module::decode_content(). This feeds a serialized canary the - * same way and asserts the object is never instantiated. + * Covers the helper-level guarantee shared by chart/source content callers. */ public function test_decode_content_does_not_instantiate_objects() { - Visualizer_POI_Canary::$awoke = false; - - // Reproduce the content the sink reads: serialize() of the source data - // (getData()), here carrying a canary object. - $source = new Visualizer_Source_Json( array( 'url' => '', 'root' => '', 'paging' => '' ) ); - $data_prop = new ReflectionProperty( 'Visualizer_Source', '_data' ); - $data_prop->setAccessible( true ); - $data_prop->setValue( $source, array( new Visualizer_POI_Canary() ) ); - $content = $source->getData(); - - $result = Visualizer_Module::decode_content( $content ); + $result = Visualizer_Module::decode_content( $this->object_payload( array( 'marker' => 'decoded' ) ) ); $this->assertFalse( Visualizer_POI_Canary::$awoke, @@ -124,73 +110,194 @@ public function test_decode_content_does_not_instantiate_objects() { $result, 'decode_content() should still return the (neutralized) array.' ); + $this->assertSame( 'decoded', $result['marker'] ); } /** * The Utility pie/polarArea render palette call site must not instantiate objects. * - * Drives the real sink (Visualizer_Module_Utility::apply_chartjs_palette), - * which previously used maybe_unserialize() on post_content, with a chart - * whose content is a serialized canary. Reverting that call site to an - * unrestricted (maybe_)unserialize() would fail this test. + * 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() { - Visualizer_POI_Canary::$awoke = false; - $chart_id = self::factory()->post->create( array( 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, - 'post_content' => wp_slash( serialize( array( new Visualizer_POI_Canary() ) ) ), + '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', ) ); - $method = new ReflectionMethod( 'Visualizer_Module_Utility', 'apply_chartjs_palette' ); - $method->setAccessible( true ); - - try { - $method->invoke( null, array(), 'pie', array(), $chart_id ); - } catch ( \Throwable $e ) { - // Only the canary matters here. - } + $utility = Visualizer_Plugin::instance()->getModule( Visualizer_Module_Utility::NAME ); + $result = $utility->apply_global_style_settings( array(), $chart_id, 'pie' ); + $this->assertCount( 3, $result['slices'], 'Palette size must match whitespace-wrapped chart data.' ); $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( 3, $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 (get_visualizer_data), which reads - * chart content via get_the_content(), with a chart whose content is a - * serialized canary. Reverting that call site to an unrestricted - * unserialize() would fail this test. + * 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() { - Visualizer_POI_Canary::$awoke = false; - $chart_id = self::factory()->post->create( array( 'post_type' => Visualizer_Plugin::CPT_VISUALIZER, - 'post_content' => wp_slash( serialize( array( new Visualizer_POI_Canary() ) ) ), + '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' ) ) ) ), ) ); - // get_the_content() reads the global post. - $GLOBALS['post'] = get_post( $chart_id ); + $GLOBALS['post'] = get_post( $decoy ); setup_postdata( $GLOBALS['post'] ); try { - Visualizer_Gutenberg_Block::get_instance()->get_visualizer_data( array( 'id' => $chart_id ) ); - } catch ( \Throwable $e ) { - // Only the canary matters here. + $result = Visualizer_Gutenberg_Block::get_instance()->get_visualizer_data( array( 'id' => $chart_id ) ); + } finally { + wp_reset_postdata(); } - 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.' + ); + } } From fcd92acff609c9b466f5ec417dd1b4fcefc0a308 Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Wed, 15 Jul 2026 13:34:59 +0300 Subject: [PATCH 10/11] fix: guard revision meta copy against object injection Admin::addRevision() and Admin::restoreRevision() copied raw chart meta through maybe_unserialize(), the same sink pattern already fixed in the chart import and clone paths. Route both through maybe_decode_content(). Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Module/Admin.php | 4 +- tests/test-security-object-injection.php | 49 ++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/classes/Visualizer/Module/Admin.php b/classes/Visualizer/Module/Admin.php index 748bf9a37..7b9c1e5dc 100644 --- a/classes/Visualizer/Module/Admin.php +++ b/classes/Visualizer/Module/Admin.php @@ -223,7 +223,7 @@ public function addRevision( $revision_id ) { if ( $meta ) { foreach ( $meta as $key => $value ) { if ( 0 === strpos( $key, 'visualizer' ) ) { - add_metadata( 'post', $revision_id, $key, maybe_unserialize( $value[0] ) ); + add_metadata( 'post', $revision_id, $key, self::maybe_decode_content( $value[0] ) ); } } } @@ -251,7 +251,7 @@ public function restoreRevision( $post_id, $revision_id ) { if ( $meta ) { foreach ( $meta as $key => $value ) { if ( 0 === strpos( $key, 'visualizer' ) ) { - add_post_meta( $post_id, $key, maybe_unserialize( $value[0] ) ); + add_post_meta( $post_id, $key, self::maybe_decode_content( $value[0] ) ); } } } diff --git a/tests/test-security-object-injection.php b/tests/test-security-object-injection.php index e9fef37c8..099bd2aca 100644 --- a/tests/test-security-object-injection.php +++ b/tests/test-security-object-injection.php @@ -300,4 +300,53 @@ public function test_clone_chart_meta_is_guarded_and_round_trips() { '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.' + ); + } } From 2d1bb5a1a2fe422551c8ef1d8ba856824d00ca0e Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Wed, 15 Jul 2026 13:45:53 +0300 Subject: [PATCH 11/11] fix: strip incomplete-class stubs from decoded chart content unserialize() with allowed_classes=false converts objects into __PHP_Incomplete_Class stubs. When the meta-copy paths (clone, revision) wrote the decoded value back through add_post_meta()/add_metadata(), WordPress's map_deep() crashed accessing the stub's properties. Strip the stubs at the decode_content() chokepoint so neutralized payloads keep their legitimate rows and never crash downstream writers. Co-Authored-By: Claude Fable 5 --- classes/Visualizer/Module.php | 29 +++++++++++++++++++++++- tests/test-security-object-injection.php | 10 ++++---- 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/classes/Visualizer/Module.php b/classes/Visualizer/Module.php index 708ebc83d..9d8166bb2 100644 --- a/classes/Visualizer/Module.php +++ b/classes/Visualizer/Module.php @@ -788,7 +788,34 @@ final public static function get_features_for_license( $plan ) { * @return mixed The decoded value (array for valid chart data), or false. */ public static function decode_content( $content ) { - return is_string( $content ) ? unserialize( trim( $content ), array( 'allowed_classes' => false ) ) : false; + if ( ! is_string( $content ) ) { + return false; + } + return self::strip_incomplete_objects( unserialize( trim( $content ), array( 'allowed_classes' => false ) ) ); + } + + /** + * Remove the __PHP_Incomplete_Class stubs the allowed_classes guard leaves + * behind; they crash map_deep() when the decoded value is written back to + * post meta. Legitimate chart content is nested arrays/scalars only. + * + * @param mixed $value The decoded value. + * @return mixed The value without object stubs; false for a top-level stub. + */ + private static function strip_incomplete_objects( $value ) { + if ( $value instanceof __PHP_Incomplete_Class ) { + return false; + } + if ( is_array( $value ) ) { + foreach ( $value as $key => $item ) { + if ( $item instanceof __PHP_Incomplete_Class ) { + unset( $value[ $key ] ); + } elseif ( is_array( $item ) ) { + $value[ $key ] = self::strip_incomplete_objects( $item ); + } + } + } + return $value; } /** diff --git a/tests/test-security-object-injection.php b/tests/test-security-object-injection.php index 099bd2aca..15fdf9f61 100644 --- a/tests/test-security-object-injection.php +++ b/tests/test-security-object-injection.php @@ -106,11 +106,11 @@ public function test_decode_content_does_not_instantiate_objects() { Visualizer_POI_Canary::$awoke, 'decode_content() must not instantiate objects from source content.' ); - $this->assertIsArray( + $this->assertSame( + array( 'marker' => 'decoded' ), $result, - 'decode_content() should still return the (neutralized) array.' + 'decode_content() must keep legitimate data and strip object stubs entirely.' ); - $this->assertSame( 'decoded', $result['marker'] ); } /** @@ -154,7 +154,7 @@ public function test_utility_pie_palette_call_site_is_guarded() { $utility = Visualizer_Plugin::instance()->getModule( Visualizer_Module_Utility::NAME ); $result = $utility->apply_global_style_settings( array(), $chart_id, 'pie' ); - $this->assertCount( 3, $result['slices'], 'Palette size must match whitespace-wrapped chart data.' ); + $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.' @@ -194,7 +194,7 @@ public function test_utility_chartjs_defaults_call_site_is_guarded() { Visualizer_Module_Utility::set_defaults( get_post( $chart_id ) ); $settings = get_post_meta( $chart_id, Visualizer_Plugin::CF_SETTINGS, true ); - $this->assertCount( 3, $settings['slices'] ); + $this->assertCount( 2, $settings['slices'] ); $this->assertFalse( Visualizer_POI_Canary::$awoke, 'ChartJS defaults must not instantiate objects from post_content.'