diff --git a/digitizer-site-worker/includes/class-aura-worker-snapshots.php b/digitizer-site-worker/includes/class-aura-worker-snapshots.php index ed8681b..57fbf5e 100644 --- a/digitizer-site-worker/includes/class-aura-worker-snapshots.php +++ b/digitizer-site-worker/includes/class-aura-worker-snapshots.php @@ -395,6 +395,79 @@ public function get( $id ) { return is_array( $data ) ? $data : null; } + /** + * Unserialize a snapshot payload without ever instantiating a class. + * + * The payload files are written by this plugin, but they sit on disk and are + * the one untrusted-if-tampered input on the restore path — a plain + * unserialize() would let a crafted payload build arbitrary objects and fire + * __wakeup()/__destruct() gadget chains. `allowed_classes => false` turns any + * serialized object into a __PHP_Incomplete_Class instead, closing that + * surface. Everything this engine actually captures — option values, the + * meta/post arrays it builds, Elementor's JSON-string payloads — is scalars + * and arrays, so nothing legitimate is lost. A payload that *did* contain an + * object was either tampered or a pathological object-valued option; the + * callers detect the resulting incomplete class (an object is not an array, + * and is_stripped_object() catches the top-level option case) and fail closed + * rather than write it back. + * + * @param string $raw Serialized payload bytes. + * @return mixed Unserialized value, or false on malformed input. + */ + private function unserialize_payload( $raw ) { + return unserialize( (string) $raw, array( 'allowed_classes' => false ) ); + } + + /** + * Maximum array nesting the stripped-object walk will descend before it + * treats the payload as unsafe. Real snapshot payloads (option values, the + * shallow meta/post maps, Elementor's JSON-string blobs) are nowhere near + * this deep, so a legitimate payload never reaches it; the cap exists only + * to bound a tampered one. + */ + private const MAX_WALK_DEPTH = 64; + + /** + * True when a value is — or contains, at any depth — an object stripped by + * allowed_classes => false. + * + * The check must recurse: allowed_classes => false strips every serialized + * object to __PHP_Incomplete_Class, but a tampered payload can nest one + * inside an otherwise-valid array (e.g. serialize( array( 'x' => $gadget ) )). + * A top-level-only check passes that array straight through and update_option + * / update_post_meta persists the incomplete class as a "successful" restore. + * Walking the whole structure is what lets every kind fail closed on it. + * + * The walk is depth-bounded: unserialize() faithfully rebuilds a serialized + * reference cycle (e.g. `a:1:{i:0;R:1;}`) into a genuinely self-referential + * array, and an unbounded recursion over one would exhaust the stack and fatal + * the restore request rather than fail closed. Hitting the cap is itself + * treated as "unsafe" (return true) so a pathological payload is rejected, not + * restored. + * + * @param mixed $value Unserialized value. + * @param int $depth Current recursion depth (internal). + * @return bool + */ + private function contains_stripped_object( $value, $depth = 0 ) { + if ( $value instanceof \__PHP_Incomplete_Class ) { + return true; + } + if ( is_array( $value ) ) { + if ( $depth >= self::MAX_WALK_DEPTH ) { + // Too deep to be a real payload — a reference cycle or a crafted + // deeply-nested array. Refuse it rather than recurse into a fatal. + return true; + } + foreach ( $value as $item ) { + if ( $this->contains_stripped_object( $item, $depth + 1 ) ) { + return true; + } + } + } + return false; + } + /** * Restore state from a snapshot. * @@ -425,7 +498,16 @@ public function restore( $id ) { } $payload_path = $record['payload_path'] ?? ''; $raw = ( $payload_path && file_exists( $payload_path ) ) ? file_get_contents( $payload_path ) : ''; - update_option( $record['target'], unserialize( $raw ) ); + $value = $this->unserialize_payload( $raw ); + // Fail closed rather than write back an incomplete class: the payload + // held a serialized object (tampered, or a rare object-valued option), + // which allowed_classes => false intentionally refused to rebuild. + // Recurses so a nested object inside an array is caught too, not just + // a top-level one. + if ( $this->contains_stripped_object( $value ) ) { + return array( 'success' => false, 'error' => 'Snapshot payload contains an object and cannot be safely restored.' ); + } + update_option( $record['target'], $value ); return array( 'success' => true ); case 'post': @@ -452,8 +534,11 @@ public function restore( $id ) { if ( ! $payload_path || ! file_exists( $payload_path ) ) { return array( 'success' => false, 'error' => 'Snapshot payload missing.' ); } - $captured = unserialize( file_get_contents( $payload_path ) ); - if ( ! is_array( $captured ) ) { + $captured = $this->unserialize_payload( file_get_contents( $payload_path ) ); + // A tampered payload that serialized an object is stripped to an + // incomplete class. Reject both a top-level one (not an array) and one + // nested inside a captured meta value, before any of it is written. + if ( ! is_array( $captured ) || $this->contains_stripped_object( $captured ) ) { return array( 'success' => false, 'error' => 'Snapshot payload corrupt.' ); } $post_id = (int) $record['target']; @@ -472,8 +557,11 @@ public function restore( $id ) { if ( ! $payload_path || ! file_exists( $payload_path ) ) { return array( 'success' => false, 'error' => 'Snapshot payload missing.' ); } - $captured = unserialize( file_get_contents( $payload_path ) ); - if ( ! is_array( $captured ) ) { + $captured = $this->unserialize_payload( file_get_contents( $payload_path ) ); + // A tampered payload that serialized an object is stripped to an + // incomplete class. Reject both a top-level one (not an array) and one + // nested inside a captured post/meta value, before any of it is written. + if ( ! is_array( $captured ) || $this->contains_stripped_object( $captured ) ) { return array( 'success' => false, 'error' => 'Snapshot payload corrupt.' ); } foreach ( $captured as $pid => $info ) { diff --git a/tests/unit/SnapshotsTest.php b/tests/unit/SnapshotsTest.php index 28b7c62..760a0e8 100644 --- a/tests/unit/SnapshotsTest.php +++ b/tests/unit/SnapshotsTest.php @@ -404,4 +404,167 @@ public function test_posts_snapshot_rejects_empty_ids(): void { $snaps = new Aura_Worker_Snapshots(); $this->assertFalse( $snaps->snapshot_posts( array(), '_x' )['success'] ); } + + // --- object-injection hardening on the restore path --------------------- + // + // Payload files are written by the plugin, but they are the one on-disk, + // tamperable input restore() consumes. A plain unserialize() there would + // build arbitrary objects and fire __wakeup()/__destruct() gadget chains, so + // every payload is unserialized with allowed_classes => false. These tests + // pin the security property (no class is ever instantiated) and prove the + // scalar/array payloads the engine actually uses still round-trip. + + /** Overwrite a snapshot's payload file with attacker-chosen bytes. */ + private function tamperPayload( array $snapshot, string $bytes ): void { + $this->assertNotEmpty( $snapshot['payload_path'] ?? '' ); + file_put_contents( $snapshot['payload_path'], $bytes ); + } + + public function test_object_payload_never_instantiates_a_class_on_restore(): void { + Aura_Snapshot_Gadget::$woke = false; + + update_option( 'gadget_opt', 'benign' ); + $snaps = new Aura_Worker_Snapshots(); + $snap = $snaps->snapshot_option( 'gadget_opt' ); + + // Attacker replaces the payload with a serialized gadget object. + $this->tamperPayload( $snap['snapshot'], serialize( new Aura_Snapshot_Gadget() ) ); + + $res = $snaps->restore( $snap['snapshot']['id'] ); + + $this->assertFalse( Aura_Snapshot_Gadget::$woke, 'allowed_classes=false must prevent __wakeup() from ever firing.' ); + $this->assertFalse( $res['success'], 'An object payload is refused, not written back.' ); + $this->assertStringContainsString( 'object', $res['error'] ); + // The live option is untouched — no incomplete class leaked into storage. + $this->assertSame( 'benign', get_option( 'gadget_opt' ) ); + } + + public function test_meta_object_payload_is_rejected_as_corrupt(): void { + Aura_Snapshot_Gadget::$woke = false; + + $this->seedPost( 88 ); + update_post_meta( 88, '_elementor_data', 'orig' ); + $snaps = new Aura_Worker_Snapshots(); + $snap = $snaps->snapshot_meta( 88, '_elementor_data' ); + + $this->tamperPayload( $snap['snapshot'], serialize( new Aura_Snapshot_Gadget() ) ); + + $res = $snaps->restore( $snap['snapshot']['id'] ); + + $this->assertFalse( Aura_Snapshot_Gadget::$woke ); + $this->assertFalse( $res['success'] ); + $this->assertStringContainsString( 'corrupt', $res['error'] ); + } + + public function test_posts_object_payload_is_rejected_as_corrupt(): void { + Aura_Snapshot_Gadget::$woke = false; + + $this->seedClassPost( 601, '{"v":1}' ); + $snaps = new Aura_Worker_Snapshots(); + $snap = $snaps->snapshot_posts( array( 601 ), '_elementor_global_class_data' ); + + $this->tamperPayload( $snap['snapshot'], serialize( new Aura_Snapshot_Gadget() ) ); + + $res = $snaps->restore( $snap['snapshot']['id'] ); + + $this->assertFalse( Aura_Snapshot_Gadget::$woke ); + $this->assertFalse( $res['success'] ); + $this->assertStringContainsString( 'corrupt', $res['error'] ); + } + + public function test_option_nested_object_payload_is_rejected(): void { + // A gadget hidden INSIDE an array: allowed_classes=false strips it to an + // incomplete class, the top level stays an array, so a top-level-only guard + // would store it as a successful restore. The recursive check must catch it. + Aura_Snapshot_Gadget::$woke = false; + + update_option( 'nested_opt', 'benign' ); + $snaps = new Aura_Worker_Snapshots(); + $snap = $snaps->snapshot_option( 'nested_opt' ); + + $this->tamperPayload( $snap['snapshot'], serialize( array( 'x' => new Aura_Snapshot_Gadget() ) ) ); + + $res = $snaps->restore( $snap['snapshot']['id'] ); + + $this->assertFalse( Aura_Snapshot_Gadget::$woke ); + $this->assertFalse( $res['success'], 'A nested object payload must not restore.' ); + $this->assertStringContainsString( 'object', $res['error'] ); + // Nothing corrupt leaked into storage. + $this->assertSame( 'benign', get_option( 'nested_opt' ) ); + } + + public function test_meta_nested_object_payload_is_rejected(): void { + // snapshot_meta serializes array( key => array( 'existed'=>.., 'value'=>.. ) ), + // so a gadget in a leaf value is the realistic nesting for this kind. + Aura_Snapshot_Gadget::$woke = false; + + $this->seedPost( 91 ); + update_post_meta( 91, '_elementor_data', 'orig' ); + $snaps = new Aura_Worker_Snapshots(); + $snap = $snaps->snapshot_meta( 91, '_elementor_data' ); + + $payload = array( '_elementor_data' => array( 'existed' => true, 'value' => new Aura_Snapshot_Gadget() ) ); + $this->tamperPayload( $snap['snapshot'], serialize( $payload ) ); + + $res = $snaps->restore( $snap['snapshot']['id'] ); + + $this->assertFalse( Aura_Snapshot_Gadget::$woke ); + $this->assertFalse( $res['success'] ); + $this->assertStringContainsString( 'corrupt', $res['error'] ); + // The live meta was not overwritten with a stripped class. + $this->assertSame( 'orig', get_post_meta( 91, '_elementor_data', true ) ); + } + + public function test_cyclic_array_payload_fails_closed_without_fataling(): void { + // unserialize() rebuilds a serialized reference cycle into a genuinely + // self-referential array; the stripped-object walk must not recurse into + // it forever (stack exhaustion / DoS) but reject it fast. + update_option( 'cyclic_opt', 'benign' ); + $snaps = new Aura_Worker_Snapshots(); + $snap = $snaps->snapshot_option( 'cyclic_opt' ); + + // a:2:{s:1:"k";i:1;s:4:"self";R:1;} — element 'self' points back at the array. + $cyclic = 'a:2:{s:1:"k";i:1;s:4:"self";R:1;}'; + $this->tamperPayload( $snap['snapshot'], $cyclic ); + + $start = microtime( true ); + $res = $snaps->restore( $snap['snapshot']['id'] ); + $elapsed = microtime( true ) - $start; + + $this->assertFalse( $res['success'], 'A cyclic payload must be refused, not restored.' ); + $this->assertStringContainsString( 'object', $res['error'] ); + $this->assertLessThan( 1.0, $elapsed, 'The walk must terminate, not spin on the cycle.' ); + $this->assertSame( 'benign', get_option( 'cyclic_opt' ) ); + } + + public function test_scalar_and_array_option_payloads_still_round_trip(): void { + // The hardening must not regress the real payloads: scalars and nested + // arrays are exactly what options and Elementor meta hold. + $snaps = new Aura_Worker_Snapshots(); + + update_option( 'scalar_opt', 'a string' ); + $s1 = $snaps->snapshot_option( 'scalar_opt' ); + update_option( 'scalar_opt', 'changed' ); + $this->assertTrue( $snaps->restore( $s1['snapshot']['id'] )['success'] ); + $this->assertSame( 'a string', get_option( 'scalar_opt' ) ); + + update_option( 'array_opt', array( 'n' => 1, 'deep' => array( 'x', 'y' ) ) ); + $s2 = $snaps->snapshot_option( 'array_opt' ); + update_option( 'array_opt', array( 'n' => 2 ) ); + $this->assertTrue( $snaps->restore( $s2['snapshot']['id'] )['success'] ); + $this->assertSame( array( 'n' => 1, 'deep' => array( 'x', 'y' ) ), get_option( 'array_opt' ) ); + } +} + +/** + * A stand-in object-injection gadget: if a plain unserialize() ever rebuilt it, + * __wakeup() would flip the static flag. allowed_classes => false must keep that + * flag false. Defined at file scope so serialize()/unserialize() can name it. + */ +final class Aura_Snapshot_Gadget { + public static bool $woke = false; + + public function __wakeup(): void { + self::$woke = true; + } }