Skip to content

Commit 5f92596

Browse files
authored
Federate comments only when the parent post is federated (#3374)
1 parent fd7825b commit 5f92596

6 files changed

Lines changed: 207 additions & 4 deletions

File tree

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: minor
2+
Type: security
3+
4+
Only share comment replies in the Fediverse when the post they belong to is itself federated, so replies on private or non-federated posts stay private.

includes/class-comment.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,17 @@ public static function should_be_federated( $comment ) {
299299
return false;
300300
}
301301

302+
/*
303+
* Do not federate brand-new comments on a post that is not federated itself
304+
* (e.g. a private post, a post switched to local visibility, or a non-ActivityPub
305+
* post type). This prevents leaking replies on content the post type's read rules
306+
* would otherwise protect. Comments that were already sent are allowed through so
307+
* their Update and Delete activities can still federate (and tear down remote copies).
308+
*/
309+
if ( ! self::was_sent( $comment ) && ! is_post_federated( $comment->comment_post_ID ) ) {
310+
return false;
311+
}
312+
302313
// It is a comment to the post and can be federated.
303314
if ( empty( $comment->comment_parent ) ) {
304315
return true;

includes/functions-post.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,42 @@ function is_post_publicly_queryable( $post ) {
165165
return \apply_filters( 'activitypub_is_post_publicly_queryable', $queryable, $post );
166166
}
167167

168+
/**
169+
* Check whether a post is federated.
170+
*
171+
* A post is federated when it has been sent to the Fediverse (its federation
172+
* state is "federated") AND it is still publicly queryable, i.e. its post type
173+
* is enabled for ActivityPub, it has a public status, it is not password-
174+
* protected, and its content visibility allows it (see `is_post_publicly_queryable()`).
175+
*
176+
* Re-checking the live queryability alongside the stored state guards against a
177+
* stale "federated" status left behind when a post is moved to a private status,
178+
* switched to local visibility, or its post type loses ActivityPub support.
179+
*
180+
* The federation-state check also keeps `is_post_publicly_queryable()`'s
181+
* preview allowance inert here: a draft/pending post is never in the federated
182+
* state, so the preview branch can never make this return true.
183+
*
184+
* @since unreleased
185+
*
186+
* @param mixed $post The post ID or object.
187+
*
188+
* @return boolean True if the post is federated, false otherwise.
189+
*/
190+
function is_post_federated( $post ) {
191+
if ( empty( $post ) ) {
192+
return false;
193+
}
194+
195+
$post = \get_post( $post );
196+
197+
if ( ! $post ) {
198+
return false;
199+
}
200+
201+
return ACTIVITYPUB_OBJECT_STATE_FEDERATED === get_wp_object_state( $post ) && is_post_publicly_queryable( $post );
202+
}
203+
168204
/**
169205
* Check if a post is an ActivityPub post.
170206
*

tests/phpunit/tests/includes/class-test-comment.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,90 @@ public function test_check_ability_to_federate_threaded_comment( $parent_comment
117117
$this->assertEquals( $expected['should_be_federated'], Comment::should_be_federated( $comment ) );
118118
}
119119

120+
/**
121+
* Comments on a post that was never federated must not be federated.
122+
*
123+
* Regression: an approved reply by an ActivityPub-enabled user on a private or
124+
* non-ActivityPub post (one that was never federated itself) was sent to the public
125+
* outbox, leaking private content (e.g. a private Sensei LMS message reply).
126+
*
127+
* @covers ::should_be_federated
128+
*/
129+
public function test_does_not_federate_comment_on_non_federated_post() {
130+
// A private post that was never federated (no activitypub_status meta).
131+
$post_id = self::factory()->post->create(
132+
array(
133+
'post_status' => 'private',
134+
'post_author' => 1,
135+
)
136+
);
137+
138+
$comment_id = self::factory()->comment->create(
139+
array(
140+
'comment_post_ID' => $post_id,
141+
'user_id' => 1,
142+
'comment_approved' => '1',
143+
'comment_content' => 'SECRET private reply',
144+
)
145+
);
146+
147+
$this->assertFalse(
148+
Comment::should_be_federated( \get_comment( $comment_id ) ),
149+
'A comment on a post that was never federated must not be federated.'
150+
);
151+
}
152+
153+
/**
154+
* Comments on a federated post are federated.
155+
*
156+
* @covers ::should_be_federated
157+
*/
158+
public function test_federates_comment_on_federated_post() {
159+
$post_id = self::factory()->post->create( array( 'post_author' => 1 ) );
160+
\update_post_meta( $post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
161+
162+
$comment_id = self::factory()->comment->create(
163+
array(
164+
'comment_post_ID' => $post_id,
165+
'user_id' => 1,
166+
'comment_approved' => '1',
167+
'comment_content' => 'A public reply',
168+
)
169+
);
170+
171+
$this->assertTrue(
172+
Comment::should_be_federated( \get_comment( $comment_id ) ),
173+
'A comment on a federated post should be federated.'
174+
);
175+
}
176+
177+
/**
178+
* A comment that was already sent still federates even when its parent post
179+
* is no longer federated, so its Update and Delete activities can reach
180+
* remote copies.
181+
*
182+
* @covers ::should_be_federated
183+
*/
184+
public function test_federates_already_sent_comment_on_non_federated_post() {
185+
// A post that is not federated.
186+
$post_id = self::factory()->post->create( array( 'post_status' => 'private' ) );
187+
188+
// A comment that was already sent to the Fediverse.
189+
$comment_id = self::factory()->comment->create(
190+
array(
191+
'comment_post_ID' => $post_id,
192+
'user_id' => 1,
193+
'comment_approved' => '1',
194+
'comment_meta' => array( 'activitypub_status' => ACTIVITYPUB_OBJECT_STATE_FEDERATED ),
195+
)
196+
);
197+
198+
$this->assertTrue(
199+
Comment::should_be_federated( \get_comment( $comment_id ) ),
200+
'An already-sent comment must still federate even when its parent post is not federated.'
201+
);
202+
}
203+
120204
/**
121205
* Test pre_comment_approved.
122206
*

tests/phpunit/tests/includes/class-test-functions-post.php

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,68 @@
1212
*/
1313
class Test_Functions_Post extends \WP_UnitTestCase {
1414

15+
/**
16+
* Test is_post_federated function.
17+
*
18+
* A post counts as federated only when its federation state is "federated"
19+
* AND it currently still meets the public-queryability criteria (post type
20+
* enabled, public status, allowed visibility, no password).
21+
*
22+
* @covers \Activitypub\is_post_federated
23+
*/
24+
public function test_is_post_federated() {
25+
// Federated and still publicly queryable.
26+
$federated_post_id = self::factory()->post->create();
27+
\update_post_meta( $federated_post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
28+
$this->assertTrue( \Activitypub\is_post_federated( $federated_post_id ), 'A federated, publicly queryable post is federated.' );
29+
30+
// Never federated (no status), even though it is publicly queryable.
31+
$never_post_id = self::factory()->post->create();
32+
$this->assertFalse( \Activitypub\is_post_federated( $never_post_id ), 'A post that was never federated is not federated.' );
33+
34+
// Federated but soft-deleted (status no longer "federated").
35+
$deleted_post_id = self::factory()->post->create();
36+
\update_post_meta( $deleted_post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_DELETED );
37+
$this->assertFalse( \Activitypub\is_post_federated( $deleted_post_id ), 'A soft-deleted post is not federated.' );
38+
39+
// Federated status, but visibility later switched to local — must not count as federated.
40+
$local_post_id = self::factory()->post->create();
41+
\update_post_meta( $local_post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
42+
\update_post_meta( $local_post_id, 'activitypub_content_visibility', ACTIVITYPUB_CONTENT_VISIBILITY_LOCAL );
43+
$this->assertFalse( \Activitypub\is_post_federated( $local_post_id ), 'A federated post switched to local visibility is no longer federated.' );
44+
45+
// Federated status, but post moved to a private status — must not count as federated.
46+
$private_post_id = self::factory()->post->create( array( 'post_status' => 'private' ) );
47+
\update_post_meta( $private_post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
48+
$this->assertFalse( \Activitypub\is_post_federated( $private_post_id ), 'A federated post moved to private status is no longer federated.' );
49+
50+
// Federated status, but password-protected — must not count as federated.
51+
$password_post_id = self::factory()->post->create( array( 'post_password' => 'secret' ) );
52+
\update_post_meta( $password_post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
53+
$this->assertFalse( \Activitypub\is_post_federated( $password_post_id ), 'A federated but password-protected post is not federated.' );
54+
55+
// Federated status, but post type no longer supports ActivityPub.
56+
\register_post_type( 'unsupported', array() );
57+
$unsupported_post_id = self::factory()->post->create( array( 'post_type' => 'unsupported' ) );
58+
\update_post_meta( $unsupported_post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
59+
$this->assertFalse( \Activitypub\is_post_federated( $unsupported_post_id ), 'A federated post on an unsupported post type is not federated.' );
60+
\unregister_post_type( 'unsupported' );
61+
62+
// An integration can veto via the public-queryability filter.
63+
$filtered_post_id = self::factory()->post->create();
64+
\update_post_meta( $filtered_post_id, 'activitypub_status', ACTIVITYPUB_OBJECT_STATE_FEDERATED );
65+
$this->assertTrue( \Activitypub\is_post_federated( $filtered_post_id ), 'Federated post is federated before filtering.' );
66+
\add_filter( 'activitypub_is_post_publicly_queryable', '__return_false' );
67+
try {
68+
$this->assertFalse( \Activitypub\is_post_federated( $filtered_post_id ), 'An integration filtering the post non-queryable makes it not federated.' );
69+
} finally {
70+
\remove_filter( 'activitypub_is_post_publicly_queryable', '__return_false' );
71+
}
72+
73+
// Invalid input.
74+
$this->assertFalse( \Activitypub\is_post_federated( 0 ), 'An empty post is not federated.' );
75+
}
76+
1577
/**
1678
* Test is_post_disabled function.
1779
*

tests/phpunit/tests/includes/scheduler/class-test-comment.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,23 @@ class Test_Comment extends \Activitypub\Tests\ActivityPub_Outbox_TestCase {
2020
/**
2121
* Post ID for testing.
2222
*
23+
* Initialized to 0 so data providers (which run before set_up()) emit a
24+
* non-null value and the isset() substitution in test_no_activity_scheduled()
25+
* picks up the real post ID assigned in set_up().
26+
*
2327
* @var int
2428
*/
25-
protected static $comment_post_ID;
29+
protected static $comment_post_ID = 0;
2630

2731
/**
28-
* Set up test resources.
32+
* Set up each test.
2933
*/
30-
public static function set_up_before_class() {
31-
parent::set_up_before_class();
34+
public function set_up() {
35+
parent::set_up();
3236

37+
// Comments only federate on a post that was itself federated.
3338
self::$comment_post_ID = self::factory()->post->create( array( 'post_author' => self::$user_id ) );
39+
\update_post_meta( self::$comment_post_ID, 'activitypub_status', \ACTIVITYPUB_OBJECT_STATE_FEDERATED );
3440
}
3541

3642
/**

0 commit comments

Comments
 (0)