From 67c52c590a85ff12f1b21d7ba6f3b5c02b068d33 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 17 Mar 2026 10:29:23 +0100 Subject: [PATCH 01/20] fix: disable data explorer query/script execution from URL params --- .../data_explorer/containers/DataExplorer.tsx | 93 ------------------- 1 file changed, 93 deletions(-) diff --git a/ui/src/data_explorer/containers/DataExplorer.tsx b/ui/src/data_explorer/containers/DataExplorer.tsx index 4605ed2107..ba169ef4a2 100644 --- a/ui/src/data_explorer/containers/DataExplorer.tsx +++ b/ui/src/data_explorer/containers/DataExplorer.tsx @@ -2,15 +2,9 @@ import React, {PureComponent} from 'react' import {connect, ResolveThunks} from 'react-redux' import {withRouter, WithRouterProps} from 'react-router' -import qs from 'qs' -import uuid from 'uuid' -import _ from 'lodash' // Utils -import {stripPrefix} from 'src/utils/basepath' import {GlobalAutoRefresher} from 'src/utils/AutoRefresher' -import {getConfig} from 'src/dashboards/utils/cellGetters' -import {defaultQueryDraft} from 'src/shared/utils/timeMachine' import { TimeMachineContainer, TimeMachineContextConsumer, @@ -136,8 +130,6 @@ class DataExplorer extends PureComponent { public async componentDidMount() { const {autoRefresh} = this.props - await this.resolveQueryParams() - GlobalAutoRefresher.poll(autoRefresh) this.setState({isComponentMounted: true}) @@ -149,13 +141,6 @@ class DataExplorer extends PureComponent { if (autoRefresh !== prevProps.autoRefresh) { GlobalAutoRefresher.poll(autoRefresh) } - - if ( - prevProps.location === this.props.location && - this.state.isComponentMounted - ) { - this.writeQueryParams() - } } public componentWillUnmount() { @@ -221,84 +206,6 @@ class DataExplorer extends PureComponent { ) } - private async resolveQueryParams() { - const { - source, - sourceLink, - queryDrafts, - onUpdateQueryDrafts, - onInitFluxScript, - } = this.props - const {query, script} = this.readQueryParams() - - if (script) { - onInitFluxScript(script) - return - } - - if (query) { - if (queryDrafts.find(q => q.query === query)) { - // Has matching query draft already loaded - return - } - - const id = uuid.v4() - const queryConfig = await getConfig( - source.links.queries, - id, - query, - this.templates - ) - - const queryDraft = { - id, - query, - queryConfig, - source: sourceLink, - type: QueryType.InfluxQL, - } - - onUpdateQueryDrafts([queryDraft]) - return - } - - if (!queryDrafts.length) { - const queryDraft = defaultQueryDraft(QueryType.InfluxQL) - - onUpdateQueryDrafts([queryDraft]) - return - } - } - - private readQueryParams(): {query?: string; script?: string} { - const {query, script} = qs.parse(location.search, { - ignoreQueryPrefix: true, - }) - - return {query: query as string, script: script as string} - } - - private writeQueryParams() { - const {router, queryDrafts, script, queryType} = this.props - const query = _.get(queryDrafts, '0.query') - const isFlux = queryType === QueryType.Flux - - let queryParams - - if (isFlux && script) { - queryParams = {script} - } else if (!isFlux && query) { - queryParams = {query} - } - - const pathname = stripPrefix(location.pathname) - const search = queryParams ? `?${qs.stringify(queryParams)}` : '' - - if (location.search !== search) { - router.push(pathname + search) - } - } - private get writeDataForm(): JSX.Element { const {source, errorThrownAction, writeLineProtocol, queryType} = this.props From c9cd31d0abb185ab161f7ef7899e8c8da22df7ad Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 17 Mar 2026 10:38:25 +0100 Subject: [PATCH 02/20] fix: cookie hardening --- oauth2/cookies.go | 8 +++++++- oauth2/cookies_test.go | 6 +++--- server/server.go | 2 +- 3 files changed, 11 insertions(+), 5 deletions(-) diff --git a/oauth2/cookies.go b/oauth2/cookies.go index 45c435f0e1..1be8168d95 100644 --- a/oauth2/cookies.go +++ b/oauth2/cookies.go @@ -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 @@ -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, @@ -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) @@ -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) diff --git a/oauth2/cookies_test.go b/oauth2/cookies_test.go index 865a6eceba..602453a303 100644 --- a/oauth2/cookies_test.go +++ b/oauth2/cookies_test.go @@ -162,21 +162,21 @@ 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 { diff --git a/server/server.go b/server/server.go index 996fa00469..774f290236 100644 --- a/server/server.go +++ b/server/server.go @@ -772,7 +772,7 @@ func (s *Server) Serve(ctx context.Context) { transport.TLSClientConfig.RootCAs = certs s.oauthClient = http.Client{Transport: transport} - auth := oauth2.NewCookieJWT(s.TokenSecret, s.AuthDuration, s.InactivityDuration) + auth := oauth2.NewCookieJWT(s.TokenSecret, s.AuthDuration, s.InactivityDuration, s.useTLS()) providerFuncs := []func(func(oauth2.Provider, oauth2.Mux)){ provide(s.githubOAuth(logger, auth)), provide(s.googleOAuth(logger, auth)), From 8350c4baa261fbfe777355064500e8e9b889ce1a Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 17 Mar 2026 10:52:48 +0100 Subject: [PATCH 03/20] fix: require XMLHttpRequest header on query execution endpoints --- server/middle_test.go | 40 +++++++++++++++++++++ server/mux.go | 23 ++++++++++-- ui/src/shared/apis/flux/cancellableQuery.ts | 1 + ui/src/shared/apis/flux/query.ts | 1 + ui/src/utils/ajax.ts | 7 +++- ui/src/worker/jobs/postJSON.ts | 5 ++- ui/src/worker/jobs/proxy.ts | 4 +++ 7 files changed, 76 insertions(+), 5 deletions(-) diff --git a/server/middle_test.go b/server/middle_test.go index a57f563708..80e218fb37 100644 --- a/server/middle_test.go +++ b/server/middle_test.go @@ -194,3 +194,43 @@ 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) + } + }) + } +} diff --git a/server/mux.go b/server/mux.go index e1c0097e9a..b12d6f8be3 100644 --- a/server/mux.go +++ b/server/mux.go @@ -198,12 +198,22 @@ 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( + "POST", + "/chronograf/v1/sources/:id/proxy/flux", + RequireRequestedWithXMLHttpRequest( + opts.Logger, + http.HandlerFunc(EnsureReader(service.ProxyFlux)), + ), + ) router.Handler("GET", "/chronograf/v1/sources/:id/proxy/flux", EnsureReader(service.ProxyFlux)) // Write proxies line protocol write requests to InfluxDB @@ -215,7 +225,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)) diff --git a/ui/src/shared/apis/flux/cancellableQuery.ts b/ui/src/shared/apis/flux/cancellableQuery.ts index ccc62ee98d..03096aa1e4 100644 --- a/ui/src/shared/apis/flux/cancellableQuery.ts +++ b/ui/src/shared/apis/flux/cancellableQuery.ts @@ -49,6 +49,7 @@ export const runQuery = ( const headers = { 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip', + 'X-Requested-With': 'XMLHttpRequest', } const request = fetch(url, { diff --git a/ui/src/shared/apis/flux/query.ts b/ui/src/shared/apis/flux/query.ts index 15f8bb9d89..f7487299fb 100644 --- a/ui/src/shared/apis/flux/query.ts +++ b/ui/src/shared/apis/flux/query.ts @@ -133,6 +133,7 @@ export const executeQuery = async ( xhr.open('POST', url) xhr.setRequestHeader('Content-Type', 'application/json') + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest') xhr.send(body) return deferred.promise diff --git a/ui/src/utils/ajax.ts b/ui/src/utils/ajax.ts index f90a0ceb0c..44a5e05a16 100644 --- a/ui/src/utils/ajax.ts +++ b/ui/src/utils/ajax.ts @@ -122,10 +122,15 @@ async function AJAX( : JSON.stringify(requestData) } + const requestHeadersWithRequestedWith = + method === 'GET' + ? requestHeaders + : {'X-Requested-With': 'XMLHttpRequest', ...requestHeaders} + const fetchResponse = await fetch(url, { method: method as string, body, - headers: requestHeaders, + headers: requestHeadersWithRequestedWith, signal, }).catch(e => e.name === 'AbortError' diff --git a/ui/src/worker/jobs/postJSON.ts b/ui/src/worker/jobs/postJSON.ts index b2293d55f6..605a98d1d1 100644 --- a/ui/src/worker/jobs/postJSON.ts +++ b/ui/src/worker/jobs/postJSON.ts @@ -7,7 +7,10 @@ export default async (msg: Message): Promise => { const response = await fetch(url, { method: 'POST', body, - headers: {'Content-Type': 'application/json'}, + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, }) return response diff --git a/ui/src/worker/jobs/proxy.ts b/ui/src/worker/jobs/proxy.ts index 827ad1499c..334d55d859 100644 --- a/ui/src/worker/jobs/proxy.ts +++ b/ui/src/worker/jobs/proxy.ts @@ -17,6 +17,10 @@ const proxy = async (msg: ProxyMsg): Promise<{data: any}> => { const response = await fetch(url, { method: 'POST', body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'XMLHttpRequest', + }, }) if (response.ok) { return {data: response.status === 204 ? '' : await response.json()} From 5523856904ade6dc811a57e8d3dc52c16a96e59d Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 17 Mar 2026 10:55:16 +0100 Subject: [PATCH 04/20] fix: add SAMEORIGIN and CORP response headers --- server/middle_test.go | 19 +++++++++++++++++++ server/mux.go | 1 + 2 files changed, 20 insertions(+) diff --git a/server/middle_test.go b/server/middle_test.go index 80e218fb37..e40a38959c 100644 --- a/server/middle_test.go +++ b/server/middle_test.go @@ -234,3 +234,22 @@ func TestRequireRequestedWithXMLHttpRequest(t *testing.T) { }) } } + +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") + } +} diff --git a/server/mux.go b/server/mux.go index b12d6f8be3..2d00c68743 100644 --- a/server/mux.go +++ b/server/mux.go @@ -410,6 +410,7 @@ func NewMux(opts MuxOpts, service Service) http.Handler { } else { out = router } + out = SecurityHeaders(out) out = Logger(opts.Logger, FlushingHandler(out)) return out From d11e0092605fd29c9bb3c45743e7790c20a81153 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 17 Mar 2026 11:17:40 +0100 Subject: [PATCH 05/20] fix: enforce same-origin on unsafe session-authenticated requests --- server/middle_test.go | 91 +++++++++++++++++++++++++++++++++++++++++++ server/mux.go | 1 + 2 files changed, 92 insertions(+) diff --git a/server/middle_test.go b/server/middle_test.go index e40a38959c..4ea3089671 100644 --- a/server/middle_test.go +++ b/server/middle_test.go @@ -253,3 +253,94 @@ func TestSecurityHeaders(t *testing.T) { 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 + hasSession bool + expected int + }{ + { + name: "unsafe with matching origin is allowed", + method: http.MethodPost, + host: "chronograf.test", + origin: "https://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: "https://chronograf.test/path", + 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.hasSession { + req.AddCookie(&http.Cookie{Name: "session", Value: "token"}) + } + + rec := httptest.NewRecorder() + protected.ServeHTTP(rec, req) + + if rec.Code != tt.expected { + t.Fatalf("status=%d, want=%d", rec.Code, tt.expected) + } + }) + } +} diff --git a/server/mux.go b/server/mux.go index 2d00c68743..a64cc48490 100644 --- a/server/mux.go +++ b/server/mux.go @@ -411,6 +411,7 @@ func NewMux(opts MuxOpts, service Service) http.Handler { out = router } out = SecurityHeaders(out) + out = RequireSameOriginForSessionAuth(opts.Logger, out) out = Logger(opts.Logger, FlushingHandler(out)) return out From d75e19bcde50a833e15c1b5949cb0c4345a959bc Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 17 Mar 2026 11:20:26 +0100 Subject: [PATCH 06/20] fix: add missing files --- server/requested_with.go | 34 +++++++++++++++++ server/same_origin.go | 76 ++++++++++++++++++++++++++++++++++++++ server/security_headers.go | 20 ++++++++++ 3 files changed, 130 insertions(+) create mode 100644 server/requested_with.go create mode 100644 server/same_origin.go create mode 100644 server/security_headers.go diff --git a/server/requested_with.go b/server/requested_with.go new file mode 100644 index 0000000000..8deb2e6281 --- /dev/null +++ b/server/requested_with.go @@ -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) + }) +} diff --git a/server/same_origin.go b/server/same_origin.go new file mode 100644 index 0000000000..0aef7b773b --- /dev/null +++ b/server/same_origin.go @@ -0,0 +1,76 @@ +package server + +import ( + "net/http" + "net/url" + "strings" + + "github.com/influxdata/chronograf" + "github.com/influxdata/chronograf/oauth2" +) + +// RequireSameOriginForSessionAuth validates Origin/Referer for unsafe methods +// when the request carries the session cookie used for browser auth. +func RequireSameOriginForSessionAuth(logger chronograf.Logger, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isUnsafeMethod(r.Method) || !hasSessionCookie(r) { + next.ServeHTTP(w, r) + return + } + + if !isRequestSameOrigin(r) { + logger. + WithField("component", "same_origin_guard"). + WithField("remote_addr", r.RemoteAddr). + WithField("method", r.Method). + WithField("url", r.URL). + WithField("origin", r.Header.Get("Origin")). + WithField("referer", r.Header.Get("Referer")). + Error("Cross-origin unsafe request blocked") + Error(w, http.StatusForbidden, "cross-origin request blocked", logger) + return + } + + next.ServeHTTP(w, r) + }) +} + +func isUnsafeMethod(method string) bool { + switch method { + case http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete: + return true + default: + return false + } +} + +func hasSessionCookie(r *http.Request) bool { + _, err := r.Cookie(oauth2.DefaultCookieName) + return err == nil +} + +func isRequestSameOrigin(r *http.Request) bool { + expectedHost := r.Host + if expectedHost == "" { + return false + } + + if origin := r.Header.Get("Origin"); origin != "" { + return sameHost(origin, expectedHost) + } + + if referer := r.Header.Get("Referer"); referer != "" { + return sameHost(referer, expectedHost) + } + + return false +} + +func sameHost(rawURL, expectedHost string) bool { + u, err := url.Parse(rawURL) + if err != nil { + return false + } + + return strings.EqualFold(u.Host, expectedHost) +} diff --git a/server/security_headers.go b/server/security_headers.go new file mode 100644 index 0000000000..67a48a4803 --- /dev/null +++ b/server/security_headers.go @@ -0,0 +1,20 @@ +package server + +import "net/http" + +const ( + xFrameOptionsHeaderName = "X-Frame-Options" + xFrameOptionsHeaderValue = "SAMEORIGIN" + crossOriginResourcePolicyHeaderName = "Cross-Origin-Resource-Policy" + crossOriginResourcePolicyHeaderValue = "same-origin" +) + +// SecurityHeaders sets defense-in-depth browser isolation headers. +func SecurityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + headers := w.Header() + headers.Set(xFrameOptionsHeaderName, xFrameOptionsHeaderValue) + headers.Set(crossOriginResourcePolicyHeaderName, crossOriginResourcePolicyHeaderValue) + next.ServeHTTP(w, r) + }) +} From 7ebbfda702e3e021b7911ddc6ce9fc1277f63e22 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Tue, 17 Mar 2026 16:48:07 +0100 Subject: [PATCH 07/20] fix: require XMLHttpRequest header for flux proxy GET --- server/mux.go | 9 ++++++++- ui/src/utils/ajax.ts | 4 +++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/server/mux.go b/server/mux.go index a64cc48490..aeb8e55398 100644 --- a/server/mux.go +++ b/server/mux.go @@ -214,7 +214,14 @@ func NewMux(opts MuxOpts, service Service) http.Handler { http.HandlerFunc(EnsureReader(service.ProxyFlux)), ), ) - router.Handler("GET", "/chronograf/v1/sources/:id/proxy/flux", 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)) diff --git a/ui/src/utils/ajax.ts b/ui/src/utils/ajax.ts index 44a5e05a16..1d32226e88 100644 --- a/ui/src/utils/ajax.ts +++ b/ui/src/utils/ajax.ts @@ -122,8 +122,10 @@ async function AJAX( : JSON.stringify(requestData) } + const isFluxProxyGet = + method === 'GET' && typeof url === 'string' && url.includes('/proxy/flux') const requestHeadersWithRequestedWith = - method === 'GET' + method === 'GET' && !isFluxProxyGet ? requestHeaders : {'X-Requested-With': 'XMLHttpRequest', ...requestHeaders} From 38ea6f7666d0547d10425f3487999274efa750bb Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Wed, 18 Mar 2026 06:55:25 +0100 Subject: [PATCH 08/20] test(e2e): inject same-origin headers into unsafe cy.request calls --- ui/cypress/support/commands.ts | 65 +++++++++++++++++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/ui/cypress/support/commands.ts b/ui/cypress/support/commands.ts index c5ba9ee4bc..1c29377659 100644 --- a/ui/cypress/support/commands.ts +++ b/ui/cypress/support/commands.ts @@ -1,4 +1,37 @@ const apiUrl = '/chronograf/v1' +const unsafeMethods = new Set(['POST', 'PUT', 'PATCH', 'DELETE']) + +const shouldInjectSameOriginHeaders = (method: string, url: string): boolean => { + if (!unsafeMethods.has(method.toUpperCase())) { + return false + } + + return ( + url.startsWith('/chronograf/') || + url.startsWith('http://localhost:8888/chronograf/') + ) +} + +const withSameOriginHeaders = (options: any): any => { + const method = String(options?.method || 'GET').toUpperCase() + const url = String(options?.url || '') + + if (!shouldInjectSameOriginHeaders(method, url)) { + return options + } + + const baseUrl = String(Cypress.config('baseUrl') || 'http://localhost:8888') + .replace(/\/$/, '') + + return { + ...options, + headers: { + Origin: baseUrl, + Referer: `${baseUrl}/`, + ...(options.headers || {}), + }, + } +} export const getByTestID = ( dataTest: string, @@ -589,6 +622,36 @@ export const clickAttached = (subject?: JQuery): void => { }) } +Cypress.Commands.overwrite('request', (originalFn: any, ...args: any[]) => { + if (args.length === 1 && typeof args[0] === 'object') { + return originalFn(withSameOriginHeaders(args[0])) + } + + if (args.length === 1 && typeof args[0] === 'string') { + return originalFn(withSameOriginHeaders({url: args[0]})) + } + + if ( + args.length === 2 && + typeof args[0] === 'string' && + typeof args[1] === 'string' + ) { + return originalFn(withSameOriginHeaders({method: args[0], url: args[1]})) + } + + if ( + args.length === 3 && + typeof args[0] === 'string' && + typeof args[1] === 'string' + ) { + return originalFn( + withSameOriginHeaders({method: args[0], url: args[1], body: args[2]}) + ) + } + + return originalFn(...args) +}) + Cypress.Commands.add('getByTestID', getByTestID) Cypress.Commands.add('createInfluxDBConnection', createInfluxDBConnection) Cypress.Commands.add('removeInfluxDBConnections', removeInfluxDBConnections) @@ -617,4 +680,4 @@ Cypress.Commands.add('toInitialState', toInitialState) Cypress.Commands.add('writePoints', writePoints) Cypress.Commands.add('clickAttached', {prevSubject: 'element'}, clickAttached) Cypress.Commands.add('changeUserInfo', changeUserInfo) -Cypress.Commands.add('deleteMappings', deleteMappings) \ No newline at end of file +Cypress.Commands.add('deleteMappings', deleteMappings) From 3e80203ce3bf097f864cbc7abf008e0b71928d50 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Wed, 18 Mar 2026 07:42:13 +0100 Subject: [PATCH 09/20] test(e2e): fix flaky DB name check in explore_influxql --- .../integration/explore_influxql.test.ts | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/ui/cypress/integration/explore_influxql.test.ts b/ui/cypress/integration/explore_influxql.test.ts index 8855490693..e11db121ae 100644 --- a/ui/cypress/integration/explore_influxql.test.ts +++ b/ui/cypress/integration/explore_influxql.test.ts @@ -121,19 +121,31 @@ describe('InfluxQL', () => { }) it('create and delete a database with use of metaquery templates', () => { + let targetDatabase = 'db_name' + cy.intercept(`chronograf/v1/sources/${source.id}/queries`).as('postQuery') cy.get('.query-editor--status-actions').within(() => { cy.get('.dropdown').contains('Metaquery Templates').click() cy.getByTestID('dropdown--item').contains('Create Database').click() + }) + cy.get('.CodeMirror-code') + .invoke('text') + .then(queryText => { + const match = queryText.match(/CREATE DATABASE\\s+\"([^\"]+)\"/i) + if (match && match[1]) { + targetDatabase = match[1] + } + }) + cy.get('.query-editor--status-actions').within(() => { cy.get('button').contains('Submit Query').click() cy.wait('@postQuery') cy.reload() }) cy.contains('.query-builder--column', 'DB.RetentionPolicy').within(() => { - cy.contains('.query-builder--list-item', 'db_name.autogen').should( - 'exist' - ) + cy.contains('.query-builder--list-item', `${targetDatabase}.autogen`, { + timeout: 10000, + }).should('exist') }) cy.get('.query-editor--status-actions').within(() => { cy.get('.dropdown').contains('Metaquery Templates').click() @@ -142,9 +154,9 @@ describe('InfluxQL', () => { cy.wait('@postQuery') }) cy.contains('.query-builder--column', 'DB.RetentionPolicy').within(() => { - cy.contains('.query-builder--list-item', 'db_name.autogen').should( - 'not.exist' - ) + cy.contains('.query-builder--list-item', `${targetDatabase}.autogen`, { + timeout: 10000, + }).should('not.exist') }) }) }) From 39e85228e1d99130fdc9ba5ad87531b9b0524624 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Wed, 18 Mar 2026 08:22:55 +0100 Subject: [PATCH 10/20] docs: update CHANGELOG --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1e314f060..cbaf075c9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. From 96fe10b8b47e186faf36a2ae53995a5df7501949 Mon Sep 17 00:00:00 2001 From: alespour <42931850+alespour@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:59:00 +0100 Subject: [PATCH 11/20] fix: force X-Requested-With header in AJAX requests Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- ui/src/utils/ajax.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/src/utils/ajax.ts b/ui/src/utils/ajax.ts index 1d32226e88..1aa61ae64a 100644 --- a/ui/src/utils/ajax.ts +++ b/ui/src/utils/ajax.ts @@ -127,7 +127,7 @@ async function AJAX( const requestHeadersWithRequestedWith = method === 'GET' && !isFluxProxyGet ? requestHeaders - : {'X-Requested-With': 'XMLHttpRequest', ...requestHeaders} + : {...requestHeaders, 'X-Requested-With': 'XMLHttpRequest'} const fetchResponse = await fetch(url, { method: method as string, From 0a79a280a974fb9a1724de9e96552084fbd6853e Mon Sep 17 00:00:00 2001 From: alespour <42931850+alespour@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:08:21 +0100 Subject: [PATCH 12/20] fix: ensure security headers are set on blocked same-origin requests Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- server/mux.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/mux.go b/server/mux.go index aeb8e55398..bc1605de19 100644 --- a/server/mux.go +++ b/server/mux.go @@ -417,8 +417,8 @@ func NewMux(opts MuxOpts, service Service) http.Handler { } else { out = router } - out = SecurityHeaders(out) out = RequireSameOriginForSessionAuth(opts.Logger, out) + out = SecurityHeaders(out) out = Logger(opts.Logger, FlushingHandler(out)) return out From 2ac02d117a08d171a23f5d5f0bced8c6a2a844fc Mon Sep 17 00:00:00 2001 From: alespour <42931850+alespour@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:10:36 +0100 Subject: [PATCH 13/20] test: use oauth2.DefaultCookieName in same-origin middleware tests Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- server/middle_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/middle_test.go b/server/middle_test.go index 4ea3089671..c0e8e290f8 100644 --- a/server/middle_test.go +++ b/server/middle_test.go @@ -332,7 +332,7 @@ func TestRequireSameOriginForSessionAuth(t *testing.T) { req.Header.Set("Referer", tt.referer) } if tt.hasSession { - req.AddCookie(&http.Cookie{Name: "session", Value: "token"}) + req.AddCookie(&http.Cookie{Name: oauth2.DefaultCookieName, Value: "token"}) } rec := httptest.NewRecorder() From 1f468aa4f8e51a5d7773092198261dc249f9ca53 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Wed, 18 Mar 2026 09:06:14 +0100 Subject: [PATCH 14/20] fix: same origin also compares scheme and port --- server/middle_test.go | 25 ++++++++++++++++++-- server/same_origin.go | 55 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 73 insertions(+), 7 deletions(-) diff --git a/server/middle_test.go b/server/middle_test.go index c0e8e290f8..d9903032ea 100644 --- a/server/middle_test.go +++ b/server/middle_test.go @@ -269,6 +269,7 @@ func TestRequireSameOriginForSessionAuth(t *testing.T) { host string origin string referer string + forwarded string hasSession bool expected int }{ @@ -276,7 +277,7 @@ func TestRequireSameOriginForSessionAuth(t *testing.T) { name: "unsafe with matching origin is allowed", method: http.MethodPost, host: "chronograf.test", - origin: "https://chronograf.test", + origin: "http://chronograf.test", hasSession: true, expected: http.StatusNoContent, }, @@ -292,7 +293,24 @@ func TestRequireSameOriginForSessionAuth(t *testing.T) { name: "unsafe with matching referer is allowed", method: http.MethodPatch, host: "chronograf.test", - referer: "https://chronograf.test/path", + 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, }, @@ -331,6 +349,9 @@ func TestRequireSameOriginForSessionAuth(t *testing.T) { 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"}) } diff --git a/server/same_origin.go b/server/same_origin.go index 0aef7b773b..cb73005f76 100644 --- a/server/same_origin.go +++ b/server/same_origin.go @@ -1,6 +1,7 @@ package server import ( + "net" "net/http" "net/url" "strings" @@ -50,27 +51,71 @@ func hasSessionCookie(r *http.Request) bool { } func isRequestSameOrigin(r *http.Request) bool { + expectedScheme := requestScheme(r) expectedHost := r.Host if expectedHost == "" { return false } if origin := r.Header.Get("Origin"); origin != "" { - return sameHost(origin, expectedHost) + return sameHost(origin, expectedScheme, expectedHost) } if referer := r.Header.Get("Referer"); referer != "" { - return sameHost(referer, expectedHost) + return sameHost(referer, expectedScheme, expectedHost) } return false } -func sameHost(rawURL, expectedHost string) bool { +func requestScheme(r *http.Request) string { + if xfp := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); xfp != "" { + return strings.ToLower(strings.TrimSpace(strings.Split(xfp, ",")[0])) + } + if r.TLS != nil { + return "https" + } + return "http" +} + +func sameHost(rawURL, expectedScheme, expectedHost string) bool { u, err := url.Parse(rawURL) - if err != nil { + if err != nil || u.Scheme == "" || u.Host == "" { return false } + if !strings.EqualFold(u.Scheme, expectedScheme) { + return false + } + + originHost := u.Hostname() + originPort := u.Port() + if originPort == "" { + originPort = defaultPort(u.Scheme) + } - return strings.EqualFold(u.Host, expectedHost) + hostOnly := expectedHost + expectedPort := "" + if h, p, err := net.SplitHostPort(expectedHost); err == nil { + hostOnly = h + expectedPort = p + } else { + expectedPort = defaultPort(expectedScheme) + } + + if originPort == "" || expectedPort == "" { + return false + } + + return strings.EqualFold(originHost, hostOnly) && originPort == expectedPort +} + +func defaultPort(scheme string) string { + switch strings.ToLower(scheme) { + case "http": + return "80" + case "https": + return "443" + default: + return "" + } } From 1ed29130950625abf6f162806b898f1cd2384382 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Wed, 18 Mar 2026 09:52:20 +0100 Subject: [PATCH 15/20] fix: secure cookies --- server/server.go | 22 ++++++++++++++++++- server/server_test.go | 50 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/server/server.go b/server/server.go index 774f290236..d80c1ae65c 100644 --- a/server/server.go +++ b/server/server.go @@ -503,6 +503,26 @@ func (s *Server) useTLS() bool { return s.Cert != "" } +// useSecureCookies determines whether auth cookies should be marked Secure. +// It is true when Chronograf serves TLS directly, or when the configured +// public URL is HTTPS (e.g. TLS termination at a reverse proxy). +func (s *Server) useSecureCookies() bool { + if s.useTLS() { + return true + } + + if s.PublicURL == "" { + return false + } + + publicURL, err := url.Parse(s.PublicURL) + if err != nil { + return false + } + + return strings.EqualFold(publicURL.Scheme, "https") +} + // NewListener will return an http or https listener depending useTLS(). func (s *Server) NewListener() (net.Listener, error) { addr := net.JoinHostPort(s.Host, strconv.Itoa(s.Port)) @@ -772,7 +792,7 @@ func (s *Server) Serve(ctx context.Context) { transport.TLSClientConfig.RootCAs = certs s.oauthClient = http.Client{Transport: transport} - auth := oauth2.NewCookieJWT(s.TokenSecret, s.AuthDuration, s.InactivityDuration, s.useTLS()) + auth := oauth2.NewCookieJWT(s.TokenSecret, s.AuthDuration, s.InactivityDuration, s.useSecureCookies()) providerFuncs := []func(func(oauth2.Provider, oauth2.Mux)){ provide(s.githubOAuth(logger, auth)), provide(s.googleOAuth(logger, auth)), diff --git a/server/server_test.go b/server/server_test.go index 4537af4587..909b356732 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -81,6 +81,56 @@ func Test_validBasepath(t *testing.T) { } } +func Test_useSecureCookies(t *testing.T) { + tests := []struct { + name string + server Server + expected bool + }{ + { + name: "secure when tls is enabled", + server: Server{ + Cert: "server.crt", + }, + expected: true, + }, + { + name: "secure when public url is https", + server: Server{ + PublicURL: "https://chronograf.example.com", + }, + expected: true, + }, + { + name: "not secure when public url is http", + server: Server{ + PublicURL: "http://chronograf.example.com", + }, + expected: false, + }, + { + name: "not secure when public url is invalid", + server: Server{ + PublicURL: "://bad", + }, + expected: false, + }, + { + name: "not secure when no tls and no public url", + server: Server{}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := tt.server.useSecureCookies(); got != tt.expected { + t.Fatalf("useSecureCookies() = %v, want %v", got, tt.expected) + } + }) + } +} + func TestValidAuth(t *testing.T) { tests := []struct { desc string From ec44334e284e8a86cc7ac6288c22a49b1db1d7cd Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Wed, 18 Mar 2026 09:55:41 +0100 Subject: [PATCH 16/20] refactor(ui): remove unused withRouter from DataExplorer --- ui/src/data_explorer/containers/DataExplorer.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/ui/src/data_explorer/containers/DataExplorer.tsx b/ui/src/data_explorer/containers/DataExplorer.tsx index ba169ef4a2..8e5cebb240 100644 --- a/ui/src/data_explorer/containers/DataExplorer.tsx +++ b/ui/src/data_explorer/containers/DataExplorer.tsx @@ -1,7 +1,6 @@ // Libraries import React, {PureComponent} from 'react' import {connect, ResolveThunks} from 'react-redux' -import {withRouter, WithRouterProps} from 'react-router' // Utils import {GlobalAutoRefresher} from 'src/utils/AutoRefresher' @@ -100,8 +99,7 @@ type ReduxDispatchProps = ResolveThunks<{ type Props = PassedProps & ConnectedProps & ReduxStateProps & - ReduxDispatchProps & - WithRouterProps + ReduxDispatchProps interface State { isWriteFormVisible: boolean @@ -340,7 +338,7 @@ class DataExplorer extends PureComponent { const DataExplorer2 = ErrorHandling(DataExplorer) const ConnectedDataExplorer = ( - props: PassedProps & WithRouterProps & ReduxStateProps & ReduxDispatchProps + props: PassedProps & ReduxStateProps & ReduxDispatchProps ) => { return ( @@ -400,4 +398,4 @@ const mdtp = { onSetTimeZone: setTimeZoneAction, } -export default withRouter(connect(mstp, mdtp)(ConnectedDataExplorer)) +export default connect(mstp, mdtp)(ConnectedDataExplorer) From f4dc2b4d82e99e5cb3fc5dca6dcf8d20d7b4984e Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Wed, 18 Mar 2026 11:15:02 +0100 Subject: [PATCH 17/20] test: fix regexp --- ui/cypress/integration/explore_influxql.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/cypress/integration/explore_influxql.test.ts b/ui/cypress/integration/explore_influxql.test.ts index e11db121ae..bbf9c5bb34 100644 --- a/ui/cypress/integration/explore_influxql.test.ts +++ b/ui/cypress/integration/explore_influxql.test.ts @@ -131,7 +131,7 @@ describe('InfluxQL', () => { cy.get('.CodeMirror-code') .invoke('text') .then(queryText => { - const match = queryText.match(/CREATE DATABASE\\s+\"([^\"]+)\"/i) + const match = queryText.match(/CREATE DATABASE\s+"([^"]+)"/i) if (match && match[1]) { targetDatabase = match[1] } From c14a0b360e48456d82ddad937803bc3d8ce002e1 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Wed, 18 Mar 2026 11:15:14 +0100 Subject: [PATCH 18/20] style: go fmt --- server/server_test.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/server/server_test.go b/server/server_test.go index 909b356732..5ce3e800b2 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -83,9 +83,9 @@ func Test_validBasepath(t *testing.T) { func Test_useSecureCookies(t *testing.T) { tests := []struct { - name string - server Server - expected bool + name string + server Server + expected bool }{ { name: "secure when tls is enabled", @@ -116,8 +116,8 @@ func Test_useSecureCookies(t *testing.T) { expected: false, }, { - name: "not secure when no tls and no public url", - server: Server{}, + name: "not secure when no tls and no public url", + server: Server{}, expected: false, }, } From ea3e1c64bece6ccd8295ed494130f0816f4fa6f7 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Wed, 18 Mar 2026 11:23:54 +0100 Subject: [PATCH 19/20] style: formatting --- ui/src/data_explorer/containers/DataExplorer.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/ui/src/data_explorer/containers/DataExplorer.tsx b/ui/src/data_explorer/containers/DataExplorer.tsx index 8e5cebb240..a1b0a50eb7 100644 --- a/ui/src/data_explorer/containers/DataExplorer.tsx +++ b/ui/src/data_explorer/containers/DataExplorer.tsx @@ -96,10 +96,7 @@ type ReduxDispatchProps = ResolveThunks<{ onSetTimeZone: typeof setTimeZoneAction }> -type Props = PassedProps & - ConnectedProps & - ReduxStateProps & - ReduxDispatchProps +type Props = PassedProps & ConnectedProps & ReduxStateProps & ReduxDispatchProps interface State { isWriteFormVisible: boolean From d01989f446037c55c0e26ed0896e9710f1e04cf6 Mon Sep 17 00:00:00 2001 From: Ales Pour Date: Thu, 26 Mar 2026 17:30:15 +0100 Subject: [PATCH 20/20] test(oauth2): cover secure cookie flag in NewCookieJWT --- oauth2/cookies_test.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/oauth2/cookies_test.go b/oauth2/cookies_test.go index 602453a303..4ff068023e 100644 --- a/oauth2/cookies_test.go +++ b/oauth2/cookies_test.go @@ -182,6 +182,15 @@ func TestNewCookieJWT(t *testing.T) { } else if cookie.Inactivity != defaultInactivityDuration { 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) {