summary refs log tree commit diff
diff options
context:
space:
mode:
authorDavid Teller <d.o.teller+github@gmail.com>2022-05-11 10:32:27 +0200
committerDavid Teller <d.o.teller+github@gmail.com>2022-05-11 10:32:30 +0200
commitae66c672fe8b5fbfe497888d03b2cbd68381de76 (patch)
tree4c837eb8b98c8b6bd1a0fa910100df30dea7161f
parentFix `/messages` throwing a 500 when querying for non-existent room (#12683) (diff)
downloadsynapse-ts/spam-errors.tar.xz
Uniformize spam-checker API: github/ts/spam-errors ts/spam-errors
- Some callbacks should return `True` to allow, `False` to deny, while others should return `True` to deny and `False` to allow. With this PR, all callbacks return `ALLOW` to allow or a `Codes` (typically `Codes.FORBIDDEN`) to deny.
- Similarly, some methods returned `True` to allow, `False` to deny, while others returned `True` to deny and `False` to allow. They now all return `ALLOW` to allow or a `Codes` to deny.
- Spam-checker implementations may now return an explicit code, e.g. to differentiate between "User account has been suspended" (which is in practice required by law in some countries, including UK) and "This message looks like spam".
-rw-r--r--docs/modules/spam_checker_callbacks.md11
-rw-r--r--synapse/events/spamcheck.py207
-rw-r--r--synapse/federation/federation_base.py5
-rw-r--r--synapse/handlers/directory.py17
-rw-r--r--synapse/handlers/federation.py10
-rw-r--r--synapse/handlers/message.py11
-rw-r--r--synapse/handlers/room.py6
-rw-r--r--synapse/handlers/room_member.py35
-rw-r--r--synapse/handlers/user_directory.py3
-rw-r--r--synapse/rest/media/v1/media_storage.py9
-rw-r--r--synapse/spam_checker_api/__init__.py20
11 files changed, 224 insertions, 110 deletions
diff --git a/docs/modules/spam_checker_callbacks.md b/docs/modules/spam_checker_callbacks.md
index 472d957180..d0db863ff8 100644
--- a/docs/modules/spam_checker_callbacks.md
+++ b/docs/modules/spam_checker_callbacks.md
@@ -18,6 +18,17 @@ async def check_event_for_spam(event: "synapse.events.EventBase") -> Union[bool,
 
 Called when receiving an event from a client or via federation. The callback must return
 either:
+  - on `Decision.ALLOW`, the action is permitted.
+  - on `Decision.DENY`, the action is rejected with a default error message/code.
+  - on `Codes`, the action is rejected with a specific error message/code. In case
+      of doubt, use `Codes.FORBIDDEN`.
+  - (deprecated) on `False`, behave as `Decision.ALLOW`. Deprecated as methods in
+      this API are inconsistent, some expect `True` for `ALLOW` and others `True`
+      for `DENY`.
+  - (deprecated) on `True`, behave as `Decision.DENY`. Deprecated as methods in
+      this API are inconsistent, some expect `True` for `ALLOW` and others `True`
+      for `DENY`.
+
 - an error message string, to indicate the event must be rejected because of spam and 
   give a rejection reason to forward to clients;
 - the boolean `True`, to indicate that the event is spammy, but not provide further details; or
diff --git a/synapse/events/spamcheck.py b/synapse/events/spamcheck.py
index 3b6795d40f..b15dbe1489 100644
--- a/synapse/events/spamcheck.py
+++ b/synapse/events/spamcheck.py
@@ -27,9 +27,10 @@ from typing import (
     Union,
 )
 
+from synapse.api.errors import Codes
 from synapse.rest.media.v1._base import FileInfo
 from synapse.rest.media.v1.media_storage import ReadableFileWrapper
-from synapse.spam_checker_api import RegistrationBehaviour
+from synapse.spam_checker_api import ALLOW, Decision, RegistrationBehaviour
 from synapse.types import RoomAlias, UserProfile
 from synapse.util.async_helpers import delay_cancellation, maybe_awaitable
 
@@ -39,17 +40,34 @@ if TYPE_CHECKING:
 
 logger = logging.getLogger(__name__)
 
+
+DEPRECATED_BOOL = bool
+
 CHECK_EVENT_FOR_SPAM_CALLBACK = Callable[
     ["synapse.events.EventBase"],
-    Awaitable[Union[bool, str]],
+    Awaitable[Union[ALLOW, Codes, str, DEPRECATED_BOOL]],
+]
+USER_MAY_JOIN_ROOM_CALLBACK = Callable[
+    [str, str, bool], Awaitable[Union[ALLOW, Codes, DEPRECATED_BOOL]]
+]
+USER_MAY_INVITE_CALLBACK = Callable[
+    [str, str, str], Awaitable[Union[ALLOW, Codes, DEPRECATED_BOOL]]
+]
+USER_MAY_SEND_3PID_INVITE_CALLBACK = Callable[
+    [str, str, str, str], Awaitable[Union[ALLOW, Codes, DEPRECATED_BOOL]]
+]
+USER_MAY_CREATE_ROOM_CALLBACK = Callable[
+    [str], Awaitable[Union[ALLOW, Codes, DEPRECATED_BOOL]]
+]
+USER_MAY_CREATE_ROOM_ALIAS_CALLBACK = Callable[
+    [str, RoomAlias], Awaitable[Union[ALLOW, Codes, DEPRECATED_BOOL]]
+]
+USER_MAY_PUBLISH_ROOM_CALLBACK = Callable[
+    [str, str], Awaitable[Union[ALLOW, Codes, DEPRECATED_BOOL]]
+]
+CHECK_USERNAME_FOR_SPAM_CALLBACK = Callable[
+    [UserProfile], Awaitable[Union[ALLOW, Codes, DEPRECATED_BOOL]]
 ]
-USER_MAY_JOIN_ROOM_CALLBACK = Callable[[str, str, bool], Awaitable[bool]]
-USER_MAY_INVITE_CALLBACK = Callable[[str, str, str], Awaitable[bool]]
-USER_MAY_SEND_3PID_INVITE_CALLBACK = Callable[[str, str, str, str], Awaitable[bool]]
-USER_MAY_CREATE_ROOM_CALLBACK = Callable[[str], Awaitable[bool]]
-USER_MAY_CREATE_ROOM_ALIAS_CALLBACK = Callable[[str, RoomAlias], Awaitable[bool]]
-USER_MAY_PUBLISH_ROOM_CALLBACK = Callable[[str, str], Awaitable[bool]]
-CHECK_USERNAME_FOR_SPAM_CALLBACK = Callable[[UserProfile], Awaitable[bool]]
 LEGACY_CHECK_REGISTRATION_FOR_SPAM_CALLBACK = Callable[
     [
         Optional[dict],
@@ -65,11 +83,11 @@ CHECK_REGISTRATION_FOR_SPAM_CALLBACK = Callable[
         Collection[Tuple[str, str]],
         Optional[str],
     ],
-    Awaitable[RegistrationBehaviour],
+    Awaitable[Union[RegistrationBehaviour, Codes]],
 ]
 CHECK_MEDIA_FILE_FOR_SPAM_CALLBACK = Callable[
     [ReadableFileWrapper, FileInfo],
-    Awaitable[bool],
+    Awaitable[Union[ALLOW, Codes, DEPRECATED_BOOL]],
 ]
 
 
@@ -240,7 +258,7 @@ class SpamChecker:
 
     async def check_event_for_spam(
         self, event: "synapse.events.EventBase"
-    ) -> Union[bool, str]:
+    ) -> Union[ALLOW, Codes, str]:
         """Checks if a given event is considered "spammy" by this server.
 
         If the server considers an event spammy, then it will be rejected if
@@ -251,19 +269,29 @@ class SpamChecker:
             event: the event to be checked
 
         Returns:
-            True or a string if the event is spammy. If a string is returned it
-            will be used as the error message returned to the user.
+            - on `ALLOW`, the event is considered good (non-spammy) and should
+                be let through. Other spamcheck filters may still reject it.
+            - on `Codes`, the event is considered spammy and is rejected with a specific
+                error message/code.
+            - on `str`, the event is considered spammy and the string is used as error
+                message.
         """
         for callback in self._check_event_for_spam_callbacks:
-            res: Union[bool, str] = await delay_cancellation(callback(event))
-            if res:
+            res: Union[ALLOW, Codes, str, DEPRECATED_BOOL] = await delay_cancellation(
+                callback(event)
+            )
+            if res is False or res is ALLOW:
+                continue
+            elif res is True:
+                return Codes.FORBIDDEN
+            else:
                 return res
 
-        return False
+        return ALLOW
 
     async def user_may_join_room(
         self, user_id: str, room_id: str, is_invited: bool
-    ) -> bool:
+    ) -> Decision:
         """Checks if a given users is allowed to join a room.
         Not called when a user creates a room.
 
@@ -273,48 +301,54 @@ class SpamChecker:
             is_invited: Whether the user is invited into the room
 
         Returns:
-            Whether the user may join the room
+            - on `ALLOW`, the action is permitted.
+            - on `Codes`, the action is rejected with a specific error message/code.
         """
         for callback in self._user_may_join_room_callbacks:
             may_join_room = await delay_cancellation(
                 callback(user_id, room_id, is_invited)
             )
-            if may_join_room is False:
-                return False
+            if may_join_room is True or may_join_room is ALLOW:
+                continue
+            elif may_join_room is False:
+                return Codes.FORBIDDEN
+            else:
+                return may_join_room
 
-        return True
+        return ALLOW
 
     async def user_may_invite(
         self, inviter_userid: str, invitee_userid: str, room_id: str
-    ) -> bool:
+    ) -> Decision:
         """Checks if a given user may send an invite
 
-        If this method returns false, the invite will be rejected.
-
         Args:
             inviter_userid: The user ID of the sender of the invitation
             invitee_userid: The user ID targeted in the invitation
             room_id: The room ID
 
         Returns:
-            True if the user may send an invite, otherwise False
+            - on `ALLOW`, the action is permitted.
+            - on `Codes`, the action is rejected with a specific error message/code.
         """
         for callback in self._user_may_invite_callbacks:
             may_invite = await delay_cancellation(
                 callback(inviter_userid, invitee_userid, room_id)
             )
