Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/changelog/fix-outbox-add-infinite-recursion
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: fixed

Fix an infinite loop when saving activities to the outbox on sites where the outbox post type passes through content filters.
11 changes: 11 additions & 0 deletions includes/collection/class-outbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ public static function add( Activity $activity, $user_id, $visibility = ACTIVITY
\kses_remove_filters();
}

// Prevent infinite recursion: wp_insert_post fires wp_after_insert_post,
// which would re-enter Post::triage() -> add_to_outbox() -> Outbox::add().
$has_triage = false !== \has_action( 'wp_after_insert_post', array( Scheduler\Post::class, 'triage' ) );
if ( $has_triage ) {
\remove_action( 'wp_after_insert_post', array( Scheduler\Post::class, 'triage' ), 33 );
}
Comment on lines +129 to +134
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

$has_triage is derived from has_action(), but the removal is hard-coded to priority 33. If Post::triage is attached at a different priority (or multiple priorities), this will leave it hooked and the recursion/OOM can still occur; additionally, the re-add always forces priority 33 even if it was different before. Consider capturing the priority returned by has_action() and removing/restoring at that priority (and/or removing all priorities where the callback is registered).

Copilot uses AI. Check for mistakes.

$id = \wp_insert_post( $outbox_item, true );

// Update the activity ID if the post was inserted successfully.
Expand All @@ -140,6 +147,10 @@ public static function add( Activity $activity, $user_id, $visibility = ACTIVITY
);
}

if ( $has_triage ) {
\add_action( 'wp_after_insert_post', array( Scheduler\Post::class, 'triage' ), 33, 4 );
}

if ( $has_kses ) {
\kses_init_filters();
}
Expand Down
64 changes: 64 additions & 0 deletions tests/phpunit/tests/includes/collection/class-test-outbox.php
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,70 @@ public function undo_object_provider() {
);
}

/**
* Test that Outbox::add() does not cause infinite recursion via Post::triage().
*
* When wp_insert_post() fires inside Outbox::add(), it triggers wp_after_insert_post,
* which could re-enter Post::triage() → add_to_outbox() → Outbox::add() in an infinite
* loop if the triage hook is not temporarily removed.
*
* @covers ::add
*/
public function test_add_does_not_recurse_via_post_triage() {
// Ensure the triage hook is registered as a baseline.
\add_action( 'wp_after_insert_post', array( \Activitypub\Scheduler\Post::class, 'triage' ), 33, 4 );

$triage_hooked_during_insert = null;

// Check whether Post::triage is hooked when wp_after_insert_post fires
// during the outbox insert.
\add_action(
'wp_after_insert_post',
function ( $post_id ) use ( &$triage_hooked_during_insert ) {
if ( Outbox::POST_TYPE === \get_post_type( $post_id ) ) {
$triage_hooked_during_insert = \has_action(
'wp_after_insert_post',
array( \Activitypub\Scheduler\Post::class, 'triage' )
);
}
},
0 // Run before priority 33 to inspect hook state.
);

$object = new Base_Object();
$object->set_id( 'https://example.com/recursion-test' );
$object->set_type( 'Note' );
$object->set_content( '<p>Recursion test</p>' );

$id = \Activitypub\add_to_outbox( $object, 'Create', self::$user_id );

$this->assertIsInt( $id );
$this->assertFalse( $triage_hooked_during_insert, 'Post::triage should be unhooked during Outbox::add() to prevent recursion.' );
Comment on lines +446 to +470
Copy link

Copilot AI Apr 3, 2026

Choose a reason for hiding this comment

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

This test adds an anonymous wp_after_insert_post action and never removes it. Because the closure captures $triage_hooked_during_insert by reference, it will persist beyond this test and can leak state / affect later tests that insert outbox posts. Store the closure in a variable and remove_action() it at the end of the test (or in tear_down()).

Suggested change
// Check whether Post::triage is hooked when wp_after_insert_post fires
// during the outbox insert.
\add_action(
'wp_after_insert_post',
function ( $post_id ) use ( &$triage_hooked_during_insert ) {
if ( Outbox::POST_TYPE === \get_post_type( $post_id ) ) {
$triage_hooked_during_insert = \has_action(
'wp_after_insert_post',
array( \Activitypub\Scheduler\Post::class, 'triage' )
);
}
},
0 // Run before priority 33 to inspect hook state.
);
$object = new Base_Object();
$object->set_id( 'https://example.com/recursion-test' );
$object->set_type( 'Note' );
$object->set_content( '<p>Recursion test</p>' );
$id = \Activitypub\add_to_outbox( $object, 'Create', self::$user_id );
$this->assertIsInt( $id );
$this->assertFalse( $triage_hooked_during_insert, 'Post::triage should be unhooked during Outbox::add() to prevent recursion.' );
$inspect_triage_hook = function ( $post_id ) use ( &$triage_hooked_during_insert ) {
if ( Outbox::POST_TYPE === \get_post_type( $post_id ) ) {
$triage_hooked_during_insert = \has_action(
'wp_after_insert_post',
array( \Activitypub\Scheduler\Post::class, 'triage' )
);
}
};
// Check whether Post::triage is hooked when wp_after_insert_post fires
// during the outbox insert.
\add_action(
'wp_after_insert_post',
$inspect_triage_hook,
0 // Run before priority 33 to inspect hook state.
);
try {
$object = new Base_Object();
$object->set_id( 'https://example.com/recursion-test' );
$object->set_type( 'Note' );
$object->set_content( '<p>Recursion test</p>' );
$id = \Activitypub\add_to_outbox( $object, 'Create', self::$user_id );
$this->assertIsInt( $id );
$this->assertFalse( $triage_hooked_during_insert, 'Post::triage should be unhooked during Outbox::add() to prevent recursion.' );
} finally {
\remove_action( 'wp_after_insert_post', $inspect_triage_hook, 0 );
}

Copilot uses AI. Check for mistakes.
}

/**
* Test that Outbox::add() restores the Post::triage hook after inserting.
*
* @covers ::add
*/
public function test_add_restores_triage_hook() {
// Ensure the triage hook is registered as a baseline.
\add_action( 'wp_after_insert_post', array( \Activitypub\Scheduler\Post::class, 'triage' ), 33, 4 );

$object = new Base_Object();
$object->set_id( 'https://example.com/restore-hook-test' );
$object->set_type( 'Note' );
$object->set_content( '<p>Hook restore test</p>' );

\Activitypub\add_to_outbox( $object, 'Create', self::$user_id );

// After add_to_outbox completes, the triage hook should be restored.
$this->assertNotFalse(
\has_action( 'wp_after_insert_post', array( \Activitypub\Scheduler\Post::class, 'triage' ) ),
'Post::triage hook should be restored after Outbox::add() completes.'
);
}

/**
* Helper method to create a dummy activity object for testing.
*
Expand Down
Loading