Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
67c52c5
fix: disable data explorer query/script execution from URL params
alespour Mar 17, 2026
c9cd31d
fix: cookie hardening
alespour Mar 17, 2026
8350c4b
fix: require XMLHttpRequest header on query execution endpoints
alespour Mar 17, 2026
5523856
fix: add SAMEORIGIN and CORP response headers
alespour Mar 17, 2026
d11e009
fix: enforce same-origin on unsafe session-authenticated requests
alespour Mar 17, 2026
d75e19b
fix: add missing files
alespour Mar 17, 2026
7ebbfda
fix: require XMLHttpRequest header for flux proxy GET
alespour Mar 17, 2026
38ea6f7
test(e2e): inject same-origin headers into unsafe cy.request calls
alespour Mar 18, 2026
3e80203
test(e2e): fix flaky DB name check in explore_influxql
alespour Mar 18, 2026
39e8522
docs: update CHANGELOG
alespour Mar 18, 2026
96fe10b
fix: force X-Requested-With header in AJAX requests
alespour Mar 18, 2026
0a79a28
fix: ensure security headers are set on blocked same-origin requests
alespour Mar 18, 2026
2ac02d1
test: use oauth2.DefaultCookieName in same-origin middleware tests
alespour Mar 18, 2026
1f468aa
fix: same origin also compares scheme and port
alespour Mar 18, 2026
1ed2913
fix: secure cookies
alespour Mar 18, 2026
ec44334
refactor(ui): remove unused withRouter from DataExplorer
alespour Mar 18, 2026
f4dc2b4
test: fix regexp
alespour Mar 18, 2026
c14a0b3
style: go fmt
alespour Mar 18, 2026
ea3e1c6
style: formatting
alespour Mar 18, 2026
d01989f
test(oauth2): cover secure cookie flag in NewCookieJWT
alespour Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## Unreleased

### Security Fixes

1. [#6186](https://github.com/influxdata/chronograf/pull/6186): Hardened CSRF protections on Data Explorer and unsafe query endpoints.

## v1.11.0 [2026-02-19]

⚠️ Removed support for Linux i386, armhf, armel and static build. Removed support for Darwin arm64.
Expand Down
8 changes: 7 additions & 1 deletion oauth2/cookies.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,13 @@ type cookie struct {
Name string // Name is the name of the cookie stored on the browser
Lifespan time.Duration // Lifespan is the expiration date of the cookie. 0 means session cookie
Inactivity time.Duration // Inactivity is the length of time a token is valid if there is no activity
Secure bool // Secure controls whether the cookie is sent only over HTTPS
Now func() time.Time
Tokens Tokenizer
}

// NewCookieJWT creates an Authenticator that uses cookies for auth
func NewCookieJWT(secret string, lifespan, inactivity time.Duration) Authenticator {
func NewCookieJWT(secret string, lifespan, inactivity time.Duration, secure bool) Authenticator {
// Server interprets a token duration longer than the cookie lifespan as
// a token that was issued by a server with a longer auth-duration and is
// thus invalid, as a security precaution. So, inactivity must be set to
Expand All @@ -35,6 +36,7 @@ func NewCookieJWT(secret string, lifespan, inactivity time.Duration) Authenticat
Name: DefaultCookieName,
Lifespan: lifespan,
Inactivity: inactivity,
Secure: secure,
Now: DefaultNowTime,
Tokens: &JWT{
Secret: secret,
Expand Down Expand Up @@ -107,6 +109,8 @@ func (c *cookie) setCookie(w http.ResponseWriter, value string, exp time.Time) {
Value: value,
HttpOnly: true,
Path: "/",
SameSite: http.SameSiteStrictMode,
Secure: c.Secure,
}

// Only set a cookie to be persistent (endure beyond the browser session)
Expand All @@ -126,6 +130,8 @@ func (c *cookie) Expire(w http.ResponseWriter) {
HttpOnly: true,
Path: "/",
Expires: c.Now().Add(-1 * time.Hour),
SameSite: http.SameSiteStrictMode,
Secure: c.Secure,
}

http.SetCookie(w, &cookie)
Expand Down
15 changes: 12 additions & 3 deletions oauth2/cookies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,26 +162,35 @@ func TestCookieValidate(t *testing.T) {
}

func TestNewCookieJWT(t *testing.T) {
auth := NewCookieJWT("secret", 2*time.Second, defaultInactivityDuration)
auth := NewCookieJWT("secret", 2*time.Second, defaultInactivityDuration, false)
if cookie, ok := auth.(*cookie); !ok {
t.Errorf("NewCookieJWT() did not create cookie Authenticator")
} else if cookie.Inactivity != time.Second {
t.Errorf("NewCookieJWT() inactivity was not two seconds: %s", cookie.Inactivity)
}

auth = NewCookieJWT("secret", time.Hour, defaultInactivityDuration)
auth = NewCookieJWT("secret", time.Hour, defaultInactivityDuration, false)
if cookie, ok := auth.(*cookie); !ok {
t.Errorf("NewCookieJWT() did not create cookie Authenticator")
} else if cookie.Inactivity != defaultInactivityDuration {
t.Errorf("NewCookieJWT() inactivity was not five minutes: %s", cookie.Inactivity)
}

auth = NewCookieJWT("secret", 0, defaultInactivityDuration)
auth = NewCookieJWT("secret", 0, defaultInactivityDuration, false)
if cookie, ok := auth.(*cookie); !ok {
t.Errorf("NewCookieJWT() did not create cookie Authenticator")
} else if cookie.Inactivity != defaultInactivityDuration {
Comment thread
alespour marked this conversation as resolved.
t.Errorf("NewCookieJWT() inactivity was not five minutes: %s", cookie.Inactivity)
}

auth = NewCookieJWT("secret", 30*time.Minute, defaultInactivityDuration, true)
if cookie, ok := auth.(*cookie); !ok {
t.Errorf("NewCookieJWT() did not create cookie Authenticator")
} else if cookie.Inactivity != defaultInactivityDuration {
t.Errorf("NewCookieJWT() inactivity was not five minutes: %s", cookie.Inactivity)
} else if !cookie.Secure {
t.Errorf("NewCookieJWT() secure was false, expected true")
}
}

func TestCookieExtend(t *testing.T) {
Expand Down
171 changes: 171 additions & 0 deletions server/middle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -194,3 +194,174 @@ func TestRouteMatchesPrincipal(t *testing.T) {
})
}
}

func TestRequireRequestedWithXMLHttpRequest(t *testing.T) {
logger := log.New(log.DebugLevel)
protected := RequireRequestedWithXMLHttpRequest(
logger,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}),
)

tests := []struct {
name string
header string
expected int
}{
{name: "missing header", expected: http.StatusForbidden},
{name: "invalid header", header: "fetch", expected: http.StatusForbidden},
{
name: "valid header",
header: "XMLHttpRequest",
expected: http.StatusNoContent,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "http://chronograf.test/query", nil)
if tt.header != "" {
req.Header.Set("X-Requested-With", tt.header)
}

rec := httptest.NewRecorder()
protected.ServeHTTP(rec, req)

if rec.Code != tt.expected {
t.Fatalf("status=%d, want=%d", rec.Code, tt.expected)
}
})
}
}