-            if may_invite is False:
-                return False
+            if may_invite is True or may_invite is ALLOW:
+                continue
+            elif may_invite is False:
+                return Codes.FORBIDDEN
+            else:
+                return may_invite
 
-        return True
+        return ALLOW
 
     async def user_may_send_3pid_invite(
         self, inviter_userid: str, medium: str, address: str, room_id: str
-    ) -> bool:
+    ) -> Decision:
         """Checks if a given user may invite a given threepid into the room
 
-        If this method returns false, the threepid invite will be rejected.
-
         Note that if the threepid is already associated with a Matrix user ID, Synapse
         will call user_may_invite with said user ID instead.
 
@@ -325,78 +359,94 @@ class SpamChecker:
             room_id: The room ID
 
         Returns:
-            True if the user may send the invite, otherwise False
+            - on `ALLOW`, the action is permitted.
+            - on `Codes`, the action is rejected with a specific error message/code.
         """
         for callback in self._user_may_send_3pid_invite_callbacks:
             may_send_3pid_invite = await delay_cancellation(
                 callback(inviter_userid, medium, address, room_id)
             )
-            if may_send_3pid_invite is False:
-                return False
+            if may_send_3pid_invite is True or may_send_3pid_invite is ALLOW:
+                continue
+            elif may_send_3pid_invite is False:
+                return Codes.FORBIDDEN
+            else:
+                return may_send_3pid_invite
 
