PluginProbe
WANotifier for Forms and Actions / 3.2.0
WANotifier for Forms and Actions v3.2.0
3.2.0 3.1.1 3.1.0 3.0.4 2.7.10 2.7.11 2.7.12 2.7.13 2.7.2 2.7.3 2.7.4 2.7.5 2.7.6 2.7.7 2.7.8 2.7.9 3.0.0 3.0.1 3.0.2 3.0.3 trunk 0.1.0 0.1.1 1.0.0 1.0.1 All 68 releases
notifier / libraries / action-scheduler / classes / ActionScheduler_ScheduleDeserializer.php

ActionScheduler_ScheduleDeserializer.php in WANotifier for Forms and Actions 3.2.0, at libraries/action-scheduler/classes/ActionScheduler_ScheduleDeserializer.php

413 lines 15.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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