VYPR
Moderate severityNVD Advisory· Published Feb 27, 2026· Updated Mar 2, 2026

Beszel Vulnerable to Docker API Path Traversal via Unsanitized Container ID

CVE-2026-27734

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.

PackageAffected versionsPatched versions
github.com/henrygd/beszelGo
< 0.18.40.18.4

Affected products

2
  • Beszel/Beszelllm-fuzzy2 versions
    <0.18.2+ 1 more
    • (no CPE)range: <0.18.2
    • (no CPE)range: < 0.18.4

Patches

1
311095cfddda

harden against docker api path traversal

https://github.com/henrygd/beszelhenrygdFeb 18, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.