Prevent pagination ending when a page is full of rejected events (ELEMENTSEC-2025-1636)

Fixes: https://github.com/element-hq/synapse/security/advisories/GHSA-6qf2-7x63-mm6v

Reviewed-on: https://github.com/element-hq/synapse-private/pull/117
This commit is contained in:
Olivier 'reivilibre
2026-05-05 17:12:11 +01:00
parent 0eefdbcb95
commit 2d48851438
3 changed files with 101 additions and 9 deletions
+6 -5
View File
@@ -566,7 +566,7 @@ class PaginationHandler:
(
events,
next_key,
_,
limited,
) = await self.store.paginate_room_events_by_topological_ordering(
room_id=room_id,
from_key=from_token.room_key,
@@ -645,7 +645,7 @@ class PaginationHandler:
(
events,
next_key,
_,
limited,
) = await self.store.paginate_room_events_by_topological_ordering(
room_id=room_id,
from_key=from_token.room_key,
@@ -668,11 +668,12 @@ class PaginationHandler:
next_token = from_token.copy_and_replace(StreamKeyType.ROOM, next_key)
# if no events are returned from pagination, that implies
# we have reached the end of the available events.
# if no events are returned from pagination (this page is empty)
# and there aren't any more pages (not limited),
# that implies we have reached the end of the available events.
# In that case we do not return end, to tell the client
# there is no need for further queries.
if not events:
if not limited and not events:
return GetMessagesResult(
messages_chunk=[],
bundled_aggregations={},
+10 -3
View File
@@ -2425,12 +2425,19 @@ class StreamWorkerStore(EventsWorkerStore, SQLBaseStore):
event_filter: If provided filters the events to those that match the filter.
Returns:
The results as a list of events, a token that points to the end of
the result set, and a boolean to indicate if there were more events
but we hit the limit. If no events are returned then the end of the
- The results as a list of events;
- a token that points to the end of the result set; and
- a boolean to indicate if there were more events
but we hit the limit (`limited`)
If no events are returned and `limited` is false, then the end of the
stream has been reached (i.e. there are no events between `from_key`
and `to_key`).
When `limited` is true, that means that more pagination can be attempted.
Note that `limited` can be true even if no events are returned,
because rejected events are filtered out after the limit check.
When Direction.FORWARDS: from_key < x <= to_key, (ascending order)
When Direction.BACKWARDS: from_key >= x > to_key, (descending order)
"""
+85 -1
View File
@@ -66,7 +66,10 @@ from synapse.util.stringutils import random_string
from tests import unittest
from tests.http.server._base import make_request_with_cancellation_test
from tests.storage.test_stream import PaginationTestCase
from tests.test_utils.event_injection import create_event
from tests.test_utils.event_injection import (
create_event,
inject_event,
)
from tests.unittest import override_config
from tests.utils import default_config
@@ -2371,6 +2374,87 @@ class RoomMessageListTestCase(RoomBase):
channel.json_body["errcode"], Codes.NOT_JSON, channel.json_body
)
def test_room_messages_paginate_through_rejected_events(
self,
) -> None:
"""Test that pagination continues past a batch of rejected events.
Regression test for https://github.com/element-hq/synapse/security/advisories/GHSA-6qf2-7x63-mm6v
Synapse before 1.152.1 had a bug meaning that a batch full of only
rejected events would cause `/messages` to not return any more
pagination tokens, falsely signalling the end of backpagination.
"""
# Send an early message that should not be filtered.
early_event_id = self.helper.send(self.room_id, "early message")["event_id"]
# Inject a batch of events and mark them as rejected in the database.
# We create more events than a single pagination request would fetch,
# so that one page of backward pagination request would only see rejected events.
for _ in range(3):
event = self.get_success(
inject_event(
self.hs,
room_id=self.room_id,
sender=self.user_id,
type=EventTypes.Message,
content={"body": "filtered event", "msgtype": "m.text"},
)
)
self.get_success(
self.hs.get_datastores().main.db_pool.runInteraction(
"mark_rejected",
self.hs.get_datastores().main.mark_event_rejected_txn,
event.event_id,
"testing",
)
)
# Send a message after all the rejected events.
latest_event_id = self.helper.send(self.room_id, "latest message")["event_id"]
# Start backpaginating.
channel = self.make_request(
"GET", f"/rooms/{self.room_id}/messages?dir=b&limit=2"
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
events_in_page = [e["event_id"] for e in channel.json_body["chunk"]]
end_token: str | None = channel.json_body["end"]
self.assertEqual(
events_in_page,
[latest_event_id],
"The latest event should be included in the first page we see whilst backpaginating",
)
event_ids_in_pages: list[list[str]] = [events_in_page]
# Bound the number of backpagination attempts to 2
for _ in range(2):
channel = self.make_request(
"GET", f"/rooms/{self.room_id}/messages?from={end_token}&dir=b&limit=2"
)
self.assertEqual(channel.code, HTTPStatus.OK, channel.json_body)
events_in_page = [e["event_id"] for e in channel.json_body["chunk"]]
event_ids_in_pages.append(events_in_page)
if early_event_id in events_in_page:
# We have found the event we were looking for
return
self.assertIn(
"end",
channel.json_body,
f"No `end` token received. Did not find {early_event_id} whilst backpaginating ({latest_event_id = }, {event_ids_in_pages = })",
)
# Use the end_token in the next iteration
end_token = channel.json_body["end"]
self.fail(
f"Exhausted backpagination attempts. Did not find {early_event_id} whilst backpaginating ({latest_event_id = }, {event_ids_in_pages = })"
)
class RoomMessageFilterTestCase(RoomBase):
"""Tests /rooms/$room_id/messages REST events."""