diff options
Diffstat (limited to 'packages/meshbay-node/src')
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/linkpreview.py | 51 | ||||
| -rw-r--r-- | packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py | 40 |
2 files changed, 85 insertions, 6 deletions
diff --git a/packages/meshbay-node/src/meshbay_node/linkpreview.py b/packages/meshbay-node/src/meshbay_node/linkpreview.py index 64067d1..b223aea 100644 --- a/packages/meshbay-node/src/meshbay_node/linkpreview.py +++ b/packages/meshbay-node/src/meshbay_node/linkpreview.py @@ -14,12 +14,15 @@ hub: Because the node makes an outbound request to an address a *member* chose, this is an SSRF surface. `safe_url()` is the gate: http(s) only, no -credentials, and the resolved address must be globally routable — no -loopback, private, link-local, multicast or reserved range. Redirects are -followed by hand so every hop is re-checked. Residual: a DNS name that -resolves clean here and to something internal microseconds later at connect -time (rebinding) — narrow, and closed properly by pinning the checked IP, -which is a follow-up. +credentials, the port restricted to the web set, and every resolved address +must be globally routable — no loopback, private, link-local, multicast or +reserved range. Redirects are followed by hand so every hop is re-checked, +and the address the connection actually landed on is re-checked against the +same rule (`_reject_if_rebound`), so a name that resolves clean and then to +something internal (rebinding) does not get its body read. A full pin — +connect to the validated literal, verify the certificate for the name — is +the remaining hardening. How many previews a member can trigger is +rate-limited by the caller (`_do_link_preview_request`). Nothing is stored durably: the caller keeps an in-memory TTL cache and the OG image rides the existing `media_cache` thumb store (same as a poster). @@ -42,9 +45,15 @@ _TIMEOUT = 5.0 _MAX_REDIRECTS = 3 _MAX_HTML_BYTES = 512 * 1024 _MAX_IMAGE_BYTES = 2 * 1024 * 1024 +_MAX_IMAGE_PIXELS = 40_000_000 # ~40 MP; an OG card image is a fraction of this _IMAGE_MAX_DIM = 600 _UA = "MeshBayBot/1.0 (+https://meshbay.org; link preview)" +# Ports a real OpenGraph-bearing page is served on. Everything else — SSH, mail, +# databases, caches, search, admin panels — is refused, so a member cannot aim +# the node at an arbitrary service even on a public host. +_ALLOWED_PORTS = frozenset({80, 443, 8080, 8443}) + class UnsafeURL(ValueError): """The URL points somewhere the node must not fetch from.""" @@ -75,6 +84,12 @@ def safe_url(url: str) -> str: host = parts.hostname if not host: raise UnsafeURL("no host") + try: + port = parts.port + except ValueError: + raise UnsafeURL("bad port") + if port is not None and port not in _ALLOWED_PORTS: + raise UnsafeURL(f"port {port}") # An IP literal is checked directly; a name is resolved and every answer # must be public — a hostname with one public and one 127.0.0.1 record # would otherwise be a way in. @@ -136,12 +151,32 @@ def _first(metas: dict[str, str], *keys: str) -> str | None: return None +def _reject_if_rebound(resp: httpx.Response) -> None: + """ + `safe_url` validated the name's addresses; this checks the one the + connection actually landed on, so a name that resolves clean and then to + something internal (DNS rebinding) does not get its body read. + + Best-effort: the `network_stream` extension is not present on every + transport (a MockTransport in tests has none), and its absence is not a + failure — the pre-check and the per-hop redirect re-check still stand. + """ + try: + stream = resp.extensions.get("network_stream") + addr = stream.get_extra_info("server_addr") if stream else None + except Exception: + return + if addr and not _addr_is_public(str(addr[0])): + raise UnsafeURL(f"connected to non-public address {addr[0]}") + + async def _get(client: httpx.AsyncClient, url: str) -> httpx.Response: """One GET with manual, re-validated redirects.""" current = safe_url(url) for _ in range(_MAX_REDIRECTS + 1): resp = await client.get(current, headers={"User-Agent": _UA}, follow_redirects=False) + _reject_if_rebound(resp) if resp.is_redirect and "location" in resp.headers: current = safe_url(urljoin(current, resp.headers["location"])) continue @@ -242,6 +277,10 @@ def _downscale(raw: bytes) -> bytes | None: return None try: with Image.open(BytesIO(raw)) as im: + # The header is parsed but the pixels are not decoded yet — refuse a + # decompression bomb before convert()/thumbnail() allocate for it. + if im.width * im.height > _MAX_IMAGE_PIXELS: + return None im = im.convert("RGB") im.thumbnail((_IMAGE_MAX_DIM, _IMAGE_MAX_DIM)) out = BytesIO() diff --git a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py index 003cd23..64ba75c 100644 --- a/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py +++ b/packages/meshbay-node/src/meshbay_node/transport/webrtc_server.py @@ -143,6 +143,16 @@ def _link_preview_cache_put(url: str, value: dict) -> None: _link_preview_cache.pop(oldest, None) _link_preview_cache[url] = (time.time(), value) + +# A member pasting a link is normal; a member — or a hub minting tokens for many +# accounts — firing hundreds is amplification/DoS and a way to make the node +# reach arbitrary hosts on demand (finding M3). Only a real outbound fetch is +# counted (a cache hit costs nothing), and the ceilings are generous enough that +# ordinary chat never meets them. +_LINK_PREVIEW_RATE_WINDOW = 60.0 +_LINK_PREVIEW_RATE_PER_CONN = 15 +_LINK_PREVIEW_RATE_NODE = 60 + # Upload limits (finding C5a). Uploads used to land directly in the shared root under # a name the client chose, overwriting whatever was already there — which both violated # node sovereignty and defeated the delete authorization (overwrite a file, become its @@ -3588,6 +3598,27 @@ class WebRTCPeerSession: ], }) + def _link_preview_rate_ok(self) -> bool: + """ + True when this preview fetch is within both the per-connection and the + node-wide window; records it when so, and both counts are trimmed to the + window on every call so the lists cannot grow without bound. + """ + now = time.monotonic() + w = _LINK_PREVIEW_RATE_WINDOW + mine = [t for t in getattr(self, "_link_preview_hits", []) if now - t < w] + node = [t for t in self._ctx.get("link_preview_hits", []) if now - t < w] + if (len(mine) >= _LINK_PREVIEW_RATE_PER_CONN + or len(node) >= _LINK_PREVIEW_RATE_NODE): + self._link_preview_hits = mine + self._ctx["link_preview_hits"] = node + return False + mine.append(now) + node.append(now) + self._link_preview_hits = mine + self._ctx["link_preview_hits"] = node + return True + async def _do_link_preview_request(self, msg: dict) -> None: """ Unfurl a URL a member pasted into chat (draft-v6 §2.7 enrichment rule: @@ -3608,6 +3639,15 @@ class WebRTCPeerSession: self._send({**cached, "type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION}) return + if not self._link_preview_rate_ok(): + # Same shape as any other miss — the client shows the bare link. A + # rate-limited result is not cached, so it is retried once the + # window clears rather than pinned as "no preview". + log.debug("link_preview_req: rate-limited (peer=%s)", self._peer_id) + self._send({"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION, + "url": key, "ok": False}) + return + resp: dict = {"type": MNP.LINK_PREVIEW_RESP, "v": MNP_VERSION, "url": key, "ok": False} try: |