-        return True
+        return ALLOW
 
-    async def user_may_create_room(self, userid: str) -> bool:
+    async def user_may_create_room(self, userid: str) -> Decision:
         """Checks if a given user may create a room
 
-        If this method returns false, the creation request will be rejected.
-
         Args:
             userid: The ID of the user attempting to create a room
 
         Returns:
-            True if the user may create a room, otherwise False
+            - on `ALLOW`, the action is permitted.
+            - on `Codes`, the action is rejected with a specific error message/code.
         """
         for callback in self._user_may_create_room_callbacks:
             may_create_room = await delay_cancellation(callback(userid))
-            if may_create_room is False:
-                return False
+            if may_create_room is True or may_create_room is ALLOW:
+                continue
+            elif may_create_room is False:
+                return Codes.FORBIDDEN
+            else:
+                return may_create_room
 
-        return True
+        return ALLOW
 
     async def user_may_create_room_alias(
         self, userid: str, room_alias: RoomAlias
-    ) -> bool:
+    ) -> Decision:
         """Checks if a given user may create a room alias
 
-        If this method returns false, the association request will be rejected.
-
         Args:
             userid: The ID of the user attempting to create a room alias
             room_alias: The alias to be created
 
         Returns:
-            True if the user may create a room alias, otherwise False
+            - on `ALLOW`, the action is permitted.
+            - on `Codes`, the action is rejected with a specific error message/code.
         """
         for callback in self._user_may_create_room_alias_callbacks:
             may_create_room_alias = await delay_cancellation(
                 callback(userid, room_alias)
             )
