Arcane has a Command Injection in Arcane Updater Lifecycle Labels Enables RCE
Description
Arcane provides modern docker management. Prior to 1.13.0, Arcane has a command injection in the updater service. Arcane’s updater service supported lifecycle labels com.getarcaneapp.arcane.lifecycle.pre-update and com.getarcaneapp.arcane.lifecycle.post-update that allowed defining a command to run before or after a container update. The label value is passed directly to /bin/sh -c without sanitization or validation. Because any authenticated user (not limited to administrators) can create projects through the API, an attacker can create a project that specifies one of these lifecycle labels with a malicious command. When an administrator later triggers a container update (either manually or via scheduled update checks), Arcane reads the lifecycle label and executes its value as a shell command inside the container. This vulnerability is fixed in 1.13.0.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/getarcaneapp/arcane/backendGo | < 0.0.0-20260114065515-5a9c2f92e11f | 0.0.0-20260114065515-5a9c2f92e11f |
Affected products
1- Range: v0.1.0, v0.1.1, v0.10.0, …
Patches
15a9c2f92e11ffix: remove updater lifecycle hooks (#1468)
5 files changed · +0 −626
backend/internal/services/updater_service.go+0 −40 modified@@ -654,19 +654,6 @@ func (s *UpdaterService) updateContainer(ctx context.Context, cnt container.Summ slog.DebugContext(ctx, "updateContainer: starting update", "containerId", cnt.ID, "containerName", name, "newRef", newRef, "isArcane", isArcane) - // Execute pre-update lifecycle hook - hookResult := arcaneupdater.ExecutePreUpdateCommand(ctx, dcli, cnt.ID, labels) - if hookResult.Executed { - if hookResult.SkipUpdate { - slog.InfoContext(ctx, "updateContainer: container requested skip via pre-update hook", "containerId", cnt.ID, "containerName", name) - return fmt.Errorf("container requested skip update via exit code %d", arcaneupdater.ExitCodeSkipUpdate) - } - if hookResult.Error != nil { - slog.WarnContext(ctx, "updateContainer: pre-update hook failed", "containerId", cnt.ID, "err", hookResult.Error) - // Continue with update despite hook failure (configurable in future) - } - } - originalName := inspect.Name // Get custom stop signal if configured @@ -732,13 +719,6 @@ func (s *UpdaterService) updateContainer(ctx context.Context, cnt container.Summ } _ = s.eventService.LogContainerEvent(ctx, models.EventTypeContainerStart, resp.ID, name, systemUser.ID, systemUser.Username, "0", models.JSON{"action": "updater_start"}) - // Execute post-update lifecycle hook on the new container - hookResult = arcaneupdater.ExecutePostUpdateCommand(ctx, dcli, resp.ID, labels) - if hookResult.Executed && hookResult.Error != nil { - slog.WarnContext(ctx, "updateContainer: post-update hook failed", "newContainerId", resp.ID, "err", hookResult.Error) - // Log but don't fail the update - } - _ = s.eventService.LogContainerEvent(ctx, models.EventTypeContainerUpdate, resp.ID, name, systemUser.ID, systemUser.Username, "0", models.JSON{ "oldContainerId": cnt.ID, "newContainerId": resp.ID, @@ -1136,26 +1116,6 @@ func (s *UpdaterService) restartContainersUsingOldIDs(ctx context.Context, oldID NewImages: map[string]string{"main": s.normalizeRef(p.newRef)}, } - // Lifecycle hooks for "check" phase (best-effort) - if !arcaneupdater.IsArcaneContainer(labels) { - pre := arcaneupdater.ExecutePreCheckCommand(ctx, dcli, p.cnt.ID, labels) - if pre.Executed { - if pre.SkipUpdate { - res.Status = "skipped" - res.Error = fmt.Sprintf("container requested skip via pre-check hook (exit %d)", arcaneupdater.ExitCodeSkipUpdate) - results = append(results, res) - continue - } - if pre.Error != nil { - slog.WarnContext(ctx, "restartContainersUsingOldIDs: pre-check hook failed", "container", name, "error", pre.Error.Error()) - } - } - post := arcaneupdater.ExecutePostCheckCommand(ctx, dcli, p.cnt.ID, labels) - if post.Executed && post.Error != nil { - slog.WarnContext(ctx, "restartContainersUsingOldIDs: post-check hook failed", "container", name, "error", post.Error.Error()) - } - } - if p.newRef == "" { res.Status = "skipped" res.Error = "no matching updated image"
backend/internal/utils/arcaneupdater/labels.go+0 −30 modified@@ -7,21 +7,9 @@ const ( LabelArcane = "com.getarcaneapp.arcane" // Identifies the Arcane container itself LabelUpdater = "com.getarcaneapp.arcane.updater" // Enable/disable updates (true/false) - // Lifecycle hook labels - LabelPreCheck = "com.getarcaneapp.arcane.lifecycle.pre-check" // Command to run before checking for updates - LabelPostCheck = "com.getarcaneapp.arcane.lifecycle.post-check" // Command to run after checking for updates - LabelPreUpdate = "com.getarcaneapp.arcane.lifecycle.pre-update" // Command to run before stopping container - LabelPostUpdate = "com.getarcaneapp.arcane.lifecycle.post-update" // Command to run after starting new container - - // Lifecycle timeout labels (in seconds) - LabelPreUpdateTimeout = "com.getarcaneapp.arcane.lifecycle.pre-update-timeout" - LabelPostUpdateTimeout = "com.getarcaneapp.arcane.lifecycle.post-update-timeout" - // Dependency labels LabelDependsOn = "com.getarcaneapp.arcane.depends-on" // Comma-separated list of container names this depends on LabelStopSignal = "com.getarcaneapp.arcane.stop-signal" // Custom stop signal (e.g., SIGINT) - - ExitCodeSkipUpdate = 75 ) // IsArcaneContainer checks if the container is the Arcane application itself @@ -59,24 +47,6 @@ func IsUpdateDisabled(labels map[string]string) bool { return false } -// GetLifecycleCommand returns the lifecycle command for the given label, if set -func GetLifecycleCommand(labels map[string]string, lifecycleLabel string) []string { - if labels == nil { - return nil - } - for k, v := range labels { - if strings.EqualFold(k, lifecycleLabel) { - v = strings.TrimSpace(v) - if v == "" { - return nil - } - // Simple shell-style split (use /bin/sh -c for complex commands) - return []string{"/bin/sh", "-c", v} - } - } - return nil -} - // GetStopSignal returns the custom stop signal if set, otherwise empty string func GetStopSignal(labels map[string]string) string { if labels == nil {
backend/internal/utils/arcaneupdater/labels_test.go+0 −85 modified@@ -143,91 +143,6 @@ func TestIsUpdateDisabled(t *testing.T) { } } -func TestGetLifecycleCommand(t *testing.T) { - tests := []struct { - name string - labels map[string]string - lifecycleLabel string - want []string - }{ - { - name: "nil labels", - labels: nil, - lifecycleLabel: LabelPreUpdate, - want: nil, - }, - { - name: "empty labels", - labels: map[string]string{}, - lifecycleLabel: LabelPreUpdate, - want: nil, - }, - { - name: "no matching label", - labels: map[string]string{"other": "value"}, - lifecycleLabel: LabelPreUpdate, - want: nil, - }, - { - name: "empty command", - labels: map[string]string{LabelPreUpdate: ""}, - lifecycleLabel: LabelPreUpdate, - want: nil, - }, - { - name: "whitespace only command", - labels: map[string]string{LabelPreUpdate: " "}, - lifecycleLabel: LabelPreUpdate, - want: nil, - }, - { - name: "valid command", - labels: map[string]string{LabelPreUpdate: "echo 'before update'"}, - lifecycleLabel: LabelPreUpdate, - want: []string{"/bin/sh", "-c", "echo 'before update'"}, - }, - { - name: "command with leading/trailing whitespace", - labels: map[string]string{LabelPreUpdate: " echo test "}, - lifecycleLabel: LabelPreUpdate, - want: []string{"/bin/sh", "-c", "echo test"}, - }, - { - name: "case insensitive label key", - labels: map[string]string{"COM.GETARCANEAPP.ARCANE.LIFECYCLE.PRE-UPDATE": "echo test"}, - lifecycleLabel: LabelPreUpdate, - want: []string{"/bin/sh", "-c", "echo test"}, - }, - { - name: "pre-check command", - labels: map[string]string{LabelPreCheck: "curl -f http://health"}, - lifecycleLabel: LabelPreCheck, - want: []string{"/bin/sh", "-c", "curl -f http://health"}, - }, - { - name: "post-update command", - labels: map[string]string{LabelPostUpdate: "docker exec -it nginx nginx -s reload"}, - lifecycleLabel: LabelPostUpdate, - want: []string{"/bin/sh", "-c", "docker exec -it nginx nginx -s reload"}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := GetLifecycleCommand(tt.labels, tt.lifecycleLabel) - if len(got) != len(tt.want) { - t.Errorf("GetLifecycleCommand() len = %v, want %v", len(got), len(tt.want)) - return - } - for i := range got { - if got[i] != tt.want[i] { - t.Errorf("GetLifecycleCommand()[%d] = %v, want %v", i, got[i], tt.want[i]) - } - } - }) - } -} - func TestGetStopSignal(t *testing.T) { tests := []struct { name string
backend/internal/utils/arcaneupdater/lifecycle.go+0 −211 removed@@ -1,211 +0,0 @@ -package arcaneupdater - -import ( - "bytes" - "context" - "fmt" - "log/slog" - "strconv" - "strings" - "time" - - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/client" -) - -const ( - DefaultPreUpdateTimeout = 60 * time.Second - DefaultPostUpdateTimeout = 60 * time.Second -) - -// LifecycleHookResult contains the result of executing a lifecycle hook -type LifecycleHookResult struct { - Executed bool - SkipUpdate bool // True if exit code was ExitCodeSkipUpdate (75) - ExitCode int - Output string - Error error -} - -// ExecutePreUpdateCommand runs the pre-update lifecycle hook on a container -// Returns SkipUpdate=true if the command exits with code 75 (EX_TEMPFAIL) -func ExecutePreUpdateCommand(ctx context.Context, dcli *client.Client, containerID string, labels map[string]string) LifecycleHookResult { - cmd := GetLifecycleCommand(labels, LabelPreUpdate) - if len(cmd) == 0 { - return LifecycleHookResult{Executed: false} - } - - timeout := getTimeout(labels, LabelPreUpdateTimeout, DefaultPreUpdateTimeout) - - slog.DebugContext(ctx, "ExecutePreUpdateCommand: running pre-update hook", - "containerID", containerID, - "command", cmd, - "timeout", timeout) - - return executeLifecycleCommand(ctx, dcli, containerID, cmd, timeout) -} - -// ExecutePostUpdateCommand runs the post-update lifecycle hook on a container -func ExecutePostUpdateCommand(ctx context.Context, dcli *client.Client, containerID string, labels map[string]string) LifecycleHookResult { - cmd := GetLifecycleCommand(labels, LabelPostUpdate) - if len(cmd) == 0 { - return LifecycleHookResult{Executed: false} - } - - timeout := getTimeout(labels, LabelPostUpdateTimeout, DefaultPostUpdateTimeout) - - slog.DebugContext(ctx, "ExecutePostUpdateCommand: running post-update hook", - "containerID", containerID, - "command", cmd, - "timeout", timeout) - - return executeLifecycleCommand(ctx, dcli, containerID, cmd, timeout) -} - -// ExecutePreCheckCommand runs the pre-check lifecycle hook on a container -func ExecutePreCheckCommand(ctx context.Context, dcli *client.Client, containerID string, labels map[string]string) LifecycleHookResult { - cmd := GetLifecycleCommand(labels, LabelPreCheck) - if len(cmd) == 0 { - return LifecycleHookResult{Executed: false} - } - - return executeLifecycleCommand(ctx, dcli, containerID, cmd, DefaultPreUpdateTimeout) -} - -// ExecutePostCheckCommand runs the post-check lifecycle hook on a container -func ExecutePostCheckCommand(ctx context.Context, dcli *client.Client, containerID string, labels map[string]string) LifecycleHookResult { - cmd := GetLifecycleCommand(labels, LabelPostCheck) - if len(cmd) == 0 { - return LifecycleHookResult{Executed: false} - } - - return executeLifecycleCommand(ctx, dcli, containerID, cmd, DefaultPostUpdateTimeout) -} - -func executeLifecycleCommand(ctx context.Context, dcli *client.Client, containerID string, cmd []string, timeout time.Duration) LifecycleHookResult { - result := LifecycleHookResult{Executed: true} - - // Create exec configuration - execConfig := container.ExecOptions{ - Cmd: cmd, - AttachStdout: true, - AttachStderr: true, - } - - // Create the exec instance - execResp, err := dcli.ContainerExecCreate(ctx, containerID, execConfig) - if err != nil { - result.Error = fmt.Errorf("failed to create exec: %w", err) - slog.WarnContext(ctx, "executeLifecycleCommand: failed to create exec", - "containerID", containerID, - "error", err) - return result - } - - // Attach to the exec instance to get output - attachResp, err := dcli.ContainerExecAttach(ctx, execResp.ID, container.ExecAttachOptions{}) - if err != nil { - result.Error = fmt.Errorf("failed to attach to exec: %w", err) - slog.WarnContext(ctx, "executeLifecycleCommand: failed to attach to exec", - "containerID", containerID, - "error", err) - return result - } - defer attachResp.Close() - - // Read output with timeout - outputChan := make(chan []byte, 1) - errChan := make(chan error, 1) - - go func() { - var buf bytes.Buffer - _, err := buf.ReadFrom(attachResp.Reader) - if err != nil { - errChan <- err - return - } - outputChan <- buf.Bytes() - }() - - // Wait for output or timeout - timeoutCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - select { - case <-timeoutCtx.Done(): - result.Error = fmt.Errorf("lifecycle command timed out after %v", timeout) - slog.WarnContext(ctx, "executeLifecycleCommand: command timed out", - "containerID", containerID, - "timeout", timeout) - return result - case err := <-errChan: - result.Error = fmt.Errorf("error reading output: %w", err) - return result - case output := <-outputChan: - result.Output = string(output) - } - - // Inspect the exec to get exit code - execInspect, err := dcli.ContainerExecInspect(ctx, execResp.ID) - if err != nil { - result.Error = fmt.Errorf("failed to inspect exec: %w", err) - slog.WarnContext(ctx, "executeLifecycleCommand: failed to inspect exec", - "containerID", containerID, - "error", err) - return result - } - - result.ExitCode = execInspect.ExitCode - - // Check for skip update signal (exit code 75 = EX_TEMPFAIL) - if result.ExitCode == ExitCodeSkipUpdate { - result.SkipUpdate = true - slog.InfoContext(ctx, "executeLifecycleCommand: container requested skip update", - "containerID", containerID, - "exitCode", result.ExitCode) - return result - } - - // Non-zero exit code (other than 75) is an error - if result.ExitCode != 0 { - result.Error = fmt.Errorf("lifecycle command exited with code %d", result.ExitCode) - slog.WarnContext(ctx, "executeLifecycleCommand: command failed", - "containerID", containerID, - "exitCode", result.ExitCode, - "output", result.Output) - return result - } - - slog.DebugContext(ctx, "executeLifecycleCommand: command completed successfully", - "containerID", containerID, - "exitCode", result.ExitCode) - - return result -} - -func getTimeout(labels map[string]string, timeoutLabel string, defaultTimeout time.Duration) time.Duration { - if labels == nil { - return defaultTimeout - } - - for k, v := range labels { - if k == timeoutLabel { - v = strings.TrimSpace(v) - if v == "" { - return defaultTimeout - } - - // Try parsing as seconds - if secs, err := strconv.Atoi(v); err == nil && secs > 0 { - return time.Duration(secs) * time.Second - } - - // Try parsing as duration string - if d, err := time.ParseDuration(v); err == nil && d > 0 { - return d - } - } - } - - return defaultTimeout -}
backend/internal/utils/arcaneupdater/lifecycle_test.go+0 −260 removed@@ -1,260 +0,0 @@ -package arcaneupdater - -import ( - "context" - "errors" - "strings" - "testing" - "time" -) - -func TestGetTimeout(t *testing.T) { - tests := []struct { - name string - labels map[string]string - label string - defaultValue time.Duration - want time.Duration - }{ - { - name: "no label - use default", - labels: map[string]string{}, - label: LabelPreUpdateTimeout, - defaultValue: 60 * time.Second, - want: 60 * time.Second, - }, - { - name: "nil labels - use default", - labels: nil, - label: LabelPreUpdateTimeout, - defaultValue: 60 * time.Second, - want: 60 * time.Second, - }, - { - name: "valid timeout 30", - labels: map[string]string{LabelPreUpdateTimeout: "30"}, - label: LabelPreUpdateTimeout, - defaultValue: 60 * time.Second, - want: 30 * time.Second, - }, - { - name: "valid timeout 120", - labels: map[string]string{LabelPostUpdateTimeout: "120"}, - label: LabelPostUpdateTimeout, - defaultValue: 60 * time.Second, - want: 120 * time.Second, - }, - { - name: "invalid timeout - use default", - labels: map[string]string{LabelPreUpdateTimeout: "invalid"}, - label: LabelPreUpdateTimeout, - defaultValue: 60 * time.Second, - want: 60 * time.Second, - }, - { - name: "negative timeout - use default", - labels: map[string]string{LabelPreUpdateTimeout: "-10"}, - label: LabelPreUpdateTimeout, - defaultValue: 60 * time.Second, - want: 60 * time.Second, - }, - { - name: "zero timeout - use default", - labels: map[string]string{LabelPreUpdateTimeout: "0"}, - label: LabelPreUpdateTimeout, - defaultValue: 60 * time.Second, - want: 60 * time.Second, - }, - { - name: "timeout with whitespace", - labels: map[string]string{LabelPreUpdateTimeout: " 45 "}, - label: LabelPreUpdateTimeout, - defaultValue: 60 * time.Second, - want: 45 * time.Second, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := getTimeout(tt.labels, tt.label, tt.defaultValue) - if got != tt.want { - t.Errorf("getTimeout() = %v, want %v", got, tt.want) - } - }) - } -} - -func TestLifecycleHookResult_SkipUpdate(t *testing.T) { - tests := []struct { - name string - result LifecycleHookResult - wantSkip bool - }{ - { - name: "exit code 75 - should skip", - result: LifecycleHookResult{ - Executed: true, - SkipUpdate: true, - ExitCode: ExitCodeSkipUpdate, - }, - wantSkip: true, - }, - { - name: "exit code 0 - should not skip", - result: LifecycleHookResult{ - Executed: true, - SkipUpdate: false, - ExitCode: 0, - }, - wantSkip: false, - }, - { - name: "not executed - should not skip", - result: LifecycleHookResult{ - Executed: false, - SkipUpdate: false, - }, - wantSkip: false, - }, - { - name: "error occurred - should not skip", - result: LifecycleHookResult{ - Executed: true, - ExitCode: 1, - Error: errors.New("command failed"), - }, - wantSkip: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := tt.result.SkipUpdate; got != tt.wantSkip { - t.Errorf("LifecycleHookResult.SkipUpdate = %v, want %v", got, tt.wantSkip) - } - }) - } -} - -func TestExecuteLifecycleCommands_NoCommand(t *testing.T) { - ctx := context.Background() - - // Test with no pre-update command - result := ExecutePreUpdateCommand(ctx, nil, "test-container", map[string]string{}) - if result.Executed { - t.Error("ExecutePreUpdateCommand() should not execute when no command is configured") - } - - // Test with no post-update command - result = ExecutePostUpdateCommand(ctx, nil, "test-container", map[string]string{}) - if result.Executed { - t.Error("ExecutePostUpdateCommand() should not execute when no command is configured") - } - - // Test with no pre-check command - result = ExecutePreCheckCommand(ctx, nil, "test-container", map[string]string{}) - if result.Executed { - t.Error("ExecutePreCheckCommand() should not execute when no command is configured") - } - - // Test with no post-check command - result = ExecutePostCheckCommand(ctx, nil, "test-container", map[string]string{}) - if result.Executed { - t.Error("ExecutePostCheckCommand() should not execute when no command is configured") - } -} - -func TestLifecycleCommandOutput(t *testing.T) { - tests := []struct { - name string - output string - wantContains string - }{ - { - name: "output with timestamp", - output: "2024-01-01T00:00:00Z Starting backup...", - wantContains: "Starting backup", - }, - { - name: "multi-line output", - output: "Line 1\nLine 2\nLine 3", - wantContains: "Line 2", - }, - { - name: "empty output", - output: "", - wantContains: "", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result := LifecycleHookResult{ - Executed: true, - Output: tt.output, - } - if tt.wantContains != "" && !strings.Contains(result.Output, tt.wantContains) { - t.Errorf("Output does not contain expected string: want %q in %q", tt.wantContains, result.Output) - } - }) - } -} - -func TestDefaultTimeouts(t *testing.T) { - if DefaultPreUpdateTimeout != 60*time.Second { - t.Errorf("DefaultPreUpdateTimeout = %v, want %v", DefaultPreUpdateTimeout, 60*time.Second) - } - if DefaultPostUpdateTimeout != 60*time.Second { - t.Errorf("DefaultPostUpdateTimeout = %v, want %v", DefaultPostUpdateTimeout, 60*time.Second) - } -} - -func TestExitCodeSkipUpdate(t *testing.T) { - if ExitCodeSkipUpdate != 75 { - t.Errorf("ExitCodeSkipUpdate = %d, want 75", ExitCodeSkipUpdate) - } -} - -func TestLifecycleHookResult_ErrorHandling(t *testing.T) { - tests := []struct { - name string - result LifecycleHookResult - wantError bool - }{ - { - name: "no error", - result: LifecycleHookResult{ - Executed: true, - ExitCode: 0, - Error: nil, - }, - wantError: false, - }, - { - name: "with error", - result: LifecycleHookResult{ - Executed: true, - ExitCode: 1, - Error: errors.New("command failed"), - }, - wantError: true, - }, - { - name: "not executed no error", - result: LifecycleHookResult{ - Executed: false, - Error: nil, - }, - wantError: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - hasError := tt.result.Error != nil - if hasError != tt.wantError { - t.Errorf("LifecycleHookResult has error = %v, want %v", hasError, tt.wantError) - } - }) - } -}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/advisories/GHSA-gjqq-6r35-w3r8ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-23520ghsaADVISORY
- github.com/getarcaneapp/arcane/commit/5a9c2f92e11f86f8997da8c672844468f930b7e4ghsax_refsource_MISCWEB
- github.com/getarcaneapp/arcane/pull/1468ghsax_refsource_MISCWEB
- github.com/getarcaneapp/arcane/releases/tag/v1.13.0ghsax_refsource_MISCWEB
- github.com/getarcaneapp/arcane/security/advisories/GHSA-gjqq-6r35-w3r8ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.