Skip to content

Commit efb2187

Browse files
authored
Merge pull request chigwell#260 from vsem-azamat/feat/server-roots-only
feat(roots): opt-in server-only roots; pin symlink escape tests
2 parents bd172b6 + 272a50b commit efb2187

5 files changed

Lines changed: 191 additions & 0 deletions

File tree

‎.env.example‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ TELEGRAM_SESSION_NAME=telegram_session
8080
# if server CLI roots are configured. Set this to 1/true to fall back to the
8181
# server CLI roots in those cases. Default is deny-all.
8282
# TELEGRAM_ALLOW_SERVER_ROOTS_FALLBACK=1
83+
# Use the server roots as-is and never send roots/list (for clients that never
84+
# answer it). No effect without server roots. Default is off.
85+
# TELEGRAM_SERVER_ROOTS_ONLY=1
8386

8487
# --- HTTP/SSE transport security (optional) ---
8588
# Only relevant when MCP_TRANSPORT=http or sse. If the server is reachable via

‎README.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,9 @@ Security behavior:
642642
`TELEGRAM_ALLOW_SERVER_ROOTS_FALLBACK=1` to fall back to the server CLI roots
643643
in that case (opt-in; the default stays deny-all). The same opt-in also applies
644644
when `list_roots` fails unexpectedly and no client paths could be recovered.
645+
- `roots/list` times out after `TELEGRAM_ROOTS_TIMEOUT_SECONDS` (default 10).
646+
With server roots configured, `TELEGRAM_SERVER_ROOTS_ONLY=1` uses them directly
647+
and skips `roots/list`; client Roots are then ignored.
645648
- Paths are resolved through real paths and must stay inside an allowed root.
646649
- Traversal, wildcard-like, shell-like, and null-byte path patterns are rejected.
647650
- Relative paths resolve under the first allowed root.
@@ -844,6 +847,7 @@ Telegram messages, display names, chat titles, and button labels are untrusted c
844847
- **Database is locked:** make sure no other process is using the same file session.
845848
- **`AuthKeyDuplicatedError` / "Another telegram-mcp process is already connected with this session":** two processes tried to connect the same Telegram session at once (e.g. an MCP client restarted the connector before the old process exited), which Telegram rejects and can invalidate the session for both. The server now takes an exclusive lock per session before connecting; a second concurrent launch waits briefly (default 20s, override with `TELEGRAM_LOCK_GRACE_SECONDS`) for the first to release it and otherwise exits without ever calling `connect()`, instead of racing into a duplicate connection. Retry once only one instance is running — the refusal names the PID holding the lock. If several instances on this host are meant to share one session (all reaching Telegram from the same IP), set `TELEGRAM_SESSION_LOCK=shared`; see [Sharing one session from one host](#sharing-one-session-from-one-host).
846849
- **File tools are disabled:** pass allowed roots or configure MCP Roots in your client.
850+
- **File tools wait for `roots/list`:** configure server roots and set `TELEGRAM_SERVER_ROOTS_ONLY=1`.
847851
- **Path rejected:** ensure the path is inside an allowed root and does not use traversal or wildcard patterns.
848852
- **Auth errors after password changes:** regenerate your session string.
849853
- **Bot-only tool rejected:** regular user accounts cannot manage bot command settings.

‎telegram_mcp/runtime.py‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,7 @@ async def ensure_connected(cl: TelegramClient = None):
926926
ROOTS_STATUS_UNSUPPORTED_FALLBACK = "unsupported_fallback"
927927
ROOTS_STATUS_CLIENT_DENY_ALL = "client_deny_all"
928928
ROOTS_STATUS_SERVER_FALLBACK = "server_fallback"
929+
ROOTS_STATUS_SERVER_ONLY = "server_only"
929930
ROOTS_STATUS_ERROR = "error"
930931
ROOTS_STATUS_TIMEOUT = "timeout"
931932
# Some clients accept the server-initiated roots/list request but never answer
@@ -2156,6 +2157,12 @@ def _server_roots_fallback_enabled(value: Optional[str] = None) -> bool:
21562157
return _parse_bool_env(raw_value, False)
21572158

21582159

2160+
def _server_roots_only_enabled(value: Optional[str] = None) -> bool:
2161+
"""TELEGRAM_SERVER_ROOTS_ONLY: use server roots and skip roots/list (default off)."""
2162+
raw_value = os.getenv("TELEGRAM_SERVER_ROOTS_ONLY") if value is None else value
2163+
return _parse_bool_env(raw_value, False)
2164+
2165+
21592166
def _roots_request_timeout(value: Optional[str] = None) -> Optional[float]:
21602167
"""Seconds to wait for the client's ``roots/list`` reply.
21612168
@@ -2180,6 +2187,8 @@ async def _get_effective_allowed_roots_with_status(
21802187
if fallback_roots:
21812188
return fallback_roots, ROOTS_STATUS_READY
21822189
return [], ROOTS_STATUS_NOT_CONFIGURED
2190+
if fallback_roots and _server_roots_only_enabled():
2191+
return fallback_roots, ROOTS_STATUS_SERVER_ONLY
21832192

21842193
try:
21852194
timeout = _roots_request_timeout()
@@ -2269,6 +2278,7 @@ async def _ensure_allowed_roots(
22692278
(
22702279
f"{tool_name} is disabled because the MCP client never answered the "
22712280
"roots/list request. Configure server CLI roots and set "
2281+
"TELEGRAM_SERVER_ROOTS_ONLY=1 (skip roots/list) or "
22722282
"TELEGRAM_ALLOW_SERVER_ROOTS_FALLBACK=1, or raise "
22732283
"TELEGRAM_ROOTS_TIMEOUT_SECONDS."
22742284
),

‎tests/test_file_path_security.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,87 @@ async def test_readable_path_rejects_outside_root(tmp_path, monkeypatch):
100100
assert error == "Path is outside allowed roots."
101101

102102

103+
def _symlink(link, target, *, directory=False):
104+
try:
105+
link.symlink_to(target, target_is_directory=directory)
106+
except (OSError, NotImplementedError) as exc:
107+
pytest.skip(f"symlinks are not available here: {exc}")
108+
109+
110+
def _symlinked_layout(tmp_path):
111+
"""An allowed root with symlinks pointing out of it, plus an outside dir."""
112+
root = (tmp_path / "root").resolve()
113+
outside = (tmp_path / "outside").resolve()
114+
root.mkdir()
115+
outside.mkdir()
116+
(outside / "secret.txt").write_text("secret")
117+
_symlink(root / "secret_link.txt", outside / "secret.txt")
118+
_symlink(root / "outside_dir", outside, directory=True)
119+
_symlink(root / "dangling.bin", outside / "created.bin")
120+
return root, outside
121+
122+
123+
@pytest.mark.asyncio
124+
@pytest.mark.parametrize("raw_path", ["secret_link.txt", "outside_dir/secret.txt"])
125+
async def test_readable_path_rejects_symlink_escape(tmp_path, monkeypatch, raw_path):
126+
root, _outside = _symlinked_layout(tmp_path)
127+
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [root])
128+
129+
for path in (raw_path, str(root / raw_path)):
130+
resolved, error = await main._resolve_readable_file_path(
131+
raw_path=path, ctx=None, tool_name="send_file"
132+
)
133+
assert resolved is None
134+
assert error == "Path is outside allowed roots."
135+
136+
137+
@pytest.mark.asyncio
138+
@pytest.mark.parametrize(
139+
"raw_path",
140+
["dangling.bin", "outside_dir/new.bin", "outside_dir/nested/new.bin"],
141+
)
142+
async def test_writable_path_rejects_symlink_escape(tmp_path, monkeypatch, raw_path):
143+
root, outside = _symlinked_layout(tmp_path)
144+
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [root])
145+
146+
resolved, error = await main._resolve_writable_file_path(
147+
raw_path=raw_path,
148+
default_filename="ignored.bin",
149+
ctx=None,
150+
tool_name="download_media",
151+
)
152+
153+
assert resolved is None
154+
assert error == "Path is outside allowed roots."
155+
# Rejection happens before any directory is created on the target side.
156+
assert sorted(p.name for p in outside.iterdir()) == ["secret.txt"]
157+
158+
159+
@pytest.mark.asyncio
160+
async def test_symlinks_that_stay_inside_root_resolve_to_their_target(tmp_path, monkeypatch):
161+
root = (tmp_path / "root").resolve()
162+
(root / "real").mkdir(parents=True)
163+
target = root / "real" / "file.txt"
164+
target.write_text("ok")
165+
_symlink(root / "linked", root / "real", directory=True)
166+
monkeypatch.setattr(main, "SERVER_ALLOWED_ROOTS", [root])
167+
168+
readable, error = await main._resolve_readable_file_path(
169+
raw_path="linked/file.txt", ctx=None, tool_name="send_file"
170+
)
171+
assert error is None
172+
assert readable == target
173+
174+
writable, error = await main._resolve_writable_file_path(
175+
raw_path="linked/new.bin",
176+
default_filename="ignored.bin",
177+
ctx=None,
178+
tool_name="download_media",
179+
)
180+
assert error is None
181+
assert writable == root / "real" / "new.bin"
182+
183+
103184
@pytest.mark.asyncio
104185
async def test_client_roots_replace_server_allowlist(tmp_path, monkeypatch):
105186
server_root = (tmp_path / "server_root").resolve()

‎tests/test_runtime.py‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1205,6 +1205,99 @@ async def test_list_roots_timeout_denies_without_opt_in(tmp_path, monkeypatch):
12051205
)
12061206
assert error is not None
12071207
assert "roots/list" in error
1208+
assert "TELEGRAM_SERVER_ROOTS_ONLY" in error
1209+
1210+
1211+
class _RootsSessionThatMustNotBeCalled:
1212+
"""Client that would hang on roots/list; the server must not ask it."""
1213+
1214+
def __init__(self):
1215+
self.calls = 0
1216+
1217+
async def list_roots(self):
1218+
self.calls += 1
1219+
await asyncio.sleep(3600)
1220+
1221+
1222+
def test_server_roots_only_enabled_parsing(monkeypatch):
1223+
monkeypatch.delenv("TELEGRAM_SERVER_ROOTS_ONLY", raising=False)
1224+
assert runtime._server_roots_only_enabled() is False
1225+
assert runtime._server_roots_only_enabled("1") is True
1226+
assert runtime._server_roots_only_enabled("true") is True
1227+
assert runtime._server_roots_only_enabled("off") is False
1228+
monkeypatch.setenv("TELEGRAM_SERVER_ROOTS_ONLY", "yes")
1229+
assert runtime._server_roots_only_enabled() is True
1230+
1231+
1232+
@pytest.mark.asyncio
1233+
async def test_server_roots_only_skips_client_roots_request(tmp_path, monkeypatch):
1234+
root = tmp_path / "root"
1235+
root.mkdir()
1236+
monkeypatch.setattr(runtime, "SERVER_ALLOWED_ROOTS", [root.resolve()])
1237+
monkeypatch.setenv("TELEGRAM_SERVER_ROOTS_ONLY", "1")
1238+
monkeypatch.delenv("TELEGRAM_ALLOW_SERVER_ROOTS_FALLBACK", raising=False)
1239+
monkeypatch.delenv("TELEGRAM_ROOTS_TIMEOUT_SECONDS", raising=False)
1240+
session = _RootsSessionThatMustNotBeCalled()
1241+
ctx = SimpleNamespace(session=session)
1242+
1243+
roots, status = await asyncio.wait_for(
1244+
runtime._get_effective_allowed_roots_with_status(ctx), timeout=1
1245+
)
1246+
assert status == runtime.ROOTS_STATUS_SERVER_ONLY
1247+
assert roots == [root.resolve()]
1248+
1249+
resolved, error = await asyncio.wait_for(
1250+
runtime._ensure_allowed_roots(ctx, "download_media"), timeout=1
1251+
)
1252+
assert error is None
1253+
assert resolved == [root.resolve()]
1254+
assert session.calls == 0
1255+
1256+
1257+
@pytest.mark.asyncio
1258+
async def test_server_roots_only_ignores_client_roots_when_server_roots_set(tmp_path, monkeypatch):
1259+
server_root = tmp_path / "server"
1260+
client_root = tmp_path / "client"
1261+
server_root.mkdir()
1262+
client_root.mkdir()
1263+
monkeypatch.setattr(runtime, "SERVER_ALLOWED_ROOTS", [server_root.resolve()])
1264+
monkeypatch.setenv("TELEGRAM_SERVER_ROOTS_ONLY", "1")
1265+
1266+
roots, status = await runtime._get_effective_allowed_roots_with_status(
1267+
_ctx_with_roots([SimpleNamespace(uri=client_root.as_uri())])
1268+
)
1269+
assert status == runtime.ROOTS_STATUS_SERVER_ONLY
1270+
assert roots == [server_root.resolve()]
1271+
1272+
1273+
@pytest.mark.asyncio
1274+
async def test_server_roots_only_keeps_client_roots_without_server_roots(tmp_path, monkeypatch):
1275+
client_root = tmp_path / "client"
1276+
client_root.mkdir()
1277+
monkeypatch.setattr(runtime, "SERVER_ALLOWED_ROOTS", [])
1278+
monkeypatch.setenv("TELEGRAM_SERVER_ROOTS_ONLY", "1")
1279+
1280+
roots, status = await runtime._get_effective_allowed_roots_with_status(
1281+
_ctx_with_roots([SimpleNamespace(uri=client_root.as_uri())])
1282+
)
1283+
assert status == runtime.ROOTS_STATUS_READY
1284+
assert roots == [client_root.resolve()]
1285+
1286+
1287+
@pytest.mark.asyncio
1288+
async def test_client_roots_still_replace_server_roots_by_default(tmp_path, monkeypatch):
1289+
server_root = tmp_path / "server"
1290+
client_root = tmp_path / "client"
1291+
server_root.mkdir()
1292+
client_root.mkdir()
1293+
monkeypatch.setattr(runtime, "SERVER_ALLOWED_ROOTS", [server_root.resolve()])
1294+
monkeypatch.delenv("TELEGRAM_SERVER_ROOTS_ONLY", raising=False)
1295+
1296+
roots, status = await runtime._get_effective_allowed_roots_with_status(
1297+
_ctx_with_roots([SimpleNamespace(uri=client_root.as_uri())])
1298+
)
1299+
assert status == runtime.ROOTS_STATUS_READY
1300+
assert roots == [client_root.resolve()]
12081301

12091302

12101303
def test_get_file_extension_overrides_rejects_duplicate_tool_name():

0 commit comments

Comments
 (0)