Skip to content

fix: preserve incomplete objects during duplication#1419

Merged
superdav42 merged 1 commit into
mainfrom
fix/checkout-incomplete-object-provisioning
Jun 13, 2026
Merged

fix: preserve incomplete objects during duplication#1419
superdav42 merged 1 commit into
mainfrom
fix/checkout-incomplete-object-provisioning

Conversation

@superdav42

@superdav42 superdav42 commented Jun 13, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • preserve serialized __PHP_Incomplete_Class option/meta values during site duplication instead of trying to mutate their properties
  • add a regression covering incomplete serialized plugin objects in MUCD_Data::try_replace()

Why

  • production checkout evidence showed template duplication aborting on a serialized Yoast object class that was unavailable in the duplication runtime
  • aborting after blog creation left the pending site stuck and host blogmeta unassigned

Verification

  • php -l inc/duplication/data.php
  • php -l tests/WP_Ultimo/Duplication/MUCD_Data_Test.php
  • WP-CLI probe: incomplete serialized object returns preserved
  • git diff --check

Notes

  • vendor/bin/phpunit --filter MUCD_Data_Test is blocked locally by missing /tmp/wordpress-tests-lib/includes/functions.php
  • PHPCS on these full legacy files still reports unrelated pre-existing violations outside this hotfix

aidevops.sh v3.20.58 plugin for OpenCode v1.17.4 with gpt-5.5 spent 21h 30m and 2,546,286 tokens on this with the user in an interactive session.

Summary by CodeRabbit

  • Bug Fixes

    • Enhanced the site duplication process to properly handle and preserve serialized data from plugins that may be missing or not fully loaded, ensuring data integrity and preventing corruption during duplication operations.
  • Tests

    • Added test coverage to verify incomplete serialized objects are correctly preserved during site duplication.

@superdav42 superdav42 added the origin:interactive Created by interactive user session label Jun 13, 2026
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

MUCD_Data::try_replace() now detects serialized objects from missing plugin classes (__PHP_Incomplete_Class) and returns their original serialized form unchanged. The logic prevents further deserialization attempts that could fail, and new test coverage validates the byte-for-byte preservation.

Changes

Incomplete Class Serialization Handling

Layer / File(s) Summary
Guard clause for incomplete classes in try_replace
inc/duplication/data.php
Deserialization setup refined and core replacement logic now checks for __PHP_Incomplete_Class after unserialization, returning the original value immediately to bypass array/object replacement logic.
Test incomplete class preservation
tests/WP_Ultimo/Duplication/MUCD_Data_Test.php
New test constructs a serialized payload for a missing plugin class, runs try_replace(), and asserts the serialized data remains unchanged despite URL replacement being applicable.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Possibly related PRs

  • Ultimate-Multisite/ultimate-multisite#803: Both PRs modify MUCD_Data::try_replace() unserialized-value error handling and add PHPUnit coverage for preserving original serialized data when deserialization encounters issues.

Suggested labels

review-feedback-scanned, status:in-review

Poem

🐰 A class lost to time, its serialized soul,
Try_replace holds fast—preserves it whole,
No incomplete breaks this careful dance,
The data flows safe, by design and by chance.
Test guards the gate where deserialization shines! ✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: preventing incomplete serialized objects from being mutated during site duplication, which aligns with both code changes.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/checkout-incomplete-object-provisioning

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown

🔨 Build Complete - Ready for Testing!

📦 Download Build Artifact (Recommended)

Download the zip build, upload to WordPress and test:

🌐 Test in WordPress Playground (Very Experimental)

Click the link below to instantly test this PR in your browser - no installation needed!
Playground support for multisite is very limitied, hopefully it will get better in the future.

🚀 Launch in Playground

Login credentials: admin / password

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
tests/WP_Ultimo/Duplication/MUCD_Data_Test.php (1)

305-328: ⚡ Quick win

Add coverage for the double-serialized incomplete-object branch.

Line 312 validates single-serialized incomplete objects; try_replace() also has explicit double-serialized handling. Add one companion assertion using serialize($serialized) so that branch stays protected from regressions.