func TestSecurityHeaders(t *testing.T) {
protected := SecurityHeaders(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))

req := httptest.NewRequest(http.MethodGet, "http://chronograf.test/", nil)
rec := httptest.NewRecorder()

protected.ServeHTTP(rec, req)

if got := rec.Header().Get("X-Frame-Options"); got != "SAMEORIGIN" {
t.Fatalf("X-Frame-Options=%q, want %q", got, "SAMEORIGIN")
}

if got := rec.Header().Get("Cross-Origin-Resource-Policy"); got != "same-origin" {
t.Fatalf("Cross-Origin-Resource-Policy=%q, want %q", got, "same-origin")
}
}

func TestRequireSameOriginForSessionAuth(t *testing.T) {
logger := log.New(log.DebugLevel)
protected := RequireSameOriginForSessionAuth(
logger,
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}),
)

tests := []struct {
name string
method string
host string
origin string
referer string
forwarded string
hasSession bool
expected int
}{
{
name: "unsafe with matching origin is allowed",
method: http.MethodPost,
host: "chronograf.test",
origin: "http://chronograf.test",
hasSession: true,
expected: http.StatusNoContent,
},
{
name: "unsafe with mismatched origin is blocked",
method: http.MethodPost,
host: "chronograf.test",
origin: "https://attacker.test",
hasSession: true,
expected: http.StatusForbidden,
},
{
name: "unsafe with matching referer is allowed",
method: http.MethodPatch,
host: "chronograf.test",
referer: "http://chronograf.test/path",
hasSession: true,
expected: http.StatusNoContent,
},
{
name: "unsafe with scheme mismatch is blocked",
method: http.MethodPost,
host: "chronograf.test",
origin: "https://chronograf.test",
hasSession: true,
expected: http.StatusForbidden,
},
{
name: "unsafe with forwarded proto https allows https origin",
method: http.MethodPost,
host: "chronograf.test",
origin: "https://chronograf.test",
forwarded: "https",
hasSession: true,
expected: http.StatusNoContent,
},
{
name: "unsafe missing origin and referer is blocked with session",
method: http.MethodDelete,
host: "chronograf.test",
hasSession: true,
expected: http.StatusForbidden,
},
{
name: "unsafe without session bypasses origin guard",
method: http.MethodPost,
host: "chronograf.test",
origin: "https://attacker.test",
hasSession: false,
expected: http.StatusNoContent,
},
{
name: "safe method bypasses origin guard",
method: http.MethodGet,
host: "chronograf.test",
origin: "https://attacker.test",
hasSession: true,
expected: http.StatusNoContent,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(tt.method, "http://chronograf.test/test", nil)
req.Host = tt.host
if tt.origin != "" {
req.Header.Set("Origin", tt.origin)
}
if tt.referer != "" {
req.Header.Set("Referer", tt.referer)
}
if tt.forwarded != "" {
req.Header.Set("X-Forwarded-Proto", tt.forwarded)
}
if tt.hasSession {
req.AddCookie(&http.Cookie{Name: oauth2.DefaultCookieName, Value: "token"})
}

rec := httptest.NewRecorder()
protected.ServeHTTP(rec, req)

if rec.Code != tt.expected {
t.Fatalf("status=%d, want=%d", rec.Code, tt.expected)
}
})
}
}
34 changes: 30 additions & 4 deletions server/mux.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,13 +198,30 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
router.GET("/chronograf/v1/flux/suggestions/:name", EnsureViewer(service.FluxSuggestion))

