mirror of
https://github.com/PR0M3TH3AN/SeedPass.git
synced 2025-09-08 07:18:47 +00:00
Validate encryption paths and block traversal
This commit is contained in:
@@ -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)}
|
||||
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -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
|
||||
|
@@ -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"))
|
||||
|
Reference in New Issue
Block a user