matrix-server-isenguard 0.1.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- matrix_server_isenguard-0.1.1/PKG-INFO +8 -0
- matrix_server_isenguard-0.1.1/README.md +75 -0
- matrix_server_isenguard-0.1.1/pyproject.toml +27 -0
- matrix_server_isenguard-0.1.1/setup.cfg +4 -0
- matrix_server_isenguard-0.1.1/src/isenguard/__init__.py +4 -0
- matrix_server_isenguard-0.1.1/src/isenguard/auth.py +83 -0
- matrix_server_isenguard-0.1.1/src/isenguard/dynamic_federation_whitelist.py +343 -0
- matrix_server_isenguard-0.1.1/src/isenguard/mxid_uri.py +70 -0
- matrix_server_isenguard-0.1.1/src/isenguard/user_search_fhir.py +404 -0
- matrix_server_isenguard-0.1.1/src/matrix_server_isenguard.egg-info/PKG-INFO +8 -0
- matrix_server_isenguard-0.1.1/src/matrix_server_isenguard.egg-info/SOURCES.txt +17 -0
- matrix_server_isenguard-0.1.1/src/matrix_server_isenguard.egg-info/dependency_links.txt +1 -0
- matrix_server_isenguard-0.1.1/src/matrix_server_isenguard.egg-info/requires.txt +3 -0
- matrix_server_isenguard-0.1.1/src/matrix_server_isenguard.egg-info/top_level.txt +1 -0
- matrix_server_isenguard-0.1.1/tests/test_auth.py +156 -0
- matrix_server_isenguard-0.1.1/tests/test_dynamic_federation.py +184 -0
- matrix_server_isenguard-0.1.1/tests/test_mxid_uri.py +77 -0
- matrix_server_isenguard-0.1.1/tests/test_rest_services.py +187 -0
- matrix_server_isenguard-0.1.1/tests/test_user_search_fhir.py +783 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Isenguard - FHIR plugins for Matrix Synapse
|
|
2
|
+
|
|
3
|
+
## Development
|
|
4
|
+
|
|
5
|
+
### Docker startup
|
|
6
|
+
Start the 3 Matrix servers:
|
|
7
|
+
``` bash
|
|
8
|
+
uv run demo/start.sh
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
They are named:
|
|
12
|
+
* `localhost:8080`
|
|
13
|
+
* `localhost:8081`
|
|
14
|
+
* `localhost:8082`
|
|
15
|
+
|
|
16
|
+
At first time setup, it will generate https certificates the servers need to federate.
|
|
17
|
+
|
|
18
|
+
You have to use exactly those names as homeserver when you register/login with your matrix client.
|
|
19
|
+
|
|
20
|
+
For federation, they are named:
|
|
21
|
+
* `localhost:8480`
|
|
22
|
+
* `localhost:8481`
|
|
23
|
+
* `localhost:8482`
|
|
24
|
+
|
|
25
|
+
If you want to invite a user from a different server, use the following syntax: `@some_user:localhost:8480`
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
### Server lifecycle
|
|
30
|
+
|
|
31
|
+
Start 3 federating demo servers:
|
|
32
|
+
``` bash
|
|
33
|
+
uv run demo/start.sh
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Stop the servers:
|
|
37
|
+
``` bash
|
|
38
|
+
uv run demo/stop.sh
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Delete all demo server data:
|
|
42
|
+
``` bash
|
|
43
|
+
uv run demo/clean.sh
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
### Development REST Services
|
|
48
|
+
|
|
49
|
+
The repository includes a flask server that mimics a REST API for the federation whitelist.
|
|
50
|
+
Start it by calling `flask --debug --app rest_services.app run`
|
|
51
|
+
|
|
52
|
+
The server will be available at `http://localhost:5000` with the followind APIs:
|
|
53
|
+
|
|
54
|
+
* `GET /federation/whitelist` : returns the current whitelist as JSON. It reads the whitelist from a file named
|
|
55
|
+
`server_whitelist.json` in the project root, so you can edit it locally.
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## Testing
|
|
59
|
+
|
|
60
|
+
Tests use the synapse test suite, so you must have synapse installed not from pypi, but from git:
|
|
61
|
+
Clone the synapse repository and install it in editable mode one directory up, to avoid VCS conflicts (we didn't want
|
|
62
|
+
to create a git submodule):
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
git clone --depth=1 https://github.com/element-hq/synapse.git ../synapse
|
|
66
|
+
uv pip install -e ../synapse
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
Then you can use the tst suite:
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
uv run pytest
|
|
75
|
+
```
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "matrix-server-isenguard"
|
|
3
|
+
version = "0.1.1"
|
|
4
|
+
description = "Add your description here"
|
|
5
|
+
requires-python = ">=3.13"
|
|
6
|
+
dependencies = [
|
|
7
|
+
"matrix-synapse>=1.148.0",
|
|
8
|
+
"aiohttp>=3.8.0",
|
|
9
|
+
"fhirclient>=4.0.0",
|
|
10
|
+
]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
[dependency-groups]
|
|
14
|
+
dev = [
|
|
15
|
+
"black",
|
|
16
|
+
"flask>=3.1.3",
|
|
17
|
+
"mkdocs>=1.6.1",
|
|
18
|
+
"mypy",
|
|
19
|
+
"pytest",
|
|
20
|
+
"pytest-asyncio>=1.3.0",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.pytest.ini_options]
|
|
24
|
+
asyncio_mode = "auto"
|
|
25
|
+
|
|
26
|
+
[tool.uv]
|
|
27
|
+
package = true
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Token providers for FHIR server authentication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
import time
|
|
8
|
+
from typing import Optional, Protocol
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TokenProvider(Protocol):
|
|
14
|
+
"""Returns the bearer token to send to the FHIR server."""
|
|
15
|
+
|
|
16
|
+
async def get_bearer_token(self) -> Optional[str]: ...
|
|
17
|
+
async def invalidate(self) -> None: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class OAuthClientCredentialsProvider:
|
|
21
|
+
"""Fetches and caches an id_token via OAuth2 client_credentials.
|
|
22
|
+
|
|
23
|
+
Concurrent refreshes are coalesced via an asyncio.Lock. The cached
|
|
24
|
+
token is refreshed once `now >= expires_at - safety_margin`.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
token_url: str,
|
|
30
|
+
client_id: str,
|
|
31
|
+
client_secret: str,
|
|
32
|
+
scope: str,
|
|
33
|
+
http_client,
|
|
34
|
+
safety_margin_seconds: int = 60,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._token_url = token_url
|
|
37
|
+
self._client_id = client_id
|
|
38
|
+
self._client_secret = client_secret
|
|
39
|
+
self._scope = scope
|
|
40
|
+
self._http_client = http_client
|
|
41
|
+
self._safety_margin = safety_margin_seconds
|
|
42
|
+
self._token: Optional[str] = None
|
|
43
|
+
self._expires_at: float = 0.0
|
|
44
|
+
self._lock = asyncio.Lock()
|
|
45
|
+
|
|
46
|
+
async def get_bearer_token(self) -> Optional[str]:
|
|
47
|
+
if self._is_fresh():
|
|
48
|
+
return self._token
|
|
49
|
+
async with self._lock:
|
|
50
|
+
if self._is_fresh():
|
|
51
|
+
return self._token
|
|
52
|
+
await self._refresh()
|
|
53
|
+
return self._token
|
|
54
|
+
|
|
55
|
+
async def invalidate(self) -> None:
|
|
56
|
+
async with self._lock:
|
|
57
|
+
self._token = None
|
|
58
|
+
self._expires_at = 0.0
|
|
59
|
+
|
|
60
|
+
def _is_fresh(self) -> bool:
|
|
61
|
+
return bool(self._token) and time.monotonic() < self._expires_at - self._safety_margin
|
|
62
|
+
|
|
63
|
+
async def _refresh(self) -> None:
|
|
64
|
+
data = await self._http_client.post_urlencoded_get_json(
|
|
65
|
+
self._token_url,
|
|
66
|
+
args={
|
|
67
|
+
"grant_type": "client_credentials",
|
|
68
|
+
"client_id": self._client_id,
|
|
69
|
+
"client_secret": self._client_secret,
|
|
70
|
+
"scope": self._scope,
|
|
71
|
+
},
|
|
72
|
+
)
|
|
73
|
+
if not isinstance(data, dict):
|
|
74
|
+
raise RuntimeError(
|
|
75
|
+
f"Unexpected token response type: {type(data).__name__}"
|
|
76
|
+
)
|
|
77
|
+
token = data.get("id_token")
|
|
78
|
+
if not token:
|
|
79
|
+
raise RuntimeError("OAuth token response missing 'id_token'")
|
|
80
|
+
expires_in = int(data.get("expires_in", 3600))
|
|
81
|
+
self._token = token
|
|
82
|
+
self._expires_at = time.monotonic() + expires_in
|
|
83
|
+
logger.info("Refreshed FHIR OAuth id_token (expires in %ss)", expires_in)
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dynamic Federation Whitelist Synapse Module
|
|
3
|
+
|
|
4
|
+
This module controls federation by maintaining a whitelist of allowed servers
|
|
5
|
+
that is periodically fetched from a remote REST API. Only servers on the
|
|
6
|
+
whitelist are allowed to federate with this Synapse instance.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any, Dict, List, Optional, Set
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from synapse.api.errors import HttpResponseException
|
|
14
|
+
from synapse.module_api import ModuleApi
|
|
15
|
+
from synapse.events import EventBase
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# Conservative server-name grammar: lowercase letters, digits, dots, hyphens,
|
|
21
|
+
# colons (for ports), brackets (for IPv6 literals).
|
|
22
|
+
_SERVER_NAME_RE = re.compile(r"\A[a-z0-9.\-:\[\]]+\Z")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _is_valid_server_name(name: str) -> bool:
|
|
26
|
+
"""Return True if `name` looks like a Matrix server name.
|
|
27
|
+
|
|
28
|
+
Defensive: rejects empty strings, whitespace, wildcards, control chars,
|
|
29
|
+
and anything else that wouldn't ever match a real sender domain.
|
|
30
|
+
"""
|
|
31
|
+
if not name or len(name) > 255:
|
|
32
|
+
return False
|
|
33
|
+
return bool(_SERVER_NAME_RE.match(name))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class DynamicFederationWhitelist:
|
|
37
|
+
"""A Synapse module that maintains a dynamic whitelist of federation servers."""
|
|
38
|
+
|
|
39
|
+
def __init__(self, config: Dict[str, Any], api: ModuleApi):
|
|
40
|
+
"""
|
|
41
|
+
Initialize the Dynamic Federation Whitelist module.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
config: Module configuration from homeserver.yaml
|
|
45
|
+
api: Synapse ModuleApi instance
|
|
46
|
+
"""
|
|
47
|
+
self.api = api
|
|
48
|
+
self.config = config
|
|
49
|
+
main_config = api._hs.config
|
|
50
|
+
|
|
51
|
+
# Get configuration parameters
|
|
52
|
+
self.whitelist_api_url = config.get("whitelist_api_url", "")
|
|
53
|
+
self.api_key = config.get("api_key", "")
|
|
54
|
+
self.update_interval = config.get("update_interval_minutes", 10)
|
|
55
|
+
self.timeout = config.get("timeout", 10)
|
|
56
|
+
self.enabled = config.get("enabled", True)
|
|
57
|
+
# Normalise the initial whitelist to lowercase; matrix server names are
|
|
58
|
+
# case-insensitive (RFC 1035 / Matrix spec) and we compare lowercase below.
|
|
59
|
+
self.initial_whitelist = {
|
|
60
|
+
s.lower() for s in (main_config.federation.federation_domain_whitelist or set())
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
# In-memory whitelist storage
|
|
64
|
+
self.whitelisted_servers: Set[str] = self.initial_whitelist.copy()
|
|
65
|
+
self.last_update: Optional[datetime] = None
|
|
66
|
+
self.last_fetch_success: bool = False
|
|
67
|
+
|
|
68
|
+
# Validate configuration
|
|
69
|
+
if self.enabled and not self.whitelist_api_url and not self.initial_whitelist:
|
|
70
|
+
logger.error("Neither Whitelist API URL nor initial_whitelist configured. Module will be disabled.")
|
|
71
|
+
self.enabled = False
|
|
72
|
+
|
|
73
|
+
if self.enabled and self.whitelist_api_url.startswith("http://"):
|
|
74
|
+
logger.warning(
|
|
75
|
+
"whitelist_api_url is configured with plain HTTP (%s) — the "
|
|
76
|
+
"API key will be transmitted in clear text. Use HTTPS in "
|
|
77
|
+
"production.",
|
|
78
|
+
self.whitelist_api_url,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Register federation spam checker callbacks
|
|
82
|
+
if self.enabled:
|
|
83
|
+
# Register the should_drop_federated_event callback to control federation
|
|
84
|
+
api.register_spam_checker_callbacks(
|
|
85
|
+
should_drop_federated_event=self.should_drop_server_federation_event,
|
|
86
|
+
user_may_invite=self.user_may_invite,
|
|
87
|
+
federated_user_may_invite=self.federated_user_may_invite,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Only start the update task if we have an API URL
|
|
91
|
+
if self.whitelist_api_url:
|
|
92
|
+
# Start the periodic update task
|
|
93
|
+
self.api.looping_background_call(
|
|
94
|
+
self._update_whitelist,
|
|
95
|
+
msec=self.update_interval * 60 * 1000,
|
|
96
|
+
desc="Dynamic Federation Whitelist Update",
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Also, run an initial update immediately
|
|
100
|
+
self.api.run_as_background_process(
|
|
101
|
+
"Initial Federation Whitelist Update",
|
|
102
|
+
self._update_whitelist,
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
logger.info(
|
|
106
|
+
f"Dynamic Federation Whitelist module initialized with API: {self.whitelist_api_url}, "
|
|
107
|
+
f"update interval: {self.update_interval} minutes"
|
|
108
|
+
)
|
|
109
|
+
else:
|
|
110
|
+
logger.info(
|
|
111
|
+
f"Dynamic Federation Whitelist module initialized with static whitelist: {self.whitelisted_servers}"
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
logger.info("Dynamic Federation Whitelist module is disabled")
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def parse_config(config: Dict[str, Any]) -> Dict[str, Any]:
|
|
118
|
+
"""
|
|
119
|
+
Parse and validate the module configuration.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
config: Raw configuration dictionary
|
|
123
|
+
|
|
124
|
+
Returns:
|
|
125
|
+
Parsed configuration dictionary
|
|
126
|
+
"""
|
|
127
|
+
return {
|
|
128
|
+
"whitelist_api_url": config.get("whitelist_api_url", ""),
|
|
129
|
+
"api_key": config.get("api_key", ""),
|
|
130
|
+
"update_interval_minutes": config.get("update_interval_minutes", 10),
|
|
131
|
+
"timeout": config.get("timeout", 10),
|
|
132
|
+
"enabled": config.get("enabled", True),
|
|
133
|
+
"initial_whitelist": config.get("initial_whitelist", []),
|
|
134
|
+
"headers": config.get("additional_headers", {}),
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async def should_drop_server_federation_event(self, event: EventBase) -> bool:
|
|
138
|
+
"""
|
|
139
|
+
Check if a event's origin server name should be blocked based on whitelisted_servers.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
event: event that comes in from other servers
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
True if the event should be dropped, False otherwise.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
# Check if the sender's server is whitelisted. The whitelist is stored
|
|
149
|
+
# lowercase; lowercase the origin too because Matrix server names are
|
|
150
|
+
# case-insensitive (RFC 1035, Matrix spec). NOTE: this approximates
|
|
151
|
+
# "origin server" via event.sender — federation signing makes that
|
|
152
|
+
# safe in practice, but a fully strict implementation would also
|
|
153
|
+
# check the PDU's signing origin.
|
|
154
|
+
try:
|
|
155
|
+
_, origin = event.sender.split(":", 1)
|
|
156
|
+
except (ValueError, AttributeError):
|
|
157
|
+
return True # Drop if the sender format is invalid or missing
|
|
158
|
+
origin = origin.lower()
|
|
159
|
+
if origin not in self.whitelisted_servers:
|
|
160
|
+
logger.info("Blocking federation from non-whitelisted server: %s", origin)
|
|
161
|
+
return True
|
|
162
|
+
|
|
163
|
+
logger.debug("Federation from whitelisted server: %s", origin)
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
async def user_may_invite(self, inviter_userid: str, invitee_userid: str, room_id: str) -> bool:
|
|
167
|
+
"""Check if a local user is allowed to invite a user from another server."""
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
_, remote_domain = invitee_userid.split(":", 1)
|
|
171
|
+
except (ValueError, AttributeError):
|
|
172
|
+
return True # Allow if format is invalid (let Synapse handle it)
|
|
173
|
+
|
|
174
|
+
# If it's a local user, always allow
|
|
175
|
+
# TODO: maybe this could be denied too, if we were strict (or a config option?)
|
|
176
|
+
if self.api.is_mine(invitee_userid):
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
if remote_domain.lower() not in self.whitelisted_servers:
|
|
180
|
+
logger.info("Blocking invite to non-whitelisted server: %s", remote_domain)
|
|
181
|
+
return False
|
|
182
|
+
|
|
183
|
+
return True
|
|
184
|
+
|
|
185
|
+
async def federated_user_may_invite(self, event: EventBase) -> bool:
|
|
186
|
+
"""Check if a federated user is allowed to invite a local user."""
|
|
187
|
+
|
|
188
|
+
inviter_userid = event.sender
|
|
189
|
+
try:
|
|
190
|
+
_, remote_domain = inviter_userid.split(":", 1)
|
|
191
|
+
except (ValueError, AttributeError):
|
|
192
|
+
return True # Allow if format is invalid (let Synapse handle it)
|
|
193
|
+
|
|
194
|
+
# If it's a local user, always allow (shouldn't happen for this callback)
|
|
195
|
+
# TODO: maybe this could be denied too, if we were strict (or a config option?)
|
|
196
|
+
if self.api.is_mine(inviter_userid):
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
if remote_domain.lower() not in self.whitelisted_servers:
|
|
200
|
+
logger.info("Blocking invite from non-whitelisted server: %s", remote_domain)
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
return True
|
|
204
|
+
|
|
205
|
+
async def _update_whitelist(self):
|
|
206
|
+
"""
|
|
207
|
+
Fetch the current whitelist from the remote API and update the in-memory list.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
logger.info("Fetching federation whitelist from remote API...")
|
|
211
|
+
|
|
212
|
+
servers = await self._fetch_whitelist_from_api()
|
|
213
|
+
|
|
214
|
+
if servers is not None:
|
|
215
|
+
old_count = len(self.whitelisted_servers)
|
|
216
|
+
|
|
217
|
+
# Build the new set fully before assigning, so concurrent
|
|
218
|
+
# readers in should_drop_server_federation_event never see
|
|
219
|
+
# a half-updated state (without the initial whitelist merged in).
|
|
220
|
+
new_set: Set[str] = set(servers)
|
|
221
|
+
if self.initial_whitelist:
|
|
222
|
+
new_set.update(self.initial_whitelist)
|
|
223
|
+
self.whitelisted_servers = new_set
|
|
224
|
+
|
|
225
|
+
self.last_update = datetime.now()
|
|
226
|
+
self.last_fetch_success = True
|
|
227
|
+
|
|
228
|
+
new_count = len(self.whitelisted_servers)
|
|
229
|
+
logger.info(
|
|
230
|
+
"Federation whitelist updated: %d -> %d servers. Current whitelist: %s",
|
|
231
|
+
old_count,
|
|
232
|
+
new_count,
|
|
233
|
+
sorted(self.whitelisted_servers),
|
|
234
|
+
)
|
|
235
|
+
else:
|
|
236
|
+
self.last_fetch_success = False
|
|
237
|
+
logger.warning(
|
|
238
|
+
"Failed to fetch whitelist from remote API. "
|
|
239
|
+
f"Keeping existing whitelist with {len(self.whitelisted_servers)} servers."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
except Exception as e:
|
|
243
|
+
self.last_fetch_success = False
|
|
244
|
+
logger.error(f"Error updating federation whitelist: {e}")
|
|
245
|
+
|
|
246
|
+
async def _fetch_whitelist_from_api(self) -> Optional[List[str]]:
|
|
247
|
+
"""
|
|
248
|
+
Fetch the whitelist from the remote REST API.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
List of whitelisted server names or None if request fails
|
|
252
|
+
"""
|
|
253
|
+
# Convert headers to Synapse format (bytes -> List[bytes])
|
|
254
|
+
synapse_headers = {}
|
|
255
|
+
for k, v in self.config.get("headers", {}).items():
|
|
256
|
+
synapse_headers[k.encode("utf-8")] = [v.encode("utf-8")]
|
|
257
|
+
|
|
258
|
+
# Add API key if configured
|
|
259
|
+
if self.api_key:
|
|
260
|
+
synapse_headers[b"Authorization"] = [f"Bearer {self.api_key}".encode("utf-8")]
|
|
261
|
+
|
|
262
|
+
try:
|
|
263
|
+
data = await self.api.http_client.get_json(
|
|
264
|
+
self.whitelist_api_url,
|
|
265
|
+
headers=synapse_headers,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
# Parse the response - expecting a list of server names
|
|
269
|
+
servers = []
|
|
270
|
+
|
|
271
|
+
# Handle different possible response formats
|
|
272
|
+
if isinstance(data, list):
|
|
273
|
+
# Direct list of servers
|
|
274
|
+
servers = data
|
|
275
|
+
elif isinstance(data, dict):
|
|
276
|
+
# Check for common field names
|
|
277
|
+
if "servers" in data:
|
|
278
|
+
servers = data["servers"]
|
|
279
|
+
elif "whitelist" in data:
|
|
280
|
+
servers = data["whitelist"]
|
|
281
|
+
elif "allowed" in data:
|
|
282
|
+
servers = data["allowed"]
|
|
283
|
+
elif "domains" in data:
|
|
284
|
+
servers = data["domains"]
|
|
285
|
+
else:
|
|
286
|
+
logger.warning(
|
|
287
|
+
f"Unexpected API response format: {list(data.keys())}"
|
|
288
|
+
)
|
|
289
|
+
return None
|
|
290
|
+
else:
|
|
291
|
+
logger.warning(f"Unexpected API response type: {type(data)}")
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
# Validate that we got a list of plausible hostnames. Reject any
|
|
295
|
+
# entry containing whitespace, control characters, or characters
|
|
296
|
+
# outside the hostname grammar — a compromised whitelist API
|
|
297
|
+
# should not be able to inject arbitrary tokens that get matched
|
|
298
|
+
# against parsed sender domains downstream.
|
|
299
|
+
validated_servers: List[str] = []
|
|
300
|
+
for server in servers:
|
|
301
|
+
name: Optional[str] = None
|
|
302
|
+
if isinstance(server, str):
|
|
303
|
+
name = server
|
|
304
|
+
elif isinstance(server, dict):
|
|
305
|
+
name = (
|
|
306
|
+
server.get("server")
|
|
307
|
+
or server.get("domain")
|
|
308
|
+
or server.get("name")
|
|
309
|
+
or server.get("hostname")
|
|
310
|
+
)
|
|
311
|
+
if not isinstance(name, str):
|
|
312
|
+
continue
|
|
313
|
+
name = name.strip().lower()
|
|
314
|
+
if name and _is_valid_server_name(name):
|
|
315
|
+
validated_servers.append(name)
|
|
316
|
+
|
|
317
|
+
logger.debug(
|
|
318
|
+
f"Fetched {len(validated_servers)} servers from remote API"
|
|
319
|
+
)
|
|
320
|
+
return validated_servers
|
|
321
|
+
|
|
322
|
+
except HttpResponseException as e:
|
|
323
|
+
logger.error(f"Remote API returned status {e.code} when fetching federation whitelist")
|
|
324
|
+
return None
|
|
325
|
+
except Exception as e:
|
|
326
|
+
logger.error(f"Error fetching federation whitelist: {e}")
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def create_module(config: Dict[str, Any], api: ModuleApi) -> DynamicFederationWhitelist:
|
|
331
|
+
"""
|
|
332
|
+
Factory function to create the module instance.
|
|
333
|
+
|
|
334
|
+
This is the entry point that Synapse will call to instantiate the module.
|
|
335
|
+
|
|
336
|
+
Args:
|
|
337
|
+
config: Module configuration from homeserver.yaml
|
|
338
|
+
api: Synapse ModuleApi instance
|
|
339
|
+
|
|
340
|
+
Returns:
|
|
341
|
+
Initialized DynamicFederationWhitelist instance
|
|
342
|
+
"""
|
|
343
|
+
return DynamicFederationWhitelist(config, api)
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Parse and validate Matrix user identifiers from various URI forms."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
# Matrix MXID grammar (https://spec.matrix.org/v1.10/appendices/#user-identifiers).
|
|
8
|
+
# Localpart: lowercase ASCII letters, digits, and a small punctuation set.
|
|
9
|
+
# Historical localparts include uppercase; we accept either to avoid
|
|
10
|
+
# rejecting legacy directory entries, but otherwise stay strict.
|
|
11
|
+
_LOCALPART_RE = re.compile(r"\A[a-zA-Z0-9._=/+\-]+\Z")
|
|
12
|
+
|
|
13
|
+
# Server-part: hostname (or IP) with optional :port. ASCII only — dots,
|
|
14
|
+
# hyphens, colons, brackets (IPv6), alphanumerics. No whitespace, no
|
|
15
|
+
# control characters, no other special chars.
|
|
16
|
+
_SERVER_RE = re.compile(r"\A[a-zA-Z0-9.\-:\[\]]+\Z")
|
|
17
|
+
|
|
18
|
+
# matrix: URI scheme — user form ("u/") and historical alias ("user/").
|
|
19
|
+
_MATRIX_URI_RE = re.compile(r"\Amatrix:(?:u|user)/([^:/?#]+):(.+)\Z")
|
|
20
|
+
|
|
21
|
+
# Hard upper bound from the Matrix spec.
|
|
22
|
+
_MXID_MAX_LEN = 255
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def parse_mxid(value: str | None) -> str | None:
|
|
26
|
+
"""Normalise a string to a syntactically valid MXID '@localpart:server', or None.
|
|
27
|
+
|
|
28
|
+
Validates:
|
|
29
|
+
- non-empty localpart and server
|
|
30
|
+
- localpart against the Matrix grammar
|
|
31
|
+
- server against a conservative hostname/port grammar
|
|
32
|
+
- total length <= 255
|
|
33
|
+
|
|
34
|
+
Accepts the same surface forms as before:
|
|
35
|
+
- '@localpart:server'
|
|
36
|
+
- 'matrix:u/localpart:server'
|
|
37
|
+
- 'matrix:user/localpart:server'
|
|
38
|
+
|
|
39
|
+
Returns None for anything that fails validation, with no exceptions
|
|
40
|
+
propagated. Untrusted input (FHIR responses, URI fields) should always
|
|
41
|
+
flow through this function before being handed to Synapse.
|
|
42
|
+
"""
|
|
43
|
+
if not value:
|
|
44
|
+
return None
|
|
45
|
+
s = value.strip()
|
|
46
|
+
if not s or len(s) > _MXID_MAX_LEN:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
if s.startswith("@"):
|
|
50
|
+
return _validated(s[1:])
|
|
51
|
+
|
|
52
|
+
m = _MATRIX_URI_RE.match(s)
|
|
53
|
+
if m:
|
|
54
|
+
return _validated(f"{m.group(1)}:{m.group(2)}")
|
|
55
|
+
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _validated(rest: str) -> str | None:
|
|
60
|
+
"""Validate '<localpart>:<server>' and return '@<localpart>:<server>' or None."""
|
|
61
|
+
if ":" not in rest:
|
|
62
|
+
return None
|
|
63
|
+
localpart, server = rest.split(":", 1)
|
|
64
|
+
if not localpart or not server:
|
|
65
|
+
return None
|
|
66
|
+
if not _LOCALPART_RE.match(localpart):
|
|
67
|
+
return None
|
|
68
|
+
if not _SERVER_RE.match(server):
|
|
69
|
+
return None
|
|
70
|
+
return f"@{localpart}:{server}"
|