From 1fc41c1d812a9a62f1191fde168d9120c21dd667 Mon Sep 17 00:00:00 2001 From: Andres Morey Date: Thu, 15 Feb 2024 15:39:36 +0300 Subject: [PATCH] Adds protection against cross-site WebSocket hijacking using CSRF tokens (#6) To protect against CSWSH attacks we need a way to identify cross-site requests and prevent them from connecting to the server. The easiest way to do this is by ensuring that the request Host and Origin are the same but unfortunately, kubectl proxy modifies the request Host so we can't use this method. Another easy method is to check the Sec-Fetch-Site header but unfortunately it isn't implemented in some popular browsers (see #3) so we can't use this method either. Instead, this PR uses the old-school method of CSRF token validation to identify cross-site requests and block them. After a WebSocket connection is made, the client is required to authenticate using the CSRF token value. If the token fails validation the connection is closed, otherwise it is allowed to continue. This PR also moves the GraphiQL playground interface to a static page accessible at /graphiql. --- backend/go.sum | 2 - backend/graph/handler.go | 6 ++- backend/internal/ginapp/ginapp.go | 18 ++++--- backend/internal/ginapp/graphql.go | 50 ++++++++++++++++--- backend/internal/ginapp/graphql_test.go | 47 ++++++++++++++++-- frontend/package.json | 4 +- frontend/pnpm-lock.yaml | 52 ++++++++++---------- frontend/public/graphiql.html | 65 +++++++++++++++++++++++++ frontend/src/apollo-client.ts | 5 +- frontend/src/lib/helpers.tsx | 11 +++-- frontend/src/pages/_root.tsx | 10 ++++ 11 files changed, 216 insertions(+), 54 deletions(-) create mode 100644 frontend/public/graphiql.html diff --git a/backend/go.sum b/backend/go.sum index 39cb800..3f15137 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -40,11 +40,9 @@ github.com/99designs/gqlgen v0.17.43 h1:I4SYg6ahjowErAQcHFVKy5EcWuwJ3+Xw9z2fLpuF github.com/99designs/gqlgen v0.17.43/go.mod h1:lO0Zjy8MkZgBdv4T1U91x09r0e0WFOdhVUutlQs1Rsc= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= -github.com/PuerkitoBio/goquery v1.8.1 h1:uQxhNlArOIdbrH1tr0UXwdVFgDcZDrZVdcpygAcwmWM= github.com/agnivade/levenshtein v1.1.1 h1:QY8M92nrzkmr798gCo3kmMyqXFzdQVpxLlGPRBij0P8= github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= -github.com/andybalholm/cascadia v1.3.1 h1:nhxRkql1kdYCc8Snf7D5/D3spOX+dBgjA6u8x004T2c= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= diff --git a/backend/graph/handler.go b/backend/graph/handler.go index 524e14f..ef2b88c 100644 --- a/backend/graph/handler.go +++ b/backend/graph/handler.go @@ -55,7 +55,11 @@ func NewHandler(r *Resolver, options *HandlerOptions) *handler.Server { h.AddTransport(&transport.Websocket{ Upgrader: websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { - return (r.Header.Get("Sec-Fetch-Site") == "same-origin" || r.Header.Get("Origin") == "") + // We have to return true here because `kubectl proxy` modifies the Host header + // so requests will fail same-origin tests and unfortunately not all browsers + // have implemented `sec-fetch-site` header. Instead, we will use CSRF token + // validation to ensure requests are coming from the same site. + return true }, ReadBufferSize: 1024, WriteBufferSize: 1024, diff --git a/backend/internal/ginapp/ginapp.go b/backend/internal/ginapp/ginapp.go index e3ecf86..a9bffa7 100644 --- a/backend/internal/ginapp/ginapp.go +++ b/backend/internal/ginapp/ginapp.go @@ -19,7 +19,6 @@ import ( "os" "path" - "github.com/99designs/gqlgen/graphql/playground" "github.com/gin-contrib/gzip" "github.com/gin-contrib/requestid" "github.com/gin-contrib/secure" @@ -115,9 +114,11 @@ func NewGinApp(config Config) (*GinApp, error) { c.Next() }) + var csrfProtect func(http.Handler) http.Handler + // csrf middleware if config.CSRF.Enabled { - dynamicRoutes.Use(adapter.Wrap(csrf.Protect( + csrfProtect = csrf.Protect( []byte(config.CSRF.Secret), csrf.FieldName(config.CSRF.FieldName), csrf.CookieName(config.CSRF.Cookie.Name), @@ -127,8 +128,12 @@ func NewGinApp(config Config) (*GinApp, error) { csrf.Secure(config.CSRF.Cookie.Secure), csrf.HttpOnly(config.CSRF.Cookie.HttpOnly), csrf.SameSite(config.CSRF.Cookie.SameSite), - ))) + ) + + // add to gin middleware + dynamicRoutes.Use(adapter.Wrap(csrfProtect)) + // token fetcher helper dynamicRoutes.GET("/csrf-token", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"value": csrf.Token(c.Request)}) }) @@ -156,17 +161,13 @@ func NewGinApp(config Config) (*GinApp, error) { // graphql handler h := &GraphQLHandlers{app} - endpointHandler := h.EndpointHandler(k8sCfg, config.Namespace) + endpointHandler := h.EndpointHandler(k8sCfg, config.Namespace, csrfProtect) graphql.GET("", endpointHandler) graphql.POST("", endpointHandler) } } app.dynamicroutes = dynamicRoutes // for unit tests - // graphiql - h := playground.Handler("GraphQL Playground", "/graphql") - app.GET("/graphiql", gin.WrapH(h)) - // healthz app.GET("/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{ @@ -183,6 +184,7 @@ func NewGinApp(config Config) (*GinApp, error) { app.StaticFile("/", path.Join(websiteDir, "/index.html")) app.StaticFile("/favicon.ico", path.Join(websiteDir, "/favicon.ico")) + app.StaticFile("/graphiql", path.Join(websiteDir, "/graphiql.html")) app.Static("/assets", path.Join(websiteDir, "/assets")) // use react app for unknown routes diff --git a/backend/internal/ginapp/graphql.go b/backend/internal/ginapp/graphql.go index 2bd0577..ab3d434 100644 --- a/backend/internal/ginapp/graphql.go +++ b/backend/internal/ginapp/graphql.go @@ -16,6 +16,9 @@ package ginapp import ( "context" + "errors" + "net/http" + "net/http/httptest" "github.com/99designs/gqlgen/graphql/handler/transport" "github.com/gin-gonic/gin" @@ -24,30 +27,60 @@ import ( "github.com/kubetail-org/kubetail/graph" ) +type key int + +const graphQLCookiesCtxKey key = iota + type GraphQLHandlers struct { *GinApp } // GET|POST "/graphql": GraphQL query endpoint -func (app *GraphQLHandlers) EndpointHandler(cfg *rest.Config, namespace string) gin.HandlerFunc { +func (app *GraphQLHandlers) EndpointHandler(cfg *rest.Config, namespace string, csrfProtect func(http.Handler) http.Handler) gin.HandlerFunc { // init resolver r, err := graph.NewResolver(cfg, namespace) if err != nil { panic(err) } + csrfTestServer := http.NewServeMux() + csrfTestServer.HandleFunc("/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + // init handler options opts := graph.NewDefaultHandlerOptions() + // Because we had to disable same-origin checks in the CheckOrigin() handler + // we will use use CSRF token validation to ensure requests are coming from + // the same site. (See https://dev.to/pssingh21/websockets-bypassing-sop-cors-5ajm) opts.WSInitFunc = func(ctx context.Context, initPayload transport.InitPayload) (context.Context, *transport.InitPayload, error) { - token := initPayload.Authorization() + // check if csrf protection is disabled + if csrfProtect == nil { + return ctx, &initPayload, nil + } + + csrfToken := initPayload.Authorization() - // add token to context - if token != "" { - ctx = context.WithValue(ctx, graph.K8STokenCtxKey, token) + cookies, ok := ctx.Value(graphQLCookiesCtxKey).([]*http.Cookie) + if !ok { + return ctx, nil, errors.New("AUTHORIZATION_REQUIRED") + } + + // make mock request + r, _ := http.NewRequest("POST", "/", nil) + for _, cookie := range cookies { + r.AddCookie(cookie) + } + r.Header.Set("X-CSRF-Token", csrfToken) + + // run request through csrf protect function + rr := httptest.NewRecorder() + p := csrfProtect(csrfTestServer) + p.ServeHTTP(rr, r) + + if rr.Code != 200 { + return ctx, nil, errors.New("AUTHORIZATION_REQUIRED") } - // return return ctx, &initPayload, nil } @@ -56,6 +89,11 @@ func (app *GraphQLHandlers) EndpointHandler(cfg *rest.Config, namespace string) // return gin handler func return func(c *gin.Context) { + // save cookies for use in WSInitFunc + ctx := context.WithValue(c.Request.Context(), graphQLCookiesCtxKey, c.Request.Cookies()) + c.Request = c.Request.WithContext(ctx) + + // execute h.ServeHTTP(c.Writer, c.Request) } } diff --git a/backend/internal/ginapp/graphql_test.go b/backend/internal/ginapp/graphql_test.go index ccff8a3..f661559 100644 --- a/backend/internal/ginapp/graphql_test.go +++ b/backend/internal/ginapp/graphql_test.go @@ -98,15 +98,52 @@ func (suite *GraphQLTestSuite) TestAccess() { suite.Equal(http.StatusNotFound, resp.StatusCode) }) - suite.Run("cross-origin subscriptions aren't allowed", func() { + suite.Run("cross-origin websocket requests are allowed when csrf protection is disabled", func() { // init websocket connection u := "ws" + strings.TrimPrefix(suite.defaultclient.testserver.URL, "http") + "/graphql" h := http.Header{} - h.Add("Origin", "not-the-host.com") - _, _, err := websocket.DefaultDialer.Dial(u, h) + conn, resp, err := websocket.DefaultDialer.Dial(u, h) - // check response - suite.NotNil(err) + // check that response was ok + suite.Nil(err) + suite.NotNil(conn) + suite.Equal(101, resp.StatusCode) + defer conn.Close() + + // write + conn.WriteJSON(map[string]string{"type": "connection_init"}) + + // read + _, msg, err := conn.ReadMessage() + suite.Nil(err) + suite.Contains(string(msg), "connection_ack") + }) + + suite.Run("websocket requests require csrf validation when csrf protection is enabled", func() { + // init client + cfg := NewTestConfig() + cfg.CSRF.Enabled = true + client := NewWebTestClient(suite.T(), NewTestApp(cfg)) + defer client.Teardown() + + // init websocket connection + u := "ws" + strings.TrimPrefix(client.testserver.URL, "http") + "/graphql" + h := http.Header{} + conn, resp, err := websocket.DefaultDialer.Dial(u, h) + + // check that response was ok + suite.Nil(err) + suite.NotNil(conn) + suite.Equal(101, resp.StatusCode) + defer conn.Close() + + // write + conn.WriteJSON(map[string]string{"type": "connection_init"}) + + // read + _, msg, err := conn.ReadMessage() + suite.Nil(err) + suite.Contains(string(msg), "connection_error") }) }) } diff --git a/frontend/package.json b/frontend/package.json index 6f35d12..3ecb4a3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,7 @@ "date-fns": "^3.3.1", "distinct-colors": "^3.0.0", "graphql": "^16.8.1", - "graphql-ws": "^5.14.3", + "graphql-ws": "^5.15.0", "kubetail-ui": "github:kubetail-org/kubetail-ui#v0.1.0", "lucide-react": "^0.303.0", "react": "^18.2.0", @@ -63,7 +63,7 @@ "rollup-plugin-visualizer": "^5.12.0", "tailwindcss": "^3.4.1", "typescript": "^5.3.3", - "vite": "^5.1.0", + "vite": "^5.1.2", "vite-plugin-svgr": "^4.2.0", "vite-tsconfig-paths": "^4.3.1", "vitest": "^1.2.2" diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 4e1ac4b..afcc345 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -7,7 +7,7 @@ settings: dependencies: '@apollo/client': specifier: ^3.9.4 - version: 3.9.4(@types/react@18.2.55)(graphql-ws@5.14.3)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) + version: 3.9.4(@types/react@18.2.55)(graphql-ws@5.15.0)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) '@headlessui/react': specifier: ^1.7.18 version: 1.7.18(react-dom@18.2.0)(react@18.2.0) @@ -30,8 +30,8 @@ dependencies: specifier: ^16.8.1 version: 16.8.1 graphql-ws: - specifier: ^5.14.3 - version: 5.14.3(graphql@16.8.1) + specifier: ^5.15.0 + version: 5.15.0(graphql@16.8.1) kubetail-ui: specifier: github:kubetail-org/kubetail-ui#v0.1.0 version: github.com/kubetail-org/kubetail-ui/8c7929ea3b754fa183a863591cdef500ef34cfd1(@heroicons/react@2.1.1)(@tailwindcss/forms@0.5.7)(@types/react-dom@18.2.19)(@types/react@18.2.55)(react-dom@18.2.0)(react@18.2.0) @@ -63,7 +63,7 @@ dependencies: devDependencies: '@apollo/react-testing': specifier: ^4.0.0 - version: 4.0.0(@types/react@18.2.55)(graphql-ws@5.14.3)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) + version: 4.0.0(@types/react@18.2.55)(graphql-ws@5.15.0)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) '@graphql-codegen/cli': specifier: ^5.0.2 version: 5.0.2(@types/node@20.11.17)(graphql@16.8.1)(typescript@5.3.3) @@ -105,7 +105,7 @@ devDependencies: version: 6.21.0(eslint@8.56.0)(typescript@5.3.3) '@vitejs/plugin-react': specifier: ^4.2.1 - version: 4.2.1(vite@5.1.0) + version: 4.2.1(vite@5.1.2) '@vitest/coverage-v8': specifier: 1.0.0-beta.5 version: 1.0.0-beta.5(vitest@1.2.2) @@ -152,14 +152,14 @@ devDependencies: specifier: ^5.3.3 version: 5.3.3 vite: - specifier: ^5.1.0 - version: 5.1.0(@types/node@20.11.17) + specifier: ^5.1.2 + version: 5.1.2(@types/node@20.11.17) vite-plugin-svgr: specifier: ^4.2.0 - version: 4.2.0(typescript@5.3.3)(vite@5.1.0) + version: 4.2.0(typescript@5.3.3)(vite@5.1.2) vite-tsconfig-paths: specifier: ^4.3.1 - version: 4.3.1(typescript@5.3.3)(vite@5.1.0) + version: 4.3.1(typescript@5.3.3)(vite@5.1.2) vitest: specifier: ^1.2.2 version: 1.2.2(@types/node@20.11.17)(jsdom@22.1.0) @@ -187,7 +187,7 @@ packages: '@jridgewell/trace-mapping': 0.3.22 dev: true - /@apollo/client@3.9.4(@types/react@18.2.55)(graphql-ws@5.14.3)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0): + /@apollo/client@3.9.4(@types/react@18.2.55)(graphql-ws@5.15.0)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-Ip6dxjshDT2Dp6foLASTnKBW45Fytew/5JZutZwgc78hVrrGpO9UtZA9xteHXYdap0wIgCxCfeIQwbSu1ZdQpw==} peerDependencies: graphql: ^15.0.0 || ^16.0.0 @@ -211,7 +211,7 @@ packages: '@wry/trie': 0.5.0 graphql: 16.8.1 graphql-tag: 2.12.6(graphql@16.8.1) - graphql-ws: 5.14.3(graphql@16.8.1) + graphql-ws: 5.15.0(graphql@16.8.1) hoist-non-react-statics: 3.3.2 optimism: 0.18.0 prop-types: 15.8.1 @@ -226,10 +226,10 @@ packages: transitivePeerDependencies: - '@types/react' - /@apollo/react-testing@4.0.0(@types/react@18.2.55)(graphql-ws@5.14.3)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0): + /@apollo/react-testing@4.0.0(@types/react@18.2.55)(graphql-ws@5.15.0)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-P7Z/flUHpRRZYc3FkIqxZH9XD3FuP2Sgks1IXqGq2Zb7qI0aaTfVeRsLYmZNUcFOh2pTHxs0NXgPnH1VfYOpig==} dependencies: - '@apollo/client': 3.9.4(@types/react@18.2.55)(graphql-ws@5.14.3)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) + '@apollo/client': 3.9.4(@types/react@18.2.55)(graphql-ws@5.15.0)(graphql@16.8.1)(react-dom@18.2.0)(react@18.2.0) transitivePeerDependencies: - '@types/react' - graphql @@ -1430,7 +1430,7 @@ packages: '@graphql-tools/utils': 10.0.13(graphql@16.8.1) '@types/ws': 8.5.10 graphql: 16.8.1 - graphql-ws: 5.14.3(graphql@16.8.1) + graphql-ws: 5.15.0(graphql@16.8.1) isomorphic-ws: 5.0.0(ws@8.16.0) tslib: 2.6.2 ws: 8.16.0 @@ -2985,7 +2985,7 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true - /@vitejs/plugin-react@4.2.1(vite@5.1.0): + /@vitejs/plugin-react@4.2.1(vite@5.1.2): resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: @@ -2996,7 +2996,7 @@ packages: '@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.9) '@types/babel__core': 7.20.5 react-refresh: 0.14.0 - vite: 5.1.0(@types/node@20.11.17) + vite: 5.1.2(@types/node@20.11.17) transitivePeerDependencies: - supports-color dev: true @@ -4969,8 +4969,8 @@ packages: graphql: 16.8.1 tslib: 2.6.2 - /graphql-ws@5.14.3(graphql@16.8.1): - resolution: {integrity: sha512-F/i2xNIVbaEF2xWggID0X/UZQa2V8kqKDPO8hwmu53bVOcTL7uNkxnexeEgSCVxYBQUTUNEI8+e4LO1FOhKPKQ==} + /graphql-ws@5.15.0(graphql@16.8.1): + resolution: {integrity: sha512-xWGAtm3fig9TIhSaNsg0FaDZ8Pyn/3re3RFlP4rhQcmjRDIPpk1EhRuNB+YSJtLzttyuToaDiNhwT1OMoGnJnw==} engines: {node: '>=10'} peerDependencies: graphql: '>=0.11 <=16' @@ -7553,7 +7553,7 @@ packages: debug: 4.3.4 pathe: 1.1.2 picocolors: 1.0.0 - vite: 5.1.0(@types/node@20.11.17) + vite: 5.1.2(@types/node@20.11.17) transitivePeerDependencies: - '@types/node' - less @@ -7565,7 +7565,7 @@ packages: - terser dev: true - /vite-plugin-svgr@4.2.0(typescript@5.3.3)(vite@5.1.0): + /vite-plugin-svgr@4.2.0(typescript@5.3.3)(vite@5.1.2): resolution: {integrity: sha512-SC7+FfVtNQk7So0XMjrrtLAbEC8qjFPifyD7+fs/E6aaNdVde6umlVVh0QuwDLdOMu7vp5RiGFsB70nj5yo0XA==} peerDependencies: vite: ^2.6.0 || 3 || 4 || 5 @@ -7573,14 +7573,14 @@ packages: '@rollup/pluginutils': 5.1.0 '@svgr/core': 8.1.0(typescript@5.3.3) '@svgr/plugin-jsx': 8.1.0(@svgr/core@8.1.0) - vite: 5.1.0(@types/node@20.11.17) + vite: 5.1.2(@types/node@20.11.17) transitivePeerDependencies: - rollup - supports-color - typescript dev: true - /vite-tsconfig-paths@4.3.1(typescript@5.3.3)(vite@5.1.0): + /vite-tsconfig-paths@4.3.1(typescript@5.3.3)(vite@5.1.2): resolution: {integrity: sha512-cfgJwcGOsIxXOLU/nELPny2/LUD/lcf1IbfyeKTv2bsupVbTH/xpFtdQlBmIP1GEK2CjjLxYhFfB+QODFAx5aw==} peerDependencies: vite: '*' @@ -7591,14 +7591,14 @@ packages: debug: 4.3.4 globrex: 0.1.2 tsconfck: 3.0.2(typescript@5.3.3) - vite: 5.1.0(@types/node@20.11.17) + vite: 5.1.2(@types/node@20.11.17) transitivePeerDependencies: - supports-color - typescript dev: true - /vite@5.1.0(@types/node@20.11.17): - resolution: {integrity: sha512-STmSFzhY4ljuhz14bg9LkMTk3d98IO6DIArnTY6MeBwiD1Za2StcQtz7fzOUnRCqrHSD5+OS2reg4HOz1eoLnw==} + /vite@5.1.2(@types/node@20.11.17): + resolution: {integrity: sha512-uwiFebQbTWRIGbCaTEBVAfKqgqKNKMJ2uPXsXeLIZxM8MVMjoS3j0cG8NrPxdDIadaWnPSjrkLWffLSC+uiP3Q==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -7678,7 +7678,7 @@ packages: strip-literal: 1.3.0 tinybench: 2.6.0 tinypool: 0.8.2 - vite: 5.1.0(@types/node@20.11.17) + vite: 5.1.2(@types/node@20.11.17) vite-node: 1.2.2(@types/node@20.11.17) why-is-node-running: 2.2.2 transitivePeerDependencies: diff --git a/frontend/public/graphiql.html b/frontend/public/graphiql.html new file mode 100644 index 0000000..881959d --- /dev/null +++ b/frontend/public/graphiql.html @@ -0,0 +1,65 @@ + + + + + GraphiQL + + + + + + +
Loading...
+ + + + \ No newline at end of file diff --git a/frontend/src/apollo-client.ts b/frontend/src/apollo-client.ts index af3f085..fb4e700 100644 --- a/frontend/src/apollo-client.ts +++ b/frontend/src/apollo-client.ts @@ -27,7 +27,7 @@ import { getMainDefinition } from '@apollo/client/utilities'; import { ClientOptions, createClient } from 'graphql-ws'; import generatedIntrospection from '@/lib/graphql/__generated__/introspection-result.json'; -import { getBasename, joinPaths } from './lib/helpers'; +import { getBasename, getCSRFToken, joinPaths } from './lib/helpers'; const graphqlEndpoint = (new URL(joinPaths(getBasename(), '/graphql'), window.location.origin)).toString(); @@ -40,6 +40,9 @@ const httpClientOptions: HttpOptions = { const wsClientOptions: ClientOptions = { url: graphqlEndpoint.replace(/^(http)/, 'ws'), connectionAckWaitTimeout: 3000, + connectionParams: async () => ({ + authorization: `${await getCSRFToken()}`, + }), keepAlive: 3000, retryAttempts: Infinity, shouldRetry: () => { diff --git a/frontend/src/lib/helpers.tsx b/frontend/src/lib/helpers.tsx index 8afe0a8..8f5a3bc 100644 --- a/frontend/src/lib/helpers.tsx +++ b/frontend/src/lib/helpers.tsx @@ -124,10 +124,15 @@ export class MapSet extends Map> { * Get CSRF token from server */ +let csrfToken: string; + export async function getCSRFToken() { - const url = new URL('/csrf-token', window.location.origin); - const resp = await fetch(url); - return (await resp.json()).value; + if (csrfToken === undefined) { + const url = new URL(joinPaths(getBasename(), '/csrf-token'), window.location.origin); + const resp = await fetch(url); + csrfToken = (await resp.json()).value; + } + return csrfToken; } /** diff --git a/frontend/src/pages/_root.tsx b/frontend/src/pages/_root.tsx index 2daa930..eeb4415 100644 --- a/frontend/src/pages/_root.tsx +++ b/frontend/src/pages/_root.tsx @@ -13,9 +13,12 @@ // limitations under the License. import { XCircleIcon } from '@heroicons/react/24/outline'; +import { useEffect } from 'react'; import { Toaster, resolveValue } from 'react-hot-toast'; import { Outlet } from 'react-router-dom'; +import { joinPaths, getBasename } from '@/lib/helpers'; + const CustomToaster = () => ( {(t) => ( @@ -39,6 +42,13 @@ const CustomToaster = () => ( ); export default function Root() { + // update favicon location + useEffect(() => { + const el = document.querySelector('link[rel="icon"]'); + if (!el) return; + el.setAttribute('href', joinPaths(getBasename(), '/favicon.ico')); + }, []); + return ( <>