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:
@@ -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)
|
||||
> 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)
|
||||
|
||||
- **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.
|
||||
- **Settings page**: tune the update-check interval, manage webhooks, and manage
|
||||
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.
|
||||
|
||||
### 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
|
||||
highlighting picked from the extension). Binary and oversized files are
|
||||
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
|
||||
folders), upload files **or whole folders** (the directory tree is recreated
|
||||
server-side), and download any file. **Copy/cut & paste** moves files and
|
||||
folders between directories (clipboard bar + per-row copy/cut, with an
|
||||
overwrite prompt on conflict). Every mutation is audit-logged.
|
||||
- **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
|
||||
commented `/:/host_root` volume in `docker-compose.yml`). Endpoints live under
|
||||
`/api/files/*` (`list`, `read`, `write`, `mkdir`, `touch`, `rename`, `copy`,
|
||||
|
||||
Reference in New Issue
Block a user