Allow admins to bypass the quarantine check on media downloads (#19275)
Co-authored-by: turt2live <1190097+turt2live@users.noreply.github.com> Co-authored-by: Andrew Morgan <1342360+anoadragon453@users.noreply.github.com>
This commit is contained in:
1
changelog.d/19275.feature
Normal file
1
changelog.d/19275.feature
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Server admins can bypass the quarantine media check when downloading media by setting the `admin_unsafely_bypass_quarantine` query parameter to `true` on Client-Server API media download requests.
|
||||||
@@ -115,6 +115,20 @@ is quarantined, Synapse will:
|
|||||||
- Quarantine any existing cached remote media.
|
- Quarantine any existing cached remote media.
|
||||||
- Quarantine any future remote media.
|
- Quarantine any future remote media.
|
||||||
|
|
||||||
|
## Downloading quarantined media
|
||||||
|
|
||||||
|
Normally, when media is quarantined, it will return a 404 error when downloaded.
|
||||||
|
Admins can bypass this by adding `?admin_unsafely_bypass_quarantine=true`
|
||||||
|
to the [normal download URL](https://spec.matrix.org/v1.16/client-server-api/#get_matrixclientv1mediadownloadservernamemediaid).
|
||||||
|
|
||||||
|
Bypassing the quarantine check is not recommended. Media is typically quarantined
|
||||||
|
to prevent harmful content from being served to users, which includes admins. Only
|
||||||
|
set the bypass parameter if you intentionally want to access potentially harmful
|
||||||
|
content.
|
||||||
|
|
||||||
|
Non-admin users cannot bypass quarantine checks, even when specifying the above
|
||||||
|
query parameter.
|
||||||
|
|
||||||
## Quarantining media by ID
|
## Quarantining media by ID
|
||||||
|
|
||||||
This API quarantines a single piece of local or remote media.
|
This API quarantines a single piece of local or remote media.
|
||||||
|
|||||||
@@ -439,7 +439,11 @@ class MediaRepository:
|
|||||||
return await self.store.get_cached_remote_media(origin, media_id)
|
return await self.store.get_cached_remote_media(origin, media_id)
|
||||||
|
|
||||||
async def get_local_media_info(
|
async def get_local_media_info(
|
||||||
self, request: SynapseRequest, media_id: str, max_timeout_ms: int
|
self,
|
||||||
|
request: SynapseRequest,
|
||||||
|
media_id: str,
|
||||||
|
max_timeout_ms: int,
|
||||||
|
bypass_quarantine: bool = False,
|
||||||
) -> LocalMedia | None:
|
) -> LocalMedia | None:
|
||||||
"""Gets the info dictionary for given local media ID. If the media has
|
"""Gets the info dictionary for given local media ID. If the media has
|
||||||
not been uploaded yet, this function will wait up to ``max_timeout_ms``
|
not been uploaded yet, this function will wait up to ``max_timeout_ms``
|
||||||
@@ -451,6 +455,7 @@ class MediaRepository:
|
|||||||
the file_id for local content.)
|
the file_id for local content.)
|
||||||
max_timeout_ms: the maximum number of milliseconds to wait for the
|
max_timeout_ms: the maximum number of milliseconds to wait for the
|
||||||
media to be uploaded.
|
media to be uploaded.
|
||||||
|
bypass_quarantine: whether to bypass quarantine checks
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Either the info dictionary for the given local media ID or
|
Either the info dictionary for the given local media ID or
|
||||||
@@ -466,7 +471,7 @@ class MediaRepository:
|
|||||||
respond_404(request)
|
respond_404(request)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if media_info.quarantined_by:
|
if media_info.quarantined_by and not bypass_quarantine:
|
||||||
logger.info("Media %s is quarantined", media_id)
|
logger.info("Media %s is quarantined", media_id)
|
||||||
respond_404(request)
|
respond_404(request)
|
||||||
return None
|
return None
|
||||||
@@ -500,6 +505,7 @@ class MediaRepository:
|
|||||||
max_timeout_ms: int,
|
max_timeout_ms: int,
|
||||||
allow_authenticated: bool = True,
|
allow_authenticated: bool = True,
|
||||||
federation: bool = False,
|
federation: bool = False,
|
||||||
|
bypass_quarantine: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Responds to requests for local media, if exists, or returns 404.
|
"""Responds to requests for local media, if exists, or returns 404.
|
||||||
|
|
||||||
@@ -513,11 +519,14 @@ class MediaRepository:
|
|||||||
media to be uploaded.
|
media to be uploaded.
|
||||||
allow_authenticated: whether media marked as authenticated may be served to this request
|
allow_authenticated: whether media marked as authenticated may be served to this request
|
||||||
federation: whether the local media being fetched is for a federation request
|
federation: whether the local media being fetched is for a federation request
|
||||||
|
bypass_quarantine: whether to bypass quarantine checks
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Resolves once a response has successfully been written to request
|
Resolves once a response has successfully been written to request
|
||||||
"""
|
"""
|
||||||
media_info = await self.get_local_media_info(request, media_id, max_timeout_ms)
|
media_info = await self.get_local_media_info(
|
||||||
|
request, media_id, max_timeout_ms, bypass_quarantine=bypass_quarantine
|
||||||
|
)
|
||||||
if not media_info:
|
if not media_info:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -561,6 +570,7 @@ class MediaRepository:
|
|||||||
ip_address: str,
|
ip_address: str,
|
||||||
use_federation_endpoint: bool,
|
use_federation_endpoint: bool,
|
||||||
allow_authenticated: bool = True,
|
allow_authenticated: bool = True,
|
||||||
|
bypass_quarantine: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Respond to requests for remote media.
|
"""Respond to requests for remote media.
|
||||||
|
|
||||||
@@ -577,6 +587,7 @@ class MediaRepository:
|
|||||||
federation `/download` endpoint
|
federation `/download` endpoint
|
||||||
allow_authenticated: whether media marked as authenticated may be served to this
|
allow_authenticated: whether media marked as authenticated may be served to this
|
||||||
request
|
request
|
||||||
|
bypass_quarantine: whether to bypass quarantine checks
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Resolves once a response has successfully been written to request
|
Resolves once a response has successfully been written to request
|
||||||
@@ -609,6 +620,7 @@ class MediaRepository:
|
|||||||
ip_address,
|
ip_address,
|
||||||
use_federation_endpoint,
|
use_federation_endpoint,
|
||||||
allow_authenticated,
|
allow_authenticated,
|
||||||
|
bypass_quarantine=bypass_quarantine,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if the media is cached on the client, if so return 304. We need
|
# Check if the media is cached on the client, if so return 304. We need
|
||||||
@@ -697,6 +709,7 @@ class MediaRepository:
|
|||||||
ip_address: str,
|
ip_address: str,
|
||||||
use_federation_endpoint: bool,
|
use_federation_endpoint: bool,
|
||||||
allow_authenticated: bool,
|
allow_authenticated: bool,
|
||||||
|
bypass_quarantine: bool = False,
|
||||||
) -> tuple[Responder | None, RemoteMedia]:
|
) -> tuple[Responder | None, RemoteMedia]:
|
||||||
"""Looks for media in local cache, if not there then attempt to
|
"""Looks for media in local cache, if not there then attempt to
|
||||||
download from remote server.
|
download from remote server.
|
||||||
@@ -712,6 +725,7 @@ class MediaRepository:
|
|||||||
ip_address: the IP address of the requester
|
ip_address: the IP address of the requester
|
||||||
use_federation_endpoint: whether to request the remote media over the new federation
|
use_federation_endpoint: whether to request the remote media over the new federation
|
||||||
/download endpoint
|
/download endpoint
|
||||||
|
bypass_quarantine: whether to bypass quarantine checks
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A tuple of responder and the media info of the file.
|
A tuple of responder and the media info of the file.
|
||||||
@@ -732,7 +746,7 @@ class MediaRepository:
|
|||||||
file_id = media_info.filesystem_id
|
file_id = media_info.filesystem_id
|
||||||
file_info = FileInfo(server_name, file_id)
|
file_info = FileInfo(server_name, file_id)
|
||||||
|
|
||||||
if media_info.quarantined_by:
|
if media_info.quarantined_by and not bypass_quarantine:
|
||||||
logger.info("Media is quarantined")
|
logger.info("Media is quarantined")
|
||||||
raise NotFoundError()
|
raise NotFoundError()
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from synapse.api.errors import Codes, cs_error
|
||||||
from synapse.http.server import (
|
from synapse.http.server import (
|
||||||
HttpServer,
|
HttpServer,
|
||||||
respond_with_json,
|
respond_with_json,
|
||||||
@@ -235,7 +236,23 @@ class DownloadResource(RestServlet):
|
|||||||
# Validate the server name, raising if invalid
|
# Validate the server name, raising if invalid
|
||||||
parse_and_validate_server_name(server_name)
|
parse_and_validate_server_name(server_name)
|
||||||
|
|
||||||
await self.auth.get_user_by_req(request, allow_guest=True)
|
requester = await self.auth.get_user_by_req(request, allow_guest=True)
|
||||||
|
is_admin = await self.auth.is_server_admin(requester)
|
||||||
|
bypass_quarantine = False
|
||||||
|
if parse_string(request, "admin_unsafely_bypass_quarantine") == "true":
|
||||||
|
if is_admin:
|
||||||
|
logger.info("Admin bypassing quarantine for media download")
|
||||||
|
bypass_quarantine = True
|
||||||
|
else:
|
||||||
|
respond_with_json(
|
||||||
|
request,
|
||||||
|
400,
|
||||||
|
cs_error(
|
||||||
|
"Must be a server admin to bypass quarantine",
|
||||||
|
code=Codes.UNKNOWN,
|
||||||
|
),
|
||||||
|
send_cors=True,
|
||||||
|
)
|
||||||
|
|
||||||
set_cors_headers(request)
|
set_cors_headers(request)
|
||||||
set_corp_headers(request)
|
set_corp_headers(request)
|
||||||
@@ -259,7 +276,11 @@ class DownloadResource(RestServlet):
|
|||||||
|
|
||||||
if self._is_mine_server_name(server_name):
|
if self._is_mine_server_name(server_name):
|
||||||
await self.media_repo.get_local_media(
|
await self.media_repo.get_local_media(
|
||||||
request, media_id, file_name, max_timeout_ms
|
request,
|
||||||
|
media_id,
|
||||||
|
file_name,
|
||||||
|
max_timeout_ms,
|
||||||
|
bypass_quarantine=bypass_quarantine,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
ip_address = request.getClientAddress().host
|
ip_address = request.getClientAddress().host
|
||||||
@@ -271,6 +292,7 @@ class DownloadResource(RestServlet):
|
|||||||
max_timeout_ms,
|
max_timeout_ms,
|
||||||
ip_address,
|
ip_address,
|
||||||
True,
|
True,
|
||||||
|
bypass_quarantine=bypass_quarantine,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -71,14 +71,43 @@ class QuarantineMediaTestCase(unittest.HomeserverTestCase):
|
|||||||
return resources
|
return resources
|
||||||
|
|
||||||
def _ensure_quarantined(
|
def _ensure_quarantined(
|
||||||
self, admin_user_tok: str, server_and_media_id: str
|
self,
|
||||||
|
user_tok: str,
|
||||||
|
server_and_media_id: str,
|
||||||
|
include_bypass_param: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Ensure a piece of media is quarantined when trying to access it."""
|
"""Ensure a piece of media is quarantined when trying to access it.
|
||||||
|
|
||||||
|
The include_bypass_param flag enables the presence of the
|
||||||
|
admin_unsafely_bypass_quarantine query parameter, but still expects that the
|
||||||
|
request will fail to download the media.
|
||||||
|
"""
|
||||||
|
if include_bypass_param:
|
||||||
|
query_string = "?admin_unsafely_bypass_quarantine=true"
|
||||||
|
channel = self.make_request(
|
||||||
|
"GET",
|
||||||
|
f"/_matrix/client/v1/media/download/{server_and_media_id}{query_string}",
|
||||||
|
shorthand=False,
|
||||||
|
access_token=user_tok,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Non-admins can't bypass, so this should fail regardless of whether the
|
||||||
|
# media is actually quarantined.
|
||||||
|
self.assertEqual(
|
||||||
|
400,
|
||||||
|
channel.code,
|
||||||
|
msg=(
|
||||||
|
"Expected to receive a 400 when bypassing quarantined media: %s"
|
||||||
|
% server_and_media_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Repeat the request, this time without the bypass parameter.
|
||||||
channel = self.make_request(
|
channel = self.make_request(
|
||||||
"GET",
|
"GET",
|
||||||
f"/_matrix/client/v1/media/download/{server_and_media_id}",
|
f"/_matrix/client/v1/media/download/{server_and_media_id}",
|
||||||
shorthand=False,
|
shorthand=False,
|
||||||
access_token=admin_user_tok,
|
access_token=user_tok,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Should be quarantined
|
# Should be quarantined
|
||||||
@@ -91,6 +120,62 @@ class QuarantineMediaTestCase(unittest.HomeserverTestCase):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_admin_can_bypass_quarantine(self) -> None:
|
||||||
|
self.register_user("admin", "pass", admin=True)
|
||||||
|
admin_user_tok = self.login("admin", "pass")
|
||||||
|
|
||||||
|
# Upload some media
|
||||||
|
response = self.helper.upload_media(SMALL_PNG, tok=admin_user_tok)
|
||||||
|
|
||||||
|
# Extract media ID from the response
|
||||||
|
server_name_and_media_id = response["content_uri"][6:] # Cut off 'mxc://'
|
||||||
|
server_name, media_id = server_name_and_media_id.split("/")
|
||||||
|
|
||||||
|
# Attempt to access the media
|
||||||
|
channel = self.make_request(
|
||||||
|
"GET",
|
||||||
|
f"/_matrix/client/v1/media/download/{server_name_and_media_id}",
|
||||||
|
shorthand=False,
|
||||||
|
access_token=admin_user_tok,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should be successful
|
||||||
|
self.assertEqual(200, channel.code)
|
||||||
|
|
||||||
|
# Quarantine the media
|
||||||
|
url = "/_synapse/admin/v1/media/quarantine/%s/%s" % (
|
||||||
|
urllib.parse.quote(server_name),
|
||||||
|
urllib.parse.quote(media_id),
|
||||||
|
)
|
||||||
|
channel = self.make_request(
|
||||||
|
"POST",
|
||||||
|
url,
|
||||||
|
access_token=admin_user_tok,
|
||||||
|
)
|
||||||
|
self.pump(1.0)
|
||||||
|
self.assertEqual(200, channel.code, msg=channel.json_body)
|
||||||
|
|
||||||
|
# Now access it *without* the bypass parameter - this should fail (as expected).
|
||||||
|
self._ensure_quarantined(
|
||||||
|
admin_user_tok, server_name_and_media_id, include_bypass_param=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Now access it *with* the bypass parameter - this should work
|
||||||
|
channel = self.make_request(
|
||||||
|
"GET",
|
||||||
|
f"/_matrix/client/v1/media/download/{server_name_and_media_id}?admin_unsafely_bypass_quarantine=true",
|
||||||
|
shorthand=False,
|
||||||
|
access_token=admin_user_tok,
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
200,
|
||||||
|
channel.code,
|
||||||
|
msg=(
|
||||||
|
"Expected to receive a 200 on accessing (with bypass) quarantined media: %s"
|
||||||
|
% server_name_and_media_id
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
@parameterized.expand(
|
@parameterized.expand(
|
||||||
[
|
[
|
||||||
# Attempt quarantine media APIs as non-admin
|
# Attempt quarantine media APIs as non-admin
|
||||||
@@ -154,8 +239,14 @@ class QuarantineMediaTestCase(unittest.HomeserverTestCase):
|
|||||||
self.pump(1.0)
|
self.pump(1.0)
|
||||||
self.assertEqual(200, channel.code, msg=channel.json_body)
|
self.assertEqual(200, channel.code, msg=channel.json_body)
|
||||||
|
|
||||||
# Attempt to access the media
|
# Attempt to access the media (and ensure non-admins can't download it, even
|
||||||
self._ensure_quarantined(admin_user_tok, server_name_and_media_id)
|
# with a bypass parameter). Admins cannot download it without the bypass param.
|
||||||
|
self._ensure_quarantined(
|
||||||
|
non_admin_user_tok, server_name_and_media_id, include_bypass_param=True
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
admin_user_tok, server_name_and_media_id, include_bypass_param=False
|
||||||
|
)
|
||||||
|
|
||||||
@parameterized.expand(
|
@parameterized.expand(
|
||||||
[
|
[
|
||||||
@@ -214,9 +305,21 @@ class QuarantineMediaTestCase(unittest.HomeserverTestCase):
|
|||||||
server_and_media_id_1 = mxc_1[6:]
|
server_and_media_id_1 = mxc_1[6:]
|
||||||
server_and_media_id_2 = mxc_2[6:]
|
server_and_media_id_2 = mxc_2[6:]
|
||||||
|
|
||||||
# Test that we cannot download any of the media anymore
|
# Test that we cannot download any of the media anymore, especially with the
|
||||||
self._ensure_quarantined(admin_user_tok, server_and_media_id_1)
|
# bypass parameter set. Admins cannot download the media without supplying the
|
||||||
self._ensure_quarantined(admin_user_tok, server_and_media_id_2)
|
# bypass parameter, so we check that too.
|
||||||
|
self._ensure_quarantined(
|
||||||
|
non_admin_user_tok, server_and_media_id_1, include_bypass_param=True
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
non_admin_user_tok, server_and_media_id_2, include_bypass_param=True
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
admin_user_tok, server_and_media_id_1, include_bypass_param=False
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
admin_user_tok, server_and_media_id_2, include_bypass_param=False
|
||||||
|
)
|
||||||
|
|
||||||
def test_quarantine_all_media_by_user(self) -> None:
|
def test_quarantine_all_media_by_user(self) -> None:
|
||||||
self.register_user("user_admin", "pass", admin=True)
|
self.register_user("user_admin", "pass", admin=True)
|
||||||
@@ -263,10 +366,27 @@ class QuarantineMediaTestCase(unittest.HomeserverTestCase):
|
|||||||
channel.json_body, {"num_quarantined": 3}, "Expected 3 quarantined items"
|
channel.json_body, {"num_quarantined": 3}, "Expected 3 quarantined items"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Attempt to access each piece of media
|
# Attempt to access each piece of media, ensuring that it can't be downloaded
|
||||||
self._ensure_quarantined(admin_user_tok, server_and_media_id_1)
|
# even with a bypass parameter. Admins should not be able to download the media
|
||||||
self._ensure_quarantined(admin_user_tok, server_and_media_id_2)
|
# either when not supplying the bypass parameter, so we check that too.
|
||||||
self._ensure_quarantined(admin_user_tok, server_and_media_id_3)
|
self._ensure_quarantined(
|
||||||
|
non_admin_user_tok, server_and_media_id_1, include_bypass_param=True
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
non_admin_user_tok, server_and_media_id_2, include_bypass_param=True
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
non_admin_user_tok, server_and_media_id_3, include_bypass_param=True
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
admin_user_tok, server_and_media_id_1, include_bypass_param=False
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
admin_user_tok, server_and_media_id_2, include_bypass_param=False
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
admin_user_tok, server_and_media_id_3, include_bypass_param=False
|
||||||
|
)
|
||||||
|
|
||||||
def test_cannot_quarantine_safe_media(self) -> None:
|
def test_cannot_quarantine_safe_media(self) -> None:
|
||||||
self.register_user("user_admin", "pass", admin=True)
|
self.register_user("user_admin", "pass", admin=True)
|
||||||
@@ -307,8 +427,14 @@ class QuarantineMediaTestCase(unittest.HomeserverTestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Attempt to access each piece of media, the first should fail, the
|
# Attempt to access each piece of media, the first should fail, the
|
||||||
# second should succeed.
|
# second should succeed. We check both the non-admin user with a bypass
|
||||||
self._ensure_quarantined(admin_user_tok, server_and_media_id_1)
|
# parameter, and the admin user without.
|
||||||
|
self._ensure_quarantined(
|
||||||
|
non_admin_user_tok, server_and_media_id_1, include_bypass_param=True
|
||||||
|
)
|
||||||
|
self._ensure_quarantined(
|
||||||
|
admin_user_tok, server_and_media_id_1, include_bypass_param=False
|
||||||
|
)
|
||||||
|
|
||||||
# Attempt to access each piece of media
|
# Attempt to access each piece of media
|
||||||
channel = self.make_request(
|
channel = self.make_request(
|
||||||
|
|||||||
Reference in New Issue
Block a user