GHSA-9MVM-4GWG-V8MP
Vulnerability from github – Published: 2026-05-18 13:59 – Updated: 2026-05-18 13:59Summary
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:
BrowseDirectoryInput.Path(query:path) —backend/internal/huma/handlers/volumes.go:148VolumeHandler.BrowseDirectorycallsvolumeService.ListDirectory(ctx, volumeName, input.Path)—backend/internal/huma/handlers/volumes.go:858-865. Note the route registration at line 412–419 only declaresBearerAuth/ApiKeyAuth; there is nocheckAdmin(ctx)call (compare withcustomize.go,system.go,swarm.go, etc., which do enforce admin).VolumeService.ListDirectoryruns the user-supplied path throughsanitizeBrowsePathInternal, then joins it under/volume, quotes it withstrconv.Quote, and embeds it into ash -ccommand:
// 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):
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 /volumeby sendingpath=.toDELETE /environments/{id}/volumes/{volumeName}/browse, which any authenticated user can also reach.
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/getarcaneapp/arcane/backend"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"last_affected": "1.18.1"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45626"
],
"database_specific": {
"cwe_ids": [
"CWE-78"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-18T13:59:22Z",
"nvd_published_at": null,
"severity": "MODERATE"
},
"details": "## Summary\n\n`GET /environments/{id}/volumes/{volumeName}/browse` accepts a `path` query parameter that is passed to a shell command (`sh -c \"find \u2026 | while \u2026\"`) 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.\n\n## Details\n\nThe execution flow is:\n\n1. `BrowseDirectoryInput.Path` (query: `path`) \u2014 `backend/internal/huma/handlers/volumes.go:148`\n2. `VolumeHandler.BrowseDirectory` calls `volumeService.ListDirectory(ctx, volumeName, input.Path)` \u2014 `backend/internal/huma/handlers/volumes.go:858-865`. Note the route registration at line 412\u2013419 only declares `BearerAuth`/`ApiKeyAuth`; there is no `checkAdmin(ctx)` call (compare with `customize.go`, `system.go`, `swarm.go`, etc., which do enforce admin).\n3. `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:\n\n```go\n// backend/internal/services/volume_service.go:286-300\nsanitizedPath, err := s.sanitizeBrowsePathInternal(dirPath)\n...\ntargetPath := path.Join(\"/volume\", sanitizedPath)\nquotedPath := strconv.Quote(targetPath)\ncmd := []string{\"sh\", \"-c\", fmt.Sprintf(\n \"find %s -mindepth 1 -maxdepth 1 | while IFS= read -r f; do out=$(stat -c \\\"%%s %%Y %%f %%A\\\" -- \\\"$f\\\" 2\u003e/dev/null) || continue; printf \\\"%%s\\\\0%%s\\\\0\\\" \\\"$f\\\" \\\"$out\\\"; done\",\n quotedPath)}\nstdout, _, err := s.execInContainerInternal(ctx, containerID, cmd)\n```\n\nThe sanitiser is insufficient (`backend/internal/services/volume_service.go:1448-1467`):\n\n```go\nfunc (s *VolumeService) sanitizeBrowsePathInternal(input string) (string, error) {\n trimmed := strings.TrimSpace(input)\n if trimmed == \"\" || trimmed == \"/\" { return \"/\", nil }\n cleaned := path.Clean(trimmed)\n if !path.IsAbs(cleaned) { cleaned = \"/\" + cleaned }\n if strings.Contains(cleaned, \"/../\") || strings.HasSuffix(cleaned, \"/..\") || cleaned == \"/..\" {\n return \"\", fmt.Errorf(\"invalid path: path traversal not allowed\")\n }\n if !strings.HasPrefix(cleaned, \"/\") { return \"\", fmt.Errorf(\"invalid path: must be absolute\") }\n return cleaned, nil\n}\n```\n\nOnly `../` patterns are filtered. `$(...)`, backticks, `;`, `\u0026`, `|`, `\u003e`, 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 \u2014 and bash performs `$(...)` command substitution inside double quotes.\n\nFor the input `/$( id)`:\n- `sanitizeBrowsePathInternal` returns `/$( id)` (no `../` present).\n- `path.Join(\"/volume\", \"/$( id)\")` \u2192 `/volume/$( id)`.\n- `strconv.Quote(...)` \u2192 `\"/volume/$( id)\"`.\n- The shell runs `find \"/volume/$( id)\" \u2026`, which expands to `find \"/volume/uid=0(root) gid=0(root) groups=0(root)\" \u2026`. `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: \u2026` error, then re-wrapped by `ListDirectory` and returned to the client as a 500 response body.\n\nErrors from the handler at `volumes.go:863-864` are returned via `huma.Error500InternalServerError(err.Error())`, so the substituted output is reflected in plaintext.\n\n**Blast radius / mitigations actually present:**\n- 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.\n- Therefore the injection executes inside an isolated, network-disabled container that already has read access to the same files the browse API exposes.\n- 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.).\n- 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`.\n\n**Secondary issue (same sanitiser):** `DeleteFile` (`volume_service.go:924-963`) defends against deleting volume root with `if sanitizedPath == \"/\"`. Input `path=.` yields `path.Clean(\".\") == \".\"` \u2192 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.\n\n## Impact\n\n- Authenticated user (any role, including non-admin) can execute arbitrary shell commands inside the per-volume helper container.\n- Output of those commands is reflected in HTTP 500 error bodies \u2014 usable as an exfiltration channel.\n- 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.\n- 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.\n- 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.",
"id": "GHSA-9mvm-4gwg-v8mp",
"modified": "2026-05-18T13:59:22Z",
"published": "2026-05-18T13:59:22Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/getarcaneapp/arcane/security/advisories/GHSA-9mvm-4gwg-v8mp"
},
{
"type": "PACKAGE",
"url": "https://github.com/getarcaneapp/arcane"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:L",
"type": "CVSS_V3"
}
],
"summary": "Arcane Backend: OS Command Injection in Volume Browser ListDirectory via path query parameter"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.