diff --git a/activitypub.py b/activitypub.py index e3eb759..1da6303 100644 --- a/activitypub.py +++ b/activitypub.py @@ -8,14 +8,13 @@ import re import threading from cachetools import LRUCache -from flask import request +from flask import abort, make_response, request from google.cloud import ndb from google.cloud.ndb import OR from granary import as1, as2 from httpsig import HeaderVerifier from httpsig.utils import parse_signature_header from oauth_dropins.webutil import flask_util, util -from oauth_dropins.webutil.flask_util import error from oauth_dropins.webutil.util import json_dumps, json_loads from app import app, cache @@ -49,6 +48,12 @@ seen_ids = LRUCache(100000) seen_ids_lock = threading.Lock() +def error(msg, status=400): + """Like flask_util.error, but wraps body in JSON.""" + logger.info(f'Returning {status}: {msg}') + abort(status, response=make_response({'error': msg}, status)) + + @app.get(f'/') @flask_util.cached(cache, CACHE_TIME) def actor(domain): @@ -153,26 +158,29 @@ def inbox(domain=None): digest = request.headers.get('Digest') or '' expected = b64encode(sha256(request.data).digest()).decode() if not keyId: - logger.warning('HTTP Signature missing keyId') + error('HTTP Signature missing keyId', status=401) elif not digest: - logger.warning('Missing Digest header, required for HTTP Signature') + error('Missing Digest header, required for HTTP Signature', status=401) elif digest.removeprefix('SHA-256=') != expected: - logger.warning('Invalid Digest header, required for HTTP Signature') + error('Invalid Digest header, required for HTTP Signature', status=401) else: key_actor = common.get_object(keyId, user=user).as2 key = key_actor.get("publicKey", {}).get('publicKeyPem') logger.info(f'Verifying signature for {request.path} with key {key}') try: - if HeaderVerifier(request.headers, key, required_headers=['Digest'], - method=request.method, path=request.path, - sign_header='signature').verify(): - logger.info('HTTP Signature verified!') - else: - logger.warning('HTTP Signature verification failed') + verified = HeaderVerifier(request.headers, key, + required_headers=['Digest'], + method=request.method, + path=request.path, + sign_header='signature').verify() except BaseException as e: - logger.warning(f'HTTP Signature verification failed: {e}') + error(f'HTTP Signature verification failed: {e}', status=401) + if verified: + logger.info('HTTP Signature verified!') + else: + error('HTTP Signature verification failed', status=401) else: - logger.info('No HTTP Signature') + error('No HTTP Signature', status=401) # handle activity! if type == 'Undo' and obj_as2.get('type') == 'Follow': diff --git a/tests/test_activitypub.py b/tests/test_activitypub.py index ba27aab..e67d603 100644 --- a/tests/test_activitypub.py +++ b/tests/test_activitypub.py @@ -203,6 +203,34 @@ class ActivityPubTest(testutil.TestCase): def setUp(self): super().setUp() self.user = User.get_or_create('foo.com', has_hcard=True, actor_as2=ACTOR) + with app.test_request_context('/'): + self.key_id_obj = Object(id='http://my/key/id', as2={ + **ACTOR, + 'publicKey': { + 'id': 'http://my/key/id#unused', + 'owner': 'http://own/er', + 'publicKeyPem': self.user.public_pem().decode(), + }, + }).put() + + def sign(self, path, body): + """Constructs HTTP Signature, returns headers.""" + digest = b64encode(sha256(body.encode()).digest()).decode() + headers = { + 'Date': 'Sun, 02 Jan 2022 03:04:05 GMT', + 'Host': 'localhost', + 'Content-Type': as2.CONTENT_TYPE, + 'Digest': f'SHA-256={digest}', + } + hs = HeaderSigner('http://my/key/id#unused', self.user.private_pem().decode(), + algorithm='rsa-sha256', sign_header='signature', + headers=('Date', 'Host', 'Digest', '(request-target)')) + return hs.sign(headers, method='POST', path=path) + + def post(self, path, json=None): + """Wrapper around self.client.post that adds signature.""" + body = json_dumps(json) + return self.client.post(path, data=body, headers=self.sign(path, body)) def test_actor(self, *_): got = self.client.get('/foo.com') @@ -244,13 +272,13 @@ class ActivityPubTest(testutil.TestCase): self.assertEqual(404, got.status_code) def test_individual_inbox_no_user(self, *mocks): - got = self.client.post('/nope.com/inbox', json=REPLY) + got = self.post('/nope.com/inbox', json=REPLY) self.assertEqual(404, got.status_code) def test_inbox_activity_without_id(self, *_): note = copy.deepcopy(NOTE) del note['id'] - resp = self.client.post('/inbox', json=note) + resp = self.post('/inbox', json=note) self.assertEqual(400, resp.status_code) def test_inbox_reply_object(self, *mocks): @@ -282,7 +310,7 @@ class ActivityPubTest(testutil.TestCase): '') mock_post.return_value = requests_response() - got = self.client.post('/foo.com/inbox', json=reply) + got = self.post('/foo.com/inbox', json=reply) self.assertEqual(200, got.status_code, got.get_data(as_text=True)) self.assert_req(mock_get, 'http://or.ig/post') expected_id = urllib.parse.quote_plus(reply['id']) @@ -317,7 +345,7 @@ class ActivityPubTest(testutil.TestCase): mock_head.return_value = requests_response(url='http://th.is/') - got = self.client.post('/foo.com/inbox', json=reply) + got = self.post('/foo.com/inbox', json=reply) self.assertEqual(200, got.status_code, got.get_data(as_text=True)) mock_get.assert_not_called() @@ -338,7 +366,7 @@ class ActivityPubTest(testutil.TestCase): mock_get.return_value = self.as2_resp(ACTOR) # source actor mock_post.return_value = requests_response() - got = self.client.post(path, json=NOTE) + got = self.post(path, json=NOTE) self.assertEqual(200, got.status_code, got.get_data(as_text=True)) expected_as2 = common.redirect_unwrap({ **NOTE, @@ -374,7 +402,7 @@ class ActivityPubTest(testutil.TestCase): **REPOST_FULL, 'object': f'http://localhost/r/{orig_url}', } - got = self.client.post('/foo.com/inbox', json=repost) + got = self.post('/foo.com/inbox', json=repost) self.assertEqual(200, got.status_code, got.get_data(as_text=True)) source_url = f'http://localhost/render?id={urllib.parse.quote_plus(REPOST["id"])}' @@ -413,7 +441,7 @@ class ActivityPubTest(testutil.TestCase): ] mock_post.return_value = requests_response() # webmention - got = self.client.post('/inbox', json=REPOST) + got = self.post('/inbox', json=REPOST) self.assertEqual(200, got.status_code, got.get_data(as_text=True)) # webmention @@ -448,7 +476,7 @@ class ActivityPubTest(testutil.TestCase): not_public = copy.deepcopy(NOTE) del not_public['object']['to'] - got = self.client.post('/foo.com/inbox', json=not_public) + got = self.post('/foo.com/inbox', json=not_public) self.assertEqual(200, got.status_code, got.get_data(as_text=True)) obj = Object.get_by_id(not_public['id']) @@ -483,7 +511,7 @@ class ActivityPubTest(testutil.TestCase): '') mock_post.return_value = requests_response() - got = self.client.post('/foo.com/inbox', json=mention) + got = self.post('/foo.com/inbox', json=mention) self.assertEqual(200, got.status_code, got.get_data(as_text=True)) self.assert_req(mock_get, 'https://tar.get/') expected_id = urllib.parse.quote_plus(mention['id']) @@ -516,7 +544,7 @@ class ActivityPubTest(testutil.TestCase): ] mock_post.return_value = requests_response() - got = self.client.post('/foo.com/inbox', json=LIKE) + got = self.post('/foo.com/inbox', json=LIKE) self.assertEqual(200, got.status_code) mock_get.assert_has_calls(( @@ -608,7 +636,7 @@ class ActivityPubTest(testutil.TestCase): ] mock_post.return_value = requests_response() - got = self.client.post('/foo.com/inbox', json=follow_as2) + got = self.post('/foo.com/inbox', json=follow_as2) self.assertEqual(200, got.status_code) mock_get.assert_has_calls(( @@ -645,7 +673,7 @@ class ActivityPubTest(testutil.TestCase): ] mock_post.return_value = requests_response() - got = self.client.post('/foo.com/inbox', json=FOLLOW_WRAPPED) + got = self.post('/foo.com/inbox', json=FOLLOW_WRAPPED) self.assertEqual(200, got.status_code) # check that the Follower doesn't have www @@ -661,7 +689,7 @@ class ActivityPubTest(testutil.TestCase): Follower.get_or_create('foo.com', ACTOR['id']) - got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) + got = self.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) self.assertEqual(200, got.status_code) follower = Follower.get_by_id(f'foo.com {FOLLOW["actor"]}') @@ -678,7 +706,7 @@ class ActivityPubTest(testutil.TestCase): ] mock_post.return_value = requests_response() - got = self.client.post('/foo.com/inbox', json=FOLLOW_WRAPPED) + got = self.post('/foo.com/inbox', json=FOLLOW_WRAPPED) self.assertEqual(200, got.status_code) # check that the Follower is now active @@ -691,7 +719,7 @@ class ActivityPubTest(testutil.TestCase): self.as2_resp(ACTOR), ] - got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) + got = self.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) self.assertEqual(200, got.status_code) def test_inbox_undo_follow_inactive(self, mock_head, mock_get, mock_post): @@ -702,7 +730,7 @@ class ActivityPubTest(testutil.TestCase): Follower.get_or_create('foo.com', ACTOR['id'], status='inactive') - got = self.client.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) + got = self.post('/foo.com/inbox', json=UNDO_FOLLOW_WRAPPED) self.assertEqual(200, got.status_code) def test_inbox_undo_follow_composite_object(self, mock_head, mock_get, mock_post): @@ -715,11 +743,11 @@ class ActivityPubTest(testutil.TestCase): undo_follow = copy.deepcopy(UNDO_FOLLOW_WRAPPED) undo_follow['object']['object'] = {'id': undo_follow['object']['object']} - got = self.client.post('/foo.com/inbox', json=undo_follow) + got = self.post('/foo.com/inbox', json=undo_follow) self.assertEqual(200, got.status_code) def test_inbox_unsupported_type(self, *_): - got = self.client.post('/foo.com/inbox', json={ + got = self.post('/foo.com/inbox', json={ '@context': ['https://www.w3.org/ns/activitystreams'], 'id': 'https://xoxo.zone/users/aaronpk#follows/40', 'type': 'Block', @@ -734,7 +762,7 @@ class ActivityPubTest(testutil.TestCase): id = 'https://mastodon.social/users/tmichellemoore#likes/56486252' bad_url = 'http://localhost/r/Testing \u2013 Brid.gy \u2013 Post to Mastodon 3' - got = self.client.post('/foo.com/inbox', json={ + got = self.post('/foo.com/inbox', json={ '@context': 'https://www.w3.org/ns/activitystreams', 'id': id, 'type': 'Like', @@ -752,10 +780,11 @@ class ActivityPubTest(testutil.TestCase): self.assertIsNone(Object.get_by_id(bad_url)) - @patch('activitypub.logger.warning', side_effect=logging.warning) @patch('activitypub.logger.info', side_effect=logging.info) - def test_inbox_verify_http_signature(self, mock_info, mock_warning, _, mock_get, ___): + def test_inbox_verify_http_signature(self, mock_info, _, mock_get, ___): # actor with a public key + self.key_id_obj.delete() + common.get_object.cache.clear() mock_get.return_value = self.as2_resp({ **ACTOR, 'publicKey': { @@ -765,21 +794,9 @@ class ActivityPubTest(testutil.TestCase): }, }) - # manually construct signature - body = json_dumps(NOTE) - digest = b64encode(sha256(body.encode()).digest()).decode() - headers = { - 'Date': 'Sun, 02 Jan 2022 03:04:05 GMT', - 'Host': 'localhost', - 'Content-Type': as2.CONTENT_TYPE, - 'Digest': f'SHA-256={digest}', - } - hs = HeaderSigner('http://my/key/id#unused', self.user.private_pem().decode(), - algorithm='rsa-sha256', sign_header='signature', - headers=('Date', 'Host', 'Digest', '(request-target)')) - headers = hs.sign(headers, method='POST', path='/inbox') - # valid signature + body = json_dumps(NOTE) + headers = self.sign('/inbox', json_dumps(NOTE)) resp = self.client.post('/inbox', data=body, headers=headers) self.assertEqual(200, resp.status_code, resp.get_data(as_text=True)) mock_get.assert_has_calls(( @@ -797,8 +814,9 @@ class ActivityPubTest(testutil.TestCase): 'signature': headers['signature'].replace( 'keyId="http://my/key/id#unused",', ''), }) - self.assertEqual(200, resp.status_code, resp.get_data(as_text=True)) - mock_warning.assert_any_call('HTTP Signature missing keyId') + self.assertEqual(401, resp.status_code) + self.assertEqual({'error': 'HTTP Signature missing keyId'}, resp.json) + mock_info.assert_any_call('Returning 401: HTTP Signature missing keyId') # invalid signature, content changed activitypub.seen_ids.clear() @@ -806,11 +824,10 @@ class ActivityPubTest(testutil.TestCase): obj_key.delete() resp = self.client.post('/inbox', json={**NOTE, 'content': 'z'}, headers=headers) - self.assertEqual(200, resp.status_code, resp.get_data(as_text=True)) - mock_warning.assert_any_call('Invalid Digest header, required for HTTP Signature') - - # TODO once we start failing on bad sigs - # self.assertEqual(401, resp.status_code) + self.assertEqual(401, resp.status_code) + self.assertEqual({'error': 'Invalid Digest header, required for HTTP Signature'}, + resp.json) + mock_info.assert_any_call('Returning 401: Invalid Digest header, required for HTTP Signature') # invalid signature, header changed activitypub.seen_ids.clear() @@ -818,18 +835,17 @@ class ActivityPubTest(testutil.TestCase): orig_date = headers['Date'] resp = self.client.post('/inbox', data=body, headers={**headers, 'Date': 'X'}) - self.assertEqual(200, resp.status_code, resp.get_data(as_text=True)) - mock_warning.assert_any_call('HTTP Signature verification failed') - - # TODO once we start failing on bad sigs - # self.assertEqual(401, resp.status_code) + self.assertEqual(401, resp.status_code) + self.assertEqual({'error': 'HTTP Signature verification failed'}, resp.json) + mock_info.assert_any_call('Returning 401: HTTP Signature verification failed') # no signature activitypub.seen_ids.clear() obj_key.delete() resp = self.client.post('/inbox', json=NOTE) - self.assertEqual(200, resp.status_code, resp.get_data(as_text=True)) - mock_info.assert_any_call('No HTTP Signature') + self.assertEqual(401, resp.status_code, resp.get_data(as_text=True)) + self.assertEqual({'error': 'No HTTP Signature'}, resp.json) + mock_info.assert_any_call('Returning 401: No HTTP Signature') def test_delete_actor(self, _, mock_get, ___): follower = Follower.get_or_create('foo.com', DELETE['actor']) @@ -842,7 +858,7 @@ class ActivityPubTest(testutil.TestCase): self.as2_resp(ACTOR), ] - got = self.client.post('/inbox', json=DELETE) + got = self.post('/inbox', json=DELETE) self.assertEqual(200, got.status_code) self.assertEqual('inactive', follower.key.get().status) self.assertEqual('inactive', followee.key.get().status) @@ -860,7 +876,7 @@ class ActivityPubTest(testutil.TestCase): **DELETE, 'object': 'http://an/obj', } - resp = self.client.post('/inbox', json=delete) + resp = self.post('/inbox', json=delete) self.assertEqual(200, resp.status_code) self.assertTrue(obj.key.get().deleted) self.assert_object(delete['id'], as2=delete, @@ -882,7 +898,7 @@ class ActivityPubTest(testutil.TestCase): self.as2_resp(ACTOR), ] - resp = self.client.post('/inbox', json=UPDATE_NOTE) + resp = self.post('/inbox', json=UPDATE_NOTE) self.assertEqual(200, resp.status_code) obj = UPDATE_NOTE['object'] @@ -903,7 +919,7 @@ class ActivityPubTest(testutil.TestCase): ReadTimeoutError(None, None, None), ] - got = self.client.post('/foo.com/inbox', json=LIKE) + got = self.post('/foo.com/inbox', json=LIKE) self.assertEqual(504, got.status_code) def test_inbox_no_webmention_endpoint(self, mock_head, mock_get, mock_post): @@ -914,7 +930,7 @@ class ActivityPubTest(testutil.TestCase): requests_response('foo'), ] - got = self.client.post('/foo.com/inbox', json=LIKE) + got = self.post('/foo.com/inbox', json=LIKE) self.assertEqual(200, got.status_code) self.assert_object('http://th.is/like#ok', @@ -929,13 +945,13 @@ class ActivityPubTest(testutil.TestCase): def test_inbox_id_already_seen(self, *mocks): obj_key = Object(id=FOLLOW_WRAPPED['id'], as2={}).put() - got = self.client.post('/foo.com/inbox', json=FOLLOW_WRAPPED) + got = self.post('/foo.com/inbox', json=FOLLOW_WRAPPED) self.assertEqual(200, got.status_code) self.assertEqual(0, Follower.query().count()) # second time should use in memory cache obj_key.delete() - got = self.client.post('/foo.com/inbox', json=FOLLOW_WRAPPED) + got = self.post('/foo.com/inbox', json=FOLLOW_WRAPPED) self.assertEqual(200, got.status_code) self.assertEqual(0, Follower.query().count())