CVE-2025-6023
Description
An open redirect vulnerability has been identified in Grafana OSS that can be exploited to achieve XSS attacks. The vulnerability was introduced in Grafana v11.5.0.
The open redirect can be chained with path traversal vulnerabilities to achieve XSS.
Fixed in versions 12.0.2+security-01, 11.6.3+security-01, 11.5.6+security-01, 11.4.6+security-01 and 11.3.8+security-01
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/grafana/grafanaGo | < 1.9.2-0.20250521205822-0ba0b99665a9 | 1.9.2-0.20250521205822-0ba0b99665a9 |
Affected products
1Patches
5b6dd2b70c655apply security patch: release-12.0.1/security-patch-202505051005.patch
2 files changed · +180 −5
pkg/api/static/static.go+6 −5 modified@@ -159,16 +159,17 @@ func staticHandler(ctx *web.Context, log log.Logger, opt StaticOptions) bool { if fi.IsDir() { // Redirect if missing trailing slash. if !strings.HasSuffix(ctx.Req.URL.Path, "/") { - path := fmt.Sprintf("%s/", ctx.Req.URL.Path) - if !strings.HasPrefix(path, "/") { + redirectPath := path.Clean(ctx.Req.URL.Path) + redirectPath = fmt.Sprintf("%s/", redirectPath) + if !strings.HasPrefix(redirectPath, "/") { // Disambiguate that it's a path relative to this server - path = fmt.Sprintf("/%s", path) + redirectPath = fmt.Sprintf("/%s", redirectPath) } else { // A string starting with // or /\ is interpreted by browsers as a URL, and not a server relative path rePrefix := regexp.MustCompile(`^(?:/\\|/+)`) - path = rePrefix.ReplaceAllString(path, "/") + redirectPath = rePrefix.ReplaceAllString(redirectPath, "/") } - http.Redirect(ctx.Resp, ctx.Req, path, http.StatusFound) + http.Redirect(ctx.Resp, ctx.Req, redirectPath, http.StatusFound) return true }
pkg/api/static/static_test.go+174 −0 added@@ -0,0 +1,174 @@ +package httpstatic + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/models/usertoken" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/authn/authntest" + "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatic(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "static-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create test files + testFiles := map[string]string{ + "test.txt": "Test content", + "subdir/test.txt": "Subdir content", + } + + for path, content := range testFiles { + fullPath := filepath.Join(tmpDir, path) + err := os.MkdirAll(filepath.Dir(fullPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0o644) + require.NoError(t, err) + } + + tests := []struct { + dir string + name string + path string + options StaticOptions + expectedStatus int + expectedBody string + expectedLocation string + }{ + { + name: "should serve existing file", + path: "/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should serve file from subdirectory", + path: "/subdir/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Subdir content", + dir: tmpDir, + }, + + { + name: "should redirect directory without trailing slash", + path: "/subdir", + expectedStatus: http.StatusFound, + expectedLocation: "/subdir/", + dir: tmpDir, + }, + { + name: "should handle prefix", + path: "/static/test.txt", + options: StaticOptions{Prefix: "/static"}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should handle excluded path", + path: "/test.txt", + options: StaticOptions{Exclude: []string{"/test.txt"}}, + expectedStatus: http.StatusNotFound, + dir: tmpDir, + }, + { + name: "should add custom headers", + path: "/test.txt", + options: StaticOptions{AddHeaders: func(ctx *web.Context) { ctx.Resp.Header().Set("X-Test", "test") }}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should clean up path before redirecting", + path: "/subdir/..%2F%5C127.0.0.1:80%2F%3F%2F..%2F..", + options: StaticOptions{Prefix: "subdir"}, + expectedStatus: http.StatusFound, + expectedLocation: "/", + dir: tmpDir, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sc := setupScenarioContext(t, "") + sc.m.Use(Static(tt.dir, tt.options)) + + // Create a test request + req := httptest.NewRequest("GET", tt.path, nil) + w := httptest.NewRecorder() + + // Execute the handler + sc.m.ServeHTTP(w, req) + + // Verify the response + resp := w.Result() + require.Equal(t, tt.expectedStatus, resp.StatusCode) + + if tt.expectedBody != "" { + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, tt.expectedBody, string(body)) + } + + if tt.options.AddHeaders != nil { + assert.Equal(t, "test", resp.Header.Get("X-Test")) + } + + if tt.expectedLocation != "" { + assert.Equal(t, tt.expectedLocation, resp.Header.Get("Location")) + } + }) + } +} + +type scenarioContext struct { + t *testing.T + cfg *setting.Cfg + m *web.Mux + ctxHdlr *contexthandler.ContextHandler +} + +func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHandler { + t.Helper() + + if cfg == nil { + cfg = setting.NewCfg() + } + + return contexthandler.ProvideService( + cfg, + &authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: "0", Type: claims.TypeAnonymous, SessionToken: &usertoken.UserToken{}}}, + featuremgmt.WithFeatures(), + ) +} + +func setupScenarioContext(t *testing.T, url string) *scenarioContext { + cfg := setting.NewCfg() + ctxHdlr := getContextHandler(t, cfg) + sc := &scenarioContext{ + t: t, + cfg: cfg, + ctxHdlr: ctxHdlr, + } + + sc.m = web.New() + sc.m.Use(ctxHdlr.Middleware) + + return sc +}
e0ba4b480954apply security patch: release-11.6.2/security-patch-202505051005.patch
2 files changed · +180 −5
pkg/api/static/static.go+6 −5 modified@@ -159,16 +159,17 @@ func staticHandler(ctx *web.Context, log log.Logger, opt StaticOptions) bool { if fi.IsDir() { // Redirect if missing trailing slash. if !strings.HasSuffix(ctx.Req.URL.Path, "/") { - path := fmt.Sprintf("%s/", ctx.Req.URL.Path) - if !strings.HasPrefix(path, "/") { + redirectPath := path.Clean(ctx.Req.URL.Path) + redirectPath = fmt.Sprintf("%s/", redirectPath) + if !strings.HasPrefix(redirectPath, "/") { // Disambiguate that it's a path relative to this server - path = fmt.Sprintf("/%s", path) + redirectPath = fmt.Sprintf("/%s", redirectPath) } else { // A string starting with // or /\ is interpreted by browsers as a URL, and not a server relative path rePrefix := regexp.MustCompile(`^(?:/\\|/+)`) - path = rePrefix.ReplaceAllString(path, "/") + redirectPath = rePrefix.ReplaceAllString(redirectPath, "/") } - http.Redirect(ctx.Resp, ctx.Req, path, http.StatusFound) + http.Redirect(ctx.Resp, ctx.Req, redirectPath, http.StatusFound) return true }
pkg/api/static/static_test.go+174 −0 added@@ -0,0 +1,174 @@ +package httpstatic + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/models/usertoken" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/authn/authntest" + "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatic(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "static-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create test files + testFiles := map[string]string{ + "test.txt": "Test content", + "subdir/test.txt": "Subdir content", + } + + for path, content := range testFiles { + fullPath := filepath.Join(tmpDir, path) + err := os.MkdirAll(filepath.Dir(fullPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0o644) + require.NoError(t, err) + } + + tests := []struct { + dir string + name string + path string + options StaticOptions + expectedStatus int + expectedBody string + expectedLocation string + }{ + { + name: "should serve existing file", + path: "/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should serve file from subdirectory", + path: "/subdir/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Subdir content", + dir: tmpDir, + }, + + { + name: "should redirect directory without trailing slash", + path: "/subdir", + expectedStatus: http.StatusFound, + expectedLocation: "/subdir/", + dir: tmpDir, + }, + { + name: "should handle prefix", + path: "/static/test.txt", + options: StaticOptions{Prefix: "/static"}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should handle excluded path", + path: "/test.txt", + options: StaticOptions{Exclude: []string{"/test.txt"}}, + expectedStatus: http.StatusNotFound, + dir: tmpDir, + }, + { + name: "should add custom headers", + path: "/test.txt", + options: StaticOptions{AddHeaders: func(ctx *web.Context) { ctx.Resp.Header().Set("X-Test", "test") }}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should clean up path before redirecting", + path: "/subdir/..%2F%5C127.0.0.1:80%2F%3F%2F..%2F..", + options: StaticOptions{Prefix: "subdir"}, + expectedStatus: http.StatusFound, + expectedLocation: "/", + dir: tmpDir, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sc := setupScenarioContext(t, "") + sc.m.Use(Static(tt.dir, tt.options)) + + // Create a test request + req := httptest.NewRequest("GET", tt.path, nil) + w := httptest.NewRecorder() + + // Execute the handler + sc.m.ServeHTTP(w, req) + + // Verify the response + resp := w.Result() + require.Equal(t, tt.expectedStatus, resp.StatusCode) + + if tt.expectedBody != "" { + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, tt.expectedBody, string(body)) + } + + if tt.options.AddHeaders != nil { + assert.Equal(t, "test", resp.Header.Get("X-Test")) + } + + if tt.expectedLocation != "" { + assert.Equal(t, tt.expectedLocation, resp.Header.Get("Location")) + } + }) + } +} + +type scenarioContext struct { + t *testing.T + cfg *setting.Cfg + m *web.Mux + ctxHdlr *contexthandler.ContextHandler +} + +func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHandler { + t.Helper() + + if cfg == nil { + cfg = setting.NewCfg() + } + + return contexthandler.ProvideService( + cfg, + &authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: "0", Type: claims.TypeAnonymous, SessionToken: &usertoken.UserToken{}}}, + featuremgmt.WithFeatures(), + ) +} + +func setupScenarioContext(t *testing.T, url string) *scenarioContext { + cfg := setting.NewCfg() + ctxHdlr := getContextHandler(t, cfg) + sc := &scenarioContext{ + t: t, + cfg: cfg, + ctxHdlr: ctxHdlr, + } + + sc.m = web.New() + sc.m.Use(ctxHdlr.Middleware) + + return sc +}
5b00e21638f5apply security patch: release-11.4.5/security-patch-202505051005.patch
2 files changed · +180 −5
pkg/api/static/static.go+6 −5 modified@@ -159,16 +159,17 @@ func staticHandler(ctx *web.Context, log log.Logger, opt StaticOptions) bool { if fi.IsDir() { // Redirect if missing trailing slash. if !strings.HasSuffix(ctx.Req.URL.Path, "/") { - path := fmt.Sprintf("%s/", ctx.Req.URL.Path) - if !strings.HasPrefix(path, "/") { + redirectPath := path.Clean(ctx.Req.URL.Path) + redirectPath = fmt.Sprintf("%s/", redirectPath) + if !strings.HasPrefix(redirectPath, "/") { // Disambiguate that it's a path relative to this server - path = fmt.Sprintf("/%s", path) + redirectPath = fmt.Sprintf("/%s", redirectPath) } else { // A string starting with // or /\ is interpreted by browsers as a URL, and not a server relative path rePrefix := regexp.MustCompile(`^(?:/\\|/+)`) - path = rePrefix.ReplaceAllString(path, "/") + redirectPath = rePrefix.ReplaceAllString(redirectPath, "/") } - http.Redirect(ctx.Resp, ctx.Req, path, http.StatusFound) + http.Redirect(ctx.Resp, ctx.Req, redirectPath, http.StatusFound) return true }
pkg/api/static/static_test.go+174 −0 added@@ -0,0 +1,174 @@ +package httpstatic + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/models/usertoken" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/authn/authntest" + "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatic(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "static-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create test files + testFiles := map[string]string{ + "test.txt": "Test content", + "subdir/test.txt": "Subdir content", + } + + for path, content := range testFiles { + fullPath := filepath.Join(tmpDir, path) + err := os.MkdirAll(filepath.Dir(fullPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0o644) + require.NoError(t, err) + } + + tests := []struct { + dir string + name string + path string + options StaticOptions + expectedStatus int + expectedBody string + expectedLocation string + }{ + { + name: "should serve existing file", + path: "/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should serve file from subdirectory", + path: "/subdir/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Subdir content", + dir: tmpDir, + }, + + { + name: "should redirect directory without trailing slash", + path: "/subdir", + expectedStatus: http.StatusFound, + expectedLocation: "/subdir/", + dir: tmpDir, + }, + { + name: "should handle prefix", + path: "/static/test.txt", + options: StaticOptions{Prefix: "/static"}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should handle excluded path", + path: "/test.txt", + options: StaticOptions{Exclude: []string{"/test.txt"}}, + expectedStatus: http.StatusNotFound, + dir: tmpDir, + }, + { + name: "should add custom headers", + path: "/test.txt", + options: StaticOptions{AddHeaders: func(ctx *web.Context) { ctx.Resp.Header().Set("X-Test", "test") }}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should clean up path before redirecting", + path: "/subdir/..%2F%5C127.0.0.1:80%2F%3F%2F..%2F..", + options: StaticOptions{Prefix: "subdir"}, + expectedStatus: http.StatusFound, + expectedLocation: "/", + dir: tmpDir, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sc := setupScenarioContext(t, "") + sc.m.Use(Static(tt.dir, tt.options)) + + // Create a test request + req := httptest.NewRequest("GET", tt.path, nil) + w := httptest.NewRecorder() + + // Execute the handler + sc.m.ServeHTTP(w, req) + + // Verify the response + resp := w.Result() + require.Equal(t, tt.expectedStatus, resp.StatusCode) + + if tt.expectedBody != "" { + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, tt.expectedBody, string(body)) + } + + if tt.options.AddHeaders != nil { + assert.Equal(t, "test", resp.Header.Get("X-Test")) + } + + if tt.expectedLocation != "" { + assert.Equal(t, tt.expectedLocation, resp.Header.Get("Location")) + } + }) + } +} + +type scenarioContext struct { + t *testing.T + cfg *setting.Cfg + m *web.Mux + ctxHdlr *contexthandler.ContextHandler +} + +func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHandler { + t.Helper() + + if cfg == nil { + cfg = setting.NewCfg() + } + + return contexthandler.ProvideService( + cfg, + &authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: "0", Type: claims.TypeAnonymous, SessionToken: &usertoken.UserToken{}}}, + featuremgmt.WithFeatures(), + ) +} + +func setupScenarioContext(t *testing.T, url string) *scenarioContext { + cfg := setting.NewCfg() + ctxHdlr := getContextHandler(t, cfg) + sc := &scenarioContext{ + t: t, + cfg: cfg, + ctxHdlr: ctxHdlr, + } + + sc.m = web.New() + sc.m.Use(ctxHdlr.Middleware) + + return sc +}
40ed88fe86d3apply security patch: release-11.5.5/security-patch-202505051005.patch
2 files changed · +180 −5
pkg/api/static/static.go+6 −5 modified@@ -159,16 +159,17 @@ func staticHandler(ctx *web.Context, log log.Logger, opt StaticOptions) bool { if fi.IsDir() { // Redirect if missing trailing slash. if !strings.HasSuffix(ctx.Req.URL.Path, "/") { - path := fmt.Sprintf("%s/", ctx.Req.URL.Path) - if !strings.HasPrefix(path, "/") { + redirectPath := path.Clean(ctx.Req.URL.Path) + redirectPath = fmt.Sprintf("%s/", redirectPath) + if !strings.HasPrefix(redirectPath, "/") { // Disambiguate that it's a path relative to this server - path = fmt.Sprintf("/%s", path) + redirectPath = fmt.Sprintf("/%s", redirectPath) } else { // A string starting with // or /\ is interpreted by browsers as a URL, and not a server relative path rePrefix := regexp.MustCompile(`^(?:/\\|/+)`) - path = rePrefix.ReplaceAllString(path, "/") + redirectPath = rePrefix.ReplaceAllString(redirectPath, "/") } - http.Redirect(ctx.Resp, ctx.Req, path, http.StatusFound) + http.Redirect(ctx.Resp, ctx.Req, redirectPath, http.StatusFound) return true }
pkg/api/static/static_test.go+174 −0 added@@ -0,0 +1,174 @@ +package httpstatic + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/models/usertoken" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/authn/authntest" + "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatic(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "static-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create test files + testFiles := map[string]string{ + "test.txt": "Test content", + "subdir/test.txt": "Subdir content", + } + + for path, content := range testFiles { + fullPath := filepath.Join(tmpDir, path) + err := os.MkdirAll(filepath.Dir(fullPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0o644) + require.NoError(t, err) + } + + tests := []struct { + dir string + name string + path string + options StaticOptions + expectedStatus int + expectedBody string + expectedLocation string + }{ + { + name: "should serve existing file", + path: "/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should serve file from subdirectory", + path: "/subdir/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Subdir content", + dir: tmpDir, + }, + + { + name: "should redirect directory without trailing slash", + path: "/subdir", + expectedStatus: http.StatusFound, + expectedLocation: "/subdir/", + dir: tmpDir, + }, + { + name: "should handle prefix", + path: "/static/test.txt", + options: StaticOptions{Prefix: "/static"}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should handle excluded path", + path: "/test.txt", + options: StaticOptions{Exclude: []string{"/test.txt"}}, + expectedStatus: http.StatusNotFound, + dir: tmpDir, + }, + { + name: "should add custom headers", + path: "/test.txt", + options: StaticOptions{AddHeaders: func(ctx *web.Context) { ctx.Resp.Header().Set("X-Test", "test") }}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should clean up path before redirecting", + path: "/subdir/..%2F%5C127.0.0.1:80%2F%3F%2F..%2F..", + options: StaticOptions{Prefix: "subdir"}, + expectedStatus: http.StatusFound, + expectedLocation: "/", + dir: tmpDir, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sc := setupScenarioContext(t, "") + sc.m.Use(Static(tt.dir, tt.options)) + + // Create a test request + req := httptest.NewRequest("GET", tt.path, nil) + w := httptest.NewRecorder() + + // Execute the handler + sc.m.ServeHTTP(w, req) + + // Verify the response + resp := w.Result() + require.Equal(t, tt.expectedStatus, resp.StatusCode) + + if tt.expectedBody != "" { + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, tt.expectedBody, string(body)) + } + + if tt.options.AddHeaders != nil { + assert.Equal(t, "test", resp.Header.Get("X-Test")) + } + + if tt.expectedLocation != "" { + assert.Equal(t, tt.expectedLocation, resp.Header.Get("Location")) + } + }) + } +} + +type scenarioContext struct { + t *testing.T + cfg *setting.Cfg + m *web.Mux + ctxHdlr *contexthandler.ContextHandler +} + +func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHandler { + t.Helper() + + if cfg == nil { + cfg = setting.NewCfg() + } + + return contexthandler.ProvideService( + cfg, + &authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: "0", Type: claims.TypeAnonymous, SessionToken: &usertoken.UserToken{}}}, + featuremgmt.WithFeatures(), + ) +} + +func setupScenarioContext(t *testing.T, url string) *scenarioContext { + cfg := setting.NewCfg() + ctxHdlr := getContextHandler(t, cfg) + sc := &scenarioContext{ + t: t, + cfg: cfg, + ctxHdlr: ctxHdlr, + } + + sc.m = web.New() + sc.m.Use(ctxHdlr.Middleware) + + return sc +}
0ba0b99665a9apply security patch: release-11.3.7/security-patch-202505051005.patch
2 files changed · +180 −5
pkg/api/static/static.go+6 −5 modified@@ -159,16 +159,17 @@ func staticHandler(ctx *web.Context, log log.Logger, opt StaticOptions) bool { if fi.IsDir() { // Redirect if missing trailing slash. if !strings.HasSuffix(ctx.Req.URL.Path, "/") { - path := fmt.Sprintf("%s/", ctx.Req.URL.Path) - if !strings.HasPrefix(path, "/") { + redirectPath := path.Clean(ctx.Req.URL.Path) + redirectPath = fmt.Sprintf("%s/", redirectPath) + if !strings.HasPrefix(redirectPath, "/") { // Disambiguate that it's a path relative to this server - path = fmt.Sprintf("/%s", path) + redirectPath = fmt.Sprintf("/%s", redirectPath) } else { // A string starting with // or /\ is interpreted by browsers as a URL, and not a server relative path rePrefix := regexp.MustCompile(`^(?:/\\|/+)`) - path = rePrefix.ReplaceAllString(path, "/") + redirectPath = rePrefix.ReplaceAllString(redirectPath, "/") } - http.Redirect(ctx.Resp, ctx.Req, path, http.StatusFound) + http.Redirect(ctx.Resp, ctx.Req, redirectPath, http.StatusFound) return true }
pkg/api/static/static_test.go+174 −0 added@@ -0,0 +1,174 @@ +package httpstatic + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + claims "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/models/usertoken" + "github.com/grafana/grafana/pkg/services/authn" + "github.com/grafana/grafana/pkg/services/authn/authntest" + "github.com/grafana/grafana/pkg/services/contexthandler" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/web" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStatic(t *testing.T) { + // Create a temporary directory for test files + tmpDir, err := os.MkdirTemp("", "static-test") + require.NoError(t, err) + defer os.RemoveAll(tmpDir) + + // Create test files + testFiles := map[string]string{ + "test.txt": "Test content", + "subdir/test.txt": "Subdir content", + } + + for path, content := range testFiles { + fullPath := filepath.Join(tmpDir, path) + err := os.MkdirAll(filepath.Dir(fullPath), 0o755) + require.NoError(t, err) + err = os.WriteFile(fullPath, []byte(content), 0o644) + require.NoError(t, err) + } + + tests := []struct { + dir string + name string + path string + options StaticOptions + expectedStatus int + expectedBody string + expectedLocation string + }{ + { + name: "should serve existing file", + path: "/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should serve file from subdirectory", + path: "/subdir/test.txt", + expectedStatus: http.StatusOK, + expectedBody: "Subdir content", + dir: tmpDir, + }, + + { + name: "should redirect directory without trailing slash", + path: "/subdir", + expectedStatus: http.StatusFound, + expectedLocation: "/subdir/", + dir: tmpDir, + }, + { + name: "should handle prefix", + path: "/static/test.txt", + options: StaticOptions{Prefix: "/static"}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should handle excluded path", + path: "/test.txt", + options: StaticOptions{Exclude: []string{"/test.txt"}}, + expectedStatus: http.StatusNotFound, + dir: tmpDir, + }, + { + name: "should add custom headers", + path: "/test.txt", + options: StaticOptions{AddHeaders: func(ctx *web.Context) { ctx.Resp.Header().Set("X-Test", "test") }}, + expectedStatus: http.StatusOK, + expectedBody: "Test content", + dir: tmpDir, + }, + { + name: "should clean up path before redirecting", + path: "/subdir/..%2F%5C127.0.0.1:80%2F%3F%2F..%2F..", + options: StaticOptions{Prefix: "subdir"}, + expectedStatus: http.StatusFound, + expectedLocation: "/", + dir: tmpDir, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + sc := setupScenarioContext(t, "") + sc.m.Use(Static(tt.dir, tt.options)) + + // Create a test request + req := httptest.NewRequest("GET", tt.path, nil) + w := httptest.NewRecorder() + + // Execute the handler + sc.m.ServeHTTP(w, req) + + // Verify the response + resp := w.Result() + require.Equal(t, tt.expectedStatus, resp.StatusCode) + + if tt.expectedBody != "" { + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, tt.expectedBody, string(body)) + } + + if tt.options.AddHeaders != nil { + assert.Equal(t, "test", resp.Header.Get("X-Test")) + } + + if tt.expectedLocation != "" { + assert.Equal(t, tt.expectedLocation, resp.Header.Get("Location")) + } + }) + } +} + +type scenarioContext struct { + t *testing.T + cfg *setting.Cfg + m *web.Mux + ctxHdlr *contexthandler.ContextHandler +} + +func getContextHandler(t *testing.T, cfg *setting.Cfg) *contexthandler.ContextHandler { + t.Helper() + + if cfg == nil { + cfg = setting.NewCfg() + } + + return contexthandler.ProvideService( + cfg, + &authntest.FakeService{ExpectedIdentity: &authn.Identity{ID: "0", Type: claims.TypeAnonymous, SessionToken: &usertoken.UserToken{}}}, + featuremgmt.WithFeatures(), + ) +} + +func setupScenarioContext(t *testing.T, url string) *scenarioContext { + cfg := setting.NewCfg() + ctxHdlr := getContextHandler(t, cfg) + sc := &scenarioContext{ + t: t, + cfg: cfg, + ctxHdlr: ctxHdlr, + } + + sc.m = web.New() + sc.m.Use(ctxHdlr.Middleware) + + return sc +}
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
11- github.com/advisories/GHSA-vqph-p5vc-g644ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-6023ghsaADVISORY
- github.com/grafana/grafana/commit/0ba0b99665a946cd96676ef85ec8bc83028cb1d7ghsaWEB
- github.com/grafana/grafana/commit/40ed88fe86d347bcde5ddaed6c4a20a95d2f0d55ghsaWEB
- github.com/grafana/grafana/commit/5b00e21638f565eed46acb4d0b7c009968df4c3bghsaWEB
- github.com/grafana/grafana/commit/b6dd2b70c655c61b111b328f1a7dcca6b3954936ghsaWEB
- github.com/grafana/grafana/commit/e0ba4b480954f8a33aa2cff3229f6bcc05777bd9ghsaWEB
- grafana.com/blog/2025/07/17/grafana-security-release-medium-and-high-severity-fixes-for-cve-2025-6197-and-cve-2025-6023ghsaWEB
- grafana.com/security/security-advisories/cve-2025-6023ghsaWEB
- grafana.com/blog/2025/07/17/grafana-security-release-medium-and-high-severity-fixes-for-cve-2025-6197-and-cve-2025-6023/nvd
- grafana.com/security/security-advisories/cve-2025-6023/nvd
News mentions
0No linked articles in our index yet.