Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7c695c3
Sync: Sanitize remote CRDT changes with DOMPurify to prevent XSS
maxschmeling Apr 9, 2026
5b2604b
Sync: Only sanitize remote changes from untrusted collaborators
maxschmeling Apr 13, 2026
4d87b10
Sync: Track contributors on every authenticated poll, not just updates
maxschmeling Apr 13, 2026
525e3ed
Sync: Use add_post_meta for race-safe contributor tracking
maxschmeling Apr 13, 2026
b311342
Sync: Deduplicate contributor rows before writing
maxschmeling Apr 13, 2026
3c0517d
Revert "Sync: Deduplicate contributor rows before writing"
maxschmeling Apr 13, 2026
517aa69
Sync: Skip contributor insert when user is already tracked
maxschmeling Apr 13, 2026
e4f36cc
Remove duplicate onTrustChange keys in tests
alecgeatches Apr 14, 2026
7f9d79c
Merge branch 'trunk' into sync/sanitize-remote-changes-xss
alecgeatches Apr 15, 2026
ccc491d
Fix HttpPollingEvents type errors
alecgeatches Apr 15, 2026
4ba07d9
Add onTrustChange to tests, remove unused functions
alecgeatches Apr 15, 2026
37a2a21
Switch from DOMPurify to built-in safeHTML
alecgeatches Apr 15, 2026
dfce851
Change `trustworthy` flag to `permissions.unfiltered_html`
alecgeatches Apr 15, 2026
2855189
Add guard around new clear_contributors() method
alecgeatches Apr 15, 2026
1a38799
Add TS reference to @wordpress/dom to fix build
alecgeatches Apr 15, 2026
e459ac9
Fix PHPCS violations
alecgeatches Apr 15, 2026
28f8815
Use DOMPurify based on wp_kses options
alecgeatches Apr 16, 2026
f095565
More explicitly ensure that unfilteredHtml permissions are false unle…
alecgeatches Apr 16, 2026
ec9334f
Remove inconsistent method_exists guards
alecgeatches Apr 16, 2026
d54d55f
Re-add temporary method guards until changes land in core
alecgeatches Apr 16, 2026
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
49 changes: 48 additions & 1 deletion lib/compat/wordpress-7.0/class-wp-http-polling-sync-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,14 @@ public function handle_request( WP_REST_Request $request ) {
$cursor = $room_request['after'];
$room = $room_request['room'];

// Track every authenticated access to this room, not just
// requests that carry document updates. Awareness-only polls
// still represent an active participant whose capabilities
// must be considered when computing shared room permissions.
if ( method_exists( $this->storage, 'track_contributor' ) ) {
$this->storage->track_contributor( $room, get_current_user_id() );
}

// Merge awareness state.
$merged_awareness = $this->process_awareness_update( $room, $client_id, $awareness );

Expand Down Expand Up @@ -569,9 +577,10 @@ private function add_update( string $room, int $client_id, string $type, string
* @param bool $is_compactor True if this client is nominated to perform compaction.
* @return array{
* end_cursor: int,
* should_compact: bool,
* room: string,
* should_compact: bool,
* total_updates: int,
* permissions: array{unfiltered_html: bool},
* updates: array<int, array{data: string, type: string}>,
* } Response data for this room.
*/
Expand All @@ -594,13 +603,51 @@ private function get_updates( string $room, int $client_id, int $cursor, bool $i

$should_compact = $is_compactor && $total_updates > self::COMPACTION_THRESHOLD;

// Collect permissions flags that describe whether ALL contributors
// in the room share a given RTC-related ability. The client uses
// these to decide things like whether to sanitize remote CRDT
// changes before writing them to the local entity store.
$contributors = method_exists( $this->storage, 'get_contributors' )
? $this->storage->get_contributors( $room )
: array();

$permissions = array(
'unfiltered_html' => $this->all_contributors_have_cap(
$contributors,
'unfiltered_html'
),
);

return array(
'end_cursor' => $this->storage->get_cursor( $room ),
'permissions' => $permissions,
'room' => $room,
'should_compact' => $should_compact,
'total_updates' => $total_updates,
'updates' => $typed_updates,
);
}

/**
* Determines whether every tracked contributor in a room holds a
* given capability.
*
* @param int[] $contributors WordPress user IDs tracked for the room.
* @param string $capability Capability name (e.g. 'unfiltered_html').
* @return bool True only when the list is non-empty and every user has the cap.
*/
private function all_contributors_have_cap( array $contributors, string $capability ): bool {
if ( empty( $contributors ) ) {
return false;
}

foreach ( $contributors as $contributor_id ) {
if ( ! user_can( $contributor_id, $capability ) ) {
return false;
}
}

return true;
}
}
}
74 changes: 74 additions & 0 deletions lib/compat/wordpress-7.0/class-wp-sync-post-meta-storage.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ class WP_Sync_Post_Meta_Storage implements WP_Sync_Storage {
*/
const SYNC_UPDATE_META_KEY = 'wp_sync_update_data';

/**
* Meta key for tracking contributor user IDs.
*
* @since 7.0.0
* @var string
*/
const CONTRIBUTORS_META_KEY = 'wp_sync_contributors';

/**
* Cache of cursors by room.
*
Expand Down Expand Up @@ -383,5 +391,71 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool

return true;
}

/**
* Records a user as a contributor who has submitted updates to a room.
*
* Skips the insert when the user is already tracked. A rare race
* between concurrent requests can produce at most one extra row
* per user, which get_contributors() handles via array_unique.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $user_id WordPress user ID.
* @return void
*/
public function track_contributor( string $room, int $user_id ): void {
$post_id = $this->get_storage_post_id( $room );
if ( null === $post_id ) {
return;
}

$contributors = get_post_meta( $post_id, self::CONTRIBUTORS_META_KEY, false );
if ( is_array( $contributors ) && in_array( (string) $user_id, $contributors, true ) ) {
return;
}

add_post_meta( $post_id, self::CONTRIBUTORS_META_KEY, $user_id );
}

/**
* Gets all contributor user IDs for a room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return int[] Array of unique WordPress user IDs.
*/
public function get_contributors( string $room ): array {
$post_id = $this->get_storage_post_id( $room );
if ( null === $post_id ) {
return array();
}

$values = get_post_meta( $post_id, self::CONTRIBUTORS_META_KEY, false );
if ( ! is_array( $values ) || 0 === count( $values ) ) {
return array();
}

return array_values( array_unique( array_map( 'intval', $values ) ) );
}

/**
* Clears the contributor list for a room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return void
*/
public function clear_contributors( string $room ): void {
$post_id = $this->get_storage_post_id( $room );
if ( null === $post_id ) {
return;
}

delete_post_meta( $post_id, self::CONTRIBUTORS_META_KEY );
}
}
}
32 changes: 31 additions & 1 deletion lib/compat/wordpress-7.0/collaboration.php
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,8 @@ function gutenberg_inject_real_time_collaboration_setting() {

wp_add_inline_script(
'wp-core-data',
'window._wpCollaborationEnabled = ' . wp_json_encode( $enabled ) . ';',
'window._wpCollaborationEnabled = ' . wp_json_encode( $enabled ) . ';' .
'window._wpCollaborationKsesHtml = ' . wp_json_encode( wp_kses_allowed_html( 'post' ) ) . ';',
Comment on lines +224 to +225
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.

Maybe:

$config = [
   enabled: $enabled,
   kses: $enabled ? wp_kses_allowed_html( 'post' ) : []
];


window._wpCollaboration = wp_json_encode( $config );

It might be helpful to keep window._wpCollaborationEnabled for a bit while wp-dev and gb get synced up with intent to remove.

'after'
);
}
Expand All @@ -239,6 +240,35 @@ function gutenberg_set_collaboration_option_on_activation() {
}
add_action( 'activate_gutenberg/gutenberg.php', 'gutenberg_set_collaboration_option_on_activation' );

