Skip to content
Snippets Groups Projects
Verified Commit 1c55f2c9 authored by Eliot Berriot's avatar Eliot Berriot
Browse files

Use our instance policies to discard fetched and inbox objects

parent 9151a185
No related branches found
No related tags found
No related merge requests found
Showing
with 317 additions and 31 deletions
......@@ -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("."):
try:
v = v.get(k)
except (TypeError, AttributeError):
if not permissive:
raise
return
if v is None:
return
......
......@@ -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))},
......
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:
......
from rest_framework import authentication, exceptions
from rest_framework import exceptions
class MalformedPayload(ValueError):
pass
class MissingSignature(KeyError):
pass
class BlockedActorOrDomain(exceptions.AuthenticationFailed):
pass
......@@ -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(
......
......@@ -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,
......
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)
......
......@@ -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
......
......@@ -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
)
......
......@@ -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()
......
......@@ -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})
......
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
......@@ -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": [
......
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"}
......@@ -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)
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment