Merge pull request #716 from PR0M3TH3AN/codex/normalize-and-validate-encryption-paths

Validate encryption paths to block traversal
This commit is contained in:
thePR0M3TH3AN
2025-08-03 07:56:35 -04:00
committed by GitHub
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")
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)}

View File

@@ -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

View File

@@ -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

View File

@@ -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"))