-            if may_create_room_alias is False:
-                return False
-
-        return True
-
-    async def user_may_publish_room(self, userid: str, room_id: str) -> bool:
+            if may_create_room_alias is True or may_create_room_alias is ALLOW:
+                continue
+            elif may_create_room_alias is False:
+                return Codes.FORBIDDEN
+            else:
+                return may_create_room_alias
+
+        return ALLOW
+
+    async def user_may_publish_room(
+        self, userid: str, room_id: str
+    ) -> Union[ALLOW, Codes, DEPRECATED_BOOL]:
         """Checks if a given user may publish a room to the directory
 
-        If this method returns false, the publish request will be rejected.
-
         Args:
             userid: The user ID attempting to publish the room
             room_id: The ID of the room that would be published
 
         Returns:
-            True if the user may publish the room, otherwise False
+            - on `ALLOW`, the action is permitted.
+            - on `Codes`, the action is rejected with a specific error message/code.
         """
         for callback in self._user_may_publish_room_callbacks:
             may_publish_room = await delay_cancellation(callback(userid, room_id))
-            if may_publish_room is False:
-                return False
+            if may_publish_room is True or may_publish_room is ALLOW:
+                continue
+            elif may_publish_room is False:
+                return Codes.FORBIDDEN
+            else:
+                return may_publish_room
 
-        return True
+        return ALLOW
 
-    async def check_username_for_spam(self, user_profile: UserProfile) -> bool:
+    async def check_username_for_spam(self, user_profile: UserProfile) -> Decision:
         """Checks if a user ID or display name are considered "spammy" by this server.
 
         If the server considers a username spammy, then it will not be included in
@@ -409,15 +459,21 @@ class SpamChecker:
                 * avatar_url
 
         Returns:
-            True if the user is spammy.
+            - on `ALLOW`, the action is permitted.
+            - on `Codes`, the action is rejected with a specific error message/code.
         """
         for callback in self._check_username_for_spam_callbacks:
             # Make a copy of the user profile object to ensure the spam checker cannot
             # modify it.
-            if await delay_cancellation(callback(user_profile.copy())):
-                return True
+            is_spam = await delay_cancellation(callback(user_profile.copy()))
+            if is_spam is False or is_spam is ALLOW:
+                continue
+            elif is_spam is True:
+                return Codes.FORBIDDEN
+            else:
+                return is_spam
 
-        return False
+        return ALLOW
 
     async def check_registration_for_spam(
         self,
@@ -445,6 +501,8 @@ class SpamChecker:
             behaviour = await delay_cancellation(
                 callback(email_threepid, username, request_info, auth_provider_id)
             )
+            if isinstance(behaviour, Codes):
+                return behaviour
             assert isinstance(behaviour, RegistrationBehaviour)
             if behaviour != RegistrationBehaviour.ALLOW:
                 return behaviour
@@ -453,7 +511,7 @@ class SpamChecker:
 
     async def check_media_file_for_spam(
         self, file_wrapper: ReadableFileWrapper, file_info: FileInfo
-    ) -> bool:
+    ) -> Decision:
         """Checks if a piece of newly uploaded media should be blocked.
 
         This will be called for local uploads, downloads of remote media, each
@@ -475,19 +533,22 @@ class SpamChecker:
 
                 return False
 
-
         Args:
             file: An object that allows reading the contents of the media.
             file_info: Metadata about the file.
 
         Returns:
-            True if the media should be blocked or False if it should be
-            allowed.
+            - on `ALLOW`, the action is permitted.
+            - on `Codes`, the action is rejected with a specific error message/code.
         """
 
         for callback in self._check_media_file_for_spam_callbacks:
