From 0808d594a371e7caea54f45a71db586160ae75ad Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Tue, 1 Sep 2026 19:09:20 +0200 Subject: fix(node): bound and tighten the chat link-preview SSRF surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The link-preview fetch is an outbound request to an address a member chose. safe_url() already blocked non-public addresses and re-checked each redirect hop; this adds the parts that were missing: - Rate limit. `_do_link_preview_request` was reachable by any member with no ceiling, so a member — or a hub minting tokens for many accounts — could drive unbounded outbound HTTP from the operator's machine (amplification / DoS / on-demand IP disclosure to arbitrary hosts). Now bounded per connection (15) and node-wide (60) over a 60 s window; only a real fetch counts, a cache hit is free, and over the ceiling the reply is a plain `ok: false` (bare link), not cached. - Port allowlist. safe_url() passed `parts.port` straight through, so a member could aim the node at `http://:`. Restricted to {80, 443, 8080, 8443} — every real OpenGraph page, none of SSH / mail / DB / cache / search / admin ports. - DNS rebinding. The connection's actual peer address is now re-checked against the public-address rule (`_reject_if_rebound`), so a name that resolves clean and then to something internal does not get its body read. Best-effort (no `network_stream` extension, no check); a full literal-pin is noted as remaining hardening. - Decompression bomb. `_downscale` now refuses an image whose header dimensions exceed ~40 MP before convert()/thumbnail() decode it. Third security review, finding M3. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_011pG75yGK3NthNfyjH74omG --- .../src/meshbay_node/transport/webrtc_server.py | 40 ++++++++++++++++++++++ 1 file changed, 40 insertions(+) (limited to 'packages/meshbay-node/src/meshbay_node/transport') 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: -- cgit v1.2.3