summaryrefslogtreecommitdiffstats
path: root/packages/meshbay-node/tests/test_security_regressions.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-08-16 15:28:53 +0200
committerChristophe Besson <cbesson@gmail.com>2026-08-16 15:28:53 +0200
commit188a76f52d2f30609147b1beee7754f2cbd1e778 (patch)
treec7c38428935bb43b9ed5796c5a704f910169ee08 /packages/meshbay-node/tests/test_security_regressions.py
parentfd770dc6293be67298f582de5800f2ca6fe24a8b (diff)
downloadmeshbay-188a76f52d2f30609147b1beee7754f2cbd1e778.tar.gz
fix(node): stop losing transcode slots, and reap ffmpeg without deadlocking
Reported from a phone: play a video, close the viewer, open another — the second hangs and the third is refused. Three separate causes, found by instrumenting rather than guessing, after two fixes that addressed real but different bugs. A task nobody holds can be collected mid-flight. asyncio keeps only a weak reference, so `ensure_future` with the result discarded may be garbage-collected while running — "Task was destroyed but it is pending!" — and `_stream_video` never reached the exit of its `async with sem`. `_spawn` holds every background task; all nineteen call sites go through it. Losing the peer must stop its work. The connectionstatechange handler popped the session from a dict and nothing else, so a closed tab went on transcoding for the full 120 s credit timeout. Measured in the log: 91 s of ffmpeg after the connection closed. `shutdown_tasks()` now runs on the way out, and the credit wait checks the channel before sleeping and polls in slices instead of once. And `await proc.wait()` after `kill()` still deadlocks. ffmpeg outruns a credit-paced viewer and fills the stdout pipe; stop reading it and the transport cannot finish closing, SIGKILL or not. Measured against the live node with a 169 MB video, closing the viewer after 20 segments and asking for the next one: 15.1 s then "Server busy" before, 0.1 s / 0.0 s / 0.0 s after. Chunk replies wait for room on the channel. Eight megabyte-sized chunks answered as they arrived queued 8 MB with nothing watching — measured at 7.3 MB of bufferedAmount in milliseconds. Fine on a LAN, minutes of head-of-line delay on a busy link. Upload names accept any script. The rule was ASCII-only, so `été.txt` was refused — and so was `rapport (1).pdf`, which is the form `_free_name` produces itself, meaning the node rejected names it had chosen. Widened to Unicode with the C5a and H2 protections intact, plus a refusal of names that lie about themselves: trailing space or dot, and the right-to-left override. Errors now name the file, so one bad name no longer fails every upload in flight. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-node/tests/test_security_regressions.py')
-rw-r--r--packages/meshbay-node/tests/test_security_regressions.py33
1 files changed, 32 insertions, 1 deletions
diff --git a/packages/meshbay-node/tests/test_security_regressions.py b/packages/meshbay-node/tests/test_security_regressions.py
index 77d544b..e13dec0 100644
--- a/packages/meshbay-node/tests/test_security_regressions.py
+++ b/packages/meshbay-node/tests/test_security_regressions.py
@@ -92,12 +92,43 @@ def test_upload_rejects_unsafe_filenames(name):
"My Holiday Video.mkv",
"report-2026.pdf",
"track_01.flac",
+ # Reported 2026-08-16: an upload refused as "Invalid filename". The rule was
+ # ASCII-only, so most of the world could not send a file, and — worse — it
+ # rejected the "name (1).ext" form that _free_name produces itself, so the
+ # node refused names it had chosen.
+ "été.txt",
+ "naïve café.jpg",
+ "Ich möchte.pdf",
+ "日本語.mp4",
+ "rapport (1).pdf",
])
def test_upload_accepts_ordinary_filenames(name):
- """The allowlist must not break normal use."""
+ """The allowlist must not break normal use, in any script."""
assert _safe_name_re().match(name), f"should be accepted: {name!r}"
+@pytest.mark.parametrize("name", [
+ "trailing space ",
+ "ends.with.dot.",
+ "..",
+ "a\u202eexe.txt", # right-to-left override: hides the real extension
+])
+def test_upload_rejects_names_that_lie_about_themselves(name):
+ """Widening to Unicode must not admit names that misrepresent the file."""
+ assert not _safe_name_re().match(name), f"should be rejected: {name!r}"
+
+
+def test_the_node_never_generates_a_name_it_would_refuse(tmp_path):
+ """_free_name resolves a collision by appending " (n)"; that has to be legal."""
+ from meshbay_node.transport.webrtc_server import _free_name
+ (tmp_path / "clip.mp4").touch()
+ (tmp_path / "clip (1).mp4").touch()
+ chosen = _free_name(tmp_path, "clip.mp4")
+ assert chosen not in ("clip.mp4", "clip (1).mp4")
+ assert _safe_name_re().match(chosen), (
+ f"the node picked {chosen!r} and would then reject it on the next upload")
+
+
def _session(tmp_path: Path, user_id: str) -> WebRTCPeerSession:
"""A peer session wired to a real shared root, with sending stubbed out."""
shared_root = tmp_path / "shared"