GHSA-9mvm-4gwg-v8mpMediumCVSS 6.3

Arcane Backend: OS Command Injection in Volume Browser ListDirectory via path query parameter

Published
May 18, 2026
Last Modified
May 18, 2026

🔗 CVE IDs covered (1)

📋 Description

## Summary `GET /environments/{id}/volumes/{volumeName}/browse` accepts a `path` query parameter that is passed to a shell command (`sh -c "find … | while …"`) inside an Arcane helper container. The path sanitiser blocks `../` traversal but does not strip Bourne-shell metacharacters such as `$()` or backticks, and `strconv.Quote` only escapes Go string metacharacters, not shell substitution sequences. Any authenticated user with access to a browseable volume can execute arbitrary commands inside the helper container; command output is reflected back in the 500 error body. ## Details The execution flow is: 1. `BrowseDirectoryInput.Path` (query: `path`) — `backend/internal/huma/handlers/volumes.go:148` 2. `VolumeHandler.BrowseDirectory` calls `volumeService.ListDirectory(ctx, volumeName, input.Path)` — `backend/internal/huma/handlers/volumes.go:858-865`. Note the route registration at line 412–419 only declares `BearerAuth`/`ApiKeyAuth`; there is no `checkAdmin(ctx)` call (compare with `customize.go`, `system.go`, `swarm.go`, etc., which do enforce admin). 3. `VolumeService.ListDirectory` runs the user-supplied path through `sanitizeBrowsePathInternal`, then joins it under `/volume`, quotes it with `strconv.Quote`, and embeds it into a `sh -c` command: ```go // backend/internal/services/volume_service.go:286-300 sanitizedPath, err := s.sanitizeBrowsePathInternal(dirPath) ... targetPath := path.Join("/volume", sanitizedPath) quotedPath := strconv.Quote(targetPath) cmd := []string{"sh", "-c", fmt.Sprintf( "find %s -mindepth 1 -maxdepth 1 | while IFS= read -r f; do out=$(stat -c \"%%s %%Y %%f %%A\" -- \"$f\" 2>/dev/null) || continue; printf \"%%s\\0%%s\\0\" \"$f\" \"$out\"; done", quotedPath)} stdout, _, err := s.execInContainerInternal(ctx, containerID, cmd) ``` The sanitiser is insufficient (`backend/internal/services/volume_service.go:1448-1467`): ```go func (s *VolumeService) sanitizeBrowsePathInternal(input string) (string, error) { trimmed := strings.TrimSpace(input) if trimmed == "" || trimmed == "/" { return "/", nil } cleaned := path.Clean(trimmed) if !path.IsAbs(cleaned) { cleaned = "/" + cleaned } if strings.Contains(cleaned, "/../") || strings.HasSuffix(cleaned, "/..") || cleaned == "/.." { return "", fmt.Errorf("invalid path: path traversal not allowed") } if !strings.HasPrefix(cleaned, "/") { return "", fmt.Errorf("invalid path: must be absolute") } return cleaned, nil } ``` Only `../` patterns are filtered. `$(...)`, backticks, `;`, `&`, `|`, `>`, etc. all pass through unchanged. `strconv.Quote` then wraps the path in Go-style double quotes, which `sh -c` interprets as a regular double-quoted string — and bash performs `$(...)` command substitution inside double quotes. For the input `/$( id)`: - `sanitizeBrowsePathInternal` returns `/$( id)` (no `../` present). - `path.Join("/volume", "/$( id)")` → `/volume/$( id)`. - `strconv.Quote(...)` → `"/volume/$( id)"`. - The shell runs `find "/volume/$( id)" …`, which expands to `find "/volume/uid=0(root) gid=0(root) groups=0(root)" …`. `find` fails because that path does not exist; the stderr containing the substituted command output is propagated by `execInContainerInternal` (volume_service.go:910-918) into a `command exited with code N: …` error, then re-wrapped by `ListDirectory` and returned to the client as a 500 response body. Errors from the handler at `volumes.go:863-864` are returned via `huma.Error500InternalServerError(err.Error())`, so the substituted output is reflected in plaintext. **Blast radius / mitigations actually present:** - The helper container is created by `createTempContainerInternal` with `NetworkDisabled: true`, no privileged mode, no Docker socket mount, only the target Docker volume bind-mounted (`:ro` for browse). It is auto-removed. - Therefore the injection executes inside an isolated, network-disabled container that already has read access to the same files the browse API exposes. - However: the injection grants arbitrary command execution within that container (well beyond the find/stat/readlink/head primitives the API exposes), enables data exfiltration via error-message side channel, and lets an attacker probe the helper image / volume in ways the legitimate API forbids (e.g. read symlink targets the API explicitly censors at `volume_service.go:336-356`, read past size limits, etc.). - A non-admin authenticated Arcane user is sufficient (no role check on the volumes browser routes), which makes this a privilege/capability extension for users who otherwise cannot run arbitrary `docker exec`. **Secondary issue (same sanitiser):** `DeleteFile` (`volume_service.go:924-963`) defends against deleting volume root with `if sanitizedPath == "/"`. Input `path=.` yields `path.Clean(".") == "."` → prefixed to `/.`, which fails the `== "/"` check, then `path.Join("/volume", "/.") == "/volume"`, so the executed command is `rm -rf /volume`, recursively deleting all volume contents. This is a separate logic flaw worth fixing alongside the sanitiser hardening but is reported here only for completeness. ## Impact - Authenticated user (any role, including non-admin) can execute arbitrary shell commands inside the per-volume helper container. - Output of those commands is reflected in HTTP 500 error bodies — usable as an exfiltration channel. - Attacker gains capabilities the legitimate API withholds: bypass the symlink-target censoring at `volume_service.go:336-356`, bypass per-file byte limits, enumerate the helper image, mount-time inspection, etc. - No host compromise: the container has `NetworkDisabled: true`, no privileged flag, no Docker socket; the volume is bind-mounted read-only for browse. Confidentiality/integrity/availability impact is therefore limited (CVSS C:L / I:L / A:L) but real. - The same insufficient sanitiser additionally permits a destructive `rm -rf /volume` by sending `path=.` to `DELETE /environments/{id}/volumes/{volumeName}/browse`, which any authenticated user can also reach.

🎯 Affected products1

  • go/github.com/getarcaneapp/arcane/backend:<= 1.18.1

🔗 References (2)