return 404 on requests for unknown users instead of automatically creating them

pull/413/head
Ryan Barrett 2023-02-07 18:25:24 -08:00
rodzic 3f4c7fe63e
commit b739bd4cee
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: 6BE31FDF4776E9D4
5 zmienionych plików z 82 dodań i 63 usunięć

Wyświetl plik

@ -14,7 +14,7 @@ from oauth_dropins.webutil.util import json_dumps, json_loads
from app import app, cache from app import app, cache
import common import common
from common import CACHE_TIME, redirect_unwrap, redirect_wrap from common import CACHE_TIME, redirect_unwrap, redirect_wrap, TLD_BLOCKLIST
from models import Follower, Object, Target, User from models import Follower, Object, Target, User
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -40,7 +40,15 @@ SUPPORTED_TYPES = (
@flask_util.cached(cache, CACHE_TIME, http_5xx=True) @flask_util.cached(cache, CACHE_TIME, http_5xx=True)
def actor(domain): def actor(domain):
"""Fetches a domain's h-card and converts to AS2 actor.""" """Fetches a domain's h-card and converts to AS2 actor."""
_, _, actor, _ = common.actor(domain) tld = domain.split('.')[-1]
if tld in TLD_BLOCKLIST:
error('', status=404)
user = User.get_by_id(domain)
if not user:
return f'User {domain} not found', 404
_, _, actor = common.actor(user)
return (actor, { return (actor, {
'Content-Type': as2.CONTENT_TYPE, 'Content-Type': as2.CONTENT_TYPE,
'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Origin': '*',
@ -54,6 +62,12 @@ def inbox(domain=None):
body = request.get_data(as_text=True) body = request.get_data(as_text=True)
logger.info(f'Got: {body}') logger.info(f'Got: {body}')
user = None
if domain:
user = User.get_by_id(domain)
if not user:
return f'User {domain} not found', 404
# parse and validate AS2 activity # parse and validate AS2 activity
try: try:
activity = request.json activity = request.json
@ -108,8 +122,6 @@ def inbox(domain=None):
ndb.put_multi(followers) ndb.put_multi(followers)
return 'OK' return 'OK'
user = User.get_or_create(domain) if domain else None
# fetch actor if necessary so we have name, profile photo, etc # fetch actor if necessary so we have name, profile photo, etc
if actor and isinstance(actor, str): if actor and isinstance(actor, str):
actor = activity['actor'] = common.get_as2(actor, user=user).json() actor = activity['actor'] = common.get_as2(actor, user=user).json()
@ -189,10 +201,7 @@ def accept_follow(follow, follow_unwrapped, user):
follow_unwrapped.setdefault('url', f'{follower_url}#followed-{followee_url}') follow_unwrapped.setdefault('url', f'{follower_url}#followed-{followee_url}')
# store Follower # store Follower
followee_domain = util.domain_from_link(followee_id, minimize=False) follower = Follower.get_or_create(dest=user.key.id(), src=follower_id,
# follow use_instead, if any
followee_domain = User.get_or_create(followee_domain).key.id()
follower = Follower.get_or_create(dest=followee_domain, src=follower_id,
last_follow=json_dumps(follow)) last_follow=json_dumps(follow))
follower.status = 'active' follower.status = 'active'
follower.put() follower.put()
@ -201,7 +210,7 @@ def accept_follow(follow, follow_unwrapped, user):
accept = { accept = {
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
'id': util.tag_uri(common.PRIMARY_DOMAIN, 'id': util.tag_uri(common.PRIMARY_DOMAIN,
f'accept/{followee_domain}/{follow.get("id")}'), f'accept/{user.key.id()}/{follow.get("id")}'),
'type': 'Accept', 'type': 'Accept',
'actor': followee, 'actor': followee,
'object': { 'object': {

Wyświetl plik

@ -593,7 +593,7 @@ def redirect_unwrap(val):
return val return val
def actor(domain, user=None): def actor(user):
"""Fetches a home page, converts its representative h-card to AS2 actor. """Fetches a home page, converts its representative h-card to AS2 actor.
Creates a User for the given domain if one doesn't already exist. Creates a User for the given domain if one doesn't already exist.
@ -601,15 +601,13 @@ def actor(domain, user=None):
TODO: unify with webfinger.Actor TODO: unify with webfinger.Actor
Args: Args:
domain: str user: :class:`User`
user: :class:`User`, optional
Returns: (dict mf2 item, dict AS1 actor, dict AS2 actor, User) Returns: (dict mf2 item, dict AS1 actor, dict AS2 actor)
""" """
tld = domain.split('.')[-1] assert user
if tld in TLD_BLOCKLIST:
error('', status=404)
domain = user.key.id()
url = f'https://{domain}/' url = f'https://{domain}/'
try: try:
mf2 = util.fetch_mf2(url, gateway=True) mf2 = util.fetch_mf2(url, gateway=True)
@ -621,9 +619,6 @@ def actor(domain, user=None):
if not hcard: if not hcard:
error(f"Couldn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on {mf2['url']}") error(f"Couldn't find a representative h-card (http://microformats.org/wiki/representative-hcard-parsing) on {mf2['url']}")
if not user:
user = User.get_or_create(domain)
actor_as1 = microformats2.json_to_object(hcard, rel_urls=mf2.get('rel-urls')) actor_as1 = microformats2.json_to_object(hcard, rel_urls=mf2.get('rel-urls'))
actor_as2 = postprocess_as2(as2.from_as1(actor_as1), user=user) actor_as2 = postprocess_as2(as2.from_as1(actor_as1), user=user)
actor_as2.update({ actor_as2.update({
@ -644,7 +639,7 @@ def actor(domain, user=None):
}) })
logger.info(f'Generated AS2 actor: {json_dumps(actor_as2, indent=2)}') logger.info(f'Generated AS2 actor: {json_dumps(actor_as2, indent=2)}')
return hcard, actor_as1, actor_as2, user return hcard, actor_as1, actor_as2
def fetch_followers(domain, collection): def fetch_followers(domain, collection):

Wyświetl plik

@ -219,7 +219,7 @@ class User(StringIdModel):
# check home page # check home page
try: try:
_, _, actor_as2, _ = common.actor(self.key.id(), user=self) _, _, actor_as2 = common.actor(self)
self.actor_as2 = json_dumps(actor_as2) self.actor_as2 = json_dumps(actor_as2)
self.has_hcard = True self.has_hcard = True
except (BadRequest, NotFound): except (BadRequest, NotFound):

Wyświetl plik

@ -106,10 +106,10 @@ FOLLOW = {
'id': 'https://mastodon.social/6d1a', 'id': 'https://mastodon.social/6d1a',
'type': 'Follow', 'type': 'Follow',
'actor': ACTOR['id'], 'actor': ACTOR['id'],
'object': 'https://www.realize.be/', 'object': 'https://foo.com/',
} }
FOLLOW_WRAPPED = copy.deepcopy(FOLLOW) FOLLOW_WRAPPED = copy.deepcopy(FOLLOW)
FOLLOW_WRAPPED['object'] = 'http://localhost/www.realize.be' FOLLOW_WRAPPED['object'] = 'http://localhost/foo.com'
FOLLOW_WITH_ACTOR = copy.deepcopy(FOLLOW) FOLLOW_WITH_ACTOR = copy.deepcopy(FOLLOW)
FOLLOW_WITH_ACTOR['actor'] = ACTOR FOLLOW_WITH_ACTOR['actor'] = ACTOR
FOLLOW_WRAPPED_WITH_ACTOR = copy.deepcopy(FOLLOW_WRAPPED) FOLLOW_WRAPPED_WITH_ACTOR = copy.deepcopy(FOLLOW_WRAPPED)
@ -120,12 +120,12 @@ FOLLOW_WITH_OBJECT['object'] = ACTOR
ACCEPT = { ACCEPT = {
'@context': 'https://www.w3.org/ns/activitystreams', '@context': 'https://www.w3.org/ns/activitystreams',
'type': 'Accept', 'type': 'Accept',
'id': 'tag:fed.brid.gy:accept/www.realize.be/https://mastodon.social/6d1a', 'id': 'tag:fed.brid.gy:accept/foo.com/https://mastodon.social/6d1a',
'actor': 'http://localhost/www.realize.be', 'actor': 'http://localhost/foo.com',
'object': { 'object': {
'type': 'Follow', 'type': 'Follow',
'actor': 'https://mastodon.social/users/swentel', 'actor': 'https://mastodon.social/users/swentel',
'object': 'http://localhost/www.realize.be', 'object': 'http://localhost/foo.com',
} }
} }
@ -171,6 +171,10 @@ UPDATE_NOTE = {
@patch('requests.head') @patch('requests.head')
class ActivityPubTest(testutil.TestCase): class ActivityPubTest(testutil.TestCase):
def setUp(self):
super().setUp()
User.get_or_create('foo.com')
def test_actor(self, _, mock_get, __): def test_actor(self, _, mock_get, __):
mock_get.return_value = requests_response(""" mock_get.return_value = requests_response("""
<body> <body>
@ -283,9 +287,17 @@ class ActivityPubTest(testutil.TestCase):
mock_get.side_effect = [ mock_get.side_effect = [
ValueError('Invalid IPv6 URL'), ValueError('Invalid IPv6 URL'),
] ]
got = self.client.get('/snarfed.org]') got = self.client.get('/foo.com')
self.assertEqual(400, got.status_code) self.assertEqual(400, got.status_code)
def test_actor_no_user(self, *mocks):
got = self.client.get('/nope.com')
self.assertEqual(404, got.status_code)
def test_individual_inbox_no_user(self, *mocks):
got = self.client.post('/nope.com/inbox', json=REPLY)
self.assertEqual(404, got.status_code)
def test_inbox_reply_object(self, *mocks): def test_inbox_reply_object(self, *mocks):
self._test_inbox_reply(REPLY_OBJECT, self._test_inbox_reply(REPLY_OBJECT,
{'as2': REPLY_OBJECT, {'as2': REPLY_OBJECT,
@ -357,7 +369,7 @@ class ActivityPubTest(testutil.TestCase):
mock_get.assert_not_called() mock_get.assert_not_called()
mock_post.assert_not_called() mock_post.assert_not_called()
def test_personal_inbox_create_obj(self, *mocks): def test_individual_inbox_create_obj(self, *mocks):
self._test_inbox_create_obj('/foo.com/inbox', *mocks) self._test_inbox_create_obj('/foo.com/inbox', *mocks)
def test_shared_inbox_create_obj(self, *mocks): def test_shared_inbox_create_obj(self, *mocks):
@ -498,15 +510,15 @@ class ActivityPubTest(testutil.TestCase):
mock_head, mock_get, mock_post) mock_head, mock_get, mock_post)
follow = copy.deepcopy(FOLLOW_WITH_ACTOR) follow = copy.deepcopy(FOLLOW_WITH_ACTOR)
follow['url'] = 'https://mastodon.social/users/swentel#followed-https://www.realize.be/' follow['url'] = 'https://mastodon.social/users/swentel#followed-https://foo.com/'
self.assert_object('https://mastodon.social/6d1a', self.assert_object('https://mastodon.social/6d1a',
domains=['www.realize.be'], domains=['foo.com'],
source_protocol='activitypub', source_protocol='activitypub',
status='complete', status='complete',
as2=follow, as2=follow,
as1=as2.to_as1(follow), as1=as2.to_as1(follow),
delivered=['https://www.realize.be/'], delivered=['https://foo.com/'],
type='follow', type='follow',
labels=['notification', 'activity'], labels=['notification', 'activity'],
object_ids=[FOLLOW['object']]) object_ids=[FOLLOW['object']])
@ -542,22 +554,22 @@ class ActivityPubTest(testutil.TestCase):
follow.update({ follow.update({
'actor': FOLLOW_WITH_ACTOR['actor'], 'actor': FOLLOW_WITH_ACTOR['actor'],
'object': unwrapped_user, 'object': unwrapped_user,
'url': 'https://mastodon.social/users/swentel#followed-https://www.realize.be/', 'url': 'https://mastodon.social/users/swentel#followed-https://foo.com/',
}) })
self.assert_object('https://mastodon.social/6d1a', self.assert_object('https://mastodon.social/6d1a',
domains=['www.realize.be'], domains=['foo.com'],
source_protocol='activitypub', source_protocol='activitypub',
status='complete', status='complete',
as2=follow, as2=follow,
as1=as2.to_as1(follow), as1=as2.to_as1(follow),
delivered=['https://www.realize.be/'], delivered=['https://foo.com/'],
type='follow', type='follow',
labels=['notification', 'activity'], labels=['notification', 'activity'],
object_ids=[FOLLOW['object']]) object_ids=[FOLLOW['object']])
def _test_inbox_follow_accept(self, follow_as2, accept_as2, def _test_inbox_follow_accept(self, follow_as2, accept_as2,
mock_head, mock_get, mock_post): mock_head, mock_get, mock_post):
mock_head.return_value = requests_response(url='https://www.realize.be/') mock_head.return_value = requests_response(url='https://foo.com/')
mock_get.side_effect = [ mock_get.side_effect = [
# source actor # source actor
self.as2_resp(FOLLOW_WITH_ACTOR['actor']), self.as2_resp(FOLLOW_WITH_ACTOR['actor']),
@ -582,21 +594,21 @@ class ActivityPubTest(testutil.TestCase):
# check webmention # check webmention
args, kwargs = mock_post.call_args_list[1] args, kwargs = mock_post.call_args_list[1]
self.assertEqual(('https://www.realize.be/webmention',), args) self.assertEqual(('https://foo.com/webmention',), args)
self.assertEqual({ self.assertEqual({
'source': 'http://localhost/render?id=https%3A%2F%2Fmastodon.social%2F6d1a', 'source': 'http://localhost/render?id=https%3A%2F%2Fmastodon.social%2F6d1a',
'target': 'https://www.realize.be/', 'target': 'https://foo.com/',
}, kwargs['data']) }, kwargs['data'])
# check that we stored a Follower object # check that we stored a Follower object
follower = Follower.get_by_id(f'www.realize.be {FOLLOW["actor"]}') follower = Follower.get_by_id(f'foo.com {FOLLOW["actor"]}')
self.assertEqual('active', follower.status) self.assertEqual('active', follower.status)
def test_inbox_follow_use_instead_strip_www(self, mock_head, mock_get, mock_post): def test_inbox_follow_use_instead_strip_www(self, mock_head, mock_get, mock_post):
root = User.get_or_create('realize.be') root = User.get_or_create('foo.com')
User.get_or_create('www.realize.be', use_instead=root.key).put() User.get_or_create('www.foo.com', use_instead=root.key).put()
mock_head.return_value = requests_response(url='https://www.realize.be/') mock_head.return_value = requests_response(url='https://www.foo.com/')
mock_get.side_effect = [ mock_get.side_effect = [
# source actor # source actor
self.as2_resp(ACTOR), self.as2_resp(ACTOR),
@ -606,32 +618,32 @@ class ActivityPubTest(testutil.TestCase):
mock_post.return_value = requests_response() mock_post.return_value = requests_response()
follow = copy.deepcopy(FOLLOW_WRAPPED) follow = copy.deepcopy(FOLLOW_WRAPPED)
follow['object'] = 'http://localhost/realize.be' follow['object'] = 'http://localhost/foo.com'
got = self.client.post('/foo.com/inbox', json=follow) got = self.client.post('/foo.com/inbox', json=follow)
self.assertEqual(200, got.status_code) self.assertEqual(200, got.status_code)
# check that the Follower doesn't have www # check that the Follower doesn't have www
follower = Follower.get_by_id(f'realize.be {ACTOR["id"]}') follower = Follower.get_by_id(f'foo.com {ACTOR["id"]}')
self.assertEqual('active', follower.status) self.assertEqual('active', follower.status)
follow['actor'] = ACTOR follow['actor'] = ACTOR
self.assertEqual(follow, json_loads(follower.last_follow)) self.assertEqual(follow, json_loads(follower.last_follow))
def test_inbox_undo_follow(self, mock_head, mock_get, mock_post): def test_inbox_undo_follow(self, mock_head, mock_get, mock_post):
mock_head.return_value = requests_response(url='https://www.realize.be/') mock_head.return_value = requests_response(url='https://foo.com/')
Follower.get_or_create('www.realize.be', ACTOR['id']) Follower.get_or_create('foo.com', ACTOR['id'])
got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED)
self.assertEqual(200, got.status_code) self.assertEqual(200, got.status_code)
follower = Follower.get_by_id(f'www.realize.be {FOLLOW["actor"]}') follower = Follower.get_by_id(f'foo.com {FOLLOW["actor"]}')
self.assertEqual('inactive', follower.status) self.assertEqual('inactive', follower.status)
def test_inbox_follow_inactive(self, mock_head, mock_get, mock_post): def test_inbox_follow_inactive(self, mock_head, mock_get, mock_post):
Follower.get_or_create('www.realize.be', ACTOR['id'], status='inactive') Follower.get_or_create('foo.com', ACTOR['id'], status='inactive')
mock_head.return_value = requests_response(url='https://www.realize.be/') mock_head.return_value = requests_response(url='https://foo.com/')
mock_get.side_effect = [ mock_get.side_effect = [
# source actor # source actor
self.as2_resp(FOLLOW_WITH_ACTOR['actor']), self.as2_resp(FOLLOW_WITH_ACTOR['actor']),
@ -645,25 +657,25 @@ class ActivityPubTest(testutil.TestCase):
self.assertEqual(200, got.status_code) self.assertEqual(200, got.status_code)
# check that the Follower is now active # check that the Follower is now active
follower = Follower.get_by_id(f'www.realize.be {FOLLOW["actor"]}') follower = Follower.get_by_id(f'foo.com {FOLLOW["actor"]}')
self.assertEqual('active', follower.status) self.assertEqual('active', follower.status)
def test_inbox_undo_follow_doesnt_exist(self, mock_head, mock_get, mock_post): def test_inbox_undo_follow_doesnt_exist(self, mock_head, mock_get, mock_post):
mock_head.return_value = requests_response(url='https://realize.be/') mock_head.return_value = requests_response(url='https://foo.com/')
got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED)
self.assertEqual(200, got.status_code) self.assertEqual(200, got.status_code)
def test_inbox_undo_follow_inactive(self, mock_head, mock_get, mock_post): def test_inbox_undo_follow_inactive(self, mock_head, mock_get, mock_post):
mock_head.return_value = requests_response(url='https://realize.be/') mock_head.return_value = requests_response(url='https://foo.com/')
Follower.get_or_create('realize.be', ACTOR['id'], status='inactive') Follower.get_or_create('foo.com', ACTOR['id'], status='inactive')
got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED)
self.assertEqual(200, got.status_code) self.assertEqual(200, got.status_code)
def test_inbox_undo_follow_composite_object(self, mock_head, mock_get, mock_post): def test_inbox_undo_follow_composite_object(self, mock_head, mock_get, mock_post):
mock_head.return_value = requests_response(url='https://realize.be/') mock_head.return_value = requests_response(url='https://foo.com/')
Follower.get_or_create('realize.be', ACTOR['id'], status='inactive') Follower.get_or_create('foo.com', ACTOR['id'], status='inactive')
undo_follow = copy.deepcopy(UNDO_FOLLOW_WRAPPED) undo_follow = copy.deepcopy(UNDO_FOLLOW_WRAPPED)
undo_follow['object']['object'] = {'id': undo_follow['object']['object']} undo_follow['object']['object'] = {'id': undo_follow['object']['object']}
@ -698,13 +710,13 @@ class ActivityPubTest(testutil.TestCase):
def test_individual_inbox_delete_actor_noop(self, mock_head, mock_get, mock_post): def test_individual_inbox_delete_actor_noop(self, mock_head, mock_get, mock_post):
"""Deletes sent to individual users' inboxes do nothing.""" """Deletes sent to individual users' inboxes do nothing."""
follower = Follower.get_or_create('realize.be', DELETE['actor']) follower = Follower.get_or_create('foo.com', DELETE['actor'])
followee = Follower.get_or_create(DELETE['actor'], 'snarfed.org') followee = Follower.get_or_create(DELETE['actor'], 'snarfed.org')
# other unrelated follower # other unrelated follower
other = Follower.get_or_create('realize.be', 'https://mas.to/users/other') other = Follower.get_or_create('foo.com', 'https://mas.to/users/other')
self.assertEqual(3, Follower.query().count()) self.assertEqual(3, Follower.query().count())
got = self.client.post('/realize.be/inbox', json=DELETE) got = self.client.post('/foo.com/inbox', json=DELETE)
self.assertEqual(200, got.status_code) self.assertEqual(200, got.status_code)
self.assertEqual('active', follower.key.get().status) self.assertEqual('active', follower.key.get().status)
self.assertEqual('active', followee.key.get().status) self.assertEqual('active', followee.key.get().status)
@ -712,10 +724,10 @@ class ActivityPubTest(testutil.TestCase):
def test_shared_inbox_delete_actor(self, mock_head, mock_get, mock_post): def test_shared_inbox_delete_actor(self, mock_head, mock_get, mock_post):
"""Deletes sent to the shared inbox actually deactivate followers.""" """Deletes sent to the shared inbox actually deactivate followers."""
follower = Follower.get_or_create('realize.be', DELETE['actor']) follower = Follower.get_or_create('foo.com', DELETE['actor'])
followee = Follower.get_or_create(DELETE['actor'], 'snarfed.org') followee = Follower.get_or_create(DELETE['actor'], 'snarfed.org')
# other unrelated follower # other unrelated follower
other = Follower.get_or_create('realize.be', 'https://mas.to/users/other') other = Follower.get_or_create('foo.com', 'https://mas.to/users/other')
self.assertEqual(3, Follower.query().count()) self.assertEqual(3, Follower.query().count())
got = self.client.post('/inbox', json=DELETE) got = self.client.post('/inbox', json=DELETE)
@ -768,7 +780,7 @@ class ActivityPubTest(testutil.TestCase):
object_ids=[LIKE['object']]) object_ids=[LIKE['object']])
def test_followers_collection_unknown_user(self, *args): def test_followers_collection_unknown_user(self, *args):
resp = self.client.get('/foo.com/followers') resp = self.client.get('/nope.com/followers')
self.assertEqual(404, resp.status_code) self.assertEqual(404, resp.status_code)
def test_followers_collection_empty(self, *args): def test_followers_collection_empty(self, *args):
@ -836,7 +848,7 @@ class ActivityPubTest(testutil.TestCase):
}, resp.json) }, resp.json)
def test_following_collection_unknown_user(self, *args): def test_following_collection_unknown_user(self, *args):
resp = self.client.get('/foo.com/following') resp = self.client.get('/nope.com/following')
self.assertEqual(404, resp.status_code) self.assertEqual(404, resp.status_code)
def test_following_collection_empty(self, *args): def test_following_collection_empty(self, *args):

Wyświetl plik

@ -55,9 +55,12 @@ class Webmention(View):
# if source is home page, send an actor Update to followers' instances # if source is home page, send an actor Update to followers' instances
if source.strip('/') == f'https://{self.source_domain}': if source.strip('/') == f'https://{self.source_domain}':
self.user = User.get_by_id(self.source_domain)
if not self.user:
return f'User {self.source_domain} not found', 404
self.source_url = source self.source_url = source
self.source_mf2, actor_as1, actor_as2, self.user = \ self.source_mf2, actor_as1, actor_as2 = common.actor(self.user)
common.actor(self.source_domain)
id = common.host_url(f'{source}#update-{util.now().isoformat()}') id = common.host_url(f'{source}#update-{util.now().isoformat()}')
self.source_as1 = { self.source_as1 = {
'objectType': 'activity', 'objectType': 'activity',