Support search redirection with policyserv

This commit is contained in:
Travis Ralston
2026-05-31 17:29:42 -06:00
parent 306d8b23bd
commit 7f19330d33
11 changed files with 335 additions and 1 deletions
+1
View File
@@ -0,0 +1 @@
Optionally intercept harmful room directory searches with error messages through policyserv. See new "Safety Policy" configuration for details.
@@ -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
```
+41
View File
@@ -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"]
+2
View File
@@ -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]
+2
View File
@@ -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,
+37
View File
@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
# Originally licensed under the Apache License, Version 2.0:
# <http://www.apache.org/licenses/LICENSE-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
)
+8
View File
@@ -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
+137
View File
@@ -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:
# <https://www.gnu.org/licenses/agpl-3.0.html>.
#
#
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
+6 -1
View File
@@ -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(),
)
+5
View File
@@ -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)
+72
View File
@@ -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)