mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-15 17:25:26 -04:00
fix(security): encrypt CardDAV password at rest in settings.json (#1741)
* fix(security): encrypt CardDAV password at rest in settings.json CardDAV password was stored in plaintext in data/settings.json, while other secrets (email, CalDAV) are encrypted using src.secret_storage. On read (_get_carddav_config): decrypt the password via decrypt(). On write (update_config): encrypt the password via encrypt() before saving to settings.json. decrypt() is a no-op on plaintext, so existing deployments upgrade transparently on the first read after the next config save. * test: add coverage for CardDAV password encryption Nine tests covering: - encrypt-on-save and decrypt-on-read round-trip - encrypted value is stored with enc: prefix (plaintext absent from file) - legacy plaintext passthrough - CARDDAV_PASSWORD env var passthrough (not decrypted) - empty password / no settings file - double-save does not corrupt - encrypt() idempotent on already-encrypted value
This commit is contained in:
@@ -45,10 +45,14 @@ def _save_settings(settings):
|
|||||||
def _get_carddav_config():
|
def _get_carddav_config():
|
||||||
import os
|
import os
|
||||||
settings = _load_settings()
|
settings = _load_settings()
|
||||||
|
password = settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", ""))
|
||||||
|
if password and "carddav_password" in settings:
|
||||||
|
from src.secret_storage import decrypt
|
||||||
|
password = decrypt(password)
|
||||||
return {
|
return {
|
||||||
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
|
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
|
||||||
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
|
"username": settings.get("carddav_username", os.environ.get("CARDDAV_USERNAME", "")),
|
||||||
"password": settings.get("carddav_password", os.environ.get("CARDDAV_PASSWORD", "")),
|
"password": password,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -785,7 +789,11 @@ def setup_contacts_routes():
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
raise HTTPException(400, str(e))
|
raise HTTPException(400, str(e))
|
||||||
else:
|
else:
|
||||||
settings[key] = data[key]
|
value = data[key]
|
||||||
|
if key == "carddav_password" and value:
|
||||||
|
from src.secret_storage import encrypt
|
||||||
|
value = encrypt(value)
|
||||||
|
settings[key] = value
|
||||||
_save_settings(settings)
|
_save_settings(settings)
|
||||||
# Force re-fetch
|
# Force re-fetch
|
||||||
_contact_cache["fetched_at"] = None
|
_contact_cache["fetched_at"] = None
|
||||||
|
|||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
def _import_contacts(tmp_path, monkeypatch):
|
||||||
|
sys.modules.setdefault("core.database", MagicMock())
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routes.contacts_routes.SETTINGS_FILE",
|
||||||
|
tmp_path / "settings.json",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routes.contacts_routes.DATA_DIR",
|
||||||
|
tmp_path,
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"routes.contacts_routes.LOCAL_CONTACTS_FILE",
|
||||||
|
tmp_path / "contacts.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
sys.modules.pop("src.secret_storage", None)
|
||||||
|
from src import secret_storage
|
||||||
|
monkeypatch.setattr(secret_storage, "_KEY_PATH", tmp_path / ".app_key")
|
||||||
|
monkeypatch.setattr(secret_storage, "_fernet", None)
|
||||||
|
|
||||||
|
sys.modules.pop("routes.contacts_routes", None)
|
||||||
|
from routes import contacts_routes
|
||||||
|
return contacts_routes
|
||||||
|
|
||||||
|
|
||||||
|
def test_carddav_password_encrypted_at_rest(tmp_path, monkeypatch):
|
||||||
|
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
settings = contacts._load_settings()
|
||||||
|
password = "my-carddav-secret"
|
||||||
|
from src.secret_storage import encrypt
|
||||||
|
settings["carddav_password"] = encrypt(password)
|
||||||
|
contacts._save_settings(settings)
|
||||||
|
|
||||||
|
raw_text = (tmp_path / "settings.json").read_text(encoding="utf-8")
|
||||||
|
assert password not in raw_text
|
||||||
|
raw = json.loads(raw_text)
|
||||||
|
assert raw["carddav_password"].startswith("enc:")
|
||||||
|
|
||||||
|
cfg = contacts._get_carddav_config()
|
||||||
|
assert cfg["password"] == password
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_carddav_config_decrypts_encrypted_value(tmp_path, monkeypatch):
|
||||||
|
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
from src.secret_storage import encrypt
|
||||||
|
encrypted = encrypt("super-secret")
|
||||||
|
settings = {
|
||||||
|
"carddav_url": "https://carddav.example",
|
||||||
|
"carddav_username": "u",
|
||||||
|
"carddav_password": encrypted,
|
||||||
|
}
|
||||||
|
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||||
|
|
||||||
|
cfg = contacts._get_carddav_config()
|
||||||
|
assert cfg["url"] == "https://carddav.example"
|
||||||
|
assert cfg["username"] == "u"
|
||||||
|
assert cfg["password"] == "super-secret"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_carddav_config_plaintext_legacy_passthrough(tmp_path, monkeypatch):
|
||||||
|
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
"carddav_url": "https://carddav.example",
|
||||||
|
"carddav_username": "u",
|
||||||
|
"carddav_password": "legacy-plaintext",
|
||||||
|
}
|
||||||
|
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||||
|
|
||||||
|
cfg = contacts._get_carddav_config()
|
||||||
|
assert cfg["password"] == "legacy-plaintext"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_carddav_config_env_var_passthrough(tmp_path, monkeypatch):
|
||||||
|
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||||
|
monkeypatch.setenv("CARDDAV_PASSWORD", "env-pass")
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
"carddav_url": "https://carddav.example",
|
||||||
|
"carddav_username": "u",
|
||||||
|
}
|
||||||
|
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||||
|
|
||||||
|
cfg = contacts._get_carddav_config()
|
||||||
|
assert cfg["password"] == "env-pass"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_carddav_config_env_var_not_decrypted(tmp_path, monkeypatch):
|
||||||
|
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
monkeypatch.setenv("CARDDAV_PASSWORD", "env:plain-value-not-encrypted")
|
||||||
|
settings = {
|
||||||
|
"carddav_url": "https://carddav.example",
|
||||||
|
"carddav_username": "u",
|
||||||
|
}
|
||||||
|
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||||
|
|
||||||
|
cfg = contacts._get_carddav_config()
|
||||||
|
assert cfg["password"] == "env:plain-value-not-encrypted"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_carddav_config_empty_password(tmp_path, monkeypatch):
|
||||||
|
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
"carddav_url": "https://carddav.example",
|
||||||
|
"carddav_username": "u",
|
||||||
|
}
|
||||||
|
(tmp_path / "settings.json").write_text(json.dumps(settings), encoding="utf-8")
|
||||||
|
|
||||||
|
cfg = contacts._get_carddav_config()
|
||||||
|
assert cfg["password"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_carddav_config_no_settings_file(tmp_path, monkeypatch):
|
||||||
|
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
cfg = contacts._get_carddav_config()
|
||||||
|
assert cfg["password"] == ""
|
||||||
|
assert cfg["url"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_save_encrypted_value_not_corrupted(tmp_path, monkeypatch):
|
||||||
|
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
from src.secret_storage import encrypt
|
||||||
|
password = "persistent-secret"
|
||||||
|
encrypted = encrypt(password)
|
||||||
|
|
||||||
|
settings = {"carddav_password": encrypted}
|
||||||
|
contacts._save_settings(settings)
|
||||||
|
|
||||||
|
settings2 = contacts._load_settings()
|
||||||
|
contacts._save_settings(settings2)
|
||||||
|
|
||||||
|
cfg = contacts._get_carddav_config()
|
||||||
|
assert cfg["password"] == password
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_save_re_encrypts_already_encrypted_is_noop(tmp_path, monkeypatch):
|
||||||
|
contacts = _import_contacts(tmp_path, monkeypatch)
|
||||||
|
|
||||||
|
from src.secret_storage import encrypt
|
||||||
|
password = "another-secret"
|
||||||
|
|
||||||
|
settings = contacts._load_settings()
|
||||||
|
settings["carddav_password"] = encrypt(password)
|
||||||
|
contacts._save_settings(settings)
|
||||||
|
|
||||||
|
settings2 = contacts._load_settings()
|
||||||
|
settings2["carddav_password"] = encrypt(settings2["carddav_password"])
|
||||||
|
contacts._save_settings(settings2)
|
||||||
|
|
||||||
|
raw = json.loads((tmp_path / "settings.json").read_text(encoding="utf-8"))
|
||||||
|
assert raw["carddav_password"].startswith("enc:")
|
||||||
|
|
||||||
|
cfg = contacts._get_carddav_config()
|
||||||
|
assert cfg["password"] == password
|
||||||
Reference in New Issue
Block a user