Skip to content

Commit

Permalink
Implementation of HTTP 307 response for MSC3886 POST endpoint (elemen…
Browse files Browse the repository at this point in the history
…t-hq#14018)

Co-authored-by: reivilibre <[email protected]>
Co-authored-by: Andrew Morgan <[email protected]>
  • Loading branch information
3 people authored Oct 18, 2022
1 parent 844ce47 commit 4eaf3eb
Show file tree
Hide file tree
Showing 16 changed files with 257 additions and 45 deletions.
1 change: 1 addition & 0 deletions changelog.d/14018.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Support for redirecting to an implementation of a [MSC3886](https://github.com/matrix-org/matrix-spec-proposals/pull/3886) HTTP rendezvous service.
7 changes: 6 additions & 1 deletion synapse/config/experimental.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.

from typing import Any
from typing import Any, Optional

import attr

Expand Down Expand Up @@ -120,3 +120,8 @@ def read_config(self, config: JsonDict, **kwargs: Any) -> None:

# MSC3874: Filtering /messages with rel_types / not_rel_types.
self.msc3874_enabled: bool = experimental.get("msc3874_enabled", False)

# MSC3886: Simple client rendezvous capability
self.msc3886_endpoint: Optional[str] = experimental.get(
"msc3886_endpoint", None
)
4 changes: 4 additions & 0 deletions synapse/config/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,9 @@ class HttpListenerConfig:
additional_resources: Dict[str, dict] = attr.Factory(dict)
tag: Optional[str] = None
request_id_header: Optional[str] = None
# If true, the listener will return CORS response headers compatible with MSC3886:
# https://github.com/matrix-org/matrix-spec-proposals/pull/3886
experimental_cors_msc3886: bool = False


@attr.s(slots=True, frozen=True, auto_attribs=True)
Expand Down Expand Up @@ -935,6 +938,7 @@ def parse_listener_def(num: int, listener: Any) -> ListenerConfig:
additional_resources=listener.get("additional_resources", {}),
tag=listener.get("tag"),
request_id_header=listener.get("request_id_header"),
experimental_cors_msc3886=listener.get("experimental_cors_msc3886", False),
)

return ListenerConfig(port, bind_addresses, listener_type, tls, http_config)
Expand Down
2 changes: 1 addition & 1 deletion synapse/handlers/sso.py
Original file line number Diff line number Diff line change
Expand Up @@ -874,7 +874,7 @@ async def handle_submit_username_request(
)

async def handle_terms_accepted(
self, request: Request, session_id: str, terms_version: str
self, request: SynapseRequest, session_id: str, terms_version: str
) -> None:
"""Handle a request to the new-user 'consent' endpoint
Expand Down
48 changes: 37 additions & 11 deletions synapse/http/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import types
import urllib
from http import HTTPStatus
from http.client import FOUND
from inspect import isawaitable
from typing import (
TYPE_CHECKING,
Expand Down Expand Up @@ -339,7 +340,7 @@ async def _async_render(self, request: SynapseRequest) -> Optional[Tuple[int, An

return callback_return

_unrecognised_request_handler(request)
return _unrecognised_request_handler(request)

@abc.abstractmethod
def _send_response(
Expand Down Expand Up @@ -598,7 +599,7 @@ def getChild(self, name: str, request: Request) -> resource.Resource:
class OptionsResource(resource.Resource):
"""Responds to OPTION requests for itself and all children."""

def render_OPTIONS(self, request: Request) -> bytes:
def render_OPTIONS(self, request: SynapseRequest) -> bytes:
request.setResponseCode(204)
request.setHeader(b"Content-Length", b"0")

Expand Down Expand Up @@ -763,7 +764,7 @@ def respond_with_json(


def respond_with_json_bytes(
request: Request,
request: SynapseRequest,
code: int,
json_bytes: bytes,
send_cors: bool = False,
Expand Down Expand Up @@ -859,7 +860,7 @@ def _write_bytes_to_request(request: Request, bytes_to_write: bytes) -> None:
_ByteProducer(request, bytes_generator)


def set_cors_headers(request: Request) -> None:
def set_cors_headers(request: SynapseRequest) -> None:
"""Set the CORS headers so that javascript running in a web browsers can
use this API
Expand All @@ -870,10 +871,20 @@ def set_cors_headers(request: Request) -> None:
request.setHeader(
b"Access-Control-Allow-Methods", b"GET, HEAD, POST, PUT, DELETE, OPTIONS"
)
request.setHeader(
b"Access-Control-Allow-Headers",
b"X-Requested-With, Content-Type, Authorization, Date",
)
if request.experimental_cors_msc3886:
request.setHeader(
b"Access-Control-Allow-Headers",
b"X-Requested-With, Content-Type, Authorization, Date, If-Match, If-None-Match",
)
request.setHeader(
b"Access-Control-Expose-Headers",
b"ETag, Location, X-Max-Bytes",
)
else:
request.setHeader(
b"Access-Control-Allow-Headers",
b"X-Requested-With, Content-Type, Authorization, Date",
)


def set_corp_headers(request: Request) -> None:
Expand Down Expand Up @@ -942,10 +953,25 @@ def set_clickjacking_protection_headers(request: Request) -> None:
request.setHeader(b"Content-Security-Policy", b"frame-ancestors 'none';")


def respond_with_redirect(request: Request, url: bytes) -> None:
"""Write a 302 response to the request, if it is still alive."""
def respond_with_redirect(
request: SynapseRequest, url: bytes, statusCode: int = FOUND, cors: bool = False
) -> None:
"""
Write a 302 (or other specified status code) response to the request, if it is still alive.
Args:
request: The http request to respond to.
url: The URL to redirect to.
statusCode: The HTTP status code to use for the redirect (defaults to 302).
cors: Whether to set CORS headers on the response.
"""
logger.debug("Redirect to %s", url.decode("utf-8"))
request.redirect(url)

if cors:
set_cors_headers(request)

request.setResponseCode(statusCode)
request.setHeader(b"location", url)
finish_request(request)


Expand Down
3 changes: 3 additions & 0 deletions synapse/http/site.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ def __init__(
self.reactor = site.reactor
self._channel = channel # this is used by the tests
self.start_time = 0.0
self.experimental_cors_msc3886 = site.experimental_cors_msc3886

# The requester, if authenticated. For federation requests this is the
# server name, for client requests this is the Requester object.
Expand Down Expand Up @@ -622,6 +623,8 @@ def __init__(

request_id_header = config.http_options.request_id_header

self.experimental_cors_msc3886 = config.http_options.experimental_cors_msc3886

def request_factory(channel: HTTPChannel, queued: bool) -> Request:
return request_class(
channel,
Expand Down
2 changes: 2 additions & 0 deletions synapse/rest/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
receipts,
register,
relations,
rendezvous,
report_event,
room,
room_batch,
Expand Down Expand Up @@ -132,3 +133,4 @@ def register_servlets(client_resource: HttpServer, hs: "HomeServer") -> None:
# unstable
mutual_rooms.register_servlets(hs, client_resource)
login_token_request.register_servlets(hs, client_resource)
rendezvous.register_servlets(hs, client_resource)
74 changes: 74 additions & 0 deletions synapse/rest/client/rendezvous.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Copyright 2022 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
from http.client import TEMPORARY_REDIRECT
from typing import TYPE_CHECKING, Optional

from synapse.http.server import HttpServer, respond_with_redirect
from synapse.http.servlet import RestServlet
from synapse.http.site import SynapseRequest
from synapse.rest.client._base import client_patterns

if TYPE_CHECKING:
from synapse.server import HomeServer

logger = logging.getLogger(__name__)


class RendezvousServlet(RestServlet):
"""
This is a placeholder implementation of [MSC3886](https://github.com/matrix-org/matrix-spec-proposals/pull/3886)
simple client rendezvous capability that is used by the "Sign in with QR" functionality.
This implementation only serves as a 307 redirect to a configured server rather than being a full implementation.
A module that implements the full functionality is available at: https://pypi.org/project/matrix-http-rendezvous-synapse/.
Request:
POST /rendezvous HTTP/1.1
Content-Type: ...
...
Response:
HTTP/1.1 307
Location: <configured endpoint>
"""

PATTERNS = client_patterns(
"/org.matrix.msc3886/rendezvous$", releases=[], v1=False, unstable=True
)

def __init__(self, hs: "HomeServer"):
super().__init__()
redirection_target: Optional[str] = hs.config.experimental.msc3886_endpoint
assert (
redirection_target is not None
), "Servlet is only registered if there is a redirection target"
self.endpoint = redirection_target.encode("utf-8")

async def on_POST(self, request: SynapseRequest) -> None:
respond_with_redirect(
request, self.endpoint, statusCode=TEMPORARY_REDIRECT, cors=True
)

# PUT, GET and DELETE are not implemented as they should be fulfilled by the redirect target.


def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None:
if hs.config.experimental.msc3886_endpoint is not None:
RendezvousServlet(hs).register(http_server)
3 changes: 3 additions & 0 deletions synapse/rest/client/versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ def on_GET(self, request: Request) -> Tuple[int, JsonDict]:
"org.matrix.msc3881": self.config.experimental.msc3881_enabled,
# Adds support for filtering /messages by event relation.
"org.matrix.msc3874": self.config.experimental.msc3874_enabled,
# Adds support for simple HTTP rendezvous as per MSC3886
"org.matrix.msc3886": self.config.experimental.msc3886_endpoint
is not None,
},
},
)
Expand Down
4 changes: 2 additions & 2 deletions synapse/rest/key/v2/local_key_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
from unpaddedbase64 import encode_base64

from twisted.web.resource import Resource
from twisted.web.server import Request

from synapse.http.server import respond_with_json_bytes
from synapse.http.site import SynapseRequest
from synapse.types import JsonDict

if TYPE_CHECKING:
Expand Down Expand Up @@ -99,7 +99,7 @@ def response_json_object(self) -> JsonDict:
json_object = sign_json(json_object, self.config.server.server_name, key)
return json_object

def render_GET(self, request: Request) -> Optional[int]:
def render_GET(self, request: SynapseRequest) -> Optional[int]:
time_now = self.clock.time_msec()
# Update the expiry time if less than half the interval remains.
if time_now + self.config.key.key_refresh_interval / 2 > self.valid_until_ts:
Expand Down
3 changes: 2 additions & 1 deletion synapse/rest/synapse/client/new_user_consent.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from synapse.handlers.sso import get_username_mapping_session_cookie_from_request
from synapse.http.server import DirectServeHtmlResource, respond_with_html
from synapse.http.servlet import parse_string
from synapse.http.site import SynapseRequest
from synapse.types import UserID
from synapse.util.templates import build_jinja_env

Expand Down Expand Up @@ -88,7 +89,7 @@ async def _async_render_GET(self, request: Request) -> None:
html = template.render(template_params)
respond_with_html(request, 200, html)

async def _async_render_POST(self, request: Request) -> None:
async def _async_render_POST(self, request: SynapseRequest) -> None:
try:
session_id = get_username_mapping_session_cookie_from_request(request)
except SynapseError as e:
Expand Down
3 changes: 2 additions & 1 deletion synapse/rest/well_known.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from twisted.web.server import Request

from synapse.http.server import set_cors_headers
from synapse.http.site import SynapseRequest
from synapse.types import JsonDict
from synapse.util import json_encoder
from synapse.util.stringutils import parse_server_name
Expand Down Expand Up @@ -63,7 +64,7 @@ def __init__(self, hs: "HomeServer"):
Resource.__init__(self)
self._well_known_builder = WellKnownBuilder(hs)

def render_GET(self, request: Request) -> bytes:
def render_GET(self, request: SynapseRequest) -> bytes:
set_cors_headers(request)
r = self._well_known_builder.get_well_known()
if not r:
Expand Down
1 change: 1 addition & 0 deletions tests/logging/test_terse_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ def test_with_request_context(self):
site.site_tag = "test-site"
site.server_version_string = "Server v1"
site.reactor = Mock()
site.experimental_cors_msc3886 = False
request = SynapseRequest(FakeChannel(site, None), site)
# Call requestReceived to finish instantiating the object.
request.content = BytesIO()
Expand Down
45 changes: 45 additions & 0 deletions tests/rest/client/test_rendezvous.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Copyright 2022 The Matrix.org Foundation C.I.C.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

from twisted.test.proto_helpers import MemoryReactor

from synapse.rest.client import rendezvous
from synapse.server import HomeServer
from synapse.util import Clock

from tests import unittest
from tests.unittest import override_config

endpoint = "/_matrix/client/unstable/org.matrix.msc3886/rendezvous"


class RendezvousServletTestCase(unittest.HomeserverTestCase):

servlets = [
rendezvous.register_servlets,
]

def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer:
self.hs = self.setup_test_homeserver()
return self.hs

def test_disabled(self) -> None:
channel = self.make_request("POST", endpoint, {}, access_token=None)
self.assertEqual(channel.code, 400)

@override_config({"experimental_features": {"msc3886_endpoint": "/asd"}})
def test_redirect(self) -> None:
channel = self.make_request("POST", endpoint, {}, access_token=None)
self.assertEqual(channel.code, 307)
self.assertEqual(channel.headers.getRawHeaders("Location"), ["/asd"])
8 changes: 7 additions & 1 deletion tests/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,14 +266,20 @@ class FakeSite:
site_tag = "test"
access_logger = logging.getLogger("synapse.access.http.fake")

def __init__(self, resource: IResource, reactor: IReactorTime):
def __init__(
self,
resource: IResource,
reactor: IReactorTime,
experimental_cors_msc3886: bool = False,
):
"""
Args:
resource: the resource to be used for rendering all requests
"""
self._resource = resource
self.reactor = reactor
self.experimental_cors_msc3886 = experimental_cors_msc3886

def getResourceFor(self, request):
return self._resource
Expand Down
Loading

0 comments on commit 4eaf3eb

Please sign in to comment.