From a345321047f7e2c33ecb8bab1e11aab37395fbe4 Mon Sep 17 00:00:00 2001 From: Ivan Date: Thu, 23 Apr 2026 13:17:26 -0500 Subject: [PATCH] feat(tests): add property-based tests for hex colour parsing and repository upload filename sanitization to enhance robustness --- tests/backend/test_colour_utils_hypothesis.py | 46 ++++++++++++++ ...st_repository_server_manager_hypothesis.py | 60 +++++++++++++++++++ tests/electron/safeExternalUrl.test.js | 15 +++++ tests/electron/shellPathGuard.test.js | 23 +++++++ tests/frontend/LinkUtils.test.js | 20 +++++++ 5 files changed, 164 insertions(+) create mode 100644 tests/backend/test_colour_utils_hypothesis.py create mode 100644 tests/backend/test_repository_server_manager_hypothesis.py diff --git a/tests/backend/test_colour_utils_hypothesis.py b/tests/backend/test_colour_utils_hypothesis.py new file mode 100644 index 0000000..ef6df40 --- /dev/null +++ b/tests/backend/test_colour_utils_hypothesis.py @@ -0,0 +1,46 @@ +# SPDX-License-Identifier: 0BSD + +"""Property tests for hex colour parsing.""" + +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from meshchatx.src.backend.colour_utils import ColourUtils + + +@settings(max_examples=200, deadline=None) +@given(t=st.text(max_size=200)) +def test_hex_colour_only_value_error_or_returns_bytes(t): + try: + out = ColourUtils.hex_colour_to_byte_array(t) + except ValueError: + return + except Exception as e: + raise AssertionError(f"unexpected exception: {type(e).__name__}: {e}") from e + + assert isinstance(out, bytes) + + +@settings(max_examples=150, deadline=None) +@given( + h=st.from_regex(r"[0-9a-fA-F]{2,64}", fullmatch=True).filter( + lambda x: len(x) % 2 == 0 + ) +) +def test_hex_colour_even_length_hex_round_trip(h): + out = ColourUtils.hex_colour_to_byte_array(h) + out_hash = ColourUtils.hex_colour_to_byte_array("#" + h) + assert out == out_hash + assert len(out) == len(h) // 2 + + +@settings(max_examples=80, deadline=None) +@given( + h=st.from_regex(r"[0-9a-fA-F]{1,63}", fullmatch=True).filter( + lambda x: len(x) % 2 == 1 + ) +) +def test_hex_colour_odd_length_raises(h): + with pytest.raises(ValueError): + ColourUtils.hex_colour_to_byte_array(h) diff --git a/tests/backend/test_repository_server_manager_hypothesis.py b/tests/backend/test_repository_server_manager_hypothesis.py new file mode 100644 index 0000000..0134e33 --- /dev/null +++ b/tests/backend/test_repository_server_manager_hypothesis.py @@ -0,0 +1,60 @@ +# SPDX-License-Identifier: 0BSD + +"""Property tests for repository upload filename sanitization.""" + +import os +import re +import tempfile + +from hypothesis import given, settings +from hypothesis import strategies as st + +from meshchatx.src.backend.repository_server_manager import ( + RepositoryServerManager, + _safe_any_upload_filename, +) + +_SAFE_RE = re.compile(r"[A-Za-z0-9._+\-]+") + + +@settings(max_examples=250, deadline=None) +@given(name=st.text(max_size=400)) +def test_safe_any_upload_filename_never_raises(name): + out = _safe_any_upload_filename(name) + if out is None: + return + assert isinstance(out, str) + assert out == os.path.basename(name) + assert out == name + assert ".." not in out + assert _SAFE_RE.fullmatch(out) + + +@settings(max_examples=200, deadline=None) +@given( + base=st.from_regex(r"[A-Za-z0-9._+\-]{1,120}", fullmatch=True).filter( + lambda b: b not in (".", "..") and ".." not in b + ), +) +def test_save_upload_roundtrip_for_generated_safe_names(base): + with tempfile.TemporaryDirectory() as td: + mgr = RepositoryServerManager(td) + ok, err = mgr.save_upload(base, b"\x00\x01\x02") + assert ok and err is None + dest = os.path.join(td, "repository-server", "uploads", base) + assert dest == os.path.realpath(dest) + assert os.path.isfile(dest) + assert os.path.dirname(dest) == os.path.join(td, "repository-server", "uploads") + + +@settings(max_examples=120, deadline=None) +@given( + prefix=st.text(max_size=80), + base=st.from_regex(r"[A-Za-z0-9._+\-]{1,40}", fullmatch=True), +) +def test_save_upload_rejects_path_traversal_prefix(prefix, base): + poison = prefix + "../" + base + with tempfile.TemporaryDirectory() as td: + mgr = RepositoryServerManager(td) + ok, err = mgr.save_upload(poison, b"x") + assert not ok diff --git a/tests/electron/safeExternalUrl.test.js b/tests/electron/safeExternalUrl.test.js index 0bb1ecd..9350357 100644 --- a/tests/electron/safeExternalUrl.test.js +++ b/tests/electron/safeExternalUrl.test.js @@ -41,4 +41,19 @@ describe("safeExternalUrl", () => { } } }); + + it("fuzz: full BMP code units do not throw and only yield safe schemes", () => { + for (let i = 0; i < 250; i++) { + let s = ""; + const len = Math.floor(Math.random() * 400); + for (let j = 0; j < len; j++) { + s += String.fromCharCode(Math.floor(Math.random() * 65536)); + } + expect(() => normalizeExternalUrlForOpen(s)).not.toThrow(); + const o = normalizeExternalUrlForOpen(s); + if (o !== null) { + expect(o.startsWith("http://") || o.startsWith("https://") || o.startsWith("mailto:")).toBe(true); + } + } + }); }); diff --git a/tests/electron/shellPathGuard.test.js b/tests/electron/shellPathGuard.test.js index 04220bc..97854bc 100644 --- a/tests/electron/shellPathGuard.test.js +++ b/tests/electron/shellPathGuard.test.js @@ -57,4 +57,27 @@ describe("shellPathGuard", () => { fs.unlinkSync(p); } }); + + it("fuzz: paths under default storage stay allowed", () => { + for (let i = 0; i < 200; i++) { + const seg = Array.from({ length: 10 }, () => String.fromCharCode(Math.floor(Math.random() * 94) + 33)).join( + "" + ); + const p = path.join(storage, "fuzz-shell", seg); + expect(() => isAllowedShellPath(p, ctx)).not.toThrow(); + expect(isAllowedShellPath(p, ctx)).toBe(true); + } + }); + + it("fuzz: random absolute-looking paths return boolean without throw", () => { + for (let i = 0; i < 200; i++) { + let s = process.platform === "win32" ? "C:\\" : "/"; + const n = Math.floor(Math.random() * 24) + 4; + for (let j = 0; j < n; j++) { + s += String.fromCharCode(Math.floor(Math.random() * 96) + 32); + } + expect(() => isAllowedShellPath(s, ctx)).not.toThrow(); + expect(typeof isAllowedShellPath(s, ctx)).toBe("boolean"); + } + }); }); diff --git a/tests/frontend/LinkUtils.test.js b/tests/frontend/LinkUtils.test.js index 792da3c..20a6821 100644 --- a/tests/frontend/LinkUtils.test.js +++ b/tests/frontend/LinkUtils.test.js @@ -178,4 +178,24 @@ describe("LinkUtils.js", () => { expect(LinkUtils.httpUrlHrefOrNull("file:///etc/passwd")).toBeNull(); }); }); + + describe("fuzzing robustness", () => { + it("httpUrlHrefOrNull and renderAllLinks tolerate random BMP strings", () => { + for (let i = 0; i < 300; 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(() => LinkUtils.httpUrlHrefOrNull(s)).not.toThrow(); + expect(() => LinkUtils.renderAllLinks(s)).not.toThrow(); + const h = LinkUtils.httpUrlHrefOrNull(s); + if (h !== null) { + expect(h.startsWith("http://") || h.startsWith("https://")).toBe(true); + } + const rendered = LinkUtils.renderAllLinks(s); + expect(rendered.toLowerCase()).not.toContain("