Merge pull request #2613 from hughrun/authorized-fetch

Enable communication with "authorized_fetch" Mastodon servers
This commit is contained in:
Mouse Reeve 2023-01-26 08:21:34 -08:00 committed by GitHub
commit 62d1c54b31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 89 additions and 11 deletions

View file

@ -2,11 +2,16 @@
from dataclasses import dataclass, fields, MISSING from dataclasses import dataclass, fields, MISSING
from json import JSONEncoder from json import JSONEncoder
import logging import logging
import requests
from django.apps import apps from django.apps import apps
from django.db import IntegrityError, transaction from django.db import IntegrityError, transaction
from django.utils.http import http_date
from bookwyrm import models
from bookwyrm.connectors import ConnectorException, get_data from bookwyrm.connectors import ConnectorException, get_data
from bookwyrm.signatures import make_signature
from bookwyrm.settings import DOMAIN, INSTANCE_ACTOR_USERNAME
from bookwyrm.tasks import app, MEDIUM from bookwyrm.tasks import app, MEDIUM
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -246,10 +251,10 @@ def set_related_field(
def get_model_from_type(activity_type): def get_model_from_type(activity_type):
"""given the activity, what type of model""" """given the activity, what type of model"""
models = apps.get_models() activity_models = apps.get_models()
model = [ model = [
m m
for m in models for m in activity_models
if hasattr(m, "activity_serializer") if hasattr(m, "activity_serializer")
and hasattr(m.activity_serializer, "type") and hasattr(m.activity_serializer, "type")
and m.activity_serializer.type == activity_type and m.activity_serializer.type == activity_type
@ -275,10 +280,16 @@ def resolve_remote_id(
# load the data and create the object # load the data and create the object
try: try:
data = get_data(remote_id) data = get_data(remote_id)
except ConnectorException: except ConnectionError:
logger.info("Could not connect to host for remote_id: %s", remote_id) logger.info("Could not connect to host for remote_id: %s", remote_id)
return None return None
except requests.HTTPError as e:
if (e.response is not None) and e.response.status_code == 401:
# This most likely means it's a mastodon with secure fetch enabled.
data = get_activitypub_data(remote_id)
else:
logger.info("Could not connect to host for remote_id: %s", remote_id)
return None
# determine the model implicitly, if not provided # determine the model implicitly, if not provided
# or if it's a model with subclasses like Status, check again # or if it's a model with subclasses like Status, check again
if not model or hasattr(model.objects, "select_subclasses"): if not model or hasattr(model.objects, "select_subclasses"):
@ -297,6 +308,51 @@ def resolve_remote_id(
return item.to_model(model=model, instance=result, save=save) return item.to_model(model=model, instance=result, save=save)
def get_representative():
"""Get or create an actor representing the instance
to sign requests to 'secure mastodon' servers"""
username = f"{INSTANCE_ACTOR_USERNAME}@{DOMAIN}"
email = "bookwyrm@localhost"
try:
user = models.User.objects.get(username=username)
except models.User.DoesNotExist:
user = models.User.objects.create_user(
username=username,
email=email,
local=True,
localname=INSTANCE_ACTOR_USERNAME,
)
return user
def get_activitypub_data(url):
"""wrapper for request.get"""
now = http_date()
sender = get_representative()
if not sender.key_pair.private_key:
# this shouldn't happen. it would be bad if it happened.
raise ValueError("No private key found for sender")
try:
resp = requests.get(
url,
headers={
"Accept": "application/json; charset=utf-8",
"Date": now,
"Signature": make_signature("get", sender, url, now),
},
)
except requests.RequestException:
raise ConnectorException()
if not resp.ok:
resp.raise_for_status()
try:
data = resp.json()
except ValueError:
raise ConnectorException()
return data
@dataclass(init=False) @dataclass(init=False)
class Link(ActivityObject): class Link(ActivityObject):
"""for tagging a book in a status""" """for tagging a book in a status"""

View file

@ -244,7 +244,11 @@ def get_data(url, params=None, timeout=settings.QUERY_TIMEOUT):
raise ConnectorException(err) raise ConnectorException(err)
if not resp.ok: if not resp.ok:
raise ConnectorException() if resp.status_code == 401:
# this is probably an AUTHORIZED_FETCH issue
resp.raise_for_status()
else:
raise ConnectorException()
try: try:
data = resp.json() data = resp.json()
except ValueError as err: except ValueError as err:

View file

@ -543,7 +543,7 @@ async def sign_and_send(
headers = { headers = {
"Date": now, "Date": now,
"Digest": digest, "Digest": digest,
"Signature": make_signature(sender, destination, now, digest), "Signature": make_signature("post", sender, destination, now, digest),
"Content-Type": "application/activity+json; charset=utf-8", "Content-Type": "application/activity+json; charset=utf-8",
"User-Agent": USER_AGENT, "User-Agent": USER_AGENT,
} }

View file

@ -373,3 +373,9 @@ TWO_FACTOR_LOGIN_MAX_SECONDS = 60
HTTP_X_FORWARDED_PROTO = env.bool("SECURE_PROXY_SSL_HEADER", False) HTTP_X_FORWARDED_PROTO = env.bool("SECURE_PROXY_SSL_HEADER", False)
if HTTP_X_FORWARDED_PROTO: if HTTP_X_FORWARDED_PROTO:
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Instance Actor for signing GET requests to "secure mode"
# Mastodon servers.
# Do not change this setting unless you already have an existing
# user with the same username - in which case you should change it!
INSTANCE_ACTOR_USERNAME = "bookwyrm.instance.actor"

View file

@ -22,22 +22,26 @@ def create_key_pair():
return private_key, public_key return private_key, public_key
def make_signature(sender, destination, date, digest): def make_signature(method, sender, destination, date, digest=None):
"""uses a private key to sign an outgoing message""" """uses a private key to sign an outgoing message"""
inbox_parts = urlparse(destination) inbox_parts = urlparse(destination)
signature_headers = [ signature_headers = [
f"(request-target): post {inbox_parts.path}", f"(request-target): {method} {inbox_parts.path}",
f"host: {inbox_parts.netloc}", f"host: {inbox_parts.netloc}",
f"date: {date}", f"date: {date}",
f"digest: {digest}",
] ]
headers = "(request-target) host date"
if digest is not None:
signature_headers.append(f"digest: {digest}")
headers = "(request-target) host date digest"
message_to_sign = "\n".join(signature_headers) message_to_sign = "\n".join(signature_headers)
signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key)) signer = pkcs1_15.new(RSA.import_key(sender.key_pair.private_key))
signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8"))) signed_message = signer.sign(SHA256.new(message_to_sign.encode("utf8")))
signature = { signature = {
"keyId": f"{sender.remote_id}#main-key", "keyId": f"{sender.remote_id}#main-key",
"algorithm": "rsa-sha256", "algorithm": "rsa-sha256",
"headers": "(request-target) host date digest", "headers": headers,
"signature": b64encode(signed_message).decode("utf8"), "signature": b64encode(signed_message).decode("utf8"),
} }
return ",".join(f'{k}="{v}"' for (k, v) in signature.items()) return ",".join(f'{k}="{v}"' for (k, v) in signature.items())

View file

@ -14,6 +14,7 @@ from bookwyrm.activitypub.base_activity import (
ActivityObject, ActivityObject,
resolve_remote_id, resolve_remote_id,
set_related_field, set_related_field,
get_representative,
) )
from bookwyrm.activitypub import ActivitySerializerError from bookwyrm.activitypub import ActivitySerializerError
from bookwyrm import models from bookwyrm import models
@ -52,6 +53,11 @@ class BaseActivity(TestCase):
image.save(output, format=image.format) image.save(output, format=image.format)
self.image_data = output.getvalue() self.image_data = output.getvalue()
def test_get_representative_not_existing(self, *_):
"""test that an instance representative actor is created if it does not exist"""
representative = get_representative()
self.assertIsInstance(representative, models.User)
def test_init(self, *_): def test_init(self, *_):
"""simple successfuly init""" """simple successfuly init"""
instance = ActivityObject(id="a", type="b") instance = ActivityObject(id="a", type="b")

View file

@ -86,7 +86,9 @@ class Signature(TestCase):
now = date or http_date() now = date or http_date()
data = json.dumps(get_follow_activity(sender, self.rat)) data = json.dumps(get_follow_activity(sender, self.rat))
digest = digest or make_digest(data) digest = digest or make_digest(data)
signature = make_signature(signer or sender, self.rat.inbox, now, digest) signature = make_signature(
"post", signer or sender, self.rat.inbox, now, digest
)
with patch("bookwyrm.views.inbox.activity_task.apply_async"): with patch("bookwyrm.views.inbox.activity_task.apply_async"):
with patch("bookwyrm.models.user.set_remote_server.delay"): with patch("bookwyrm.models.user.set_remote_server.delay"):
return self.send(signature, now, send_data or data, digest) return self.send(signature, now, send_data or data, digest)