// Source Proxy to Influx; Has gzip compression around the handler
influx := gziphandler.GzipHandler(http.HandlerFunc(EnsureReader(service.Influx)))
influx := RequireRequestedWithXMLHttpRequest(
opts.Logger,
gziphandler.GzipHandler(http.HandlerFunc(EnsureReader(service.Influx))),
)
router.Handler("POST", "/chronograf/v1/sources/:id/proxy", influx)

// Source Proxy to Influx's flux endpoint; compression because the responses from
// flux could be large.
router.Handler("POST", "/chronograf/v1/sources/:id/proxy/flux", EnsureReader(service.ProxyFlux))
router.Handler("GET", "/chronograf/v1/sources/:id/proxy/flux", EnsureReader(service.ProxyFlux))
router.Handler(
"POST",
"/chronograf/v1/sources/:id/proxy/flux",
RequireRequestedWithXMLHttpRequest(
opts.Logger,
http.HandlerFunc(EnsureReader(service.ProxyFlux)),
),
)
router.Handler(
"GET",
"/chronograf/v1/sources/:id/proxy/flux",
RequireRequestedWithXMLHttpRequest(
opts.Logger,
http.HandlerFunc(EnsureReader(service.ProxyFlux)),
),
)

// Write proxies line protocol write requests to InfluxDB
router.POST("/chronograf/v1/sources/:id/write", EnsureViewer(service.Write))
Expand All @@ -215,7 +232,14 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
//
// Admins should ensure that the InfluxDB source as the proper permissions
// intended for Chronograf Users with the Viewer Role type.
router.POST("/chronograf/v1/sources/:id/queries", EnsureReader(service.Queries))
router.Handler(
"POST",
"/chronograf/v1/sources/:id/queries",
RequireRequestedWithXMLHttpRequest(
opts.Logger,
http.HandlerFunc(EnsureReader(service.Queries)),
),
)

// Annotations are user-defined events associated with this source
router.GET("/chronograf/v1/sources/:id/annotations", EnsureReader(service.Annotations))
Expand Down Expand Up @@ -393,6 +417,8 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
} else {
out = router
}
out = RequireSameOriginForSessionAuth(opts.Logger, out)
out = SecurityHeaders(out)
out = Logger(opts.Logger, FlushingHandler(out))

return out
Expand Down
34 changes: 34 additions & 0 deletions server/requested_with.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package server

import (
"net/http"

"github.com/influxdata/chronograf"
)

const (
requestedWithHeaderName = "X-Requested-With"
xmlHttpRequestHeaderValue = "XMLHttpRequest"
)

// RequireRequestedWithXMLHttpRequest rejects requests that do not include
// X-Requested-With: XMLHttpRequest.
func RequireRequestedWithXMLHttpRequest(
logger chronograf.Logger,
next http.Handler,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(requestedWithHeaderName) != xmlHttpRequestHeaderValue {
logger.
WithField("component", "request_header_guard").
WithField("remote_addr", r.RemoteAddr).
WithField("method", r.Method).
WithField("url", r.URL).
Error("Missing or invalid X-Requested-With header")
Error(w, http.StatusForbidden, "missing required X-Requested-With header", logger)
return
}

next.ServeHTTP(w, r)
})
}
Loading
Loading