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(" { const malformed = [ "**bold", diff --git a/tests/frontend/NomadPageRenderer.markdown-txt.test.js b/tests/frontend/NomadPageRenderer.markdown-txt.test.js new file mode 100644 index 0000000..278e394 --- /dev/null +++ b/tests/frontend/NomadPageRenderer.markdown-txt.test.js @@ -0,0 +1,115 @@ +import { describe, expect, it } from "vitest"; +import MicronParser from "../../meshchatx/src/frontend/js/MicronParser"; +import { + escapeNomadPlainText, + renderNomadMarkdown, + renderNomadPageByPath, +} from "../../meshchatx/src/frontend/js/NomadPageRenderer"; + +describe("NomadPageRenderer plaintext (.txt and escapeNomadPlainText)", () => { + it("escapes angle brackets, ampersands, and quotes", () => { + const out = escapeNomadPlainText(`<&> "'`); + expect(out).toContain("<"); + expect(out).toContain(">"); + expect(out).toContain("&"); + expect(out).toContain("""); + expect(out).toContain("'"); + }); + + it("wraps content in a pre-wrap container class", () => { + expect(escapeNomadPlainText("x")).toContain("whitespace-pre-wrap"); + }); + + it("preserves newlines as escaped text inside the wrapper", () => { + const out = escapeNomadPlainText("line1\nline2"); + expect(out).toContain("line1"); + expect(out).toContain("line2"); + }); + + it("renderNomadPageByPath uses plaintext for .txt paths", () => { + const html = renderNomadPageByPath("/page/readme.txt", "Plain & \" '", {}, MicronParser); + expect(html).toContain("<tag>"); + expect(html).toContain("&"); + expect(html).toContain("whitespace-pre-wrap"); + }); + + it("renderNomadPageByPath .txt is case-insensitive on extension", () => { + const html = renderNomadPageByPath("/page/NOTE.TXT", "ok", {}, MicronParser); + expect(html).toContain("ok"); + expect(html).toContain("whitespace-pre-wrap"); + }); + + it("empty and whitespace-only plaintext", () => { + expect(escapeNomadPlainText("")).toContain("whitespace-pre-wrap"); + expect(escapeNomadPlainText(" \n\t ")).toContain("whitespace-pre-wrap"); + }); + + it("fuzzing: escapeNomadPlainText random unicode never throws", () => { + for (let i = 0; i < 250; i++) { + let s = ""; + const len = Math.floor(Math.random() * 1200); + for (let j = 0; j < len; j++) { + s += String.fromCharCode(Math.floor(Math.random() * 65536)); + } + expect(() => escapeNomadPlainText(s)).not.toThrow(); + const out = escapeNomadPlainText(s); + expect(out).not.toContain(" { + it("renders GFM-style table", () => { + const md = ["| A | B |", "| --- | --- |", "| 1 | 2 |"].join("\n"); + const html = renderNomadMarkdown(md); + expect(html.toLowerCase()).toContain(" { + const ul = renderNomadMarkdown("- one\n- two\n"); + expect(ul.toLowerCase()).toContain(" { + const html = renderNomadMarkdown("```js\nconst x = 1;\n```"); + expect(html.toLowerCase()).toContain(" { + const html = renderNomadMarkdown("before\n\n---\n\nafter"); + expect(html.toLowerCase()).toContain(" { + const html = renderNomadPageByPath("/page/doc.md", "## Section\n\npara", {}, MicronParser); + expect(html).toContain("nomad-markdown"); + expect(html.toLowerCase()).toContain(" { + for (let i = 0; i < 200; i++) { + let s = ""; + const len = Math.floor(Math.random() * 1000); + for (let j = 0; j < len; j++) { + s += String.fromCharCode(Math.floor(Math.random() * 65536)); + } + expect(() => renderNomadMarkdown(s)).not.toThrow(); + const html = renderNomadMarkdown(s); + expect(typeof html).toBe("string"); + expect(html.toLowerCase()).not.toContain(" { + it("removes @import rules", () => { + const s = stripExternalFromCss(`@import url("http://evil.com/a.css"); p { color: red }`); + expect(s).not.toContain("@import"); + expect(s).not.toContain("evil.com"); + expect(s).toMatch(/color:\s*red/i); + }); + + it("blocks url() pointing at http, https, or protocol-relative", () => { + expect(stripExternalFromCss(`a { background: url(http://x/y) }`)).toContain("url(blocked:"); + expect(stripExternalFromCss(`a { background: url(https://x/y) }`)).toContain("url(blocked:"); + expect(stripExternalFromCss(`a { background: url(//x/y) }`)).toContain("url(blocked:"); + }); + + it("allows local-looking and relative css without network url()", () => { + const s = stripExternalFromCss(`p { color: #333; border: 1px solid currentColor }`); + expect(s).toContain("#333"); + expect(s).toContain("currentColor"); + }); + + it("neutralises expression() and IE-style javascript in css", () => { + expect(stripExternalFromCss(`x{width:expression(alert(1))}`)).toContain("blocked("); + expect(stripExternalFromCss(`x{foo:javascript:alert(1)}`)).toContain("blocked:"); + }); + + it("fuzzing: stripExternalFromCss never throws", () => { + for (let i = 0; i < 300; i++) { + let s = ""; + const len = Math.floor(Math.random() * 2000); + for (let j = 0; j < len; j++) { + s += String.fromCharCode(Math.floor(Math.random() * 65536)); + } + expect(() => stripExternalFromCss(s)).not.toThrow(); + expect(typeof stripExternalFromCss(s)).toBe("string"); + } + }); +}); + +describe("NomadPageRenderer rewriteCssBodyHtmlSelectors", () => { + it("rewrites body and html block openers to nomad-html-root", () => { + const out = rewriteCssBodyHtmlSelectors(`body { color: red } html { margin: 0 }`); + expect(out).toContain(".nomad-html-root"); + expect(out).not.toMatch(/\bbody\s*\{/); + expect(out).not.toMatch(/\bhtml\s*\{/); + }); + + it("rewrites html, body in selector lists", () => { + const out = rewriteCssBodyHtmlSelectors(`html, body, p { margin: 0 }`); + expect(out).toContain(".nomad-html-root"); + }); + + it("rewrites html body descendant selector", () => { + const out = rewriteCssBodyHtmlSelectors(`html body p { margin: 0 }`); + expect(out).toContain(".nomad-html-root"); + }); + + it("applies stripExternalFromCss inside rewrite", () => { + const out = rewriteCssBodyHtmlSelectors(`body { background: url(https://cdn.example/bg.png) }`); + expect(out).toContain("url(blocked:"); + expect(out).not.toMatch(/url\s*\(\s*["']?https?:\/\//i); + }); + + it("fuzzing: rewriteCssBodyHtmlSelectors never throws", () => { + for (let i = 0; i < 200; i++) { + let s = ""; + const len = Math.floor(Math.random() * 1500); + for (let j = 0; j < len; j++) { + s += String.fromCharCode(Math.floor(Math.random() * 65536)); + } + expect(() => rewriteCssBodyHtmlSelectors(s)).not.toThrow(); + expect(typeof rewriteCssBodyHtmlSelectors(s)).toBe("string"); + } + }); +}); + +describe("NomadPageRenderer HTML document sanitization", () => { + it("strips inline event handlers", () => { + const html = renderNomadHtmlPage( + '

x

' + ); + expect(html.toLowerCase()).not.toContain("onclick"); + expect(html.toLowerCase()).not.toContain("onmouseover"); + expect(html.toLowerCase()).not.toContain("onerror"); + }); + + it("removes javascript and data-exfil hrefs from anchors", () => { + const html = renderNomadHtmlPage( + 'abc' + ); + expect(html).not.toContain("javascript:"); + expect(html).not.toContain("evil.com"); + }); + + it("strips external img src except safe data:image", () => { + const html = renderNomadHtmlPage( + '' + ); + expect(html).not.toContain("evil.com"); + expect(html).toContain("data:image/png"); + }); + + it("sanitises style text with network url in document", () => { + const html = renderNomadHtmlPage("

ok

"); + expect(html).not.toContain("http://x"); + expect(html).toContain("ok"); + }); + + it("handles nested style in body and head-less fragment", () => { + const html = renderNomadHtmlPage("

x

"); + expect(html).toContain("nomad-html-root"); + expect(html).toContain(".nomad-html-root"); + }); + + it("adversarial templates do not throw and omit script-like vectors", () => { + const templates = [ + "", + '', + "", + "", + "x", + String.raw`x`, + ]; + for (const t of templates) { + expect(() => renderNomadHtmlPage(t)).not.toThrow(); + assertNoDangerousHtmlPatterns(renderNomadHtmlPage(t)); + } + }); + + it("fuzzing: renderNomadHtmlPage high-volume random and mixed payloads", () => { + const snippets = [ + "", + "", + "", + "y", + "", + ]; + for (let i = 0; i < 400; i++) { + let s = snippets[i % snippets.length]; + const extra = Math.floor(Math.random() * 1200); + for (let j = 0; j < extra; j++) { + s += String.fromCharCode(Math.floor(Math.random() * 65536)); + } + let out; + expect(() => { + out = renderNomadHtmlPage(s); + }).not.toThrow(); + assertNoDangerousHtmlPatterns(out); + } + }); +}); + +describe("NomadPageRenderer in-renderer link isolation", () => { + const hash = "a".repeat(32); + + it("isolateNomadLinksInHtml rewrites /page links to data-nomadnet-url and href #", () => { + const out = isolateNomadLinksInHtml('

t

', hash); + expect(out).toContain(`data-nomadnet-url="${hash}:/page/x.mu"`); + expect(out).toContain('href="#"'); + expect(out).not.toContain('href="/page/'); + }); + + it("renderNomadHtmlPage with destinationHash isolates relative links", () => { + const html = renderNomadHtmlPage('x', { destinationHash: hash }); + expect(html).toContain(`data-nomadnet-url="${hash}:/page/a.html"`); + expect(html).not.toMatch(/href="\/page\//); + }); + + it("renderNomadMarkdown with destinationHash isolates relative links", () => { + const html = renderNomadMarkdown("[l](/page/b.md)", { destinationHash: hash }); + expect(html).toContain(`data-nomadnet-url="${hash}:/page/b.md"`); + expect(html).not.toMatch(/href="\/page\//); + }); +}); + +describe("NomadPageRenderer fragment and markdown sanitization", () => { + it("sanitizeNomadHtmlFragment removes script and keeps safe markup", () => { + const out = sanitizeNomadHtmlFragment("
ok
"); + expect(out.toLowerCase()).not.toContain(" { + const md = "[l](javascript:void(0))\n\n[ext](https://bad.example)\n\n`code`"; + const html = renderNomadMarkdown(md); + assertNoDangerousHtmlPatterns(html); + expect(html).not.toContain("bad.example"); + }); + + it("fuzzing: sanitizeNomadHtmlFragment and renderNomadMarkdown combined stress", () => { + for (let i = 0; i < 250; i++) { + let s = ""; + const len = Math.floor(Math.random() * 600); + for (let j = 0; j < len; j++) { + s += String.fromCharCode(Math.floor(Math.random() * 65536)); + } + expect(() => sanitizeNomadHtmlFragment(s)).not.toThrow(); + expect(() => renderNomadMarkdown(s)).not.toThrow(); + assertNoDangerousHtmlPatterns(renderNomadMarkdown(s)); + } + }); +}); diff --git a/tests/frontend/NomadPageRenderer.test.js b/tests/frontend/NomadPageRenderer.test.js index 8ebffee..6e58f70 100644 --- a/tests/frontend/NomadPageRenderer.test.js +++ b/tests/frontend/NomadPageRenderer.test.js @@ -74,6 +74,27 @@ describe("NomadPageRenderer", () => { expect(renderNomadPageByPath("/page/a.html", '

z

', {}, MicronParser)).toContain("z"); }); + it("renderNomadPageByPath respects disabled markdown and html", () => { + const mdOff = renderNomadPageByPath("/page/a.md", "# Title", {}, MicronParser, { + renderMarkdown: false, + }); + expect(mdOff).not.toContain("x

", {}, MicronParser, { + renderHtml: false, + }); + expect(htmlOff.toLowerCase()).not.toContain("

"); + expect(htmlOff).toContain("<"); + }); + + it("renderNomadPageByPath respects disabled plaintext for .txt", () => { + const on = renderNomadPageByPath("/page/a.txt", "line", {}, MicronParser, { renderPlaintext: true }); + const off = renderNomadPageByPath("/page/a.txt", "line", {}, MicronParser, { renderPlaintext: false }); + expect(on).toContain("whitespace-pre-wrap"); + expect(off).toContain(" { expect(() => sanitizeNomadHtmlFragment("

ok
")).not.toThrow(); }); diff --git a/tests/frontend/Toast.test.js b/tests/frontend/Toast.test.js index 8f97bd3..95c2bac 100644 --- a/tests/frontend/Toast.test.js +++ b/tests/frontend/Toast.test.js @@ -54,6 +54,18 @@ describe("Toast.vue", () => { expect(wrapper.text()).not.toContain("Test Message"); }); + it("removes a toast when GlobalEmitter emits toast-dismiss with matching key", async () => { + GlobalEmitter.emit("toast", { message: "Loading", type: "loading", duration: 0, key: "job-1" }); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).toContain("Loading"); + + GlobalEmitter.emit("toast-dismiss", { key: "job-1" }); + await wrapper.vm.$nextTick(); + + expect(wrapper.text()).not.toContain("Loading"); + }); + it("removes a toast when clicking the close button", async () => { GlobalEmitter.emit("toast", { message: "Test Message", duration: 0 }); await wrapper.vm.$nextTick(); diff --git a/tests/frontend/ToolsPage.test.js b/tests/frontend/ToolsPage.test.js index 85516fc..af51ae5 100644 --- a/tests/frontend/ToolsPage.test.js +++ b/tests/frontend/ToolsPage.test.js @@ -17,7 +17,6 @@ describe("ToolsPage.vue", () => { { path: "/bots", name: "bots", component: { template: "div" } }, { path: "/forwarder", name: "forwarder", component: { template: "div" } }, { path: "/documentation", name: "documentation", component: { template: "div" } }, - { path: "/licenses", name: "licenses", component: { template: "div" } }, { path: "/micron-editor", name: "micron-editor", component: { template: "div" } }, { path: "/paper-message", name: "paper-message", component: { template: "div" } }, { path: "/rnode-flasher", name: "rnode-flasher", component: { template: "div" } }, @@ -52,7 +51,7 @@ describe("ToolsPage.vue", () => { it("renders all tool rows", () => { const wrapper = mountToolsPage(); const toolRows = wrapper.findAll(".tool-row"); - expect(toolRows.length).toBe(18); + expect(toolRows.length).toBe(17); }); it("filters tools based on search query", async () => { @@ -77,6 +76,6 @@ describe("ToolsPage.vue", () => { await clearButton.trigger("click"); expect(wrapper.vm.searchQuery).toBe(""); - expect(wrapper.vm.filteredTools.length).toBe(18); + expect(wrapper.vm.filteredTools.length).toBe(17); }); });