Validate encryption paths and block traversal

This commit is contained in:
thePR0M3TH3AN
2025-08-03 07:54:07 -04:00
parent 906e3921a2
commit f1bf65385c
4 changed files with 77 additions and 10 deletions

View File

@@ -92,6 +92,15 @@ def _require_password(password: str | None) -> None:
raise HTTPException(status_code=401, detail="Invalid password") 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") @app.get("/api/v1/entry")
def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]: def search_entry(query: str, authorization: str | None = Header(None)) -> List[Any]:
_check_token(authorization) _check_token(authorization)
@@ -578,6 +587,7 @@ def backup_parent_seed(
if not path_str: if not path_str:
raise HTTPException(status_code=400, detail="Missing path") raise HTTPException(status_code=400, detail="Missing path")
path = Path(path_str) path = Path(path_str)
_validate_encryption_path(path)
_pm.encryption_manager.encrypt_and_save_file(_pm.parent_seed.encode("utf-8"), path) _pm.encryption_manager.encrypt_and_save_file(_pm.parent_seed.encode("utf-8"), path)
return {"status": "saved", "path": str(path)} return {"status": "saved", "path": str(path)}

View File

@@ -120,6 +120,30 @@ class EncryptionManager:
# --- All functions below this point now use the smart `decrypt_data` method --- # --- 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: def encrypt_parent_seed(self, parent_seed: str) -> None:
"""Encrypts and saves the parent seed to 'parent_seed.enc'.""" """Encrypts and saves the parent seed to 'parent_seed.enc'."""
data = parent_seed.encode("utf-8") data = parent_seed.encode("utf-8")
@@ -147,7 +171,7 @@ class EncryptionManager:
return decrypted_data.decode("utf-8").strip() return decrypted_data.decode("utf-8").strip()
def encrypt_and_save_file(self, data: bytes, relative_path: Path) -> None: 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) file_path.parent.mkdir(parents=True, exist_ok=True)
encrypted_data = self.encrypt_data(data) encrypted_data = self.encrypt_data(data)
with exclusive_lock(file_path) as fh: with exclusive_lock(file_path) as fh:
@@ -159,7 +183,7 @@ class EncryptionManager:
os.chmod(file_path, 0o600) os.chmod(file_path, 0o600)
def decrypt_file(self, relative_path: Path) -> bytes: 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: with exclusive_lock(file_path) as fh:
fh.seek(0) fh.seek(0)
encrypted_data = fh.read() encrypted_data = fh.read()
@@ -182,8 +206,7 @@ class EncryptionManager:
""" """
if relative_path is None: if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc") relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.resolve_relative_path(relative_path)
file_path = self.fingerprint_dir / relative_path
if not file_path.exists(): if not file_path.exists():
return {"entries": {}} return {"entries": {}}
@@ -216,7 +239,7 @@ class EncryptionManager:
def get_encrypted_index(self) -> Optional[bytes]: def get_encrypted_index(self) -> Optional[bytes]:
relative_path = Path("seedpass_entries_db.json.enc") 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(): if not file_path.exists():
return None return None
with exclusive_lock(file_path) as fh: with exclusive_lock(file_path) as fh:
@@ -251,7 +274,8 @@ class EncryptionManager:
data = json_lib.loads(decrypted_data) data = json_lib.loads(decrypted_data)
else: else:
data = json_lib.loads(decrypted_data.decode("utf-8")) 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 = self.load_json_data(relative_path)
current_entries = current.get("entries", {}) current_entries = current.get("entries", {})
for idx, entry in data.get("entries", {}).items(): for idx, entry in data.get("entries", {}).items():
@@ -290,8 +314,7 @@ class EncryptionManager:
"""Updates the checksum file for the specified file.""" """Updates the checksum file for the specified file."""
if relative_path is None: if relative_path is None:
relative_path = Path("seedpass_entries_db.json.enc") relative_path = Path("seedpass_entries_db.json.enc")
file_path = self.resolve_relative_path(relative_path)
file_path = self.fingerprint_dir / relative_path
if not file_path.exists(): if not file_path.exists():
return return

View File

@@ -1,5 +1,7 @@
from types import SimpleNamespace from types import SimpleNamespace
from pathlib import Path from pathlib import Path
import os
import base64
import pytest import pytest
from seedpass import api from seedpass import api
@@ -7,6 +9,7 @@ from test_api import client
from helpers import dummy_nostr_client from helpers import dummy_nostr_client
import string import string
from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy from seedpass.core.password_generation import PasswordGenerator, PasswordPolicy
from seedpass.core.encryption import EncryptionManager
from nostr.client import NostrClient, DEFAULT_RELAYS 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" api._pm.parent_seed = "seed"
called = {} called = {}
api._pm.encryption_manager = SimpleNamespace( 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 = { headers = {
"Authorization": f"Bearer {token}", "Authorization": f"Bearer {token}",
"X-SeedPass-Password": "pw", "X-SeedPass-Password": "pw",
@@ -357,6 +361,23 @@ def test_backup_parent_seed_endpoint(client, tmp_path):
assert res.status_code == 400 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): def test_relay_management_endpoints(client, dummy_nostr_client, monkeypatch):
cl, token = client cl, token = client
nostr_client, _ = dummy_nostr_client nostr_client, _ = dummy_nostr_client

View File

@@ -5,6 +5,7 @@ from tempfile import TemporaryDirectory
import os import os
import base64 import base64
import pytest
sys.path.append(str(Path(__file__).resolve().parents[1])) 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 file_path = Path(tmpdir) / rel
raw = file_path.read_bytes() raw = file_path.read_bytes()
assert raw != payload 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"))