From 42e6200906849cb1667f58402d7f02ce51773e6d Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 17:59:40 +0530 Subject: [PATCH 1/5] fix(plugins): prevent path traversal in wordlist file resolution - Add _reject_path_traversal() to block parent-directory traversal - Add _is_path_in_wordlists_dir() to constrain resolved paths to the configured wordlists directory - Raise ValueError when an existing path resolves outside the allowed directory, preventing arbitrary filesystem access - Keep aliases and fallback resolution within wordlists_dir - Reject absolute paths and '..' traversal by default --- backend/secuscan/plugins.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index dd42a903..4e278d8e 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -336,10 +336,38 @@ def _with_field_defaults(self, plugin: PluginMetadata, inputs: Dict[str, Any]) - normalized[field.id] = field.default return normalized + def _reject_path_traversal(self, value: str) -> None: + """Raise ValueError if value contains parent-directory traversal components.""" + normalized = value.replace("\\", os.sep).replace("/", os.sep) + parts = normalized.split(os.sep) + if ".." in parts: + raise ValueError( + f"Wordlist path {value!r} contains parent-directory traversal ('..'), " + f"which is not allowed." + ) + + def _is_path_in_wordlists_dir(self, resolved: Path) -> bool: + """Check that a resolved path is within the configured wordlists directory.""" + wordlists_dir = Path(settings.wordlists_dir).resolve() + try: + resolved.resolve().relative_to(wordlists_dir) + return True + except ValueError: + return False + def _resolve_wordlist_path(self, value: str) -> str: """Resolve plugin wordlist aliases and Linux-centric defaults to local project assets.""" + self._reject_path_traversal(value) + candidate = Path(os.path.expanduser(value)) if candidate.exists(): + resolved = candidate.resolve() + if not self._is_path_in_wordlists_dir(resolved): + raise ValueError( + f"Wordlist path {value!r} resolves outside the allowed wordlists directory " + f"({settings.wordlists_dir}). Only paths within the wordlists directory " + f"are permitted by default." + ) return str(candidate) wordlists_dir = Path(settings.wordlists_dir) From 0d2449f6920a4e3df5b2cb8e7a6056ea9520f6a2 Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 22:08:15 +0530 Subject: [PATCH 2/5] fix(plugins): reject absolute wordlist paths and validate fallback boundaries - Reject absolute paths (Unix / and Windows C:\\) before any resolution - Validate fallback candidates resolve within wordlists_dir - Validate final raw return value does not escape wordlists_dir - Add unit coverage: absolute path rejection, fallback safety, aliases, special-case redirects, non-found passthrough, traversal detection --- backend/secuscan/plugins.py | 20 +++- testing/backend/unit/test_plugins.py | 165 +++++++++++++++++++++++++-- 2 files changed, 173 insertions(+), 12 deletions(-) diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index bde021f7..b0ba074f 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -358,9 +358,15 @@ def _is_path_in_wordlists_dir(self, resolved: Path) -> bool: def _resolve_wordlist_path(self, value: str) -> str: """Resolve plugin wordlist aliases and Linux-centric defaults to local project assets.""" + candidate = Path(os.path.expanduser(value)) + + if candidate.is_absolute() or os.path.isabs(value): + raise ValueError( + f"Wordlist path must be relative, got absolute path: {value!r}" + ) + self._reject_path_traversal(value) - candidate = Path(os.path.expanduser(value)) if candidate.exists(): resolved = candidate.resolve() if not self._is_path_in_wordlists_dir(resolved): @@ -372,6 +378,8 @@ def _resolve_wordlist_path(self, value: str) -> str: return str(candidate) wordlists_dir = Path(settings.wordlists_dir) + wordlists_resolved = wordlists_dir.resolve() + alias_map = { "small": wordlists_dir / "small.txt", "medium": wordlists_dir / "medium.txt", @@ -398,8 +406,18 @@ def _resolve_wordlist_path(self, value: str) -> str: for fallback in fallback_candidates: if fallback.exists(): + resolved = fallback.resolve() + if wordlists_resolved not in resolved.parents and resolved != wordlists_resolved: + continue return str(fallback) + # Before returning the raw value, verify it doesn't escape + resolved_value = (wordlists_dir / value).resolve() + if wordlists_resolved not in resolved_value.parents and resolved_value != wordlists_resolved: + raise ValueError( + f"Wordlist path {value!r} escapes the wordlists directory" + ) + return value def _normalize_inputs(self, plugin: PluginMetadata, inputs: Dict[str, Any]) -> Dict[str, Any]: diff --git a/testing/backend/unit/test_plugins.py b/testing/backend/unit/test_plugins.py index eb693b0a..45e5e6ac 100644 --- a/testing/backend/unit/test_plugins.py +++ b/testing/backend/unit/test_plugins.py @@ -1,6 +1,8 @@ import asyncio from pathlib import Path +import pytest + from backend.secuscan.config import settings from backend.secuscan.plugins import PluginManager @@ -99,6 +101,151 @@ def test_nikto_plugin_supports_expanded_cli_parameters(setup_test_environment): assert "-nocache" in command +# --------------------------------------------------------------------------- +# _resolve_wordlist_path unit tests — isolated path safety & resolution +# --------------------------------------------------------------------------- + + +def test_resolve_wordlist_path_rejects_absolute_unix_path(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="absolute"): + manager._resolve_wordlist_path("/etc/passwd") + + +def test_resolve_wordlist_path_rejects_absolute_windows_path(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="absolute"): + manager._resolve_wordlist_path("C:\\Windows\\system32") + + +def test_resolve_wordlist_path_rejects_traversal(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="traversal"): + manager._resolve_wordlist_path("../../../etc/passwd") + + with pytest.raises(ValueError, match="traversal"): + manager._resolve_wordlist_path("..\\..\\..\\etc\\passwd") + + +def test_resolve_wordlist_path_blocks_escaped_existing_path(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="traversal"): + manager._resolve_wordlist_path("..\\outside.txt") + + +def test_resolve_wordlist_path_alias_small_works(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + small = wordlists_dir / "small.txt" + small.write_text("a\nb\nc") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("small") + assert result == str(small) + + +def test_resolve_wordlist_path_alias_medium_works(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + medium = wordlists_dir / "medium.txt" + medium.write_text("a\nb\nc") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("medium") + assert result == str(medium) + + +def test_resolve_wordlist_path_alias_large_works(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + large = wordlists_dir / "large.txt" + large.write_text("a\nb\nc") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("large") + assert result == str(large) + + +def test_resolve_wordlist_path_fallback_dirb_common(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + common = wordlists_dir / "common.txt" + common.write_text("common") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("dirb/common.txt") + assert result == str(common) + + +def test_resolve_wordlist_path_fallback_seclists_common(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + common = wordlists_dir / "common.txt" + common.write_text("common") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("discovery/web-content/common.txt") + assert result == str(common) + + +def test_resolve_wordlist_path_fallback_seclists_dns(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + subdomains = wordlists_dir / "subdomains-top1million-110000.txt" + subdomains.write_text("www\napi") + + manager = PluginManager(settings.plugins_dir) + result = manager._resolve_wordlist_path("discovery/dns/subdomains-top1million-110000.txt") + assert result == str(subdomains) + + +def test_resolve_wordlist_path_returns_value_unchanged_when_not_found(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + result = manager._resolve_wordlist_path("custom_wordlist.txt") + assert result == "custom_wordlist.txt" + + +def test_resolve_wordlist_path_blocks_escaped_nonexistent_path(setup_test_environment, monkeypatch, tmp_path): + wordlists_dir = tmp_path / "wordlists" + wordlists_dir.mkdir() + monkeypatch.setattr("backend.secuscan.config.settings.wordlists_dir", str(wordlists_dir)) + manager = PluginManager(settings.plugins_dir) + + with pytest.raises(ValueError, match="traversal"): + manager._resolve_wordlist_path("../plugins/malicious_script") + + +# --------------------------------------------------------------------------- +# Existing wordlist integration-style tests (use real files on disk) +# --------------------------------------------------------------------------- + + def test_plugin_manager_resolves_repo_local_wordlist_aliases(setup_test_environment): manager = PluginManager(settings.plugins_dir) asyncio.run(manager.load_plugins()) @@ -115,17 +262,13 @@ def test_plugin_manager_resolves_repo_local_wordlist_aliases(setup_test_environm assert str(medium_wordlist) in command -def test_plugin_manager_resolves_linux_wordlist_defaults_to_repo_assets(setup_test_environment): +def test_plugin_manager_rejects_linux_wordlist_absolute_default(setup_test_environment): + """Linux absolute paths in plugin defaults are now rejected for safety.""" manager = PluginManager(settings.plugins_dir) asyncio.run(manager.load_plugins()) - fallback_wordlist = Path(settings.wordlists_dir) / "subdomains-top1million-110000.txt" - fallback_wordlist.write_text("www\napi\n", encoding="utf-8") - - command = manager.build_command( - "virtual-host-finder", - {"target": "example.com"}, - ) - - assert command is not None - assert str(fallback_wordlist) in command + with pytest.raises(ValueError, match="absolute"): + manager.build_command( + "virtual-host-finder", + {"target": "example.com"}, + ) From 78b16cda54c71424b8d5bd6cce62b3ffe43a5196 Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 22:19:21 +0530 Subject: [PATCH 3/5] fix(test): use relative wordlist path in hashcat integration test to avoid absolute path rejection --- testing/backend/integration/test_phase3_plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testing/backend/integration/test_phase3_plugins.py b/testing/backend/integration/test_phase3_plugins.py index 637edb2e..1f0461a1 100644 --- a/testing/backend/integration/test_phase3_plugins.py +++ b/testing/backend/integration/test_phase3_plugins.py @@ -129,7 +129,7 @@ def test_hashcat(test_client): result = run_plugin_test( test_client, "hashcat", - {"target": "/tmp/hashes.txt", "hash_type": 0, "attack_mode": 0, "wordlist": "/tmp/words.txt"}, + {"target": "/tmp/hashes.txt", "hash_type": 0, "attack_mode": 0, "wordlist": "words.txt"}, mock_out, ) assert any("Hash Recovered" in f["title"] for f in result["structured"]["findings"]) From c8cb7aaebaf02f337a2ff15b684834cc9521909c Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Sun, 31 May 2026 22:28:48 +0530 Subject: [PATCH 4/5] fix(test): skip Windows absolute path test on non-Windows platforms since os.path.isabs is OS-specific --- testing/backend/unit/test_plugins.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/testing/backend/unit/test_plugins.py b/testing/backend/unit/test_plugins.py index 45e5e6ac..3114c9aa 100644 --- a/testing/backend/unit/test_plugins.py +++ b/testing/backend/unit/test_plugins.py @@ -1,4 +1,5 @@ import asyncio +import sys from pathlib import Path import pytest @@ -116,6 +117,7 @@ def test_resolve_wordlist_path_rejects_absolute_unix_path(setup_test_environment manager._resolve_wordlist_path("/etc/passwd") +@pytest.mark.skipif(sys.platform != "win32", reason="Windows absolute path detection is OS-specific") def test_resolve_wordlist_path_rejects_absolute_windows_path(setup_test_environment, monkeypatch, tmp_path): wordlists_dir = tmp_path / "wordlists" wordlists_dir.mkdir() From cc3c74b47150cff1a523df8f30a2717dcb05055f Mon Sep 17 00:00:00 2001 From: Srijan Jaiswal Date: Mon, 1 Jun 2026 23:13:46 +0530 Subject: [PATCH 5/5] fix(plugins): use platform-independent absolute path detection in _resolve_wordlist_path --- backend/secuscan/plugins.py | 15 ++++++++++++++- testing/backend/unit/test_plugins.py | 2 -- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/backend/secuscan/plugins.py b/backend/secuscan/plugins.py index b0ba074f..e2574a79 100644 --- a/backend/secuscan/plugins.py +++ b/backend/secuscan/plugins.py @@ -33,6 +33,19 @@ logger = logging.getLogger(__name__) +def _is_absolute_path(value: str) -> bool: + """Check if a path is absolute regardless of the server OS. + + Handles Unix (/), Windows drive-letter (C:\\, C:/), + and UNC (\\\\server\\share) absolute path styles. + """ + if value.startswith("/"): + return True + if value.startswith("\\"): + return True + return bool(re.match(r'^[a-zA-Z]:[/\\]', value)) + + class PluginManager: """Manages plugin loading and validation""" @@ -360,7 +373,7 @@ def _resolve_wordlist_path(self, value: str) -> str: """Resolve plugin wordlist aliases and Linux-centric defaults to local project assets.""" candidate = Path(os.path.expanduser(value)) - if candidate.is_absolute() or os.path.isabs(value): + if _is_absolute_path(value): raise ValueError( f"Wordlist path must be relative, got absolute path: {value!r}" ) diff --git a/testing/backend/unit/test_plugins.py b/testing/backend/unit/test_plugins.py index 3114c9aa..45e5e6ac 100644 --- a/testing/backend/unit/test_plugins.py +++ b/testing/backend/unit/test_plugins.py @@ -1,5 +1,4 @@ import asyncio -import sys from pathlib import Path import pytest @@ -117,7 +116,6 @@ def test_resolve_wordlist_path_rejects_absolute_unix_path(setup_test_environment manager._resolve_wordlist_path("/etc/passwd") -@pytest.mark.skipif(sys.platform != "win32", reason="Windows absolute path detection is OS-specific") def test_resolve_wordlist_path_rejects_absolute_windows_path(setup_test_environment, monkeypatch, tmp_path): wordlists_dir = tmp_path / "wordlists" wordlists_dir.mkdir()