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():
|
||||
import os
|
||||
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 {
|
||||
"url": settings.get("carddav_url", os.environ.get("CARDDAV_URL", "")),
|
||||
"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:
|
||||
raise HTTPException(400, str(e))
|
||||
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)
|
||||
# Force re-fetch
|
||||
_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