Close the read-side privilege escalation and fix proxy-aware IPs (0.44.0)
CI / build-and-push (push) Successful in 3m53s
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:
@@ -245,10 +245,14 @@ async def agent_stack_detail(
|
||||
agent_id: int,
|
||||
stack_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_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_name"] = agent.name
|
||||
return data
|
||||
@@ -796,7 +800,7 @@ async def agent_files_list(
|
||||
path: str = Query("/"),
|
||||
show_hidden: bool = Query(False),
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
agent = _get_or_404(session, agent_id)
|
||||
return await _proxy(
|
||||
@@ -808,22 +812,33 @@ async def agent_files_list(
|
||||
@router.get("/{agent_id}/files/read")
|
||||
async def agent_files_read(
|
||||
agent_id: int,
|
||||
request: Request,
|
||||
path: str = Query(...),
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
user: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
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")
|
||||
async def agent_files_download(
|
||||
agent_id: int,
|
||||
request: Request,
|
||||
path: str = Query(...),
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
user: User = Depends(require_admin),
|
||||
):
|
||||
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
|
||||
# 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
|
||||
|
||||
@@ -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 typing import Optional
|
||||
@@ -6,7 +11,7 @@ from typing import Optional
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from auth import get_current_user
|
||||
from auth import require_admin
|
||||
from database import get_session
|
||||
from models.audit import AuditLog
|
||||
from models.user import User
|
||||
@@ -20,7 +25,7 @@ def list_audit(
|
||||
offset: int = 0,
|
||||
stack_id: Optional[str] = None,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> list[AuditLog]:
|
||||
stmt = select(AuditLog).order_by(AuditLog.timestamp.desc())
|
||||
if stack_id:
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from auth import get_current_user, require_admin
|
||||
from auth import require_admin
|
||||
from database import get_session
|
||||
from models.backup_destination import (
|
||||
DESTINATION_TYPES,
|
||||
@@ -66,7 +65,9 @@ def create_destination(
|
||||
) -> DestinationRead:
|
||||
if body.type not in DESTINATION_TYPES:
|
||||
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.commit()
|
||||
session.refresh(d)
|
||||
@@ -95,7 +96,7 @@ def update_destination(
|
||||
if k in SECRET_KEYS and (v == "" or v == "••••••"):
|
||||
continue # keep existing secret
|
||||
existing[k] = v
|
||||
d.config = json.dumps(existing)
|
||||
d.config = dest_service.dump_config(existing)
|
||||
session.add(d)
|
||||
session.commit()
|
||||
session.refresh(d)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
"""Full host filesystem browser: list, read, edit, manage, up/download.
|
||||
|
||||
Listing and reads require an authenticated user; every mutating operation
|
||||
(write, mkdir, rename, delete, upload) requires admin and is audit-logged.
|
||||
Every operation requires admin. Reads are not less dangerous than writes here:
|
||||
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`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
@@ -23,7 +26,7 @@ from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Session
|
||||
|
||||
from auth import get_current_user, require_admin
|
||||
from auth import require_admin
|
||||
from database import get_session
|
||||
from models.user import User
|
||||
from services import audit_service, device_service, file_service
|
||||
@@ -57,24 +60,35 @@ def _guard(fn, *args, **kwargs):
|
||||
def list_dir(
|
||||
path: str = Query("/"),
|
||||
show_hidden: bool = Query(False),
|
||||
_user: User = Depends(get_current_user),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> dict:
|
||||
return _guard(device_service.browse, path, show_hidden)
|
||||
|
||||
|
||||
@router.get("/read")
|
||||
def read_file(
|
||||
request: Request,
|
||||
path: str = Query(...),
|
||||
_user: User = Depends(get_current_user),
|
||||
session: Session = Depends(get_session),
|
||||
user: User = Depends(require_admin),
|
||||
) -> 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")
|
||||
def download(
|
||||
request: Request,
|
||||
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):
|
||||
filename, chunks = _guard(file_service.open_archive, path)
|
||||
# Stream the zip as it's built so the response starts immediately
|
||||
|
||||
@@ -155,7 +155,7 @@ def stacks_updates(_user: User = Depends(get_current_user)) -> dict:
|
||||
def get_stack(
|
||||
stack_id: str,
|
||||
session: Session = Depends(get_session),
|
||||
_user: User = Depends(get_current_user),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> dict:
|
||||
stack = _get_stack_or_404(session, stack_id)
|
||||
try:
|
||||
@@ -171,7 +171,10 @@ def get_stack(
|
||||
"description": stack.description,
|
||||
"status": status,
|
||||
"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,
|
||||
"created_at": stack.created_at,
|
||||
"updated_at": stack.updated_at,
|
||||
@@ -380,8 +383,10 @@ async def service_logs(
|
||||
def export_stack(
|
||||
stack_id: str,
|
||||
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 tarfile
|
||||
import tempfile
|
||||
|
||||
@@ -98,8 +98,11 @@ def generate_yaml(
|
||||
def host_paths(
|
||||
path: str = Query("/"),
|
||||
show_hidden: bool = Query(False),
|
||||
_user: User = Depends(get_current_user),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> 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:
|
||||
return device_service.browse(path, show_hidden)
|
||||
except device_service.BrowseError as exc:
|
||||
|
||||
Reference in New Issue
Block a user