Close the read-side privilege escalation and fix proxy-aware IPs (0.44.0)
CI / build-and-push (push) Successful in 3m53s

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
This commit is contained in:
menzelj
2026-08-31 13:01:53 +02:00
co-authored by Claude Opus 5
parent b3af0c2109
commit 54c835b032
21 changed files with 352 additions and 59 deletions
+43 -3
View File
@@ -1,8 +1,11 @@
"""Push/pull stack backups to remote destinations (SFTP, S3-compatible or NFS).
All operations are synchronous (paramiko / boto3 / docker); async callers
should wrap them with ``asyncio.to_thread``. Destination config is a plain
dict parsed from the ``BackupDestination.config`` JSON column.
should wrap them with ``asyncio.to_thread``. Destination config is a dict
stored in the ``BackupDestination.config`` column as JSON, encrypted at rest
(:mod:`services.crypto_service`) because it carries SFTP passwords, SSH keys
and S3 secret keys. Always go through :func:`parse_config` / :func:`dump_config`
— never touch the column directly.
"""
from __future__ import annotations
@@ -17,6 +20,7 @@ import tempfile
from typing import Any
from models.backup_destination import BackupDestination
from services import crypto_service
logger = logging.getLogger("stackpilot.backup_dest")
@@ -26,12 +30,48 @@ class DestinationError(Exception):
def parse_config(dest: BackupDestination) -> dict:
"""Decrypt and parse a destination's config.
Tolerates plaintext (pre-encryption rows) and returns ``{}`` rather than
raising if the value can't be decrypted — a destination whose key is gone
should show up as unconfigured in the UI, not take the whole list down with
a 500. The failure is logged with the destination name so it's findable.
"""
try:
return json.loads(dest.config or "{}")
raw = crypto_service.decrypt(dest.config or "{}")
except crypto_service.DecryptError as exc:
logger.error("Destination '%s': %s", dest.name, exc)
return {}
try:
return json.loads(raw or "{}")
except json.JSONDecodeError:
return {}
def dump_config(config: dict) -> str:
"""Serialise and encrypt a config dict for storage."""
return crypto_service.encrypt(json.dumps(config or {}))
def migrate_plaintext_configs(session) -> int:
"""Encrypt destination configs written before encryption existed.
Runs once at startup. Returns how many rows were rewritten.
"""
from sqlmodel import select
migrated = 0
for dest in session.exec(select(BackupDestination)).all():
if crypto_service.is_encrypted(dest.config):
continue
dest.config = crypto_service.encrypt(dest.config or "{}")
session.add(dest)
migrated += 1
if migrated:
session.commit()
return migrated
# --------------------------------------------------------------------------- #
# SFTP (paramiko)
# --------------------------------------------------------------------------- #
+76
View File
@@ -0,0 +1,76 @@
"""Symmetric encryption for secrets that have to live in the database.
Most of StackPilot's secrets are files on disk (``.env``, ``.secrets/*``) where
filesystem permissions are the right control. A few can't be: backup
destination credentials and agent tokens are needed by background jobs, so they
sit in ``stackpilot.db``. This module encrypts those at rest.
The key is derived from ``SECRET_KEY`` rather than being a second thing to
configure — which is exactly why ``SECRET_KEY`` is now persisted (see
``config._ensure_secret``): a key that changed on every restart would take the
ciphertext with it.
Ciphertext is stored with an ``enc:v1:`` prefix so plaintext rows written by
older versions stay recognisable and can be migrated in place.
"""
from __future__ import annotations
import base64
import hashlib
import logging
from typing import Optional
from cryptography.fernet import Fernet, InvalidToken
from config import settings
logger = logging.getLogger("stackpilot.crypto")
PREFIX = "enc:v1:"
_INFO = b"stackpilot-db-field-encryption-v1"
class DecryptError(Exception):
"""Ciphertext could not be decrypted (usually: SECRET_KEY changed)."""
def _fernet() -> Fernet:
"""Fernet built from a 32-byte key derived from SECRET_KEY.
Not cached: SECRET_KEY is fixed for the process lifetime, and building a
Fernet is a hash plus a base64 encode — cheap enough not to bother.
"""
digest = hashlib.blake2b(
settings.SECRET_KEY.encode("utf-8"), key=_INFO, digest_size=32
).digest()
return Fernet(base64.urlsafe_b64encode(digest))
def is_encrypted(value: Optional[str]) -> bool:
return bool(value) and value.startswith(PREFIX)
def encrypt(plaintext: str) -> str:
"""Encrypt a string. Already-encrypted input is returned unchanged."""
if is_encrypted(plaintext):
return plaintext
token = _fernet().encrypt((plaintext or "").encode("utf-8"))
return PREFIX + token.decode("ascii")
def decrypt(value: str) -> str:
"""Decrypt a value written by :func:`encrypt`.
Plaintext (no prefix) is passed straight through, so rows written before
encryption existed keep working until the startup migration rewrites them.
"""
if not is_encrypted(value):
return value or ""
try:
return _fernet().decrypt(value[len(PREFIX):].encode("ascii")).decode("utf-8")
except (InvalidToken, ValueError) as exc:
raise DecryptError(
"Could not decrypt a stored secret. This normally means SECRET_KEY "
"changed since it was saved — restore the old key, or re-enter the "
"affected credentials."
) from exc
+20 -8
View File
@@ -87,12 +87,28 @@ def detect_devices() -> dict:
# --------------------------------------------------------------------------- #
class BrowseError(Exception):
pass
def _real_root(path: str) -> str:
"""Map a logical host path into the container view (HOST_ROOT_PREFIX)."""
"""Map a logical host path into the container view (HOST_ROOT_PREFIX).
Refuses anything that resolves inside StackPilot's own ``DATA_DIR``. That
directory holds ``stackpilot.db`` — users, password hashes, agent tokens and
backup-destination credentials — and the API deliberately never hands those
out (``AgentRead.token_set`` is a bool, destination secrets come back
masked). Without this the file browser would be a way around that, for
admins too. Note this only bites when ``HOST_ROOT_PREFIX`` is empty: with a
prefix set, no logical path can reach the container's own ``/data`` at all.
"""
prefix = settings.HOST_ROOT_PREFIX.rstrip("/")
if prefix:
return prefix + path
return path
real = prefix + path if prefix else path
data_dir = os.path.normpath(settings.DATA_DIR)
norm = os.path.normpath(real)
if norm == data_dir or norm.startswith(data_dir + os.sep):
raise BrowseError("Path is inside StackPilot's own data directory")
return real
def _is_allowed(path: str) -> bool:
@@ -104,10 +120,6 @@ def _is_allowed(path: str) -> bool:
return False
class BrowseError(Exception):
pass
def browse(path: str = "/", show_hidden: bool = False) -> dict:
path = os.path.normpath(path or "/")
if not path.startswith("/"):