-            spam = await delay_cancellation(callback(file_wrapper, file_info))
-            if spam:
-                return True
-
-        return False
+            is_spam = await delay_cancellation(callback(file_wrapper, file_info))
+            if is_spam is False or is_spam is ALLOW:
+                continue
+            elif is_spam is True:
+                return Codes.FORBIDDEN
+            else:
+                return is_spam
+
+        return ALLOW
diff --git a/synapse/federation/federation_base.py b/synapse/federation/federation_base.py
index 41ac49fdc8..45c2777117 100644
--- a/synapse/federation/federation_base.py
+++ b/synapse/federation/federation_base.py
@@ -15,6 +15,7 @@
 import logging
 from typing import TYPE_CHECKING
 
+import synapse
 from synapse.api.constants import MAX_DEPTH, EventContentFields, EventTypes, Membership
 from synapse.api.errors import Codes, SynapseError
 from synapse.api.room_versions import EventFormatVersions, RoomVersion
@@ -98,9 +99,9 @@ class FederationBase:
                 )
             return redacted_event
 
-        result = await self.spam_checker.check_event_for_spam(pdu)
+        spam_check = await self.spam_checker.check_event_for_spam(pdu)
 
-        if result:
+        if spam_check is not synapse.spam_checker_api.ALLOW:
             logger.warning("Event contains spam, soft-failing %s", pdu.event_id)
             # we redact (to save disk space) as well as soft-failing (to stop
             # using the event in prev_events).
diff --git a/synapse/handlers/directory.py b/synapse/handlers/directory.py
index 33d827a45b..fbbb667cd4 100644
--- a/synapse/handlers/directory.py
+++ b/synapse/handlers/directory.py
@@ -16,6 +16,7 @@ import logging
 import string
 from typing import TYPE_CHECKING, Iterable, List, Optional
 
+import synapse
 from synapse.api.constants import MAX_ALIAS_LENGTH, EventTypes
 from synapse.api.errors import (
     AuthError,
@@ -137,10 +138,13 @@ class DirectoryHandler:
                         403, "You must be in the room to create an alias for it"
                     )
 
