Files
stackpilot/backend/tests/test_browse_sandbox.py
T
menzeljandClaude Opus 5 60a7ccff93
CI / check (push) Successful in 7m40s
CI / build-and-push (push) Successful in 1m55s
Add a test suite, a linter and a CI gate in front of the build (0.45.0)
The repo had no tests, no lint config, and a CI that went straight from push
to docker push. That is the reason F1 could ship: authorization lives in the
routers, each of 171 routes independently picks require_admin or
get_current_user, and nothing checked the choice was right.

670 tests, no Docker daemon needed. The app is driven through TestClient
without entering it as a context manager, which skips the lifespan — no
background loops, no socket — and conftest points DATA_DIR/STACKS_DIR at a
temp directory before anything is imported.

test_route_authorization.py is the load-bearing one. Rather than 171 implied
decisions it states the policy once — every route requires admin unless it is
listed in USER_READABLE or PUBLIC — and fails on any route that disagrees. A
new route defaults to admin, which is the safe direction; what it catches is a
route written with get_current_user that nobody weighed against "can this
return a credential". Writing the allowlist meant auditing all 53 user-readable
routes, which turned up one more leak: GET /api/templates/{id} returns a
template's env, and "save stack as template" snapshots the stack's real .env
into it. Now admin-only; the listing stays open.

test_agent_authorization.py pins the same invariant on the agent, where the
whole access model is one shared token declared per route and a single
forgotten Depends(verify_token) would hand over the host.

Both were checked by reintroducing the bug: re-opening /api/files/read fails
three tests with actionable messages, dropping a token guard fails two.

test_bundled_templates.py covers the 83 templates — parse, image per service,
.env.example in sync with what compose reads, every bind-mounted file actually
shipped, and no working default password. It found one on its first run:
authentik shipped PG_PASS=change-me and AUTHENTIK_SECRET_KEY=change-me against
a compose that marks both required, so the stack would have come up with a
known password instead of refusing to start. Fixed.

The rest ports the ad-hoc harnesses from 0.44.0 into permanent tests (crypto
round-trip incl. plaintext passthrough and key-loss handling, the browse
sandbox) and covers compose_service's slug/status/file handling and
secret_service's name validation.

ruff is configured as a floor, not a style bar: F, E9 and B only. Import
sorting is deliberately out — it is style, and enabling it would rewrite the
imports of nine files that have nothing else wrong. The 12 findings it did have
are fixed here (unused imports, an unused local, four raise-without-from that
were swallowing exception context).

CI now runs check (ruff, pytest, tsc) and only builds if it passes.

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

117 lines
3.9 KiB
Python

"""The host-browser sandbox.
Two independent gates, both in :mod:`services.device_service`:
* ``_is_allowed`` — is the logical path under one of ``ALLOWED_BROWSE_ROOTS``?
* ``_real_root`` — maps the logical path into the container's view, and refuses
anything landing inside StackPilot's own ``DATA_DIR``.
The second gate exists because the API deliberately never hands out what lives
there (agent tokens come back as a bool, destination secrets come back masked),
so the file browser must not be the way around that — for admins either.
"""
from __future__ import annotations
import pytest
@pytest.fixture
def sandbox(monkeypatch):
"""Pin the sandbox settings so the tests don't depend on deployment config."""
from config import settings
from services import device_service
monkeypatch.setattr(settings, "DATA_DIR", "/data", raising=False)
monkeypatch.setattr(settings, "HOST_ROOT_PREFIX", "", raising=False)
monkeypatch.setattr(
settings, "ALLOWED_BROWSE_ROOTS", ["/mnt", "/media", "/srv", "/opt", "/home"],
raising=False,
)
return device_service
def _refused(mod, path: str) -> bool:
"""Whether the sandbox rejects a path, by either gate."""
if not mod._is_allowed(path):
return True
try:
mod._real_root(path)
return False
except mod.BrowseError:
return True
@pytest.mark.parametrize(
"path",
[
"/data",
"/data/stackpilot.db",
"/data/secret_key",
"/opt/../data/stackpilot.db", # traversal into it
],
)
def test_own_data_dir_is_refused(sandbox, path):
assert _refused(sandbox, path), f"{path} would expose StackPilot's own database"
@pytest.mark.parametrize(
"path",
["/etc/shadow", "/root/.ssh/id_rsa", "/var/run/docker.sock", "/proc/self/environ"],
)
def test_paths_outside_the_roots_are_refused(sandbox, path):
assert _refused(sandbox, path)
@pytest.mark.parametrize(
"path",
["/opt", "/opt/stacks/jellyfin/.env", "/srv/media", "/mnt", "/home/someone"],
)
def test_allowed_roots_stay_reachable(sandbox, path):
assert not _refused(sandbox, path), f"{path} should still be browsable"
def test_slash_in_the_roots_opens_everything_except_the_data_dir(sandbox, monkeypatch):
"""A "/" entry switches the sandbox off — that is why it is not a default.
It still must not open StackPilot's own data directory, since that gate is
independent of the root list.
"""
from config import settings
monkeypatch.setattr(settings, "ALLOWED_BROWSE_ROOTS", ["/"], raising=False)
assert not _refused(sandbox, "/etc/shadow")
assert _refused(sandbox, "/data/stackpilot.db")
def test_host_root_prefix_maps_paths_into_the_container(sandbox, monkeypatch):
"""With the host mounted at a prefix, /data is a host path, not our own.
The container's own ``/data`` becomes unreachable by any logical path in
this mode, so the refusal correctly does not apply.
"""
from config import settings
monkeypatch.setattr(settings, "HOST_ROOT_PREFIX", "/host_root", raising=False)
monkeypatch.setattr(settings, "ALLOWED_BROWSE_ROOTS", ["/data", "/opt"], raising=False)
assert sandbox._real_root("/data/foo") == "/host_root/data/foo"
def test_file_service_shares_the_same_gate(sandbox):
"""``file_service`` must not have its own, weaker path check."""
from services import file_service
with pytest.raises(file_service.BrowseError):
file_service._safe_real("/data/stackpilot.db")
with pytest.raises(file_service.BrowseError):
file_service._safe_real("/etc/shadow")
assert file_service._safe_real("/opt/stacks") == "/opt/stacks"
@pytest.mark.parametrize("name", ["..", ".", "a/b", "a\\b", ""])
def test_child_rejects_anything_but_a_single_component(sandbox, name):
"""Upload and rename build paths through ``_child``; traversal dies here."""
from services import file_service
with pytest.raises(file_service.BrowseError):
file_service._child("/opt", name)