Files
stackpilot/backend/config.py
T
menzeljandClaude Opus 5 54c835b032
CI / build-and-push (push) Successful in 3m53s
Close the read-side privilege escalation and fix proxy-aware IPs (0.44.0)
F1 — Any authenticated user could read any file the backend could see.
/api/files/read and /download hung on get_current_user, and the sandbox that
should have caught that was open by default: ALLOWED_BROWSE_ROOTS contained
"/", for which _is_allowed() waves through every path. So the `user` role could
download stackpilot.db (password hashes, agent tokens, backup credentials),
every stack's .env and every .secrets/* file — with no audit trail, because
only mutations were logged.

Implementing that turned up three more doors into the same room, all fixed
here since closing only the first would have made the fix cosmetic:
GET /api/stacks/{id} handed the .env to any user, /export tarred the whole
stack dir including .secrets/*, and both the agent file proxies and
/api/agents/{id}/stacks/{id} repeated the leak for every remote host. All 24
filesystem-touching routes are now admin-only; reads and downloads are audited
(listing is not — the Files page polls it). DATA_DIR is refused outright, since
the API deliberately masks agent tokens and destination secrets and the browser
would otherwise be the way around that. "/" is out of the default browse roots.

F2 — Backup destination credentials were plaintext JSON in the DB, which is
what made F1 worth exploiting. They are now Fernet-encrypted at rest behind
parse_config/dump_config, with existing rows migrated at startup.

This needed a prerequisite from F6: the key is derived from SECRET_KEY, which
was regenerated on every boot when unset. Encrypting against a key that changes
per restart would be worse than plaintext, so an auto-generated SECRET_KEY is
now persisted to ${DATA_DIR}/secret_key at mode 0600. Sessions surviving a
restart is a welcome side effect.

F3 — /api/audit is admin-only. Also hidden from the dashboard and the nav for
non-admins, so nobody polls into a 403.

F4 — uvicorn now runs with --proxy-headers, so nginx's X-Forwarded-For is
honoured. Without it request.client.host was the frontend container's IP for
every request, which made the login rate limit global instead of per-IP (10
failures locked out everyone) and filled the audit log's IP column with one
useless value.

Verified: encrypt/decrypt round-trip incl. plaintext passthrough, idempotent
re-encryption and wrong-key handling; sandbox denial for DATA_DIR, traversal
into it, and paths outside the roots, with the allowed roots still reachable.
Both against stubbed settings — there is no Docker here, so nothing was run
end to end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
2026-08-31 13:01:53 +02:00

121 lines
4.0 KiB
Python

"""Application settings, loaded from environment variables."""
from __future__ import annotations
import os
import secrets
import stat
from functools import lru_cache
from typing import Annotated
from pydantic import ValidationInfo, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# Paths
STACKS_DIR: str = "/opt/stackpilot/stacks"
DATA_DIR: str = "/opt/stackpilot/data"
# Security
# Auto-generated and persisted to ${DATA_DIR}/secret_key when left empty.
SECRET_KEY: str = ""
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
# Update checker
UPDATE_CHECK_INTERVAL_MINUTES: int = 60
# Notifications (webhook URLs)
NOTIFY_WEBHOOKS: Annotated[list[str], NoDecode] = []
# Docker
DOCKER_SOCKET: str = "/var/run/docker.sock"
HOST_PROC_PATH: str = "/host_proc"
# Throwaway image used to read/write named-volume contents during backup.
BACKUP_HELPER_IMAGE: str = "alpine:latest"
# Multi-host agent: shared bearer token the agent requires on every request.
# Only used when running the agent app (agent_app:app).
AGENT_TOKEN: str = ""
# Host browser sandbox roots. Deliberately does NOT contain "/": that entry
# makes _is_allowed() wave through every path, i.e. it switches the sandbox
# off. Add it back explicitly if you really want the whole filesystem.
ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [
"/mnt", "/media", "/srv", "/opt", "/home",
]
HOST_ROOT_PREFIX: str = "" # e.g. "/host_root" when host / is bind-mounted
# CORS
CORS_ORIGINS: Annotated[list[str], NoDecode] = [
"http://localhost:5009", "http://localhost:5173",
]
# Server
PORT: int = 5008
@field_validator("SECRET_KEY", mode="after")
@classmethod
def _ensure_secret(cls, v: str, info: ValidationInfo) -> str:
"""Return the configured key, or a persisted auto-generated one.
Generating a fresh key per process (the old behaviour) silently
invalidated every session on each restart, and would now also make the
encrypted backup-destination credentials undecryptable. So the
generated key is written next to the database instead, mode 0600, and
read back on the next start. An explicitly configured SECRET_KEY always
wins and nothing is written.
"""
if v:
return v
data_dir = info.data.get("DATA_DIR") or "/opt/stackpilot/data"
key_file = os.path.join(data_dir, "secret_key")
try:
with open(key_file, "r", encoding="utf-8") as fh:
if existing := fh.read().strip():
return existing
except OSError:
pass
generated = secrets.token_urlsafe(48)
try:
os.makedirs(data_dir, exist_ok=True)
with open(key_file, "w", encoding="utf-8") as fh:
fh.write(generated + "\n")
os.chmod(key_file, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
# Read-only data dir: fall back to the old per-process behaviour
# rather than refusing to boot. Sessions won't survive a restart.
pass
return generated
@field_validator(
"NOTIFY_WEBHOOKS", "ALLOWED_BROWSE_ROOTS", "CORS_ORIGINS", mode="before"
)
@classmethod
def _split_csv(cls, v):
if isinstance(v, str):
v = v.strip()
if not v:
return []
if v.startswith("["): # tolerate a JSON list too
import json
try:
return json.loads(v)
except json.JSONDecodeError:
pass
return [item.strip() for item in v.split(",") if item.strip()]
return v
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()