diff --git a/main.py b/main.py index d2ad52b..808fa3d 100644 --- a/main.py +++ b/main.py @@ -145,11 +145,29 @@ def save_config(config: dict): json.dump(config, f, indent=2) +def wordlist_path(name: str) -> Path: + valid_characters = string.ascii_letters + string.digits + "_-" + if ( + not isinstance(name, str) + or not 1 <= len(name) <= 64 + or any(character not in valid_characters for character in name) + ): + raise ValueError( + "Invalid wordlist name: use 1-64 ASCII letters, numbers, hyphens, or underscores" + ) + if WORDLIST_DIR.is_symlink(): + raise ValueError("Wordlist directory must not be a symlink") + path = WORDLIST_DIR / f"{name}.txt" + if path.resolve(strict=False).parent != WORDLIST_DIR.resolve(): + raise ValueError("Wordlist path resolves outside the wordlist directory") + return path + + def load_custom_wordlist(name: str) -> list[str]: - wordlist_path = WORDLIST_DIR / f"{name}.txt" - if not wordlist_path.exists(): + path = wordlist_path(name) + if not path.exists(): raise ValueError(f"Wordlist '{name}' not found") - with open(wordlist_path) as f: + with open(path) as f: words = [w.strip().lower() for w in f if w.strip()] if not words: raise ValueError(f"Wordlist '{name}' is empty") @@ -157,9 +175,9 @@ def load_custom_wordlist(name: str) -> list[str]: def save_wordlist(name: str, words: list[str]): + path = wordlist_path(name) ensure_config_dir() - wordlist_path = WORDLIST_DIR / f"{name}.txt" - with open(wordlist_path, "w") as f: + with open(path, "w") as f: f.write("\n".join(words)) diff --git a/tests/test_main.py b/tests/test_main.py index 5917610..12b2e4e 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -148,10 +148,9 @@ def test_detect_keyboard_sequence(self): warnings = check_password_patterns("qwe") assert any("Keyboard" in w for w in warnings) - def test_random_password_no_warnings(self): - pwd, _ = generate_password(20, True, True, True, True) - warnings = check_password_patterns(pwd) - assert len(warnings) == 0 + def test_password_without_patterns_has_no_warnings(self): + warnings = check_password_patterns("gT7!pL2@vN9#rK4$") + assert warnings == [] class TestSecureStorage: @@ -280,6 +279,68 @@ def record_close(descriptor): assert len(closed_descriptors) == 1 +class TestWordlistPaths: + @staticmethod + def configure_paths(monkeypatch, tmp_path): + config_dir = tmp_path / ".passgen" + monkeypatch.setattr(main_module, "CONFIG_DIR", config_dir) + monkeypatch.setattr(main_module, "WORDLIST_DIR", config_dir / "wordlists") + + def test_save_wordlist_rejects_path_traversal(self, monkeypatch, tmp_path): + self.configure_paths(monkeypatch, tmp_path) + outside_path = tmp_path / "outside.txt" + + with pytest.raises(ValueError, match="Invalid wordlist name"): + main_module.save_wordlist("../outside", ["secret"]) + + assert not outside_path.exists() + + def test_load_wordlist_rejects_symlink_escape(self, monkeypatch, tmp_path): + self.configure_paths(monkeypatch, tmp_path) + main_module.WORDLIST_DIR.mkdir(parents=True) + outside_path = tmp_path / "outside.txt" + outside_path.write_text("secret\n") + (main_module.WORDLIST_DIR / "safe.txt").symlink_to(outside_path) + + with pytest.raises(ValueError, match="outside the wordlist directory"): + main_module.load_custom_wordlist("safe") + + def test_valid_wordlist_round_trip(self, monkeypatch, tmp_path): + self.configure_paths(monkeypatch, tmp_path) + + main_module.save_wordlist("dice-words_2026", ["Alpha", "Beta"]) + + assert main_module.load_custom_wordlist("dice-words_2026") == ["alpha", "beta"] + + @pytest.mark.parametrize("name", ["a", "_words", "-words", "a" * 64]) + def test_valid_name_boundaries(self, monkeypatch, tmp_path, name): + self.configure_paths(monkeypatch, tmp_path) + + main_module.save_wordlist(name, ["Alpha"]) + + assert main_module.load_custom_wordlist(name) == ["alpha"] + + @pytest.mark.parametrize("name", ["", "a" * 65, "two words", "café", "a.b", "a/b"]) + def test_invalid_names_are_rejected(self, monkeypatch, tmp_path, name): + self.configure_paths(monkeypatch, tmp_path) + + with pytest.raises(ValueError, match="Invalid wordlist name"): + main_module.save_wordlist(name, ["Alpha"]) + + def test_symlinked_wordlist_directory_is_rejected(self, monkeypatch, tmp_path): + self.configure_paths(monkeypatch, tmp_path) + outside_dir = tmp_path / "outside" + outside_dir.mkdir() + main_module.CONFIG_DIR.mkdir() + main_module.WORDLIST_DIR.symlink_to(outside_dir, target_is_directory=True) + + with pytest.raises(ValueError, match="must not be a symlink"): + main_module.save_wordlist("safe", ["secret"]) + + assert not (outside_dir / "safe.txt").exists() + + + class TestConfigManagement: def test_save_and_load_config(self, tmp_path): config = {"length": 24, "upper": True, "lower": False} @@ -305,4 +366,4 @@ def test_wordlist_all_lowercase(self): if __name__ == "__main__": - pytest.main([__file__, "-v"]) \ No newline at end of file + pytest.main([__file__, "-v"])