🧪 Suggested test addition
+	public function test_try_replace_preserves_double_serialized_incomplete_object() {
+		$class_name = 'Missing\\Plugin\\Notification';
+		$old_url    = 'https://example.com/old/page';
+		$inner      = sprintf(
+			'O:%d:"%s":1:{s:3:"url";s:%d:"%s";}',
+			strlen($class_name),
+			$class_name,
+			strlen($old_url),
+			$old_url
+		);
+		$outer = serialize($inner);
+		$row   = ['meta_value' => $outer];
+
+		$result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old', 'example.com/new');
+
+		$this->assertSame($outer, $result);
+	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/WP_Ultimo/Duplication/MUCD_Data_Test.php` around lines 305 - 328, Add a
companion assertion to the
test_try_replace_preserves_incomplete_serialized_object that covers the
double-serialized incomplete-object branch: after building $serialized and
calling MUCD_Data::try_replace($row, 'meta_value', 'example.com/old',
'example.com/new'), also call try_replace with ['meta_value' =>
serialize($serialized)] (i.e. serialize($serialized)) and assert the result
equals serialize($serialized) and still contains the original $old_url; this
ensures MUCD_Data::try_replace's double-serialized handling is exercised.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@inc/duplication/data.php`:
- Around line 451-454: In try_replace, harden the unsafe
`@unserialize`($row[$field]) and the inner unserialize call by disabling object
instantiation and removing the error-suppression operator: call
unserialize($row[$field], ['allowed_classes' => false]) (and the same for the
inner_unserialized call), check the return value explicitly (is_array/is_scalar)
before using it, and fall back to $original_value when unserialize returns false
or an unexpected type; also remove the "@" to avoid hiding errors so failures
are handled deterministically.

---

Nitpick comments:
In `@tests/WP_Ultimo/Duplication/MUCD_Data_Test.php`:
- Around line 305-328: Add a companion assertion to the
test_try_replace_preserves_incomplete_serialized_object that covers the
double-serialized incomplete-object branch: after building $serialized and
calling MUCD_Data::try_replace($row, 'meta_value', 'example.com/old',
'example.com/new'), also call try_replace with ['meta_value' =>
serialize($serialized)] (i.e. serialize($serialized)) and assert the result
equals serialize($serialized) and still contains the original $old_url; this
ensures MUCD_Data::try_replace's double-serialized handling is exercised.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: bf7bdacd-0e99-4960-be53-59c006b27fb3

📥 Commits

Reviewing files that changed from the base of the PR and between 651407e and 9a20c9e.

📒 Files selected for processing (2)
  • inc/duplication/data.php
  • tests/WP_Ultimo/Duplication/MUCD_Data_Test.php

Comment thread inc/duplication/data.php
Comment on lines +451 to 454
$double_serialize = false;
$original_value = $row[ $field ];
$row[ $field ] = @unserialize($row[ $field ]);

@coderabbitai coderabbitai Bot Jun 13, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Read-only verification: inspect deserialization callsites and data-flow entry points.
rg -n "unserialize\\(" inc/duplication/data.php
rg -n "db_update_data\\(|update\\(|try_replace\\(" inc/duplication/data.php -C3
rg -n "get_fields_to_update|get_fields_to_update\\(" --type=php -C3

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3620


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the deserialization logic around the confirmed callsites
sed -n '430,520p' inc/duplication/data.php

# Look for incomplete-class handling and object branch logic
rg -n "__PHP_Incomplete_Class|incomplete|allowed_classes|is_object|return .*object|get_class\\(" inc/duplication/data.php

Repository: Ultimate-Multisite/ultimate-multisite

Length of output: 3055


Harden unserialize to prevent PHP object injection during duplication.

inc/duplication/data.php (try_replace) uses @unserialize($row[$field]) (and again for $inner_unserialized) with default class instantiation enabled. Even though there’s an __PHP_Incomplete_Class guard that returns $original_value, the unsafe instantiation (and potential gadget execution via magic methods) can already occur before that check.

🔧 Suggested hardening
 		public static function try_replace($row, $field, $from_string, $to_string) {
 			if (is_serialized($row[ $field ])) {
 				$double_serialize = false;
 				$original_value   = $row[ $field ];
-				$row[ $field ]    = `@unserialize`($row[ $field ]);
+				$allowed_classes  = apply_filters('wu_duplication_allowed_unserialize_classes', false);
+				$row[ $field ]    = `@unserialize`($row[ $field ], [ 'allowed_classes' => $allowed_classes ]);
@@
 				if (is_serialized($row[ $field ])) {
-					$inner_unserialized = `@unserialize`($row[ $field ]);
+					$inner_unserialized = `@unserialize`($row[ $field ], [ 'allowed_classes' => $allowed_classes ]);
🧰 Tools
🪛 GitHub Check: Code Quality Checks

[warning] 453-453:
unserialize() found. Serialized data has known vulnerability problems with Object Injection. JSON is generally a better approach for serializing data. See https://www.owasp.org/index.php/PHP_Object_Injection


[warning] 453-453:
Silencing errors is strongly discouraged. Use proper error checking instead. Found: @unserialize($row[ ...

🪛 OpenGrep (1.22.0)

[ERROR] 453-453: unserialize() with untrusted input can lead to remote code execution via PHP object injection. Use json_decode() or set allowed_classes to false.

(coderabbit.deserialization.php-unserialize)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@inc/duplication/data.php` around lines 451 - 454, In try_replace, harden the
unsafe `@unserialize`($row[$field]) and the inner unserialize call by disabling
object instantiation and removing the error-suppression operator: call
unserialize($row[$field], ['allowed_classes' => false]) (and the same for the
inner_unserialized call), check the return value explicitly (is_array/is_scalar)
before using it, and fall back to $original_value when unserialize returns false
or an unexpected type; also remove the "@" to avoid hiding errors so failures
are handled deterministically.

Source: Linters/SAST tools

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@coderabbitai Verified.

The premise is still valid in the merged PR code: inc/duplication/data.php:453 and inc/duplication/data.php:463 both call @unserialize(...) without allowed_classes, before the incomplete-class guard at inc/duplication/data.php:474 can return the original value.

Evidence: git merge-base --is-ancestor HEAD origin/main succeeded and git diff --name-only HEAD..origin/main -- inc/duplication/data.php tests/WP_Ultimo/Duplication/MUCD_Data_Test.php returned no file changes, so the affected code is now in origin/main. I also ran php -l inc/duplication/data.php and php -l tests/WP_Ultimo/Duplication/MUCD_Data_Test.php successfully.

Because PR #1419 is already merged, I am not pushing to the merged PR branch and I am not resolving this thread. This needs a follow-up fix on main that disables object instantiation for both unserialize calls while preserving the incomplete-object fallback behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@superdav42, thanks for verifying and providing the evidence. The concern is confirmed valid — the two @unserialize() calls at lines 453 and 463 can still instantiate objects (triggering __wakeup, __destruct, or autoloaders) before the __PHP_Incomplete_Class guard at line 474 can fire.

Here's the proposed follow-up fix:

  • Harden both unserialize() calls in try_replace() with ['allowed_classes' => false]
  • Remove the @ error-suppression operator (failures are already handled by the explicit false === checks)
  • Add a filter wu_duplication_allowed_unserialize_classes for extensibility (defaults to false)

Would you also like me to open a GitHub issue on main to track this follow-up fix?


🧠 Learnings used
Learnt from: superdav42
Repo: Ultimate-Multisite/ultimate-multisite PR: 1410
File: inc/class-requirements.php:39-39
Timestamp: 2026-06-12T00:44:09.258Z
Learning: Ultimate Multisite (Ultimate-Multisite/ultimate-multisite) supports PHP 8.2+ as the minimum supported PHP version (per PR `#1410`). During code review, do not flag code that uses PHP 8.0+ built-in functions or PHP 8+ language features as “compatibility” concerns—for example: str_ends_with(), str_starts_with(), str_contains(), named arguments, and match expressions—since they are natively available without polyfills.

The requested changes have been implemented and a pull request has been created: View PR

@superdav42 superdav42 merged commit b730746 into main Jun 13, 2026
12 checks passed
@superdav42 superdav42 deleted the fix/checkout-incomplete-object-provisioning branch June 13, 2026 16:43
@superdav42 superdav42 added the review-feedback-scanned Merged PR already scanned for quality feedback label Jun 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

origin:interactive Created by interactive user session review-feedback-scanned Merged PR already scanned for quality feedback

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant