diff --git a/src/seedpass/api.py b/src/seedpass/api.py index c6ec60e..fc58d2c 100644 --- a/src/seedpass/api.py +++ b/src/seedpass/api.py @@ -92,6 +92,15 @@ def _require_password(password: str | None) -> None: raise HTTPException(status_code=401, detail="Invalid password") +def _validate_encryption_path(path: Path) -> None: + """Validate that ``path`` stays within the active fingerprint directory.""" + assert _pm is not None + try: + _pm.encryption_manager.resolve_relative_path(path) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + @app.get("/api/v1/entry") def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]: _check_token(authorization) @@ -578,6 +587,7 @@ def backup_parent_seed( if not path_str: raise HTTPException(status_code=400, detail="Missing path") path = Path(path_str) + _validate_encryption_path(path) _pm.encryption_manager.encrypt_and_save_file(_pm.parent_seed.encode("utf-8"), path) return {"status": "saved", "path": str(path)} diff --git a/src/seedpass/core/encryption.py b/src/seedpass/core/encryption.py index af17586..8f612a7 100644 --- a/src/seedpass/core/encryption.py +++ b/src/seedpass/core/encryption.py @@ -120,6 +120,30 @@ class EncryptionManager: # --- All functions below this point now use the smart `decrypt_data` method --- + def resolve_relative_path(self, relative_path: Path) -> Path: + """Resolve ``relative_path`` within ``fingerprint_dir`` and validate it. + + Parameters + ---------- + relative_path: + The user-supplied path relative to ``fingerprint_dir``. + + Returns + ------- + Path + The normalized absolute path inside ``fingerprint_dir``. + + Raises + ------ + ValueError + If the resulting path is absolute or escapes ``fingerprint_dir``. + """ + + candidate = (self.fingerprint_dir / relative_path).resolve() + if not candidate.is_relative_to(self.fingerprint_dir.resolve()): + raise ValueError("Invalid path outside fingerprint directory") + return candidate + def encrypt_parent_seed(self, parent_seed: str) -> None: """Encrypts and saves the parent seed to 'parent_seed.enc'.""" data = parent_seed.encode("utf-8") @@ -147,7 +171,7 @@ class EncryptionManager: return decrypted_data.decode("utf-8").strip() def encrypt_and_save_file(self, data: bytes, relative_path: Path) -> None: - file_path = self.fingerprint_dir / relative_path + file_path = self.resolve_relative_path(relative_path) file_path.parent.mkdir(parents=True, exist_ok=True) encrypted_data = self.encrypt_data(data) with exclusive_lock(file_path) as fh: @@ -159,7 +183,7 @@ class EncryptionManager: os.chmod(file_path, 0o600) def decrypt_file(self, relative_path: Path) -> bytes: - file_path = self.fingerprint_dir / relative_path + file_path = self.resolve_relative_path(relative_path) with exclusive_lock(file_path) as fh: fh.seek(0) encrypted_data = fh.read() @@ -182,8 +206,7 @@ class EncryptionManager: """ if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") - - file_path = self.fingerprint_dir / relative_path + file_path = self.resolve_relative_path(relative_path) if not file_path.exists(): return {"entries": {}} @@ -216,7 +239,7 @@ class EncryptionManager: def get_encrypted_index(self) -> Optional[bytes]: relative_path = Path("seedpass_entries_db.json.enc") - file_path = self.fingerprint_dir / relative_path + file_path = self.resolve_relative_path(relative_path) if not file_path.exists(): return None with exclusive_lock(file_path) as fh: @@ -251,7 +274,8 @@ class EncryptionManager: data = json_lib.loads(decrypted_data) else: data = json_lib.loads(decrypted_data.decode("utf-8")) - if merge and (self.fingerprint_dir / relative_path).exists(): + existing_file = self.resolve_relative_path(relative_path) + if merge and existing_file.exists(): current = self.load_json_data(relative_path) current_entries = current.get("entries", {}) for idx, entry in data.get("entries", {}).items(): @@ -290,8 +314,7 @@ class EncryptionManager: """Updates the checksum file for the specified file.""" if relative_path is None: relative_path = Path("seedpass_entries_db.json.enc") - - file_path = self.fingerprint_dir / relative_path + file_path = self.resolve_relative_path(relative_path) if not file_path.exists(): return diff --git a/src/tests/test_api_new_endpoints.py b/src/tests/test_api_new_endpoints.py index 9d8d448..8bb6232 100644 --- a/src/tests/test_api_new_endpoints.py +++ b/src/tests/test_api_new_endpoints.py @@ -1,5 +1,7 @@ from types import SimpleNamespace from pathlib import Path +import os +import base64 import pytest from seedpass import api @@ -7,6 +9,7 @@ from test_api import client from helpers import dummy_nostr_client import string from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy +from seedpass.core.encryption import EncryptionManager from nostr.client import NostrClient, DEFAULT_RELAYS @@ -333,9 +336,10 @@ def test_backup_parent_seed_endpoint(client, tmp_path): api._pm.parent_seed = "seed" called = {} api._pm.encryption_manager = SimpleNamespace( - encrypt_and_save_file=lambda data, path: called.setdefault("path", path) + encrypt_and_save_file=lambda data, path: called.setdefault("path", path), + resolve_relative_path=lambda p: p, ) - path = tmp_path / "seed.enc" + path = Path("seed.enc") headers = { "Authorization": f"Bearer {token}", "X-SeedPass-Password": "pw", @@ -357,6 +361,23 @@ def test_backup_parent_seed_endpoint(client, tmp_path): assert res.status_code == 400 +def test_backup_parent_seed_path_traversal_blocked(client, tmp_path): + cl, token = client + api._pm.parent_seed = "seed" + key = base64.urlsafe_b64encode(os.urandom(32)) + api._pm.encryption_manager = EncryptionManager(key, tmp_path) + headers = { + "Authorization": f"Bearer {token}", + "X-SeedPass-Password": "pw", + } + res = cl.post( + "/api/v1/vault/backup-parent-seed", + json={"path": "../evil.enc", "confirm": True}, + headers=headers, + ) + assert res.status_code == 400 + + def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch): cl, token = client nostr_client, _ = dummy_nostr_client diff --git a/src/tests/test_encryption_files.py b/src/tests/test_encryption_files.py index 04fb511..d374cff 100644 --- a/src/tests/test_encryption_files.py +++ b/src/tests/test_encryption_files.py @@ -5,6 +5,7 @@ from tempfile import TemporaryDirectory import os import base64 +import pytest sys.path.append(str(Path(__file__).resolve().parents[1])) @@ -40,3 +41,15 @@ def test_encrypt_and_decrypt_file_binary_round_trip(): file_path = Path(tmpdir) / rel raw = file_path.read_bytes() assert raw != payload + + +def test_encrypt_file_rejects_traversal(): + with TemporaryDirectory() as tmpdir: + key = base64.urlsafe_b64encode(os.urandom(32)) + manager = EncryptionManager(key, Path(tmpdir)) + + with pytest.raises(ValueError): + manager.encrypt_and_save_file(b"data", Path("../evil.enc")) + + with pytest.raises(ValueError): + manager.encrypt_and_save_file(b"data", Path("/abs.enc"))