Beszel Vulnerable to Docker API Path Traversal via Unsanitized Container ID
Description
Beszel is a server monitoring platform. Prior to version 0.18.2, the hub's authenticated API endpoints GET /api/beszel/containers/logs and GET /api/beszel/containers/info pass the user-supplied "container" query parameter to the agent without validation. The agent constructs Docker Engine API URLs using fmt.Sprintf with the raw value instead of url.PathEscape(). Since Go's http.Client does not sanitize ../ sequences from URL paths sent over unix sockets, an authenticated user (including readonly role) can traverse to arbitrary Docker API endpoints on agent hosts, exposing sensitive infrastructure details. Version 0.18.4 fixes the issue.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Beszel v0.18.2 and earlier allow authenticated users to traverse Docker API endpoints via unsanitized container ID parameter, exposing sensitive infrastructure details.
The vulnerability exists in the Beszel server monitoring platform's hub API endpoints GET /api/beszel/containers/logs and GET /api/beszel/containers/info. These endpoints pass the user-supplied container query parameter directly to the agent without any validation beyond checking for emptiness [1][3]. The agent then constructs Docker Engine API URLs using Go's fmt.Sprintf with the raw container ID value instead of properly escaping it with url.PathEscape(), as shown in the agent code at agent/docker.go:651-652 and 682-683 [3].
Go's http.Client does not sanitize ../ sequences from URL paths when sent over Unix sockets, allowing path traversal [1]. An authenticated user (including those with a readonly role) can supply a crafted container ID like ../../version to reach arbitrary Docker API endpoints on the agent host. For example, container=../../version?x= causes the agent to request http://localhost/containers/../../version?x=, which the Docker daemon resolves to the /version endpoint [3].
The [2] commit introduces validation in buildDockerContainerEndpoint that rejects container IDs containing invalid characters (such as / or .) with an error message "invalid container id", and adds unit tests to confirm that path traversal attempts are blocked. The fix also includes a recordingRoundTripper test helper to verify that the escaped path is correctly used in requests to the Docker API [2].
Version 0.18.4 addresses the issue by adding proper validation of the container ID before constructing the Docker API URL, preventing the path traversal [1][3]. Users should upgrade to 0.18.4 or later. No workarounds are mentioned in the advisories. The [4] reference provides general information about the Beszel platform but does not include vulnerability-specific details.
Successful exploitation could leak sensitive infrastructure details such as the Docker daemon's version (including Go and OS/kernel versions), system information (hostname, OS type, number of containers, network configurations), or access to any other Docker API endpoint that does not require additional authentication [1][3].
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/henrygd/beszelGo | < 0.18.4 | 0.18.4 |
Affected products
2Patches
1311095cfdddaharden against docker api path traversal
4 files changed · +174 −3
agent/docker.go+36 −2 modified@@ -28,6 +28,7 @@ import ( // ansiEscapePattern matches ANSI escape sequences (colors, cursor movement, etc.) // This includes CSI sequences like \x1b[...m and simple escapes like \x1b[K var ansiEscapePattern = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b[@-Z\\-_]`) +var dockerContainerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`) const ( // Docker API timeout in milliseconds @@ -649,9 +650,34 @@ func getDockerHost() string { return scheme + socks[0] } +func validateContainerID(containerID string) error { + if !dockerContainerIDPattern.MatchString(containerID) { + return fmt.Errorf("invalid container id") + } + return nil +} + +func buildDockerContainerEndpoint(containerID, action string, query url.Values) (string, error) { + if err := validateContainerID(containerID); err != nil { + return "", err + } + u := &url.URL{ + Scheme: "http", + Host: "localhost", + Path: fmt.Sprintf("/containers/%s/%s", url.PathEscape(containerID), action), + } + if len(query) > 0 { + u.RawQuery = query.Encode() + } + return u.String(), nil +} + // getContainerInfo fetches the inspection data for a container func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID string) ([]byte, error) { - endpoint := fmt.Sprintf("http://localhost/containers/%s/json", containerID) + endpoint, err := buildDockerContainerEndpoint(containerID, "json", nil) + if err != nil { + return nil, err + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err @@ -682,7 +708,15 @@ func (dm *dockerManager) getContainerInfo(ctx context.Context, containerID strin // getLogs fetches the logs for a container func (dm *dockerManager) getLogs(ctx context.Context, containerID string) (string, error) { - endpoint := fmt.Sprintf("http://localhost/containers/%s/logs?stdout=1&stderr=1&tail=%d", containerID, dockerLogsTail) + query := url.Values{ + "stdout": []string{"1"}, + "stderr": []string{"1"}, + "tail": []string{fmt.Sprintf("%d", dockerLogsTail)}, + } + endpoint, err := buildDockerContainerEndpoint(containerID, "logs", query) + if err != nil { + return "", err + } req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return "", err
agent/docker_test.go+98 −0 modified@@ -9,6 +9,7 @@ import ( "encoding/json" "errors" "fmt" + "io" "net" "net/http" "net/http/httptest" @@ -25,6 +26,37 @@ import ( var defaultCacheTimeMs = uint16(60_000) +type recordingRoundTripper struct { + statusCode int + body string + contentType string + called bool + lastPath string + lastQuery map[string]string +} + +func (rt *recordingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + rt.called = true + rt.lastPath = req.URL.EscapedPath() + rt.lastQuery = map[string]string{} + for key, values := range req.URL.Query() { + if len(values) > 0 { + rt.lastQuery[key] = values[0] + } + } + resp := &http.Response{ + StatusCode: rt.statusCode, + Status: "200 OK", + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(rt.body)), + Request: req, + } + if rt.contentType != "" { + resp.Header.Set("Content-Type", rt.contentType) + } + return resp, nil +} + // cycleCpuDeltas cycles the CPU tracking data for a specific cache time interval func (dm *dockerManager) cycleCpuDeltas(cacheTimeMs uint16) { // Clear the CPU tracking maps for this cache time interval @@ -116,6 +148,72 @@ func TestCalculateMemoryUsage(t *testing.T) { } } +func TestBuildDockerContainerEndpoint(t *testing.T) { + t.Run("valid container ID builds escaped endpoint", func(t *testing.T) { + endpoint, err := buildDockerContainerEndpoint("0123456789ab", "json", nil) + require.NoError(t, err) + assert.Equal(t, "http://localhost/containers/0123456789ab/json", endpoint) + }) + + t.Run("invalid container ID is rejected", func(t *testing.T) { + _, err := buildDockerContainerEndpoint("../../version", "json", nil) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid container id") + }) +} + +func TestContainerDetailsRequestsValidateContainerID(t *testing.T) { + rt := &recordingRoundTripper{ + statusCode: 200, + body: `{"Config":{"Env":["SECRET=1"]}}`, + } + dm := &dockerManager{ + client: &http.Client{Transport: rt}, + } + + _, err := dm.getContainerInfo(context.Background(), "../version") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid container id") + assert.False(t, rt.called, "request should be rejected before dispatching to Docker API") +} + +func TestContainerDetailsRequestsUseExpectedDockerPaths(t *testing.T) { + t.Run("container info uses container json endpoint", func(t *testing.T) { + rt := &recordingRoundTripper{ + statusCode: 200, + body: `{"Config":{"Env":["SECRET=1"]},"Name":"demo"}`, + } + dm := &dockerManager{ + client: &http.Client{Transport: rt}, + } + + body, err := dm.getContainerInfo(context.Background(), "0123456789ab") + require.NoError(t, err) + assert.True(t, rt.called) + assert.Equal(t, "/containers/0123456789ab/json", rt.lastPath) + assert.NotContains(t, string(body), "SECRET=1", "sensitive env vars should be removed") + }) + + t.Run("container logs uses expected endpoint and query params", func(t *testing.T) { + rt := &recordingRoundTripper{ + statusCode: 200, + body: "line1\nline2\n", + } + dm := &dockerManager{ + client: &http.Client{Transport: rt}, + } + + logs, err := dm.getLogs(context.Background(), "abcdef123456") + require.NoError(t, err) + assert.True(t, rt.called) + assert.Equal(t, "/containers/abcdef123456/logs", rt.lastPath) + assert.Equal(t, "1", rt.lastQuery["stdout"]) + assert.Equal(t, "1", rt.lastQuery["stderr"]) + assert.Equal(t, "200", rt.lastQuery["tail"]) + assert.Equal(t, "line1\nline2\n", logs) + }) +} + func TestValidateCpuPercentage(t *testing.T) { tests := []struct { name string
internal/hub/hub.go+6 −0 modified@@ -9,6 +9,7 @@ import ( "net/url" "os" "path" + "regexp" "strings" "time" @@ -41,6 +42,8 @@ type Hub struct { appURL string } +var containerIDPattern = regexp.MustCompile(`^[a-fA-F0-9]{12,64}$`) + // NewHub creates a new Hub instance with default configuration func NewHub(app core.App) *Hub { hub := &Hub{} @@ -461,6 +464,9 @@ func (h *Hub) containerRequestHandler(e *core.RequestEvent, fetchFunc func(*syst if systemID == "" || containerID == "" { return e.JSON(http.StatusBadRequest, map[string]string{"error": "system and container parameters are required"}) } + if !containerIDPattern.MatchString(containerID) { + return e.JSON(http.StatusBadRequest, map[string]string{"error": "invalid container parameter"}) + } system, err := h.sm.GetSystem(systemID) if err != nil {
internal/hub/hub_test.go+34 −1 modified@@ -545,14 +545,47 @@ func TestApiRoutesAuthentication(t *testing.T) { { Name: "GET /containers/logs - with auth but invalid system should fail", Method: http.MethodGet, - URL: "/api/beszel/containers/logs?system=invalid-system&container=test-container", + URL: "/api/beszel/containers/logs?system=invalid-system&container=0123456789ab", Headers: map[string]string{ "Authorization": userToken, }, ExpectedStatus: 404, ExpectedContent: []string{"system not found"}, TestAppFactory: testAppFactory, }, + { + Name: "GET /containers/logs - traversal container should fail validation", + Method: http.MethodGet, + URL: "/api/beszel/containers/logs?system=" + system.Id + "&container=..%2F..%2Fversion", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"invalid container parameter"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/info - traversal container should fail validation", + Method: http.MethodGet, + URL: "/api/beszel/containers/info?system=" + system.Id + "&container=../../version?x=", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"invalid container parameter"}, + TestAppFactory: testAppFactory, + }, + { + Name: "GET /containers/info - non-hex container should fail validation", + Method: http.MethodGet, + URL: "/api/beszel/containers/info?system=" + system.Id + "&container=container_name", + Headers: map[string]string{ + "Authorization": userToken, + }, + ExpectedStatus: 400, + ExpectedContent: []string{"invalid container parameter"}, + TestAppFactory: testAppFactory, + }, // Auth Optional Routes - Should work without authentication {
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-phwh-4f42-gwf3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-27734ghsaADVISORY
- github.com/henrygd/beszel/commit/311095cfddda113863ca9656cf9e99411be1cef5ghsaWEB
- github.com/henrygd/beszel/releases/tag/v0.18.4ghsax_refsource_MISCWEB
- github.com/henrygd/beszel/security/advisories/GHSA-phwh-4f42-gwf3ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.