if ( ! function_exists( 'gutenberg_clear_sync_contributors_on_save' ) ) {
/**
* Clears the sync contributor tracking list when a post is saved.
*
* After a save, the content in the database is authoritative. The contributor
* list is reset so that the shared-permissions check starts fresh for
* subsequent collaborative edits.
*
* @param int $post_id Post ID.
* @param WP_Post $post Post object.
*/
function gutenberg_clear_sync_contributors_on_save( $post_id, $post ) {
if ( WP_Sync_Post_Meta_Storage::POST_TYPE === $post->post_type ) {
return;
}

if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
return;
}

$room = 'postType/' . $post->post_type . ':' . $post_id;
$storage = new WP_Sync_Post_Meta_Storage();
if ( method_exists( $storage, 'clear_contributors' ) ) {
$storage->clear_contributors( $room );
}
}
add_action( 'save_post', 'gutenberg_clear_sync_contributors_on_save', 10, 2 );
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.

Are there any other actions we might want to clear out contributors for? Maybe on wp_delete_post() we'd want to check for and clean up contributor meta? Not immediately important, though.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Actually not sure on delete. I would think we might want to just leave the standard WP behavior there.

}

/**
* Modifies the post list UI and heartbeat responses for real-time collaboration.
*
Expand Down
31 changes: 31 additions & 0 deletions lib/compat/wordpress-7.0/interface-wp-sync-storage.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,5 +85,36 @@ public function remove_updates_before_cursor( string $room, int $cursor ): bool;
* @return bool True on success, false on failure.
*/
public function set_awareness_state( string $room, array $awareness ): bool;

