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
+4 -2
View File
@@ -23,11 +23,13 @@ NOTIFY_WEBHOOKS=
# Throwaway image used to read/write named-volume contents during backups. # Throwaway image used to read/write named-volume contents during backups.
BACKUP_HELPER_IMAGE=alpine:latest BACKUP_HELPER_IMAGE=alpine:latest
# File browser (sidebar) + volume host-path picker. # File browser (sidebar) + volume host-path picker. Admin-only.
# ALLOWED_BROWSE_ROOTS: comma-separated paths the browser may reach (sandbox). # ALLOWED_BROWSE_ROOTS: comma-separated paths the browser may reach (sandbox).
# A single "/" in this list disables the sandbox -- it makes every path
# allowed. StackPilot's own DATA_DIR is refused regardless of this setting.
# HOST_ROOT_PREFIX: where the host filesystem is mounted inside the backend # HOST_ROOT_PREFIX: where the host filesystem is mounted inside the backend
# container. Leave empty to browse the container's own filesystem. To browse # container. Leave empty to browse the container's own filesystem. To browse
# the real host, uncomment the "/:/host_root" volume in docker-compose.yml and # the real host, uncomment the "/:/host_root" volume in docker-compose.yml and
# set HOST_ROOT_PREFIX=/host_root here (mount without :ro to allow edits). # set HOST_ROOT_PREFIX=/host_root here (mount without :ro to allow edits).
ALLOWED_BROWSE_ROOTS=/,/mnt,/media,/srv,/opt ALLOWED_BROWSE_ROOTS=/mnt,/media,/srv,/opt,/home
HOST_ROOT_PREFIX= HOST_ROOT_PREFIX=
+38 -2
View File
@@ -15,6 +15,33 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
> (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2) > (Auto-update) + Phase 23 (Secrets & configs) + Phase 24 (Design System v2)
> complete. > complete.
## Upgrading to 0.44.0 — two defaults changed
0.44.0 closes a privilege-escalation hole and tightens two defaults. Both
changes can affect an existing install:
1. **The `user` role loses read access to secrets.** The file browser (page and
`/api/files/*`), the host-path picker, the audit log, `GET /api/stacks/{id}/export`
and a stack's `.env` are now admin-only. Previously any logged-in account
could download `stackpilot.db`, every `.env` and every `.secrets/*` file —
and none of it was audit-logged. If you gave someone a `user` account so they
could look at stacks, they still can; they just no longer get the
credentials. Nothing changes for admins.
2. **`/` is no longer a default browse root.** The new default is
`/mnt,/media,/srv,/opt,/home`. A `/` entry makes the sandbox allow every
path, which is why it is gone — if you relied on it, set
`ALLOWED_BROWSE_ROOTS` explicitly in your `.env`. StackPilot's own `DATA_DIR`
is refused either way.
Two things also get fixed without any action on your part: the backend now runs
uvicorn with `--proxy-headers`, so the login rate limit works per client IP
instead of globally and the audit log records real IPs; and backup-destination
credentials are encrypted at rest, with existing rows migrated on first start.
That encryption is keyed off `SECRET_KEY`, which is now persisted to
`${DATA_DIR}/secret_key` when you have not set one — so restarts no longer log
everyone out. **If you have never set `SECRET_KEY`, do not delete that file**;
it is what your saved destination credentials are encrypted with.
## What works today (Phase 1) ## What works today (Phase 1)
- **File-first stacks** — every stack is a plain `compose.yaml` (+ optional `.env`) - **File-first stacks** — every stack is a plain `compose.yaml` (+ optional `.env`)
@@ -116,7 +143,7 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
still supported for generic endpoints. still supported for generic endpoints.
- **Settings page**: tune the update-check interval, manage webhooks, and manage - **Settings page**: tune the update-check interval, manage webhooks, and manage
users (create/disable/delete, promote/demote, with last-admin safeguards). users (create/disable/delete, promote/demote, with last-admin safeguards).
- **Audit log page**: searchable, paginated view of all recorded actions. - **Audit log page** (admin): searchable, paginated view of all recorded actions.
- **Mobile-responsive layout**: off-canvas sidebar + adaptive spacing. - **Mobile-responsive layout**: off-canvas sidebar + adaptive spacing.
### Phase 5 — Multi-host ### Phase 5 — Multi-host
@@ -366,13 +393,22 @@ as intuitive as Dockge, as capable as Portainer for Compose workflows.
- **View & edit**: clicking a text file opens it in a Monaco editor (with syntax - **View & edit**: clicking a text file opens it in a Monaco editor (with syntax
highlighting picked from the extension). Binary and oversized files are highlighting picked from the extension). Binary and oversized files are
detected and offered as a download instead. Admins can edit and **Save**. detected and offered as a download instead. Admins can edit and **Save**.
- **Admin only**: the whole page, including listing, viewing and downloading.
Reads are not less sensitive than writes here — the browser reaches whatever
the backend container can see, which includes every stack's `.env` and
`.secrets/*`. Reading and downloading a file are audit-logged (`file.read`,
`file.download`); directory listing is not, because the page polls it.
- **Manage** (admin): create folders/files, rename, delete (recursive for - **Manage** (admin): create folders/files, rename, delete (recursive for
folders), upload files **or whole folders** (the directory tree is recreated folders), upload files **or whole folders** (the directory tree is recreated
server-side), and download any file. **Copy/cut & paste** moves files and server-side), and download any file. **Copy/cut & paste** moves files and
folders between directories (clipboard bar + per-row copy/cut, with an folders between directories (clipboard bar + per-row copy/cut, with an
overwrite prompt on conflict). Every mutation is audit-logged. overwrite prompt on conflict). Every mutation is audit-logged.
- **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path - **Sandboxed**: all access is confined to `ALLOWED_BROWSE_ROOTS`; path
traversal and deleting a browse root are refused. To reach the real host traversal and deleting a browse root are refused. StackPilot's own `DATA_DIR`
is refused regardless of the setting — it holds `stackpilot.db` with password
hashes, agent tokens and backup-destination credentials, none of which the API
itself ever hands out. Note that a single `/` entry in `ALLOWED_BROWSE_ROOTS`
switches the sandbox off entirely; it is no longer part of the default. To reach the real host
filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the filesystem, mount it into the backend and set `HOST_ROOT_PREFIX` (see the
commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under
`/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `copy`, `/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `copy`,
+4 -1
View File
@@ -11,4 +11,7 @@ EXPOSE 5010
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD curl -fsS http://localhost:5010/agent/health || exit 1 CMD curl -fsS http://localhost:5010/agent/health || exit 1
CMD ["uvicorn", "agent_app:app", "--host", "0.0.0.0", "--port", "5010"] # Wie beim Backend: falls jemand einen TLS-Proxy vor den Agent setzt, soll die
# echte Client-IP in den Logs stehen.
CMD ["uvicorn", "agent_app:app", "--host", "0.0.0.0", "--port", "5010", \
"--proxy-headers", "--forwarded-allow-ips", "*"]
+12 -1
View File
@@ -27,4 +27,15 @@ EXPOSE 5008
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s \
CMD curl -fsS http://localhost:5008/api/health || exit 1 CMD curl -fsS http://localhost:5008/api/health || exit 1
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5008"] # --proxy-headers: nginx setzt X-Forwarded-For, ohne dieses Flag ignoriert
# uvicorn den Header und request.client.host ist fuer JEDE Anfrage die IP des
# Frontend-Containers -- was das Login-Rate-Limit global statt pro IP wirken
# laesst und die IP-Spalte im Audit-Log wertlos macht.
#
# forwarded-allow-ips=* vertraut dem Header von jedem Absender. Das ist hier
# richtig, weil der Backend-Port nur im Docker-Netz erreichbar ist (siehe
# "expose" statt "ports" in docker-compose.yml). Wer 5008 direkt nach aussen
# gibt, muss den Wert auf die IP des eigenen Proxys einschraenken -- sonst
# kann ein Client seine eigene Herkunfts-IP faelschen.
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "5008", \
"--proxy-headers", "--forwarded-allow-ips", "*"]
+40 -6
View File
@@ -1,11 +1,13 @@
"""Application settings, loaded from environment variables.""" """Application settings, loaded from environment variables."""
from __future__ import annotations from __future__ import annotations
import os
import secrets import secrets
import stat
from functools import lru_cache from functools import lru_cache
from typing import Annotated from typing import Annotated
from pydantic import field_validator from pydantic import ValidationInfo, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
@@ -17,7 +19,8 @@ class Settings(BaseSettings):
DATA_DIR: str = "/opt/stackpilot/data" DATA_DIR: str = "/opt/stackpilot/data"
# Security # Security
SECRET_KEY: str = "" # Auto-generated if empty (dev only); set in prod. # Auto-generated and persisted to ${DATA_DIR}/secret_key when left empty.
SECRET_KEY: str = ""
ALGORITHM: str = "HS256" ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 60 ACCESS_TOKEN_EXPIRE_MINUTES: int = 60
REFRESH_TOKEN_EXPIRE_DAYS: int = 30 REFRESH_TOKEN_EXPIRE_DAYS: int = 30
@@ -39,9 +42,11 @@ class Settings(BaseSettings):
# Only used when running the agent app (agent_app:app). # Only used when running the agent app (agent_app:app).
AGENT_TOKEN: str = "" AGENT_TOKEN: str = ""
# Host browser sandbox roots # 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] = [ ALLOWED_BROWSE_ROOTS: Annotated[list[str], NoDecode] = [
"/", "/mnt", "/media", "/srv", "/opt", "/mnt", "/media", "/srv", "/opt", "/home",
] ]
HOST_ROOT_PREFIX: str = "" # e.g. "/host_root" when host / is bind-mounted HOST_ROOT_PREFIX: str = "" # e.g. "/host_root" when host / is bind-mounted
@@ -55,8 +60,37 @@ class Settings(BaseSettings):
@field_validator("SECRET_KEY", mode="after") @field_validator("SECRET_KEY", mode="after")
@classmethod @classmethod
def _ensure_secret(cls, v: str) -> str: def _ensure_secret(cls, v: str, info: ValidationInfo) -> str:
return v or secrets.token_urlsafe(48) """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( @field_validator(
"NOTIFY_WEBHOOKS", "ALLOWED_BROWSE_ROOTS", "CORS_ORIGINS", mode="before" "NOTIFY_WEBHOOKS", "ALLOWED_BROWSE_ROOTS", "CORS_ORIGINS", mode="before"
+15 -1
View File
@@ -36,7 +36,12 @@ from routers import (
volumes, volumes,
ws, ws,
) )
from services import schedule_service, template_service, update_service from services import (
backup_destination_service,
schedule_service,
template_service,
update_service,
)
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("stackpilot") logger = logging.getLogger("stackpilot")
@@ -51,6 +56,15 @@ async def lifespan(app: FastAPI):
stacks.sync_discovered_stacks(session) stacks.sync_discovered_stacks(session)
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
logger.warning("Stack discovery failed: %s", exc) logger.warning("Stack discovery failed: %s", exc)
# One-off: encrypt backup-destination credentials written before they were
# stored encrypted (see services/crypto_service.py).
try:
with Session(engine) as session:
encrypted = backup_destination_service.migrate_plaintext_configs(session)
if encrypted:
logger.info("Encrypted %d backup destination config(s) at rest", encrypted)
except Exception as exc: # noqa: BLE001
logger.warning("Destination config encryption migration failed: %s", exc)
try: try:
moved = template_service.migrate_legacy_db_templates() moved = template_service.migrate_legacy_db_templates()
if moved: if moved:
+2
View File
@@ -5,6 +5,8 @@ sqlmodel==0.0.22
pydantic==2.10.4 pydantic==2.10.4
pydantic-settings==2.7.1 pydantic-settings==2.7.1
python-jose[cryptography]==3.3.0 python-jose[cryptography]==3.3.0
# Direct dependency: services/crypto_service encrypts DB-stored secrets.
cryptography==44.0.0
passlib[bcrypt]==1.7.4 passlib[bcrypt]==1.7.4
bcrypt==4.2.1 bcrypt==4.2.1
python-multipart==0.0.20 python-multipart==0.0.20
+20 -5
View File
@@ -245,10 +245,14 @@ async def agent_stack_detail(
agent_id: int, agent_id: int,
stack_id: str, stack_id: str,
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> dict: ) -> dict:
agent = _get_or_404(session, agent_id) agent = _get_or_404(session, agent_id)
data = await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}") data = await _proxy(session, agent, "GET", f"/agent/stacks/{stack_id}")
# Withhold the .env from the read-only role, exactly as the local
# GET /api/stacks/{id} does.
if user.role != "admin" and isinstance(data, dict):
data["env"] = ""
data["agent_id"] = agent.id data["agent_id"] = agent.id
data["agent_name"] = agent.name data["agent_name"] = agent.name
return data return data
@@ -796,7 +800,7 @@ async def agent_files_list(
path: str = Query("/"), path: str = Query("/"),
show_hidden: bool = Query(False), show_hidden: bool = Query(False),
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: User = Depends(get_current_user), _admin: User = Depends(require_admin),
) -> dict: ) -> dict:
agent = _get_or_404(session, agent_id) agent = _get_or_404(session, agent_id)
return await _proxy( return await _proxy(
@@ -808,22 +812,33 @@ async def agent_files_list(
@router.get("/{agent_id}/files/read") @router.get("/{agent_id}/files/read")
async def agent_files_read( async def agent_files_read(
agent_id: int, agent_id: int,
request: Request,
path: str = Query(...), path: str = Query(...),
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: User = Depends(get_current_user), user: User = Depends(require_admin),
) -> dict: ) -> dict:
agent = _get_or_404(session, agent_id) agent = _get_or_404(session, agent_id)
return await _proxy(session, agent, "GET", "/agent/files/read", params={"path": path}) result = await _proxy(session, agent, "GET", "/agent/files/read", params={"path": path})
audit_service.record(
session, user=user.username, action="file.read",
target=f"{agent.name}:{path}", ip=_ip(request),
)
return result
@router.get("/{agent_id}/files/download") @router.get("/{agent_id}/files/download")
async def agent_files_download( async def agent_files_download(
agent_id: int, agent_id: int,
request: Request,
path: str = Query(...), path: str = Query(...),
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: User = Depends(get_current_user), user: User = Depends(require_admin),
): ):
agent = _get_or_404(session, agent_id) agent = _get_or_404(session, agent_id)
audit_service.record(
session, user=user.username, action="file.download",
target=f"{agent.name}:{path}", ip=_ip(request),
)
# Stream the agent's response straight through (works for single files and # Stream the agent's response straight through (works for single files and
# for on-the-fly folder zips), so nothing is staged to disk and the # for on-the-fly folder zips), so nothing is staged to disk and the
# download starts immediately. Pull the first chunk eagerly so a failed # download starts immediately. Pull the first chunk eagerly so a failed
+8 -3
View File
@@ -1,4 +1,9 @@
"""Audit log query endpoint.""" """Audit log query endpoint.
Admin-only: the log is security telemetry (who did what, from which IP,
including every administrator's activity) and has no business being readable
by an account with the ``user`` role.
"""
from __future__ import annotations from __future__ import annotations
from typing import Optional from typing import Optional
@@ -6,7 +11,7 @@ from typing import Optional
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlmodel import Session, select from sqlmodel import Session, select
from auth import get_current_user from auth import require_admin
from database import get_session from database import get_session
from models.audit import AuditLog from models.audit import AuditLog
from models.user import User from models.user import User
@@ -20,7 +25,7 @@ def list_audit(
offset: int = 0, offset: int = 0,
stack_id: Optional[str] = None, stack_id: Optional[str] = None,
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: User = Depends(get_current_user), _admin: User = Depends(require_admin),
) -> list[AuditLog]: ) -> list[AuditLog]:
stmt = select(AuditLog).order_by(AuditLog.timestamp.desc()) stmt = select(AuditLog).order_by(AuditLog.timestamp.desc())
if stack_id: if stack_id:
+5 -4
View File
@@ -2,12 +2,11 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
import json
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select from sqlmodel import Session, select
from auth import get_current_user, require_admin from auth import require_admin
from database import get_session from database import get_session
from models.backup_destination import ( from models.backup_destination import (
DESTINATION_TYPES, DESTINATION_TYPES,
@@ -66,7 +65,9 @@ def create_destination(
) -> DestinationRead: ) -> DestinationRead:
if body.type not in DESTINATION_TYPES: if body.type not in DESTINATION_TYPES:
raise HTTPException(status_code=400, detail=f"Unknown type '{body.type}'") raise HTTPException(status_code=400, detail=f"Unknown type '{body.type}'")
d = BackupDestination(name=body.name, type=body.type, config=json.dumps(body.config)) d = BackupDestination(
name=body.name, type=body.type, config=dest_service.dump_config(body.config)
)
session.add(d) session.add(d)
session.commit() session.commit()
session.refresh(d) session.refresh(d)
@@ -95,7 +96,7 @@ def update_destination(
if k in SECRET_KEYS and (v == "" or v == "••••••"): if k in SECRET_KEYS and (v == "" or v == "••••••"):
continue # keep existing secret continue # keep existing secret
existing[k] = v existing[k] = v
d.config = json.dumps(existing) d.config = dest_service.dump_config(existing)
session.add(d) session.add(d)
session.commit() session.commit()
session.refresh(d) session.refresh(d)
+21 -7
View File
@@ -1,7 +1,10 @@
"""Full host filesystem browser: list, read, edit, manage, up/download. """Full host filesystem browser: list, read, edit, manage, up/download.
Listing and reads require an authenticated user; every mutating operation Every operation requires admin. Reads are not less dangerous than writes here:
(write, mkdir, rename, delete, upload) requires admin and is audit-logged. the browser reaches whatever the backend container can see, which includes
every stack's ``.env`` and ``.secrets/*``. Reading a file and downloading one
are audit-logged just like the mutating operations; directory listing is not,
because the Files page polls it and would drown the log.
All paths are sandboxed by :mod:`services.file_service`. All paths are sandboxed by :mod:`services.file_service`.
""" """
from __future__ import annotations from __future__ import annotations
@@ -23,7 +26,7 @@ from fastapi.responses import FileResponse, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlmodel import Session from sqlmodel import Session
from auth import get_current_user, require_admin from auth import require_admin
from database import get_session from database import get_session
from models.user import User from models.user import User
from services import audit_service, device_service, file_service from services import audit_service, device_service, file_service
@@ -57,24 +60,35 @@ def _guard(fn, *args, **kwargs):
def list_dir( def list_dir(
path: str = Query("/"), path: str = Query("/"),
show_hidden: bool = Query(False), show_hidden: bool = Query(False),
_user: User = Depends(get_current_user), _admin: User = Depends(require_admin),
) -> dict: ) -> dict:
return _guard(device_service.browse, path, show_hidden) return _guard(device_service.browse, path, show_hidden)
@router.get("/read") @router.get("/read")
def read_file( def read_file(
request: Request,
path: str = Query(...), path: str = Query(...),
_user: User = Depends(get_current_user), session: Session = Depends(get_session),
user: User = Depends(require_admin),
) -> dict: ) -> dict:
return _guard(file_service.read_file, path) result = _guard(file_service.read_file, path)
audit_service.record(
session, user=user.username, action="file.read", target=path, ip=_ip(request)
)
return result
@router.get("/download") @router.get("/download")
def download( def download(
request: Request,
path: str = Query(...), path: str = Query(...),
_user: User = Depends(get_current_user), session: Session = Depends(get_session),
user: User = Depends(require_admin),
): ):
audit_service.record(
session, user=user.username, action="file.download", target=path, ip=_ip(request)
)
if _guard(file_service.is_dir, path): if _guard(file_service.is_dir, path):
filename, chunks = _guard(file_service.open_archive, path) filename, chunks = _guard(file_service.open_archive, path)
# Stream the zip as it's built so the response starts immediately # Stream the zip as it's built so the response starts immediately
+8 -3
View File
@@ -155,7 +155,7 @@ def stacks_updates(_user: User = Depends(get_current_user)) -> dict:
def get_stack( def get_stack(
stack_id: str, stack_id: str,
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> dict: ) -> dict:
stack = _get_stack_or_404(session, stack_id) stack = _get_stack_or_404(session, stack_id)
try: try:
@@ -171,7 +171,10 @@ def get_stack(
"description": stack.description, "description": stack.description,
"status": status, "status": status,
"yaml": compose_service.read_compose(stack_id), "yaml": compose_service.read_compose(stack_id),
"env": compose_service.read_env(stack_id), # The .env is where credentials live by convention, so it is withheld
# from the read-only role — same reasoning as the admin-only file
# browser. Non-admins still get status, services and the compose file.
"env": compose_service.read_env(stack_id) if user.role == "admin" else "",
"containers": containers, "containers": containers,
"created_at": stack.created_at, "created_at": stack.created_at,
"updated_at": stack.updated_at, "updated_at": stack.updated_at,
@@ -380,8 +383,10 @@ async def service_logs(
def export_stack( def export_stack(
stack_id: str, stack_id: str,
session: Session = Depends(get_session), session: Session = Depends(get_session),
_user: User = Depends(get_current_user), _admin: User = Depends(require_admin),
): ):
"""Download the whole stack folder as a tarball. Admin only: the archive
contains the ``.env`` and every ``.secrets/*`` file verbatim."""
import io import io
import tarfile import tarfile
import tempfile import tempfile
+4 -1
View File
@@ -98,8 +98,11 @@ def generate_yaml(
def host_paths( def host_paths(
path: str = Query("/"), path: str = Query("/"),
show_hidden: bool = Query(False), show_hidden: bool = Query(False),
_user: User = Depends(get_current_user), _admin: User = Depends(require_admin),
) -> dict: ) -> dict:
"""Directory picker for the volume wizard. Same browse() as the file
browser, so it carries the same admin requirement — and only admins can
create a volume with the result anyway."""
try: try:
return device_service.browse(path, show_hidden) return device_service.browse(path, show_hidden)
except device_service.BrowseError as exc: except device_service.BrowseError as exc:
+43 -3
View File
@@ -1,8 +1,11 @@
"""Push/pull stack backups to remote destinations (SFTP, S3-compatible or NFS). """Push/pull stack backups to remote destinations (SFTP, S3-compatible or NFS).
All operations are synchronous (paramiko / boto3 / docker); async callers All operations are synchronous (paramiko / boto3 / docker); async callers
should wrap them with ``asyncio.to_thread``. Destination config is a plain should wrap them with ``asyncio.to_thread``. Destination config is a dict
dict parsed from the ``BackupDestination.config`` JSON column. 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 from __future__ import annotations
@@ -17,6 +20,7 @@ import tempfile
from typing import Any from typing import Any
from models.backup_destination import BackupDestination from models.backup_destination import BackupDestination
from services import crypto_service
logger = logging.getLogger("stackpilot.backup_dest") logger = logging.getLogger("stackpilot.backup_dest")
@@ -26,12 +30,48 @@ class DestinationError(Exception):
def parse_config(dest: BackupDestination) -> dict: 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: 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: except json.JSONDecodeError:
return {} 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) # 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: 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("/") prefix = settings.HOST_ROOT_PREFIX.rstrip("/")
if prefix: real = prefix + path if prefix else path
return prefix + path data_dir = os.path.normpath(settings.DATA_DIR)
return path 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: def _is_allowed(path: str) -> bool:
@@ -104,10 +120,6 @@ def _is_allowed(path: str) -> bool:
return False return False
class BrowseError(Exception):
pass
def browse(path: str = "/", show_hidden: bool = False) -> dict: def browse(path: str = "/", show_hidden: bool = False) -> dict:
path = os.path.normpath(path or "/") path = os.path.normpath(path or "/")
if not path.startswith("/"): if not path.startswith("/"):
+1 -1
View File
@@ -1,3 +1,3 @@
"""Single source of truth for the StackPilot release version.""" """Single source of truth for the StackPilot release version."""
APP_VERSION = "0.43.0" APP_VERSION = "0.44.0"
+5 -3
View File
@@ -14,10 +14,12 @@ services:
- NOTIFY_WEBHOOKS=${NOTIFY_WEBHOOKS:-} - NOTIFY_WEBHOOKS=${NOTIFY_WEBHOOKS:-}
# Throwaway image used to snapshot named-volume contents during backups. # Throwaway image used to snapshot named-volume contents during backups.
- BACKUP_HELPER_IMAGE=${BACKUP_HELPER_IMAGE:-alpine:latest} - BACKUP_HELPER_IMAGE=${BACKUP_HELPER_IMAGE:-alpine:latest}
# File browser (sidebar) + volume host-path picker. ALLOWED_BROWSE_ROOTS # File browser (sidebar) + volume host-path picker. Admin-only, and
# limits which paths are reachable; HOST_ROOT_PREFIX is where the host # ALLOWED_BROWSE_ROOTS limits which paths are reachable -- note that a
# single "/" in that list disables the limit entirely. StackPilot's own
# DATA_DIR is always refused. HOST_ROOT_PREFIX is where the host
# filesystem is mounted inside this container (see the volume below). # filesystem is mounted inside this container (see the volume below).
- ALLOWED_BROWSE_ROOTS=${ALLOWED_BROWSE_ROOTS:-/,/mnt,/media,/srv,/opt} - ALLOWED_BROWSE_ROOTS=${ALLOWED_BROWSE_ROOTS:-/mnt,/media,/srv,/opt,/home}
- HOST_ROOT_PREFIX=${HOST_ROOT_PREFIX:-} - HOST_ROOT_PREFIX=${HOST_ROOT_PREFIX:-}
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "stackpilot-frontend", "name": "stackpilot-frontend",
"private": true, "private": true,
"version": "0.43.0", "version": "0.44.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+15 -5
View File
@@ -23,15 +23,24 @@ import { useThemeStore } from "@/store/theme";
import { agentsApi } from "@/api/agents"; import { agentsApi } from "@/api/agents";
import { VersionBadge } from "./VersionBadge"; import { VersionBadge } from "./VersionBadge";
export const NAV_ITEMS = [ type NavItem = {
to: string;
label: string;
icon: typeof LayoutDashboard;
end?: boolean;
/** Hidden for non-admins — the matching API routes require the admin role. */
adminOnly?: boolean;
};
export const NAV_ITEMS: NavItem[] = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard, end: true }, { to: "/", label: "Dashboard", icon: LayoutDashboard, end: true },
{ to: "/stacks", label: "Stacks", icon: Boxes }, { to: "/stacks", label: "Stacks", icon: Boxes },
{ to: "/networks", label: "Networks", icon: Network }, { to: "/networks", label: "Networks", icon: Network },
{ to: "/images", label: "Images", icon: Image }, { to: "/images", label: "Images", icon: Image },
{ to: "/volumes", label: "Volumes", icon: Database }, { to: "/volumes", label: "Volumes", icon: Database },
{ to: "/files", label: "Files", icon: FolderTree }, { to: "/files", label: "Files", icon: FolderTree, adminOnly: true },
{ to: "/templates", label: "Templates", icon: LayoutTemplate }, { to: "/templates", label: "Templates", icon: LayoutTemplate },
{ to: "/audit", label: "Audit", icon: ScrollText }, { to: "/audit", label: "Audit", icon: ScrollText, adminOnly: true },
{ to: "/settings", label: "Settings", icon: Settings }, { to: "/settings", label: "Settings", icon: Settings },
]; ];
@@ -76,6 +85,7 @@ export function TopNav() {
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthStore((s) => s.user); const user = useAuthStore((s) => s.user);
const navItems = NAV_ITEMS.filter((i) => !i.adminOnly || user?.role === "admin");
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const { theme, toggle } = useThemeStore(); const { theme, toggle } = useThemeStore();
@@ -125,7 +135,7 @@ export function TopNav() {
aria-label="Primary" aria-label="Primary"
className="mx-auto hidden items-center gap-0.5 rounded-pill border border-sp-border bg-sp-surface p-1 lg:flex" className="mx-auto hidden items-center gap-0.5 rounded-pill border border-sp-border bg-sp-surface p-1 lg:flex"
> >
{NAV_ITEMS.map(({ to, label, end }) => ( {navItems.map(({ to, label, end }) => (
<NavLink key={to} to={to} end={end}> <NavLink key={to} to={to} end={end}>
{({ isActive }) => ( {({ isActive }) => (
<span <span
@@ -229,7 +239,7 @@ export function TopNav() {
</button> </button>
</div> </div>
<nav className="flex-1 space-y-1 overflow-y-auto px-3" aria-label="Primary"> <nav className="flex-1 space-y-1 overflow-y-auto px-3" aria-label="Primary">
{NAV_ITEMS.map(({ to, label, icon: Icon, end }) => ( {navItems.map(({ to, label, icon: Icon, end }) => (
<NavLink <NavLink
key={to} key={to}
to={to} to={to}
+10 -2
View File
@@ -34,7 +34,13 @@ export function Dashboard() {
const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 }); const stacks = useQuery({ queryKey: ["stacks"], queryFn: stacksApi.list, refetchInterval: 5000 });
const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 }); const stats = useQuery({ queryKey: ["stack-stats"], queryFn: stacksApi.stats, refetchInterval: 5000 });
const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 30000 }); const info = useQuery({ queryKey: ["system"], queryFn: systemApi.info, refetchInterval: 30000 });
const audit = useQuery({ queryKey: ["audit"], queryFn: () => systemApi.audit(10), refetchInterval: 10000 }); // The audit log is admin-only on the API; don't poll it into a 403 for others.
const audit = useQuery({
queryKey: ["audit"],
queryFn: () => systemApi.audit(10),
refetchInterval: 10000,
enabled: isAdmin,
});
const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 }); const agents = useQuery({ queryKey: ["agents"], queryFn: () => agentsApi.list(), refetchInterval: 15000 });
const hasAgents = (agents.data?.length ?? 0) > 0; const hasAgents = (agents.data?.length ?? 0) > 0;
@@ -137,7 +143,8 @@ export function Dashboard() {
<AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} /> <AgentDashboardSection key={agent.id} agent={agent} isAdmin={isAdmin} />
))} ))}
{/* ---- Recent activity ---- */} {/* ---- Recent activity (admin only, like the audit log itself) ---- */}
{isAdmin && (
<section> <section>
<h2 className="sp-label mb-3 flex items-center gap-2"> <h2 className="sp-label mb-3 flex items-center gap-2">
<Clock className="h-4 w-4" /> Recent activity <Clock className="h-4 w-4" /> Recent activity
@@ -161,6 +168,7 @@ export function Dashboard() {
)} )}
</Card> </Card>
</section> </section>
)}
</div> </div>
); );
} }