From 7f19330d33ea7ae46e502fb7fb6c837f02965d6e Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Mon, 6 Apr 2026 17:53:32 -0600 Subject: [PATCH] Support search redirection with policyserv --- changelog.d/19658.feature | 1 + .../configuration/config_documentation.md | 24 +++ schema/synapse-config.schema.yaml | 41 ++++++ synapse/config/_base.pyi | 2 + synapse/config/homeserver.py | 2 + synapse/config/safety.py | 37 +++++ synapse/handlers/room_list.py | 8 + synapse/handlers/server_policy.py | 137 ++++++++++++++++++ synapse/http/client.py | 7 +- synapse/server.py | 5 + tests/handlers/test_room_list.py | 72 +++++++++ 11 files changed, 335 insertions(+), 1 deletion(-) create mode 100644 changelog.d/19658.feature create mode 100644 synapse/config/safety.py create mode 100644 synapse/handlers/server_policy.py diff --git a/changelog.d/19658.feature b/changelog.d/19658.feature new file mode 100644 index 0000000000..aba1ad82d9 --- /dev/null +++ b/changelog.d/19658.feature @@ -0,0 +1 @@ +Optionally intercept harmful room directory searches with error messages through policyserv. See new "Safety Policy" configuration for details. \ No newline at end of file diff --git a/docs/usage/configuration/config_documentation.md b/docs/usage/configuration/config_documentation.md index d028d65fe3..8f8adc3cb8 100644 --- a/docs/usage/configuration/config_documentation.md +++ b/docs/usage/configuration/config_documentation.md @@ -4785,3 +4785,27 @@ auto_accept_invites: only_from_local_users: true worker_to_run_on: worker_1 ``` +--- +### `safety_policy` + +*(object)* Controls various aspects of the user and content safety policy for the homeserver. +Safety policy is currently provided by the "server-centric" API of [policyserv](https://github.com/matrix-org/policyserv). +NOTE: policyserv is a specific implementation of a Matrix policy server. Other policy server implementations may not work with Synapse. Refer to your policy server's documentation for details on what it supports. +NOTE: This section is expected to gain more options in the future. + +This setting has the following sub-options: + +* `policyserv_url` (string): The base URL to use when contacting the policyserv instance. When empty or not provided, the safety policy support is disabled. Defaults to `null`. + +* `policyserv_api_key` (string): The API key for the server-centric API of the above-provided policyserv instance. When empty or not provided, the safety policy support is disabled. Defaults to `null`. + +* `enable_search_redirection` (boolean): Whether to check room directory searches with against the safety policy (via the above-configured policyserv instance). Defaults to `false`. + NOTE: The specific policies or filters which determine a room search to be unsafe or harmful is dependent on that policyserv community's settings. In general, when an unsafe search is performed, the caller is met with zero results and possible deterrence messaging to discourage repeated or similar searches. Defaults to `false`. + +Example configuration: +```yaml +safety_policy: + policyserv_url: https://beta2.matrix.org + policyserv_api_key: your_secret_ps_api_key + enable_search_redirection: true +``` diff --git a/schema/synapse-config.schema.yaml b/schema/synapse-config.schema.yaml index dc57cfeea5..5968e29dfd 100644 --- a/schema/synapse-config.schema.yaml +++ b/schema/synapse-config.schema.yaml @@ -5883,6 +5883,47 @@ properties: only_for_direct_messages: true only_from_local_users: true worker_to_run_on: worker_1 + safety_policy: + type: object + description: >- + Controls various aspects of the user and content safety policy for the homeserver. + + Safety policy is currently provided by the "server-centric" API of + [policyserv](https://github.com/matrix-org/policyserv). + + NOTE: policyserv is a specific implementation of a Matrix policy server. Other policy + server implementations may not work with Synapse. Refer to your policy server's + documentation for details on what it supports. + + NOTE: This section is expected to gain more options in the future. + properties: + policyserv_url: + type: string + description: >- + The base URL to use when contacting the policyserv instance. When empty or not + provided, the safety policy support is disabled. + default: null + policyserv_api_key: + type: string + description: >- + The API key for the server-centric API of the above-provided policyserv instance. + When empty or not provided, the safety policy support is disabled. + default: null + enable_search_redirection: + type: boolean + description: >- + Whether to check room directory searches with against the safety policy (via the + above-configured policyserv instance). Defaults to `false`. + + NOTE: The specific policies or filters which determine a room search to be unsafe + or harmful is dependent on that policyserv community's settings. In general, when + an unsafe search is performed, the caller is met with zero results and possible + deterrence messaging to discourage repeated or similar searches. + default: false + examples: + - policyserv_url: https://beta2.matrix.org + policyserv_api_key: "your_secret_ps_api_key" + enable_search_redirection: true $defs: bytes: type: ["string", "integer"] diff --git a/synapse/config/_base.pyi b/synapse/config/_base.pyi index 7c371d161c..47751a10f8 100644 --- a/synapse/config/_base.pyi +++ b/synapse/config/_base.pyi @@ -45,6 +45,7 @@ from synapse.config import ( # noqa: F401 retention, room, room_directory, + safety, saml2, server, server_notices, @@ -122,6 +123,7 @@ class RootConfig: user_types: user_types.UserTypesConfig mas: mas.MasConfig matrix_rtc: matrixrtc.MatrixRtcConfig + safety: safety.SafetyConfig config_classes: list[type["Config"]] = ... config_files: list[str] diff --git a/synapse/config/homeserver.py b/synapse/config/homeserver.py index 94ebe583a4..da0b9936a1 100644 --- a/synapse/config/homeserver.py +++ b/synapse/config/homeserver.py @@ -52,6 +52,7 @@ from .repository import ContentRepositoryConfig from .retention import RetentionConfig from .room import RoomConfig from .room_directory import RoomDirectoryConfig +from .safety import SafetyConfig from .saml2 import SAML2Config from .server import ServerConfig from .server_notices import ServerNoticesConfig @@ -109,6 +110,7 @@ class HomeServerConfig(RootConfig): StatsConfig, ServerNoticesConfig, RoomDirectoryConfig, + SafetyConfig, ThirdPartyRulesConfig, TracerConfig, WorkerConfig, diff --git a/synapse/config/safety.py b/synapse/config/safety.py new file mode 100644 index 0000000000..36ce4a764d --- /dev/null +++ b/synapse/config/safety.py @@ -0,0 +1,37 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creation Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# +# Originally licensed under the Apache License, Version 2.0: +# . +# +# [This file includes modifications made by New Vector Limited] +# +# + +from typing import Any + +from synapse.types import JsonDict + +from ._base import Config + + +class SafetyConfig(Config): + section = "safety" + + def read_config(self, config: JsonDict, **kwargs: Any) -> None: + safety_config = config.get("safety_policy", {}) + self.policyserv_url = safety_config.get("policyserv_url", None) + self.policyserv_api_key = safety_config.get("policyserv_api_key", None) + self.enable_search_redirection = safety_config.get( + "enable_search_redirection", False + ) diff --git a/synapse/handlers/room_list.py b/synapse/handlers/room_list.py index b25fd0a1e7..7cb899c36a 100644 --- a/synapse/handlers/room_list.py +++ b/synapse/handlers/room_list.py @@ -64,6 +64,7 @@ class RoomListHandler: def __init__(self, hs: "HomeServer"): self.server_name = hs.hostname # nb must be called this for @cached self.store = hs.get_datastores().main + self._server_policy_handler = hs.get_server_policy_handler() self._storage_controllers = hs.get_storage_controllers() self.hs = hs self.enable_room_list_search = hs.config.roomdirectory.enable_room_list_search @@ -118,6 +119,10 @@ class RoomListHandler: network_tuple, ) + if search_filter: + query = search_filter.get(PublicRoomsFilterFields.GENERIC_SEARCH_TERM, "") + await self._server_policy_handler.assert_neutral_search_query(query) + capped_limit: int = ( MAX_PUBLIC_ROOMS_IN_RESPONSE if limit is None or limit > MAX_PUBLIC_ROOMS_IN_RESPONSE @@ -472,6 +477,9 @@ class RoomListHandler: return {"chunk": [], "total_room_count_estimate": 0} if search_filter: + query = search_filter.get(PublicRoomsFilterFields.GENERIC_SEARCH_TERM, "") + await self._server_policy_handler.assert_neutral_search_query(query) + # Searching across federation is defined in MSC2197. # However, the remote homeserver may or may not actually support it. # So we first try an MSC2197 remote-filtered search, then fall back diff --git a/synapse/handlers/server_policy.py b/synapse/handlers/server_policy.py new file mode 100644 index 0000000000..b8985e64ca --- /dev/null +++ b/synapse/handlers/server_policy.py @@ -0,0 +1,137 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . +# +# + +import logging +from http import HTTPStatus +from typing import TYPE_CHECKING + +from twisted.web.client import readBody +from twisted.web.http_headers import Headers + +from synapse.api.errors import HttpResponseException, SynapseError +from synapse.logging.context import make_deferred_yieldable + +if TYPE_CHECKING: + from synapse.server import HomeServer + +logger = logging.getLogger(__name__) + + +class _PolicyservCheckType: + TEXT = "text" + EVENT_ID = "event_id" + + +class ServerPolicyHandler: + """Similar to the RoomPolicyHandler, but applies to the whole homeserver. + + Primarily used to interact with external-to-Synapse safety tooling. + + Current features include: + * Search Redirection - When a user tries to search for a room in our directory, the + query is first checked by a safety tool. If deemed unsafe or harmful, the search + returns zero results and *may* include deterrence messaging as a redirect. + + The above is accomplished using policyserv-compatible "server-centric" API calls. + For details, refer to the policyserv documentation: https://github.com/matrix-org/policyserv + + Note: policyserv is a specific Matrix policy server implementation. There is no standard + Matrix specification for the policyserv API. This may change as the API surface is + evaluated over time. + """ + + def __init__(self, hs: "HomeServer"): + self._hs = hs + self._federation_client = hs.get_federation_client() + self._policyserv_url = hs.config.safety.policyserv_url + self._policyserv_api_key = hs.config.safety.policyserv_api_key + self._has_policyserv = bool(self._policyserv_url) and bool( + self._policyserv_api_key + ) + self._http_client = hs.get_proxied_http_client() + self._enable_search_redirection = hs.config.safety.enable_search_redirection + + async def assert_neutral_search_query(self, query: str) -> None: + """Asserts that the given search is neutral ("not unsafe or harmful"). + + What specific criteria are used to determine neutrality is left as an implementation + detail for the underlying policy provider. Typically, this will determine searches + for illegal material to be unsafe or harmful. + + Args: + query: The search query to be checked for safety. + + Raises: + SynapseError: When the query is deemed unsafe or harmful. This may include + deterrence messaging to discourage future, similar, searches. + """ + if not self._has_policyserv or not self._enable_search_redirection: + return # disabled implicitly or explicitly - don't raise an error + + if not query: + return # nothing is being searched for - don't raise an error + + await self._policyserv_check(_PolicyservCheckType.TEXT, query.encode("utf-8")) + + async def _policyserv_check(self, check_type: str, body: bytes) -> None: + """Performs a check against the policyserv Server-Centric Check API. + + Args: + check_type: The type of check to perform. This is the last component of the + check API to use. Try to use _PolicyservCheckType where possible. + body: The request body to send to the given check API. Must already be + formatted for that specific check type. + + Raises: + SynapseError: When policyserv fails the check, or there was an error contacting + the policyserv API. + """ + + # Do some quick asserts - we shouldn't be called if we don't have these details. + assert self._policyserv_url is not None + assert self._policyserv_api_key is not None + + # Call policyserv's check API, re-raising errors as Synapse errors if needed. + try: + response = await self._http_client.request( + method="POST", + uri=f"{self._policyserv_url}/_policyserv/v1/check/{check_type}", + data=body, + headers=Headers( + { + b"Authorization": [ + b"Bearer " + self._policyserv_api_key.encode("utf-8") + ], + } + ), + timeout=3, # somewhat arbitrary, but should be long enough for text matching + ) + except HttpResponseException as ex: + logger.info("HTTP error during policyserv request: %s", ex) + raise ex.to_synapse_error() + except Exception as ex: + logger.exception("Error contacting policyserv: %s", ex) + raise SynapseError( + HTTPStatus.INTERNAL_SERVER_ERROR, "unknown error contacting policyserv" + ) + + if response.code != 200: + # error handling copied from BaseHttpClient.post_json_get_json + body = await make_deferred_yieldable(readBody(response)) + response_ex = HttpResponseException( + response.code, response.phrase.decode("ascii", errors="replace"), body + ).to_synapse_error() + logger.info("policyserv rejected request: %s", response_ex) + raise response_ex diff --git a/synapse/http/client.py b/synapse/http/client.py index 05c5f13a87..ec58ffdb1b 100644 --- a/synapse/http/client.py +++ b/synapse/http/client.py @@ -379,6 +379,7 @@ class BaseHttpClient: uri: str, data: bytes | None = None, headers: Headers | None = None, + timeout: int | None = None, ) -> IResponse: """ Args: @@ -386,6 +387,7 @@ class BaseHttpClient: uri: URI to query. data: Data to send in the request body, if applicable. headers: Request headers. + timeout: Request timeout in seconds, or None to use the default timeout. Returns: Response object, once the headers have been read. @@ -434,6 +436,7 @@ class BaseHttpClient: # Avoid buffering the body in treq since we do not reuse # response bodies. unbuffered=True, + timeout=timeout, **self._extra_treq_args, ) @@ -899,6 +902,7 @@ class ReplicationClient(BaseHttpClient): uri: str, data: bytes | None = None, headers: Headers | None = None, + timeout: int | None = None, ) -> IResponse: """ Make a request, differs from BaseHttpClient.request in that it does not use treq. @@ -908,6 +912,7 @@ class ReplicationClient(BaseHttpClient): uri: URI to query. data: Data to send in the request body, if applicable. headers: Request headers. + timeout: Timeout in seconds for the request. If None, use default timeout. Returns: Response object, once the headers have been read. @@ -966,7 +971,7 @@ class ReplicationClient(BaseHttpClient): # (Updated url https://github.com/twisted/twisted/issues/9534) request_deferred = timeout_deferred( deferred=request_deferred, - timeout=60, + timeout=timeout if timeout is not None else 60, clock=self.hs.get_clock(), ) diff --git a/synapse/server.py b/synapse/server.py index 8bf19f11b5..622f7ce20c 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -129,6 +129,7 @@ from synapse.handlers.room_policy import RoomPolicyHandler from synapse.handlers.room_summary import RoomSummaryHandler from synapse.handlers.search import SearchHandler from synapse.handlers.send_email import SendEmailHandler +from synapse.handlers.server_policy import ServerPolicyHandler from synapse.handlers.set_password import SetPasswordHandler from synapse.handlers.sliding_sync import SlidingSyncHandler from synapse.handlers.sso import SsoHandler @@ -1112,6 +1113,10 @@ class HomeServer(metaclass=abc.ABCMeta): def get_room_policy_handler(self) -> RoomPolicyHandler: return RoomPolicyHandler(self) + @cache_in_self + def get_server_policy_handler(self) -> ServerPolicyHandler: + return ServerPolicyHandler(self) + @cache_in_self def get_event_client_serializer(self) -> EventClientSerializer: return EventClientSerializer(self) diff --git a/tests/handlers/test_room_list.py b/tests/handlers/test_room_list.py index e7c4436d1d..5431a10ffe 100644 --- a/tests/handlers/test_room_list.py +++ b/tests/handlers/test_room_list.py @@ -1,8 +1,15 @@ from http import HTTPStatus +from unittest.mock import AsyncMock +from twisted.internet.testing import MemoryReactor + +from synapse.api.constants import PublicRoomsFilterFields +from synapse.api.errors import Codes, SynapseError from synapse.rest import admin from synapse.rest.client import directory, login, room +from synapse.server import HomeServer from synapse.types import JsonDict +from synapse.util.clock import Clock from tests import unittest from tests.utils import default_config @@ -16,6 +23,26 @@ class RoomListHandlerTestCase(unittest.HomeserverTestCase): directory.register_servlets, ] + def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer: + self.mock_policy_handler = AsyncMock() + + # Other tests ideally ensure that the handler respects the configuration correctly. + # We're interested in testing that the handler is called, not that it's configured. + async def assert_neutral_search_query(query: str) -> None: + if query == "test_intentional_failure": + raise SynapseError( + HTTPStatus.BAD_REQUEST, "mocked policy fail", Codes.FORBIDDEN + ) + self.assertEqual(query, "test_search_term") + + self.mock_policy_handler.assert_neutral_search_query = ( + assert_neutral_search_query + ) + hs = self.setup_test_homeserver( + server_policy_handler=self.mock_policy_handler, + ) + return hs + def _create_published_room( self, tok: str, extra_content: JsonDict | None = None ) -> str: @@ -91,3 +118,48 @@ class RoomListHandlerTestCase(unittest.HomeserverTestCase): {room1, room3}, "test3 should be able to see only 2 rooms", ) + + def test_policyserv_can_intercept_searches(self) -> None: + """ + Tests that if a "safety policy" is configured, then that policy + server is consulted when requests for a search term are made. + + This functionality doesn't apply when there's no search term. + + No rooms are required to test this - the expected output is an + error to force zero results being returned. + + Typically, this functionality is used to intercept unsafe searches + for rooms and instead "redirect" the caller to elsewhere. The redirect + is done socially, not technically - the user is provided links to + support resources they can access. + """ + # Per docstring, quickly assert that no search means no policyserv call. + # This works because our mock handler will assert that the search query + # is a specific value - `None`/an empty string is not that value. + self.get_success( + self.hs.get_room_list_handler().get_local_public_room_list( + search_filter=None, + ) + ) + + # Now test that "safe" search queries are passed through normally + self.get_success( + self.hs.get_room_list_handler().get_local_public_room_list( + search_filter={ + PublicRoomsFilterFields.GENERIC_SEARCH_TERM: "test_search_term", + }, + ) + ) + + # Finally, test that an "unsafe" search query is intercepted by policyserv + err = self.get_failure( + self.hs.get_room_list_handler().get_local_public_room_list( + search_filter={ + PublicRoomsFilterFields.GENERIC_SEARCH_TERM: "test_intentional_failure", + }, + ), + SynapseError, + ).value + self.assertEqual(err.code, HTTPStatus.BAD_REQUEST) + self.assertEqual(err.errcode, Codes.FORBIDDEN)