From bd2875eb0d7d3bce2389ff0e1e7397a5bede9c6e Mon Sep 17 00:00:00 2001
From: Ivan
Date: Mon, 13 Apr 2026 22:05:15 -0500
Subject: [PATCH] feat(tests): add comprehensive path traversal and security
tests for mesh PageNode APIs, including normalization and file handling, to
enhance robustness against potential vulnerabilities
---
.../test_mesh_page_file_path_security.py | 224 ++++++++++++++++++
tests/backend/test_meshchat_coverage.py | 52 ++++
tests/backend/test_page_node.py | 23 ++
tests/frontend/ArchivesPage.test.js | 8 +-
tests/frontend/MarkdownRenderer.test.js | 23 ++
.../NomadPageRenderer.markdown-txt.test.js | 115 +++++++++
.../NomadPageRenderer.security.test.js | 221 +++++++++++++++++
tests/frontend/NomadPageRenderer.test.js | 21 ++
tests/frontend/Toast.test.js | 12 +
tests/frontend/ToolsPage.test.js | 5 +-
10 files changed, 700 insertions(+), 4 deletions(-)
create mode 100644 tests/backend/test_mesh_page_file_path_security.py
create mode 100644 tests/frontend/NomadPageRenderer.markdown-txt.test.js
create mode 100644 tests/frontend/NomadPageRenderer.security.test.js
diff --git a/tests/backend/test_mesh_page_file_path_security.py b/tests/backend/test_mesh_page_file_path_security.py
new file mode 100644
index 0000000..e2d02bb
--- /dev/null
+++ b/tests/backend/test_mesh_page_file_path_security.py
@@ -0,0 +1,224 @@
+"""
+Path traversal regression tests and property-based fuzzing for mesh PageNode
+page/file APIs and normalize_page_filename.
+"""
+
+import os
+import shutil
+import tempfile
+from unittest.mock import MagicMock, patch
+
+import pytest
+from hypothesis import HealthCheck, given, settings
+from hypothesis import strategies as st
+
+from meshchatx.src.backend.page_node import (
+ PageNode,
+ _safe_mesh_file_basename,
+ normalize_page_filename,
+)
+
+
+@pytest.fixture
+def node_dir():
+ d = tempfile.mkdtemp()
+ yield d
+ shutil.rmtree(d, ignore_errors=True)
+
+
+@pytest.fixture
+def mock_rns():
+ with patch("meshchatx.src.backend.page_node.RNS") as mock:
+ mock_identity = MagicMock()
+ mock_identity.hash = b"\x01" * 16
+ mock_identity.get_public_key.return_value = b"\x02" * 64
+
+ mock_destination = MagicMock()
+ mock_destination.hash = b"\x03" * 16
+
+ mock.Identity.return_value = mock_identity
+ mock.Identity.from_file.return_value = mock_identity
+ mock.Destination.return_value = mock_destination
+ mock.Destination.IN = 1
+ mock.Destination.SINGLE = 0
+ mock.Destination.ALLOW_ALL = 0xFF
+ mock.Transport = MagicMock()
+
+ yield mock, mock_identity, mock_destination
+
+
+def _make_node(node_dir, mock_rns):
+ _, mock_identity, _ = mock_rns
+ return PageNode(
+ node_id="sec-test-node",
+ name="Sec Test",
+ base_dir=node_dir,
+ identity=mock_identity,
+ )
+
+
+TRAVERSAL_PAGE_INPUTS = [
+ "../../etc/passwd",
+ "..\\..\\windows\\system32",
+ "page/../../../secret.mu",
+ "/page/../../../x.mu",
+ "foo/../bar/../baz.mu",
+ "....//....//evil.mu",
+ "\0../x.mu",
+]
+
+
+class TestPathTraversalKnownVectors:
+ def test_normalize_strips_to_basename(self):
+ for raw in TRAVERSAL_PAGE_INPUTS:
+ try:
+ out = normalize_page_filename(raw)
+ except ValueError:
+ continue
+ assert os.sep not in out
+ assert "/" not in out
+ assert "\\" not in out
+ assert out not in (".", "..")
+ assert os.path.basename(out) == out
+
+ def test_add_page_never_writes_outside_pages_dir(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ for raw in TRAVERSAL_PAGE_INPUTS:
+ try:
+ saved = node.add_page(raw, "probe")
+ except ValueError:
+ continue
+ full = os.path.realpath(os.path.join(node.pages_dir, saved))
+ root = os.path.realpath(node.pages_dir)
+ assert full == root or full.startswith(root + os.sep), (raw, saved, full)
+
+ def test_add_file_rejects_dot_segments(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ with pytest.raises(ValueError):
+ node.add_file("..", b"x")
+ with pytest.raises(ValueError):
+ node.add_file(".", b"x")
+ with pytest.raises(ValueError):
+ node.add_file(" .. ", b"x")
+
+ def test_remove_file_dot_segments_returns_false(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ assert node.remove_file("..") is False
+ assert node.remove_file(".") is False
+
+ def test_safe_mesh_file_basename(self):
+ assert _safe_mesh_file_basename("a/b/c.txt") == "c.txt"
+ with pytest.raises(ValueError):
+ _safe_mesh_file_basename("..")
+ with pytest.raises(ValueError):
+ _safe_mesh_file_basename("")
+
+
+@settings(max_examples=300, deadline=None)
+@given(name=st.text(min_size=0, max_size=500))
+def test_normalize_page_filename_never_emits_path_segments(name):
+ try:
+ out = normalize_page_filename(name)
+ except ValueError:
+ return
+ assert os.sep not in out
+ assert "/" not in out
+ assert "\\" not in out
+ assert out not in (".", "..")
+
+
+@settings(
+ max_examples=200,
+ deadline=None,
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+)
+@given(name=st.text(min_size=0, max_size=500))
+def test_add_page_writes_only_under_pages_dir(mock_rns, name):
+ with tempfile.TemporaryDirectory() as node_dir:
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ try:
+ saved = node.add_page(name, "x")
+ except ValueError:
+ return
+ full = os.path.realpath(os.path.join(node.pages_dir, saved))
+ root = os.path.realpath(node.pages_dir)
+ assert full == root or full.startswith(root + os.sep)
+
+
+@settings(
+ max_examples=200,
+ deadline=None,
+ suppress_health_check=[HealthCheck.function_scoped_fixture],
+)
+@given(fname=st.text(min_size=0, max_size=500))
+def test_add_file_writes_only_under_files_dir(mock_rns, fname):
+ with tempfile.TemporaryDirectory() as node_dir:
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ try:
+ saved = node.add_file(fname, b"x")
+ except ValueError:
+ return
+ full = os.path.realpath(os.path.join(node.files_dir, saved))
+ root = os.path.realpath(node.files_dir)
+ assert full == root or full.startswith(root + os.sep)
+
+
+class TestPageRespondersTraversal:
+ def test_page_responder_ignores_path_in_request_path(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_page("safe.mu", "ok")
+ responder = node._make_page_responder("safe.mu")
+ result = responder("/page/../../../etc/passwd", None, "r", "l", None, 0)
+ assert result == b"ok"
+
+ def test_file_responder_ignores_path_prefix_in_request_path(
+ self, node_dir, mock_rns
+ ):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_file("blob.bin", b"data")
+ responder = node._make_file_responder("blob.bin")
+ out = responder("/file/../blob.bin", None, "r", "l", None, 0)
+ assert isinstance(out, list)
+ out[0].close()
+
+
+def test_try_serve_local_helpers_strip_traversal():
+ """Regression: meshchat local serve must not join parent dirs for file names."""
+ from meshchatx.meshchat import ReticulumMeshChat
+
+ app = MagicMock(spec=ReticulumMeshChat)
+ node = MagicMock()
+ node.running = True
+ node.destination = MagicMock()
+ node.destination.hash = b"\xab" * 16
+ node.files_dir = "/tmp/mesh_files"
+ node.pages_dir = "/tmp/mesh_pages"
+ node._stats = {"files_served": 0, "pages_served": 0}
+ node.get_page_content = MagicMock(return_value="page ok")
+ app.page_node_manager = MagicMock()
+ app.page_node_manager.nodes = {"1": node}
+
+ dh = node.destination.hash
+ file_out = ReticulumMeshChat._try_serve_local_page_node_file(
+ app,
+ dh,
+ "/file/../..",
+ )
+ assert file_out is None
+
+ page_out = ReticulumMeshChat._try_serve_local_page_node(
+ app,
+ dh,
+ "/page/../../../x.mu",
+ )
+ node.get_page_content.assert_called_once()
+ called = node.get_page_content.call_args[0][0]
+ assert called == "x.mu"
+ assert page_out == "page ok"
diff --git a/tests/backend/test_meshchat_coverage.py b/tests/backend/test_meshchat_coverage.py
index 123477d..6a26a42 100644
--- a/tests/backend/test_meshchat_coverage.py
+++ b/tests/backend/test_meshchat_coverage.py
@@ -167,6 +167,10 @@ def test_get_config_dict_basic(mock_app):
"location_manual_lon",
"location_manual_alt",
"telemetry_enabled",
+ "nomad_render_markdown_enabled",
+ "nomad_render_html_enabled",
+ "nomad_render_plaintext_enabled",
+ "nomad_default_page_path",
"message_outbound_bubble_color",
"message_inbound_bubble_color",
"message_failed_bubble_color",
@@ -189,6 +193,54 @@ def test_get_config_dict_basic(mock_app):
assert config_dict["theme"] == "light"
assert config_dict["is_transport_enabled"] is True
assert config_dict["identity_public_key"] == "a1" * 32
+ assert "nomad_default_page_path" in config_dict
+ assert "nomad_render_markdown_enabled" in config_dict
+
+
+@pytest.mark.asyncio
+async def test_update_config_nomad_renderer(mock_app):
+ mock_app.send_config_to_websocket_clients = MagicMock(return_value=asyncio.Future())
+ mock_app.send_config_to_websocket_clients.return_value.set_result(None)
+ for name in (
+ "nomad_render_markdown_enabled",
+ "nomad_render_html_enabled",
+ "nomad_render_plaintext_enabled",
+ "nomad_default_page_path",
+ ):
+ setattr(mock_app.config, name, MagicMock())
+
+ await mock_app.update_config(
+ {
+ "nomad_render_markdown_enabled": False,
+ "nomad_render_html_enabled": True,
+ "nomad_render_plaintext_enabled": False,
+ "nomad_default_page_path": "/page/index.html",
+ }
+ )
+ mock_app.config.nomad_render_markdown_enabled.set.assert_called_with(False)
+ mock_app.config.nomad_render_html_enabled.set.assert_called_with(True)
+ mock_app.config.nomad_render_plaintext_enabled.set.assert_called_with(False)
+ mock_app.config.nomad_default_page_path.set.assert_called_with("/page/index.html")
+
+
+@pytest.mark.asyncio
+async def test_update_config_nomad_default_page_path_empty_resets(mock_app):
+ mock_app.send_config_to_websocket_clients = MagicMock(return_value=asyncio.Future())
+ mock_app.send_config_to_websocket_clients.return_value.set_result(None)
+ mock_app.config.nomad_default_page_path = MagicMock()
+
+ await mock_app.update_config({"nomad_default_page_path": ""})
+ mock_app.config.nomad_default_page_path.set.assert_called_with("/page/index.mu")
+
+
+@pytest.mark.asyncio
+async def test_update_config_nomad_default_page_path_invalid_skipped(mock_app):
+ mock_app.send_config_to_websocket_clients = MagicMock(return_value=asyncio.Future())
+ mock_app.send_config_to_websocket_clients.return_value.set_result(None)
+ mock_app.config.nomad_default_page_path = MagicMock()
+
+ await mock_app.update_config({"nomad_default_page_path": "/page/../../etc/passwd"})
+ mock_app.config.nomad_default_page_path.set.assert_not_called()
@pytest.mark.asyncio
diff --git a/tests/backend/test_page_node.py b/tests/backend/test_page_node.py
index 98b0850..d5933be 100644
--- a/tests/backend/test_page_node.py
+++ b/tests/backend/test_page_node.py
@@ -298,6 +298,16 @@ class TestPageNodeResponders:
result = responder("/page/index.mu", None, "req1", "link1", None, 0)
assert result == b"page content"
+ def test_page_responder_records_remote_identity(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ node.add_page("index.mu", "x")
+ rid = MagicMock()
+ rid.hash = bytes.fromhex("ef" * 16)
+ responder = node._make_page_responder("index.mu")
+ responder("/page/index.mu", None, "req1", "link1", rid, 0)
+ assert node.get_status()["unique_connections"] == 1
+
def test_page_responder_missing_returns_none(self, node_dir, mock_rns):
node = _make_node(node_dir, mock_rns)
node.setup()
@@ -361,6 +371,8 @@ class TestPageNodeStatus:
assert status["destination_hash"] is not None
assert "index.mu" in status["pages"]
assert isinstance(status["stats"], dict)
+ assert status["unique_connections"] == 0
+ assert status["uptime_seconds"] >= 0
def test_get_destination_hash_when_not_running(self, node_dir, mock_rns):
node = _make_node(node_dir, mock_rns)
@@ -372,11 +384,22 @@ class TestPageNodeLinkCallbacks:
node = _make_node(node_dir, mock_rns)
node.setup()
mock_link = MagicMock()
+ mock_link.get_remote_identity.return_value = None
node._link_established(mock_link)
assert mock_link in node.active_links
assert node._stats["links_established"] == 1
mock_link.set_link_closed_callback.assert_called_once()
+ def test_link_established_records_remote_identity(self, node_dir, mock_rns):
+ node = _make_node(node_dir, mock_rns)
+ node.setup()
+ mock_link = MagicMock()
+ rid = MagicMock()
+ rid.hash = bytes.fromhex("cd" * 16)
+ mock_link.get_remote_identity.return_value = rid
+ node._link_established(mock_link)
+ assert node.get_status()["unique_connections"] == 1
+
def test_link_closed_callback(self, node_dir, mock_rns):
node = _make_node(node_dir, mock_rns)
node.setup()
diff --git a/tests/frontend/ArchivesPage.test.js b/tests/frontend/ArchivesPage.test.js
index 0e5401b..53d79b7 100644
--- a/tests/frontend/ArchivesPage.test.js
+++ b/tests/frontend/ArchivesPage.test.js
@@ -46,7 +46,7 @@ describe("ArchivesPage.vue", () => {
page_path: "/readme.txt",
hash: "abcdef",
})
- ).toBe("readme.mu");
+ ).toBe("readme.txt");
});
it("muExportFilenameDisambiguated appends hash prefix", () => {
@@ -57,6 +57,12 @@ describe("ArchivesPage.vue", () => {
hash: "1234567890ab",
})
).toBe("a_12345678.mu");
+ expect(
+ wrapper.vm.muExportFilenameDisambiguated({
+ page_path: "/notes.md",
+ hash: "1234567890ab",
+ })
+ ).toBe("notes_12345678.md");
});
it("downloadTextAsFile creates a blob URL and revokes it", () => {
diff --git a/tests/frontend/MarkdownRenderer.test.js b/tests/frontend/MarkdownRenderer.test.js
index b5b2eb0..2185608 100644
--- a/tests/frontend/MarkdownRenderer.test.js
+++ b/tests/frontend/MarkdownRenderer.test.js
@@ -2,6 +2,15 @@ import { describe, it, expect } from "vitest";
import MarkdownRenderer from "@/js/MarkdownRenderer";
describe("MarkdownRenderer.js", () => {
+ describe("render (ConversationViewer / message bodies)", () => {
+ it("escapes leading greater-than so blockquote regex does not apply (documented behaviour)", () => {
+ const result = MarkdownRenderer.render("> quoted line\n\nNormal paragraph.");
+ expect(result).toContain("> quoted line");
+ expect(result).toContain("Normal paragraph");
+ expect(result).not.toContain("blockquote");
+ });
+ });
+
describe("render", () => {
it("renders basic text correctly", () => {
expect(MarkdownRenderer.render("Hello")).toContain("Hello");
@@ -187,6 +196,20 @@ describe("MarkdownRenderer.js", () => {
});
});
+ it("fuzzing: full unicode code points do not crash render or strip", () => {
+ for (let i = 0; i < 200; i++) {
+ let s = "";
+ const len = Math.floor(Math.random() * 900);
+ for (let j = 0; j < len; j++) {
+ s += String.fromCharCode(Math.floor(Math.random() * 65536));
+ }
+ expect(() => MarkdownRenderer.render(s)).not.toThrow();
+ expect(() => MarkdownRenderer.strip(s)).not.toThrow();
+ const r = MarkdownRenderer.render(s);
+ expect(r.toLowerCase()).not.toContain("">bc


ok