mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-05-12 13:44:43 +00:00
feat(tests): add comprehensive path traversal and security tests for mesh PageNode APIs, including normalization and file handling, to enhance robustness against potential vulnerabilities
This commit is contained in:
@@ -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"
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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("<script");
|
||||
}
|
||||
});
|
||||
|
||||
it("handles malformed or unclosed markdown tags gracefully", () => {
|
||||
const malformed = [
|
||||
"**bold",
|
||||
|
||||
@@ -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 <tag> & \" '", {}, 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("<script");
|
||||
expect(out.toLowerCase()).not.toContain("<script");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("NomadPageRenderer Nomad .md pages (marked + sanitize)", () => {
|
||||
it("renders GFM-style table", () => {
|
||||
const md = ["| A | B |", "| --- | --- |", "| 1 | 2 |"].join("\n");
|
||||
const html = renderNomadMarkdown(md);
|
||||
expect(html.toLowerCase()).toContain("<table");
|
||||
expect(html).toContain("A");
|
||||
expect(html).toContain("2");
|
||||
});
|
||||
|
||||
it("renders bullet and ordered list markup", () => {
|
||||
const ul = renderNomadMarkdown("- one\n- two\n");
|
||||
expect(ul.toLowerCase()).toContain("<ul");
|
||||
expect(ul).toContain("one");
|
||||
|
||||
const ol = renderNomadMarkdown("1. first\n2. second\n");
|
||||
expect(ol.toLowerCase()).toContain("<ol");
|
||||
expect(ol).toContain("second");
|
||||
});
|
||||
|
||||
it("renders fenced code block with language class", () => {
|
||||
const html = renderNomadMarkdown("```js\nconst x = 1;\n```");
|
||||
expect(html.toLowerCase()).toContain("<pre");
|
||||
expect(html).toContain("language-js");
|
||||
expect(html).toContain("const x");
|
||||
});
|
||||
|
||||
it("renders horizontal rule", () => {
|
||||
const html = renderNomadMarkdown("before\n\n---\n\nafter");
|
||||
expect(html.toLowerCase()).toContain("<hr");
|
||||
expect(html).toContain("before");
|
||||
expect(html).toContain("after");
|
||||
});
|
||||
|
||||
it("renderNomadPageByPath routes .md through markdown pipeline", () => {
|
||||
const html = renderNomadPageByPath("/page/doc.md", "## Section\n\npara", {}, MicronParser);
|
||||
expect(html).toContain("nomad-markdown");
|
||||
expect(html.toLowerCase()).toContain("<h2");
|
||||
expect(html).toContain("Section");
|
||||
});
|
||||
|
||||
it("fuzzing: renderNomadMarkdown full unicode stress", () => {
|
||||
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("<script");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isolateNomadLinksInHtml,
|
||||
renderNomadHtmlPage,
|
||||
renderNomadMarkdown,
|
||||
rewriteCssBodyHtmlSelectors,
|
||||
sanitizeNomadHtmlFragment,
|
||||
stripExternalFromCss,
|
||||
} from "../../meshchatx/src/frontend/js/NomadPageRenderer";
|
||||
|
||||
function assertNoDangerousHtmlPatterns(html) {
|
||||
const lower = html.toLowerCase();
|
||||
expect(lower).not.toContain("<script");
|
||||
expect(lower).not.toContain("<iframe");
|
||||
expect(lower).not.toContain("javascript:");
|
||||
expect(lower).not.toContain("vbscript:");
|
||||
expect(lower).not.toContain("<object");
|
||||
expect(lower).not.toContain("<embed");
|
||||
}
|
||||
|
||||
describe("NomadPageRenderer stripExternalFromCss", () => {
|
||||
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(
|
||||
'<body><p onclick="alert(1)" onmouseover="evil()">x</p><img onerror="bad()" src="x"></body>'
|
||||
);
|
||||
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(
|
||||
'<body><a href="javascript:alert(1)">a</a><a href="data:text/html,<script>x</script>">b</a><a href="https://evil.com">c</a></body>'
|
||||
);
|
||||
expect(html).not.toContain("javascript:");
|
||||
expect(html).not.toContain("evil.com");
|
||||
});
|
||||
|
||||
it("strips external img src except safe data:image", () => {
|
||||
const html = renderNomadHtmlPage(
|
||||
'<body><img src="https://evil.com/i.png"><img src="data:image/png;base64,iVBORw0KGgo="></body>'
|
||||
);
|
||||
expect(html).not.toContain("evil.com");
|
||||
expect(html).toContain("data:image/png");
|
||||
});
|
||||
|
||||
it("sanitises style text with network url in document", () => {
|
||||
const html = renderNomadHtmlPage("<body><style>p { background: url(http://x/y) }</style><p>ok</p></body>");
|
||||
expect(html).not.toContain("http://x");
|
||||
expect(html).toContain("ok");
|
||||
});
|
||||
|
||||
it("handles nested style in body and head-less fragment", () => {
|
||||
const html = renderNomadHtmlPage("<p>x</p><style>body{color:blue}</style>");
|
||||
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 = [
|
||||
"<svg onload=alert(1)></svg>",
|
||||
'<math><mi//xlink:href="javascript:alert(1)">',
|
||||
"<body><style>@import 'http://a.com/b.css';</style></body>",
|
||||
"<template><script>alert(1)</script></template>",
|
||||
"<marquee onstart=alert(1)>x</marquee>",
|
||||
String.raw`<body><a href="jav	ascript:alert(1)">x</a></body>`,
|
||||
];
|
||||
for (const t of templates) {
|
||||
expect(() => renderNomadHtmlPage(t)).not.toThrow();
|
||||
assertNoDangerousHtmlPatterns(renderNomadHtmlPage(t));
|
||||
}
|
||||
});
|
||||
|
||||
it("fuzzing: renderNomadHtmlPage high-volume random and mixed payloads", () => {
|
||||
const snippets = [
|
||||
"<script>x</script>",
|
||||
"<style>body{background:url(https://a.b/c)}</style>",
|
||||
"<iframe src=x></iframe>",
|
||||
"<a href=http://x>y</a>",
|
||||
"<svg><script>.</script></svg>",
|
||||
];
|
||||
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('<p><a href="/page/x.mu">t</a></p>', 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('<body><a href="/page/a.html">x</a></body>', { 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("<div>ok</div><script>bad</script>");
|
||||
expect(out.toLowerCase()).not.toContain("<script");
|
||||
expect(out).toContain("ok");
|
||||
});
|
||||
|
||||
it("markdown pipeline does not emit executable URLs", () => {
|
||||
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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,27 @@ describe("NomadPageRenderer", () => {
|
||||
expect(renderNomadPageByPath("/page/a.html", '<p class="x">z</p>', {}, MicronParser)).toContain("z");
|
||||
});
|
||||
|
||||
it("renderNomadPageByPath respects disabled markdown and html", () => {
|
||||
const mdOff = renderNomadPageByPath("/page/a.md", "# Title", {}, MicronParser, {
|
||||
renderMarkdown: false,
|
||||
});
|
||||
expect(mdOff).not.toContain("<h1");
|
||||
expect(mdOff).toContain("Title");
|
||||
|
||||
const htmlOff = renderNomadPageByPath("/page/a.html", "<p>x</p>", {}, MicronParser, {
|
||||
renderHtml: false,
|
||||
});
|
||||
expect(htmlOff.toLowerCase()).not.toContain("<p>");
|
||||
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("<pre");
|
||||
});
|
||||
|
||||
it("sanitizeNomadHtmlFragment handles arbitrary strings without throwing", () => {
|
||||
expect(() => sanitizeNomadHtmlFragment("<div>ok</div>")).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user