Respond correctly to unknown methods on known endpoints (#14605)

Respond with a 405 error if a request is received on a known endpoint,
but to an unknown method, per MSC3743.
This commit is contained in:
Patrick Cloke 2023-02-09 13:04:24 -05:00 committed by GitHub
parent 8a6e043488
commit d22c1c862c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 89 additions and 51 deletions

1
changelog.d/14605.bugfix Normal file
View file

@ -0,0 +1 @@
Return spec-compliant JSON errors when unknown endpoints are requested.

View file

@ -235,6 +235,14 @@ The following fields are returned in the JSON response body:
Request: Request:
```
POST /_synapse/admin/v1/media/delete?before_ts=<before_ts>
{}
```
*Deprecated in Synapse v1.78.0:* This API is available at the deprecated endpoint:
``` ```
POST /_synapse/admin/v1/media/<server_name>/delete?before_ts=<before_ts> POST /_synapse/admin/v1/media/<server_name>/delete?before_ts=<before_ts>
@ -243,7 +251,7 @@ POST /_synapse/admin/v1/media/<server_name>/delete?before_ts=<before_ts>
URL Parameters URL Parameters
* `server_name`: string - The name of your local server (e.g `matrix.org`). * `server_name`: string - The name of your local server (e.g `matrix.org`). *Deprecated in Synapse v1.78.0.*
* `before_ts`: string representing a positive integer - Unix timestamp in milliseconds. * `before_ts`: string representing a positive integer - Unix timestamp in milliseconds.
Files that were last used before this timestamp will be deleted. It is the timestamp of Files that were last used before this timestamp will be deleted. It is the timestamp of
last access, not the timestamp when the file was created. last access, not the timestamp when the file was created.

View file

@ -88,6 +88,15 @@ process, for example:
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb
``` ```
# Upgrading to v1.78.0
## Deprecate the `/_synapse/admin/v1/media/<server_name>/delete` admin API
Synapse 1.78.0 replaces the `/_synapse/admin/v1/media/<server_name>/delete`
admin API with an identical endpoint at `/_synapse/admin/v1/media/delete`. Please
update your tooling to use the new endpoint. The deprecated version will be removed
in a future release.
# Upgrading to v1.76.0 # Upgrading to v1.76.0
## Faster joins are enabled by default ## Faster joins are enabled by default
@ -137,6 +146,7 @@ and then do `pip install matrix-synapse[user-search]` for a PyPI install.
Docker images and Debian packages need nothing specific as they already Docker images and Debian packages need nothing specific as they already
include or specify ICU as an explicit dependency. include or specify ICU as an explicit dependency.
# Upgrading to v1.73.0 # Upgrading to v1.73.0
## Legacy Prometheus metric names have now been removed ## Legacy Prometheus metric names have now been removed

View file

@ -30,7 +30,6 @@ from typing import (
Iterable, Iterable,
Iterator, Iterator,
List, List,
NoReturn,
Optional, Optional,
Pattern, Pattern,
Tuple, Tuple,
@ -340,7 +339,8 @@ class _AsyncResource(resource.Resource, metaclass=abc.ABCMeta):
return callback_return return callback_return
return _unrecognised_request_handler(request) # A request with an unknown method (for a known endpoint) was received.
raise UnrecognizedRequestError(code=405)
@abc.abstractmethod @abc.abstractmethod
def _send_response( def _send_response(
@ -396,7 +396,6 @@ class DirectServeJsonResource(_AsyncResource):
@attr.s(slots=True, frozen=True, auto_attribs=True) @attr.s(slots=True, frozen=True, auto_attribs=True)
class _PathEntry: class _PathEntry:
pattern: Pattern
callback: ServletCallback callback: ServletCallback
servlet_classname: str servlet_classname: str
@ -425,13 +424,14 @@ class JsonResource(DirectServeJsonResource):
): ):
super().__init__(canonical_json, extract_context) super().__init__(canonical_json, extract_context)
self.clock = hs.get_clock() self.clock = hs.get_clock()
self.path_regexs: Dict[bytes, List[_PathEntry]] = {} # Map of path regex -> method -> callback.
self._routes: Dict[Pattern[str], Dict[bytes, _PathEntry]] = {}
self.hs = hs self.hs = hs
def register_paths( def register_paths(
self, self,
method: str, method: str,
path_patterns: Iterable[Pattern], path_patterns: Iterable[Pattern[str]],
callback: ServletCallback, callback: ServletCallback,
servlet_classname: str, servlet_classname: str,
) -> None: ) -> None:
@ -455,8 +455,8 @@ class JsonResource(DirectServeJsonResource):
for path_pattern in path_patterns: for path_pattern in path_patterns:
logger.debug("Registering for %s %s", method, path_pattern.pattern) logger.debug("Registering for %s %s", method, path_pattern.pattern)
self.path_regexs.setdefault(method_bytes, []).append( self._routes.setdefault(path_pattern, {})[method_bytes] = _PathEntry(
_PathEntry(path_pattern, callback, servlet_classname) callback, servlet_classname
) )
def _get_handler_for_request( def _get_handler_for_request(
@ -478,14 +478,17 @@ class JsonResource(DirectServeJsonResource):
# Loop through all the registered callbacks to check if the method # Loop through all the registered callbacks to check if the method
# and path regex match # and path regex match
for path_entry in self.path_regexs.get(request_method, []): for path_pattern, methods in self._routes.items():
m = path_entry.pattern.match(request_path) m = path_pattern.match(request_path)
if m: if m:
# We found a match! # We found a matching path!
path_entry = methods.get(request_method)
if not path_entry:
raise UnrecognizedRequestError(code=405)
return path_entry.callback, path_entry.servlet_classname, m.groupdict() return path_entry.callback, path_entry.servlet_classname, m.groupdict()
# Huh. No one wanted to handle that? Fiiiiiine. Send 400. # Huh. No one wanted to handle that? Fiiiiiine.
return _unrecognised_request_handler, "unrecognised_request_handler", {} raise UnrecognizedRequestError(code=404)
async def _async_render(self, request: SynapseRequest) -> Tuple[int, Any]: async def _async_render(self, request: SynapseRequest) -> Tuple[int, Any]:
callback, servlet_classname, group_dict = self._get_handler_for_request(request) callback, servlet_classname, group_dict = self._get_handler_for_request(request)
@ -567,19 +570,6 @@ class StaticResource(File):
return super().render_GET(request) return super().render_GET(request)
def _unrecognised_request_handler(request: Request) -> NoReturn:
"""Request handler for unrecognised requests
This is a request handler suitable for return from
_get_handler_for_request. It actually just raises an
UnrecognizedRequestError.
Args:
request: Unused, but passed in to match the signature of ServletCallback.
"""
raise UnrecognizedRequestError(code=404)
class UnrecognizedRequestResource(resource.Resource): class UnrecognizedRequestResource(resource.Resource):
""" """
Similar to twisted.web.resource.NoResource, but returns a JSON 404 with an Similar to twisted.web.resource.NoResource, but returns a JSON 404 with an

View file

@ -15,7 +15,7 @@
import logging import logging
from http import HTTPStatus from http import HTTPStatus
from typing import TYPE_CHECKING, Tuple from typing import TYPE_CHECKING, Optional, Tuple
from synapse.api.constants import Direction from synapse.api.constants import Direction
from synapse.api.errors import Codes, NotFoundError, SynapseError from synapse.api.errors import Codes, NotFoundError, SynapseError
@ -285,7 +285,12 @@ class DeleteMediaByDateSize(RestServlet):
timestamp and size. timestamp and size.
""" """
PATTERNS = admin_patterns("/media/(?P<server_name>[^/]*)/delete$") PATTERNS = [
*admin_patterns("/media/delete$"),
# This URL kept around for legacy reasons, it is undesirable since it
# overlaps with the DeleteMediaByID servlet.
*admin_patterns("/media/(?P<server_name>[^/]*)/delete$"),
]
def __init__(self, hs: "HomeServer"): def __init__(self, hs: "HomeServer"):
self.store = hs.get_datastores().main self.store = hs.get_datastores().main
@ -294,7 +299,7 @@ class DeleteMediaByDateSize(RestServlet):
self.media_repository = hs.get_media_repository() self.media_repository = hs.get_media_repository()
async def on_POST( async def on_POST(
self, request: SynapseRequest, server_name: str self, request: SynapseRequest, server_name: Optional[str] = None
) -> Tuple[int, JsonDict]: ) -> Tuple[int, JsonDict]:
await assert_requester_is_admin(self.auth, request) await assert_requester_is_admin(self.auth, request)
@ -322,7 +327,8 @@ class DeleteMediaByDateSize(RestServlet):
errcode=Codes.INVALID_PARAM, errcode=Codes.INVALID_PARAM,
) )
if self.server_name != server_name: # This check is useless, we keep it for the legacy endpoint only.
if server_name is not None and self.server_name != server_name:
raise SynapseError(HTTPStatus.BAD_REQUEST, "Can only delete local media") raise SynapseError(HTTPStatus.BAD_REQUEST, "Can only delete local media")
logging.info( logging.info(
@ -489,6 +495,8 @@ def register_servlets_for_media_repo(hs: "HomeServer", http_server: HttpServer)
ProtectMediaByID(hs).register(http_server) ProtectMediaByID(hs).register(http_server)
UnprotectMediaByID(hs).register(http_server) UnprotectMediaByID(hs).register(http_server)
ListMediaInRoom(hs).register(http_server) ListMediaInRoom(hs).register(http_server)
DeleteMediaByID(hs).register(http_server) # XXX DeleteMediaByDateSize must be registered before DeleteMediaByID as
# their URL routes overlap.
DeleteMediaByDateSize(hs).register(http_server) DeleteMediaByDateSize(hs).register(http_server)
DeleteMediaByID(hs).register(http_server)
UserMediaRestServlet(hs).register(http_server) UserMediaRestServlet(hs).register(http_server)

View file

@ -259,6 +259,32 @@ class RoomKeysNewVersionServlet(RestServlet):
self.auth = hs.get_auth() self.auth = hs.get_auth()
self.e2e_room_keys_handler = hs.get_e2e_room_keys_handler() self.e2e_room_keys_handler = hs.get_e2e_room_keys_handler()
async def on_GET(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
"""
Retrieve the version information about the most current backup version (if any)
It takes out an exclusive lock on this user's room_key backups, to ensure
clients only upload to the current backup.
Returns 404 if the given version does not exist.
GET /room_keys/version HTTP/1.1
{
"version": "12345",
"algorithm": "m.megolm_backup.v1",
"auth_data": "dGhpcyBzaG91bGQgYWN0dWFsbHkgYmUgZW5jcnlwdGVkIGpzb24K"
}
"""
requester = await self.auth.get_user_by_req(request, allow_guest=False)
user_id = requester.user.to_string()
try:
info = await self.e2e_room_keys_handler.get_version_info(user_id)
except SynapseError as e:
if e.code == 404:
raise SynapseError(404, "No backup found", Codes.NOT_FOUND)
return 200, info
async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]: async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:
""" """
Create a new backup version for this user's room_keys with the given Create a new backup version for this user's room_keys with the given
@ -301,7 +327,7 @@ class RoomKeysNewVersionServlet(RestServlet):
class RoomKeysVersionServlet(RestServlet): class RoomKeysVersionServlet(RestServlet):
PATTERNS = client_patterns("/room_keys/version(/(?P<version>[^/]+))?$") PATTERNS = client_patterns("/room_keys/version/(?P<version>[^/]+)$")
def __init__(self, hs: "HomeServer"): def __init__(self, hs: "HomeServer"):
super().__init__() super().__init__()
@ -309,12 +335,11 @@ class RoomKeysVersionServlet(RestServlet):
self.e2e_room_keys_handler = hs.get_e2e_room_keys_handler() self.e2e_room_keys_handler = hs.get_e2e_room_keys_handler()
async def on_GET( async def on_GET(
self, request: SynapseRequest, version: Optional[str] self, request: SynapseRequest, version: str
) -> Tuple[int, JsonDict]: ) -> Tuple[int, JsonDict]:
""" """
Retrieve the version information about a given version of the user's Retrieve the version information about a given version of the user's
room_keys backup. If the version part is missing, returns info about the room_keys backup.
most current backup version (if any)
It takes out an exclusive lock on this user's room_key backups, to ensure It takes out an exclusive lock on this user's room_key backups, to ensure
clients only upload to the current backup. clients only upload to the current backup.
@ -339,20 +364,16 @@ class RoomKeysVersionServlet(RestServlet):
return 200, info return 200, info
async def on_DELETE( async def on_DELETE(
self, request: SynapseRequest, version: Optional[str] self, request: SynapseRequest, version: str
) -> Tuple[int, JsonDict]: ) -> Tuple[int, JsonDict]:
""" """
Delete the information about a given version of the user's Delete the information about a given version of the user's
room_keys backup. If the version part is missing, deletes the most room_keys backup. Doesn't delete the actual room data.
current backup version (if any). Doesn't delete the actual room data.
DELETE /room_keys/version/12345 HTTP/1.1 DELETE /room_keys/version/12345 HTTP/1.1
HTTP/1.1 200 OK HTTP/1.1 200 OK
{} {}
""" """
if version is None:
raise SynapseError(400, "No version specified to delete", Codes.NOT_FOUND)
requester = await self.auth.get_user_by_req(request, allow_guest=False) requester = await self.auth.get_user_by_req(request, allow_guest=False)
user_id = requester.user.to_string() user_id = requester.user.to_string()
@ -360,7 +381,7 @@ class RoomKeysVersionServlet(RestServlet):
return 200, {} return 200, {}
async def on_PUT( async def on_PUT(
self, request: SynapseRequest, version: Optional[str] self, request: SynapseRequest, version: str
) -> Tuple[int, JsonDict]: ) -> Tuple[int, JsonDict]:
""" """
Update the information about a given version of the user's room_keys backup. Update the information about a given version of the user's room_keys backup.
@ -386,11 +407,6 @@ class RoomKeysVersionServlet(RestServlet):
user_id = requester.user.to_string() user_id = requester.user.to_string()
info = parse_json_object_from_request(request) info = parse_json_object_from_request(request)
if version is None:
raise SynapseError(
400, "No version specified to update", Codes.MISSING_PARAM
)
await self.e2e_room_keys_handler.update_version(user_id, version, info) await self.e2e_room_keys_handler.update_version(user_id, version, info)
return 200, {} return 200, {}

View file

@ -34,7 +34,9 @@ class TagListServlet(RestServlet):
GET /user/{user_id}/rooms/{room_id}/tags HTTP/1.1 GET /user/{user_id}/rooms/{room_id}/tags HTTP/1.1
""" """
PATTERNS = client_patterns("/user/(?P<user_id>[^/]*)/rooms/(?P<room_id>[^/]*)/tags") PATTERNS = client_patterns(
"/user/(?P<user_id>[^/]*)/rooms/(?P<room_id>[^/]*)/tags$"
)
def __init__(self, hs: "HomeServer"): def __init__(self, hs: "HomeServer"):
super().__init__() super().__init__()

View file

@ -213,7 +213,8 @@ class DeleteMediaByDateSizeTestCase(unittest.HomeserverTestCase):
self.admin_user_tok = self.login("admin", "pass") self.admin_user_tok = self.login("admin", "pass")
self.filepaths = MediaFilePaths(hs.config.media.media_store_path) self.filepaths = MediaFilePaths(hs.config.media.media_store_path)
self.url = "/_synapse/admin/v1/media/%s/delete" % self.server_name self.url = "/_synapse/admin/v1/media/delete"
self.legacy_url = "/_synapse/admin/v1/media/%s/delete" % self.server_name
# Move clock up to somewhat realistic time # Move clock up to somewhat realistic time
self.reactor.advance(1000000000) self.reactor.advance(1000000000)
@ -332,11 +333,13 @@ class DeleteMediaByDateSizeTestCase(unittest.HomeserverTestCase):
channel.json_body["error"], channel.json_body["error"],
) )
def test_delete_media_never_accessed(self) -> None: @parameterized.expand([(True,), (False,)])
def test_delete_media_never_accessed(self, use_legacy_url: bool) -> None:
""" """
Tests that media deleted if it is older than `before_ts` and never accessed Tests that media deleted if it is older than `before_ts` and never accessed
`last_access_ts` is `NULL` and `created_ts` < `before_ts` `last_access_ts` is `NULL` and `created_ts` < `before_ts`
""" """
url = self.legacy_url if use_legacy_url else self.url
# upload and do not access # upload and do not access
server_and_media_id = self._create_media() server_and_media_id = self._create_media()
@ -351,7 +354,7 @@ class DeleteMediaByDateSizeTestCase(unittest.HomeserverTestCase):
now_ms = self.clock.time_msec() now_ms = self.clock.time_msec()
channel = self.make_request( channel = self.make_request(
"POST", "POST",
self.url + "?before_ts=" + str(now_ms), url + "?before_ts=" + str(now_ms),
access_token=self.admin_user_tok, access_token=self.admin_user_tok,
) )
self.assertEqual(200, channel.code, msg=channel.json_body) self.assertEqual(200, channel.code, msg=channel.json_body)