Implementation of HTTP 307 response for MSC3886 POST endpoint (#14018)

Co-authored-by: reivilibre <olivier@librepush.net>
Co-authored-by: Andrew Morgan <andrewm@element.io>
pull/14230/head
Hugh Nimmo-Smith 2022-10-18 16:52:25 +01:00 committed by GitHub
parent 844ce47b9b
commit 4eaf3eb840
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 257 additions and 45 deletions

View File

@ -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.

View File

@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
from typing import Any from typing import Any, Optional
import attr import attr
@ -120,3 +120,8 @@ class ExperimentalConfig(Config):
# MSC3874: Filtering /messages with rel_types / not_rel_types. # MSC3874: Filtering /messages with rel_types / not_rel_types.
self.msc3874_enabled: bool = experimental.get("msc3874_enabled", False) self.msc3874_enabled: bool = experimental.get("msc3874_enabled", False)
# MSC3886: Simple client rendezvous capability
self.msc3886_endpoint: Optional[str] = experimental.get(
"msc3886_endpoint", None
)

View File

@ -207,6 +207,9 @@ class HttpListenerConfig:
additional_resources: Dict[str, dict] = attr.Factory(dict) additional_resources: Dict[str, dict] = attr.Factory(dict)
tag: Optional[str] = None tag: Optional[str] = None
request_id_header: 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) @attr.s(slots=True, frozen=True, auto_attribs=True)
@ -935,6 +938,7 @@ def parse_listener_def(num: int, listener: Any) -> ListenerConfig:
additional_resources=listener.get("additional_resources", {}), additional_resources=listener.get("additional_resources", {}),
tag=listener.get("tag"), tag=listener.get("tag"),
request_id_header=listener.get("request_id_header"), 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) return ListenerConfig(port, bind_addresses, listener_type, tls, http_config)

View File

@ -874,7 +874,7 @@ class SsoHandler:
) )
async def handle_terms_accepted( async def handle_terms_accepted(
self, request: Request, session_id: str, terms_version: str self, request: SynapseRequest, session_id: str, terms_version: str
) -> None: ) -> None:
"""Handle a request to the new-user 'consent' endpoint """Handle a request to the new-user 'consent' endpoint

View File

