ActionScheduler_ScheduleDeserializer{} │ WC 4.1.0
Used to safely deserialize schedule information when retrieving a stored scheduled action.
This helps to guard against deserialization attacks, while maintaining backwards compatibility with older versions of Action Scheduler.
Hooks from the class
Usage
$ActionScheduler_ScheduleDeserializer = new ActionScheduler_ScheduleDeserializer(); // use class methods
Methods
- public __construct( array $allowed_scheduler_classes, array $allowed_nested_classes )
- public __invoke( $serialized_blob )
- public static is_enforced()
- public static unserialize( $blob )
- private class_name_of( object $maybe_incomplete )
- private deserialize()
- private is_allowed_nested_class( string $class_name, array $allowed_nested )
- private is_schedule_implementation( string $class_name )
- protected reject( $offending_class, $shadow_eligible )
- private scan_object_graph( $value, bool $is_root, int $depth )
Notes
| See: https://github.com/woocommerce/action-scheduler/issues/1318 |
Changelog
| Since 4.1.0 | Introduced. |
ActionScheduler_ScheduleDeserializer{} ActionScheduler ScheduleDeserializer{} code WC 11.2.1
class ActionScheduler_ScheduleDeserializer {
/**
* Maximum object-graph depth we will walk while vetting a blob.
*
* Real schedules nest only a handful of levels deep (a cron schedule's CronExpression graph is
* the deepest at ~6). Anything beyond this bound is treated as untrustworthy rather than walked.
*
* @var int
*/
private const MAX_GRAPH_DEPTH = 100;
/**
* Action Scheduler's own concrete schedule classes. Along with any class that implements
* ActionScheduler_Schedule, these are always trusted.
*
* @var string[]
*/
private const KNOWN_SCHEDULE_CLASSES = array(
ActionScheduler_SimpleSchedule::class,
ActionScheduler_IntervalSchedule::class,
ActionScheduler_CronSchedule::class,
ActionScheduler_CanceledSchedule::class,
ActionScheduler_NullSchedule::class,
);
/**
* The default supporting classes Action Scheduler is willing to instantiate when nested in a schedule.
*
* @var string[]
*/
private const DEFAULT_NESTED_CLASSES = array(
// The bundled CronExpression family, which ActionScheduler_CronSchedule nests.
CronExpression::class,
CronExpression_FieldFactory::class,
CronExpression_AbstractField::class,
CronExpression_MinutesField::class,
CronExpression_HoursField::class,
CronExpression_DayOfMonthField::class,
CronExpression_MonthField::class,
CronExpression_DayOfWeekField::class,
CronExpression_YearField::class,
// Additional trusted classes, likely to be used in schedule representations.
ActionScheduler_DateTime::class,
DateTime::class,
DateTimeImmutable::class,
DateTimeZone::class,
DateInterval::class,
);
/**
* The raw serialized schedule blob being deserialized.
*
* @var string
*/
protected $source_data;
/**
* The class name of the outermost object in the blob.
*
* @var string
*/
protected $outer_class;
/**
* List of classes that are known to be acceptable outer classes.
*
* @var string[]
*/
private $allowed_scheduler_classes;
/**
* Any nested objects must be instances of one of these classes.
*
* @var string[]
*/
private $allowed_nested_classes;
/**
* Object IDs already visited during the walk, keyed by spl_object_id, to short-circuit cycles
* and shared references.
*
* @var array<int,bool>
*/
private $seen = array();
/**
* Unexpected/disallowed class names discovered nested in the blob.
*
* @var string[]
*/
private $potential_offenders;
/**
* Safely deserialize a stored schedule blob without instantiating unexpected classes.
*
* @param mixed $blob The raw serialized schedule blob as stored in the database.
*
* @return ActionScheduler_Schedule|false
*/
public static function unserialize( $blob ) {
/**
* Filters the list of classes that are permitted as nested properties of a schedule object.
*
* The result of this filter is not memoized: this is deliberate, so that it can be changed
* if the context (including the current blog, in a multisite network) alters during the
* same request.
*
* @since 4.1.0
*
* @param string[] $default List of allowed nested class names.
*/
$allowed_nested = (array) apply_filters( 'action_scheduler_allowed_nested_schedule_classes', self::DEFAULT_NESTED_CLASSES );
$deserializer = new self( self::KNOWN_SCHEDULE_CLASSES, $allowed_nested );
return $deserializer( $blob );
}
/**
* Build our ActionScheduler_ScheduleDeserializer instance.
*
* @param array $allowed_scheduler_classes Scheduler classes we accept.
* @param array $allowed_nested_classes Nested classes we accept.
*/
public function __construct( array $allowed_scheduler_classes, array $allowed_nested_classes ) {
$this->allowed_scheduler_classes = $allowed_scheduler_classes;
$this->allowed_nested_classes = $allowed_nested_classes;
}
/**
* Deserialize a blob of serialized data.
*
* @param mixed $serialized_blob Data to unserialize. Expected to be a string.
*
* @return ActionScheduler_Schedule|false
*/
public function __invoke( $serialized_blob ) {
if ( ! is_string( $serialized_blob ) || '' === $serialized_blob ) {
return false;
}
$this->seen = array();
$this->potential_offenders = array();
$this->source_data = $serialized_blob;
try {
return $this->deserialize();
} catch ( Throwable $e ) {
return false;
}
}
/**
* Perform the two-pass deserialization for this instance's already-validated blob.
*
* @return ActionScheduler_Schedule|false
*/
private function deserialize() {
// Initial attempt to unserialize, specifying all trusted classes: for default actions using
// supplied schedule classes, this should succeed without any problems. Otherwise, the result
// may contain one or more __PHP_Incomplete_Class references that we will need to examine.
$all_trusted_classes = array_merge( $this->allowed_scheduler_classes, $this->allowed_nested_classes );
$candidate = @unserialize( $this->source_data, array( 'allowed_classes' => $all_trusted_classes ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged
// A schedule is always an object graph.
if ( ! is_object( $candidate ) ) {
return false;
}
$this->outer_class = $this->class_name_of( $candidate );
// Verify that we have a valid schedule representation, scanning for disallowed nested classes.
if ( ! $this->is_schedule_implementation( $this->outer_class ) || ! $this->scan_object_graph( $candidate, true, 0 ) ) {
return $this->reject( $this->outer_class, false );
}
// If the candidate is an acceptable schedule class, and doesn't contain any potential offenders, it is safe.
if ( ! $candidate instanceof __PHP_Incomplete_Class && empty( $this->potential_offenders ) ) {
return $candidate;
}
// Otherwise the top-level object is itself a valid schedule, but references one or more disallowed classes.
foreach ( $this->potential_offenders as $offender ) {
if ( ! $this->is_allowed_nested_class( $offender, $this->allowed_nested_classes ) ) {
return $this->reject( $offender, true );
}
}
// If we reach this point, any potential offenders must in fact be safe.
$allowed = array_values(
array_unique(
array_merge( $all_trusted_classes, array( $this->outer_class ), $this->potential_offenders )
)
);
// Re-run unserialize.
return @unserialize( $this->source_data, array( 'allowed_classes' => $allowed ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged
}
/**
* Walk the parsed graph once, recording unexpected (placeholder) classes and guarding traversal.
*
* Objects whose class was outside the safe set are __PHP_Incomplete_Class placeholders; their
* original names are collected into $this->potential_offenders (the root is excluded — it is tracked
* separately as $this->outer_class). Real objects are trusted and only recursed into so a placeholder
* nested inside one is still found. A tampered blob can decode (via `r:`/`R:` references) into a
* self-referential or extremely deep graph, so visited objects are tracked to short-circuit cycles
* and shared references, and recursion depth is capped.
*
* @param mixed $value The value to walk (object, array, or scalar).
* @param bool $is_root Whether $value is the outermost object (excluded from $this->potential_offenders).
* @param int $depth Current recursion depth.
*
* @return bool True if the value was fully walked; false if it was too deep to vet safely.
*/
private function scan_object_graph( $value, bool $is_root, int $depth ): bool {
if ( $depth > self::MAX_GRAPH_DEPTH ) {
return false;
}
if ( is_object( $value ) ) {
$object_id = spl_object_id( $value );
if ( isset( $this->seen[ $object_id ] ) ) {
return true; // Already walked (cycle or shared reference); nothing new to collect.
}
$this->seen[ $object_id ] = true;
$is_placeholder = $value instanceof __PHP_Incomplete_Class;
if ( $is_placeholder && ! $is_root ) {
$this->potential_offenders[] = $this->class_name_of( $value );
}
foreach ( (array) $value as $key => $child ) {
if ( $is_placeholder && '__PHP_Incomplete_Class_Name' === $key ) {
continue;
}
if ( ! $this->scan_object_graph( $child, false, $depth + 1 ) ) {
return false;
}
}
return true;
}
if ( is_array( $value ) ) {
foreach ( $value as $child ) {
if ( ! $this->scan_object_graph( $child, false, $depth + 1 ) ) {
return false;
}
}
}
return true;
}
/**
* Handle a blob that references a class outside the vetted set.
*
* Always surfaces the event for observability. Under enforcement (the default) the blob is
* rejected by returning false, which callers already handle like a corrupt schedule.
*
* Shadow mode ("report only") relaxes this, but only for a $shadow_eligible rejection — one where
* the outermost object is a valid schedule and merely a *nested* class was unexpected. That is the
* sole case where a mis-tuned allow-list could disrupt an otherwise-legitimate action, so it is the
* only case shadow mode lets through. Structural rejections ($shadow_eligible false) — a top-level
* non-schedule (the issue #1318 PoC) or an unwalkable object graph — have no legitimate form and are
* always rejected, even in shadow mode, so report-only can never be downgraded into reviving the
* vulnerability.
*
* @param string $offending_class The class that triggered the rejection.
* @param bool $shadow_eligible Whether shadow mode may let this particular rejection through.
* @return ActionScheduler_Schedule|false False when rejected. A shadow-mode passthrough is only
* reached for a nested rejection, i.e. after the top-level
* schedule gate passed, so any object returned is a schedule.
*/
protected function reject( $offending_class, $shadow_eligible ) {
// A rejection stands unless we are in shadow mode AND this rejection is eligible for report-only
// passthrough. Resolve it once so the reported outcome and the branch taken cannot disagree, and
// the action_scheduler_enforce_schedule_allowed_classes filter runs a single time.
$rejected = self::is_enforced() || ! $shadow_eligible;
/**
* Fires when a stored schedule blob references a class Action Scheduler did not expect.
*
* @since 4.1.0
*
* @param string $offending_class The class that triggered the rejection.
* @param string $outer_class The outermost class in the blob.
* @param string[] $unexpected_classes Every class the blob names that is outside the safe set.
* @param bool $rejected Whether the blob was rejected (true) or allowed through in
* shadow mode (false). Always true for a structural rejection
* (top-level non-schedule or unwalkable graph), which shadow
* mode cannot relax.
*/
do_action( 'action_scheduler_unexpected_schedule_class', $offending_class, $this->outer_class, $this->potential_offenders, $rejected );
if ( $rejected ) {
return false;
}
// Shadow mode, nested-only rejection: behave as Action Scheduler did before this change. This is
// reached only when the top-level object is a valid schedule, so the unrestricted parse can no
// longer instantiate a bare top-level gadget.
return @unserialize( $this->source_data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged
}
/**
* Decide whether the outermost class in a schedule blob may be instantiated.
*
* Allowed if the class is loaded and implements ActionScheduler_Schedule. A class that is not
* loaded cannot be validated (and, prior to this change, already produced a broken action), so
* rejecting it here does not regress any case that previously worked.
*
* @param string $class_name Class name discovered in the blob.
* @return bool
*/
private function is_schedule_implementation( string $class_name ): bool {
if ( '' === $class_name || ! class_exists( $class_name ) ) {
return false;
}
$implements = class_implements( $class_name );
return is_array( $implements ) && isset( $implements['ActionScheduler_Schedule'] );
}
/**
* Decide whether a nested class (a property of the schedule) may be instantiated.
*
* The only classes Action Scheduler itself ever nests inside a schedule are the bundled
* CronExpression family. Composite schedules that nest another schedule are also fine. The
* default list is filterable so extenders can vet their own supporting classes.
*
* @param string $class_name Class name discovered nested in the blob.
* @param string[] $allowed_nested The resolved nested allow-list to check against.
*
* @return bool
*/
private function is_allowed_nested_class( string $class_name, array $allowed_nested ): bool {
if ( '' === $class_name ) {
return false;
}
if ( in_array( $class_name, $allowed_nested, true ) ) {
return true;
}
// A schedule nested inside another schedule (composite schedules) is safe by the same rule
// as the top-level check.
if ( class_exists( $class_name ) ) {
$implements = class_implements( $class_name );
if ( is_array( $implements ) && isset( $implements['ActionScheduler_Schedule'] ) ) {
return true;
}
}
return false;
}
/**
* Get the class name of an object parsed with a restricted allowed_classes set.
*
* Objects whose class was disallowed become __PHP_Incomplete_Class, which hides the original
* name behind get_class(); it lives in the __PHP_Incomplete_Class_Name pseudo-property instead.
* stdClass is never converted by PHP, so its real name is returned directly.
*
* @param object $maybe_incomplete Object parsed with a restricted allowed_classes set.
* @return string The original class name, or '' if it cannot be determined.
*/
private function class_name_of( object $maybe_incomplete ): string {
if ( $maybe_incomplete instanceof __PHP_Incomplete_Class ) {
$vars = (array) $maybe_incomplete;
return isset( $vars['__PHP_Incomplete_Class_Name'] ) ? (string) $vars['__PHP_Incomplete_Class_Name'] : '';
}
return get_class( $maybe_incomplete );
}
/**
* Whether an unexpected class causes the blob to be rejected (true) or merely reported while the
* legacy, unrestricted deserialization proceeds (false, "shadow mode").
*
* Enforcement is on by default. Site owners can opt into shadow mode for a release to gather
* data before enforcing, without changing any stored data.
*
* @return bool
*/
public static function is_enforced(): bool {
/**
* Filters whether Action Scheduler rejects schedule blobs referencing unexpected classes.
*
* Return false to run in "shadow mode": unexpected classes are reported via the
* `action_scheduler_unexpected_schedule_class` action but the blob is still deserialized
* exactly as it was before this hardening. Useful to confirm the allow-list is complete for
* your site before switching enforcement on.
*
* @since 4.1.0
*
* @param bool $enforced Whether to reject blobs with unexpected classes. Default true.
*/
return (bool) apply_filters( 'action_scheduler_enforce_schedule_allowed_classes', true );
}
}