diff --git a/zerver/lib/message.py b/zerver/lib/message.py index 153dfe2761..cd2775272b 100644 --- a/zerver/lib/message.py +++ b/zerver/lib/message.py @@ -24,7 +24,7 @@ from zerver.lib.cache import ( to_dict_cache_key_id, ) from zerver.lib.display_recipient import bulk_fetch_display_recipients -from zerver.lib.exceptions import JsonableError +from zerver.lib.exceptions import JsonableError, MissingAuthenticationError from zerver.lib.markdown import MessageRenderingResult, markdown_convert, topic_links from zerver.lib.markdown import version as markdown_version from zerver.lib.mention import MentionData @@ -33,6 +33,7 @@ from zerver.lib.stream_subscription import ( get_subscribed_stream_recipient_ids_for_user, num_subscribers_for_stream_id, ) +from zerver.lib.streams import get_web_public_streams_queryset from zerver.lib.timestamp import datetime_to_timestamp from zerver.lib.topic import DB_TOPIC_NAME, MESSAGE__TOPIC, TOPIC_LINKS, TOPIC_NAME from zerver.lib.topic_mutes import build_topic_mute_checker, topic_is_muted @@ -709,6 +710,48 @@ def access_message( raise JsonableError(_("Invalid message(s)")) +def access_web_public_message( + realm: Realm, + message_id: int, +) -> Message: + """Access control method for unauthenticated requests interacting + with a message in web public streams. + """ + + # We throw a MissingAuthenticationError for all errors in this + # code path, to avoid potentially leaking information on whether a + # message with the provided ID exists on the server if the client + # shouldn't have access to it. + if not realm.web_public_streams_enabled(): + raise MissingAuthenticationError() + + try: + message = Message.objects.select_related().get(id=message_id) + except Message.DoesNotExist: + raise MissingAuthenticationError() + + if not message.is_stream_message(): + raise MissingAuthenticationError() + + queryset = get_web_public_streams_queryset(realm) + try: + stream = queryset.get(id=message.recipient.type_id) + except Stream.DoesNotExist: + raise MissingAuthenticationError() + + # These should all have been enforced by the code in + # get_web_public_streams_queryset + assert stream.is_web_public + assert not stream.deactivated + assert not stream.invite_only + assert stream.history_public_to_subscribers + + # Now that we've confirmed this message was sent to the target + # web-public stream, we can return it as having been successfully + # accessed. + return message + + def has_message_access( user_profile: UserProfile, message: Message, diff --git a/zerver/tests/test_message_edit.py b/zerver/tests/test_message_edit.py index fd27cfbb05..d8c4e371f0 100644 --- a/zerver/tests/test_message_edit.py +++ b/zerver/tests/test_message_edit.py @@ -9,8 +9,10 @@ from django.http import HttpResponse from django.utils.timezone import now as timezone_now from zerver.lib.actions import ( + do_change_plan_type, do_change_stream_post_policy, do_change_user_role, + do_deactivate_stream, do_delete_messages, do_set_realm_property, do_update_message, @@ -311,6 +313,78 @@ class EditMessageTest(EditMessageTestCase): result = self.client_get("/json/messages/" + str(msg_id)) self.assert_json_error(result, "Invalid message(s)") + def test_fetch_raw_message_spectator(self) -> None: + user_profile = self.example_user("iago") + self.login("iago") + web_public_stream = self.make_stream("web-public-stream", is_web_public=True) + self.subscribe(user_profile, web_public_stream.name) + + web_public_stream_msg_id = self.send_stream_message( + user_profile, web_public_stream.name, content="web-public message" + ) + + non_web_public_stream = self.make_stream("non-web-public-stream") + non_web_public_stream_msg_id = self.send_stream_message( + user_profile, non_web_public_stream.name, content="non web-public message" + ) + + # Generate a private message to use in verification. + private_message_id = self.send_personal_message(user_profile, user_profile) + + invalid_message_id = private_message_id + 1000 + + self.logout() + + # Confirm WEB_PUBLIC_STREAMS_ENABLED is enforced. + with self.settings(WEB_PUBLIC_STREAMS_ENABLED=False): + result = self.client_get("/json/messages/" + str(web_public_stream_msg_id)) + self.assert_json_error( + result, "Not logged in: API authentication or user session required", 401 + ) + + # Verify success with web-public stream and default SELF_HOSTED plan type. + result = self.client_get("/json/messages/" + str(web_public_stream_msg_id)) + self.assert_json_success(result) + self.assertEqual(result.json()["raw_content"], "web-public message") + + # Verify LIMITED plan type does not allow web-public access. + do_change_plan_type(user_profile.realm, Realm.LIMITED, acting_user=None) + result = self.client_get("/json/messages/" + str(web_public_stream_msg_id)) + self.assert_json_error( + result, "Not logged in: API authentication or user session required", 401 + ) + + # Verify works with STANDARD_FREE plan type too. + do_change_plan_type(user_profile.realm, Realm.STANDARD_FREE, acting_user=None) + result = self.client_get("/json/messages/" + str(web_public_stream_msg_id)) + self.assert_json_success(result) + self.assertEqual(result.json()["raw_content"], "web-public message") + + # Verify private messages are rejected. + result = self.client_get("/json/messages/" + str(private_message_id)) + self.assert_json_error( + result, "Not logged in: API authentication or user session required", 401 + ) + + # Verify an actual public stream is required. + result = self.client_get("/json/messages/" + str(non_web_public_stream_msg_id)) + self.assert_json_error( + result, "Not logged in: API authentication or user session required", 401 + ) + + # Verify invalid message IDs are rejected with the same error message. + result = self.client_get("/json/messages/" + str(invalid_message_id)) + self.assert_json_error( + result, "Not logged in: API authentication or user session required", 401 + ) + + # Verify deactivated streams are rejected. This may change in the future. + do_deactivate_stream(web_public_stream, acting_user=None) + result = self.client_get("/json/messages/" + str(web_public_stream_msg_id)) + self.assert_json_error( + result, "Not logged in: API authentication or user session required", 401 + ) + def test_fetch_raw_message_stream_wrong_realm(self) -> None: user_profile = self.example_user("hamlet") self.login_user(user_profile) diff --git a/zerver/views/message_edit.py b/zerver/views/message_edit.py index 759fa78772..09f00c02e6 100644 --- a/zerver/views/message_edit.py +++ b/zerver/views/message_edit.py @@ -1,16 +1,18 @@ import datetime -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Union import orjson +from django.contrib.auth.models import AnonymousUser from django.db import IntegrityError, transaction from django.http import HttpRequest, HttpResponse from django.utils.timezone import now as timezone_now from django.utils.translation import gettext as _ +from zerver.context_processors import get_valid_realm_from_request from zerver.lib.actions import check_update_message, do_delete_messages from zerver.lib.exceptions import JsonableError from zerver.lib.html_diff import highlight_html_differences -from zerver.lib.message import access_message +from zerver.lib.message import access_message, access_web_public_message from zerver.lib.request import REQ, RequestNotes, has_request_variables from zerver.lib.response import json_success from zerver.lib.timestamp import datetime_to_timestamp @@ -168,8 +170,14 @@ def delete_message_backend( @has_request_variables def json_fetch_raw_message( request: HttpRequest, - user_profile: UserProfile, + maybe_user_profile: Union[UserProfile, AnonymousUser], message_id: int = REQ(converter=to_non_negative_int, path_only=True), ) -> HttpResponse: - (message, user_message) = access_message(user_profile, message_id) + + if not maybe_user_profile.is_authenticated: + realm = get_valid_realm_from_request(request) + message = access_web_public_message(realm, message_id) + else: + (message, user_message) = access_message(maybe_user_profile, message_id) + return json_success({"raw_content": message.content}) diff --git a/zproject/urls.py b/zproject/urls.py index adf1229aa5..8dd5c8e0e4 100644 --- a/zproject/urls.py +++ b/zproject/urls.py @@ -324,7 +324,7 @@ v1_api_and_json_patterns = [ ), rest_path( "messages/", - GET=json_fetch_raw_message, + GET=(json_fetch_raw_message, {"allow_anonymous_user_web"}), PATCH=update_message_backend, DELETE=delete_message_backend, ),