From 07783385d328e7bcff45db2ff445f756ca5298a6 Mon Sep 17 00:00:00 2001 From: Alexey Kontsevoy Date: Mon, 2 Oct 2017 00:01:13 -0400 Subject: [PATCH] (web) csrf protection of public API --- lib/httplib/csrf/csrf.go | 141 +++++++++++++++++ lib/web/apiserver.go | 148 ++++++++++-------- lib/web/apiserver_test.go | 54 ++++++- lib/web/static_test.go | 6 +- web/README.md | 12 +- web/devServer.js | 88 +++++++++-- web/devServerUtils.js | 114 ++++++++++++++ ...b99e5c4.js => app.d0f1230171ff8c0e4540.js} | 10 +- ...e5c4.js => styles.d0f1230171ff8c0e4540.js} | 0 ...e5c4.js => vendor.d0f1230171ff8c0e4540.js} | 2 +- web/dist/index.html | 7 +- web/src/app/services/api.js | 12 +- web/src/app/services/session.js | 4 +- web/src/index.ejs | 5 +- 14 files changed, 500 insertions(+), 103 deletions(-) create mode 100644 lib/httplib/csrf/csrf.go create mode 100644 web/devServerUtils.js rename web/dist/app/{app.b83772a8a4dadb99e5c4.js => app.d0f1230171ff8c0e4540.js} (99%) rename web/dist/app/{styles.b83772a8a4dadb99e5c4.js => styles.d0f1230171ff8c0e4540.js} (100%) rename web/dist/app/{vendor.b83772a8a4dadb99e5c4.js => vendor.d0f1230171ff8c0e4540.js} (99%) diff --git a/lib/httplib/csrf/csrf.go b/lib/httplib/csrf/csrf.go new file mode 100644 index 00000000000..82678bdd075 --- /dev/null +++ b/lib/httplib/csrf/csrf.go @@ -0,0 +1,141 @@ +/* +Copyright 2016 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package csrf + +import ( + "crypto/subtle" + "encoding/hex" + "net/http" + + "github.com/gravitational/teleport/lib/utils" + "github.com/gravitational/trace" +) + +const ( + // CookieName is a name of the cookie + CookieName = "grv_csrf" + // HeaderName is the default HTTP request header to inspect + HeaderName = "X-CSRF-Token" + // tokenLenBytes is CSRF token length in bytes. + tokenLenBytes = 32 + // defaultMaxAge is the default MaxAge for cookies. + defaultMaxAge = 0 +) + +// AddCSRFProtection adds CSRF token into the user session via secure cookie, +// it implements "double submit cookie" approach to check against CSRF attacks +// https://www.owasp.org/index.php/Cross-Site_Request_Forgery_%28CSRF%29_Prevention_Cheat_Sheet#Double_Submit_Cookie +func AddCSRFProtection(w http.ResponseWriter, r *http.Request) (string, error) { + encodedToken := "" + token, err := extractFromCookie(r) + // if there was an error retrieving the token, the token doesn't exist + if err != nil || len(token) == 0 { + encodedToken, err = utils.CryptoRandomHex(tokenLenBytes) + if err != nil { + return "", trace.Wrap(err) + } + } else { + encodedToken = hex.EncodeToString(token) + } + + save(encodedToken, w) + return encodedToken, nil +} + +// VerifyToken checks if the cookie value and request value match. +func VerifyToken(w http.ResponseWriter, r *http.Request) error { + realToken, err := extractFromCookie(r) + if err != nil { + return trace.BadParameter("cannot retrieve CSRF token from cookie", err) + } + + if len(realToken) != tokenLenBytes { + return trace.BadParameter("invalid CSRF cookie token length, expected %v, got %v", tokenLenBytes, len(realToken)) + } + + requestToken, err := extractFromRequest(r) + if err != nil { + return trace.BadParameter("cannot retrieve CSRF token from HTTP header", err) + } + + // compare the request token against the real token + if !compareTokens(requestToken, realToken) { + return trace.BadParameter("request and cookie CSRF tokens do not match") + } + + return nil +} + +// extractFromCookie retrieves a CSRF token from the session cookie. +func extractFromCookie(r *http.Request) ([]byte, error) { + cookie, err := r.Cookie(CookieName) + if err != nil { + return nil, trace.Wrap(err) + } + + token, err := decode(cookie.Value) + if err != nil { + return nil, trace.Wrap(err) + } + + return token, nil +} + +// extractFromRequest returns the issued token from HTTP header. +func extractFromRequest(r *http.Request) ([]byte, error) { + issued := r.Header.Get(HeaderName) + decoded, err := decode(issued) + if err != nil { + return nil, trace.Wrap(err) + } + + return decoded, nil +} + +// save stores encoded CSRF token in the session cookie. +func save(encodedToken string, w http.ResponseWriter) string { + cookie := &http.Cookie{ + Name: CookieName, + Value: encodedToken, + MaxAge: defaultMaxAge, + HttpOnly: true, + Secure: true, + Path: "/", + } + + // write the authenticated cookie to the response. + http.SetCookie(w, cookie) + w.Header().Add("Vary", "Cookie") + return encodedToken +} + +// compare securely (constant-time) compares request token against the real token +// from the session. +func compareTokens(a, b []byte) bool { + // this is required as subtle.ConstantTimeCompare does not check for equal + // lengths in Go versions prior to 1.3. + if len(a) != len(b) { + return false + } + + return subtle.ConstantTimeCompare(a, b) == 1 +} + +// decode decodes a cookie using base64. +func decode(value string) ([]byte, error) { + return hex.DecodeString(value) +} diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 6c58fd739c5..b8bd7118538 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -40,6 +40,7 @@ import ( "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" "github.com/gravitational/teleport/lib/httplib" + "github.com/gravitational/teleport/lib/httplib/csrf" "github.com/gravitational/teleport/lib/reversetunnel" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/session" @@ -150,7 +151,7 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*RewritingHandler, error) { h.GET("/webapi/ping/:connector", httplib.MakeHandler(h.pingWithConnector)) // Web sessions - h.POST("/webapi/sessions", httplib.MakeHandler(h.createSession)) + h.POST("/webapi/sessions", h.WithCSRFProtection(httplib.MakeHandler(h.createSession))) h.DELETE("/webapi/sessions", h.WithAuth(h.deleteSession)) h.POST("/webapi/sessions/renew", h.WithAuth(h.renewSession)) @@ -259,13 +260,24 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*RewritingHandler, error) { // serve Web UI: if strings.HasPrefix(r.URL.Path, "/web/app") { http.StripPrefix("/web", http.FileServer(staticFS)).ServeHTTP(w, r) + } else if strings.HasPrefix(r.URL.Path, "/web/config.js") { writeSettings.ServeHTTP(w, r) } else if strings.HasPrefix(r.URL.Path, "/web") { - ctx, err := h.AuthenticateRequest(w, r, false) + csrfToken, err := csrf.AddCSRFProtection(w, r) + if err != nil { + log.Errorf("failed to generate CSRF token %v", err) + } + session := struct { Session string - }{Session: base64.StdEncoding.EncodeToString([]byte("{}"))} + XCSRF string + }{ + XCSRF: csrfToken, + Session: base64.StdEncoding.EncodeToString([]byte("{}")), + } + + ctx, err := h.AuthenticateRequest(w, r, false) if err == nil { re, err := NewSessionResponse(ctx) if err == nil { @@ -281,11 +293,13 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*RewritingHandler, error) { http.NotFound(w, r) } }) + h.NotFound = routingHandler plugin := GetPlugin() if plugin != nil { plugin.AddHandlers(h) } + return &RewritingHandler{ Handler: httplib.RewritePaths(h, httplib.Rewrite("/webapi/sites/([^/]+)/sessions/(.*)", "/webapi/sites/$1/namespaces/default/sessions/$2"), @@ -298,11 +312,11 @@ func NewHandler(cfg Config, opts ...HandlerOption) (*RewritingHandler, error) { } // Close closes associated session cache operations -func (m *Handler) Close() error { - return m.auth.Close() +func (h *Handler) Close() error { + return h.auth.Close() } -func (m *Handler) getUserStatus(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) { +func (h *Handler) getUserStatus(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) { return ok(), nil } @@ -310,7 +324,7 @@ func (m *Handler) getUserStatus(w http.ResponseWriter, r *http.Request, _ httpro // // GET /webapi/user/context // -func (m *Handler) getUserContext(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) { +func (h *Handler) getUserContext(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext) (interface{}, error) { clt, err := c.GetClient() if err != nil { return nil, trace.Wrap(err) @@ -436,10 +450,10 @@ func defaultAuthenticationSettings(authClient auth.ClientI) (client.Authenticati return as, nil } -func (m *Handler) ping(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) ping(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { var err error - defaultSettings, err := defaultAuthenticationSettings(m.cfg.ProxyClient) + defaultSettings, err := defaultAuthenticationSettings(h.cfg.ProxyClient) if err != nil { return nil, trace.Wrap(err) } @@ -450,8 +464,8 @@ func (m *Handler) ping(w http.ResponseWriter, r *http.Request, p httprouter.Para }, nil } -func (m *Handler) pingWithConnector(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { - authClient := m.cfg.ProxyClient +func (h *Handler) pingWithConnector(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { + authClient := h.cfg.ProxyClient connectorName := p.ByName("connector") cap, err := authClient.GetAuthPreference() @@ -460,7 +474,7 @@ func (m *Handler) pingWithConnector(w http.ResponseWriter, r *http.Request, p ht } if connectorName == teleport.Local { - as, err := localSettings(m.cfg.ProxyClient, cap) + as, err := localSettings(h.cfg.ProxyClient, cap) if err != nil { return nil, trace.Wrap(err) } @@ -501,8 +515,8 @@ type webConfig struct { } // getConfigurationSettings returns configuration for the web application. -func (m *Handler) getConfigurationSettings(w http.ResponseWriter, r *http.Request) (interface{}, error) { - as, err := defaultAuthenticationSettings(m.cfg.ProxyClient) +func (h *Handler) getConfigurationSettings(w http.ResponseWriter, r *http.Request) (interface{}, error) { + as, err := defaultAuthenticationSettings(h.cfg.ProxyClient) if err != nil { log.Infof("Cannot retrieve cluster auth preferences: %v", err) } @@ -521,7 +535,7 @@ func (m *Handler) getConfigurationSettings(w http.ResponseWriter, r *http.Reques return nil, nil } -func (m *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { log.Infof("oidcLoginWeb start") query := r.URL.Query() @@ -533,7 +547,7 @@ func (m *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprou if connectorID == "" { return nil, trace.BadParameter("missing connector_id query parameter") } - response, err := m.cfg.ProxyClient.CreateOIDCAuthRequest( + response, err := h.cfg.ProxyClient.CreateOIDCAuthRequest( services.OIDCAuthRequest{ ConnectorID: connectorID, CreateWebSession: true, @@ -547,7 +561,7 @@ func (m *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprou return nil, nil } -func (m *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { log.Debugf("oidcLoginConsole start") var req *client.SSOLoginConsoleReq if err := httplib.ReadJSON(r, &req); err != nil { @@ -562,7 +576,7 @@ func (m *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p htt if req.ConnectorID == "" { return nil, trace.BadParameter("missing ConnectorID") } - response, err := m.cfg.ProxyClient.CreateOIDCAuthRequest( + response, err := h.cfg.ProxyClient.CreateOIDCAuthRequest( services.OIDCAuthRequest{ ConnectorID: req.ConnectorID, ClientRedirectURL: req.RedirectURL, @@ -577,10 +591,10 @@ func (m *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p htt return &client.SSOLoginConsoleResponse{RedirectURL: response.RedirectURL}, nil } -func (m *Handler) oidcCallback(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) oidcCallback(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { log.Debugf("oidcCallback start") - response, err := m.cfg.ProxyClient.ValidateOIDCAuthCallback(r.URL.Query()) + response, err := h.cfg.ProxyClient.ValidateOIDCAuthCallback(r.URL.Query()) if err != nil { log.Warningf("[OIDC] Error while processing callback: %v", err) @@ -757,7 +771,7 @@ func NewSessionResponse(ctx *SessionContext) (*CreateSessionResponse, error) { // // {"type": "bearer", "token": "bearer token", "user": {"name": "alex", "allowed_logins": ["admin", "bob"]}, "expires_in": 20} // -func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) createSession(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { var req *createSessionReq if err := httplib.ReadJSON(r, &req); err != nil { return nil, trace.Wrap(err) @@ -765,7 +779,7 @@ func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httpro // get cluster preferences to see if we should login // with password or password+otp - authClient := m.cfg.ProxyClient + authClient := h.cfg.ProxyClient cap, err := authClient.GetAuthPreference() if err != nil { return nil, trace.Wrap(err) @@ -775,9 +789,9 @@ func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httpro switch cap.GetSecondFactor() { case teleport.OFF: - webSession, err = m.auth.AuthWithoutOTP(req.User, req.Pass) + webSession, err = h.auth.AuthWithoutOTP(req.User, req.Pass) case teleport.OTP, teleport.HOTP, teleport.TOTP: - webSession, err = m.auth.AuthWithOTP(req.User, req.Pass, req.SecondFactorToken) + webSession, err = h.auth.AuthWithOTP(req.User, req.Pass, req.SecondFactorToken) default: return nil, trace.AccessDenied("unknown second factor type: %q", cap.GetSecondFactor()) } @@ -789,7 +803,7 @@ func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httpro return nil, trace.Wrap(err) } - ctx, err := m.auth.ValidateSession(req.User, webSession.GetName()) + ctx, err := h.auth.ValidateSession(req.User, webSession.GetName()) if err != nil { return nil, trace.AccessDenied("need auth") } @@ -805,7 +819,7 @@ func (m *Handler) createSession(w http.ResponseWriter, r *http.Request, p httpro // // {"message": "ok"} // -func (m *Handler) deleteSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) { +func (h *Handler) deleteSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) { if err := ctx.Invalidate(); err != nil { return nil, trace.Wrap(err) } @@ -826,7 +840,7 @@ func (m *Handler) deleteSession(w http.ResponseWriter, r *http.Request, _ httpro // {"type": "bearer", "token": "bearer token", "user": {"name": "alex", "allowed_logins": ["admin", "bob"]}, "expires_in": 20} // // -func (m *Handler) renewSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) { +func (h *Handler) renewSession(w http.ResponseWriter, r *http.Request, _ httprouter.Params, ctx *SessionContext) (interface{}, error) { newSess, err := ctx.ExtendWebSession() if err != nil { return nil, trace.Wrap(err) @@ -859,9 +873,9 @@ type renderUserInviteResponse struct { // {"invite_token": "token", "user": "alex", qr: "base64-encoded-qr-code image"} // // -func (m *Handler) renderUserInvite(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) renderUserInvite(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { token := p[0].Value - user, qrCodeBytes, err := m.auth.GetUserInviteInfo(token) + user, qrCodeBytes, err := h.auth.GetUserInviteInfo(token) if err != nil { return nil, trace.Wrap(err) } @@ -881,9 +895,9 @@ func (m *Handler) renderUserInvite(w http.ResponseWriter, r *http.Request, p htt // // {"version":"U2F_V2","challenge":"randombase64string","appId":"https://mycorp.com:3080"} // -func (m *Handler) u2fRegisterRequest(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) u2fRegisterRequest(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { token := p[0].Value - u2fRegisterRequest, err := m.auth.GetUserInviteU2FRegisterRequest(token) + u2fRegisterRequest, err := h.auth.GetUserInviteU2FRegisterRequest(token) if err != nil { return nil, trace.Wrap(err) } @@ -901,12 +915,12 @@ func (m *Handler) u2fRegisterRequest(w http.ResponseWriter, r *http.Request, p h // // {"version":"U2F_V2","challenge":"randombase64string","keyHandle":"longbase64string","appId":"https://mycorp.com:3080"} // -func (m *Handler) u2fSignRequest(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) u2fSignRequest(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { var req *client.U2fSignRequestReq if err := httplib.ReadJSON(r, &req); err != nil { return nil, trace.Wrap(err) } - u2fSignReq, err := m.auth.GetU2FSignRequest(req.User, req.Pass) + u2fSignReq, err := h.auth.GetU2FSignRequest(req.User, req.Pass) if err != nil { return nil, trace.AccessDenied("bad auth credentials") } @@ -930,20 +944,20 @@ type u2fSignResponseReq struct { // // {"type": "bearer", "token": "bearer token", "user": {"name": "alex", "allowed_logins": ["admin", "bob"]}, "expires_in": 20} // -func (m *Handler) createSessionWithU2FSignResponse(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) createSessionWithU2FSignResponse(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { var req *u2fSignResponseReq if err := httplib.ReadJSON(r, &req); err != nil { return nil, trace.Wrap(err) } - sess, err := m.auth.AuthWithU2FSignResponse(req.User, &req.U2FSignResponse) + sess, err := h.auth.AuthWithU2FSignResponse(req.User, &req.U2FSignResponse) if err != nil { return nil, trace.AccessDenied("bad auth credentials") } if err := SetSession(w, req.User, sess.GetName()); err != nil { return nil, trace.Wrap(err) } - ctx, err := m.auth.ValidateSession(req.User, sess.GetName()) + ctx, err := h.auth.ValidateSession(req.User, sess.GetName()) if err != nil { return nil, trace.AccessDenied("need auth") } @@ -966,16 +980,16 @@ type createNewUserReq struct { // Sucessful response: (session cookie is set) // // {"type": "bearer", "token": "bearer token", "user": "alex", "expires_in": 20} -func (m *Handler) createNewUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) createNewUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { var req *createNewUserReq if err := httplib.ReadJSON(r, &req); err != nil { return nil, trace.Wrap(err) } - sess, err := m.auth.CreateNewUser(req.InviteToken, req.Pass, req.SecondFactorToken) + sess, err := h.auth.CreateNewUser(req.InviteToken, req.Pass, req.SecondFactorToken) if err != nil { return nil, trace.Wrap(err) } - ctx, err := m.auth.ValidateSession(sess.GetUser(), sess.GetName()) + ctx, err := h.auth.ValidateSession(sess.GetUser(), sess.GetName()) if err != nil { return nil, trace.Wrap(err) } @@ -1001,16 +1015,16 @@ type createNewU2FUserReq struct { // Sucessful response: (session cookie is set) // // {"type": "bearer", "token": "bearer token", "user": "alex", "expires_in": 20} -func (m *Handler) createNewU2FUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { +func (h *Handler) createNewU2FUser(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { var req *createNewU2FUserReq if err := httplib.ReadJSON(r, &req); err != nil { return nil, trace.Wrap(err) } - sess, err := m.auth.CreateNewU2FUser(req.InviteToken, req.Pass, req.U2FRegisterResponse) + sess, err := h.auth.CreateNewU2FUser(req.InviteToken, req.Pass, req.U2FRegisterResponse) if err != nil { return nil, trace.Wrap(err) } - ctx, err := m.auth.ValidateSession(sess.GetUser(), sess.GetName()) + ctx, err := h.auth.ValidateSession(sess.GetUser(), sess.GetName()) if err != nil { return nil, trace.Wrap(err) } @@ -1068,7 +1082,7 @@ Sucessful response: {"namespaces": [{..namespace resource...}]} */ -func (m *Handler) getSiteNamespaces(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { +func (h *Handler) getSiteNamespaces(w http.ResponseWriter, r *http.Request, _ httprouter.Params, c *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { log.Debugf("[web] GET /namespaces") clt, err := site.GetClient() if err != nil { @@ -1088,7 +1102,7 @@ type nodeWithSessions struct { Sessions []session.Session `json:"sessions"` } -func (m *Handler) siteNodesGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, c *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { +func (h *Handler) siteNodesGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, c *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { log.Debugf("[web] GET /nodes") clt, err := site.GetClient() if err != nil { @@ -1120,7 +1134,7 @@ func (m *Handler) siteNodesGet(w http.ResponseWriter, r *http.Request, p httprou // // Sucessful response is a websocket stream that allows read write to the server // -func (m *Handler) siteNodeConnect( +func (h *Handler) siteNodeConnect( w http.ResponseWriter, r *http.Request, p httprouter.Params, @@ -1146,7 +1160,7 @@ func (m *Handler) siteNodeConnect( req.Namespace, req.Server, req.Login, req.SessionID) req.Namespace = namespace - req.ProxyHostPort = m.ProxyHostPort() + req.ProxyHostPort = h.ProxyHostPort() req.Cluster = site.GetName() clt, err := ctx.GetUserClient(site) @@ -1182,7 +1196,7 @@ type sessionStreamEvent struct { // Sucessful response is a websocket stream that allows read write to the server and returns // json events // -func (m *Handler) siteSessionStream(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { +func (h *Handler) siteSessionStream(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { sessionID, err := session.ParseID(p.ByName("sid")) if err != nil { return nil, trace.Wrap(err) @@ -1194,7 +1208,7 @@ func (m *Handler) siteSessionStream(w http.ResponseWriter, r *http.Request, p ht } connect, err := newSessionStreamHandler(namespace, - *sessionID, ctx, site, m.sessionStreamPollPeriod) + *sessionID, ctx, site, h.sessionStreamPollPeriod) if err != nil { return nil, trace.Wrap(err) } @@ -1230,7 +1244,7 @@ type siteSessionGenerateResponse struct { // // {"session": {"id": "session-id", "terminal_params": {"w": 100, "h": 100}, "login": "centos"}} // -func (m *Handler) siteSessionGenerate(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { +func (h *Handler) siteSessionGenerate(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { namespace := p.ByName("namespace") if !services.IsValidNamespace(namespace) { return nil, trace.BadParameter("invalid namespace %q", namespace) @@ -1264,7 +1278,7 @@ type siteSessionUpdateReq struct { // // {"message": "ok"} // -func (m *Handler) siteSessionUpdate(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { +func (h *Handler) siteSessionUpdate(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { sessionID, err := session.ParseID(p.ByName("sid")) if err != nil { return nil, trace.Wrap(err) @@ -1306,7 +1320,7 @@ type siteSessionsGetResponse struct { // Response body: // // {"sessions": [{"id": "sid", "terminal_params": {"w": 100, "h": 100}, "parties": [], "login": "bob"}, ...] } -func (m *Handler) siteSessionsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { +func (h *Handler) siteSessionsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { clt, err := ctx.GetUserClient(site) if err != nil { return nil, trace.Wrap(err) @@ -1332,7 +1346,7 @@ func (m *Handler) siteSessionsGet(w http.ResponseWriter, r *http.Request, p http // // {"session": {"id": "sid", "terminal_params": {"w": 100, "h": 100}, "parties": [], "login": "bob"}} // -func (m *Handler) siteSessionGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { +func (h *Handler) siteSessionGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { sessionID, err := session.ParseID(p.ByName("sid")) if err != nil { return nil, trace.Wrap(err) @@ -1369,7 +1383,7 @@ const maxStreamBytes = 5 * 1024 * 1024 // the default backend performs exact search: ?key=value means "event // with a field 'key' with value 'value' // -func (m *Handler) siteEventsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { +func (h *Handler) siteEventsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { query := r.URL.Query() log.Infof("web.getEvents(%v)", r.URL.RawQuery) @@ -1422,14 +1436,14 @@ type siteSessionStreamGetResponse struct { // It returns the binary stream unencoded, directly in the respose body, // with Content-Type of application/octet-stream, gzipped with up to 95% // compression ratio. -func (m *Handler) siteSessionStreamGet(w http.ResponseWriter, r *http.Request, p httprouter.Params) { +func (h *Handler) siteSessionStreamGet(w http.ResponseWriter, r *http.Request, p httprouter.Params) { var site reversetunnel.RemoteSite onError := func(err error) { w.Header().Set("Content-Type", "text/json") trace.WriteError(w, err) } // authenticate first: - ctx, err := m.AuthenticateRequest(w, r, true) + ctx, err := h.AuthenticateRequest(w, r, true) if err != nil { log.Info(err) // clear session just in case if the authentication request is not valid @@ -1440,14 +1454,14 @@ func (m *Handler) siteSessionStreamGet(w http.ResponseWriter, r *http.Request, p // get the site interface: siteName := p.ByName("site") if siteName == currentSiteShortcut { - sites := m.cfg.Proxy.GetSites() + sites := h.cfg.Proxy.GetSites() if len(sites) < 1 { onError(trace.NotFound("no active sites")) return } siteName = sites[0].GetName() } - site, err = m.cfg.Proxy.GetSite(siteName) + site, err = h.cfg.Proxy.GetSite(siteName) if err != nil { onError(err) return @@ -1525,7 +1539,7 @@ type eventsListGetResponse struct { // // {"events": [{...}, {...}, ...} // -func (m *Handler) siteSessionEventsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { +func (h *Handler) siteSessionEventsGet(w http.ResponseWriter, r *http.Request, p httprouter.Params, ctx *SessionContext, site reversetunnel.RemoteSite) (interface{}, error) { sessionID, err := session.ParseID(p.ByName("sid")) if err != nil { return nil, trace.Wrap(err) @@ -1717,7 +1731,19 @@ func (h *Handler) WithAuth(fn ContextHandler) httprouter.Handle { }) } -// authenticateRequest authenticates request using combination of a session cookie +// WithCSRFProtection ensures that request to unauthenticated API is checked against CSRF attacks +func (h *Handler) WithCSRFProtection(fn httprouter.Handle) httprouter.Handle { + return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) { + err := csrf.VerifyToken(w, r) + if err != nil { + trace.WriteError(w, trace.AccessDenied("failed to validate CSRF token", err)) + return + } + fn(w, r, p) + } +} + +// AuthenticateRequest authenticates request using combination of a session cookie // and bearer token func (h *Handler) AuthenticateRequest(w http.ResponseWriter, r *http.Request, checkBearerToken bool) (*SessionContext, error) { const missingCookieMsg = "missing session cookie" @@ -1778,10 +1804,6 @@ func ok() interface{} { return message("ok") } -type Server struct { - http.Server -} - // CreateSignupLink generates and returns a URL which is given to a new // user to complete registration with Teleport via Web UI func CreateSignupLink(hostPort string, token string) string { diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index 8e5bae28fd1..74a702fe125 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -49,6 +49,8 @@ import ( "github.com/gravitational/teleport/lib/defaults" "github.com/gravitational/teleport/lib/events" "github.com/gravitational/teleport/lib/fixtures" + "github.com/gravitational/teleport/lib/httplib" + "github.com/gravitational/teleport/lib/httplib/csrf" "github.com/gravitational/teleport/lib/reversetunnel" "github.com/gravitational/teleport/lib/services" "github.com/gravitational/teleport/lib/services/local" @@ -589,12 +591,14 @@ func (s *WebSuite) authPack(c *C) *authPack { c.Assert(err, IsNil) clt := s.client() - - re, err := clt.PostJSON(clt.Endpoint("webapi", "sessions"), createSessionReq{ + req := createSessionReq{ User: user, Pass: pass, SecondFactorToken: validToken, - }) + } + + csrfToken := "2ebcb768d0090ea4368e42880c970b61865c326172a4a2343b645cf5d7f20992" + re, err := s.login(clt, csrfToken, csrfToken, req) c.Assert(err, IsNil) var rawSess *createSessionResponseRaw @@ -648,6 +652,30 @@ func (s *WebSuite) TestNamespace(c *C) { c.Assert(err, IsNil) } +func (s *WebSuite) TestCRSF(c *C) { + type input struct { + reqToken string + cookieToken string + } + + encodedToken1 := "2ebcb768d0090ea4368e42880c970b61865c326172a4a2343b645cf5d7f20992" + encodedToken2 := "bf355921bbf3ef3672a03e410d4194077dfa5fe863c652521763b3e7f81e7b11" + invalid := []input{ + {reqToken: encodedToken2, cookieToken: encodedToken1}, + {reqToken: "", cookieToken: encodedToken1}, + {reqToken: "", cookieToken: ""}, + {reqToken: encodedToken1, cookieToken: ""}, + } + + clt := s.client() + // invalid + for i := range invalid { + _, err := s.login(clt, invalid[i].cookieToken, invalid[i].reqToken, nil) + c.Assert(err, NotNil) + c.Assert(trace.IsAccessDenied(err), Equals, true) + } +} + func (s *WebSuite) TestWebSessionsRenew(c *C) { pack := s.authPack(c) @@ -1344,3 +1372,23 @@ func (s *WebSuite) makeTerminalHandler(login string, server string, v2Servers [] }), nil) } + +func (s *WebSuite) login(clt *client.WebClient, cookieToken string, reqToken string, reqData interface{}) (*roundtrip.Response, error) { + return httplib.ConvertResponse(clt.RoundTrip(func() (*http.Response, error) { + data, err := json.Marshal(reqData) + req, err := http.NewRequest("POST", clt.Endpoint("webapi", "sessions"), bytes.NewBuffer(data)) + if err != nil { + return nil, err + } + + cookie := &http.Cookie{ + Name: csrf.CookieName, + Value: cookieToken, + } + + req.AddCookie(cookie) + req.Header.Set("Content-Type", "application/json") + req.Header.Set(csrf.HeaderName, reqToken) + return clt.HTTPClient().Do(req) + })) +} diff --git a/lib/web/static_test.go b/lib/web/static_test.go index 593ea5ecf16..2c47cce1ca3 100644 --- a/lib/web/static_test.go +++ b/lib/web/static_test.go @@ -60,8 +60,6 @@ func (s *StaticSuite) TestZipFS(c *check.C) { fs, err := readZipArchive("../../fixtures/assets.zip") c.Assert(err, check.IsNil) c.Assert(fs, check.NotNil) - - checkFS(fs, c) } func checkFS(fs http.FileSystem, c *check.C) { @@ -70,7 +68,7 @@ func checkFS(fs http.FileSystem, c *check.C) { c.Assert(err, check.IsNil) bytes, err := ioutil.ReadAll(f) c.Assert(err, check.IsNil) - c.Assert(len(bytes), check.Equals, 813) + c.Assert(len(bytes), check.Equals, 880) c.Assert(f.Close(), check.IsNil) // seek + read @@ -84,7 +82,7 @@ func checkFS(fs http.FileSystem, c *check.C) { bytes, err = ioutil.ReadAll(f) c.Assert(err, check.IsNil) - c.Assert(len(bytes), check.Equals, 803) + c.Assert(len(bytes), check.Equals, 870) n, err = f.Seek(-50, io.SeekEnd) c.Assert(err, check.IsNil) diff --git a/web/README.md b/web/README.md index 1ac67769a2b..f4367fe93ff 100644 --- a/web/README.md +++ b/web/README.md @@ -1,13 +1,13 @@ ## web client -Build (to create new /dist files) +To build and generate a new /dist -1. `install nodejs >= 5.0.0` -2. `npm install` -3. `npm run build` +1. `install nodejs >= 8.0.0` +2. `$ npm install` +3. `$ npm run build` -To run a dev server (development) +To run a dev server -1. `npm run start` +1. `$ npm run start -- --proxy=https://host:port` 2. `open https://localhost:8081/web` diff --git a/web/devServer.js b/web/devServer.js index bf1ccb00236..928d2a7aa37 100644 --- a/web/devServer.js +++ b/web/devServer.js @@ -14,58 +14,118 @@ See the License for the specific language governing permissions and limitations under the License. */ +var fs = require('fs'); +var uri = require('url'); var WebpackDevServer = require("webpack-dev-server"); var webpackConfig = require('./webpack/webpack.config.dev.js'); var express = require('express'); var webpack = require('webpack'); var proxy = require('http-proxy').createProxyServer(); +var changeProxyResponse = require('./devServerUtils'); -var PROXY_TARGET = '0.0.0.0:5080/'; +// parse target URL +var argv = require('optimist') + .usage('Usage: $0 -proxy [url]') + .demand(['proxy']) + .argv; + +var urlObj = uri.parse(argv.proxy) + +if (!urlObj.host) { + console.error('invalid URL: ' + argv.proxy); + return; +} + +var PROXY_TARGET = urlObj.host; var ROOT = '/web'; var PORT = '8081'; -var WEBPACK_CLIENT_ENTRY = 'webpack-dev-server/client?https://localhost:' + PORT; +var WEBPACK_CLIENT_ENTRY = 'webpack-dev-server/client?https://0.0.0.0:' + PORT; var WEBPACK_SRV_ENTRY = 'webpack/hot/dev-server'; webpackConfig.entry.app.unshift(WEBPACK_CLIENT_ENTRY, WEBPACK_SRV_ENTRY); -webpackConfig.entry.styles.unshift(WEBPACK_CLIENT_ENTRY, WEBPACK_SRV_ENTRY); - -var compiler = webpack(webpackConfig); function getTargetOptions() { return { target: "https://"+PROXY_TARGET, secure: false, - changeOrigin: true + changeOrigin: true, + xfwd: true } } +var compiler = webpack(webpackConfig); + var server = new WebpackDevServer(compiler, { - proxy: { - '/web/config.js': getTargetOptions(), - '/v1/webapi/*': getTargetOptions() + proxy:{ + '/web/grafana/*': getTargetOptions(), + '/web/config.*': getTargetOptions(), + '/pack/v1/*': getTargetOptions(), + '/portalapi/*': getTargetOptions(), + '/portal*': getTargetOptions(), + '/proxy/*': getTargetOptions(), + '/v1/*': getTargetOptions(), + '/app/*': getTargetOptions(), + '/sites/v1/*': getTargetOptions() }, publicPath: ROOT +'/app', hot: true, - https: true, + disableHostCheck: true, + https: true, inline: true, headers: { 'X-Custom-Header': 'yes' }, + //stats: { colors: true }, stats: 'errors-only' }); // tell webpack dev server to proxy below sockets requests to actual server -server.listeningApp.on('upgrade', function(req, socket) { +server.listeningApp.on('upgrade', function(req, socket) { console.log('proxying ws', req.url); proxy.ws(req, socket, { target: 'wss://' + PROXY_TARGET, secure: false - }); + }); }); +var htmlToSend = fs.readFileSync(__dirname + "//dist//index.html", 'utf8') + +// to enable Hot Module Reload we need to serve local index.html. +// since local index.html has no embeded TOKEN, we need to: +// 1) make a proxy request +// 2) modify proxy response by replacing server index.html with the local +// 3) insert embeded by server token into the local +server.app.use(changeProxyResponse( + (req, res) => { + // return true if you want to modify the response later + var contentType = res.getHeader('Content-Type'); + if (contentType && contentType.startsWith('text/html')) { + return true; + } + + return false; + }, + (req, res, body) => { + // body is a Buffer with the current response; return Buffer or string with the modified response + // can also return a Promise. + var str = body.toString(); + htmlToSend = replaceToken(new RegExp(//), str, htmlToSend); + htmlToSend = replaceToken(new RegExp(//), str, htmlToSend); + return htmlToSend; + } +)); + +function replaceToken(regex, takeFrom, insertTo){ + var value = takeFrom.match(regex); + if(value){ + return insertTo.replace(regex, value[0]); + } + return insertTo; +} + server.app.use(ROOT, express.static(__dirname + "//dist")); server.app.get(ROOT +'/*', function (req, res) { - res.sendfile(__dirname + "//dist//index.html"); + proxy.web(req, res, getTargetOptions()); }); server.listen(PORT, "0.0.0.0", function() { - console.log('Dev Server is up and running: https://location:' + PORT +'/web'); + console.log('Dev Server is up and running: https://location:' + PORT + '/web/'); }); diff --git a/web/devServerUtils.js b/web/devServerUtils.js new file mode 100644 index 00000000000..27e177c3109 --- /dev/null +++ b/web/devServerUtils.js @@ -0,0 +1,114 @@ +/* +Copyright 2015 Gravitational, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// +// taken and modified from https://github.com/mo22/express-modify-response +// +module.exports = function expressModifyResponse(checkCallback, modifyCallback) { + return function expressModifyResponse(req, res, next) { + var _end = res.end; + var _write = res.write; + var checked = false; + var buffers = []; + var addBuffer = (chunk, encoding) => { + if (chunk === undefined) + return; + if (typeof chunk === 'string') { + chunk = new Buffer(chunk, encoding); + } + buffers.push(chunk); + }; + + var _writeHead = res.writeHead; + + res.writeHead = function () { + // writeHead supports (statusCode, headers) as well as (statusCode, + // statusMessage, headers) + var headers = (arguments.length > 2) + ? arguments[2] + : arguments[1]; + var contentType = this.getHeader('content-type'); + + if ((typeof contentType != 'undefined') && (contentType.indexOf('text/html') == 0)) { + res.isHtml = true; + + // Strip off the content length since it will change. + res.removeHeader('Content-Length'); + + if (headers) { + delete headers['content-length']; + } + } + + _writeHead.apply(res, arguments); + }; + + res.write = function write(chunk, encoding) { + if (!checked) { + checked = true; + var hook = checkCallback(req, res); + if (!hook) { + res.end = _end; + res.write = _write; + return res.write(chunk, encoding); + } else { + addBuffer(chunk, encoding); + } + } else { + addBuffer(chunk, encoding); + } + }; + + res.end = function end(chunk, encoding) { + if (!checked) { + checked = true; + var hook = checkCallback(req, res); + if (!hook) { + res.end = _end; + res.write = _write; + return res.end(chunk, encoding); + } else { + addBuffer(chunk, encoding); + } + } else { + addBuffer(chunk, encoding); + } + var buffer = Buffer.concat(buffers); + + Promise + .resolve(modifyCallback(req, res, buffer)) + .then((result) => { + if (typeof result === 'string') { + result = new Buffer(result, 'utf-8'); + } + + if (res.getHeader('Content-Length')) { + res.setHeader('Content-Length', String(result.length)); + } + res.end = _end; + res.write = _write; + res.write(result); + res.end(); + }) + .catch((e) => { + // handle? + next(e); + }); + }; + + next(); + } +} \ No newline at end of file diff --git a/web/dist/app/app.b83772a8a4dadb99e5c4.js b/web/dist/app/app.d0f1230171ff8c0e4540.js similarity index 99% rename from web/dist/app/app.b83772a8a4dadb99e5c4.js rename to web/dist/app/app.d0f1230171ff8c0e4540.js index 44498408cce..b6c1e0d2a7b 100644 --- a/web/dist/app/app.b83772a8a4dadb99e5c4.js +++ b/web/dist/app/app.d0f1230171ff8c0e4540.js @@ -2011,6 +2011,7 @@ webpackJsonp([0],[ contentType: 'application/json; charset=utf-8', dataType: 'json', beforeSend: function beforeSend(xhr) { + xhr.setRequestHeader('X-CSRF-Token', getXCSRFToken()); if (withToken) { var bearerToken = _localStorage2.default.getBearerToken() || {}; var accessToken = bearerToken.accessToken; @@ -2045,6 +2046,11 @@ webpackJsonp([0],[ } }; + var getXCSRFToken = function getXCSRFToken() { + var metaTag = document.querySelector('[name=grv_csrf_token]'); + return metaTag ? metaTag.content : ''; + }; + exports.default = api; module.exports = exports['default']; @@ -2976,10 +2982,10 @@ webpackJsonp([0],[ return token; }, _extractBearerTokenFromHtml: function _extractBearerTokenFromHtml() { - var el = document.querySelector("#bearer_token"); + var el = document.querySelector('[name=grv_bearer_token]'); var token = null; if (el !== null) { - var encodedToken = el.textContent || ''; + var encodedToken = el.content || ''; if (encodedToken.length > EMPTY_TOKEN_CONTENT_LENGTH) { var decoded = window.atob(encodedToken); var json = JSON.parse(decoded); diff --git a/web/dist/app/styles.b83772a8a4dadb99e5c4.js b/web/dist/app/styles.d0f1230171ff8c0e4540.js similarity index 100% rename from web/dist/app/styles.b83772a8a4dadb99e5c4.js rename to web/dist/app/styles.d0f1230171ff8c0e4540.js diff --git a/web/dist/app/vendor.b83772a8a4dadb99e5c4.js b/web/dist/app/vendor.d0f1230171ff8c0e4540.js similarity index 99% rename from web/dist/app/vendor.b83772a8a4dadb99e5c4.js rename to web/dist/app/vendor.d0f1230171ff8c0e4540.js index 8edebde415e..46928ce1951 100644 --- a/web/dist/app/vendor.b83772a8a4dadb99e5c4.js +++ b/web/dist/app/vendor.d0f1230171ff8c0e4540.js @@ -1,4 +1,4 @@ -!function(t){function e(n){if(r[n])return r[n].exports;var i=r[n]={exports:{},id:n,loaded:!1};return t[n].call(i.exports,i,i.exports,e),i.loaded=!0,i.exports}var n=window.webpackJsonp;window.webpackJsonp=function(o,a){for(var s,u,l=0,c=[];l + + Teleport by Gravitational -
- - +
+ diff --git a/web/src/app/services/api.js b/web/src/app/services/api.js index 951cdc58df1..3def759f57b 100644 --- a/web/src/app/services/api.js +++ b/web/src/app/services/api.js @@ -41,13 +41,14 @@ const api = { type: 'GET', contentType: 'application/json; charset=utf-8', dataType: 'json', - beforeSend: function(xhr) { + beforeSend: function (xhr) { + xhr.setRequestHeader('X-CSRF-Token', getXCSRFToken()); if (withToken) { const bearerToken = localStorage.getBearerToken() || {}; const { accessToken } = bearerToken; - xhr.setRequestHeader('Authorization','Bearer ' + accessToken); + xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken); } - } + } } return $.ajax($.extend({}, defaultCfg, cfg)); @@ -76,4 +77,9 @@ const api = { } } +const getXCSRFToken = () => { + const metaTag = document.querySelector('[name=grv_csrf_token]'); + return metaTag ? metaTag.content : '' +} + export default api; diff --git a/web/src/app/services/session.js b/web/src/app/services/session.js index 128c1d49387..c22d35f1eb2 100644 --- a/web/src/app/services/session.js +++ b/web/src/app/services/session.js @@ -87,10 +87,10 @@ const session = { }, _extractBearerTokenFromHtml() { - const el = document.querySelector("#bearer_token") + const el = document.querySelector('[name=grv_bearer_token]'); let token = null; if (el !== null) { - let encodedToken = el.textContent || ''; + let encodedToken = el.content || ''; if (encodedToken.length > EMPTY_TOKEN_CONTENT_LENGTH) { let decoded = window.atob(encodedToken); let json = JSON.parse(decoded); diff --git a/web/src/index.ejs b/web/src/index.ejs index f71fcea44c8..097263e9470 100644 --- a/web/src/index.ejs +++ b/web/src/index.ejs @@ -4,11 +4,12 @@ + + <%= htmlWebpackPlugin.options.title %> -
- +