@ -19,6 +19,7 @@ import logging
import types import types
import urllib import urllib
from http import HTTPStatus from http import HTTPStatus
from http.client import FOUND
from inspect import isawaitable from inspect import isawaitable
from typing import ( from typing import (
TYPE_CHECKING, TYPE_CHECKING,
@ -339,7 +340,7 @@ class _AsyncResource(resource.Resource, metaclass=abc.ABCMeta):
return callback_return return callback_return
_unrecognised_request_handler(request) return _unrecognised_request_handler(request)
@abc.abstractmethod @abc.abstractmethod
def _send_response( def _send_response(
@ -598,7 +599,7 @@ class RootRedirect(resource.Resource):
class OptionsResource(resource.Resource): class OptionsResource(resource.Resource):
"""Responds to OPTION requests for itself and all children.""" """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.setResponseCode(204)
request.setHeader(b"Content-Length", b"0") request.setHeader(b"Content-Length", b"0")
@ -763,7 +764,7 @@ def respond_with_json(
def respond_with_json_bytes( def respond_with_json_bytes(
request: Request, request: SynapseRequest,
code: int, code: int,
json_bytes: bytes, json_bytes: bytes,
send_cors: bool = False, send_cors: bool = False,
@ -859,7 +860,7 @@ def _write_bytes_to_request(request: Request, bytes_to_write: bytes) -> None:
_ByteProducer(request, bytes_generator) _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 """Set the CORS headers so that javascript running in a web browsers can
use this API use this API
@ -870,10 +871,20 @@ def set_cors_headers(request: Request) -> None:
request.setHeader( request.setHeader(
b"Access-Control-Allow-Methods", b"GET, HEAD, POST, PUT, DELETE, OPTIONS" b"Access-Control-Allow-Methods", b"GET, HEAD, POST, PUT, DELETE, OPTIONS"
) )
request.setHeader( if request.experimental_cors_msc3886:
b"Access-Control-Allow-Headers", request.setHeader(
b"X-Requested-With, Content-Type, Authorization, Date", 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: def set_corp_headers(request: Request) -> None:
@ -942,10 +953,25 @@ def set_clickjacking_protection_headers(request: Request) -> None:
request.setHeader(b"Content-Security-Policy", b"frame-ancestors 'none';") request.setHeader(b"Content-Security-Policy", b"frame-ancestors 'none';")
def respond_with_redirect(request: Request, url: bytes) -> None: def respond_with_redirect(
"""Write a 302 response to the request, if it is still alive.""" 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")) 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) finish_request(request)

View File

@ -82,6 +82,7 @@ class SynapseRequest(Request):
self.reactor = site.reactor self.reactor = site.reactor
self._channel = channel # this is used by the tests self._channel = channel # this is used by the tests
self.start_time = 0.0 self.start_time = 0.0
self.experimental_cors_msc3886 = site.experimental_cors_msc3886
# The requester, if authenticated. For federation requests this is the # The requester, if authenticated. For federation requests this is the
# server name, for client requests this is the Requester object. # server name, for client requests this is the Requester object.
@ -622,6 +623,8 @@ class SynapseSite(Site):
request_id_header = config.http_options.request_id_header 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: def request_factory(channel: HTTPChannel, queued: bool) -> Request:
return request_class( return request_class(
channel, channel,

View File

@ -44,6 +44,7 @@ from synapse.rest.client import (
receipts, receipts,
register, register,
relations, relations,
rendezvous,
report_event, report_event,
room, room,
room_batch, room_batch,
@ -132,3 +133,4 @@ class ClientRestResource(JsonResource):
# unstable # unstable
mutual_rooms.register_servlets(hs, client_resource) mutual_rooms.register_servlets(hs, client_resource)
login_token_request.register_servlets(hs, client_resource) login_token_request.register_servlets(hs, client_resource)
rendezvous.register_servlets(hs, client_resource)

View File

@ -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)

View File

@ -116,6 +116,9 @@ class VersionsRestServlet(RestServlet):
"org.matrix.msc3881": self.config.experimental.msc3881_enabled, "org.matrix.msc3881": self.config.experimental.msc3881_enabled,
# Adds support for filtering /messages by event relation. # Adds support for filtering /messages by event relation.
"org.matrix.msc3874": self.config.experimental.msc3874_enabled, "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,
}, },
}, },
) )

View File

@ -20,9 +20,9 @@ from signedjson.sign import sign_json
from unpaddedbase64 import encode_base64 from unpaddedbase64 import encode_base64
from twisted.web.resource import Resource from twisted.web.resource import Resource
from twisted.web.server import Request
from synapse.http.server import respond_with_json_bytes from synapse.http.server import respond_with_json_bytes
from synapse.http.site import SynapseRequest
from synapse.types import JsonDict from synapse.types import JsonDict
if TYPE_CHECKING: if TYPE_CHECKING:
@ -99,7 +99,7 @@ class LocalKey(Resource):
json_object = sign_json(json_object, self.config.server.server_name, key) json_object = sign_json(json_object, self.config.server.server_name, key)
return json_object 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() time_now = self.clock.time_msec()
# Update the expiry time if less than half the interval remains. # 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: if time_now + self.config.key.key_refresh_interval / 2 > self.valid_until_ts:

View File

@ -20,6 +20,7 @@ from synapse.api.errors import SynapseError
from synapse.handlers.sso import get_username_mapping_session_cookie_from_request from synapse.handlers.sso import get_username_mapping_session_cookie_from_request
from synapse.http.server import DirectServeHtmlResource, respond_with_html from synapse.http.server import DirectServeHtmlResource, respond_with_html
from synapse.http.servlet import parse_string from synapse.http.servlet import parse_string
from synapse.http.site import SynapseRequest
from synapse.types import UserID from synapse.types import UserID
from synapse.util.templates import build_jinja_env from synapse.util.templates import build_jinja_env
@ -88,7 +89,7 @@ class NewUserConsentResource(DirectServeHtmlResource):
html = template.render(template_params) html = template.render(template_params)
respond_with_html(request, 200, html) 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: try:
session_id = get_username_mapping_session_cookie_from_request(request) session_id = get_username_mapping_session_cookie_from_request(request)
except SynapseError as e: except SynapseError as e:

View File

@ -18,6 +18,7 @@ from twisted.web.resource import Resource
from twisted.web.server import Request from twisted.web.server import Request
from synapse.http.server import set_cors_headers from synapse.http.server import set_cors_headers
from synapse.http.site import SynapseRequest
from synapse.types import JsonDict from synapse.types import JsonDict
from synapse.util import json_encoder from synapse.util import json_encoder
from synapse.util.stringutils import parse_server_name from synapse.util.stringutils import parse_server_name
@ -63,7 +64,7 @@ class ClientWellKnownResource(Resource):
Resource.__init__(self) Resource.__init__(self)
self._well_known_builder = WellKnownBuilder(hs) self._well_known_builder = WellKnownBuilder(hs)
def render_GET(self, request: Request) -> bytes: def render_GET(self, request: SynapseRequest) -> bytes:
set_cors_headers(request) set_cors_headers(request)
r = self._well_known_builder.get_well_known() r = self._well_known_builder.get_well_known()
if not r: if not r:

View File

@ -153,6 +153,7 @@ class TerseJsonTestCase(LoggerCleanupMixin, TestCase):
site.site_tag = "test-site" site.site_tag = "test-site"
site.server_version_string = "Server v1" site.server_version_string = "Server v1"
site.reactor = Mock() site.reactor = Mock()
site.experimental_cors_msc3886 = False
request = SynapseRequest(FakeChannel(site, None), site) request = SynapseRequest(FakeChannel(site, None), site)
# Call requestReceived to finish instantiating the object. # Call requestReceived to finish instantiating the object.
request.content = BytesIO() request.content = BytesIO()

View File

@ -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"])

View File

@ -266,7 +266,12 @@ class FakeSite:
site_tag = "test" site_tag = "test"
access_logger = logging.getLogger("synapse.access.http.fake") 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: Args:
@ -274,6 +279,7 @@ class FakeSite:
""" """
self._resource = resource self._resource = resource
self.reactor = reactor self.reactor = reactor
self.experimental_cors_msc3886 = experimental_cors_msc3886
def getResourceFor(self, request): def getResourceFor(self, request):
return self._resource return self._resource

View File

@ -222,13 +222,22 @@ class OptionsResourceTests(unittest.TestCase):
self.resource = OptionsResource() self.resource = OptionsResource()
self.resource.putChild(b"res", DummyResource()) self.resource.putChild(b"res", DummyResource())
def _make_request(self, method: bytes, path: bytes) -> FakeChannel: def _make_request(
self, method: bytes, path: bytes, experimental_cors_msc3886: bool = False
) -> FakeChannel:
"""Create a request from the method/path and return a channel with the response.""" """Create a request from the method/path and return a channel with the response."""
# Create a site and query for the resource. # Create a site and query for the resource.
site = SynapseSite( site = SynapseSite(
"test", "test",
"site_tag", "site_tag",
parse_listener_def(0, {"type": "http", "port": 0}), parse_listener_def(
0,
{
"type": "http",
"port": 0,
"experimental_cors_msc3886": experimental_cors_msc3886,
},
),
self.resource, self.resource,
"1.0", "1.0",
max_request_body_size=4096, max_request_body_size=4096,
@ -239,25 +248,58 @@ class OptionsResourceTests(unittest.TestCase):
channel = make_request(self.reactor, site, method, path, shorthand=False) channel = make_request(self.reactor, site, method, path, shorthand=False)
return channel return channel
def _check_cors_standard_headers(self, channel: FakeChannel) -> None:
# Ensure the correct CORS headers have been added
# as per https://spec.matrix.org/v1.4/client-server-api/#web-browser-clients
self.assertEqual(
channel.headers.getRawHeaders(b"Access-Control-Allow-Origin"),
[b"*"],
"has correct CORS Origin header",
)
self.assertEqual(
channel.headers.getRawHeaders(b"Access-Control-Allow-Methods"),
[b"GET, HEAD, POST, PUT, DELETE, OPTIONS"], # HEAD isn't in the spec
"has correct CORS Methods header",
)
self.assertEqual(
channel.headers.getRawHeaders(b"Access-Control-Allow-Headers"),
[b"X-Requested-With, Content-Type, Authorization, Date"],
"has correct CORS Headers header",
)
def _check_cors_msc3886_headers(self, channel: FakeChannel) -> None:
# Ensure the correct CORS headers have been added
# as per https://github.com/matrix-org/matrix-spec-proposals/blob/hughns/simple-rendezvous-capability/proposals/3886-simple-rendezvous-capability.md#cors
self.assertEqual(
channel.headers.getRawHeaders(b"Access-Control-Allow-Origin"),
[b"*"],
"has correct CORS Origin header",
)
self.assertEqual(
channel.headers.getRawHeaders(b"Access-Control-Allow-Methods"),
[b"GET, HEAD, POST, PUT, DELETE, OPTIONS"], # HEAD isn't in the spec
"has correct CORS Methods header",
)
self.assertEqual(
channel.headers.getRawHeaders(b"Access-Control-Allow-Headers"),
[
b"X-Requested-With, Content-Type, Authorization, Date, If-Match, If-None-Match"
],
"has correct CORS Headers header",
)
self.assertEqual(
channel.headers.getRawHeaders(b"Access-Control-Expose-Headers"),
[b"ETag, Location, X-Max-Bytes"],
"has correct CORS Expose Headers header",
)
def test_unknown_options_request(self) -> None: def test_unknown_options_request(self) -> None:
"""An OPTIONS requests to an unknown URL still returns 204 No Content.""" """An OPTIONS requests to an unknown URL still returns 204 No Content."""
channel = self._make_request(b"OPTIONS", b"/foo/") channel = self._make_request(b"OPTIONS", b"/foo/")
self.assertEqual(channel.code, 204) self.assertEqual(channel.code, 204)
self.assertNotIn("body", channel.result) self.assertNotIn("body", channel.result)
# Ensure the correct CORS headers have been added self._check_cors_standard_headers(channel)
self.assertTrue(
channel.headers.hasHeader(b"Access-Control-Allow-Origin"),
"has CORS Origin header",
)
self.assertTrue(
channel.headers.hasHeader(b"Access-Control-Allow-Methods"),
"has CORS Methods header",
)
self.assertTrue(
channel.headers.hasHeader(b"Access-Control-Allow-Headers"),
"has CORS Headers header",
)
def test_known_options_request(self) -> None: def test_known_options_request(self) -> None:
"""An OPTIONS requests to an known URL still returns 204 No Content.""" """An OPTIONS requests to an known URL still returns 204 No Content."""
@ -265,19 +307,17 @@ class OptionsResourceTests(unittest.TestCase):
self.assertEqual(channel.code, 204) self.assertEqual(channel.code, 204)
self.assertNotIn("body", channel.result) self.assertNotIn("body", channel.result)
# Ensure the correct CORS headers have been added self._check_cors_standard_headers(channel)
self.assertTrue(
channel.headers.hasHeader(b"Access-Control-Allow-Origin"), def test_known_options_request_msc3886(self) -> None:
"has CORS Origin header", """An OPTIONS requests to an known URL still returns 204 No Content."""
) channel = self._make_request(
self.assertTrue( b"OPTIONS", b"/res/", experimental_cors_msc3886=True
channel.headers.hasHeader(b"Access-Control-Allow-Methods"),
"has CORS Methods header",
)
self.assertTrue(
channel.headers.hasHeader(b"Access-Control-Allow-Headers"),
"has CORS Headers header",
) )
self.assertEqual(channel.code, 204)
self.assertNotIn("body", channel.result)
self._check_cors_msc3886_headers(channel)
def test_unknown_request(self) -> None: def test_unknown_request(self) -> None:
"""A non-OPTIONS request to an unknown URL should 404.""" """A non-OPTIONS request to an unknown URL should 404."""