Skip to content
This repository was archived by the owner on Apr 26, 2024. It is now read-only.

Commit 63d28a8

Browse files
authored
Additional validation of receipts (#16327)
Reject invalid receipts with a reasonable error message & expands tests for receipts.
1 parent 4663d55 commit 63d28a8

File tree

6 files changed

+241
-165
lines changed

6 files changed

+241
-165
lines changed

changelog.d/16327.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix a long-standing bug where invalid receipts would be accepted.

synapse/handlers/receipts.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ def __init__(self, hs: "HomeServer"):
3737
self.server_name = hs.config.server.server_name
3838
self.store = hs.get_datastores().main
3939
self.event_auth_handler = hs.get_event_auth_handler()
40+
self.event_handler = hs.get_event_handler()
41+
self._storage_controllers = hs.get_storage_controllers()
4042

4143
self.hs = hs
4244

@@ -81,6 +83,20 @@ async def _received_remote_receipt(self, origin: str, content: JsonDict) -> None
8183
)
8284
continue
8385

86+
# Let's check that the origin server is in the room before accepting the receipt.
87+
# We don't want to block waiting on a partial state so take an
88+
# approximation if needed.
89+
domains = await self._storage_controllers.state.get_current_hosts_in_room_or_partial_state_approximation(
90+
room_id
91+
)
92+
if origin not in domains:
93+
logger.info(
94+
"Ignoring receipt for room %r from server %s as they're not in the room",
95+
room_id,
96+
origin,
97+
)
98+
continue
99+
84100
for receipt_type, users in room_values.items():
85101
for user_id, user_values in users.items():
86102
if get_domain_from_id(user_id) != origin:
@@ -158,17 +174,23 @@ async def received_client_receipt(
158174
self,
159175
room_id: str,
160176
receipt_type: str,
161-
user_id: str,
177+
user_id: UserID,
162178
event_id: str,
163179
thread_id: Optional[str],
164180
) -> None:
165181
"""Called when a client tells us a local user has read up to the given
166182
event_id in the room.
167183
"""
184+
185+
# Ensure the room/event exists, this will raise an error if the user
186+
# cannot view the event.
187+
if not await self.event_handler.get_event(user_id, room_id, event_id):
188+
return
189+
168190
receipt = ReadReceipt(
169191
room_id=room_id,
170192
receipt_type=receipt_type,
171-
user_id=user_id,
193+
user_id=user_id.to_string(),
172194
event_ids=[event_id],
173195
thread_id=thread_id,
174196
data={"ts": int(self.clock.time_msec())},

synapse/rest/client/read_marker.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ async def on_POST(
8484
await self.receipts_handler.received_client_receipt(
8585
room_id,
8686
receipt_type,
87-
user_id=requester.user.to_string(),
87+
user_id=requester.user,
8888
event_id=event_id,
8989
# Setting the thread ID is not possible with the /read_markers endpoint.
9090
thread_id=None,

synapse/rest/client/receipts.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ async def on_POST(
108108
await self.receipts_handler.received_client_receipt(
109109
room_id,
110110
receipt_type,
111-
user_id=requester.user.to_string(),
111+
user_id=requester.user,
112112
event_id=event_id,
113113
thread_id=thread_id,
114114
)

tests/rest/client/test_receipts.py

Lines changed: 213 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,16 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
from http import HTTPStatus
15+
from typing import Optional
16+
1417
from twisted.test.proto_helpers import MemoryReactor
1518

1619
import synapse.rest.admin
17-
from synapse.rest.client import login, receipts, register
20+
from synapse.api.constants import EduTypes, EventTypes, HistoryVisibility, ReceiptTypes
21+
from synapse.rest.client import login, receipts, room, sync
1822
from synapse.server import HomeServer
23+
from synapse.types import JsonDict
1924
from synapse.util import Clock
2025

2126
from tests import unittest
@@ -24,30 +29,113 @@
2429
class ReceiptsTestCase(unittest.HomeserverTestCase):
2530
servlets = [
2631
login.register_servlets,
27-
register.register_servlets,
2832
receipts.register_servlets,
2933
synapse.rest.admin.register_servlets,
34+
room.register_servlets,
35+
sync.register_servlets,
3036
]
3137

3238
def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
33-
self.owner = self.register_user("owner", "pass")
34-
self.owner_tok = self.login("owner", "pass")
39+
self.url = "/sync?since=%s"
40+
self.next_batch = "s0"
41+
42+
# Register the first user
43+
self.user_id = self.register_user("kermit", "monkey")
44+
self.tok = self.login("kermit", "monkey")
45+
46+
# Create the room
47+
self.room_id = self.helper.create_room_as(self.user_id, tok=self.tok)
48+
49+
# Register the second user
50+
self.user2 = self.register_user("kermit2", "monkey")
51+
self.tok2 = self.login("kermit2", "monkey")
52+
53+
# Join the second user
54+
self.helper.join(room=self.room_id, user=self.user2, tok=self.tok2)
3555

3656
def test_send_receipt(self) -> None:
57+
# Send a message.
58+
res = self.helper.send(self.room_id, body="hello", tok=self.tok)
59+
60+
# Send a read receipt
61+
channel = self.make_request(
62+
"POST",
63+
f"/rooms/{self.room_id}/receipt/{ReceiptTypes.READ}/{res['event_id']}",
64+
{},
65+
access_token=self.tok2,
66+
)
67+
self.assertEqual(channel.code, 200)
68+
self.assertNotEqual(self._get_read_receipt(), None)
69+
70+
def test_send_receipt_unknown_event(self) -> None:
71+
"""Receipts sent for unknown events are ignored to not break message retention."""
72+
# Attempt to send a receipt to an unknown room.
3773
channel = self.make_request(
3874
"POST",
3975
"/rooms/!abc:beep/receipt/m.read/$def",
4076
content={},
41-
access_token=self.owner_tok,
77+
access_token=self.tok2,
78+
)
79+
self.assertEqual(channel.code, 200, channel.result)
80+
self.assertIsNone(self._get_read_receipt())
81+
82+
# Attempt to send a receipt to an unknown event.
83+
channel = self.make_request(
84+
"POST",
85+
f"/rooms/{self.room_id}/receipt/m.read/$def",
86+
content={},
87+
access_token=self.tok2,
4288
)
4389
self.assertEqual(channel.code, 200, channel.result)
90+
self.assertIsNone(self._get_read_receipt())
91+
92+
def test_send_receipt_unviewable_event(self) -> None:
93+
"""Receipts sent for unviewable events are errors."""
94+
# Create a room where new users can't see events from before their join
95+
# & send events into it.
96+
room_id = self.helper.create_room_as(
97+
self.user_id,
98+
tok=self.tok,
99+
extra_content={
100+
"preset": "private_chat",
101+
"initial_state": [
102+
{
103+
"content": {"history_visibility": HistoryVisibility.JOINED},
104+
"state_key": "",
105+
"type": EventTypes.RoomHistoryVisibility,
106+
}
107+
],
108+
},
109+
)
110+
res = self.helper.send(room_id, body="hello", tok=self.tok)
111+
112+
# Attempt to send a receipt from the wrong user.
113+
channel = self.make_request(
114+
"POST",
115+
f"/rooms/{room_id}/receipt/{ReceiptTypes.READ}/{res['event_id']}",
116+
content={},
117+
access_token=self.tok2,
118+
)
119+
self.assertEqual(channel.code, 403, channel.result)
120+
121+
# Join the user to the room, but they still can't see the event.
122+
self.helper.invite(room_id, self.user_id, self.user2, tok=self.tok)
123+
self.helper.join(room=room_id, user=self.user2, tok=self.tok2)
124+
125+
channel = self.make_request(
126+
"POST",
127+
f"/rooms/{room_id}/receipt/{ReceiptTypes.READ}/{res['event_id']}",
128+
content={},
129+
access_token=self.tok2,
130+
)
131+
self.assertEqual(channel.code, 403, channel.result)
44132

45133
def test_send_receipt_invalid_room_id(self) -> None:
46134
channel = self.make_request(
47135
"POST",
48136
"/rooms/not-a-room-id/receipt/m.read/$def",
49137
content={},
50-
access_token=self.owner_tok,
138+
access_token=self.tok,
51139
)
52140
self.assertEqual(channel.code, 400, channel.result)
53141
self.assertEqual(
@@ -59,7 +147,7 @@ def test_send_receipt_invalid_event_id(self) -> None:
59147
"POST",
60148
"/rooms/!abc:beep/receipt/m.read/not-an-event-id",
61149
content={},
62-
access_token=self.owner_tok,
150+
access_token=self.tok,
63151
)
64152
self.assertEqual(channel.code, 400, channel.result)
65153
self.assertEqual(
@@ -71,6 +159,123 @@ def test_send_receipt_invalid_receipt_type(self) -> None:
71159
"POST",
72160
"/rooms/!abc:beep/receipt/invalid-receipt-type/$def",
73161
content={},
74-
access_token=self.owner_tok,
162+
access_token=self.tok,
75163
)
76164
self.assertEqual(channel.code, 400, channel.result)
165+
166+
def test_private_read_receipts(self) -> None:
167+
# Send a message as the first user
168+
res = self.helper.send(self.room_id, body="hello", tok=self.tok)
169+
170+
# Send a private read receipt to tell the server the first user's message was read
171+
channel = self.make_request(
172+
"POST",
173+
f"/rooms/{self.room_id}/receipt/{ReceiptTypes.READ_PRIVATE}/{res['event_id']}",
174+
{},
175+
access_token=self.tok2,
176+
)
177+
self.assertEqual(channel.code, 200)
178+
179+
# Test that the first user can't see the other user's private read receipt
180+
self.assertIsNone(self._get_read_receipt())
181+
182+
def test_public_receipt_can_override_private(self) -> None:
183+
"""
184+
Sending a public read receipt to the same event which has a private read
185+
receipt should cause that receipt to become public.
186+
"""
187+
# Send a message as the first user
188+
res = self.helper.send(self.room_id, body="hello", tok=self.tok)
189+
190+
# Send a private read receipt
191+
channel = self.make_request(
192+
"POST",
193+
f"/rooms/{self.room_id}/receipt/{ReceiptTypes.READ_PRIVATE}/{res['event_id']}",
194+
{},
195+
access_token=self.tok2,
196+
)
197+
self.assertEqual(channel.code, 200)
198+
self.assertIsNone(self._get_read_receipt())
199+
200+
# Send a public read receipt
201+
channel = self.make_request(
202+
"POST",
203+
f"/rooms/{self.room_id}/receipt/{ReceiptTypes.READ}/{res['event_id']}",
204+
{},
205+
access_token=self.tok2,
206+
)
207+
self.assertEqual(channel.code, 200)
208+
209+
# Test that we did override the private read receipt
210+
self.assertNotEqual(self._get_read_receipt(), None)
211+
212+
def test_private_receipt_cannot_override_public(self) -> None:
213+
"""
214+
Sending a private read receipt to the same event which has a public read
215+
receipt should cause no change.
216+
"""
217+
# Send a message as the first user
218+
res = self.helper.send(self.room_id, body="hello", tok=self.tok)
219+
220+
# Send a public read receipt
221+
channel = self.make_request(
222+
"POST",
223+
f"/rooms/{self.room_id}/receipt/{ReceiptTypes.READ}/{res['event_id']}",
224+
{},
225+
access_token=self.tok2,
226+
)
227+
self.assertEqual(channel.code, 200)
228+
self.assertNotEqual(self._get_read_receipt(), None)
229+
230+
# Send a private read receipt
231+
channel = self.make_request(
232+
"POST",
233+
f"/rooms/{self.room_id}/receipt/{ReceiptTypes.READ_PRIVATE}/{res['event_id']}",
234+
{},
235+
access_token=self.tok2,
236+
)
237+
self.assertEqual(channel.code, 200)
238+
239+
# Test that we didn't override the public read receipt
240+
self.assertIsNone(self._get_read_receipt())
241+
242+
def test_read_receipt_with_empty_body_is_rejected(self) -> None:
243+
# Send a message as the first user
244+
res = self.helper.send(self.room_id, body="hello", tok=self.tok)
245+
246+
# Send a read receipt for this message with an empty body
247+
channel = self.make_request(
248+
"POST",
249+
f"/rooms/{self.room_id}/receipt/m.read/{res['event_id']}",
250+
access_token=self.tok2,
251+
)
252+
self.assertEqual(channel.code, HTTPStatus.BAD_REQUEST)
253+
self.assertEqual(channel.json_body["errcode"], "M_NOT_JSON", channel.json_body)
254+
255+
def _get_read_receipt(self) -> Optional[JsonDict]:
256+
"""Syncs and returns the read receipt."""
257+
258+
# Checks if event is a read receipt
259+
def is_read_receipt(event: JsonDict) -> bool:
260+
return event["type"] == EduTypes.RECEIPT
261+
262+
# Sync
263+
channel = self.make_request(
264+
"GET",
265+
self.url % self.next_batch,
266+
access_token=self.tok,
267+
)
268+
self.assertEqual(channel.code, 200)
269+
270+
# Store the next batch for the next request.
271+
self.next_batch = channel.json_body["next_batch"]
272+
273+
if channel.json_body.get("rooms", None) is None:
274+
return None
275+
276+
# Return the read receipt
277+
ephemeral_events = channel.json_body["rooms"]["join"][self.room_id][
278+
"ephemeral"
279+
]["events"]
280+
receipt_event = filter(is_read_receipt, ephemeral_events)
281+
return next(receipt_event, None)

0 commit comments

Comments
 (0)