Skip to content
This repository has been archived by the owner on Apr 26, 2024. It is now read-only.

Respond correctly to unknown methods on known endpoints #14605

Merged
merged 12 commits into from Feb 9, 2023
1 change: 1 addition & 0 deletions changelog.d/14605.bugfix
@@ -0,0 +1 @@
Return spec-compliant JSON errors when unknown endpoints are requested.
10 changes: 9 additions & 1 deletion docs/admin_api/media_admin_api.md
Expand Up @@ -235,6 +235,14 @@ The following fields are returned in the JSON response body:

Request:

```
POST /_synapse/admin/v1/media/delete?before_ts=<before_ts>

{}
```

*Deprecated in Synapse v1.77.0:* This API is available at the deprecated endpoint:
clokep marked this conversation as resolved.
Show resolved Hide resolved

```
POST /_synapse/admin/v1/media/<server_name>/delete?before_ts=<before_ts>

Expand All @@ -243,7 +251,7 @@ POST /_synapse/admin/v1/media/<server_name>/delete?before_ts=<before_ts>

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.75.0.*
clokep marked this conversation as resolved.
Show resolved Hide resolved
* `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
last access, not the timestamp when the file was created.
Expand Down
10 changes: 10 additions & 0 deletions docs/upgrade.md
Expand Up @@ -88,6 +88,15 @@ process, for example:
dpkg -i matrix-synapse-py3_1.3.0+stretch1_amd64.deb
```

# Upgrading to v1.77.0
clokep marked this conversation as resolved.
Show resolved Hide resolved

## Deprecate the `/_synapse/admin/v1/media/<server_name>/delete` admin API

Synapse 1.77.0 replaces the `/_synapse/admin/v1/media/<server_name>/delete`
clokep marked this conversation as resolved.
Show resolved Hide resolved
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

## Faster joins are enabled by default
Expand Down Expand Up @@ -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
include or specify ICU as an explicit dependency.


# Upgrading to v1.73.0

## Legacy Prometheus metric names have now been removed
Expand Down
40 changes: 15 additions & 25 deletions synapse/http/server.py
Expand Up @@ -30,7 +30,6 @@
Iterable,
Iterator,
List,
NoReturn,
Optional,
Pattern,
Tuple,
Expand Down Expand Up @@ -340,7 +339,8 @@ async def _async_render(self, request: SynapseRequest) -> Optional[Tuple[int, An

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
def _send_response(
Expand Down Expand Up @@ -396,7 +396,6 @@ def _send_error_response(

@attr.s(slots=True, frozen=True, auto_attribs=True)
class _PathEntry:
pattern: Pattern
callback: ServletCallback
servlet_classname: str

Expand Down Expand Up @@ -425,13 +424,14 @@ def __init__(
):
super().__init__(canonical_json, extract_context)
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

def register_paths(
self,
method: str,
path_patterns: Iterable[Pattern],
path_patterns: Iterable[Pattern[str]],
callback: ServletCallback,
servlet_classname: str,
) -> None:
Expand All @@ -455,8 +455,8 @@ def register_paths(

for path_pattern in path_patterns:
logger.debug("Registering for %s %s", method, path_pattern.pattern)
self.path_regexs.setdefault(method_bytes, []).append(
_PathEntry(path_pattern, callback, servlet_classname)
self._routes.setdefault(path_pattern, {})[method_bytes] = _PathEntry(
callback, servlet_classname
)

def _get_handler_for_request(
Expand All @@ -478,14 +478,17 @@ def _get_handler_for_request(

# Loop through all the registered callbacks to check if the method
# and path regex match
for path_entry in self.path_regexs.get(request_method, []):
m = path_entry.pattern.match(request_path)
for path_pattern, methods in self._routes.items():
m = path_pattern.match(request_path)
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()

# Huh. No one wanted to handle that? Fiiiiiine. Send 400.
return _unrecognised_request_handler, "unrecognised_request_handler", {}
# Huh. No one wanted to handle that? Fiiiiiine.
raise UnrecognizedRequestError(code=404)

async def _async_render(self, request: SynapseRequest) -> Tuple[int, Any]:
callback, servlet_classname, group_dict = self._get_handler_for_request(request)
Expand Down Expand Up @@ -567,19 +570,6 @@ def render_GET(self, request: Request) -> bytes:
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):
"""
Similar to twisted.web.resource.NoResource, but returns a JSON 404 with an
Expand Down
18 changes: 13 additions & 5 deletions synapse/rest/admin/media.py
Expand Up @@ -15,7 +15,7 @@

import logging
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.errors import Codes, NotFoundError, SynapseError
Expand Down Expand Up @@ -285,7 +285,12 @@ class DeleteMediaByDateSize(RestServlet):
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"):
self.store = hs.get_datastores().main
Expand All @@ -294,7 +299,7 @@ def __init__(self, hs: "HomeServer"):
self.media_repository = hs.get_media_repository()

async def on_POST(
self, request: SynapseRequest, server_name: str
self, request: SynapseRequest, server_name: Optional[str] = None
) -> Tuple[int, JsonDict]:
await assert_requester_is_admin(self.auth, request)

Expand Down Expand Up @@ -322,7 +327,8 @@ async def on_POST(
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")

logging.info(
Expand Down Expand Up @@ -489,6 +495,8 @@ def register_servlets_for_media_repo(hs: "HomeServer", http_server: HttpServer)
ProtectMediaByID(hs).register(http_server)
UnprotectMediaByID(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)
DeleteMediaByID(hs).register(http_server)
UserMediaRestServlet(hs).register(http_server)
48 changes: 32 additions & 16 deletions synapse/rest/client/room_keys.py
Expand Up @@ -259,6 +259,32 @@ def __init__(self, hs: "HomeServer"):
self.auth = hs.get_auth()
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]:
"""
Create a new backup version for this user's room_keys with the given
Expand Down Expand Up @@ -301,20 +327,19 @@ async def on_POST(self, request: SynapseRequest) -> Tuple[int, JsonDict]:


class RoomKeysVersionServlet(RestServlet):
PATTERNS = client_patterns("/room_keys/version(/(?P<version>[^/]+))?$")
PATTERNS = client_patterns("/room_keys/version/(?P<version>[^/]+)$")

def __init__(self, hs: "HomeServer"):
super().__init__()
self.auth = hs.get_auth()
self.e2e_room_keys_handler = hs.get_e2e_room_keys_handler()

async def on_GET(
self, request: SynapseRequest, version: Optional[str]
self, request: SynapseRequest, version: str
) -> Tuple[int, JsonDict]:
"""
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
most current backup version (if any)
room_keys backup.

It takes out an exclusive lock on this user's room_key backups, to ensure
clients only upload to the current backup.
Expand All @@ -339,28 +364,24 @@ async def on_GET(
return 200, info

async def on_DELETE(
self, request: SynapseRequest, version: Optional[str]
self, request: SynapseRequest, version: str
) -> Tuple[int, JsonDict]:
"""
Delete the information about a given version of the user's
room_keys backup. If the version part is missing, deletes the most
current backup version (if any). Doesn't delete the actual room data.
room_keys backup. Doesn't delete the actual room data.

DELETE /room_keys/version/12345 HTTP/1.1
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)
user_id = requester.user.to_string()

await self.e2e_room_keys_handler.delete_version(user_id, version)
return 200, {}

async def on_PUT(
self, request: SynapseRequest, version: Optional[str]
self, request: SynapseRequest, version: str
) -> Tuple[int, JsonDict]:
"""
Update the information about a given version of the user's room_keys backup.
Expand All @@ -386,11 +407,6 @@ async def on_PUT(
user_id = requester.user.to_string()
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)
return 200, {}

Expand Down
4 changes: 3 additions & 1 deletion synapse/rest/client/tags.py
Expand Up @@ -34,7 +34,9 @@ class TagListServlet(RestServlet):
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"):
super().__init__()
Expand Down
9 changes: 6 additions & 3 deletions tests/rest/admin/test_media.py
Expand Up @@ -213,7 +213,8 @@ def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None:
self.admin_user_tok = self.login("admin", "pass")

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
self.reactor.advance(1000000000)
Expand Down Expand Up @@ -332,11 +333,13 @@ def test_invalid_parameter(self) -> None:
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
`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
server_and_media_id = self._create_media()
Expand All @@ -351,7 +354,7 @@ def test_delete_media_never_accessed(self) -> None:
now_ms = self.clock.time_msec()
channel = self.make_request(
"POST",
self.url + "?before_ts=" + str(now_ms),
url + "?before_ts=" + str(now_ms),
access_token=self.admin_user_tok,
)
self.assertEqual(200, channel.code, msg=channel.json_body)
Expand Down