Secure by default uplift (#511)
Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
This commit is contained in:
@@ -49,7 +49,9 @@ SEARXNG_INSTANCE=http://localhost:8080
|
||||
# Enable authentication (default: true)
|
||||
# AUTH_ENABLED=true
|
||||
|
||||
# Host port for the Odysseus web UI in Docker Compose.
|
||||
# Host bind address and port for the Odysseus web UI in Docker Compose.
|
||||
# Keep APP_BIND on loopback unless you intentionally want LAN/reverse-proxy access.
|
||||
# APP_BIND=127.0.0.1
|
||||
# Change this if another local service already uses 7000 (macOS AirPlay often does).
|
||||
# APP_PORT=7000
|
||||
|
||||
|
||||
21
README.md
21
README.md
@@ -44,7 +44,7 @@ A full, hover-to-play tour lives on the landing page (`docs/index.html`).
|
||||
|
||||
Defaults work out of the box: clone, run, then configure models/search/email
|
||||
inside **Settings**. Only edit `.env` for deployment-level overrides like
|
||||
`APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
|
||||
`APP_BIND`, `APP_PORT`, `AUTH_ENABLED`, `DATABASE_URL`, or a pre-seeded admin password.
|
||||
|
||||
On first setup, Odysseus creates an admin account (`admin` unless
|
||||
`ODYSSEUS_ADMIN_USER` is set) and prints a temporary password in the terminal.
|
||||
@@ -61,8 +61,10 @@ cd odysseus
|
||||
cp .env.example .env # optional, but recommended for explicit defaults
|
||||
docker compose up -d --build
|
||||
```
|
||||
Open `http://localhost:7000` when the containers are healthy. If the port is
|
||||
taken, set `APP_PORT=7001` in `.env` and recreate the container.
|
||||
Open `http://localhost:7000` when the containers are healthy. Docker Compose
|
||||
binds the web UI to `127.0.0.1` by default. If the port is taken, set
|
||||
`APP_PORT=7001` in `.env` and recreate the container. Set `APP_BIND=0.0.0.0`
|
||||
only when you intentionally want LAN/reverse-proxy access.
|
||||
|
||||
### Native Linux / macOS
|
||||
```bash
|
||||
@@ -72,10 +74,11 @@ python3 -m venv venv
|
||||
source venv/bin/activate
|
||||
pip install -r requirements.txt
|
||||
python setup.py
|
||||
python -m uvicorn app:app --host 0.0.0.0 --port 7000
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 7000
|
||||
```
|
||||
Requirements: Python 3.11+. Cookbook also needs `tmux` for background model
|
||||
downloads and serves.
|
||||
downloads and serves. Use `--host 0.0.0.0` only when you intentionally want
|
||||
LAN/reverse-proxy access.
|
||||
|
||||
### Apple Silicon
|
||||
Docker on macOS cannot use the Metal GPU. For GPU-accelerated Cookbook on an
|
||||
@@ -97,9 +100,9 @@ It launches at `http://127.0.0.1:7860`. To build a clickable app wrapper:
|
||||
<summary>Cookbook, GPU, Ollama, and troubleshooting notes</summary>
|
||||
|
||||
**Docker bundled services.** Compose starts Odysseus, ChromaDB, SearXNG, and
|
||||
ntfy. ChromaDB/SearXNG/ntfy bind host ports to `127.0.0.1` by default, so they
|
||||
are reachable from the host but not exposed to your LAN/public internet unless
|
||||
you opt in.
|
||||
ntfy. Odysseus and the bundled service ports bind to `127.0.0.1` by default, so
|
||||
they are reachable from the host but not exposed to your LAN/public internet
|
||||
unless you opt in.
|
||||
|
||||
**Cookbook storage in Docker.** Downloads live in `./data/huggingface`
|
||||
(`~/.cache/huggingface` in the container). Cookbook-installed Python CLIs and
|
||||
@@ -234,6 +237,8 @@ Key settings:
|
||||
| `OPENAI_API_KEY` | -- | Optional OpenAI key. Prefer adding providers in the app unless pre-seeding. |
|
||||
| `SEARXNG_INSTANCE` | `http://localhost:8080` | SearXNG URL. Docker overrides this to `http://searxng:8080`. |
|
||||
| `SEARXNG_SECRET` | generated on first Docker boot | Optional SearXNG cookie/CSRF secret. Leave blank unless you need to pin it. |
|
||||
| `APP_BIND` | `127.0.0.1` | Docker Compose host bind address for the web UI. Use `0.0.0.0` only for intentional LAN/reverse-proxy access. |
|
||||
| `APP_PORT` | `7000` | Docker Compose host port for the web UI. |
|
||||
| `AUTH_ENABLED` | `true` | Enable/disable login |
|
||||
| `LOCALHOST_BYPASS` | `false` | Development-only auth bypass for loopback requests. Keep false for shared/network deployments. |
|
||||
| `DATABASE_URL` | `sqlite:///./data/app.db` | Database connection string |
|
||||
|
||||
@@ -2,7 +2,7 @@ services:
|
||||
odysseus:
|
||||
build: .
|
||||
ports:
|
||||
- "${APP_PORT:-7000}:7000"
|
||||
- "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
|
||||
@@ -21,6 +21,7 @@ from src.integrations import (
|
||||
update_integration,
|
||||
delete_integration,
|
||||
get_integration,
|
||||
mask_integration_secret,
|
||||
execute_api_call,
|
||||
INTEGRATION_PRESETS,
|
||||
migrate_from_settings,
|
||||
@@ -431,12 +432,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(403, "Admin only")
|
||||
items = load_integrations()
|
||||
# Mask API keys for frontend display
|
||||
safe = []
|
||||
for item in items:
|
||||
copy = dict(item)
|
||||
if copy.get("api_key"):
|
||||
copy["api_key"] = copy["api_key"][:4] + "****"
|
||||
safe.append(copy)
|
||||
safe = [mask_integration_secret(item) for item in items]
|
||||
return {"integrations": safe}
|
||||
|
||||
@router.get("/integrations/presets")
|
||||
@@ -452,7 +448,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
raise HTTPException(403, "Admin only")
|
||||
body = await request.json()
|
||||
item = add_integration(body)
|
||||
return {"ok": True, "integration": item}
|
||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||
|
||||
@router.put("/integrations/{integration_id}")
|
||||
async def update_integration_route(integration_id: str, request: Request):
|
||||
@@ -464,7 +460,7 @@ def setup_auth_routes(auth_manager: AuthManager) -> APIRouter:
|
||||
item = update_integration(integration_id, body)
|
||||
if not item:
|
||||
raise HTTPException(404, "Integration not found")
|
||||
return {"ok": True, "integration": item}
|
||||
return {"ok": True, "integration": mask_integration_secret(item)}
|
||||
|
||||
@router.delete("/integrations/{integration_id}")
|
||||
async def delete_integration_route(integration_id: str, request: Request):
|
||||
|
||||
@@ -7,6 +7,10 @@ from typing import Dict, List, Optional, Any
|
||||
|
||||
import httpx
|
||||
|
||||
from core.atomic_io import atomic_write_json
|
||||
from core.platform_compat import safe_chmod
|
||||
from src.secret_storage import decrypt, encrypt, is_encrypted
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DATA_FILE = os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "integrations.json")
|
||||
@@ -143,23 +147,69 @@ def _ensure_data_dir() -> None:
|
||||
os.makedirs(os.path.dirname(DATA_FILE), exist_ok=True)
|
||||
|
||||
|
||||
def _encrypt_integration_secrets(integrations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Return storage-safe copies with API keys encrypted at rest."""
|
||||
safe: List[Dict[str, Any]] = []
|
||||
for item in integrations:
|
||||
copy = dict(item)
|
||||
api_key = copy.get("api_key", "")
|
||||
if api_key:
|
||||
copy["api_key"] = encrypt(str(api_key))
|
||||
safe.append(copy)
|
||||
return safe
|
||||
|
||||
|
||||
def _decrypt_integration_secrets(integrations: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Return runtime copies with API keys decrypted for callers."""
|
||||
decoded: List[Dict[str, Any]] = []
|
||||
for item in integrations:
|
||||
copy = dict(item)
|
||||
api_key = copy.get("api_key", "")
|
||||
if api_key:
|
||||
copy["api_key"] = decrypt(str(api_key))
|
||||
decoded.append(copy)
|
||||
return decoded
|
||||
|
||||
|
||||
def _has_plaintext_api_key(integrations: List[Dict[str, Any]]) -> bool:
|
||||
return any(
|
||||
bool(item.get("api_key")) and not is_encrypted(str(item.get("api_key")))
|
||||
for item in integrations
|
||||
)
|
||||
|
||||
|
||||
def mask_integration_secret(integration: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Return a copy safe for API responses."""
|
||||
safe = dict(integration)
|
||||
api_key = safe.get("api_key", "")
|
||||
if api_key:
|
||||
safe["api_key"] = f"{str(api_key)[:4]}****"
|
||||
return safe
|
||||
|
||||
|
||||
def load_integrations() -> List[Dict[str, Any]]:
|
||||
"""Load all integrations from disk."""
|
||||
"""Load all integrations from disk with secrets decrypted for runtime use."""
|
||||
if not os.path.exists(DATA_FILE):
|
||||
return []
|
||||
try:
|
||||
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
integrations = json.load(f)
|
||||
if not isinstance(integrations, list):
|
||||
log.error("Invalid integrations file shape: expected a list")
|
||||
return []
|
||||
if _has_plaintext_api_key(integrations):
|
||||
save_integrations(_decrypt_integration_secrets(integrations))
|
||||
return _decrypt_integration_secrets(integrations)
|
||||
except (json.JSONDecodeError, IOError) as exc:
|
||||
log.error("Failed to load integrations: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
def save_integrations(integrations: List[Dict[str, Any]]) -> None:
|
||||
"""Persist integrations list to disk."""
|
||||
"""Persist integrations list to disk with API keys encrypted at rest."""
|
||||
_ensure_data_dir()
|
||||
with open(DATA_FILE, "w", encoding="utf-8") as f:
|
||||
json.dump(integrations, f, indent=2)
|
||||
atomic_write_json(DATA_FILE, _encrypt_integration_secrets(integrations), indent=2)
|
||||
safe_chmod(DATA_FILE, 0o600)
|
||||
|
||||
|
||||
def get_integration(integration_id: str) -> Optional[Dict[str, Any]]:
|
||||
|
||||
@@ -1058,56 +1058,53 @@ class TaskScheduler:
|
||||
except Exception as e:
|
||||
raw["notes_tasks"] = f"Error: {e}"
|
||||
|
||||
# Auto-discover API integrations (Miniflux RSS, etc.) from integrations.json
|
||||
# Auto-discover API integrations (Miniflux RSS, etc.).
|
||||
try:
|
||||
import httpx
|
||||
from pathlib import Path as _P
|
||||
integrations_file = _P("data/integrations.json")
|
||||
if integrations_file.exists():
|
||||
integrations = json.loads(integrations_file.read_text(encoding="utf-8"))
|
||||
for integ in integrations:
|
||||
if not integ.get("enabled"):
|
||||
continue
|
||||
preset = integ.get("preset", "")
|
||||
base_url = integ.get("base_url", "").rstrip("/")
|
||||
api_key = integ.get("api_key", "")
|
||||
if not base_url:
|
||||
continue
|
||||
from src.integrations import load_integrations
|
||||
for integ in load_integrations():
|
||||
if not integ.get("enabled"):
|
||||
continue
|
||||
preset = integ.get("preset", "")
|
||||
base_url = integ.get("base_url", "").rstrip("/")
|
||||
api_key = integ.get("api_key", "")
|
||||
if not base_url:
|
||||
continue
|
||||
|
||||
# Build auth headers
|
||||
headers = {}
|
||||
if integ.get("auth_type") == "header" and api_key:
|
||||
headers[integ.get("auth_header", "X-Auth-Token")] = api_key
|
||||
elif integ.get("auth_type") == "bearer" and api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
# Build auth headers
|
||||
headers = {}
|
||||
if integ.get("auth_type") == "header" and api_key:
|
||||
headers[integ.get("auth_header", "X-Auth-Token")] = api_key
|
||||
elif integ.get("auth_type") == "bearer" and api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
# Miniflux: fetch unread entries (cached 3 min across tasks)
|
||||
if preset == "miniflux":
|
||||
async def _fetch_miniflux(_base=base_url, _headers=dict(headers)):
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{_base}/v1/entries",
|
||||
params={"status": "unread", "limit": 15, "order": "published_at", "direction": "desc"},
|
||||
headers=_headers,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
entries = resp.json().get("entries", []) or []
|
||||
if not entries:
|
||||
return None
|
||||
lines = []
|
||||
for e in entries[:15]:
|
||||
title = e.get("title", "?")
|
||||
feed = (e.get("feed") or {}).get("title", "?")
|
||||
url = e.get("url", "")
|
||||
lines.append(f"- [{feed}] {title} — {url}")
|
||||
return "\n".join(lines)
|
||||
try:
|
||||
val = await _cached(("miniflux_unread", base_url), 180, _fetch_miniflux)
|
||||
if val:
|
||||
raw["rss_miniflux_unread"] = val
|
||||
except Exception as e:
|
||||
logger.warning(f"Miniflux fetch failed: {e}")
|
||||
# Miniflux: fetch unread entries (cached 3 min across tasks)
|
||||
if preset == "miniflux":
|
||||
async def _fetch_miniflux(_base=base_url, _headers=dict(headers)):
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
resp = await client.get(
|
||||
f"{_base}/v1/entries",
|
||||
params={"status": "unread", "limit": 15, "order": "published_at", "direction": "desc"},
|
||||
headers=_headers,
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
entries = resp.json().get("entries", []) or []
|
||||
if not entries:
|
||||
return None
|
||||
lines = []
|
||||
for e in entries[:15]:
|
||||
title = e.get("title", "?")
|
||||
feed = (e.get("feed") or {}).get("title", "?")
|
||||
url = e.get("url", "")
|
||||
lines.append(f"- [{feed}] {title} — {url}")
|
||||
return "\n".join(lines)
|
||||
try:
|
||||
val = await _cached(("miniflux_unread", base_url), 180, _fetch_miniflux)
|
||||
if val:
|
||||
raw["rss_miniflux_unread"] = val
|
||||
except Exception as e:
|
||||
logger.warning(f"Miniflux fetch failed: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Integrations discovery failed: {e}")
|
||||
|
||||
|
||||
@@ -111,6 +111,77 @@ def test_secret_storage_key_created_with_safe_mode(tmp_path, monkeypatch):
|
||||
assert mode == 0o600, f"expected 0o600, got 0o{mode:o}"
|
||||
|
||||
|
||||
# ── secure-by-default deployment + integration storage ─────────
|
||||
|
||||
def test_docker_compose_binds_web_ui_to_loopback_by_default():
|
||||
compose = Path("docker-compose.yml").read_text(encoding="utf-8")
|
||||
assert "${APP_BIND:-127.0.0.1}:${APP_PORT:-7000}:7000" in compose
|
||||
assert '"${APP_PORT:-7000}:7000"' not in compose
|
||||
|
||||
|
||||
def test_readme_native_quickstart_uses_loopback():
|
||||
readme = Path("README.md").read_text(encoding="utf-8")
|
||||
assert "python -m uvicorn app:app --host 127.0.0.1 --port 7000" in readme
|
||||
assert "Use `--host 0.0.0.0` only when you intentionally want" in readme
|
||||
|
||||
|
||||
def _import_integrations(tmp_path, monkeypatch):
|
||||
"""Import src.integrations with data + encryption key redirected to tmp."""
|
||||
_import_secret_storage(tmp_path, monkeypatch)
|
||||
sys.modules.pop("src.integrations", None)
|
||||
from src import integrations # noqa: WPS433
|
||||
monkeypatch.setattr(integrations, "DATA_FILE", str(tmp_path / "integrations.json"))
|
||||
return integrations
|
||||
|
||||
|
||||
def test_integrations_api_keys_are_encrypted_at_rest(tmp_path, monkeypatch):
|
||||
integrations = _import_integrations(tmp_path, monkeypatch)
|
||||
|
||||
integrations.save_integrations([
|
||||
{
|
||||
"id": "miniflux",
|
||||
"name": "Miniflux",
|
||||
"base_url": "https://rss.example",
|
||||
"auth_type": "bearer",
|
||||
"api_key": "secret-token",
|
||||
}
|
||||
])
|
||||
|
||||
raw_text = (tmp_path / "integrations.json").read_text(encoding="utf-8")
|
||||
raw = json.loads(raw_text)
|
||||
assert raw[0]["api_key"].startswith("enc:")
|
||||
assert "secret-token" not in raw_text
|
||||
|
||||
loaded = integrations.load_integrations()
|
||||
assert loaded[0]["api_key"] == "secret-token"
|
||||
assert integrations.mask_integration_secret(loaded[0])["api_key"] == "secr****"
|
||||
|
||||
|
||||
def test_integrations_plaintext_keys_migrate_on_load(tmp_path, monkeypatch):
|
||||
integrations = _import_integrations(tmp_path, monkeypatch)
|
||||
data_file = tmp_path / "integrations.json"
|
||||
data_file.write_text(
|
||||
json.dumps([
|
||||
{
|
||||
"id": "legacy",
|
||||
"name": "Legacy API",
|
||||
"base_url": "https://api.example",
|
||||
"auth_type": "header",
|
||||
"api_key": "legacy-secret",
|
||||
}
|
||||
]),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
loaded = integrations.load_integrations()
|
||||
|
||||
assert loaded[0]["api_key"] == "legacy-secret"
|
||||
migrated_text = data_file.read_text(encoding="utf-8")
|
||||
migrated = json.loads(migrated_text)
|
||||
assert migrated[0]["api_key"].startswith("enc:")
|
||||
assert "legacy-secret" not in migrated_text
|
||||
|
||||
|
||||
# ── _q IMAP mailbox quoter ─────────────────────────────────────
|
||||
|
||||
def _import_q():
|
||||
|
||||
Reference in New Issue
Block a user