Compare commits

...

1 Commits

Author SHA1 Message Date
David Teller
ae66c672fe Uniformize spam-checker API:
- 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".
2022-05-11 10:32:30 +02:00
11 changed files with 221 additions and 107 deletions

View File

@@ -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

View File

@@ -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
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 True
return ALLOW
async def user_may_publish_room(self, userid: str, room_id: str) -> bool:
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
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 False
return ALLOW

View File

@@ -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).

View File

@@ -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:

View File

@@ -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")

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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`.
"""