-            if not await self.spam_checker.user_may_create_room_alias(
+            spam_check = await self.spam_checker.user_may_create_room_alias(
                 user_id, room_alias
-            ):
-                raise AuthError(403, "This user is not permitted to create this alias")
+            )
+            if spam_check is not synapse.spam_checker_api.ALLOW:
+                raise AuthError(
+                    403, "This alias creation request has been rejected", spam_check
+                )
 
             if not self.config.roomdirectory.is_alias_creation_allowed(
                 user_id, room_id, room_alias_str
@@ -426,9 +430,12 @@ class DirectoryHandler:
         """
         user_id = requester.user.to_string()
 
-        if not await self.spam_checker.user_may_publish_room(user_id, room_id):
+        spam_check = await self.spam_checker.user_may_publish_room(user_id, room_id)
+        if spam_check is not synapse.spam_checker_api.ALLOW:
             raise AuthError(
-                403, "This user is not permitted to publish rooms to the room list"
+                403,
+                "This request to publish a room to the room list has been rejected",
+                spam_check,
             )
 
         if requester.is_guest:
diff --git a/synapse/handlers/federation.py b/synapse/handlers/federation.py
index be5099b507..aa3be099a5 100644
--- a/synapse/handlers/federation.py
+++ b/synapse/handlers/federation.py
@@ -27,6 +27,7 @@ from signedjson.key import decode_verify_key_bytes
 from signedjson.sign import verify_signed_json
 from unpaddedbase64 import decode_base64
 
+import synapse
 from synapse import event_auth
 from synapse.api.constants import EventContentFields, EventTypes, Membership
 from synapse.api.errors import (
@@ -799,11 +800,14 @@ class FederationHandler:
         if self.hs.config.server.block_non_admin_invites:
             raise SynapseError(403, "This server does not accept room invites")
 
-        if not await self.spam_checker.user_may_invite(
+        spam_check = await self.spam_checker.user_may_invite(
             event.sender, event.state_key, event.room_id
-        ):
+        )
+        if spam_check is not synapse.spam_checker_api.ALLOW:
             raise SynapseError(
-                403, "This user is not permitted to send invites to this server/user"
+                403,
+                "This user is not permitted to send invites to this server/user",
+                spam_check,
             )
 
         membership = event.content.get("membership")
diff --git a/synapse/handlers/message.py b/synapse/handlers/message.py
index e47799e7f9..9f51212e4e 100644
--- a/synapse/handlers/message.py
+++ b/synapse/handlers/message.py
@@ -23,6 +23,7 @@ from canonicaljson import encode_canonical_json
 
 from twisted.internet.interfaces import IDelayedCall
 
+import synapse
 from synapse import event_auth
 from synapse.api.constants import (
     EventContentFields,
@@ -881,11 +882,11 @@ class EventCreationHandler:
                 event.sender,
             )
 
-            spam_error = await self.spam_checker.check_event_for_spam(event)
-            if spam_error:
-                if not isinstance(spam_error, str):
-                    spam_error = "Spam is not permitted here"
-                raise SynapseError(403, spam_error, Codes.FORBIDDEN)
+            spam_check = await self.spam_checker.check_event_for_spam(event)
+            if spam_check is not synapse.spam_checker_api.ALLOW:
+                raise SynapseError(
+                    403, "This message had been rejected as probable spam", spam_check
+                )
 
             ev = await self.handle_new_client_event(
                 requester=requester,
diff --git a/synapse/handlers/room.py b/synapse/handlers/room.py
index 604eb6ec15..53a2a5df46 100644
--- a/synapse/handlers/room.py
+++ b/synapse/handlers/room.py
@@ -33,6 +33,7 @@ from typing import (
 import attr
 from typing_extensions import TypedDict
 
+import synapse
 from synapse.api.constants import (
     EventContentFields,
     EventTypes,
@@ -407,9 +408,10 @@ class RoomCreationHandler:
         """
         user_id = requester.user.to_string()
 
-        if not await self.spam_checker.user_may_create_room(user_id):
+        spam_check = await self.spam_checker.user_may_create_room(user_id)
+        if spam_check is not synapse.spam_checker_api.ALLOW:
             raise SynapseError(
-                403, "You are not permitted to create rooms", Codes.FORBIDDEN
+                403, "This room creation request has been rejected", spam_check
             )
 
         creation_content: JsonDict = {
diff --git a/synapse/handlers/room_member.py b/synapse/handlers/room_member.py
index 802e57c4d0..d3ef6a05ca 100644
--- a/synapse/handlers/room_member.py
+++ b/synapse/handlers/room_member.py
@@ -18,6 +18,7 @@ import random
 from http import HTTPStatus
 from typing import TYPE_CHECKING, Iterable, List, Optional, Set, Tuple
 
+import synapse
 from synapse import types
 from synapse.api.constants import (
     AccountDataTypes,
@@ -679,8 +680,6 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
             if target_id == self._server_notices_mxid:
                 raise SynapseError(HTTPStatus.FORBIDDEN, "Cannot invite this user")
 
-            block_invite = False
-
             if (
                 self._server_notices_mxid is not None
                 and requester.user.to_string() == self._server_notices_mxid
@@ -697,16 +696,18 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
                         "Blocking invite: user is not admin and non-admin "
                         "invites disabled"
                     )
-                    block_invite = True
+                    raise SynapseError(403, "Invites have been disabled on this server")
 
-                if not await self.spam_checker.user_may_invite(
+                spam_check = await self.spam_checker.user_may_invite(
                     requester.user.to_string(), target_id, room_id
-                ):
+                )
+                if spam_check is not synapse.spam_checker_api.ALLOW:
                     logger.info("Blocking invite due to spam checker")
-                    block_invite = True
-
-            if block_invite:
-                raise SynapseError(403, "Invites have been disabled on this server")
+                    raise SynapseError(
+                        403,
+                        "This invite has been rejected as probable spam",
+                        spam_check,
+                    )
 
         # An empty prev_events list is allowed as long as the auth_event_ids are present
         if prev_event_ids is not None:
@@ -814,11 +815,14 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
                 # We assume that if the spam checker allowed the user to create
                 # a room then they're allowed to join it.
                 and not new_room
-                and not await self.spam_checker.user_may_join_room(
+            ):
+                spam_check = await self.spam_checker.user_may_join_room(
                     target.to_string(), room_id, is_invited=inviter is not None
                 )
-            ):
-                raise SynapseError(403, "Not allowed to join this room")
+                if spam_check is not synapse.spam_checker_api.ALLOW:
+                    raise SynapseError(
+                        403, "This request to join room has been rejected", spam_check
+                    )
 
             # Check if a remote join should be performed.
             remote_join, remote_room_hosts = await self._should_perform_remote_join(
@@ -1372,13 +1376,14 @@ class RoomMemberHandler(metaclass=abc.ABCMeta):
             )
         else:
             # Check if the spamchecker(s) allow this invite to go through.
-            if not await self.spam_checker.user_may_send_3pid_invite(
+            spam_check = await self.spam_checker.user_may_send_3pid_invite(
                 inviter_userid=requester.user.to_string(),
                 medium=medium,
                 address=address,
                 room_id=room_id,
-            ):
-                raise SynapseError(403, "Cannot send threepid invite")
+            )
+            if spam_check is not synapse.spam_checker_api.ALLOW:
+                raise SynapseError(403, "Cannot send threepid invite", spam_check)
 
             stream_id = await self._make_and_store_3pid_invite(
                 requester,
diff --git a/synapse/handlers/user_directory.py b/synapse/handlers/user_directory.py
index 74f7fdfe6c..b7ece32958 100644
--- a/synapse/handlers/user_directory.py
+++ b/synapse/handlers/user_directory.py
@@ -100,7 +100,8 @@ class UserDirectoryHandler(StateDeltasHandler):
         # Remove any spammy users from the results.
         non_spammy_users = []
         for user in results["results"]:
-            if not await self.spam_checker.check_username_for_spam(user):
+            spam_check = await self.spam_checker.check_username_for_spam(user)
+            if spam_check is synapse.spam_checker_api.ALLOW:
                 non_spammy_users.append(user)
         results["results"] = non_spammy_users
 
diff --git a/synapse/rest/media/v1/media_storage.py b/synapse/rest/media/v1/media_storage.py
index 604f18bf52..c85a8a636b 100644
--- a/synapse/rest/media/v1/media_storage.py
+++ b/synapse/rest/media/v1/media_storage.py
@@ -36,6 +36,7 @@ from twisted.internet.defer import Deferred
 from twisted.internet.interfaces import IConsumer
 from twisted.protocols.basic import FileSender
 
+import synapse
 from synapse.api.errors import NotFoundError
 from synapse.logging.context import defer_to_thread, make_deferred_yieldable
 from synapse.util import Clock
@@ -145,15 +146,17 @@ class MediaStorage:
                     f.flush()
                     f.close()
 
-                    spam = await self.spam_checker.check_media_file_for_spam(
+                    spam_check = await self.spam_checker.check_media_file_for_spam(
                         ReadableFileWrapper(self.clock, fname), file_info
                     )
-                    if spam:
+                    if spam_check is not synapse.spam_checker_api.ALLOW:
                         logger.info("Blocking media due to spam checker")
                         # Note that we'll delete the stored media, due to the
                         # try/except below. The media also won't be stored in
                         # the DB.
-                        raise SpamMediaException()
+                        raise SpamMediaException(
+                            "File rejected as probable spam", spam_check
+                        )
 
                     for provider in self.storage_providers:
                         await provider.store_file(path, file_info)
diff --git a/synapse/spam_checker_api/__init__.py b/synapse/spam_checker_api/__init__.py
index 73018f2d00..074f4356f7 100644
--- a/synapse/spam_checker_api/__init__.py
+++ b/synapse/spam_checker_api/__init__.py
@@ -12,13 +12,31 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 from enum import Enum
+from typing import NewType, Union
+
+from synapse.api.errors import Codes
 
 
 class RegistrationBehaviour(Enum):
     """
-    Enum to define whether a registration request should allowed, denied, or shadow-banned.
+    Enum to define whether a registration request should be allowed, denied, or shadow-banned.
     """
 
     ALLOW = "allow"
     SHADOW_BAN = "shadow_ban"
     DENY = "deny"
+
+
+Allow = NewType("Allow", str)
+
+ALLOW = Allow("Allow")
+"""
+Return this constant to allow a message to pass.
+"""
+
+Decision = Union[ALLOW, Codes]
+"""
+Union to define whether a request should be allowed or rejected.
+
+To reject a request without any specific information, use `Codes.FORBIDDEN`.
+"""