Skip to content

Schedule deserialization hardening#1344

Open
barryhughes wants to merge 16 commits into
trunkfrom
fix/schedule-serialization
Open

Schedule deserialization hardening#1344
barryhughes wants to merge 16 commits into
trunkfrom
fix/schedule-serialization

Conversation

@barryhughes

@barryhughes barryhughes commented Jul 11, 2026

Copy link
Copy Markdown
Member

Both of the default action stores persist schedule information as serialized PHP blobs. However, there are currently no protections during the deserialization process to prevent PHP object injection attacks.

The core concept here (originally introduced by #1341, thank you @vladolaru) is to handle deserialization in one or more passes. High-level (slightly simplified) summary of the algorithm in this PR:

  • First pass: unserialize() with an allowed_classes list covering the library's own schedule classes and the small set of support classes they legitimately nest (the CronExpression family, and the built-in date/time value objects). Normal data hydrates fully in this pass — if the outermost object is a schedule and nothing decoded to __PHP_Incomplete_Class, we return it with no further work.
  • If unexpected classes remain: the outermost object must itself be a schedule (a top-level non-schedule — e.g. an injected gadget — is rejected here, before we walk anything). Each remaining __PHP_Incomplete_Class is then vetted: acceptable if it's on the (filterable) support allow-list or implements ActionScheduler_Schedule. If all pass, we unserialize() once more restricted to the expanded set and return the result; if any fails, we return false (which the caller treats as corrupt).

The idea is to minimize any additional overhead when we are dealing with perfectly normal and expected schedule data, while still filtering out potentially dangerous class references.


Testing

The test suite should provide reasonable coverage, but we can also perform some manual testing and exploration. For instance, you can try creating conventional scheduled actions of various sorts via WP CLI:

wp eval "as_schedule_cron_action( time(), '* */2 * * *', 'testcronaction' );"

Then, visit Tools ‣ Scheduled Actions. You should see the created actions in the queue of pending actions and, if you run them manually, they should successfully complete or fail as expected.

Tip

When is it acceptable for a test action like testcronaction, created above, to fail? When we haven't registered a callback. You can workaround this by adding something like add_action( 'testcronaction', fn () => 0 ); to a mu-plugin, or just look at the failed action log and verify it says something like, 'Scheduled action for testcronaction will not be executed as no callbacks are registered.' This is fine and expected.

Second, we can simulate a perfectly valid custom schedule that happens to contain an instance of an unknown class:

class Problematic_Custom_Schedule implements ActionScheduler_Schedule {
	public $data;

	public function __construct() {
		$this->data = new Custom_Entity;
	}

	public function next(?DateTime $after = null) {
		return $after === null ? date_create( '+1 day' ) : $after->modify( '+1 day' );
	}

	public function is_recurring() {
		return false;
	}

	public function get_date() {
		return date_create();
	}
}

class Custom_Entity {
	public $data = 123;
}

ActionScheduler::store()->save_action( 
	new ActionScheduler_Action( 'testaction', [], new Problematic_Custom_Schedule )
);

Run the above with wp eval-file /path/to/script.php, then head over to Tools ‣ Scheduled Actions. Depending on how quickly you get there it will probably be sitting in either the past-due queue or else will have moved to cancelled (which is how these particularl cases are handled).

Shows an action cancelled because of corruption

Feel free to explore other permutations 🙂


Notes/questions

  • The above example of Problematic_Custom_Schedule illustrates how some third party or custom schedules, that until now were considered valid, will be handled. This is potentially problematic (by default, cancelled actions cannot be re-run and will be deleted after one month); so we need to decide if that risk is acceptable.
  • Note here that I did not find any cases like this when scanning through a body of known WooCommerce extensions. Of course, that doesn't cover everything out there (still, it's a useful indicator).
  • It may even be desirable to introduce a new status like suspended (right now I'm less interested in what we name it and more the concept as a whole) which would then give admins a chance to review and force run the actions. An existing status like failed might also work for this, but we would need to add the ability to forcibly re-run.
  • Further, we provide a new action_scheduler_allowed_nested_schedule_classes filter to make it easier for custom schedule implementations to expand the allow-list.
  • At site/project level, new hook action_scheduler_enforce_schedule_allowed_classes essentially allows for these new mechanics to be turned off (while still making it possible to observe any problems). Of course, this also requires the ability to add custom code. We again need to ask if that is acceptable by itself, or should be supported with some sort of settings UI.
  • Last, it seems fine to ship this as part of a 4.1.0 release alongside other fixes already merged to trunk, but we could also separate it out.

vladolaru and others added 13 commits July 8, 2026 13:54
When a scheduled action is read back, its schedule is stored as a native
PHP-serialized blob and passed to unserialize() with no allowed_classes
restriction. An attacker able to influence those stored bytes could make PHP
instantiate an arbitrary class, running its __wakeup()/__destruct() magic
methods (PHP object injection). The existing "is this an ActionScheduler_Schedule?"
check in validate_schedule() runs only AFTER unserialize(), i.e. after the
damage is done.

Move that trust decision to before instantiation using a two-phase
deserialization in the new ActionScheduler_ScheduleDeserializer:

  1. Parse the blob with allowed_classes => false. This is inert: every object
     becomes a __PHP_Incomplete_Class placeholder, so no constructor, __wakeup(),
     __destruct() or autoloading runs.
  2. Inspect the class names present. Only if the outermost class implements
     ActionScheduler_Schedule, and every nested class is on a small vetted
     allow-list (the bundled CronExpression family, filterable, plus any class
     implementing the schedule interface), re-run unserialize() restricted to
     exactly that verified set.

The allow-list is derived, not declared: any loaded schedule class -- including
third-party ones -- is trusted automatically, so extenders need to do nothing.
This also means no backward-compatibility window: a schedule class that is not
loaded already produced a broken action before this change, so rejecting it here
regresses nothing that previously worked. Legacy blobs carrying the full
CronExpression object graph continue to deserialize unchanged.

Rejections reuse the existing corrupt-blob path (NullSchedule for canceled
actions, ActionScheduler_InvalidActionException otherwise), so no new failure
mode is introduced. An action_scheduler_unexpected_schedule_class hook fires for
observability, and a shadow mode
(action_scheduler_enforce_schedule_allowed_classes filter, default enforce) lets
site owners report-only for a release before enforcing -- without touching any
stored data.

Both the default custom-table store (ActionScheduler_DBStore) and the legacy
post store (ActionScheduler_wpPostStore, which reads the raw serialized meta to
bypass the meta API's implicit unserialize) are covered.

No storage-format change and no migration: existing rows are read exactly as
before, only more safely.

Refs #1318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two hardening fixes surfaced in code review of the two-phase deserializer:

1. Cyclic/deep object graphs (DoS). The class-name discovery walk recursed
   through the inert object graph with no cycle or depth guard. A tampered blob
   can use PHP's r:/R: references to encode a self-referential graph, sending the
   walk into unbounded recursion and exhausting the stack before the blob was
   ever rejected. We now track visited objects by spl_object_id (short-circuiting
   cycles and shared references) and cap recursion depth well above any real
   schedule (cron's CronExpression graph is the deepest at ~6 levels). A graph we
   cannot fully walk is treated as untrustworthy and rejected.

2. Log parity on re-hydration. The restricted phase-2 unserialize() was not
   silenced, whereas the stores historically used @unserialize() for schedule
   blobs. Restored the @ so corrupt data cannot start emitting warnings/notices
   into logs or output that it did not before this change.

Adds a regression test covering the self-referential-blob case.

Refs #1318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three small correctness/clarity fixes from code review:

- Resolve is_enforced() once in handle_rejection() instead of calling it twice.
  A non-deterministic or expensive action_scheduler_enforce_schedule_allowed_classes
  filter could otherwise make the value reported to the observability action
  disagree with the branch actually taken.
- Correct get_schedule_from_post_meta()'s documented return type to mixed: when
  the stored meta is not a serialized object it is returned as-is (a scalar) for
  validate_schedule() to reject, matching the prior get_post_meta() behaviour.
- Annotate the intentional direct, uncached postmeta read with a
  DirectDatabaseQuery/NoCaching phpcs:ignore, matching this file's convention for
  its other direct queries and documenting why the meta cache is bypassed.

Refs #1318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
is_allowed_nested_class() called get_allowed_nested_classes() for every nested
class, so the action_scheduler_allowed_nested_schedule_classes filter ran once
per nested object on every schedule fetch -- around seven times for a cron
schedule (CronExpression, its field factory and five field objects). The list is
invariant across the loop.

Resolve it once before the loop and pass it into is_allowed_nested_class(). The
parameter is optional and defaults to resolving the list, so a standalone call is
unaffected.

Refs #1318

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A blob referencing only Action Scheduler's own schedule classes and vetted
support classes -- the common case -- is now hydrated in one restricted
unserialize() instead of the inert-then-rehydrate two-phase walk. The two-phase
path remains for third-party, filtered, or tampered blobs.

Refs #1318

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds DateTime, DateTimeImmutable, DateTimeZone and DateInterval to the default
nested allow-list so third-party schedules that store one as a property keep
deserializing. They define no exploitable magic-method sink. Also switches both
allow-lists to ::class references.

Refs #1318

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Shadow (report-only) mode routed every rejection through an unrestricted
unserialize(), so it re-instantiated a bare top-level gadget -- the exact
issue #1318 PoC. Rejections are now split: a top-level non-schedule or an
unwalkable graph is always blocked, and only an unexpected nested support class
under an otherwise-valid schedule may pass through in shadow mode. The
observability action now reports the outcome actually taken.

Refs #1318

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the inert-then-rehydrate two-phase parse with a single restricted
unserialize(): classes outside the trusted set become __PHP_Incomplete_Class
placeholders that double as the list of unexpected classes to vet, so one walk
of the graph yields both the hydrated object and everything still needing a
trust decision. The separate all-inert parse and its walk are gone.

A static unserialize() facade drives a short-lived instance -- the constructor
takes the allow-lists, __invoke takes the blob, and per-blob walk state is reset
on each invocation. The nested allow-list is resolved through its filter once per
call (never process-cached) so a late- or context-registered filter is honoured.
The top-level schedule gate runs before the walk, so a bare top-level gadget is
rejected without walking a hostile graph. Marked @internal.

Refs #1318

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds coverage for the paths the hardening introduced: shadow mode still blocks a
top-level gadget but lets an unexpected nested class through; the observability
action fires on rejection; the nested allow-list filter is honoured per call; a
third-party schedule nesting built-in date/time objects round-trips; an overly
deep graph is rejected; a reused deserializer instance does not leak state; the
injected nested allow-list is authoritative; and a non-serialized post-store meta
value is treated as an invalid action. Adds two third-party schedule fixtures.

Refs #1318

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
as_get_datetime_object() returns an ActionScheduler_DateTime (a DateTime
subclass), so a third-party schedule that stores that idiomatic helper's result
as a property nests one rather than a plain DateTime, and was rejected because
only DateTime itself was on the default nested allow-list. It is a trivial
subclass with no added magic methods, so it is safe to trust.

Refs #1318

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the one attack shape the existing nested-gadget test did not: the outer
object is a valid third-party schedule (a placeholder in the safe parse, not a
core schedule), so the walk must recurse into that placeholder to find the nested
gadget. Asserts the blob is rejected and the gadget's __wakeup() never runs.

Refs #1318

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@barryhughes
barryhughes requested review from a team and vbelolapotkov and removed request for a team July 11, 2026 00:04
Comment thread classes/data-stores/ActionScheduler_wpPostStore.php
barryhughes and others added 2 commits July 10, 2026 17:27
A tampered blob can keep a trusted schedule class as the outer object but
replace a scalar property (e.g. scheduled_timestamp) with an object; the
schedule's __wakeup() then feeds it to a DateTime constructor and throws a
TypeError. That is an Error, so @ does not suppress it and the stores'
catch (Exception) guards do not catch it -- one poisoned row could fatal a read
(500 the admin list, stall a queue run).

Wrap the two-pass deserialization in a catch (Throwable) that returns false,
routing such failures into the existing corrupt-data path (null/cancelled).
Not a regression (trunk ran the same __wakeup on the same bytes), but squarely
within this change's goal of handling corrupt schedule data gracefully.

Refs #1318

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@kalessil kalessil 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.

LGTM overall. Leaving some feedback in the comments below.

Comment thread classes/data-stores/ActionScheduler_wpPostStore.php
Comment thread classes/ActionScheduler_ScheduleDeserializer.php
* @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 );

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.

There is potential to use the singleton pattern here - better for performance.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Here also replying to this earlier comment, I'm a little reluctant to implement these ideas (but may be overthinking it, so may as well talk it out) because of scenarios like this:

foreach ( $blog_ids as $blog_id ) {
	switch_to_blog( $blog_id );

	if ( ! as_next_scheduled_action( 'custom_job' ) ) {
		warn_job_not_scheduled();
	}
}

Obviously the above snippet is a bit contrived, but I just mean to illustrate that it will (potentially) lead to schedule deserialization taking place multiple times within the same request, but across different and changing contexts (for which the range of acceptable/unacceptable classes may also change).

For example, instances of Plugin_A_Schedule_Data may be allowed on the first iteration, when we are inside Blog 1, but not on the next when we are inside Blog 2 and Plugin A is not even active. And/or, there may be plugin classes that are acceptable within Blog 2 but which were unknown when this first runs, within Blog 1.

This probably isn't very common, and I can think of supporting mechanics to work around it, but am basically feeling a little wary.

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.

Given your prior work on A-S, I'd trust your gut feeling.

"Just" drop in a comment there clarifying that the new instance is by design to accommodate context switching (as multisite and more) - so future people have this context and don't challenge this decision because of a lack of context.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

That's a good call. There is an existing comment alluding to this, specifically in the sense of multisite network context changes (in the docblock for the action_scheduler_allowed_nested_schedule_classes hook), but I think it can be promoted to class level and made clearer.

I'd trust your gut feeling.

I'm definitely up for this work being challenged. It's a tricky line we're having to follow between full-on correctness and keeping existing sites working.

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.

Weighing out complexity vs. maintainability aspects, $deserializer = new self( self::KNOWN_SCHEDULE_CLASSES, $allowed_nested ); is a clear winner.

What I read from this thread is that my proposal is actually low ROI: micro-optimization with potential risk to multi-site (and likely more hidden scenarios). Let's ignore this one.

* @return bool
*/
private function is_schedule_implementation( string $class_name ): bool {
if ( '' === $class_name || ! class_exists( $class_name ) ) {

@kalessil kalessil Jul 15, 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.

Nit: I'd add a second parameter as false:

  • Line ~328 — is_schedule_implementation(): class_exists( $class_name ) - Line ~332 — is_schedule_implementation(): class_implements( $class_name )
  • Line ~359 — is_allowed_nested_class(): class_exists( $class_name )
  • Line ~360 — is_allowed_nested_class(): class_implements( $class_name )

Effectively, it's sealing the auto-loading attack channel once and for all (I know we have a prior array check, but extra hardening, if feasible, will not hurt).

This requires constructor patching to keep the hierarchy investigation working (it'll load the class definition):

$this->allowed_nested_classes = array_filter( $allowed_nested_classes, 'class_exists' );

One thing to investigate, though, is when a custom class is not directly extending A-S classes (adds multiple inheritance layers).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Re multiple inheritance layers, I think this is fine:

class Baz implements ActionScheduler_Schedule {
	public function next( ?DateTime $after = null ) {}
	public function is_recurring() {}
}

class Bar extends Baz {}
class Foo extends Bar {}

var_dump( class_implements( Foo::class ) );
# array(1) {
# 	["ActionScheduler_Schedule"]=>
#   string(24) "ActionScheduler_Schedule"
# }

(...but let me know if I've misunderstood.)

@barryhughes barryhughes Jul 15, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I'd add a second parameter as false:

The thing is that the current mechanism relies on this being true.

This does indeed mean there is potential for autoloader side-effects (ie, when a file contains both a class definition and other top-level code, then the top-level code will be executed), but that's one of the trade-offs we're reckoning with here.

If false, then we will fail to load legitimate (third party) schedule implementations that rely on an autoloader for discovery.

I'm not closed to the idea, but it increases the risk of breakages (in the sense that up-until-now perfectly valid scheduled actions would risk being cancelled) in production.

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.

I see, let's keep as it is. We can take this risk if security reports point to this attack vector again after this solution is merged. We'll have some options then if this scenario materializes.

@barryhughes

Copy link
Copy Markdown
Member Author

✍🏼 This was discussed elsewhere, but it's possibly worth dropping a note in here, too, to make some of the background thinking a little clearer.

An obvious alternative or supplement to this approach would be to adopt JSON-based (de)serialization of scheduling information. The problem with that is that, unless we also continue to write serialized blobs in their current form and current location — and we could, but it raises bigger performance questions that the current approach alone — then a version rollback would potentially render a large number of scheduled actions invalid (because older versions could not read that information).

That's a particular concern for this library, because multiple plugins may ship different (and sometimes quite old) versions of Action Scheduler, so a rollback can happen indirectly simply by a plugin that is no longer required being deactivated.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Insecure Deserialization

3 participants