synapse-orch-ai 1.9.1 → 1.9.3
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.
- package/backend/core/browser_automation.py +91 -0
- package/backend/core/egress_guard.py +223 -0
- package/backend/core/egress_proxy.py +229 -0
- package/backend/core/expressions.py +522 -0
- package/backend/core/file_roots.py +80 -0
- package/backend/core/internal_auth.py +69 -14
- package/backend/core/models.py +20 -2
- package/backend/core/orchestration/steps.py +38 -75
- package/backend/core/react_engine.py +8 -0
- package/backend/core/routes/settings.py +5 -1
- package/backend/core/routes/tools.py +14 -4
- package/backend/core/safe_fetch.py +106 -0
- package/backend/core/server.py +67 -34
- package/backend/tools/code_search.py +23 -21
- package/backend/tools/file_reader.py +12 -2
- package/backend/tools/pdf_parser.py +5 -5
- package/backend/tools/sandbox.py +6 -2
- package/backend/tools/web_scraper.py +172 -28
- package/backend/tools/xlsx_parser.py +5 -5
- package/frontend-build/.next/BUILD_ID +1 -1
- package/frontend-build/.next/build-manifest.json +3 -3
- package/frontend-build/.next/prerender-manifest.json +3 -3
- package/frontend-build/.next/server/app/_global-error.html +1 -1
- package/frontend-build/.next/server/app/_global-error.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/_not-found.html +1 -1
- package/frontend-build/.next/server/app/_not-found.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/api/agent-types/route.js +3 -2
- package/frontend-build/.next/server/app/api/agent-types/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js +3 -2
- package/frontend-build/.next/server/app/api/agents/generate-prompt/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/auth/login/route.js +3 -2
- package/frontend-build/.next/server/app/api/auth/login/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/auth/logout/route.js +2 -1
- package/frontend-build/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/auth/status/route.js +3 -2
- package/frontend-build/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/builder/chat/route.js +3 -2
- package/frontend-build/.next/server/app/api/builder/chat/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/builder/resume/route.js +3 -2
- package/frontend-build/.next/server/app/api/builder/resume/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/chat/route.js +3 -2
- package/frontend-build/.next/server/app/api/chat/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/chat/stream/route.js +3 -2
- package/frontend-build/.next/server/app/api/chat/stream/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js +3 -2
- package/frontend-build/.next/server/app/api/logs/[type]/[run_id]/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/logs/[type]/route.js +3 -2
- package/frontend-build/.next/server/app/api/logs/[type]/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/models/route.js +3 -2
- package/frontend-build/.next/server/app/api/models/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/notifications/stream/route.js +3 -2
- package/frontend-build/.next/server/app/api/notifications/stream/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/[orch_id]/run/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/events/stream/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/human-input/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js +3 -2
- package/frontend-build/.next/server/app/api/orchestrations/runs/[run_id]/resume/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js +3 -2
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js +3 -2
- package/frontend-build/.next/server/app/api/schedules/[schedule_id]/run/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/api/schedules/route.js +3 -2
- package/frontend-build/.next/server/app/api/schedules/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/icon.png/route.js +2 -1
- package/frontend-build/.next/server/app/icon.png/route.js.nft.json +1 -1
- package/frontend-build/.next/server/app/index.html +1 -1
- package/frontend-build/.next/server/app/index.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/login.html +1 -1
- package/frontend-build/.next/server/app/login.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
- package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
- package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
- package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0.-gycy._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0~bybkp._.js → [root-of-the-server]__003vgom._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t4a96i._.js → [root-of-the-server]__03ek9xq._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__07vf6z1._.js → [root-of-the-server]__03x0ii~._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0wqyl-j._.js → [root-of-the-server]__03zuar1._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0zl78ks._.js → [root-of-the-server]__04ks5f_._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0t.cymd._.js → [root-of-the-server]__0_fafcz._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0_~_1-l._.js +10 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0fa0k5_._.js → [root-of-the-server]__0dbuutb._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0hnd6qu._.js → [root-of-the-server]__0hlm8c7._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0is-hlj._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0n-ayat._.js → [root-of-the-server]__0i~uoki._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0js9sik._.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0ncun1_._.js +1 -1
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0i0g0o9._.js → [root-of-the-server]__0oe4zjm._.js} +2 -2
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0av_50r._.js → [root-of-the-server]__0v4clie._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0w02yhg._.js +3 -0
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0wbbjuq._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__0-0m2_n._.js → [root-of-the-server]__0wgri4w._.js} +2 -2
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0xtvg9h._.js +3 -0
- package/frontend-build/.next/server/chunks/{[root-of-the-server]__01abhvg._.js → [root-of-the-server]__0yuz2ug._.js} +2 -2
- package/frontend-build/.next/server/chunks/node_modules_next_124cnn1._.js +3 -0
- package/frontend-build/.next/server/chunks/node_modules_next_dist_12gp6q5._.js +6 -0
- package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
- package/frontend-build/.next/server/pages/404.html +1 -1
- package/frontend-build/.next/server/pages/500.html +1 -1
- package/frontend-build/.next/server/server-reference-manifest.js +1 -1
- package/frontend-build/.next/server/server-reference-manifest.json +1 -1
- package/frontend-build/.next/static/chunks/{0a-yeaiijslpf.css → 13upr_rmz7sy8.css} +5 -5
- package/package.json +1 -1
- package/frontend-build/.next/server/chunks/[root-of-the-server]__08ei_wz._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0_qd3xa._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0aw6-s5._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0f5cyte._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0j8-xkl._.js +0 -13
- package/frontend-build/.next/server/chunks/[root-of-the-server]__0uulfit._.js +0 -3
- package/frontend-build/.next/server/chunks/[root-of-the-server]__10fof-m._.js +0 -3
- /package/frontend-build/.next/static/{sxSQKRDxOSif09kARGRDM → OIEoX5QA1J1xrSoahxwNa}/_buildManifest.js +0 -0
- /package/frontend-build/.next/static/{sxSQKRDxOSif09kARGRDM → OIEoX5QA1J1xrSoahxwNa}/_clientMiddlewareManifest.js +0 -0
- /package/frontend-build/.next/static/{sxSQKRDxOSif09kARGRDM → OIEoX5QA1J1xrSoahxwNa}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""What the Browser Automation server may be asked to do.
|
|
2
|
+
|
|
3
|
+
The server is Playwright's MCP server (`@playwright/mcp`), started by `core/server.py`, on by default, and called
|
|
4
|
+
by agents that read web pages, so what a page says can steer it. Its tools are offered through `gated()`:
|
|
5
|
+
|
|
6
|
+
* **Only the tools reviewed for the pinned version.** None that run code in the server's own process
|
|
7
|
+
(`browser_run_code_unsafe`; Playwright's own description calls it RCE-equivalent), run a model's script in the
|
|
8
|
+
page (`browser_evaluate`), or read local files into a page (`browser_file_upload`, `browser_drop`). A tool a
|
|
9
|
+
later version adds is not offered until someone reviews it and adds it here.
|
|
10
|
+
* **A `url` is http(s)**, or `about:blank`: never `file:`, `chrome:`, `data:` and the like.
|
|
11
|
+
* **A `filename` stays in the server's folder**: relative, no `..`. The server is started with that folder as its
|
|
12
|
+
working directory and output directory, so it is where its own files land.
|
|
13
|
+
|
|
14
|
+
Where the browser may connect is not decided here: its network goes through the egress proxy
|
|
15
|
+
(`core.egress_proxy`). BRW1's browser component replaces this server and its gate (`core/browser/gate.py`).
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import PurePosixPath, PureWindowsPath
|
|
20
|
+
from typing import Any
|
|
21
|
+
from urllib.parse import urlsplit
|
|
22
|
+
|
|
23
|
+
#: The server's name in `core/server.py`'s list, and the package it runs, pinned: `npx -y @playwright/mcp` ran
|
|
24
|
+
#: whatever was newest the first time, and a new version's new tools reached agents unreviewed.
|
|
25
|
+
BROWSER_AUTOMATION = "Browser Automation"
|
|
26
|
+
PLAYWRIGHT_MCP = "@playwright/mcp@0.0.83"
|
|
27
|
+
|
|
28
|
+
#: The tools of the pinned version that are offered. Reviewed 2026-10-28 against what 0.0.83 lists.
|
|
29
|
+
OFFERED = frozenset({
|
|
30
|
+
"browser_navigate", "browser_navigate_back", "browser_tabs", "browser_snapshot", "browser_find",
|
|
31
|
+
"browser_wait_for", "browser_take_screenshot", "browser_console_messages", "browser_network_requests",
|
|
32
|
+
"browser_network_request", "browser_click", "browser_type", "browser_fill_form", "browser_select_option",
|
|
33
|
+
"browser_press_key", "browser_hover", "browser_drag", "browser_handle_dialog", "browser_resize",
|
|
34
|
+
"browser_emulate_media", "browser_close",
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
_URL_ARGS = ("url",)
|
|
38
|
+
_FILE_ARGS = ("filename",)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def refusal(name: str, arguments: dict | None) -> str | None:
|
|
42
|
+
"""Why this call is not made, or None when it may be."""
|
|
43
|
+
if name not in OFFERED:
|
|
44
|
+
return f"{name} is not available in Synapse's browser."
|
|
45
|
+
args = arguments or {}
|
|
46
|
+
for key in _URL_ARGS:
|
|
47
|
+
url = args.get(key)
|
|
48
|
+
if url is None:
|
|
49
|
+
continue
|
|
50
|
+
scheme = urlsplit(str(url)).scheme.lower()
|
|
51
|
+
if scheme not in ("http", "https") and str(url) != "about:blank":
|
|
52
|
+
return f"{name} opens http and https addresses only."
|
|
53
|
+
for key in _FILE_ARGS:
|
|
54
|
+
filename = args.get(key)
|
|
55
|
+
if filename is None:
|
|
56
|
+
continue
|
|
57
|
+
text = str(filename)
|
|
58
|
+
if (text.startswith(("/", "\\", "~")) or PureWindowsPath(text).drive
|
|
59
|
+
or ".." in PurePosixPath(text.replace("\\", "/")).parts):
|
|
60
|
+
return f"{name} saves only inside its own folder: give a file name, not a path."
|
|
61
|
+
return None
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _Gated:
|
|
65
|
+
"""The server's session, offering only `OFFERED` and refusing what `refusal` refuses. Anything else (a ping,
|
|
66
|
+
initialize) is the session's own."""
|
|
67
|
+
|
|
68
|
+
def __init__(self, session: Any) -> None:
|
|
69
|
+
self._session = session
|
|
70
|
+
|
|
71
|
+
def __getattr__(self, name: str) -> Any:
|
|
72
|
+
return getattr(self._session, name)
|
|
73
|
+
|
|
74
|
+
async def list_tools(self, *args: Any, **kwargs: Any) -> Any:
|
|
75
|
+
result = await self._session.list_tools(*args, **kwargs)
|
|
76
|
+
return result.model_copy(update={"tools": [t for t in result.tools if t.name in OFFERED]})
|
|
77
|
+
|
|
78
|
+
async def call_tool(self, name: str, arguments: dict | None = None, *args: Any, **kwargs: Any) -> Any:
|
|
79
|
+
reason = refusal(name, arguments)
|
|
80
|
+
if reason is not None:
|
|
81
|
+
import mcp.types as types
|
|
82
|
+
|
|
83
|
+
return types.CallToolResult(content=[types.TextContent(type="text", text=reason)], isError=True)
|
|
84
|
+
return await self._session.call_tool(name, arguments, *args, **kwargs)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def gated(server_name: str, session: Any) -> Any:
|
|
88
|
+
"""`session`, gated when it is the Browser Automation server's; any other server's as it is."""
|
|
89
|
+
if server_name != BROWSER_AUTOMATION or isinstance(session, _Gated):
|
|
90
|
+
return session
|
|
91
|
+
return _Gated(session)
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Egress policy for tools that fetch user- or model-supplied URLs.
|
|
2
|
+
|
|
3
|
+
A tool's URLs are data, not configuration: they arrive from a prompt, and a
|
|
4
|
+
prompt can be steered by whatever the last scraped page said. A fetcher that
|
|
5
|
+
goes wherever it is pointed will sooner or later be pointed at the network
|
|
6
|
+
the process itself runs on — the cloud metadata endpoints (169.254.169.254,
|
|
7
|
+
169.254.170.2), loopback services on its own host, or anything else the
|
|
8
|
+
subnet can reach. On a cloud runtime the metadata endpoint alone turns one
|
|
9
|
+
injected URL into live credentials.
|
|
10
|
+
|
|
11
|
+
The policy: http(s) only, and only to addresses that are actually on the
|
|
12
|
+
public internet. Every address a hostname resolves to is checked, not just
|
|
13
|
+
the first — a name with one public and one private record must not pass.
|
|
14
|
+
|
|
15
|
+
`EGRESS_ALLOW_HOSTS` (comma-separated hostnames) exempts specific hosts from
|
|
16
|
+
the address checks, for installs that genuinely scrape an intranet or a
|
|
17
|
+
localhost dev server, and for tests dialing loopback stand-ins. Empty —
|
|
18
|
+
deny — is the default.
|
|
19
|
+
|
|
20
|
+
The DNS-rebinding window (resolution here vs. resolution at connect time) is
|
|
21
|
+
deliberately not closed: closing it means owning socket creation and TLS SNI
|
|
22
|
+
handling in every fetch path. Network-level egress control is the layer for
|
|
23
|
+
that; this module is the in-process guard in front of it.
|
|
24
|
+
|
|
25
|
+
Deliberately stdlib-only, and deliberately not a Settings consumer: it is
|
|
26
|
+
imported by tool subprocesses that must construct in any environment.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import asyncio
|
|
32
|
+
import ipaddress
|
|
33
|
+
import os
|
|
34
|
+
import socket
|
|
35
|
+
import time
|
|
36
|
+
from urllib.parse import urlparse
|
|
37
|
+
|
|
38
|
+
#: Carrier-grade NAT. Not covered by `is_private`, and it is where several
|
|
39
|
+
#: managed-Kubernetes overlays put internal services.
|
|
40
|
+
_CGNAT = ipaddress.ip_network("100.64.0.0/10")
|
|
41
|
+
|
|
42
|
+
#: One resolution per host per window is enough for a page's subresources;
|
|
43
|
+
#: short enough that the cache is not itself a rebinding aid.
|
|
44
|
+
_RESOLVE_TTL_SECONDS = 60.0
|
|
45
|
+
_RESOLVE_CACHE_MAX = 512
|
|
46
|
+
|
|
47
|
+
_resolve_cache: dict[str, tuple[float, list[str]]] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class BlockedEgress(ValueError):
|
|
51
|
+
"""A URL that must not be fetched. The message is safe to echo to the model."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class MalformedUrl(BlockedEgress):
|
|
55
|
+
"""Not a URL with a scheme, so it names no destination to check.
|
|
56
|
+
|
|
57
|
+
A subclass for the same reason `UnresolvedHost` is: the scraper should
|
|
58
|
+
refuse what it cannot evaluate. A custom HTTP tool should not, because the
|
|
59
|
+
commonest way to get here is an unrendered `{{global.api_base}}` — a missing
|
|
60
|
+
variable, whose own error message names the variable and is worth more than
|
|
61
|
+
this one. Nothing is conceded: a string with no scheme is one httpx refuses
|
|
62
|
+
to send anywhere. A URL with a scheme that is simply not http stays a plain
|
|
63
|
+
`BlockedEgress`.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class UnresolvedHost(BlockedEgress):
|
|
68
|
+
"""The host could not be resolved, so nothing is known about where it points.
|
|
69
|
+
|
|
70
|
+
A subclass, so every existing caller keeps failing closed: for the scraper,
|
|
71
|
+
which is handed URLs a model invented, "unknown" is the right thing to
|
|
72
|
+
refuse. A *saved* custom HTTP tool is the other case — the host may be an
|
|
73
|
+
internal API, a service not yet deployed, or a DNS blip, and refusing it is
|
|
74
|
+
a guess about somebody else's network. `core/builder_tools.py` already says
|
|
75
|
+
so in as many words about the same URLs at creation time.
|
|
76
|
+
|
|
77
|
+
Letting it through concedes nothing: httpx resolves through the same stack
|
|
78
|
+
a moment later and fails the same lookup. What is not conceded is an
|
|
79
|
+
address — an IP literal always "resolves", so `169.254.169.254` is still
|
|
80
|
+
refused by the check below.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def allowed_hosts() -> frozenset[str]:
|
|
85
|
+
raw = os.environ.get("EGRESS_ALLOW_HOSTS", "")
|
|
86
|
+
return frozenset(h.strip().lower() for h in raw.split(",") if h.strip())
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def reject_address(address: str) -> str | None:
|
|
90
|
+
"""Return a reason to refuse this IP, or None to allow it.
|
|
91
|
+
|
|
92
|
+
Grouped by what each range would reach, because the point is not that the
|
|
93
|
+
addresses are unusual but that they are *ours*.
|
|
94
|
+
"""
|
|
95
|
+
try:
|
|
96
|
+
ip = ipaddress.ip_address(address)
|
|
97
|
+
except ValueError:
|
|
98
|
+
return f"{address} is not an IP address"
|
|
99
|
+
|
|
100
|
+
# An IPv4-mapped IPv6 address (`::ffff:169.254.170.2`) reaches the IPv4
|
|
101
|
+
# host while failing every IPv4 range check, so unwrap before testing.
|
|
102
|
+
if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped is not None:
|
|
103
|
+
ip = ip.ipv4_mapped
|
|
104
|
+
|
|
105
|
+
if ip.is_loopback:
|
|
106
|
+
return "loopback"
|
|
107
|
+
if ip.is_link_local:
|
|
108
|
+
# 169.254.0.0/16 and fe80::/10 — the cloud metadata range.
|
|
109
|
+
return "link-local, including the cloud metadata endpoint"
|
|
110
|
+
# Before `is_private`, which also covers 0.0.0.0/8 and would report the
|
|
111
|
+
# unspecified address as "private" — true, and unhelpful to whoever typed it.
|
|
112
|
+
if ip.is_unspecified:
|
|
113
|
+
return "the unspecified address"
|
|
114
|
+
if ip.is_private:
|
|
115
|
+
return "private network"
|
|
116
|
+
if ip.is_multicast or ip.is_reserved:
|
|
117
|
+
return "multicast or reserved"
|
|
118
|
+
if ip.version == 4 and ip in _CGNAT:
|
|
119
|
+
return "shared address space"
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def resolve(host: str) -> list[str]:
|
|
124
|
+
"""All addresses `host` resolves to, via the event loop's resolver, cached briefly."""
|
|
125
|
+
now = time.monotonic()
|
|
126
|
+
hit = _resolve_cache.get(host)
|
|
127
|
+
if hit is not None and now - hit[0] < _RESOLVE_TTL_SECONDS:
|
|
128
|
+
return hit[1]
|
|
129
|
+
|
|
130
|
+
loop = asyncio.get_running_loop()
|
|
131
|
+
try:
|
|
132
|
+
infos = await loop.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
|
|
133
|
+
except socket.gaierror as exc:
|
|
134
|
+
raise UnresolvedHost(f"could not resolve {host}") from exc
|
|
135
|
+
# sockaddr[0] is typed `str | int` because the tuple shape differs by
|
|
136
|
+
# address family; for AF_INET and AF_INET6 it is always the address.
|
|
137
|
+
addresses = sorted({str(info[4][0]) for info in infos})
|
|
138
|
+
|
|
139
|
+
if len(_resolve_cache) >= _RESOLVE_CACHE_MAX:
|
|
140
|
+
_resolve_cache.clear()
|
|
141
|
+
_resolve_cache[host] = (now, addresses)
|
|
142
|
+
return addresses
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def check_host(host: str) -> None:
|
|
146
|
+
"""Raise `BlockedEgress` unless every address behind `host` is public."""
|
|
147
|
+
host = host.strip().lower().strip("[]")
|
|
148
|
+
if not host:
|
|
149
|
+
raise BlockedEgress("that URL has no host")
|
|
150
|
+
if host in allowed_hosts():
|
|
151
|
+
return
|
|
152
|
+
_check_addresses(host, await _addresses(host))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def checked_addresses(host: str) -> list[str]:
|
|
156
|
+
"""The addresses behind `host`, every one of them public, or raise `BlockedEgress`.
|
|
157
|
+
|
|
158
|
+
For a caller that opens the connection itself (`core.egress_proxy`): it dials
|
|
159
|
+
one of these and never the name again, so the address it checked is the
|
|
160
|
+
address it reaches. A host in `EGRESS_ALLOW_HOSTS` is resolved, not checked.
|
|
161
|
+
"""
|
|
162
|
+
host = host.strip().lower().strip("[]")
|
|
163
|
+
if not host:
|
|
164
|
+
raise BlockedEgress("that URL has no host")
|
|
165
|
+
addresses = await _addresses(host)
|
|
166
|
+
if host not in allowed_hosts():
|
|
167
|
+
_check_addresses(host, addresses)
|
|
168
|
+
return addresses
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
async def _addresses(host: str) -> list[str]:
|
|
172
|
+
try:
|
|
173
|
+
ipaddress.ip_address(host)
|
|
174
|
+
return [host]
|
|
175
|
+
except ValueError:
|
|
176
|
+
return await resolve(host)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _check_addresses(host: str, addresses: list[str]) -> None:
|
|
180
|
+
for address in addresses:
|
|
181
|
+
reason = reject_address(address)
|
|
182
|
+
if reason is not None:
|
|
183
|
+
# `host` is echoed because the caller supplied it. The addresses
|
|
184
|
+
# it resolved to are not: that is internal topology, and reading
|
|
185
|
+
# it back is exactly what an attacker is probing for.
|
|
186
|
+
raise BlockedEgress(f"{host} resolves to a blocked range: {reason}")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
async def check_tool_url(url: str) -> None:
|
|
190
|
+
"""`check_url`, with the posture a *saved* custom tool needs.
|
|
191
|
+
|
|
192
|
+
The scraper is handed URLs a model invented, so refusing whatever it cannot
|
|
193
|
+
evaluate is right for it. A custom HTTP tool is different: its URL was
|
|
194
|
+
written by a person, saved, and may name an internal API, a service not yet
|
|
195
|
+
deployed, or a host this machine simply cannot resolve. Refusing those is a
|
|
196
|
+
guess about somebody else's network, and it concedes nothing to allow them —
|
|
197
|
+
httpx resolves through the same stack a moment later and fails the same
|
|
198
|
+
lookup, and a string with no scheme is one httpx will not send anywhere.
|
|
199
|
+
|
|
200
|
+
What is *not* conceded is an address: an IP literal always resolves, so the
|
|
201
|
+
cloud metadata endpoint and loopback are refused here exactly as they are
|
|
202
|
+
for the scraper.
|
|
203
|
+
|
|
204
|
+
One function because there are two call sites — the ReAct loop and the
|
|
205
|
+
orchestration TOOL step — and a control written twice is a control that
|
|
206
|
+
drifts. That is the lesson the stdio-MCP gate had to be moved for.
|
|
207
|
+
"""
|
|
208
|
+
try:
|
|
209
|
+
await check_url(url)
|
|
210
|
+
except (UnresolvedHost, MalformedUrl):
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
async def check_url(url: str) -> None:
|
|
215
|
+
"""Check one URL, or raise `BlockedEgress`."""
|
|
216
|
+
parsed = urlparse(url)
|
|
217
|
+
if not parsed.scheme:
|
|
218
|
+
raise MalformedUrl("that is not a URL; it has no http:// or https:// scheme")
|
|
219
|
+
if parsed.scheme not in {"http", "https"}:
|
|
220
|
+
raise BlockedEgress(
|
|
221
|
+
f"{parsed.scheme} URLs are not supported; use http or https"
|
|
222
|
+
)
|
|
223
|
+
await check_host(parsed.hostname or "")
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""One egress proxy for every browser Synapse starts.
|
|
2
|
+
|
|
3
|
+
`core/egress_guard.py` decides where a fetch may go. A browser makes its own
|
|
4
|
+
requests — redirects, a page's scripts, in-page fetches, WebSockets, service
|
|
5
|
+
workers — and a hook inside the browser does not see every one of them. A
|
|
6
|
+
proxy does: the browser is started with this one as its proxy, Playwright puts
|
|
7
|
+
loopback through it too (`<-loopback>` in Chromium's bypass list), and it opens
|
|
8
|
+
no connection the guard refuses.
|
|
9
|
+
|
|
10
|
+
It also closes the window the guard leaves open on purpose. The guard resolves
|
|
11
|
+
a name, and the fetch it guards resolves it again; here the address that was
|
|
12
|
+
checked (`egress_guard.checked_addresses`) is the address dialled, and the name
|
|
13
|
+
is never looked up a second time.
|
|
14
|
+
|
|
15
|
+
What it speaks is the least a browser needs: `CONNECT host:port` for TLS and
|
|
16
|
+
WebSockets, and an absolute-form `http://` request for plain HTTP, sent on as
|
|
17
|
+
one request per connection (`Connection: close`) so a second request on the
|
|
18
|
+
same connection cannot name another host. A refusal is a `403` whose body is
|
|
19
|
+
the guard's message (it names the host asked for, never an address it
|
|
20
|
+
resolved to); anything that is not a proxy request is a `400`.
|
|
21
|
+
|
|
22
|
+
It listens on loopback and needs no credentials: what it reaches is the public
|
|
23
|
+
internet, which anything on the machine reaches already.
|
|
24
|
+
|
|
25
|
+
Deliberately stdlib-only, like the guard: the scraper's subprocess starts its own.
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import sys
|
|
31
|
+
from urllib.parse import urlsplit
|
|
32
|
+
|
|
33
|
+
from core import egress_guard
|
|
34
|
+
|
|
35
|
+
#: What a Chromium behind this proxy is started with. UDP never goes through a proxy: WebRTC takes the proxied path
|
|
36
|
+
#: or none, and QUIC, which WebTransport rides with no route in its way, is off.
|
|
37
|
+
CHROMIUM_ARGS = ("--force-webrtc-ip-handling-policy=disable_non_proxied_udp", "--disable-quic")
|
|
38
|
+
|
|
39
|
+
#: A request head larger than this is not one a browser sends.
|
|
40
|
+
_HEAD_LIMIT = 64 * 1024
|
|
41
|
+
_HEAD_TIMEOUT_SECONDS = 30.0
|
|
42
|
+
CONNECT_TIMEOUT_SECONDS = 10.0
|
|
43
|
+
#: After the browser closes its side, how long the other side has to finish.
|
|
44
|
+
_DRAIN_SECONDS = 30.0
|
|
45
|
+
|
|
46
|
+
_REASONS = {400: "Bad Request", 403: "Forbidden", 502: "Bad Gateway"}
|
|
47
|
+
|
|
48
|
+
#: Headers meant for the proxy itself, never sent on.
|
|
49
|
+
_FOR_THE_PROXY = {"proxy-connection", "proxy-authorization", "proxy-authenticate"}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class _BadRequest(ValueError):
|
|
53
|
+
pass
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class _Request:
|
|
57
|
+
def __init__(self, head: bytes):
|
|
58
|
+
lines = head[:-4].decode("latin-1").split("\r\n")
|
|
59
|
+
parts = lines[0].split(" ")
|
|
60
|
+
if len(parts) != 3 or not parts[2].startswith("HTTP/"):
|
|
61
|
+
raise _BadRequest("not an HTTP request")
|
|
62
|
+
self.method, target, version = parts
|
|
63
|
+
self.tunnel = self.method.upper() == "CONNECT"
|
|
64
|
+
try:
|
|
65
|
+
if self.tunnel:
|
|
66
|
+
url = urlsplit(f"//{target}")
|
|
67
|
+
self.host, self.port = url.hostname, url.port
|
|
68
|
+
if not self.host or not self.port:
|
|
69
|
+
raise _BadRequest("CONNECT needs host:port")
|
|
70
|
+
self.forward = b""
|
|
71
|
+
return
|
|
72
|
+
url = urlsplit(target)
|
|
73
|
+
self.host, self.port = url.hostname, url.port or 80
|
|
74
|
+
except ValueError as exc:
|
|
75
|
+
raise _BadRequest(f"not an address: {exc}") from None
|
|
76
|
+
if url.scheme != "http" or not self.host:
|
|
77
|
+
raise _BadRequest("a proxy request is CONNECT host:port or an absolute http:// URL")
|
|
78
|
+
|
|
79
|
+
headers = []
|
|
80
|
+
for line in lines[1:]:
|
|
81
|
+
name, _, value = line.partition(":")
|
|
82
|
+
if name.strip().lower() not in _FOR_THE_PROXY:
|
|
83
|
+
headers.append((name.strip(), value.strip()))
|
|
84
|
+
connection = {v.strip().lower() for n, value in headers if n.lower() == "connection"
|
|
85
|
+
for v in value.split(",")}
|
|
86
|
+
upgrade = "upgrade" in connection and any(n.lower() == "upgrade" for n, _ in headers)
|
|
87
|
+
if not upgrade:
|
|
88
|
+
headers = [(n, v) for n, v in headers if n.lower() not in ("connection", "keep-alive")]
|
|
89
|
+
headers.append(("Connection", "close"))
|
|
90
|
+
path = (url.path or "/") + (f"?{url.query}" if url.query else "")
|
|
91
|
+
self.forward = "".join([f"{self.method} {path} {version}\r\n",
|
|
92
|
+
*(f"{n}: {v}\r\n" for n, v in headers), "\r\n"]).encode("latin-1")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class EgressProxy:
|
|
96
|
+
"""A loopback proxy that dials only what `core.egress_guard` allows. `start()` once, `close()` when done."""
|
|
97
|
+
|
|
98
|
+
host = "127.0.0.1"
|
|
99
|
+
|
|
100
|
+
def __init__(self) -> None:
|
|
101
|
+
self.port = 0
|
|
102
|
+
self.loop: asyncio.AbstractEventLoop | None = None
|
|
103
|
+
self._server: asyncio.base_events.Server | None = None
|
|
104
|
+
self._starting: asyncio.Future | None = None
|
|
105
|
+
self._writers: set[asyncio.StreamWriter] = set()
|
|
106
|
+
self._closed = False
|
|
107
|
+
|
|
108
|
+
@property
|
|
109
|
+
def url(self) -> str:
|
|
110
|
+
return f"http://{self.host}:{self.port}"
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def closed(self) -> bool:
|
|
114
|
+
return self._closed
|
|
115
|
+
|
|
116
|
+
async def start(self) -> str:
|
|
117
|
+
"""Listen, once; every caller waits for the same start."""
|
|
118
|
+
if self._starting is None:
|
|
119
|
+
self.loop = asyncio.get_running_loop()
|
|
120
|
+
self._starting = asyncio.ensure_future(self._listen())
|
|
121
|
+
await asyncio.shield(self._starting)
|
|
122
|
+
return self.url
|
|
123
|
+
|
|
124
|
+
async def _listen(self) -> None:
|
|
125
|
+
self._server = await asyncio.start_server(self._serve, self.host, 0, limit=_HEAD_LIMIT)
|
|
126
|
+
self.port = self._server.sockets[0].getsockname()[1]
|
|
127
|
+
|
|
128
|
+
async def close(self) -> None:
|
|
129
|
+
self._closed = True
|
|
130
|
+
if self._server is not None:
|
|
131
|
+
self._server.close()
|
|
132
|
+
for writer in list(self._writers):
|
|
133
|
+
writer.close()
|
|
134
|
+
if self._server is not None:
|
|
135
|
+
await self._server.wait_closed()
|
|
136
|
+
|
|
137
|
+
async def _serve(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
138
|
+
self._writers.add(writer)
|
|
139
|
+
upstream: asyncio.StreamWriter | None = None
|
|
140
|
+
host = ""
|
|
141
|
+
try:
|
|
142
|
+
try:
|
|
143
|
+
head = await asyncio.wait_for(reader.readuntil(b"\r\n\r\n"), _HEAD_TIMEOUT_SECONDS)
|
|
144
|
+
except (asyncio.IncompleteReadError, asyncio.LimitOverrunError, TimeoutError, ConnectionError):
|
|
145
|
+
return
|
|
146
|
+
request = _Request(head)
|
|
147
|
+
host = request.host
|
|
148
|
+
addresses = await egress_guard.checked_addresses(host)
|
|
149
|
+
up_reader, upstream = await _dial(addresses, request.port)
|
|
150
|
+
self._writers.add(upstream)
|
|
151
|
+
if request.tunnel:
|
|
152
|
+
writer.write(b"HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
153
|
+
else:
|
|
154
|
+
upstream.write(request.forward)
|
|
155
|
+
await _relay(reader, writer, up_reader, upstream)
|
|
156
|
+
except _BadRequest as exc:
|
|
157
|
+
_answer(writer, 400, str(exc))
|
|
158
|
+
except egress_guard.BlockedEgress as exc:
|
|
159
|
+
print(f"egress proxy: refused {host}: {exc}", file=sys.stderr)
|
|
160
|
+
_answer(writer, 403, str(exc))
|
|
161
|
+
except (OSError, TimeoutError):
|
|
162
|
+
_answer(writer, 502, f"could not reach {host}")
|
|
163
|
+
finally:
|
|
164
|
+
for w in (upstream, writer):
|
|
165
|
+
if w is not None:
|
|
166
|
+
self._writers.discard(w)
|
|
167
|
+
w.close()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
async def _dial(addresses: list[str], port: int):
|
|
171
|
+
"""A connection to the first of `addresses` that answers. Never the name: the addresses are what was checked."""
|
|
172
|
+
last: BaseException = OSError("no address")
|
|
173
|
+
for address in addresses:
|
|
174
|
+
try:
|
|
175
|
+
return await asyncio.wait_for(asyncio.open_connection(address, port), CONNECT_TIMEOUT_SECONDS)
|
|
176
|
+
except (OSError, TimeoutError) as exc:
|
|
177
|
+
last = exc
|
|
178
|
+
raise last
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
async def _pipe(src: asyncio.StreamReader, dst: asyncio.StreamWriter) -> None:
|
|
182
|
+
try:
|
|
183
|
+
while data := await src.read(65536):
|
|
184
|
+
dst.write(data)
|
|
185
|
+
await dst.drain()
|
|
186
|
+
except ConnectionError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _relay(client_r, client_w, up_r, up_w) -> None:
|
|
191
|
+
"""Both ways until the far side is done. The browser closing its side gives the far side a moment to finish."""
|
|
192
|
+
outbound = asyncio.ensure_future(_pipe(client_r, up_w))
|
|
193
|
+
inbound = asyncio.ensure_future(_pipe(up_r, client_w))
|
|
194
|
+
try:
|
|
195
|
+
done, _ = await asyncio.wait({outbound, inbound}, return_when=asyncio.FIRST_COMPLETED)
|
|
196
|
+
if inbound not in done:
|
|
197
|
+
if up_w.can_write_eof():
|
|
198
|
+
try:
|
|
199
|
+
up_w.write_eof()
|
|
200
|
+
except OSError:
|
|
201
|
+
pass
|
|
202
|
+
await asyncio.wait({inbound}, timeout=_DRAIN_SECONDS)
|
|
203
|
+
finally:
|
|
204
|
+
for task in (outbound, inbound):
|
|
205
|
+
task.cancel()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _answer(writer: asyncio.StreamWriter, status: int, reason: str) -> None:
|
|
209
|
+
body = f"{reason}\n".encode()
|
|
210
|
+
try:
|
|
211
|
+
writer.write(f"HTTP/1.1 {status} {_REASONS[status]}\r\nContent-Type: text/plain; charset=utf-8\r\n"
|
|
212
|
+
f"Content-Length: {len(body)}\r\nX-Synapse-Egress: refused\r\nConnection: close\r\n\r\n"
|
|
213
|
+
.encode() + body)
|
|
214
|
+
except (OSError, RuntimeError):
|
|
215
|
+
pass
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
_shared: EgressProxy | None = None
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
async def shared() -> EgressProxy:
|
|
222
|
+
"""This event loop's proxy, started on first use. A closed one, or one from another loop, is replaced."""
|
|
223
|
+
global _shared
|
|
224
|
+
loop = asyncio.get_running_loop()
|
|
225
|
+
proxy = _shared
|
|
226
|
+
if proxy is None or proxy.closed or (proxy.loop is not None and proxy.loop is not loop):
|
|
227
|
+
proxy = _shared = EgressProxy()
|
|
228
|
+
await proxy.start()
|
|
229
|
+
return proxy
|