/**
* Records a user as a contributor who has submitted updates to a room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @param int $user_id WordPress user ID.
* @return void
*/
public function track_contributor( string $room, int $user_id ): void;

/**
* Gets all contributor user IDs for a room.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return int[] Array of WordPress user IDs.
*/
public function get_contributors( string $room ): array;

/**
* Clears the contributor list for a room, typically called on save.
*
* @since 7.0.0
*
* @param string $room Room identifier.
* @return void
*/
public function clear_contributors( string $room ): void;
}
}
17 changes: 17 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/sync/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"@wordpress/private-apis": "file:../private-apis",
"@wordpress/undo-manager": "file:../undo-manager",
"diff": "^8.0.3",
"dompurify": "^3.3.3",
"fast-deep-equal": "^3.1.3",
"lib0": "0.2.99",
"y-protocols": "^1.0.7",
Expand Down
19 changes: 17 additions & 2 deletions packages/sync/src/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,15 @@ import {
yieldToEventLoop,
} from './performance';
import { getProviderCreators } from './providers';
import { sanitizeRemoteChanges } from './sanitize';
import type {
CollectionHandlers,
CRDTDoc,
EntityID,
ObjectID,
ObjectData,
ObjectType,
Permissions,
ProviderCreator,
RecordHandlers,
SyncConfig,
Expand Down Expand Up @@ -55,6 +57,7 @@ interface EntityState {
handlers: RecordHandlers;
objectId: ObjectID;
objectType: ObjectType;
permissions: Permissions;
syncConfig: SyncConfig;
unload: () => void;
ydoc: CRDTDoc;
Expand Down Expand Up @@ -261,6 +264,7 @@ export function createSyncManager( debug = false ): SyncManager {
handlers,
objectId,
objectType,
permissions: { unfilteredHtml: false },
syncConfig,
unload,
ydoc,
Expand All @@ -282,6 +286,11 @@ export function createSyncManager( debug = false ): SyncManager {
// Attach status listener after provider creation.
provider.on( 'status', handlers.onStatusChange );

// Listen for shared-contributor permission changes.
provider.on( 'permissions', ( permissions ) => {
entityState.permissions = permissions;
} );

return provider;
} )
);
Expand Down Expand Up @@ -616,7 +625,7 @@ export function createSyncManager( debug = false ): SyncManager {
return;
}

const { handlers, syncConfig, ydoc } = entityState;
const { handlers, permissions, syncConfig, ydoc } = entityState;

// Determine which synced properties have actually changed by comparing
// them against the current edited entity record.
Expand All @@ -631,10 +640,16 @@ export function createSyncManager( debug = false ): SyncManager {
return;
}

// Sanitize remote content when any contributor can not sync unfiltered HTML.
const effectiveChanges = permissions.unfilteredHtml
? changes
: sanitizeRemoteChanges( changes );

log( 'updateEntityRecord', 'changes', entityId, {
changedKeys,
permissions,
} );
handlers.editRecord( changes );
handlers.editRecord( effectiveChanges );
}

/**
Expand Down
Loading
Loading