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.

Usage

$ActionScheduler_ScheduleDeserializer = new ActionScheduler_ScheduleDeserializer();
// use class methods

Methods

  1. public __construct( array $allowed_scheduler_classes, array $allowed_nested_classes )
  2. public __invoke( $serialized_blob )
  3. public static is_enforced()
  4. public static unserialize( $blob )
  5. private class_name_of( object $maybe_incomplete )
  6. private deserialize()
  7. private is_allowed_nested_class( string $class_name, array $allowed_nested )
  8. private is_schedule_implementation( string $class_name )
  9. protected reject( $offending_class, $shadow_eligible )
  10. 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{} 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 );
	}
}