diff options
| author | Christophe Besson <cbesson@gmail.com> | 2026-08-13 11:18:24 +0200 |
|---|---|---|
| committer | Christophe Besson <cbesson@gmail.com> | 2026-08-13 11:18:24 +0200 |
| commit | b86be704df752f2fd3086fcca43b7f4de78389d1 (patch) | |
| tree | 6d318beccadd2bc5f0d516fde1e71a86e043bf51 /packages/meshbay-node/tests | |
| parent | 9df71bd1e5244743fae8c1b2bda41143f0748d9d (diff) | |
| download | meshbay-b86be704df752f2fd3086fcca43b7f4de78389d1.tar.gz | |
fix: resource limits, signaling authz, node admin UI token
Phase 11.5 — findings H6, C4 (partial), and milestone 11.5.3.
H6 — resource exhaustion. Several paths let one peer degrade or stall a node:
* the DataChannel frame limit was a flat 64 MB applied BEFORE authentication,
so an unauthenticated peer could announce a huge frame and dribble bytes
into it. Unauthenticated peers now get 64 KB; the large budget is granted
only after the GEK proof, where it is needed for uploads.
* _do_stream_segment ran subprocess.run(..., timeout=30) directly in the event
loop, stalling the entire daemon — every peer, every group — for up to
thirty seconds per request. Now async, with a timeout and process kill.
* ffmpeg was spawned per stream request with no cap. Both streaming paths now
share a transport-wide semaphore.
* POST /v1/nodes/{id}/webrtc/offer was reachable by any authenticated user for
any node, with no membership check and no rate limit, making the target node
allocate an aiortc PeerConnection and gather ICE on demand — remote resource
exhaustion against a third party's machine. Now rate limited, capped per
user, SDP size bounded, and the caller must share an active group with the
node. That also closes the H4 gap where signaling ignored group status.
* POST /v1/nodes/{id}/incoming took peer_ip verbatim, so any user could make an
arbitrary node emit UDP packets to an address of their choosing. The probe
target must now match the caller's own source address.
C4 (partial) — the pre-proof bundle window. GEK and keypair bundle fetches are
served before the GEK proof by necessity: the client needs its wrapped bundle in
order to compute the proof. That window is a disclosure surface a hub can reach
by forging a JWT. Bounded to 4 fetches per session and audited as
"pre_proof_fetch". The real fix is removing remote keypair bundles entirely,
which belongs to the native client (Phase 13.3).
11.5.3 — the node admin UI was unauthenticated because it binds loopback. But
any local process can reach it, and so can a page in the operator's browser via
DNS rebinding — and this API re-initialises group keys and reads the audit log.
H2 showed script execution there equals full control. Now gated by a per-run
token, printed at startup, accepted as ?t= or X-MeshBay-Token.
One test needed rewriting rather than adding: the first version asserted
"subprocess.run(" was absent from the source, which also matched the comment
documenting the old behaviour. It now parses the AST and checks the property.
Tests: 121 node, 142 hub+common. Regression suite 47 node + 10 hub.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests')
| -rw-r--r-- | packages/meshbay-node/tests/test_security_regressions.py | 75 |
1 files changed, 75 insertions, 0 deletions
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py index 9552848..a6d69c6 100644 --- a/packages/meshbay-node/tests/test_security_regressions.py +++ b/packages/meshbay-node/tests/test_security_regressions.py @@ -10,6 +10,7 @@ If one of these starts failing, a fix has been reverted. Do not "fix" the test. """ import base64 +import struct from pathlib import Path import pytest @@ -465,6 +466,80 @@ def test_peer_errors_do_not_leak_internals(): assert '"detail": str(e)' not in source +def test_pre_handshake_message_budget_is_small(): + """ + H6: the frame limit was a flat 64 MB applied before authentication, so an + unauthenticated peer could announce a huge frame and dribble bytes into it. + """ + from meshbay_node.transport.webrtc_server import ( + MAX_MSG, PRE_HANDSHAKE_MAX_MSG, _DataChannelBuffer, + ) + assert PRE_HANDSHAKE_MAX_MSG <= 1024 * 1024 + assert PRE_HANDSHAKE_MAX_MSG < MAX_MSG + + buf = _DataChannelBuffer(max_message=PRE_HANDSHAKE_MAX_MSG) + buf.feed(struct.pack(">I", PRE_HANDSHAKE_MAX_MSG + 1) + b"x") + with pytest.raises(ValueError): + list(buf.messages()) + + +def test_stream_segment_is_not_synchronous(): + """ + H6: _do_stream_segment ran subprocess.run(timeout=30) inside the event loop, + stalling every peer on the node for up to thirty seconds per request. + + Asserts the property (the worker is a coroutine, ffmpeg is spawned through + asyncio) rather than grepping for "subprocess.run" — which also matches the + comment that documents the old behaviour. + """ + import ast + import inspect + from meshbay_node.transport.webrtc_server import WebRTCPeerSession + + assert inspect.iscoroutinefunction(WebRTCPeerSession._do_stream_segment_async) + + source = (Path(__file__).parent.parent / "src" / "meshbay_node" + / "transport" / "webrtc_server.py").read_text() + tree = ast.parse(source) + blocking = [ + node for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and node.func.attr == "run" + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "subprocess" + ] + assert not blocking, "blocking subprocess.run() in the event loop" + assert "_transcode_sem" in source, "ffmpeg spawns must be capped" + + +def test_pre_proof_fetches_are_bounded(): + """C4: the pre-proof bundle window is a disclosure surface; bound it.""" + from meshbay_node.transport.webrtc_server import MAX_PRE_PROOF_FETCHES + assert 0 < MAX_PRE_PROOF_FETCHES <= 10 + + +def test_node_admin_ui_requires_token(): + """ + 11.5.3: "localhost only" is not authentication. Any local process — or a + rebound browser page — could re-initialise a group's GEK and read the audit log. + """ + from fastapi.testclient import TestClient + from meshbay_node.ui.app import create_ui_app + + app = create_ui_app({"status": "running", "groups_ctx": {}, + "indexes": {}, "ui_token": "secret-token"}) + client = TestClient(app) + + assert client.get("/api/status").status_code == 403 + assert client.get("/api/status?t=wrong").status_code == 403 + assert client.get("/api/config?t=wrong").status_code == 403 + assert client.get("/api/status?t=secret-token").status_code == 200 + assert client.get( + "/api/status", headers={"X-MeshBay-Token": "secret-token"} + ).status_code == 200 + + def test_admin_ui_escapes_filenames(tmp_path): """ H2: filenames are chosen by any group member and were rendered into the |