Skip to content

Commit 034d9ab

Browse files
backport of commit f278040
1 parent 55e3dbf commit 034d9ab

File tree

3 files changed

+267
-10
lines changed

3 files changed

+267
-10
lines changed

.changelog/22671.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
```release-note:security
2+
security: Fixed proxied URL path validation to prevent path traversal.
3+
```

agent/ui_endpoint.go

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -813,20 +813,34 @@ func (s *HTTPHandlers) UIMetricsProxy(resp http.ResponseWriter, req *http.Reques
813813
// Replace prefix in the path
814814
subPath := strings.TrimPrefix(req.URL.Path, "/v1/internal/ui/metrics-proxy")
815815

816-
// Append that to the BaseURL (which might contain a path prefix component)
817-
newURL := cfg.BaseURL + subPath
816+
// This prevents path traversal while preserving URL structure
817+
cleanedSubPath := path.Clean(subPath)
818+
819+
// Clean the base URL for security in logging and comparisons
820+
cleanedBaseURL := cfg.BaseURL
821+
if parsedBase, err := url.Parse(cfg.BaseURL); err == nil && parsedBase.Path != "" {
822+
parsedBase.Path = path.Clean(parsedBase.Path)
823+
cleanedBaseURL = parsedBase.String()
824+
}
825+
826+
// Parse the base URL to get its components
827+
baseURL, err := url.Parse(cfg.BaseURL)
828+
if err != nil {
829+
log.Error("couldn't parse base URL", "base_url", cleanedBaseURL)
830+
return nil, HTTPError{StatusCode: http.StatusBadRequest, Reason: "Invalid base URL."}
831+
}
832+
833+
// Join the base path with the cleaned subpath using proper URL path joining
834+
baseURL.Path = path.Join(baseURL.Path, cleanedSubPath)
835+
newURL := baseURL.String()
818836

819837
// Parse it into a new URL
820838
u, err := url.Parse(newURL)
821839
if err != nil {
822-
log.Error("couldn't parse target URL", "base_url", cfg.BaseURL, "path", subPath)
840+
log.Error("couldn't parse target URL", "base_url", cleanedBaseURL, "path", subPath)
823841
return nil, HTTPError{StatusCode: http.StatusBadRequest, Reason: "Invalid path."}
824842
}
825843

826-
// Clean the new URL path to prevent path traversal attacks and remove any
827-
// double slashes etc.
828-
u.Path = path.Clean(u.Path)
829-
830844
if len(cfg.PathAllowlist) > 0 {
831845
// This could be done better with a map, but for the prometheus default
832846
// integration this list has two items in it, so the straight iteration
@@ -840,7 +854,7 @@ func (s *HTTPHandlers) UIMetricsProxy(resp http.ResponseWriter, req *http.Reques
840854
}
841855
if denied {
842856
log.Error("target URL path is not allowed",
843-
"base_url", cfg.BaseURL,
857+
"base_url", cleanedBaseURL,
844858
"path", subPath,
845859
"target_url", u.String(),
846860
"path_allowlist", cfg.PathAllowlist,
@@ -863,9 +877,18 @@ func (s *HTTPHandlers) UIMetricsProxy(resp http.ResponseWriter, req *http.Reques
863877
// hit this handler. Any /../ that are far enough into the path to hit this
864878
// handler, can't backtrack far enough to eat into the BaseURL either. But we
865879
// leave this in anyway in case something changes in the future.
866-
if !strings.HasPrefix(u.String(), cfg.BaseURL) {
880+
881+
// Security fix: Ensure BaseURL has trailing "/" for proper prefix checking
882+
baseURLForPrefix := cfg.BaseURL
883+
if !strings.HasSuffix(baseURLForPrefix, "/") {
884+
baseURLForPrefix += "/"
885+
}
886+
887+
targetURL := u.String()
888+
// Allow exact match of BaseURL (without trailing slash) or proper prefix match
889+
if targetURL != cfg.BaseURL && !strings.HasPrefix(targetURL, baseURLForPrefix) {
867890
log.Error("target URL escaped from base path",
868-
"base_url", cfg.BaseURL,
891+
"base_url", cleanedBaseURL,
869892
"path", subPath,
870893
"target_url", u.String(),
871894
)

agent/ui_endpoint_test.go

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"net/url"
1515
"os"
1616
"path/filepath"
17+
"strings"
1718
"sync/atomic"
1819
"testing"
1920
"time"
@@ -2635,6 +2636,78 @@ func TestUIEndpoint_MetricsProxy(t *testing.T) {
26352636
wantCode: http.StatusMovedPermanently,
26362637
wantContains: "Moved Permanently",
26372638
},
2639+
{
2640+
name: "path traversal with single dot-dot should be cleaned",
2641+
config: config.UIMetricsProxy{
2642+
BaseURL: backendURL,
2643+
},
2644+
path: endpointPath + "/../ok",
2645+
wantCode: http.StatusMovedPermanently,
2646+
wantContains: "Moved Permanently",
2647+
},
2648+
{
2649+
name: "path traversal with multiple dot-dots should be cleaned",
2650+
config: config.UIMetricsProxy{
2651+
BaseURL: backendURL,
2652+
},
2653+
path: endpointPath + "/../../ok",
2654+
wantCode: http.StatusMovedPermanently,
2655+
wantContains: "Moved Permanently",
2656+
},
2657+
{
2658+
name: "path traversal with mixed slashes should be cleaned",
2659+
config: config.UIMetricsProxy{
2660+
BaseURL: backendURL,
2661+
},
2662+
path: endpointPath + "/./../ok",
2663+
wantCode: http.StatusMovedPermanently,
2664+
wantContains: "Moved Permanently",
2665+
},
2666+
{
2667+
name: "path traversal with encoded dots should be handled",
2668+
config: config.UIMetricsProxy{
2669+
BaseURL: backendURL,
2670+
},
2671+
path: endpointPath + "/%2e%2e/ok",
2672+
wantCode: http.StatusOK,
2673+
wantContains: "OK",
2674+
},
2675+
{
2676+
name: "path with double slashes should be cleaned",
2677+
config: config.UIMetricsProxy{
2678+
BaseURL: backendURL,
2679+
},
2680+
path: endpointPath + "//ok",
2681+
wantCode: http.StatusMovedPermanently,
2682+
wantContains: "Moved Permanently",
2683+
},
2684+
{
2685+
name: "path with trailing slash should work",
2686+
config: config.UIMetricsProxy{
2687+
BaseURL: backendURL,
2688+
},
2689+
path: endpointPath + "/ok/",
2690+
wantCode: http.StatusOK,
2691+
wantContains: "OK",
2692+
},
2693+
{
2694+
name: "baseURL exact match should work",
2695+
config: config.UIMetricsProxy{
2696+
BaseURL: strings.TrimSuffix(backendURL, "/"),
2697+
},
2698+
path: endpointPath + "/",
2699+
wantCode: http.StatusNotFound,
2700+
wantContains: "not found on backend",
2701+
},
2702+
{
2703+
name: "clean path prevents directory traversal",
2704+
config: config.UIMetricsProxy{
2705+
BaseURL: backendURL,
2706+
},
2707+
path: endpointPath + "/subdir/../../../etc/passwd",
2708+
wantCode: http.StatusMovedPermanently,
2709+
wantContains: "Moved Permanently",
2710+
},
26382711
{
26392712
name: "adding auth header",
26402713
config: config.UIMetricsProxy{
@@ -2671,6 +2744,40 @@ func TestUIEndpoint_MetricsProxy(t *testing.T) {
26712744
wantCode: http.StatusOK,
26722745
wantContains: "RawQuery: foo=bar&encoded=test%5B0%5D%26%26test%5B1%5D%3D%3D%21%40%C2%A3%24%25%5E",
26732746
},
2747+
{
2748+
name: "targetURL exactly matches BaseURL without trailing slash",
2749+
config: config.UIMetricsProxy{
2750+
BaseURL: strings.TrimSuffix(backendURL, "/some/prefix"),
2751+
},
2752+
path: endpointPath + "/",
2753+
wantCode: http.StatusNotFound,
2754+
wantContains: "not found on backend",
2755+
},
2756+
{
2757+
name: "targetURL matches BaseURL prefix with trailing slash",
2758+
config: config.UIMetricsProxy{
2759+
BaseURL: backendURL,
2760+
},
2761+
path: endpointPath + "/ok",
2762+
wantCode: http.StatusOK,
2763+
wantContains: "OK",
2764+
},
2765+
{
2766+
name: "targetURL fails prefix check - different domain",
2767+
config: config.UIMetricsProxy{
2768+
BaseURL: "http://localhost:9999/metrics",
2769+
},
2770+
path: endpointPath + "/../../evil.com/attack",
2771+
wantCode: http.StatusMovedPermanently,
2772+
},
2773+
{
2774+
name: "targetURL fails prefix check - sibling path escape",
2775+
config: config.UIMetricsProxy{
2776+
BaseURL: backend.URL + "/secure/metrics",
2777+
},
2778+
path: endpointPath + "/../../../public/data",
2779+
wantCode: http.StatusMovedPermanently,
2780+
},
26742781
}
26752782

26762783
for _, tc := range cases {
@@ -2715,3 +2822,127 @@ func TestUIEndpoint_MetricsProxy(t *testing.T) {
27152822
})
27162823
}
27172824
}
2825+
2826+
func TestUIEndpoint_MetricsProxy_TargetURLValidation(t *testing.T) {
2827+
if testing.Short() {
2828+
t.Skip("too slow for testing.Short")
2829+
}
2830+
2831+
t.Parallel()
2832+
2833+
// Create a test backend server
2834+
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2835+
w.WriteHeader(http.StatusOK)
2836+
w.Write([]byte("OK"))
2837+
}))
2838+
defer backend.Close()
2839+
2840+
a := NewTestAgent(t, `
2841+
ui_config {
2842+
enabled = true
2843+
}
2844+
`)
2845+
defer a.Shutdown()
2846+
2847+
endpointPath := "/v1/internal/ui/metrics-proxy"
2848+
2849+
cases := []struct {
2850+
name string
2851+
baseURL string
2852+
requestPath string
2853+
wantCode int
2854+
wantContains string
2855+
}{
2856+
{
2857+
name: "exact BaseURL match - allowed",
2858+
baseURL: backend.URL + "/metrics",
2859+
requestPath: endpointPath + "/",
2860+
wantCode: http.StatusOK,
2861+
wantContains: "OK",
2862+
},
2863+
{
2864+
name: "proper prefix match with trailing slash - allowed",
2865+
baseURL: backend.URL + "/metrics",
2866+
requestPath: endpointPath + "/dashboard",
2867+
wantCode: http.StatusOK,
2868+
wantContains: "OK",
2869+
},
2870+
{
2871+
name: "baseURL without trailing slash, targetURL with prefix - allowed",
2872+
baseURL: strings.TrimSuffix(backend.URL, "/") + "/metrics",
2873+
requestPath: endpointPath + "/dashboard",
2874+
wantCode: http.StatusOK,
2875+
wantContains: "OK",
2876+
},
2877+
{
2878+
name: "baseURL with trailing slash, targetURL matches prefix - allowed",
2879+
baseURL: backend.URL + "/metrics/",
2880+
requestPath: endpointPath + "/dashboard",
2881+
wantCode: http.StatusOK,
2882+
wantContains: "OK",
2883+
},
2884+
{
2885+
name: "targetURL escapes from baseURL prefix - blocked",
2886+
baseURL: backend.URL + "/secure/metrics",
2887+
requestPath: endpointPath + "/../../public/data",
2888+
wantCode: http.StatusMovedPermanently,
2889+
wantContains: "Moved Permanently",
2890+
},
2891+
{
2892+
name: "targetURL attempts sibling directory access - blocked",
2893+
baseURL: backend.URL + "/app/metrics",
2894+
requestPath: endpointPath + "/../admin/config",
2895+
wantCode: http.StatusMovedPermanently,
2896+
wantContains: "Moved Permanently",
2897+
},
2898+
{
2899+
name: "targetURL with encoded dots - path.Clean prevents traversal",
2900+
baseURL: backend.URL + "/app/metrics",
2901+
requestPath: endpointPath + "/%2e%2e/admin/config",
2902+
wantCode: http.StatusOK,
2903+
wantContains: "OK",
2904+
},
2905+
{
2906+
name: "baseURL validation - exact match is allowed",
2907+
baseURL: backend.URL,
2908+
requestPath: endpointPath + "/",
2909+
wantCode: http.StatusOK,
2910+
wantContains: "OK",
2911+
},
2912+
{
2913+
name: "baseURL validation - proper prefix is allowed",
2914+
baseURL: backend.URL + "/secure",
2915+
requestPath: endpointPath + "/dashboard",
2916+
wantCode: http.StatusOK,
2917+
wantContains: "OK",
2918+
},
2919+
}
2920+
2921+
for _, tc := range cases {
2922+
tc := tc
2923+
t.Run(tc.name, func(t *testing.T) {
2924+
// Configure the metrics proxy with the specific BaseURL
2925+
cfg := *a.config
2926+
cfg.UIConfig.MetricsProxy = config.UIMetricsProxy{
2927+
BaseURL: tc.baseURL,
2928+
}
2929+
2930+
require.NoError(t, a.reloadConfigInternal(&cfg))
2931+
2932+
// Make the request
2933+
h := a.srv.handler()
2934+
req := httptest.NewRequest("GET", tc.requestPath, nil)
2935+
rec := httptest.NewRecorder()
2936+
2937+
h.ServeHTTP(rec, req)
2938+
2939+
require.Equal(t, tc.wantCode, rec.Code,
2940+
"Wrong status code for %s. Body = %s", tc.name, rec.Body.String())
2941+
2942+
if tc.wantContains != "" {
2943+
require.Contains(t, rec.Body.String(), tc.wantContains,
2944+
"Response body should contain expected text for %s", tc.name)
2945+
}
2946+
})
2947+
}
2948+
}

0 commit comments

Comments
 (0)