From 1c55f2c9a6426ec69ae5dfe1f70526d1f44dc40e Mon Sep 17 00:00:00 2001 From: Eliot Berriot <contact@eliotberriot.com> Date: Wed, 9 Jan 2019 17:52:14 +0100 Subject: [PATCH] Use our instance policies to discard fetched and inbox objects --- api/funkwhale_api/federation/activity.py | 43 ++++++++- api/funkwhale_api/federation/api_views.py | 8 +- .../federation/authentication.py | 26 +++++- api/funkwhale_api/federation/exceptions.py | 8 ++ api/funkwhale_api/federation/fields.py | 4 +- api/funkwhale_api/federation/serializers.py | 2 +- api/funkwhale_api/federation/utils.py | 22 ++++- api/funkwhale_api/manage/serializers.py | 9 +- api/funkwhale_api/moderation/models.py | 18 +++- api/tests/federation/test_activity.py | 63 +++++++++++++ api/tests/federation/test_api_views.py | 2 +- api/tests/federation/test_authentication.py | 90 ++++++++++++++++++- api/tests/federation/test_serializers.py | 4 +- api/tests/federation/test_utils.py | 37 ++++++-- api/tests/manage/test_serializers.py | 12 +++ 15 files changed, 317 insertions(+), 31 deletions(-) diff --git a/api/funkwhale_api/federation/activity.py b/api/funkwhale_api/federation/activity.py index 211b8230..86c79107 100644 --- a/api/funkwhale_api/federation/activity.py +++ b/api/funkwhale_api/federation/activity.py @@ -80,6 +80,30 @@ OBJECT_TYPES = ( BROADCAST_TO_USER_ACTIVITIES = ["Follow", "Accept"] +def should_reject(id, actor_id=None, payload={}): + from funkwhale_api.moderation import models as moderation_models + + policies = moderation_models.InstancePolicy.objects.active() + + media_types = ["Audio", "Artist", "Album", "Track", "Library", "Image"] + relevant_values = [ + recursive_gettattr(payload, "type", permissive=True), + recursive_gettattr(payload, "object.type", permissive=True), + recursive_gettattr(payload, "target.type", permissive=True), + ] + # if one of the payload types match our internal media types, then + # we apply policies that reject media + if set(media_types) & set(relevant_values): + policy_type = Q(block_all=True) | Q(reject_media=True) + else: + policy_type = Q(block_all=True) + + query = policies.matching_url_query(id) & policy_type + if actor_id: + query |= policies.matching_url_query(actor_id) & policy_type + return policies.filter(query).exists() + + @transaction.atomic def receive(activity, on_behalf_of): from . import models @@ -92,6 +116,16 @@ def receive(activity, on_behalf_of): data=activity, context={"actor": on_behalf_of, "local_recipients": True} ) serializer.is_valid(raise_exception=True) + if should_reject( + id=serializer.validated_data["id"], + actor_id=serializer.validated_data["actor"].fid, + payload=activity, + ): + logger.info( + "[federation] Discarding activity due to instance policies %s", + serializer.validated_data.get("id"), + ) + return try: copy = serializer.save() except IntegrityError: @@ -283,7 +317,7 @@ class OutboxRouter(Router): return activities -def recursive_gettattr(obj, key): +def recursive_gettattr(obj, key, permissive=False): """ Given a dictionary such as {'user': {'name': 'Bob'}} and a dotted string such as user.name, returns 'Bob'. @@ -292,7 +326,12 @@ def recursive_gettattr(obj, key): """ v = obj for k in key.split("."): - v = v.get(k) + try: + v = v.get(k) + except (TypeError, AttributeError): + if not permissive: + raise + return if v is None: return diff --git a/api/funkwhale_api/federation/api_views.py b/api/funkwhale_api/federation/api_views.py index 75ffad0b..4c5aaf92 100644 --- a/api/funkwhale_api/federation/api_views.py +++ b/api/funkwhale_api/federation/api_views.py @@ -13,6 +13,7 @@ from funkwhale_api.music import models as music_models from . import activity from . import api_serializers +from . import exceptions from . import filters from . import models from . import routes @@ -128,11 +129,16 @@ class LibraryViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): except KeyError: return response.Response({"fid": ["This field is required"]}) try: - library = utils.retrieve( + library = utils.retrieve_ap_object( fid, queryset=self.queryset, serializer_class=serializers.LibrarySerializer, ) + except exceptions.BlockedActorOrDomain: + return response.Response( + {"detail": "This domain/account is blocked on your instance."}, + status=400, + ) except requests.exceptions.RequestException as e: return response.Response( {"detail": "Error while fetching the library: {}".format(str(e))}, diff --git a/api/funkwhale_api/federation/authentication.py b/api/funkwhale_api/federation/authentication.py index f32c78ff..6f0fd329 100644 --- a/api/funkwhale_api/federation/authentication.py +++ b/api/funkwhale_api/federation/authentication.py @@ -1,8 +1,14 @@ import cryptography +import logging + from django.contrib.auth.models import AnonymousUser from rest_framework import authentication, exceptions -from . import actors, keys, signing, utils +from funkwhale_api.moderation import models as moderation_models +from . import actors, exceptions, keys, signing, utils + + +logger = logging.getLogger(__name__) class SignatureAuthentication(authentication.BaseAuthentication): @@ -17,8 +23,24 @@ class SignatureAuthentication(authentication.BaseAuthentication): raise exceptions.AuthenticationFailed(str(e)) try: - actor = actors.get_actor(key_id.split("#")[0]) + actor_url = key_id.split("#")[0] + except (TypeError, IndexError, AttributeError): + raise exceptions.AuthenticationFailed("Invalid key id") + + policies = ( + moderation_models.InstancePolicy.objects.active() + .filter(block_all=True) + .matching_url(actor_url) + ) + if policies.exists(): + raise exceptions.BlockedActorOrDomain() + + try: + actor = actors.get_actor(actor_url) except Exception as e: + logger.info( + "Discarding HTTP request from blocked actor/domain %s", actor_url + ) raise exceptions.AuthenticationFailed(str(e)) if not actor.public_key: diff --git a/api/funkwhale_api/federation/exceptions.py b/api/funkwhale_api/federation/exceptions.py index b3fb73ab..303a0c4b 100644 --- a/api/funkwhale_api/federation/exceptions.py +++ b/api/funkwhale_api/federation/exceptions.py @@ -1,6 +1,14 @@ +from rest_framework import authentication, exceptions +from rest_framework import exceptions + + class MalformedPayload(ValueError): pass class MissingSignature(KeyError): pass + + +class BlockedActorOrDomain(exceptions.AuthenticationFailed): + pass diff --git a/api/funkwhale_api/federation/fields.py b/api/funkwhale_api/federation/fields.py index f23d0907..3523396d 100644 --- a/api/funkwhale_api/federation/fields.py +++ b/api/funkwhale_api/federation/fields.py @@ -7,8 +7,8 @@ class ActorRelatedField(serializers.EmailField): def to_representation(self, value): return value.full_username - def to_interal_value(self, value): - value = super().to_interal_value(value) + def to_internal_value(self, value): + value = super().to_internal_value(value) username, domain = value.split("@") try: return models.Actor.objects.get( diff --git a/api/funkwhale_api/federation/serializers.py b/api/funkwhale_api/federation/serializers.py index 1cece3b9..d0e07cd8 100644 --- a/api/funkwhale_api/federation/serializers.py +++ b/api/funkwhale_api/federation/serializers.py @@ -567,7 +567,7 @@ class LibrarySerializer(PaginatedCollectionSerializer): return r def create(self, validated_data): - actor = utils.retrieve( + actor = utils.retrieve_ap_object( validated_data["actor"], queryset=models.Actor, serializer_class=ActorSerializer, diff --git a/api/funkwhale_api/federation/utils.py b/api/funkwhale_api/federation/utils.py index d02c8bf6..f29608a2 100644 --- a/api/funkwhale_api/federation/utils.py +++ b/api/funkwhale_api/federation/utils.py @@ -1,9 +1,12 @@ import unicodedata import re from django.conf import settings +from django.db.models import Q from funkwhale_api.common import session +from funkwhale_api.moderation import models as moderation_models +from . import exceptions from . import signing @@ -58,7 +61,14 @@ def slugify_username(username): return re.sub(r"[-\s]+", "_", value) -def retrieve(fid, actor=None, serializer_class=None, queryset=None): +def retrieve_ap_object( + fid, actor=None, serializer_class=None, queryset=None, apply_instance_policies=True +): + from . import activity, serializers + + policies = moderation_models.InstancePolicy.objects.active().filter(block_all=True) + if apply_instance_policies and policies.matching_url(fid): + raise exceptions.BlockedActorOrDomain() if queryset: try: # queryset can also be a Model class @@ -83,6 +93,16 @@ def retrieve(fid, actor=None, serializer_class=None, queryset=None): ) response.raise_for_status() data = response.json() + + # we match against moderation policies here again, because the FID of the returned + # object may not be the same as the URL used to access it + try: + id = data["id"] + except KeyError: + pass + else: + if apply_instance_policies and activity.should_reject(id=id, payload=data): + raise exceptions.BlockedActorOrDomain() if not serializer_class: return data serializer = serializer_class(data=data) diff --git a/api/funkwhale_api/manage/serializers.py b/api/funkwhale_api/manage/serializers.py index 6795b30d..e8d1a294 100644 --- a/api/funkwhale_api/manage/serializers.py +++ b/api/funkwhale_api/manage/serializers.py @@ -316,15 +316,8 @@ class ManageInstancePolicySerializer(serializers.ModelSerializer): @transaction.atomic def save(self, *args, **kwargs): - block_all = self.validated_data.get("block_all", False) - need_purge = ( - # we purge when we create with block all - (not self.instance and block_all) - or - # or when block all value switch from False to True - (self.instance and block_all and not self.instance.block_all) - ) instance = super().save(*args, **kwargs) + need_purge = self.instance.is_active and self.instance.block_all if need_purge: target = instance.target diff --git a/api/funkwhale_api/moderation/models.py b/api/funkwhale_api/moderation/models.py index 59b077ba..c184bbda 100644 --- a/api/funkwhale_api/moderation/models.py +++ b/api/funkwhale_api/moderation/models.py @@ -9,10 +9,22 @@ class InstancePolicyQuerySet(models.QuerySet): def active(self): return self.filter(is_active=True) - def matching_url(self, url): + def matching_url(self, *urls): + if not urls: + return self.none() + query = None + for url in urls: + new_query = self.matching_url_query(url) + if query: + query = query | new_query + else: + query = new_query + return self.filter(query) + + def matching_url_query(self, url): parsed = urllib.parse.urlparse(url) - return self.filter( - models.Q(target_domain_id=parsed.hostname) | models.Q(target_actor__fid=url) + return models.Q(target_domain_id=parsed.hostname) | models.Q( + target_actor__fid=url ) diff --git a/api/tests/federation/test_activity.py b/api/tests/federation/test_activity.py index a65b7b0c..67f60b48 100644 --- a/api/tests/federation/test_activity.py +++ b/api/tests/federation/test_activity.py @@ -46,6 +46,69 @@ def test_receive_validates_basic_attributes_and_stores_activity(factories, now, assert ii.is_read is False +def test_receive_calls_should_reject(factories, now, mocker): + should_reject = mocker.patch.object(activity, "should_reject", return_value=True) + local_to_actor = factories["users.User"]().create_actor() + remote_actor = factories["federation.Actor"]() + a = { + "@context": [], + "actor": remote_actor.fid, + "type": "Noop", + "id": "https://test.activity", + "to": [local_to_actor.fid, remote_actor.fid], + } + + copy = activity.receive(activity=a, on_behalf_of=remote_actor) + should_reject.assert_called_once_with( + id=a["id"], actor_id=remote_actor.fid, payload=a + ) + assert copy is None + + +@pytest.mark.parametrize( + "params, policy_kwargs, expected", + [ + ({"id": "https://ok.test"}, {"target_domain__name": "notok.test"}, False), + ( + {"id": "https://ok.test"}, + {"target_domain__name": "ok.test", "is_active": False}, + False, + ), + ( + {"id": "https://ok.test"}, + {"target_domain__name": "ok.test", "block_all": False}, + False, + ), + # id match blocked domain + ({"id": "http://notok.test"}, {"target_domain__name": "notok.test"}, True), + # actor id match blocked domain + ( + {"id": "http://ok.test", "actor_id": "https://notok.test"}, + {"target_domain__name": "notok.test"}, + True, + ), + # reject media + ( + { + "payload": {"type": "Library"}, + "id": "http://ok.test", + "actor_id": "http://notok.test", + }, + { + "target_domain__name": "notok.test", + "block_all": False, + "reject_media": True, + }, + True, + ), + ], +) +def test_should_reject(factories, params, policy_kwargs, expected): + factories["moderation.InstancePolicy"](for_domain=True, **policy_kwargs) + + assert activity.should_reject(**params) is expected + + def test_get_actors_from_audience_urls(settings, db): settings.FEDERATION_HOSTNAME = "federation.hostname" library_uuid1 = uuid.uuid4() diff --git a/api/tests/federation/test_api_views.py b/api/tests/federation/test_api_views.py index c2d69518..feb2ea24 100644 --- a/api/tests/federation/test_api_views.py +++ b/api/tests/federation/test_api_views.py @@ -23,7 +23,7 @@ def test_user_can_list_their_library_follows(factories, logged_in_api_client): def test_user_can_fetch_library_using_url(mocker, factories, logged_in_api_client): library = factories["music.Library"]() mocked_retrieve = mocker.patch( - "funkwhale_api.federation.utils.retrieve", return_value=library + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=library ) url = reverse("api:v1:federation:libraries-fetch") response = logged_in_api_client.post(url, {"fid": library.fid}) diff --git a/api/tests/federation/test_authentication.py b/api/tests/federation/test_authentication.py index 100971a3..2888588b 100644 --- a/api/tests/federation/test_authentication.py +++ b/api/tests/federation/test_authentication.py @@ -1,4 +1,6 @@ -from funkwhale_api.federation import authentication, keys +import pytest + +from funkwhale_api.federation import authentication, exceptions, keys def test_authenticate(factories, mocker, api_request): @@ -38,3 +40,89 @@ def test_authenticate(factories, mocker, api_request): assert user.is_anonymous is True assert actor.public_key == public.decode("utf-8") assert actor.fid == actor_url + + +def test_authenticate_skips_blocked_domain(factories, api_request): + policy = factories["moderation.InstancePolicy"](block_all=True, for_domain=True) + private, public = keys.get_key_pair() + actor_url = "https://{}/actor".format(policy.target_domain.name) + + signed_request = factories["federation.SignedRequest"]( + auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"] + ) + prepared = signed_request.prepare() + django_request = api_request.get( + "/", + **{ + "HTTP_DATE": prepared.headers["date"], + "HTTP_SIGNATURE": prepared.headers["signature"], + } + ) + authenticator = authentication.SignatureAuthentication() + + with pytest.raises(exceptions.BlockedActorOrDomain): + authenticator.authenticate(django_request) + + +def test_authenticate_skips_blocked_actor(factories, api_request): + policy = factories["moderation.InstancePolicy"](block_all=True, for_actor=True) + private, public = keys.get_key_pair() + actor_url = policy.target_actor.fid + + signed_request = factories["federation.SignedRequest"]( + auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"] + ) + prepared = signed_request.prepare() + django_request = api_request.get( + "/", + **{ + "HTTP_DATE": prepared.headers["date"], + "HTTP_SIGNATURE": prepared.headers["signature"], + } + ) + authenticator = authentication.SignatureAuthentication() + + with pytest.raises(exceptions.BlockedActorOrDomain): + authenticator.authenticate(django_request) + + +def test_authenticate_ignore_inactive_policy(factories, api_request, mocker): + policy = factories["moderation.InstancePolicy"]( + block_all=True, for_domain=True, is_active=False + ) + private, public = keys.get_key_pair() + actor_url = "https://{}/actor".format(policy.target_domain.name) + + signed_request = factories["federation.SignedRequest"]( + auth__key=private, auth__key_id=actor_url + "#main-key", auth__headers=["date"] + ) + mocker.patch( + "funkwhale_api.federation.actors.get_actor_data", + return_value={ + "id": actor_url, + "type": "Person", + "outbox": "https://test.com", + "inbox": "https://test.com", + "followers": "https://test.com", + "preferredUsername": "test", + "publicKey": { + "publicKeyPem": public.decode("utf-8"), + "owner": actor_url, + "id": actor_url + "#main-key", + }, + }, + ) + prepared = signed_request.prepare() + django_request = api_request.get( + "/", + **{ + "HTTP_DATE": prepared.headers["date"], + "HTTP_SIGNATURE": prepared.headers["signature"], + } + ) + authenticator = authentication.SignatureAuthentication() + authenticator.authenticate(django_request) + actor = django_request.actor + + assert actor.public_key == public.decode("utf-8") + assert actor.fid == actor_url diff --git a/api/tests/federation/test_serializers.py b/api/tests/federation/test_serializers.py index fb151b2d..207a8fbe 100644 --- a/api/tests/federation/test_serializers.py +++ b/api/tests/federation/test_serializers.py @@ -507,7 +507,7 @@ def test_music_library_serializer_to_ap(factories): def test_music_library_serializer_from_public(factories, mocker): actor = factories["federation.Actor"]() retrieve = mocker.patch( - "funkwhale_api.federation.utils.retrieve", return_value=actor + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor ) data = { "@context": [ @@ -550,7 +550,7 @@ def test_music_library_serializer_from_public(factories, mocker): def test_music_library_serializer_from_private(factories, mocker): actor = factories["federation.Actor"]() retrieve = mocker.patch( - "funkwhale_api.federation.utils.retrieve", return_value=actor + "funkwhale_api.federation.utils.retrieve_ap_object", return_value=actor ) data = { "@context": [ diff --git a/api/tests/federation/test_utils.py b/api/tests/federation/test_utils.py index e89c5254..761f44d3 100644 --- a/api/tests/federation/test_utils.py +++ b/api/tests/federation/test_utils.py @@ -1,7 +1,7 @@ from rest_framework import serializers import pytest -from funkwhale_api.federation import utils +from funkwhale_api.federation import exceptions, utils @pytest.mark.parametrize( @@ -53,20 +53,43 @@ def test_extract_headers_from_meta(): assert cleaned_headers == expected -def test_retrieve(r_mock): +def test_retrieve_ap_object(db, r_mock): fid = "https://some.url" m = r_mock.get(fid, json={"hello": "world"}) - result = utils.retrieve(fid) + result = utils.retrieve_ap_object(fid) assert result == {"hello": "world"} assert m.request_history[-1].headers["Accept"] == "application/activity+json" +def test_retrieve_ap_object_honor_instance_policy_domain(factories): + domain = factories["moderation.InstancePolicy"]( + block_all=True, for_domain=True + ).target_domain + fid = "https://{}/test".format(domain.name) + + with pytest.raises(exceptions.BlockedActorOrDomain): + utils.retrieve_ap_object(fid) + + +def test_retrieve_ap_object_honor_instance_policy_different_url_and_id( + r_mock, factories +): + domain = factories["moderation.InstancePolicy"]( + block_all=True, for_domain=True + ).target_domain + fid = "https://ok/test" + m = r_mock.get(fid, json={"id": "http://{}/test".format(domain.name)}) + + with pytest.raises(exceptions.BlockedActorOrDomain): + utils.retrieve_ap_object(fid) + + def test_retrieve_with_actor(r_mock, factories): actor = factories["federation.Actor"]() fid = "https://some.url" m = r_mock.get(fid, json={"hello": "world"}) - result = utils.retrieve(fid, actor=actor) + result = utils.retrieve_ap_object(fid, actor=actor) assert result == {"hello": "world"} assert m.request_history[-1].headers["Accept"] == "application/activity+json" @@ -76,16 +99,16 @@ def test_retrieve_with_actor(r_mock, factories): def test_retrieve_with_queryset(factories): actor = factories["federation.Actor"]() - assert utils.retrieve(actor.fid, queryset=actor.__class__) + assert utils.retrieve_ap_object(actor.fid, queryset=actor.__class__) -def test_retrieve_with_serializer(r_mock): +def test_retrieve_with_serializer(db, r_mock): class S(serializers.Serializer): def create(self, validated_data): return {"persisted": "object"} fid = "https://some.url" r_mock.get(fid, json={"hello": "world"}) - result = utils.retrieve(fid, serializer_class=S) + result = utils.retrieve_ap_object(fid, serializer_class=S) assert result == {"persisted": "object"} diff --git a/api/tests/manage/test_serializers.py b/api/tests/manage/test_serializers.py index 36dbe509..1cb3ff90 100644 --- a/api/tests/manage/test_serializers.py +++ b/api/tests/manage/test_serializers.py @@ -141,6 +141,18 @@ def test_instance_policy_serializer_save_domain(factories): assert policy.target_domain == domain +def test_instance_policy_serializer_save_actor(factories): + actor = factories["federation.Actor"]() + + data = {"target": {"id": actor.full_username, "type": "actor"}, "block_all": True} + + serializer = serializers.ManageInstancePolicySerializer(data=data) + serializer.is_valid(raise_exception=True) + policy = serializer.save() + + assert policy.target_actor == actor + + def test_manage_actor_action_purge(factories, mocker): actors = factories["federation.Actor"].create_batch(size=3) s = serializers.ManageActorActionSerializer(queryset=None) -- GitLab