Use our instance policies to discard fetched and inbox objects

This commit is contained in:
Eliot Berriot 2019-01-09 17:52:14 +01:00
parent 9151a185e0
commit 1c55f2c9a6
No known key found for this signature in database
GPG Key ID: DD6965E2476E5C27
15 changed files with 317 additions and 31 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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