| 1 |
<?php |
| 2 |
/** |
| 3 |
* Used to safely deserialize schedule information when retrieving a stored scheduled action. |
| 4 |
* |
| 5 |
* This helps to guard against deserialization attacks, while maintaining backwards compatibility |
| 6 |
* with older versions of Action Scheduler. |
| 7 |
* |
| 8 |
* @internal This implementation is subject to change without notice or back-compat measures. |
| 9 |
* @see https://github.com/woocommerce/action-scheduler/issues/1318 |
| 10 |
* @since 4.1.0 |
| 11 |
*/ |
| 12 |
class ActionScheduler_ScheduleDeserializer { |
| 13 |
/** |
| 14 |
* Maximum object-graph depth we will walk while vetting a blob. |
| 15 |
* |
| 16 |
* Real schedules nest only a handful of levels deep (a cron schedule's CronExpression graph is |
| 17 |
* the deepest at ~6). Anything beyond this bound is treated as untrustworthy rather than walked. |
| 18 |
* |
| 19 |
* @var int |
| 20 |
*/ |
| 21 |
private const MAX_GRAPH_DEPTH = 100; |
| 22 |
|
| 23 |
/** |
| 24 |
* Action Scheduler's own concrete schedule classes. Along with any class that implements |
| 25 |
* ActionScheduler_Schedule, these are always trusted. |
| 26 |
* |
| 27 |
* @var string[] |
| 28 |
*/ |
| 29 |
private const KNOWN_SCHEDULE_CLASSES = array( |
| 30 |
ActionScheduler_SimpleSchedule::class, |
| 31 |
ActionScheduler_IntervalSchedule::class, |
| 32 |
ActionScheduler_CronSchedule::class, |
| 33 |
ActionScheduler_CanceledSchedule::class, |
| 34 |
ActionScheduler_NullSchedule::class, |
| 35 |
); |
| 36 |
|
| 37 |
/** |
| 38 |
* The default supporting classes Action Scheduler is willing to instantiate when nested in a schedule. |
| 39 |
* |
| 40 |
* @var string[] |
| 41 |
*/ |
| 42 |
private const DEFAULT_NESTED_CLASSES = array( |
| 43 |
// The bundled CronExpression family, which ActionScheduler_CronSchedule nests. |
| 44 |
CronExpression::class, |
| 45 |
CronExpression_FieldFactory::class, |
| 46 |
CronExpression_AbstractField::class, |
| 47 |
CronExpression_MinutesField::class, |
| 48 |
CronExpression_HoursField::class, |
| 49 |
CronExpression_DayOfMonthField::class, |
| 50 |
CronExpression_MonthField::class, |
| 51 |
CronExpression_DayOfWeekField::class, |
| 52 |
CronExpression_YearField::class, |
| 53 |
|
| 54 |
// Additional trusted classes, likely to be used in schedule representations. |
| 55 |
ActionScheduler_DateTime::class, |
| 56 |
DateTime::class, |
| 57 |
DateTimeImmutable::class, |
| 58 |
DateTimeZone::class, |
| 59 |
DateInterval::class, |
| 60 |
); |
| 61 |
|
| 62 |
/** |
| 63 |
* The raw serialized schedule blob being deserialized. |
| 64 |
* |
| 65 |
* @var string |
| 66 |
*/ |
| 67 |
protected $source_data; |
| 68 |
|
| 69 |
/** |
| 70 |
* The class name of the outermost object in the blob. |
| 71 |
* |
| 72 |
* @var string |
| 73 |
*/ |
| 74 |
protected $outer_class; |
| 75 |
|
| 76 |
/** |
| 77 |
* List of classes that are known to be acceptable outer classes. |
| 78 |
* |
| 79 |
* @var string[] |
| 80 |
*/ |
| 81 |
private $allowed_scheduler_classes; |
| 82 |
|
| 83 |
/** |
| 84 |
* Any nested objects must be instances of one of these classes. |
| 85 |
* |
| 86 |
* @var string[] |
| 87 |
*/ |
| 88 |
private $allowed_nested_classes; |
| 89 |
|
| 90 |
/** |
| 91 |
* Object IDs already visited during the walk, keyed by spl_object_id, to short-circuit cycles |
| 92 |
* and shared references. |
| 93 |
* |
| 94 |
* @var array<int,bool> |
| 95 |
*/ |
| 96 |
private $seen = array(); |
| 97 |
|
| 98 |
/** |
| 99 |
* Unexpected/disallowed class names discovered nested in the blob. |
| 100 |
* |
| 101 |
* @var string[] |
| 102 |
*/ |
| 103 |
private $potential_offenders; |
| 104 |
|
| 105 |
/** |
| 106 |
* Safely deserialize a stored schedule blob without instantiating unexpected classes. |
| 107 |
* |
| 108 |
* @param mixed $blob The raw serialized schedule blob as stored in the database. |
| 109 |
* |
| 110 |
* @return ActionScheduler_Schedule|false |
| 111 |
*/ |
| 112 |
public static function unserialize( $blob ) { |
| 113 |
/** |
| 114 |
* Filters the list of classes that are permitted as nested properties of a schedule object. |
| 115 |
* |
| 116 |
* The result of this filter is not memoized: this is deliberate, so that it can be changed |
| 117 |
* if the context (including the current blog, in a multisite network) alters during the |
| 118 |
* same request. |
| 119 |
* |
| 120 |
* @since 4.1.0 |
| 121 |
* |
| 122 |
* @param string[] $default List of allowed nested class names. |
| 123 |
*/ |
| 124 |
$allowed_nested = (array) apply_filters( 'action_scheduler_allowed_nested_schedule_classes', self::DEFAULT_NESTED_CLASSES ); |
| 125 |
$deserializer = new self( self::KNOWN_SCHEDULE_CLASSES, $allowed_nested ); |
| 126 |
return $deserializer( $blob ); |
| 127 |
} |
| 128 |
|
| 129 |
/** |
| 130 |
* Build our ActionScheduler_ScheduleDeserializer instance. |
| 131 |
* |
| 132 |
* @param array $allowed_scheduler_classes Scheduler classes we accept. |
| 133 |
* @param array $allowed_nested_classes Nested classes we accept. |
| 134 |
*/ |
| 135 |
public function __construct( array $allowed_scheduler_classes, array $allowed_nested_classes ) { |
| 136 |
$this->allowed_scheduler_classes = $allowed_scheduler_classes; |
| 137 |
$this->allowed_nested_classes = $allowed_nested_classes; |
| 138 |
} |
| 139 |
|
| 140 |
/** |
| 141 |
* Deserialize a blob of serialized data. |
| 142 |
* |
| 143 |
* @param mixed $serialized_blob Data to unserialize. Expected to be a string. |
| 144 |
* |
| 145 |
* @return ActionScheduler_Schedule|false |
| 146 |
*/ |
| 147 |
public function __invoke( $serialized_blob ) { |
| 148 |
if ( ! is_string( $serialized_blob ) || '' === $serialized_blob ) { |
| 149 |
return false; |
| 150 |
} |
| 151 |
|
| 152 |
$this->seen = array(); |
| 153 |
$this->potential_offenders = array(); |
| 154 |
$this->source_data = $serialized_blob; |
| 155 |
|
| 156 |
try { |
| 157 |
return $this->deserialize(); |
| 158 |
} catch ( Throwable $e ) { |
| 159 |
return false; |
| 160 |
} |
| 161 |
} |
| 162 |
|
| 163 |
/** |
| 164 |
* Perform the two-pass deserialization for this instance's already-validated blob. |
| 165 |
* |
| 166 |
* @return ActionScheduler_Schedule|false |
| 167 |
*/ |
| 168 |
private function deserialize() { |
| 169 |
// Initial attempt to unserialize, specifying all trusted classes: for default actions using |
| 170 |
// supplied schedule classes, this should succeed without any problems. Otherwise, the result |
| 171 |
// may contain one or more __PHP_Incomplete_Class references that we will need to examine. |
| 172 |
$all_trusted_classes = array_merge( $this->allowed_scheduler_classes, $this->allowed_nested_classes ); |
| 173 |
$candidate = @unserialize( $this->source_data, array( 'allowed_classes' => $all_trusted_classes ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged |
| 174 |
|
| 175 |
// A schedule is always an object graph. |
| 176 |
if ( ! is_object( $candidate ) ) { |
| 177 |
return false; |
| 178 |
} |
| 179 |
|
| 180 |
$this->outer_class = $this->class_name_of( $candidate ); |
| 181 |
|
| 182 |
// Verify that we have a valid schedule representation, scanning for disallowed nested classes. |
| 183 |
if ( ! $this->is_schedule_implementation( $this->outer_class ) || ! $this->scan_object_graph( $candidate, true, 0 ) ) { |
| 184 |
return $this->reject( $this->outer_class, false ); |
| 185 |
} |
| 186 |
|
| 187 |
// If the candidate is an acceptable schedule class, and doesn't contain any potential offenders, it is safe. |
| 188 |
if ( ! $candidate instanceof __PHP_Incomplete_Class && empty( $this->potential_offenders ) ) { |
| 189 |
return $candidate; |
| 190 |
} |
| 191 |
|
| 192 |
// Otherwise the top-level object is itself a valid schedule, but references one or more disallowed classes. |
| 193 |
foreach ( $this->potential_offenders as $offender ) { |
| 194 |
if ( ! $this->is_allowed_nested_class( $offender, $this->allowed_nested_classes ) ) { |
| 195 |
return $this->reject( $offender, true ); |
| 196 |
} |
| 197 |
} |
| 198 |
|
| 199 |
// If we reach this point, any potential offenders must in fact be safe. |
| 200 |
$allowed = array_values( |
| 201 |
array_unique( |
| 202 |
array_merge( $all_trusted_classes, array( $this->outer_class ), $this->potential_offenders ) |
| 203 |
) |
| 204 |
); |
| 205 |
|
| 206 |
// Re-run unserialize. |
| 207 |
return @unserialize( $this->source_data, array( 'allowed_classes' => $allowed ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged |
| 208 |
} |
| 209 |
|
| 210 |
/** |
| 211 |
* Walk the parsed graph once, recording unexpected (placeholder) classes and guarding traversal. |
| 212 |
* |
| 213 |
* Objects whose class was outside the safe set are __PHP_Incomplete_Class placeholders; their |
| 214 |
* original names are collected into $this->potential_offenders (the root is excluded — it is tracked |
| 215 |
* separately as $this->outer_class). Real objects are trusted and only recursed into so a placeholder |
| 216 |
* nested inside one is still found. A tampered blob can decode (via `r:`/`R:` references) into a |
| 217 |
* self-referential or extremely deep graph, so visited objects are tracked to short-circuit cycles |
| 218 |
* and shared references, and recursion depth is capped. |
| 219 |
* |
| 220 |
* @param mixed $value The value to walk (object, array, or scalar). |
| 221 |
* @param bool $is_root Whether $value is the outermost object (excluded from $this->potential_offenders). |
| 222 |
* @param int $depth Current recursion depth. |
| 223 |
* |
| 224 |
* @return bool True if the value was fully walked; false if it was too deep to vet safely. |
| 225 |
*/ |
| 226 |
private function scan_object_graph( $value, bool $is_root, int $depth ): bool { |
| 227 |
if ( $depth > self::MAX_GRAPH_DEPTH ) { |
| 228 |
return false; |
| 229 |
} |
| 230 |
|
| 231 |
if ( is_object( $value ) ) { |
| 232 |
$object_id = spl_object_id( $value ); |
| 233 |
if ( isset( $this->seen[ $object_id ] ) ) { |
| 234 |
return true; // Already walked (cycle or shared reference); nothing new to collect. |
| 235 |
} |
| 236 |
$this->seen[ $object_id ] = true; |
| 237 |
|
| 238 |
$is_placeholder = $value instanceof __PHP_Incomplete_Class; |
| 239 |
if ( $is_placeholder && ! $is_root ) { |
| 240 |
$this->potential_offenders[] = $this->class_name_of( $value ); |
| 241 |
} |
| 242 |
|
| 243 |
foreach ( (array) $value as $key => $child ) { |
| 244 |
if ( $is_placeholder && '__PHP_Incomplete_Class_Name' === $key ) { |
| 245 |
continue; |
| 246 |
} |
| 247 |
if ( ! $this->scan_object_graph( $child, false, $depth + 1 ) ) { |
| 248 |
return false; |
| 249 |
} |
| 250 |
} |
| 251 |
|
| 252 |
return true; |
| 253 |
} |
| 254 |
|
| 255 |
if ( is_array( $value ) ) { |
| 256 |
foreach ( $value as $child ) { |
| 257 |
if ( ! $this->scan_object_graph( $child, false, $depth + 1 ) ) { |
| 258 |
return false; |
| 259 |
} |
| 260 |
} |
| 261 |
} |
| 262 |
|
| 263 |
return true; |
| 264 |
} |
| 265 |
|
| 266 |
/** |
| 267 |
* Handle a blob that references a class outside the vetted set. |
| 268 |
* |
| 269 |
* Always surfaces the event for observability. Under enforcement (the default) the blob is |
| 270 |
* rejected by returning false, which callers already handle like a corrupt schedule. |
| 271 |
* |
| 272 |
* Shadow mode ("report only") relaxes this, but only for a $shadow_eligible rejection — one where |
| 273 |
* the outermost object is a valid schedule and merely a *nested* class was unexpected. That is the |
| 274 |
* sole case where a mis-tuned allow-list could disrupt an otherwise-legitimate action, so it is the |
| 275 |
* only case shadow mode lets through. Structural rejections ($shadow_eligible false) — a top-level |
| 276 |
* non-schedule (the issue #1318 PoC) or an unwalkable object graph — have no legitimate form and are |
| 277 |
* always rejected, even in shadow mode, so report-only can never be downgraded into reviving the |
| 278 |
* vulnerability. |
| 279 |
* |
| 280 |
* @param string $offending_class The class that triggered the rejection. |
| 281 |
* @param bool $shadow_eligible Whether shadow mode may let this particular rejection through. |
| 282 |
* @return ActionScheduler_Schedule|false False when rejected. A shadow-mode passthrough is only |
| 283 |
* reached for a nested rejection, i.e. after the top-level |
| 284 |
* schedule gate passed, so any object returned is a schedule. |
| 285 |
*/ |
| 286 |
protected function reject( $offending_class, $shadow_eligible ) { |
| 287 |
// A rejection stands unless we are in shadow mode AND this rejection is eligible for report-only |
| 288 |
// passthrough. Resolve it once so the reported outcome and the branch taken cannot disagree, and |
| 289 |
// the action_scheduler_enforce_schedule_allowed_classes filter runs a single time. |
| 290 |
$rejected = self::is_enforced() || ! $shadow_eligible; |
| 291 |
|
| 292 |
/** |
| 293 |
* Fires when a stored schedule blob references a class Action Scheduler did not expect. |
| 294 |
* |
| 295 |
* @since 4.1.0 |
| 296 |
* |
| 297 |
* @param string $offending_class The class that triggered the rejection. |
| 298 |
* @param string $outer_class The outermost class in the blob. |
| 299 |
* @param string[] $unexpected_classes Every class the blob names that is outside the safe set. |
| 300 |
* @param bool $rejected Whether the blob was rejected (true) or allowed through in |
| 301 |
* shadow mode (false). Always true for a structural rejection |
| 302 |
* (top-level non-schedule or unwalkable graph), which shadow |
| 303 |
* mode cannot relax. |
| 304 |
*/ |
| 305 |
do_action( 'action_scheduler_unexpected_schedule_class', $offending_class, $this->outer_class, $this->potential_offenders, $rejected ); |
| 306 |
|
| 307 |
if ( $rejected ) { |
| 308 |
return false; |
| 309 |
} |
| 310 |
|
| 311 |
// Shadow mode, nested-only rejection: behave as Action Scheduler did before this change. This is |
| 312 |
// reached only when the top-level object is a valid schedule, so the unrestricted parse can no |
| 313 |
// longer instantiate a bare top-level gadget. |
| 314 |
return @unserialize( $this->source_data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged |
| 315 |
} |
| 316 |
|
| 317 |
/** |
| 318 |
* Decide whether the outermost class in a schedule blob may be instantiated. |
| 319 |
* |
| 320 |
* Allowed if the class is loaded and implements ActionScheduler_Schedule. A class that is not |
| 321 |
* loaded cannot be validated (and, prior to this change, already produced a broken action), so |
| 322 |
* rejecting it here does not regress any case that previously worked. |
| 323 |
* |
| 324 |
* @param string $class_name Class name discovered in the blob. |
| 325 |
* @return bool |
| 326 |
*/ |
| 327 |
private function is_schedule_implementation( string $class_name ): bool { |
| 328 |
if ( '' === $class_name || ! class_exists( $class_name ) ) { |
| 329 |
return false; |
| 330 |
} |
| 331 |
|
| 332 |
$implements = class_implements( $class_name ); |
| 333 |
return is_array( $implements ) && isset( $implements['ActionScheduler_Schedule'] ); |
| 334 |
} |
| 335 |
|
| 336 |
/** |
| 337 |
* Decide whether a nested class (a property of the schedule) may be instantiated. |
| 338 |
* |
| 339 |
* The only classes Action Scheduler itself ever nests inside a schedule are the bundled |
| 340 |
* CronExpression family. Composite schedules that nest another schedule are also fine. The |
| 341 |
* default list is filterable so extenders can vet their own supporting classes. |
| 342 |
* |
| 343 |
* @param string $class_name Class name discovered nested in the blob. |
| 344 |
* @param string[] $allowed_nested The resolved nested allow-list to check against. |
| 345 |
* |
| 346 |
* @return bool |
| 347 |
*/ |
| 348 |
private function is_allowed_nested_class( string $class_name, array $allowed_nested ): bool { |
| 349 |
if ( '' === $class_name ) { |
| 350 |
return false; |
| 351 |
} |
| 352 |
|
| 353 |
if ( in_array( $class_name, $allowed_nested, true ) ) { |
| 354 |
return true; |
| 355 |
} |
| 356 |
|
| 357 |
// A schedule nested inside another schedule (composite schedules) is safe by the same rule |
| 358 |
// as the top-level check. |
| 359 |
if ( class_exists( $class_name ) ) { |
| 360 |
$implements = class_implements( $class_name ); |
| 361 |
if ( is_array( $implements ) && isset( $implements['ActionScheduler_Schedule'] ) ) { |
| 362 |
return true; |
| 363 |
} |
| 364 |
} |
| 365 |
|
| 366 |
return false; |
| 367 |
} |
| 368 |
|
| 369 |
/** |
| 370 |
* Get the class name of an object parsed with a restricted allowed_classes set. |
| 371 |
* |
| 372 |
* Objects whose class was disallowed become __PHP_Incomplete_Class, which hides the original |
| 373 |
* name behind get_class(); it lives in the __PHP_Incomplete_Class_Name pseudo-property instead. |
| 374 |
* stdClass is never converted by PHP, so its real name is returned directly. |
| 375 |
* |
| 376 |
* @param object $maybe_incomplete Object parsed with a restricted allowed_classes set. |
| 377 |
* @return string The original class name, or '' if it cannot be determined. |
| 378 |
*/ |
| 379 |
private function class_name_of( object $maybe_incomplete ): string { |
| 380 |
if ( $maybe_incomplete instanceof __PHP_Incomplete_Class ) { |
| 381 |
$vars = (array) $maybe_incomplete; |
| 382 |
return isset( $vars['__PHP_Incomplete_Class_Name'] ) ? (string) $vars['__PHP_Incomplete_Class_Name'] : ''; |
| 383 |
} |
| 384 |
|
| 385 |
return get_class( $maybe_incomplete ); |
| 386 |
} |
| 387 |
|
| 388 |
/** |
| 389 |
* Whether an unexpected class causes the blob to be rejected (true) or merely reported while the |
| 390 |
* legacy, unrestricted deserialization proceeds (false, "shadow mode"). |
| 391 |
* |
| 392 |
* Enforcement is on by default. Site owners can opt into shadow mode for a release to gather |
| 393 |
* data before enforcing, without changing any stored data. |
| 394 |
* |
| 395 |
* @return bool |
| 396 |
*/ |
| 397 |
public static function is_enforced(): bool { |
| 398 |
/** |
| 399 |
* Filters whether Action Scheduler rejects schedule blobs referencing unexpected classes. |
| 400 |
* |
| 401 |
* Return false to run in "shadow mode": unexpected classes are reported via the |
| 402 |
* `action_scheduler_unexpected_schedule_class` action but the blob is still deserialized |
| 403 |
* exactly as it was before this hardening. Useful to confirm the allow-list is complete for |
| 404 |
* your site before switching enforcement on. |
| 405 |
* |
| 406 |
* @since 4.1.0 |
| 407 |
* |
| 408 |
* @param bool $enforced Whether to reject blobs with unexpected classes. Default true. |
| 409 |
*/ |
| 410 |
return (bool) apply_filters( 'action_scheduler_enforce_schedule_allowed_classes', true ); |
| 411 |
} |
| 412 |
} |
| 413 |
|